基于TCP协议的网络编程 - 《疯狂Java讲义》

核心提示:TCP/IP 通信协议是一种可靠的网络协议,它在通信的两端各建立一个Socket ,从而在通信的两端之间形成网络虚拟链路。一旦建立了虚拟的网络链路,两端的程序就可以通过虚拟链路进行通信。 Java对基于TCP 协议的网络通信提供了良好的封装,Java 使用Socket 对象

 

TCP/IP通信协议是一种可靠的网络协议,它在通信的两端各建立一个Socket,从而在通信的两端之间形成网络虚拟链路。一旦建立了虚拟的网络链路,两端的程序就可以通过虚拟链路进行通信。Java对基于TCP协议的网络通信提供了良好的封装,Java使用Socket对象来代表两端的通信端口,并通过Socket产生IO流来进行网络通信。

17.3.1 TCP协议基础

IP协议是Internet上使用的一个关键协议,它的全称是Internet Protocol,即Internet协议,通常简称IP协议。通过使用IP协议,从而使Internet成为一个允许连接不同类型的计算机和不同操作系统的网络。

要使两台计算机彼此之间进行通信,必须使两台计算机使用同一种“语言”,IP协议只保证计算机能发送和接收分组数据。IP协议负责将消息从一个主机传送到另一个主机,消息在传送的过程中被分割成一个个的小包。

尽管计算机通过安装IP软件,保证了计算机之间可以发送和接收数据,但IP协议还不能解决数据分组在传输过程中可能出现的问题。因此,若要解决可能出现的问题,连上Internet的计算机还需要安装TCP协议来提供可靠并且无差错的通信服务。

TCP协议被称作一种端对端协议。这是因为它为两台计算机之间的连接起了重要作用:当一台计算机需要与另一台远程计算机连接时,TCP协议会让它们建立一个连接:用于发送和接收数据的虚拟链路。

TCP协议负责收集这些信息包,并将其按适当的次序放好传送,在接收端收到后再将其正确地还原。TCP协议保证了数据包在传送中准确无误。TCP协议使用重发机制:当一个通信实体发送一个消息给另一个通信实体后,需要收到另一个通信实体确认信息,如果没有收到另一个通信实体的确认信息,则会再次重发刚才发送的信息。

通过这种重发机制,TCP协议向应用程序提供可靠的通信连接,使它能够自动适应网上的各种变化。即使在 Internet 暂时出现堵塞的情况下,TCP也能够保证通信的可靠。

图17.4显示了TCP协议控制两个通信实体互相通信的示意图:

 

图17.4 TCP协议的通信示意图

综上所述,虽然IP和TCP这两个协议的功能不尽相同,也可以分开单独使用,但它们是在同一时期作为一个协议来设计的,并且在功能上也是互补的。只有两者的结合,才能保证 Internet 在复杂的环境下正常运行。凡是要连接到 Internet 的计算机,都必须同时安装和使用这两个协议,因此在实际中常把这两个协议统称作TCP/IP协议。

17.3.2 使用ServletSocket创建TCP服务器端

从图17.4中看上去TCP通信的两个通信实体之间并没有服务器端、客户端之分,但那是两个通信实体已经建立虚拟链路之后的示意图。在两个通信实体没有建立虚拟链路之前,必须有一个通信实体先做出“主动姿态”,主动接收来自其他通信实体的连接请求。

Java中能接受其他通信实体连接请求的类是ServerSocket,ServerSocket对象用于监听来自客户端的Socket连接,如果没有连接,它将一直处于等待状态。ServerSocket包含一个监听来自客户端连接请求的方法:

l Socket accept():如果接收到一个客户端Socket的连接请求,该方法将返回一个与客户端Socket对应的Socket(如图17.4所示每个TCP连接有两个Socket);否则该方法将一直处于等待状态,线程也被阻塞。

为了创建ServerSocket对象,ServerSocket类提供了如下几个构造器:

Ø  ServerSocket(int port):用指定的端口port来创建一个ServerSocket。该端口应该是有一个有效的端口整数值:0~65535。

Ø  ServerSocket(int port,int backlog):增加一个用来改变连接队列长度的参数backlog。

Ø  ServerSocket(int port,int backlog,InetAddress localAddr):在机器存在多个 IP地址的情况下,允许通过localAddr这个参数来指定将ServerSocket绑定到指定的IP地址。

当ServerSocket使用完毕,应使用ServerSocket的close()方法来关闭该ServerSocket。通常情况下,服务器不应该只接受一个客户端请求,而应该不断地接受来自客户端的所有请求,所以Java程序通常会通过循环,不断地调用ServerSocket的accept()方法。如下代码片段所示:

//创建一个ServerSocket,用于监听客户端Socket的连接请求

ServerSocket ss = new ServerSocket(30000);

//采用循环不断接受来自客户端的请求

while (true)

{

//每当接受到客户端Socket的请求,服务器端也对应产生一个Socket

Socket s = ss.accept();

//下面就可以使用Socket进行通信了

...

}

上面程序中创建ServerSocket没有指定IP地址,则该ServerSocket将会绑定到本机默认的IP地址。程序中使用30000作为该ServerSocket的端口号,通常推荐使用10000以上的端口,主要是为了避免与其他应用程序的通用端口冲突。

17.3.3 使用Socket进行通信

客户端通常可使用Socket的构造器来连接到指定服务器,Socket通常可使用如下两个构造器:

Ø  Socket(InetAddress/String remoteAddress, int port):创建连接到指定远程主机、远程端口的Socket,该构造器没有指定本地地址、本地端口,默认使用本地主机的默认IP地址,默认使用系统动态指定的IP地址。

Ø  Socket(InetAddress/String remoteAddress, int port, InetAddress localAddr, int localPort):创建连接到指定远程主机、远程端口的Socket,并指定本地IP地址和本地端口号,适用于本地主机有多个IP地址的情形。

上面两个构造器中指定远程主机时既可使用InetAddress来指定,也可直接使用String对象来指定,但程序通常使用String对象(如192.168.2.23)来指定远程IP。当本地主机只有一个IP地址时,使用第一个方法更为简单。如下代码所示:

//创建连接到本机、30000端口的Socket

Socket s = new Socket("127.0.0.1" , 30000);

//下面就可以使用Socket进行通信了

...

当程序执行上面代码中粗体字代码时,该代码将会连接到指定服务器,让服务器端的ServerSocket的accept()方法向下执行,于是服务器端和客户端就产生一对互相连接的Socket。

 

