Step By Step(Java 网络篇)
本篇为您介绍的是如何通过套接字(Socket)编写基于TCP/IP(IPv4)的网络应用程序。
在基于TCP的网络通讯中,每次进行数据传输之前,均需要在服务器端和客户端之间建立TCP连接,之后再在该连接通道上进行数据传输。然而在连接之前,我们还需要做哪些准备呢?很明显,如果我们是客户端,则需要预先知道待连接的服务器的IP地址和端口号。这就如同打电话,通话之前先拨号,拨号之前需要知道对方的电话号码。那么对于服务器而言又需要做什么呢?由于它是被动等待客户端发起连接的,因此服务器唯一需要准备工作是监听指定的IP地址和端口号。可以看出,无论是服务器还是客户端,在准备建立连接之前都是需要与IP地址和端口号打交道的。那么我们就从这里开始吧。
1. 套接字地址:
1) 如何通过Java的类库获取主机IP地址。
InetAddress类代表了一个网络目标地址,包括主机名和数字类型的地址信息。该类有两个子类,Inet4Address和Inet6Address,分别对应于目前IP地址的两个版本。InetAddress实例是不可变的,一旦创建,每个实例就始终指向同一地址。见如下示例:
1 public class MyTest {
2 public static void main(String[] args) {
3 //1. 通过NetworkInterface类枚举出当前主机的全部网络设备数据
4 try {
5 //1.1 返回当前主机每一个网络接口对应的实例
6 Enumeration<NetworkInterface> interfaceList = NetworkInterface.getNetworkInterfaces();
7 if (interfaceList == null) {
8 System.out.println("--No interfaces found--");
9 } else {
10 while (interfaceList.hasMoreElements()) {
11 NetworkInterface iface = interfaceList.nextElement();
12 //1.2 iface.getName()方法返回的是设备的名称,通常由字母和数字联合构成。
13 System.out.println("Interface " + iface.getName() + ":");
14 //1.3 枚举出真正持有IP地址信息的对象
15 Enumeration<InetAddress> addrList = iface.getInetAddresses();
16 if (!addrList.hasMoreElements())
17 System.out.println("\t(No Addresses for this interface.");
18 while (addrList.hasMoreElements()) {
19 InetAddress addr = addrList.nextElement();
20 //1.4 这里输出的就是我们通常所说的点分形式的IP地址,如192.163.1.1
21 if (addr instanceof Inet4Address)
22 System.out.println("\tAddress " + addr.getHostAddress());
23 }
24 }
25 }
26 } catch (SocketException e) {
27 System.out.println("Error getting network interfaces: " + e.getMessage());
28 }
29 //2. 根据指定主机名(host),获取该主机的全部网络设备信息。
30 String host = "Stephen-PC";
31 try {
32 System.out.println(host + ":");
33 //2.1 通过主机名称直接获取该主机的全部网络地址。
34 InetAddress[] addrList = InetAddress.getAllByName(host);
35 for (InetAddress addr : addrList) {
36 //2.2 getHostName()获取的是主机名,getHostAddress()获取的是点分形式的IP地址。
37 if (addr instanceof Inet4Address)
38 System.out.println("\t" + addr.getHostName() + "/" + addr.getHostAddress());
39 }
40 } catch (UnknownHostException e) {
41 System.out.println("\tUnable to find address for " + host);
42 }
43 }
44 }
45 /* 输出结果如下:
46 Interface lo:
47 Address 127.0.0.1
48 Interface net0:
49 (No Addresses for this interface.
50 Interface net1:
51 (No Addresses for this interface.
52 Interface net2:
53 (No Addresses for this interface.
54 Interface net3:
55 (No Addresses for this interface.
56 Interface ppp0:
57 (No Addresses for this interface.
58 Interface eth0:
59 (No Addresses for this interface.
60 Interface eth1:
61 (No Addresses for this interface.
62 Interface eth2:
63 (No Addresses for this interface.
64 Interface ppp1:
65 (No Addresses for this interface.
66 Interface net4:
67 (No Addresses for this interface.
68 Interface eth3:
69 Address 10.24.194.17
70 Interface net5:
71 (No Addresses for this interface.
72 Interface net6:
73 (No Addresses for this interface.
74 Interface eth4:
75 Interface net7:
76 (No Addresses for this interface.
77 Interface net8:
78 (No Addresses for this interface.
79 Interface eth5:
80 Address 192.168.225.1
81 Interface eth6:
82 Address 192.168.220.1
83 Interface eth7:
84 (No Addresses for this interface.
85 Interface eth8:
86 (No Addresses for this interface.
87 Interface eth9:
88 (No Addresses for this interface.
89 Interface eth10:
90 (No Addresses for this interface.
91 Interface eth11:
92 (No Addresses for this interface.
93 Stephen-PC:
94 Stephen-PC/10.24.194.17
95 Stephen-PC/192.168.225.1
96 Stephen-PC/192.168.220.1
97 */
该示例代码通过两种不同的方式获取主机中网络设备的IPv4地址。由于我的电脑中安装了VMWare虚拟机软件,因此在只有一张物理网卡的情况下,却输出了更多的虚拟网络设备地址。
我们这里应用的第一种方式是通过JDK中提供的NetworkInterface类枚举出当前主机中所有的网络接口对象。其中每一个对象对应于一个网络接口设备,之后再通过枚举出的每个NetworkInterface对象获取与该设备关联的设备名称(iface.getName())和基于该设备设定的全部IP地址(iface.getInetAddresses())。和第一种方式不同的是,第二种方式通过主机名称直接获取了InetAddress对象数组,再通过遍历数组中的每一个元素获取相关设备的IP地址。和第一种方法相比,第二种方法可以根据主机名称获取网络中任意主机的IP地址信息,而第一种方式只能获取当前主机的网络地址信息。
2. TCP套接字--客户端:
Java为TCP协议提供了两个Socket类:Socket和ServerSocket。他们分别表示客户端和服务器的Socket对象。Socket对象中包含的数据是通讯类型(TCP/UDP)、远端IP地址(Client)/本地监听IP地址(Serve)、端口号等。其中IP地址和端口号都是通过InetAddress对象获取的。在开始通讯之前,要建立一个TCP连接,这需要先由客户端TCP向服务器端TCP发送连接请求。ServerSocket实例则监听TCP连接请求,并为每个请求创建一个新的Socket实例。也就是说,服务器端要同时处理ServerSocket和Socket实例,而客户端只需要使用Socket实例。下面我们还是从一个简单的TCP客户端的例子开始。
典型的TCP客户端要经过如下三步:
1) 创建一个Socket实例:构造函数向指定的远程主机和端口建立一个TCP连接
2) 通过套接字的输入输出流(I/O Streams)进行通信:一个Socket连接实例包括一个InputStream和OutputStream,它们的用法同于其他Java输入输出流。
3) 使用Socket类的close()方法关闭连接。
1 public class MyTest {
2 public static void main(String[] args) throws UnknownHostException, IOException {
3 String server = "10.24.194.17";
4 byte[] data = "HelloWorld".getBytes();
5 int port = 5050;
6 //1. 用指定的IP和Port构造服务器的Socket对象。如果这里不给出server和port的话,
7 //后面就需要显示的调用socket.connect(),并在该方法中传入这两个参数。
8 Socket socket = new Socket(server,port);
9 System.out.println("Connected to server... sending to string");
10 //2. 基于该Socket对象上的数据通道,获取输入和输出流对象,便于之后的数据读取和写出。
11 InputStream in = socket.getInputStream();
12 OutputStream out = socket.getOutputStream();
13 //3. 写数据到服务器端。
14 out.write(data);
15 int totalBytesRcvd = 0;
16 int bytesRcvd;
17 //4. 从服务器端读取应答数据,直到本次命令的所有数据都返回。如果read返回-1,表示服务器关闭了该套接字。
18 while (totalBytesRcvd < data.length) {
19 if ((bytesRcvd = in.read(data,totalBytesRcvd,data.length-totalBytesRcvd)) == -1)
20 throw new SocketException("Connection closed prematurely.");
21 totalBytesRcvd += bytesRcvd;
22 }
23 System.out.println("Received: " + new String(data));
24 //5. 关闭该Socket对象。
25 socket.close();
26 }
27 }
从目前来看,由于还没有合适的服务器监听指定的IP和端口,该示例程序暂时不能正常运行。后面会给出与之相对应的服务器程序,这样我们就可以同时启动这两个示例程序,然后再观察他们的输出结果。
这里需要给出解释的是为什么我们需要用多个read调用,以确保数据被完全读入呢?因为客户端在write的时候,如果网络状况不好,服务器极有可能是通过多次读取才接收到全部数据的,即便是本例中的10个字节,这种情况理论上也是存在的。鉴于此,客户端将通过多次read,以保证数据被全部读取。
3. TCP套接字--服务器:
现在我们将注意力转向服务器。服务器端的工作是建立一个通信终端,并被动的等待客户端的连接。典型的TCP服务器执行如下两个步骤:
1) 创建一个ServerSocket实例并指定本地端口。次套接字的功能是侦听该指定端口收到的连接;
2) 重复执行如下三步:
2.1)调用ServerSocket的accept()方法以获取下一个客户端连接。基于新建立的客户端连接,创建一个Socket的实例,并由accept()方法返回;
2.2)使用所返回的Socket实例的InputStream和OutputStream与客户端进行通信;
2.3)通信完成后,使用Socket类的close()方法关闭该客户端套接字连接。
下面的Socket Server示例代码,可用于处理来自上一个例子中客户端的数据请求。
1 public class MyTest {
2 private static final int BUFSIZE = 32;
3 public static void main(String[] args) throws UnknownHostException, IOException {
4 int port = 5050;
5 InetAddress saddr = InetAddress.getByName("10.24.194.17");
6 //1. 通过制定IP和Port,构造服务器的监听Socket对象。需要说明的是,在ServerSocket构造后
7 //该Socket对象即已经针对该IP和Port处于bind和listen状态了。
8 ServerSocket ssock = new ServerSocket(port,20,saddr);
9 int recvMsgSize;
10 byte[] receiveBuf = new byte[BUFSIZE];
11 while (true) {
12 //2. accept()将阻塞在这里等待客户端的连接。直到有新的客户端连接进来的时候
13 //该函数才正常返回,返回的Socket对象就是之后和客户端进行通信的Socket对象。
14 Socket csock = ssock.accept();
15 //这里可以通过accept返回的Socket对象获取当前正在连接的客户端的IP地址。
16 SocketAddress caddr = csock.getRemoteSocketAddress();
17 System.out.println("Handling client at " + caddr);
18 //3. 这里和客户端的代码一致,通过Socket返回的输入和输出流对象来进行数据传输。
19 InputStream in = csock.getInputStream();
20 OutputStream out = csock.getOutputStream();
21 //4. 这里和客户端的读取机制大体相同,如果read返回-1,表示客户端主动关闭了。
22 while ((recvMsgSize = in.read(receiveBuf)) != -1) {
23 out.write(receiveBuf,0,recvMsgSize);
24 }
25 //发送完毕后服务器主动关闭客户端Socket对象。
26 csock.close();
27 }
28 }
29 }
需要说明的是,这两个客户端和服务器的例子都是非常简单的,其目的主要还是用于介绍Java中网络编程的一些基础知识。至于真实的网络客户端和网络服务器的设计和实现,一般而言,需要针对实际的需求予以一定程度上的定制,以便优化服务程序的运行效率。即便如此,在网络服务器的设计中还是存在一定的通用技巧的,如异步处理、线程池、任务队列、心跳检测等很多和并发相关的知识。鉴于这些知识和技巧并不是本篇的重点,如果展开讨论,足以再开出单独一篇。需要补充的是,Java中在NIO中同样提供了一些基于异步的Socket通讯模型,我们会在本篇的后面予以介绍。
在C++中,不同的操作系统提供了不同机制的异步通讯模型,以便提高整体通讯效率,如Win32的完成端口,Linux的epoll等。事实上,对于没有提供类似特化通讯机制的操作系统,完全可以通过select机制在Java中实现非阻塞的Socket通讯。
4. 数据发送和接收:
在数据传输过程有一个非常引人注目的一个知识点,即大端(BIG ENDIAN)字节序和小端(LITTLE ENDIAN)字节序。其中大端字节序也被称为网络字节序,Java中使用的就是该字节序。这里我们先给出关于BIG ENDIAN和LITTLE ENDIAN的一些最基本的解释,如下:
对于多字节(> 1 字节)类型,如short、int和long等。对于有些cpu体系结构(和操作系统平台无关),如x86,在存储多字节类型的变量数据是,是按照小端的方式存储的,即低位在前,高位在后,如int a = 0x12345678; 其中a的内存地址是0x807D1234,对于小端存储的变量而言,地址0x807D1234存储的字节(byte)为0x78,0x807D1235存储的是0x56,0x807D1236为0x34,0x807D1237为0x12,而端存储的恰恰相反,即高位在前,低位在后。还是针对此例,如果为大端字节序,0x807D1234将存储0x12,0x807D1235存储0x34,0x807D1236存储0x56,0x807D1237存储0x78。
在真实的通讯传输中,由于我们无法预知新加入的服务器或者客户端的主机字节序,因此在设计协议之前便需要确定C/S之间的数据传输编码形式,之后所有的应用程序在进行数据传输之前都需要自行确保发送数据的字节序为协议指定的字节序。
Java的输入输出框架为我们提供了DataInputStream和DataOutputStream两个流对象,与其他流对象不同的是,他们提供了可以直接读取/写入原始数据类型的接口方法,如writeInt、writeBoolean、writeShort、readInt、readLong等。这样我们便可以将这他们封装在SocketInputStream/SocketOutputStream外部,既SocketInputStream/SocketOutputStream分别作为他们的内部流对象用于提供真实的数据流。由于Java中使用的字节序为大端字节序,如果此时系统定义的传输编码方式为小端字节序的话,我们将无法再通过DataInputStream/DataOutputStream直接读取/写入原始数据类型了,为了能够仍然保持这种方便的操作,我们需要提供一对类似的流对象可以将小端字节序中的数据翻转成为Java缺省的大端字节序后,再以ReadInt/writeInt等方法的方式将原始类型返回给调用者或将原始类型重新编码后以小端的方式写出。下面的代码将给出一个小端数据输入输出流对象的实现。
1) 小端数据输出流:
1 public class LEDataOutputStream implements DataOutput {
2 protected final DataOutputStream dout;
3 protected final byte[] work;
4 public LEDataOutputStream(OutputStream out) {
5 dout = new DataOutputStream(out);
6 work = new byte[8];
7 }
8 public final void close() throws IOException {
9 dout.close();
10 }
11 public void flush() throws IOException {
12 dout.flush();
13 }
14 public final int size() {
15 return dout.size();
16 }
17 public final synchronized void write(int ib) throws IOException {
18 dout.write(ib);
19 }
20 public final void write(byte ba[]) throws IOException {
21 dout.write(ba, 0, ba.length);
22 }
23 public final synchronized void write(byte ba[], int off, int len) throws IOException {
24 dout.write(ba, off, len);
25 }
26 //boolean由于只是占一个字节,因此可以直接写出了。
27 public final void writeBoolean(boolean v) throws IOException {
28 dout.writeBoolean(v);
29 }
30 //byte和boolean是同样的情况,他们都只是占有一个字节。
31 public final void writeByte(int v) throws IOException {
32 dout.writeByte(v);
33 }
34 //byte的数组也是和byte一样,不需要在翻转了。
35 public final void writeBytes(String s) throws IOException {
36 dout.writeBytes(s);
37 }
38 //由于char是两个字节的,所以这里就需要翻转了。
39 public final void writeChar(int v) throws IOException {
40 work[0] = (byte) v;
41 work[1] = (byte) (v >> 8);
42 dout.write(work, 0, 2);
43 }
44 //对于多字节的数组而言,数据元素在数组中的位置是不变的,需要翻转的是每个元素的自身。
45 //就行这里的writeChars方法一样,他翻转了每个元素自身的存储方式,但是在数组中的位置没有改变
46 public final void writeChars(String s) throws IOException {
47 int len = s.length();
48 for (int i = 0; i < len; i++) {
49 writeChar(s.charAt(i));
50 }
51 }
52 public final void writeDouble(double v) throws IOException {
53 writeLong(Double.doubleToLongBits(v));
54 }
55 public final void writeFloat(float v) throws IOException {
56 writeInt(Float.floatToIntBits(v));
57 }
58 //short、int和long都是多字节的,因此就都需要翻转了。
59 public final void writeInt(int v) throws IOException {
60 work[0] = (byte) v;
61 work[1] = (byte) (v >> 8);
62 work[2] = (byte) (v >> 16);
63 work[3] = (byte) (v >> 24);
64 dout.write(work, 0, 4);
65 }
66 public final void writeLong(long v) throws IOException {
67 work[0] = (byte) v;
68 work[1] = (byte) (v >> 8);
69 work[2] = (byte) (v >> 16);
70 work[3] = (byte) (v >> 24);
71 work[4] = (byte) (v >> 32);
72 work[5] = (byte) (v >> 40);
73 work[6] = (byte) (v >> 48);
74 work[7] = (byte) (v >> 56);
75 dout.write(work, 0, 8);
76 }
77 public final void writeShort(int v) throws IOException {
78 work[0] = (byte) v;
79 work[1] = (byte) (v >> 8);
80 dout.write(work, 0, 2);
81 }
82 public final void writeUTF(String s) throws IOException {
83 dout.writeUTF(s);
84 }
85 }
2) 小端数据输入流:
输入流的实现和输出流的实现在基本概念和应用技巧上是完全一样的,这里就不再给出更多的注释了。
1 public class LEDataInputStream implements DataInput {
2 protected final DataInputStream din;
3 protected final InputStream is;
4 protected final byte[] work;
5 public static String readUTF(DataInput in) throws IOException {
6 return DataInputStream.readUTF(in);
7 }
8 public LEDataInputStream(InputStream in) {
9 is = in;
10 din = new DataInputStream(in);
11 work = new byte[8];
12 }
13 public final void close() throws IOException {
14 din.close();
15 }
16 public final int read(byte ba[], int off, int len) throws IOException {
17 return is.read(ba, off, len);
18 }
19 public final boolean readBoolean() throws IOException {
20 return din.readBoolean();
21 }
22 public final byte readByte() throws IOException {
23 return din.readByte();
24 }
25 public final char readChar() throws IOException {
26 din.readFully(work, 0, 2);
27 return (char) ((work[1] & 0xff) << 8 | (work[0] & 0xff));
28 }
29 public final double readDouble() throws IOException {
30 return Double.longBitsToDouble(readLong());
31 }
32 public final float readFloat() throws IOException {
33 return Float.intBitsToFloat(readInt());
34 }
35 public final void readFully(byte ba[]) throws IOException {
36 din.readFully(ba, 0, ba.length);
37 }
38 public final void readFully(byte ba[], int off, int len) throws IOException {
39 din.readFully(ba, off, len);
40 }
41 public final int readInt() throws IOException {
42 din.readFully(work, 0, 4);
43 return (work[3]) << 24 | (work[2] & 0xff) << 16 | (work[1] & 0xff) << 8 | (work[0] & 0xff);
44 }
45 public final String readLine() throws IOException {
46 return din.readLine();
47 }
48 public final long readLong() throws IOException {
49 din.readFully(work, 0, 8);
50 return (long) (work[7]) << 56 |
51 (long) (work[6] & 0xff) << 48 | (long) (work[5] & 0xff) << 40 | (long) (work[4] & 0xff) << 32
52 | (long) (work[3] & 0xff) << 24 | (long) (work[2] & 0xff) << 16
53 | (long) (work[1] & 0xff) << 8 | (long) (work[0] & 0xff);
54 }
55 public final short readShort() throws IOException {
56 din.readFully(work, 0, 2);
57 return (short) ((work[1] & 0xff) << 8 | (work[0] & 0xff));
58 }
59 public final String readUTF() throws IOException {
60 return din.readUTF();
61 }
62 public final int readUnsignedByte() throws IOException {
63 return din.readUnsignedByte();
64 }
65 public final int readUnsignedShort() throws IOException {
66 din.readFully(work, 0, 2);
67 return ((work[1] & 0xff) << 8 | (work[0] & 0xff));
68 }
69 public final int skipBytes(int n) throws IOException {
70 return din.skipBytes(n);
71 }
72 }
5. NIO:
为什么要使用nio呢?基本的Java套接字对于小规模系统可以很好的运行,但当涉及同时处理上千个客户端的服务器时,可能会产生一些问题。由于创建、维护和切换线程需要的系统开销,一客户一线程方式在系统扩展性方面受到了限制。使用线程池可以节省那种系统开销,同时允许实现者利用并行硬件的优势。nio中提供的Selector和Channel抽象对象为我们提供了一次轮询一组客户端的方法,从而避免了我们不得不主动轮询每个客户端并获悉是否有新的数据到来。在nio中还提供了一组Buffer类,如ByteBuffer、IntBuffer等,和普通io中的Stream对象相比,nio中的Buffer对象提供了更高的效率和可预测的I/O。Stream抽象好的方面是隐藏了底层缓冲区的有限性,提供了一个能够容纳任意长度的容器的假象。坏的方面是要实现这样一个假象,要么会产生大量的内存开销,要么会引入大量的上下文切换,甚至可能两者都有。在使用线程时,这些开销都隐藏在了具体实现中,因此也失去了对其可控性和可预测性。这种方法让编程变得容易,但要调整他们的性能则变得更困难。不幸的是,如果要使用Java的Socket对象,流是唯一的选择。下面将给出一个利用Channel完成非阻塞操作的例子:
1 public class MyTest {
2 public static void main(String[] args) throws IOException {
3 String server = "10.1.24.199";
4 int servPort = 5050;
5 //创建一个客户端SocketChannel,并将其设置为非阻塞方式。
6 SocketChannel clntChannel = SocketChannel.open();
7 clntChannel.configureBlocking(false);
8 //尝试连接服务器,由于当前的SocketChannel是非阻塞方式的,因此finishConnect()方法
9 //在成功连接之前将会立即返回false,知道成功连接后在返回true。如果在这个过程中出现
10 //任何网络错误,finishConnect()方法将会抛出IOException的异常。对于阻塞模式下的
11 //SocketChannel,finishConnect()在没有成功连接之前将不会立即返回,而是阻塞等待连接
12 //成功,或在出现网络错误时抛出异常。
13 if (!clntChannel.connect(new InetSocketAddress(server, servPort))) {
14 while (!clntChannel.finishConnect()) {
15 //TODO: 做一些其他的事情,以避免这种忙等待消耗更多的系统资源。
16 //即使是调用Thread.sleep(0);也可以降低这种忙等待产生的消耗。
17 System.out.print(".");
18 }
19 }
20 byte[] demoData = "hello world".getBytes();
21 //构建ByteBuffer的两种常用方式,一种是包装已经存在的数组对象,
22 //再有就是通过allocate静态方法直接分配内存。
23 ByteBuffer writeBuf = ByteBuffer.wrap(demoData);
24 ByteBuffer readBuf = ByteBuffer.allocate(demoData.length);
25 int totalBytesRecv = 0;
26 int bytesRecv;
27 while (totalBytesRecv < demoData.length) {
28 //由于该SocketChannel是非阻塞模式,因为write和read调用均会立即返回。
29 //如果此时没有数据可读,read将返回0,只有在出现网络异常的情况下,read
30 //才会返回-1。
31 if (writeBuf.hasRemaining())
32 clntChannel.write(writeBuf);
33 if ((bytesRecv = clntChannel.read(readBuf)) == -1)
34 throw new SocketException("Connection Closed Prematurely");
35 totalBytesRecv += bytesRecv;
36 System.out.print(".");
37 }
38 System.out.println("Received: " + new String(readBuf.array(),0,totalBytesRecv));
39 clntChannel.close();
40 }
41 }
需要说明的是,上面的示例代码只是用最为简单的方式来演示nio中SocketChannel和ByteBuffer等常用对象的使用方法。由于网络的运行状况是比较复杂的,上面的代码在真实的环境中并不适合,因为有很多异常的场景没有被考虑进来,而这些异常的处理方式尽管确实存在一些通用的技巧,但是在真实环境中仍然需要结合不同的应用方式作出必要的调整和优化。而该部分内容的讨论是需要另外一个专门的议题并结合实际的案例来予以讲述的。希望今后有机会和大家来共同探讨。
Selector类可用于避免使用非阻塞式客户端中很浪费资源的“忙等”方法。例如,考虑一个即时消息服务器。可能有上千个客户端同时连接到服务器,但在任何时刻只有非常少量的消息需要读取和分发。这就需要一种方法阻塞等待,直到至少有一个信道可以进行I/O操作,并指出那个信道。NIO的Selector就实现了这样的功能。一个Selector实例可以同时检查一组信道的I/O状态。用专业术语说,Selector就一个多路开关选择器,因为一个Selector能够管理多个信道上的I/O操作。
1 interface TCPProtocal {
2 //当有新客户端连接到服务器时,调用该函数
3 void handleAccept(SelectionKey key) throws IOException;
4 //当客户端有新的数据发送到服务器时,调用该函数
5 void handleRead(SelectionKey key) throws IOException;
6 //当可以写数据到客户端时条用该函数。
7 void handleWrite(SelectionKey key) throws IOException;
8 }
9
10 class SelectorProtocal implements TCPProtocal {
11 private int bufSize;
12 public SelectorProtocal(int bufSize) {
13 this.bufSize = bufSize;
14 }
15 @Override
16 public void handleAccept(SelectionKey key) throws IOException {
17 //1. 通过发生的事件(key)来获取发生事件的通道,一般而言,发生Accept事件的Channel
18 //通常为Server端负责监听的SocketChannel。之后再通过该通道接收来自客户端的连接请求,
19 //最后返回用于之后I/O操作的客户端信道。
20 SocketChannel clntChannel = ((ServerSocketChannel)key.channel()).accept();
21 //2. 同样将该客户端的SocketChannel设置为非阻塞模式。
22 clntChannel.configureBlocking(false);
23 //3. 将Accept返回的客户端SocketChannel也注册到产生当前事件的Selector实例中,
24 //同时告知Selector,只是感知该SocketChannel的READ事件,即有数据到来是触发。
25 //第三个参数是附件参数,这里可以放置任何对象和该SocketChannel关联,在该事件
26 //发生时,可以通过key的attachment方法获取该附件对象。
27 clntChannel.register(key.selector(), SelectionKey.OP_READ,ByteBuffer.allocate(bufSize));
28 }
29 @Override
30 public void handleRead(SelectionKey key) throws IOException {
31 //1. 同样通过发生的事件(key)获取产生事件的通道。
32 SocketChannel clntChannel = (SocketChannel)key.channel();
33 //2. 再通过key获取存储数据的ByteBuffer对象。
34 ByteBuffer buf = (ByteBuffer)key.attachment();
35 long bytesRead = clntChannel.read(buf);
36 if (bytesRead == -1) {
37 clntChannel.close();
38 } else if (bytesRead > 0) {
39 //这里将重新设置该key在Selector中感兴趣的事件。
40 //由于通常在收到数据后可能需要返回应答数据给客户端,因此这里对该
41 //SocketChannel增加了OP_WRITE写事件。
42 key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
43 }
44 }
45 @Override
46 public void handleWrite(SelectionKey key) throws IOException {
47 ByteBuffer buf = (ByteBuffer)key.attachment();
48 buf.flip();
49 SocketChannel clntChannel = (SocketChannel)key.channel();
50 clntChannel.write(buf);
51 //如果所有应答数据都发送,则需要将该SocketChannel的OP_WRITE取消,而只是
52 //注册该SocketChannel的OP_READ事件,以等待客户端有新的请求数据发送过来。
53 if (!buf.hasRemaining()) {
54 key.interestOps(SelectionKey.OP_READ);
55 }
56 buf.compact();
57 }
58 }
59
60 public class MyTest {
61 private static final int BUFSIZE = 256;
62 private static final int TIMEOUT = 3000;
63 public static void main(String[] args) throws IOException {
64 Selector selector = Selector.open();
65 //打开服务器监听SocketChannel
66 ServerSocketChannel listenChannel = ServerSocketChannel.open();
67 //绑定监听SocketChannel到指定的IP地址和端口
68 listenChannel.socket().bind(new InetSocketAddress("10.1.24.199", 5050));
69 listenChannel.configureBlocking(false);
70 //该监听SocketChannel只能注册OP_ACCEPT接收客户端连接的事件。
71 listenChannel.register(selector, SelectionKey.OP_ACCEPT);
72 TCPProtocal p = new SelectorProtocal(BUFSIZE);
73 while (true) {
74 //给当前的Selector实例设置超时,从而避免了忙等待现象,也避免了
75 //无限期阻塞的现象,当超时时,select函数返回0。
76 if (selector.select(TIMEOUT) == 0) {
77 System.out.print(".");
78 continue;
79 }
80 //此时已经有感知的事件发生了,通过selector获取所有事件集合的迭代器
81 Iterator<SelectionKey> keyIt = selector.selectedKeys().iterator();
82 while (keyIt.hasNext()) {
83 SelectionKey key = keyIt.next();
84 //判断事件的类型,是否为accept、read或者write
85 if (key.isAcceptable())
86 p.handleAccept(key);
87 if (key.isReadable())
88 p.handleRead(key);
89 if (key.isValid() && key.isWritable())
90 p.handleWrite(key);
91 //在处理完该事件之后需要从集合中主动移除该事件。
92 keyIt.remove();
93 }
94 }
95 }
96 }
该服务器示例代码同样也是学习性代码,不能实际用于真正的服务器程序中,因为该示例存在明显的性能缺陷和伸缩性问题。简单的说,handleWrite和handleRead等I/O操作和监听操作位于同一线程,如果连接的并发量较高时,将会有大量的客户端无法成功连接,因为此时该线程可能正在处理I/O操作。如果想解决该问题,常用的方法是为I/O(read/write)操作再新创建一个selector实例用以感知客户端的I/O事件,并在新的线程中负责该selector的select()调用。而当前的selector只是负责服务器监听SocketChannel的accept事件。与此同时,还需要增加一个线程池用以处理I/O事件,这样当host为多cpu和多核时,可以更加充分的利用硬件资源,从而提高服务器的运行效率和伸缩性。