TCP 协议基础
IP 协议是 Internet 上使用的一个关键协议,它的全称是 Internet Protocol,即 Internet 协议,通常简称 IP 协议。通过使用 IP 协议,从而使 Internet 成为一个允许连接不同类型的计算机和不同操作系统的网络。
要使两台计算机彼此能进行通信,必须使两台计算机使用同一种“语言”,IP 协议只保证计算机能发送和接收分组数据。IP 协议负责将消息从一个主机传送到另一个主机,消息在传送的过程中被分割成一个个的小包。
尽管计算机通过安装 IP 软件,保证了计算机之间可以发送和接收数据,但 IP 协议还不能解决数据分组在传输过程中可能出现的问题。因此,若要解决可能出现的问题,连上 Internet 的计算机还需要安装 TCP 协议来提供可靠并且无差错的通信服务。
TCP 协议被称作一种端对端协议。这是因为它对两台计算机之间的连接起了重要作用——当一台计算机需要与另一台远程计算机连接时,TCP 协议会让它们建立一个连接:用于发送和接收数据的虚拟链路。
TCP 协议负责收集这些信息包,并将其按适当的次序放好传送,接收端收到后再将其正确地还原。TCP 协议保证了数据包在传送中准确无误。TCP 协议使用重发机制——当一个通信实体发送一个消息给另一个通信实体后,需要收到另一个通信实体的确认信息,如果没有收到另一个通信实体的确认信息,则会再次重发刚才发送的信息。
通过这种重发机制,TCP 协议向应用程序提供了可靠的通信连接,使它能够自动适应网上的各种变化。即使在 Internet 暂时出现堵塞的情况下,TCP 也能够保证通信的可靠性。
下图显示了 TCP 协议控制两个通信实体互相通信的示意图。
综上所述,虽然 IP 和 TCP 这两个协议的功能不尽相同,也可以分开单独使用,但它们是在同一时期作为一个协议来设计的,并且在功能上也是互补的。只有两者结合起来,才能保证 Internet 在复杂的环境下正常运行。凡是要连接到 Internet 的计算机,都必须同时安装和使用这两个协议,因此在实际中常把这两个协议统称为 TCP/IP 协议。
使用 ServerSocket 创建 TCP 服务器端
上图并没有看出 TCP 通信的两个通信实体之间有服务器端、客户端之分,这是因为此图是两个通信实体已经建立虚拟链路之后的示意图。在两个通信实体没有建立虚拟链路之前,必须有一个通信实体先做出“主动姿态”,主动接收来自其他通信实体的连接请求。
Java 中能接收其他通信实体连接请求的类是 ServerSocket,ServerSocket 对象用于监听来自客户端的 Socket 连接,如果没有连接,它将一直处于等待状态。ServerSocket 包含一个监听来自客户端连接请求的方法。
- Socket accept():如果接收到一个客户端 Socket 的连接请求,该方法将返回一个与客户端 Socket对应的 Socket(如上图所示,每个 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 的端口号,通常推荐使用 1024 以上的端口,主要是为了避免与其他应用程序的通用端口冲突。
使用 Socket 进行通信
客户端通常可以使用 Socket 的构造器来连接到指定服务器,Socket 通常可以使用如下两个构造器。
- Socket(InetAddress/String remoteAddress, Int port):创建连接到指定远程主机、远程端囗的 Socket,该构造器没有指定本地地址、本地端口,默认使用本地主机的默认 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 之后,就得到了如上图所示的通信示意图,程序无须再区分服务器端、客户端,而是通过各自的 Socket 进行通信。Socket 提供了如下两个方法来获取输入流和输出流。
- InputStream getInputStream():返回该 Socket 对象对应的输入流,让程序通过该输入流从 Socket 中取出数据。
- OutputStream getOutputStream():返回该 Socket 对象对应的输出流,让程序通过该输出流向 Socket 中输出数据。
看到这两个方法返回的 InputStream 和 OutputStream,读者应该可以明白 Java 在设计 IO 体系上的苦心了——不管底层的 IO 流是怎样的节点流:文件流也好,网络 Socket 产生的流也好,程序都可以将其包装成处理流,从而提供更多方便的处理。下面以一个最简单的网络通信程序为例来介绍基于 TCP 协议的网络通信。
下面的服务器端程序非常简单,它仅仅建立 ServerSocket 监听,并使用 Socket 获取输出流输出。
import java.net.*; import java.io.*; 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 获取输入流读取数据。
import java.net.*; import java.io.*; 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 进行读、写操作完成之前超出了该时间限制,那么这些方法就会抛出 SocketTimeoutExceptmon 异常,程序可以对该异常进行捕获,并进行适当处理。
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.connect(new InetSocketAddress(host, port), 10000);
加入多线程
前面 Server 和 Client 只是进行了简单的通信操作;服务器端接收到客户端连接之后,服务器端向客户端输出一个字符串,而客户端也只是读取服务器端的字符串后就退出了。实际应用中的客户端则可能需要和服务器端保持长时间通信,即服务器端需要不断地读取客户端数据,并向客户端写入数据;客户端也需要不断地读取服务器端数据,并向服务器端写入数据。
在使用传统 BufferedReader 的 readLine() 方法读取数据时,在该方法成功返回之前,线程被阻塞,程序无法继续执行。考虑到这个原因,服务器端应该为每个 Socket 单独启动一个线程,每个线程负责与一个客户端进行通信。
客户端读取服务器端数据的线程同样会被阻塞,所以系统应该单独启动一个线程,该线程专门负责读取服务器端数据。
现在考虑实现一个命令行界面的C/S聊天室应用,服务器端应该包含多个线程,每个 Socket 对应一个线程,该线程负责读取 Socket 对应输入流的数据(从客户端发送过来的数据),并将读到的数据向每个 Socket 输出流发送一次(将一个客户端发送的数据“广播”给其他客户端),因此需要在服务器端使用 List 来保存所有的 Socket。
下面是服务器端的实现代码,程序为服务器端提供了两个类,一个是创建 ServerSocket 监听的主类,一个是负责处理每个通信的线程类。
import java.net.*; import java.io.*; import java.util.*; public class MyServer { // 定义保存所有Socket的ArrayList,并将其包装为线程安全的 public static List<Socket> socketList = Collections.synchronizedList(new ArrayList<>()); 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 所有的通信任务,如程序中4行粗体字代码所示。服务器端线程类的代码如下。
import java.io.*; import java.net.*; // 负责处理每个线程通信的线程类 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() 线程执行体中的粗体字代码所示。
每个客户端应该包含两个线程,一个负责读取用户的键盘输入,并将用户输入的数据写入 Socket 对应的输出流中;一个负责读取 Socket 对应输入流中的数据(从服务器端发送过来的数据),并将这些数据打印输出。其中负责读取用户键盘输入的线程由 MyClient 负责,也就是由程序的主线程负责。客户端主程序代码如下。
import java.io.*; import java.net.*; public class MyClient { public static void main(String[] args) throws Exception { Socket 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); } } }
当该线程读到用户键盘输入的内容后,将用户键盘输入的内容写入该 Socket 对应的输出流。除此之外,当主线程使用 Socket 连接到服务器之后,启动了 ClientThread 来处理该线程的 Socket 通信,如程序中①号代码所示。ClientThread 线程负责读取 Socket 输入流中的内容,并将这些内容在控制台打印出来。
import java.io.*; import java.net.*; 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结构聊天室的功能。
记录用户信息
上面程序虽然已经完成了粗略的通信功能,每个客户端可以看到其他客户端发送的信息,但无法知道是哪个客户端发送的信息,这是因为服务器端从未记录过用户信息,当客户端使用 Socket 连接到服务器端之后,程序只是使用 socketList 集合保存了服务器端对应生成的 Socket,并没有保存该 Socket 关联的客户信息。
下面程序将考虑使用 Map 来保存用户状态信息,因为本程序将考虑实现私聊功能,也就是说,一个客户端可以将信息发送给另一个指定客户端。实际上,所有客户端只与服务器端连接,客户端之间并没有互相连接,也就是说,当一个客户端信息发送到服务器端之后,服务器端必须可以判断该信息到底是向所有用户发送,还是向指定用户发送,并需要知道向哪个用户发送。这里需要解决如下两个问题。
- 客户端发送来的信息必须有特殊的标识——让服务器端可以判断是公聊信息,还是私聊信息。
- 如果是私聊信息,客户端会发送该消息的目的用户(私聊对象)给服务器端,服务器端如何将该信息发送给该私聊对象。
为了解决第一个问题,可以让客户端在发送不同信息之前,先对这些信息进行适当处理,比如在内容前后添加一些特殊字符一一这种特殊字符被称为协议字符。本例提供了一个接口,该接口专门用于定义协议字符。
public interface CrazyitProtocol{ // 定义协议字符串的长度 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 等方法。
public class CrazyitMap<K, V> { // 创建一个线程安全的HashMap public Map<K, V> map = Collections.synchronizedMap(new HashMap<K, V>()); // 根据value来删除指定项 public synchronized void removeByValue(Object value) { for (Object key : map.keySet()) { if (map.get(key) == value) { map.remove(key); break; } } } // 获取所有value组成的Set集合 public synchronized Set<V> valueSet() { Set<V> result = new HashSet<V>(); // 将map中所有value添加到result集合中 map.forEach((key, value) -> result.add(value)); return result; } // 根据value查找key。 public synchronized K getKeyByValue(V val) { // 遍历所有key组成的集合 for (K key : map.keySet()) { // 如果指定key对应的value与被搜索的value相同,则返回对应的key if (map.get(key) == val || map.get(key).equals(val)) { return key; } } return null; } // 实现put()方法,该方法不允许value重复 public synchronized 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 map.put(key, value); } }
严格来讲,CrazyitMap 已经不是一个标准的 Map 结构了,但程序需要这样一个数据结构来保存用户名和对应输出流之间的映射关系,这样既可以通过用户名找到对应的输出流,也可以根据输出流找到对应的用户名。
服务器端的主类一样只是建立 ServerSocket 来监听来自客户端 Socket 的连接请求,但该程序增加了一些异常处理,可能看上去比上一节的程序稍微复杂一点。
public class Server { private static final int SERVER_PORT = 30000; // 使用CrazyitMap对象来保存每个客户名字和对应输出流之间的对应关系。 public static CrazyitMap<String, PrintStream> clients = new CrazyitMap<>(); public void init() { try ( // 建立监听的ServerSocket ServerSocket ss = new ServerSocket(SERVER_PORT)) { // 采用死循环来不断接受来自客户端的请求 while (true) { Socket socket = ss.accept(); new ServerThread(socket).start(); } } // 如果抛出异常 catch (IOException ex) { System.out.println("服务器启动失败,是否端口" + SERVER_PORT + "已被占用?"); } } public static void main(String[] args) { Server server = new Server(); server.init(); } }
该程序的关键代码依然只有三行,如程序中粗体字代码所示。它们依然是完成建立 ServerSocket,监听客户端 Socket 连接请求,并为已连接的 Socket 启动单独的线程。
服务器端线程类比上一节的程序要复杂一点,因为该线程类要分别处理公聊、私聊两类聊天信息。除此之外,还需要处理用户名是否重复的问題。服务器端线程类的代码如下。
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.getInputStream())); // 获取该Socket对应的输出流 ps = new PrintStream(socket.getOutputStream()); String line = null; while ((line = br.readLine()) != null) { // 如果读到的行以CrazyitProtocol.USER_ROUND开始,并以其结束, // 可以确定读到的是用户登录的用户名 if (line.startsWith(CrazyitProtocol.USER_ROUND) && line.endsWith(CrazyitProtocol.USER_ROUND)) { // 得到真实消息 String userName = getRealMsg(line); // 如果用户名重复 if (Server.clients.map.containsKey(userName)) { System.out.println("重复"); ps.println(CrazyitProtocol.NAME_REP); } else { System.out.println("成功"); ps.println(CrazyitProtocol.LOGIN_SUCCESS); Server.clients.put(userName, ps); } } // 如果读到的行以CrazyitProtocol.PRIVATE_ROUND开始,并以其结束, // 可以确定是私聊信息,私聊信息只向特定的输出流发送 else if (line.startsWith(CrazyitProtocol.PRIVATE_ROUND) && line.endsWith(CrazyitProtocol.PRIVATE_ROUND)) { // 得到真实消息 String userAndMsg = getRealMsg(line); // 以SPLIT_SIGN分割字符串,前半是私聊用户,后半是聊天信息 String user = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[0]; String msg = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[1]; // 获取私聊用户对应的输出流,并发送私聊信息 Server.clients.map.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.map.size()); // 关闭网络、IO资源 try { if (br != null) { br.close(); } if (ps != null) { ps.close(); } if (socket != null) { socket.close(); } } catch (IOException ex) { ex.printStackTrace(); } } } // 将读到的内容去掉前后的协议字符,恢复成真实数据 private String getRealMsg(String line) { return line.substring(CrazyitProtocol.PROTOCOL_LEN, line.length() - CrazyitProtocol.PROTOCOL_LEN); } }
上面程序比前一节的程序除增加了异常处理之外,主要增加了对读取数据的判断,如程序中两行粗体字代码所示。程序读取到客户端发送过来的内容之后,会根据该内容前后的协议字符串对该内容进行相应的处理。
客户端主类增加了让用户输入用户名的代码,并且不允许用户名重复。除此之外,还可以根据用户的键盘输入来判断用户是否想发送私聊信息。客户端主类的代码如下。
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(CrazyitProtocol.USER_ROUND + userName + CrazyitProtocol.USER_ROUND); // 读取服务器的响应 String result = brServer.readLine(); // 如果用户重复,开始下次循环 if (result.equals(CrazyitProtocol.NAME_REP)) { tip = "用户名重复!请重新"; continue; } // 如果服务器返回登录成功,结束循环 if (result.equals(CrazyitProtocol.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(CrazyitProtocol.PRIVATE_ROUND + line.split(":")[0] + CrazyitProtocol.SPLIT_SIGN + line.split(":")[1] + CrazyitProtocol.PRIVATE_ROUND); } else { ps.println(CrazyitProtocol.MSG_ROUND + line + CrazyitProtocol.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() 方法中粗体字代码所示。
本程序中客户端线程类几乎没有太大的改变,仅仅添加了异常处理部分的代码。
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 类启动多个客户端,并输入不同的用户名,登录服务器后的聊天界面如下图所示。
半关闭的 Socket
前面介绍服务器端和客户端通信时,总是以行作为通信的最小数据单位,在每行内容的前后分别添加特殊的协议字符串,服务器端处理信息时也是逐行进行处理的。在另一些协议里,通信的数据单位可能是多行的,例如前面介绍的通过 URLConnection 来获取远程主机的数据,远程主机响应的内容就包含很多数据——在这种情况下,需要解决一个问题:socket 的输出流如何表示输出数据已经结束?
如果要表示输出已经结束,则可以通过关闭输出流来实现。但在网络通信中则不能通过关闭输出流来表示输出已经结束,因为当关闭输出流时,该输出流对应的 Socket 也将随之关闭,这样导致程序无法再从该 Socket 的输入流中读取数据了。
在这种情况下,Socket 提供了如下两个半关闭的方法,只关闭 Socket 的输入流或者输出流,用以表示输出数据已经发送完成。
- shutdownInput():关闭该 Socket 的输入流,程序还可通过该 Socket 的输出流输出数据。
- shutdownOutput():关闭该 Socket 的输出流,程序还可通过该 Socket 的输入流读取数据。
当调用 shutdownInput() 或 shutdownOutput() 方法关闭 Socket 的输入流或输出流之后,该 Socket 处于“半关闭”状态,Socket 可通过 isInputShutdown() 方法判断该 Socket 是否处于半读状态(read-half),通过 isOutputShutdown() 方法判断该 Socket 是否处于半写状态(write-half)。
注意:即使同一个 Socket 实例先后调用 shutdownInput()、shutdownOutput() 方法,该 Socket 实例依然没有被关闭,只是该 Socket 既不能输出数据,也不能读取数据而已。
下面程序示范了半关闭方法的用法。在该程序中服务器端先向客户端发送多条数据,数据发送完成后,该 Socket 对象调用 shutdownOutput() 方法来关闭输出流,表明数据发送结束一一关闭输出流之后,依然可以从 Socket 中读取数据。
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()); while (scan.hasNextLine()) { System.out.println(scan.nextLine()); } scan.close(); socket.close(); ss.close(); } }
上面程序中的第一行粗体字代码关闭了 Socket 的输出流之后,程序判断该 Socket 是否处于关闭状态,将可看到该代码输出 false。反之,如果将第一行粗体字代码换成 ps.close()——关闭输出流,将可看到第二行粗体字代码输出 true,这表明关闭输出流导致 Socket 也随之关闭。
本程序的客户端代码比较普通,只是先读取服务器端返回的数据,再向服务器端输出一些内容。
public class Client { public static void main(String[] args) throws Exception { Socket s = new Socket("localhost", 30000); Scanner scan = new Scanner(s.getInputStream()); while (scan.hasNextLine()) { System.out.println(scan.nextLine()); } PrintStream ps = new PrintStream(s.getOutputStream()); ps.println("客户端的第一行数据"); ps.println("客户端的第二行数据"); ps.close(); scan.close(); s.close(); } }
当调用 Socket 的 shutdownOutput() 或 shutdownInput() 方法关闭了输出流或输入流之后,该 Socket 无法再次打开输出流或输入流,因此这种做法通常不适合保持持久通信状态的交互式应用,只适用于一站式的通信协议,例如 HTTP 协议——客户端连接到服务器端后,开始发送请求数据,发送完成后无须再次发送数据,只需要读取服务器端响应数据即可,当读取响应完成后,该 Socket 连接也被关闭了。