上面程序连接到“远程主机”的IP地址使用的是127.0.0.1,这个IP地址是一个特殊的地址,它总是代表本级的IP地址。因为笔者示例程序的服务器端、客户端都是在本机运行,所以Socket连接到远程主机的IP地址使用127.0.0.1。

当客户端、服务器端产生了对应的Socket之后,此时就到了如图17.4所示的通信示意图,程序无须再区分服务器、客户端,而是通过各自的Socket进行通信,Socket提供如下两个方法来获取输入流和输出流:

l InputStream getInputStream():返回该Socket对象对应的输入流,让程序通过该输入流从Socket中取出数据。

l OutputStream getOutputStream():返回该Socket对象对应的输出流,让程序通过该输出流向Socket中输出数据。

看到这两个方法返回的InputStream和OutputStream,读者应该可以明白Java在设计IO体系上的苦心了:不管底层的IO流是怎样的节点流:文件流也好,网络Socket产生的流也好,程序都可以将其包装成处理流,从而提供更多方便的处理。下面以一个最简单的网络通信程序为例来介绍基于TCP协议的网络通信。

下面的服务器程序非常简单,它仅仅建立ServerSocket监听,并使用Socket获取输出流输出。

程序清单:codes/17/17-3/Server.java

public class Server

{

public static void main(String[] args)

throws IOException

{

//创建一个ServerSocket,用于监听客户端Socket的连接请求

ServerSocket ss = new ServerSocket(30000);

//采用循环不断接受来自客户端的请求

while (true)

{

//每当接受到客户端Socket的请求,服务器端也对应产生一个Socket

Socket s = ss.accept();

//将Socket对应的输出流包装成PrintStream

PrintStream ps = new PrintStream(s.getOutputStream());

//进行普通IO操作

ps.println("您好,您收到了服务器的新年祝福!");

//关闭输出流,关闭Socket

ps.close();

s.close();

}

}

}

下面的客户端程序也非常简单,它仅仅使用Socket建立与指定IP、指定端口的连接,并使用Socket获取输入流读取数据。

程序清单:codes/17/17-3/Client.java

public class Client

{

public static void main(String[] args)

throws IOException

{

Socket socket = new Socket("127.0.0.1" , 30000);

//将Socket对应的输入流包装成BufferedReader

BufferedReader br = new BufferedReader(

new InputStreamReader(socket.getInputStream()));

//进行普通IO操作

String line = br.readLine();

System.out.println("来自服务器的数据:" + line);

//关闭输入流、socket

br.close();

socket.close();

}

}

上面程序中粗体字代码是使用ServerSocket和Socket建立网络连接的代码,斜体字代码是通过Socket获取输入流、输出流进行通信的代码。通过程序不难看出:一旦使用ServerSocket、Socket建立网络连接之后,程序通过网络通信与普通IO并没有太大的区别。

先运行上面程序中的Server类,将看到服务器一直处于等待状态,因为服务器使用了死循环来接受来自客户端的请求;再运行Client类,将可看到程序输出:“来自服务器的数据:您好,您收到了服务器的新年祝福!”,这表明客户端和服务器端通信成功。

 

上面程序为了突出通过ServerSocket和Socket建立连接、并通过底层IO流进行通信的主题,程序没有进行异常处理,也没有使用finally块来关闭资源。

实际应用中,程序可能不想让执行网络连接、读取服务器数据的进程一直阻塞,而是希望当网络连接、读取操作超过合理时间之后,系统自动认为该操作失败,这个合理时间就是超时时长。Socket对象提供了一个setSoTimeout(int timeout)来设置超时时长。如下的代码片段所示:

Socket s = new Socket("127.0.0.1" , 30000);

//设置10秒之后即认为超时

s.setSoTimeout(10000);

当我们为Socket对象指定了超时时长之后,如果在使用Socket进行读、写操作完成之前已经超出了该时间限制,那么这些方法就会抛出SocketTimeoutException异常,程序可以对该异常进行捕捉,并进行适当处理。如下代码所示:

try

{

//使用Scanner来读取网络输入流中的数据

Scanner scan = new Scanner(s.getInputStream())

//读取一行字符

String line = scan.nextLine()

...

}

//捕捉SocketTimeoutException异常

catch(SocketTimeoutException ex)

{

//对异常进行处理

...

}

假设程序需要为Socket连接服务器时指定超时时长:即经过指定时间后,如果该Socket还未连接到远程服务器,则系统认为该Socket连接超时。但Socket的所有构造器里都没有提供指定超时时长的参数,所以程序应该先创建一个无连接的Socket,再调用Socket的connect()方法来连接远程服务器,而connect方法就可以接受一个超时时长参数。如下代码所示:

//创建一个无连接的Socket

Socket s = new Socket();

//让该Socket连接到远程服务器,如果经过10秒还没有连接到,则认为连接超时。

s.connconnect(new InetAddress(host, port) ,10000);

17.3.4 加入多线程

前面Server和Client只是进行了简单的通信操作:服务器接收到客户端连接之后,服务器向客户端输出一个字符串,而客户端也只是读取服务器的字符串后就退出了。实际应用中的客户端则可能需要和服务器端保持长时间通信,即服务器需要不断地读取客户端数据,并向客户端写入数据;客户端也需要不断地读取服务器数据,并向服务器写入数据。

当我们使用传统BufferedReader的readLine()方法读取数据时,当该方法成功返回之前,线程被阻塞,程序无法继续执行。考虑到这个原因,因此服务器应该为每个Socket单独启动一条线程,每条线程负责与一个客户端进行通信。

客户端读取服务器数据的线程同样会被阻塞,所以系统应该单独启动一条线程,该线程专门负责读取服务器数据。

下面考虑实现一个命令行界面的C/S聊天室应用,服务器端则应该包含多条线程,每个Socket对应一条线程,该线程负责读取Socket对应输入流的数据(从客户端发送过来的数据),并将读到的数据向每个Socket输出流发送一遍(将一个客户端发送的数据“广播”给其他客户端),因此需要在服务器端使用List来保存所有的Socket。

下面是服务器端的实现代码,程序为服务器提供了2个类,一个是创建ServerSocket监听的主类,一个是负责处理每个Socket通信的线程类。

程序清单:codes/17/17-3/MultiThread/server/MyServer.java

public class MyServer

{

//定义保存所有Socket的ArrayList

public static ArrayList<Socket> socketList = new ArrayList<Socket>();

public static void main(String[] args)

throws IOException

{

ServerSocket ss = new ServerSocket(30000);

while(true)

{

//此行代码会阻塞,将一直等待别人的连接

Socket s = ss.accept();

socketList.add(s);

//每当客户端连接后启动一条ServerThread线程为该客户端服务

new Thread(new ServerThread(s)).start();

}

}

}

上面程序是服务器端只负责接受客户端Socket的连接请求,每当客户端Socket连接到该ServerSocket之后,程序将对应Socket加入socketList集合中保存,并为该Socket启动一条线程,该线程负责处理该Socket所有的通信任务,如程序中四行粗体字代码所示。服务器端线程类的代码如下:

程序清单:codes/17/17-3/MultiThread/server/ServerThread.java

//负责处理每个线程通信的线程类

public class ServerThread implements Runnable

{

//定义当前线程所处理的Socket

Socket s = null;

//该线程所处理的Socket所对应的输入流

BufferedReader br = null;

public ServerThread(Socket s)

throws IOException

{

this.s = s;

//初始化该Socket对应的输入流

br = new BufferedReader(new InputStreamReader(s.getInputStream()));

}

public void run()

{

try

{

String content = null;

//采用循环不断从Socket中读取客户端发送过来的数据

while ((content = readFromClient()) != null)

{

//遍历socketList中的每个Socket,

//将读到的内容向每个Socket发送一次

for (Socket s : MyServer.socketList)

{

PrintStream ps = new PrintStream(s.getOutputStream());

ps.println(content);

}

}

}

catch (IOException e)

{

//e.printStackTrace();

}

}

//定义读取客户端数据的方法

private String readFromClient()

{

try

{

return br.readLine();

}

//如果捕捉到异常,表明该Socket对应的客户端已经关闭

catch (IOException e)

{

//删除该Socket。

MyServer.socketList.remove(s);                                    //①

}

return null;

}

}

上面服务器端线程类不断读取客户端数据,程序使用readFromClient()方法来读取客户端数据,如果读取数据过程中捕获到IOException异常,则表明该Socket对应的客户端Socket出现了问题(到底什么问题我们不管,反正不正常),程序就将该Socket从socketList中删除,如readFromClient()方法中①号代码所示。

当服务器线程读到客户端数据之后,程序遍历socketList集合,并将该数据向socketList集合中的每个Socket发送一次——该服务器线程将把从Socket中读到的数据向socketList中的每个Socket转发一次。如run()线程执行体中的粗体字代码所示。

每个客户端应该包含2条线程:一条负责读取用户的键盘输入,并将用户输入的数据写入Socket对应的输出流中;一条负责读取Socket对应输入流中的数据(从服务器发送过来的数据),并将这些数据打印输出。其中负责读取用户键盘输入的线程由Myclient负责,也就是由程序的主线程负责。客户端主程序代码如下:

程序清单:codes/17/17-3/MultiThread/client/MyClient.java

public class MyClient

{

public static void main(String[] args)throws Exception

{

Socket s = s = new Socket("127.0.0.1" , 30000);

//客户端启动ClientThread线程不断读取来自服务器的数据

new Thread(new ClientThread(s)).start();                                //①

//获取该Socket对应的输出流

PrintStream ps = new PrintStream(s.getOutputStream());

String line = null;

//不断读取键盘输入

BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

while ((line = br.readLine()) != null)

{

//将用户的键盘输入内容写入Socket对应的输出流

ps.println(line);

}

}

}

上面程序中获取键盘输入的代码在第15章中已有详细解释,此处不再赘述。当该线程读到用户键盘输入的内容后,将用户键盘输入的内容写入该Socket对应的输出流。

除此之外,当主线程使用Socket连接到服务器之后,并启动了ClientThread来处理该线程的Socket通信,如程序中①号代码所示。ClientThread线程负责读取Socket输入流中的内容,并将这些内容在控制台打印出来。

程序清单:codes/17/17-3/MultiThread/client/ClientThread.java

public class ClientThread implements Runnable

{

//该线程负责处理的Socket

private Socket s;

//该现成所处理的Socket所对应的输入流

BufferedReader br = null;

public ClientThread(Socket s)

throws IOException

{

this.s = s;

br = new BufferedReader(

new InputStreamReader(s.getInputStream()));

}

public void run()

{

try

{

String content = null;

//不断读取Socket输入流中的内容,并将这些内容打印输出

while ((content = br.readLine()) != null)

{

System.out.println(content);

}

}

catch (Exception e)

{

e.printStackTrace();

}

}

}

上面线程的功能也非常简单,它只是不断获取Socket输入流中的内容,当获取Socket输入流中的内容后,直接将这些内容打印在控制台。如上程序中粗体字代码所示。

先运行上面程序中的MyServer类,该类运行后只是作为服务器,看不到任何输出。接着可以运行多个MyClient——相当于启动多个聊天室客户端登录该服务器,接着可以看到,我们在任何一个客户端通过键盘输入一些内容后单击“回车”键,将可看到所有客户端(包括自己)都会在控制台收到他刚刚输入的内容,这就粗略实现了一个C/S结构聊天室的功能。

17.3.5 记录用户信息

上面程序虽然已经完成了粗略的通信功能,每个客户端可以看到其他客户端发送的信息,但无法知道是哪个客户端发送的信息,这是因为服务器从未记录过用户信息,当客户端使用Socket连接到服务器之后,程序只是使用socketList保存了服务器对应生成的Socket,并没有保存该Socket关联的客户信息。

下面程序将考虑使用Map来保存用户状态信息,因为本程序将会考虑实现私聊功能,也就是说一个客户端可以将信息发送另一个指定客户端。实际上,我们知道所有客户端只与服务器连接,客户端之间并没有互相连接,也就是说,当一个客户端信息发送到服务器端之后,服务器必须可以判断该信息到底需要向所有用户发送?还是向指定用户发送、并需要知道向哪个用户发送?这里需要解决两个问题:

Ø  客户端发送来信息必须有特殊的标识——让服务可以判断是公聊信息,还是私聊信息。

Ø  如果是私聊信息,客户端会发送一个该消息的目的用户(私聊对象)给服务器,服务器如何将该信息发送给该私聊对象。

为了解决第一个问题,我们可以让客户端在发送不同信息之前,先对这些信息添加适当处理,比如在内容前后添加一些特殊字符——我们把这种特殊字符称为协议字符。本例提供了一个MyProtocol接口,该接口里专门用于定义协议字符:

程序清单:codes/17/17-3/Senior/server/YeekuProtocol.java

public interface YeekuProtocol

{

//定义协议字符串的长度

int PROTOCOL_LEN = 2;

//下面是一些协议字符串,服务器和客户端交换的信息

//都应该在前、后添加这种特殊字符串。

String MSG_ROUND = "§γ";

String USER_ROUND = "∏∑";

String LOGIN_SUCCESS = "1";

String NAME_REP = "-1";

String PRIVATE_ROUND = "★【";

String SPLIT_SIGN = "※";

}

实际上由于服务器和客户端都需要使用这些协议字符串,所以程序需要在客户端和服务器同时保留该接口对应的class文件。

为了解决第二个问题,我们可以考虑使用一个Map来保存聊天室所有用户和对应Socket之间的映射关系——这样服务器就可以根据用户名来找到对应的Socket。但实际上本程序并未这么做,程序仅仅是用Map保存了聊天室所有用户名和对应输出流之间的映射关系,因为服务器实际上只要获取该用户名对应的输出流即可。服务器端提供了一个HashMap的子类,该类不允许value重复,并提供根据value获取key,根据value来删除key等方法。

程序清单:codes/17/17-3/Senior/server/YeekuMap.java

//扩展HashMap类,MyMap类要求value也不可重复

public class YeekuMap<K,V> extends HashMap<K,V>

{

//根据value来删除指定项

public void removeByValue(Object value)

{

for (Object key : keySet())

{

if (get(key) == value)

{

remove(key);

break;

}

}

}

//获取所有value组成的Set集合

public Set<V> valueSet()

{

Set<V> result = new HashSet<V>();

//遍历所有key组成的集合

for (K key : keySet())

{

//将每个key对应的value添加到result集合中

result.add(get(key));

}

return result;

}

//根据value查找key。

public K getKeyByValue(V val)

{

//遍历所有key组成的集合

for (K key : keySet())

{

//如果指定key对应的value与被搜索的value相同

//则返回对应的key

if (get(key).equals(val)

&& get(key) == val)

{

return key;

}

}

return null;

}

//重写HashMap的put方法,该方法不允许value重复

public V put(K key,V value)

{

//遍历所有value组成的集合

for (V val : valueSet() )

{

//如果指定value与试图放入集合的value相同

//则抛出一个RuntimeException异常

if (val.equals(value)

&& val.hashCode()== value.hashCode())

{

throw new RuntimeException

("MyMap实例中不允许有重复value!");

}

}

return super.put(key , value);

}

}

严格来讲:YeekuMap已经不是一个标准的Map结构了,但程序需要这样一个数据结构来保存用户名和对应输出流之间的映射关系,程序既可以通过用户名找到对应的输出流,也可以根据输出流找到对应的用户名。

服务器端的主类一样只是建立ServerSocket来监听来自客户端Socket的连接请求,只是该程序增加了一些异常处理,可能看上去比上一节的程序稍微复杂一点。

程序清单:codes/17/17-3/Senior/server/Server.java

public class Server

{

private static final int SERVER_PORT = 30000;

//使用MyMap对象来保存每个客户名字和对应输出流之间的对应关系。

public static YeekuMap<String , PrintStream> clients =

new YeekuMap<String , PrintStream>();

public void init()

{

ServerSocket ss = null;

try

{

//建立监听的ServerSocket

ss = new ServerSocket(SERVER_PORT);

//采用死循环来不断接受来自客户端的请求

while(true)

{

Socket socket = ss.accept();

new ServerThread(socket).start();

}

}

//如果抛出异常

catch (IOException ex)

{

System.out.println("服务器启动失败,是否端口"

+ SERVER_PORT + "已被占用?");

}

//使用finally块来关闭资源

finally

{

try

{

if (ss != null)

{

ss.close();

}

}

catch (IOException ex)

{

ex.printStackTrace();

}

System.exit(1);

}

}

public static void main(String[] args)

{

Server server = new Server();

server.init();

}

}

该程序的关键代码依然只有三行,如程序中粗体字代码所示:它们依然是完成建立ServerSocket、监听客户端Socket连接请求,并为已连接的Socket启动单独的线程。

服务器线程类比上一节的程序要复杂一点,因为该线程类要分别处理公聊、私聊两类聊天信息,除此之外,还需要处理用户名是否重复的问题。服务器线程类代码如下。

程序清单:codes/17/17-3/Senior/server/ServerThread.java

public class ServerThread extends Thread

{

private Socket socket;

BufferedReader br = null;

PrintStream ps = null;

//定义一个构造器,用于接收一个Socket来创建ServerThread线程

public ServerThread(Socket socket)

{

this.socket = socket;

}

public void run()

{

try

{

//获取该Socket对应的输入流

br = new BufferedReader(new InputStreamReader(socket.getIn

putStream()));

//获取该Socket对应的输出流

ps = new PrintStream(socket.getOutputStream());

String line = null;

while((line = br.readLine())!= null)

{

//如果读到的行以MyProtocol.USER_ROUND开始,并以其结束,

//可以确定读到的是用户登录的用户名

if (line.startsWith(YeekuProtocol.USER_ROUND)

&& line.endsWith(YeekuProtocol.USER_ROUND))

{

//得到真实消息

String userName = getRealMsg(line);

//如果用户名重复

if (Server.clients.containsKey(userName))

{

System.out.println("重复");

ps.println(YeekuProtocol.NAME_REP);

}

else

{

System.out.println("成功");

ps.println(YeekuProtocol.LOGIN_SUCCESS);

Server.clients.put(userName , ps);

}

}

//如果读到的行以YeekuProtocol.PRIVATE_ROUND开始,并以其结束,

//可以确定是私聊信息,私聊信息只向特定的输出流发送

else if (line.startsWith(YeekuProtocol.PRIVATE_ROUND)

&& line.endsWith(YeekuProtocol.PRIVATE_ROUND))

{

//得到真实消息

String userAndMsg = getRealMsg(line);

//以SPLIT_SIGN来分割字符串,前面部分是私聊用户,后面部分是聊天信息

String user = userAndMsg.split(YeekuProtocol.SPLIT_SIGN)[0];

String msg = userAndMsg.split(YeekuProtocol.SPLIT_SIGN)[1];

//获取私聊用户对应的输出流,并发送私聊信息

Server.clients.get(user).println(

Server.clients.getKeyByValue(ps) + "悄悄地对你说:" + msg);

}

//公聊要向每个Socket发送

else

{

//得到真实消息

String msg = getRealMsg(line);

//遍历clients中的每个输出流

for (PrintStream clientPs : Server.clients.valueSet())

{

clientPs.println(Server.clients.getKeyByValue(ps)

+ "说:" + msg);

}

}

}

}

//捕捉到异常后,表明该Socket对应的客户端已经出现了问题

//所以程序将其对应的输出流从Map中删除

catch (IOException e)

{

Server.clients.removeByValue(ps);

System.out.println(Server.clients.size());

//关闭网络、IO资源

try

{

if (br != null)

{

br.close();

}

if (ps != null)

{

ps.close();

}

if (socket != null)

{

socket.close();

}

}

catch (IOException ex)

{

ex.printStackTrace();

}

}

}

//将读到的内容去掉前后的协议字符,恢复成真实数据

public String getRealMsg(String line)

{

return line.substring(YeekuProtocol.PROTOCOL_LEN,

 line.length() - YeekuProtocol.PROTOCOL_LEN);

}

}

上面比前一节的程序除了增加了异常处理之外,主要增加了对读取数据的判断,如程序中两行粗体字代码所示,程序读取到客户端发送过来的内容之后,会根据该内容前后的协议字符串对该内容进行相应的处理。

客户端主类增加了让用户输入用户名的代码,并不允许用户名重复;除此之外还可以根据用户的键盘输入来判断用户是否想发送私聊信息。客户端主类代码如下:

程序清单:codes/17/17-3/Senior/client/Client.java

public class Client

{

private static final int SERVER_PORT = 30000;

private Socket socket;

private PrintStream ps;

private BufferedReader brServer;

private BufferedReader keyIn;

public void init()

{

try

{

//初始化代表键盘的输入流

keyIn = new BufferedReader(

new InputStreamReader(System.in));

//连接到服务器

socket = new Socket("127.0.0.1", SERVER_PORT);

//获取该Socket对应的输入流和输出流

ps = new PrintStream(socket.getOutputStream());

brServer = new BufferedReader(

new InputStreamReader(socket.getInputStream()));

String tip = "";

//采用循环不断地弹出对话框要求输入用户名

while(true)

{

String userName = JOptionPane.showInputDialog(tip + "输入用户名");

//将用户输入的用户名的前后增加协议字符串后发送

ps.println(YeekuProtocol.USER_ROUND + userName

+ YeekuProtocol.USER_ROUND);

//读取服务器的响应

String result = brServer.readLine();

//如果用户重复,开始下次循环

if (result.equals(YeekuProtocol.NAME_REP))

{

tip = "用户名重复!请重新";

continue;

}

//如果服务器返回登录成功,结束循环

if (result.equals(YeekuProtocol.LOGIN_SUCCESS))

{

break;

}

}

}

//捕捉到异常,关闭网络资源,并退出该程序

catch (UnknownHostException ex)

{

System.out.println("找不到远程服务器,请确定服务器已经启动!");

closeRs();

System.exit(1);

}

catch (IOException ex)

{

System.out.println("网络异常!请重新登录!");

closeRs();

System.exit(1);

}

//以该Socket对应的输入流启动ClientThread线程

new ClientThread(brServer).start();

}

//定义一个读取键盘输出,并向网络发送的方法

private void readAndSend()

{

try

{

//不断读取键盘输入

String line = null;

while((line = keyIn.readLine()) != null)

{

//如果发送的信息中有冒号,且以//开头,则认为想发送私聊信息

if (line.indexOf(":") > 0 && line.startsWith("//"))

{

line = line.substring(2);

ps.println(YeekuProtocol.PRIVATE_ROUND +

line.split(":")[0] + YeekuProtocol.SPLIT_SIGN +

line.split(":")[1] + YeekuProtocol.PRIVATE_ROUND);

}

else

{

ps.println(YeekuProtocol.MSG_ROUND + line

+ YeekuProtocol.MSG_ROUND);

}

}

}

//捕捉到异常,关闭网络资源,并退出该程序

catch (IOException ex)

{

System.out.println("网络通信异常!请重新登录!");

closeRs();

System.exit(1);

}

}

//关闭Socket、输入流、输出流的方法

private void closeRs()

{

try

{

if (keyIn != null)

{

ps.close();

}

if (brServer != null)

{

ps.close();

}

if (ps != null)

{

ps.close();

}

if (socket != null)

{

keyIn.close();

}

}

catch (IOException ex)

{

ex.printStackTrace();

}

}

public static void main(String[] args)

{

Client client = new Client();

client.init();

client.readAndSend();

}

}

上面程序使用JOptionPane弹出一个输入对话框让用户输入用户名,如程序init()方法中的粗体字代码所示,然后程序立即将用户输入的用户名发送给服务器,服务器会返回该用户名是否重复的提示,程序又立即读取服务器提示,并根据服务器提示判断是否需要继续让用户输入用户名。

与前一节的客户端主类相比,该程序还增加了对用户输入信息的判断,程序判断用户输入的内容是否以双斜线(//)开头,并包含冒号(:),如果满足该特征,系统认为该用户想发送私聊信息,就会将冒号(:)之前的部分当成私聊用户名,冒号(:)之后部分当成聊天信息,如readAndSend()方法中粗体字代码所示。

本程序客户端线程类几乎没有太大的改变,仅仅添加了异常处理部分的代码。

程序清单:codes/17/17-3/Senior/client/ClientThread.java

public class ClientThread extends Thread

{

//该客户端线程负责处理的输入流

BufferedReader br = null;

//使用一个网络输入流来创建客户端线程

public ClientThread(BufferedReader br)

{

this.br = br;

}

public void run()

{

try

{

String line = null;

//不断从输入流中读取数据,并将这些数据打印输出

while((line = br.readLine())!= null)

{

System.out.println(line);

/*

本例仅打印了从服务器端读到的内容。实际上,此处的情况可以更复杂:

如果我们希望客户端能看到聊天室的用户列表,则可以让服务器在

每次有用户登录、用户退出时,将所有用户列表信息都向客户端发送一遍。

为了区分服务器发送的是聊天信息,还是用户列表,服务器也应该

在要发送的信息前、后都添加一定的协议字符串,客户端此处则根据协议

字符串的不同而进行不同的处理!

更复杂的情况:

如果两端进行游戏,则还有可能发送游戏信息,例如两端进行五子棋游戏,

则还需要发送下棋坐标信息等,服务器同样在这些下棋坐标信息前、后

添加协议字符串后再发送,客户端就可以根据该信息知道对手的下棋坐标。

*/

}

}

catch (IOException ex)

{

ex.printStackTrace();

}

//使用finally块来关闭该线程对应的输入流

finally

{

try

{

if (br != null)

{

br.close();

}

}

catch (IOException ex)

{

ex.printStackTrace();

}

}

}

}

虽然上面程序非常简单,但正如程序注释中指出的:如果服务器可以返回更多丰富类型的数据,则该线程类的处理将会更复杂,那么该程序可以扩展到非常强大。

先运行上面的Server类,启动服务器;再多次运行Client类启动多个客户端,并输入不同用户名登录服务器后聊天的界面如图17.5所示:

 

图17.5 两个客户端聊天效果

本程序没有提供GUI界面部分,直接使用DOS窗口进行聊天——因为增加GUI界面会让程序代码更多,从而引起读者的畏难心理。如果读者理解了本程序之后,相信读者一定乐意为该程序添加界面部分,因为整个程序的所有核心功能都已经实现了。不仅如此,读者完全可以在本程序的基础上扩展成一个仿QQ游戏大厅的网络程序——笔者所教过的很多学生都可以做到这一点。

前面我们介绍服务器和客户端通信时,总是以行作为通信的最小数据单位,在每行内容的前后分别添加上特殊的协议字符串,服务器处理信息时也是逐行进行处理。在另一些协议里,通信的数据单位可能是多行的,例如前面介绍的通过URLConnection来获取远程主机的数据,远程主机响应的内容就包含很多数据——在这种情况下需要解决一个问题, Socket的输出流如何表示输出数据已经结束?

在第15章介绍IO时我们知道如果要表示输出已经结束,可以通过关闭输出流来实现。但在网络通信里则不能通过关闭输出流来表示输出已经结束,因为当我们关闭输出流时,该输出流对应的Socket也将随之关闭。这样导致关闭输出流后,该Socket也随之关闭,程序将无法再从该Socket的输入流中读取数据。

在这种情况下,Socket提供了如下两个半关闭的方法:

只关闭Socket的输入流或者输出流,用以表示输出数据已经发送完成。

Ø  shutdownInput():关闭该Socket的输入流,程序还可通过该Socket的输出流输出数据。

Ø  shutdownOutput():关闭该Scoket的输出流,程序还可通过该Socket的输入流读取数据。

当调用shutdownInput()或shutdownOutput()方法关闭Socket的输入流或输出流之后,该Socket处于“半关闭”状态。Socket可通过方法 isInputShutdown()判断该Socket是否处于半读状态(read-half) ,通过方法isOutputShutdown()判断该Socket是否处于半写状态(read-half)。

 

即使同一个Socket实例先后调用shutdownInput()、shutdownOutput()方法,该Socket实例依然没有被关闭,只是该Socket既不能输出数据,也不能读数据而已。

下面程序示范了半关闭的用法,在该程序中服务器先向客户端发送多条数据,数据发送完成后,该Socket对象调用shutdownOutput()方法来关闭输入流,表明数据发送结束——关闭输出流之后依然可以从Socket中读取数据。

程序清单:codes/17/17-3/HalfClose/Server.java

public class Server

{

public static void main(String[] args)

throws Exception

{

ServerSocket ss = new ServerSocket(30000);

Socket socket = ss.accept();

PrintStream ps = new PrintStream(socket.getOutputStream());

ps.println("服务器的第一行数据");

ps.println("服务器的第二行数据");

//关闭socket的输出流,表明输出数据已经结束

socket.shutdownOutput();

//下面语句将输出false,表明socket还未关闭。

System.out.println(socket.isClosed());

Scanner scan = new Scanner(socket.getInputStream());

if (scan.hasNextLine())

{

System.out.println(scan.nextLine());

}

scan.close();

socket.close();

ss.close();

}

}

上面程序中第一行粗体字代码关闭了Socket的输出流之后,程序判断该Socket是否处于关闭状态,将可看到该代码输出false。反之,如果将第一行粗体字代码换成ps.close()——关闭输出流,将可看到第二行粗体字代码输出true,这表明关闭输出流导致Socket也随之关闭。

本程序的客户端代码比较普通,只是先读取服务器返回的数据,再向服务器输出一些内容。客户端代码比较简单,故此处不再赘述。读者可参考codes/17/17-3/HalfClose/Client.java程序来查看该代码。

当调用Socket的shutdownOutput()或shutdownInput()方法关闭了输出流或输入流之后,该Socket无法再次打开输出流成输入流,因此这种做法通常不适合保持持久通信状态的交互式应用,只适用于一站式的通信协议,例如HTTP协议:客户端连接到服务器后,开始发送请求数据,发送完成后无须再次发送数据,只需要读取服务器响应数据即可,当读取响应完成后,该Socket连接也被关闭了。

17.3.6 使用NIO实现非阻塞Socket通信

从JDK 1.4开始,Java提供的NIO API来开发高性能网络服务器,前面介绍的网络通信程序是基于阻塞式API的——即当程序执行输入、输出操作后,在这些操作返回之前会一直阻塞该线程,所以服务器必须为每个客户端都提供一条独立线程进行处理,当服务器需要同时处理大量客户端时,这种做法会导致性能下降。使用NIO API则可以让服务器使用一个或有限几个线程来同时处理连接到服务器上的所有客户端。

如果读者忘记了NIO里Channel、Buffer、Charset等API的概念和用法,读者可以再次阅读本书第15章关于新IO的内容。

Java的NIO为非阻塞式的Socket通信提供了如下几个特殊类:

Ø  Selector:它是SelectableChannel对象的多路复用器,所有希望采用非阻塞方式进行通信的Channel都应该注册到Selector对象。可通过调用此类的静态open()方法来创建Selector实例,该方法将使用系统默认的Selector来返回新的Selector。

Selector可以同时监控多个SelectableChannel的IO状况,是非阻塞IO的核心。一个Selector实例有3个SelectionKey的集合:

ü  所有SelectionKey集合:代表了注册在该Selector上的Channel,这个集合可以通过keys()方法返回。

ü  被选择的SelectionKey集合:代表了所有可通过select()方法监测到、需要进行IO处理的Channel,这个集合可以通过selectedKeys()返回。

ü  被取消的SelectionKey集合:代表了所有被取消注册关系的Channel,在下一次执行select()方法时,这些Channel对应的SelectionKey会被彻底删除,程序通常无须直接访问该集合。

除此之外,Selector还提供了系列和select()相关的方法,如下所示:

ü  int select():监控所有注册的Channel,当它们中间有需要处理的IO操作时,该方法返回,并将对应的SelectionKey加入被选择的SelectionKey集合中,该方法返回这些Channel的数量。

ü  int select(long timeout):可以设置超时时长的select()操作。

ü  int selectNow():执行一个立即返回的select()操作,相对于无参数的select()方法而言,该方法不会阻塞线程。

ü  Selector wakeup():使一个还未返回的select()方法立刻返回。

Ø  SelectableChannel:它代表可以支持非阻塞IO操作的Channel对象,可以将其注册到Selector上,这种注册的关系由SelectionKey实例表示。Selector对象提供了一个select()方法,该方法允许应用程序同时监控多个IO Channel。

应用程序可调用SelectableChannel 的register()方法将其注册到指定Selector上,当该Selector上某些SelectableChannel上有需要处理的IO操作时,程序可以调用Selector实例的select()方法获取它们的数量,并可以通过selectedKeys()方法返回它们对应的SelectKey集合——通过该集合就可以获取所有需要处理IO操作的SelectableChannel集。

SelectableChannel对象支持阻塞和非阻塞两种模式(所有channel默认都是阻塞模式),必须使用非阻塞式模式才可以利用非阻塞IO操作。SelectableChannel提供了如下两个方法来设置和返回该Channel的模式状态:

ü  SelectableChannel configureBlocking(boolean block):设置是否采用阻塞模式。

ü  boolean isBlocking():返回该Channel是否是阻塞模式。

不同的SelectableChannel所支持的操作不一样,例如ServerSocketChannel代表一个ServerSocket,它就只支持OP_ACCEPT操作。SelectableChannel提供如下方法来返回它支持的所有操作:

ü  int validOps() :返回一个bit mask,表示这个channel上支持的IO操作。

在SelectionKey中,用静态常量定义了4种IO操作:OP_READ(1)、OP_WRITE(4)、OP_CONNECT(8)、OP_ACCEP(16),这四值任意2个、3个、4个进行按位或的结果和相加的结果相等,而且它们任意2个、3个、4个相加的结果总是互不相同,所以系统可以根据validOps()方法的返回值确定该SelectableChannel支持的操作。例如返回5,我们知道它支持读(1)和写(4)。

除此之外,SelectableChannel还提供了如下几个方法来获取它的注册状态:

ü  boolean isRegistered():返回该Channel是否已注册在一个或多个Selector上。

ü  SelectionKey keyFor(Selector sel):返回该Channel和sel Selector之间的注册关系,如果不存在注册关系,则返回null。

Ø  SelectionKey:该对象代表SelectableChannel和Selector之间的注册关系。

Ø  ServerSocketChannel:支持非阻塞操作,对应于java.net.ServerSocket这个类,提供了TCP协议IO接口,只支持OP_ACCEPT操作。该类也提供了accept()方法,功能相当于ServerSocket提供的accept()方法。

Ø  SocketChannel:支持非阻塞操作,对应于java.net.Socket这个类,提供了TCP协议IO接口,支持OP_CONNECT,OP_READ和OP_WRITE操作。这个类还实现了ByteChannel接口、ScatteringByteChannel接口和GatheringByteChannel接口,所以可以直接通过SocketChannel来读写ByteBuffer对象。

图17.6显示了使用NIO实现非阻塞式服务器的示意图:

 

图17.6 NIO的非阻塞式服务器示意

从图17.6中可以看出,服务器上所有Channel(包括ServerSocketChannel和SocketChannel)都需要向Selector注册,而该Selector则负责监视这些Socket的IO状态,当其中任意一个或多个Channel具有可用的IO操作时,该Selector的select()方法将会返回大于0的整数,该整数值就表示该Selector上有多少个Channel具有可用的IO操作,并提供了selectedKeys()方法来返回这些Channel对应的SelectionKey集合。正是通过Selector,使得服务器端只需要不断地调用Selector实例的select()方法即可知道当前所有Channel是否有需要处理的IO操作。

 

当Selector上注册的所有Channel都没有需要处理的IO操作时,select()方法将被阻塞,调用该方法的线程被阻塞。

本示例程序使用NIO实现了多人聊天室的功能,服务器使用循环不断获取Selector的select()方法返回值,当该返回值大于0时就处理该Selector上被选择SelectionKey所对应的Channel。

服务器端需要使用ServerSocketChannel来监听客户端的连接请求,Java中该类的设计比较糟糕:它不是ServerSocket的完整抽象,所以不能直接让该Channel监听某个端口;而且不允许使用ServerSoceket的getChannel()方法来获取ServerSocketChannel实例。程序必须先调用它的socket()方法获得关联ServerSocket对象,再用该ServerSocket对象绑定到来指定监听IP和端口。创建一个可用的ServerSocketChannel需采用如下代码片段:

//通过open方法来打开一个未绑定的ServerSocketChannel实例

ServerSocketChannel server = ServerSocketChannel.open();

InetSocketAddress isa = new InetSocketAddress("127.0.0.1", 30000);

//将该ServerSocketChannel绑定到指定IP地址

server.socket().bind(isa);

如果需要使用非阻塞方式来处理该ServerSocketChannel,还应该设置它的非阻塞模式,并将其注册到指定的Selector。如下代码片段:

//设置ServerSocket以非阻塞方式工作

server.configureBlocking(false);

//将server注册到指定Selector对象

server.register(selector, SelectionKey.OP_ACCEPT);

经过上面步骤后,该ServerSocketChannel可以接受客户端的连接请求,但我们需要调用Selector的select()方法来监听所有Channel上的IO操作。

程序清单:codes/17/17-3/NoBlock/NServer.java

public class NServer

{

//用于检测所有Channel状态的Selector

private Selector selector = null;

//定义实现编码、解码的字符集对象

private Charset charset = Charset.forName("UTF-8");

public void init()throws IOException

{

selector = Selector.open();

//通过open方法来打开一个未绑定的ServerSocketChannel实例

ServerSocketChannel server = ServerSocketChannel.open();

InetSocketAddress isa = new InetSocketAddress(

"127.0.0.1", 30000);

//将该ServerSocketChannel绑定到指定IP地址

server.socket().bind(isa);

//设置ServerSocket以非阻塞方式工作

server.configureBlocking(false);

//将server注册到指定Selector对象

server.register(selector, SelectionKey.OP_ACCEPT);

while (selector.select() > 0)

{

//依次处理selector上的每个已选择的SelectionKey

for (SelectionKey sk : selector.selectedKeys())

{

//从selector上的已选择Key集中删除正在处理的SelectionKey

selector.selectedKeys().remove(sk);                             //①

//如果sk对应的通道包含客户端的连接请求

if (sk.isAcceptable())                                             //②

{

//调用accept方法接受连接,产生服务器端对应的SocketChannel

SocketChannel sc = server.accept();

//设置采用非阻塞模式

sc.configureBlocking(false);

//将该SocketChannel也注册到selector

sc.register(selector, SelectionKey.OP_READ);

//将sk对应的Channel设置成准备接受其他请求

sk.interestOps(SelectionKey.OP_ACCEPT);

}

//如果sk对应的通道有数据需要读取

if (sk.isReadable())                                               //③

{

//获取该SelectionKey对应的Channel,该Channel中有可读的数据

SocketChannel sc = (SocketChannel)sk.channel();

//定义准备执行读取数据的ByteBuffer

ByteBuffer buff = ByteBuffer.allocate(1024);

String content = "";

//开始读取数据

try

{

while(sc.read(buff) > 0)

{

buff.flip();

content += charset.decode(buff);

}

//打印从该sk对应的Channel里读取到的数据

System.out.println("=====" + content);

//将sk对应的Channel设置成准备下一次读取

sk.interestOps(SelectionKey.OP_READ);

}

//如果捕捉到该sk对应的Channel出现了异常,即表明该Channel

//对应的Client出现了问题,所以从Selector中取消sk的注册

catch (IOException ex)

{

//从Selector中删除指定的SelectionKey

sk.cancel();

if (sk.channel() != null)

{

sk.channel().close();

}

}

//如果content的长度大于0,即聊天信息不为空

if (content.length() > 0)

{

//遍历该selector里注册的所有SelectKey

for (SelectionKey key : selector.keys())

{

//获取该key对应的Channel

Channel targetChannel = key.channel();

//如果该channel是SocketChannel对象

if (targetChannel instanceof SocketChannel)

{

//将读到的内容写入该Channel中

SocketChannel dest = (SocketChannel)targetChannel;

dest.write(charset.encode(content));

}

}

}

}

}

}

}

public static void main(String[] args)

throws IOException

{

new NServer().init();

}

}

上面程序启动时即建立一个可监听连接请求的ServerSocketChannel,并将该Channel注册到指定Selector,接着程序直接采用循环不断监控Selector对象的select()方法返回值,当该返回值大于0时处理该Selector上所有被选择的SelectionKey。

开始处理指定SelectionKey之后立即从该Selector中的被选择的SelectionKey集合中删除该SelectionKey,如程序中①号代码所示。

服务器端的Selector仅需要监听两种操作:连接和读数据,所以程序中分别处理了这两种操作,如程序中②和③代码所示。处理连接操作之时,系统只需将接受连接后产生的SocketChannel注册到指定Selector对象即可;处理读数据操作之时,系统先从该Socket中读取数据,再将数据写入Selector上注册的所有Channel。

使用NIO来实现服务器时,甚至无须使用ArrayList来保存服务器中所有SocketChannel,因为所有的SocketChannel都需要注册到指定的Selector对象。除此之外,当客户端关闭时会导致服务器对应的Channel也抛出异常,而且本程序只有一条线程,如果该异常得不到处理将会导致整个服务器退出,所以程序捕捉了这种异常,并在处理异常时从Selector删除异常Channel的注册,如程序中斜体字代码所示。

本示例程序的客户端程序需要两个线程,一个线程负责读取用户的键盘输入,并将输入的内容写入SocketChannel中,另一个线程则不断地查询Selector对象的select()方法的返回值。

程序清单:codes/17/17-3/NoBlock/NClient.java

public class NClient

{

//定义检测SocketChannel的Selector对象

private Selector selector = null;

//定义处理编码和解码的字符集

private Charset charset = Charset.forName("UTF-8");

//客户端SocketChannel

private SocketChannel sc = null;

public void init()throws IOException

{

selector = Selector.open();

InetSocketAddress isa = new InetSocketAddress("127.0.0.1", 30000);

//调用open静态方法创建连接到指定主机的SocketChannel

sc = SocketChannel.open(isa);

//设置该sc以非阻塞方式工作

sc.configureBlocking(false);

//将SocketChannel对象注册到指定Selector

sc.register(selector, SelectionKey.OP_READ);

//启动读取服务器端数据的线程

new ClientThread().start();

//创建键盘输入流

Scanner scan = new Scanner(System.in);

while (scan.hasNextLine())

{

//读取键盘输入

String line = scan.nextLine();

//将键盘输入的内容输出到SocketChannel中

sc.write(charset.encode(line));

}

}

//定义读取服务器数据的线程

private class ClientThread extends Thread

{

public void run()

{

try

{

while (selector.select() > 0)

{

//遍历每个有可用IO操作Channel对应的SelectionKey

for (SelectionKey sk : selector.selectedKeys())

{

//删除正在处理的SelectionKey

selector.selectedKeys().remove(sk);

//如果该SelectionKey对应的Channel中有可读的数据

if (sk.isReadable())

{

//使用NIO读取Channel中的数据

SocketChannel sc = (SocketChannel)sk.channel();

ByteBuffer buff = ByteBuffer.allocate(1024);

String content = "";

while(sc.read(buff) > 0)

{

sc.read(buff);

buff.flip();

content += charset.decode(buff);

}

//打印输出读取的内容

System.out.println("聊天信息:" + content);

//为下一次读取作准备

sk.interestOps(SelectionKey.OP_READ);

}

}

}

}

catch (IOException ex)

{

ex.printStackTrace();

}

}

}

public static void main(String[] args)

throws IOException

{

new NClient().init();

}

}

相比之下,客户端程序比服务器程序要简单多了,客户端只有一条SocketChannel,将该SocketChannel注册到指定Selector后,程序启动另一条线程来监测该Selector即可。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值