一、Socket 套接字
引言
操作系统提供的网络编程 API 叫做 socket API,socket API 中涉及到一个核心概念 socket
而 socket 本质上是一个文件描述符。我们通过 socket 文件来操作网卡。
我们都知道,对于计算机来说,键盘是一个标准输入文件;显示器是一个标准输出文件。因为操作系统在管理硬件设备和软件资源的时候,为了风格统一,就都通过文件来管理。所以一切皆文件,而网卡也是一个硬件设备,操作系统也是用文件来管理网卡的,所以此处用来表示网卡设备的文件,也就是 socket 文件。
两类 socket
操作系统提供的 socket API 主要有两类:TCP / UDP
TCP:Transmission Control Protocol ( 传输控制协议 )
UDP:User Datagram Protocol ( 用户数据报协议 )
1. 流套接字 ( 底层使用了 TCP 协议 ) 特点: 有连接 可靠传输 面向字节流 全双工
2. 数据报套接字 (底层使用了 UDP 协议) 特点: 无连接 不可靠传输 面向数据报 全双工
TCP 和 UDP 两者的区别与联系
1. 连接的区别
有连接相当于打电话。无连接就相当于发微信。
举个例子:A 给 B 打电话,首先会有一个等待过程,就是 【嘟嘟嘟…】这个尝试建立连接的过程,注意,此时还只是尝试连接!只有当 B 按下了手机的接听键,说明他们之间真正地连接了,也就能真正地进行通信了!
而如果 A 给 B 发微信,B 不需要像接电话那样尝试建立连接,只要 B 有网络,就能够接收数据。然而,B 接收到了微信消息,但没有打开微信看信息,或者说,看了 A 发来的信息,之后没有回复他。那么就是另外一回事了…
2. 传输的区别
可靠传输:发送方能够知道接收方是不是收到了数据。
不可靠传输:发送方不知道接收方是不是收到了数据。
注意:可靠性传输并不代表发送的数据能 100% 让接收方获取到。此外,可靠性传输并不代表安全性传输,安全性传输是指:A 在给 B 传输数据的过程中,不会被第三方 C 截获数据。或者说,就算传输过程中被 C 截获到了,C 也不知道数据是啥。
3. 字节流与数据报的区别
面向字节流:假设 A 准备给 B 发送 100 个字节的数据,A 可以一次发 1个 字节,重复100次,也可以一次发 10个 字节,重复 10次。
上述的解释完美地体现了以面向字节流发送数据的灵活性,接收数据其实也是一样的。
面向数据报:以一个个的数据报为基本单位 ( 每个数据报多大,不同的协议里面是有不同的约定 )
发送的时候,一次至少发一个数据报 ( 如果尝试发送一个半,实际只能发出去一个 )
接收的时候,一次至少收一个数据报 ( 如果尝试接收半个,剩下半个就没了 )
4. 全双工
TCP 和 UDP 都是基于全双工的方式,那么我们就来讨论一下全双工和半双工的区别。
全双工:双向通信,A 和 B 可以同时向对方发送接收数据。
半双工:单向通信,要么 A 给 B 发,要么是 B 给 A 发,不能同时发。
可以将传输数据想象成用水灌入水管,如下图所示:
二、客户端和服务器之间的通信流程 (重点)
客户端和服务器之间的通信流程如下,顺序按照时间轴和序号。
在下图中,最复杂的步骤是第三步,即服务器根据客户端的请求来计算响应。
其实客户端也需要和用户进行交互,下面的程序大都是通过 Scanner 类 这个标准输入来让用户输入具体的需求,即命令行的方式实现交互。
这就好比:你现在正在使用浏览器,然而浏览器需要知道用户到底是想查资料、还是看视频、还是打游戏…你得把你的需求 / 命令告诉他。
三、UDP数据报套接字编程流程
引言
UDP socket 中有三个核心的类:
1. DatagramSocket 描述一个 socket 对象 2. DatagramPacket 描述一个 UDP 数据报 3. InetSocketAddress 描述客户端/服务器的 ip 和 port
1. 基于 UDP 的 socket 来写一个简单的回显程序
回显程序:Echo
回显程序说白了就是:A 给 B 说什么,B 就给 A 回应什么,相当于是复读机。
创建包 demo1,用 [ UdpEchoClient 类 ] 描述客户端,用 [ UdpEchoServer 类 ] 描述服务器
(1) UdpEchoServer 类
package demo1; import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.SocketException; public class UdpEchoServer { private DatagramSocket socket = null; //port 表示端口号 //服务器在启动的时候,需要关联(绑定)上一个端口号 //收到数据的时候,就会根据这个端口号来决定把数据交给哪个进程 //虽然此处 port 写的类型是 int, 但实际上端口号是一个 两字节的无符号整数 //范围 0 ~ 65535 public UdpEchoServer(int port) throws SocketException { socket = new DatagramSocket(port); } /** * 通过 start 方法来启动服务器 */ public void start() throws IOException { System.out.println("服务器启动!"); //服务器一般都是持续运行的 while (true) { //2. 服务器读取客户端发来的请求并解析 // 读取请求,当前服务器不知道客户端什么时候发来请求,所以 receive 方法就会发生阻塞等待 // 如果真的有请求过来了,此时 receive 方法就会返回 // 参数 DatagramPacket 是一个输出型参数,在 socket 中读到的数据会设置到这个参数的对象中 // DatagramPacket 在构造的时候,需要指定一个缓冲区(缓冲区其实就是一段内存空间,通常使用 byte[]) DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); socket.receive(requestPacket); //把 requestPacket 对象中的内容取出来,作为一个字符串 //即从 requestPacket 得到的数据,从 0 到最终的长度放入到 String 中去 String request = new String(requestPacket.getData(), 0, requestPacket.getLength()); //3. 根据请求计算响应 String response = process(request); //4. 构造响应并返回 // 把响应写回到客户端,这时候也需要构造以一个 DatagramPacket // 此处给 DatagramPacket 中设置长度,必须是 "字节的个数" // 如果直接读取 response.length,是错误的!因为它表示[字符串的长度],也就是[字符的个数],那么这是错误的! // 当前的 responsePacket 在构造的时候,还需要指定这个包发给谁 // 其实发送的目标,就是发请求的那一方 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress()); socket.send(responsePacket); // 加上打印日志 // format 即格式化字符串的方式,类似于 C 语言中的 sprintf // 和 C语言一样,%d 表示要打印一个有符号十进制的整数,%s 表示要打印一个字符串 String log = String.format("[%s, %d] req: %s; resp: %s", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response); System.out.println(log); } } /** * process 方法根据请求来计算响应 * 由于当前是一个回显服务器,就把客户端发送的请求直接返回即可 */ private String process(String request) { return request; } public static void main(String[] args) throws IOException { //new 一个服务器对象 UdpEchoServer server = new UdpEchoServer(9090); server.start(); } }
(2) UdpEchoClient 类
package demo1; import java.io.IOException; import java.net.*; import java.util.Scanner; public class UdpEchoClient { private DatagramSocket socket = null; private String serverIp; private int serverPort; public UdpEchoClient(String serverIp, int serverPort) throws SocketException { //客户端在发送请求之前,需要知道 服务器的 IP 地址 和 端口号 //因为只有商家知道了收件人的地址和号码,才能将商品发出去! this.serverIp = serverIp; this.serverPort = serverPort; //客户端这里不能指定端口号,尤其是不能将 serverPort 放进去 //这里没有指定端口,就相当于操作系统会自动分配一个空闲的端口号,给客户端使用 this.socket = new DatagramSocket(); } public void start() throws IOException { Scanner scanner = new Scanner(System.in); while (true) { //从标准输入读入一个数据 System.out.print("->"); String request = scanner.nextLine(); if (request.equals("exit")) { System.out.println("exit!"); return; } //1. 客户端构造请求并往服务器中发送 // 把字符串构造成一个 UDP 请求,并发送数据 // 在这个 DatagramPacket 中,既要包含即将发送的具体数据,又要指定这个数据即将发给哪个服务器 // 下面在传入服务器 IP 的时候,要通过 getByName方法转换一下,而传入服务器的端口号时,正常写入即可 DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIp), serverPort); socket.send(requestPacket); //send 方法就开始发送了! //5. 客户端从服务器中获取响应 // 这里 receive 方法接收数据的时候,将数据放入 responsePacket 参数中去。 DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096); socket.receive(responsePacket); String response = new String(responsePacket.getData(), 0, responsePacket.getLength()); //6. 显示响应结果 String log = String.format("req: %s; resp: %s", request, response); System.out.println(log); } } public static void main(String[] args) throws IOException { //new 一个客户端对象 // [ 127.0.0.1 ] 这是一个环回 IP,其实就表示主机本身 UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090); client.start(); } }
通信结果:
(3) 注意几个点
① 客户端是主动的一方,服务器是被动的一方。
举个例子:客户端,顾名思义就是客户,服务器顾名思义就是服务的人。我们将客户端想成汽车,服务器想成加油站,加油站有很多,汽车也有很多,但毋庸置疑,汽车的数量一定远远超过加油站的数量。所以,当汽车没有油了,驾驶员就需要选择去哪个加油站去加油;加油站就要等着客户到来,客户没来,就干等着,客户来了,就忙起来。那么,汽车就是主动的一方,加油站就是被动的一方。
② 客户端可以不指定端口号,尤其是不能将服务器的端口号当成某个客户端的端口。
举个例子:如果说 IP 地址是收件人的地址,那么端口号就是收件人的电话号码。所以说,端口不能乱填。
而当我们没有为客户端指定端口的时候,操作系统会自动分配一个空闲的端口号,给客户端使用。那么为什么客户端可以自动生成,而服务器就必须手动指定呢?
因为 客户端是主动发起请求的一方,服务器是被动接受请求的一方。
举个例子:客户端是商家,服务器是收件人,只有收件人把地址和手机号码告诉了商家,商家才能够根据收件人填写的信息发货;当商家知道了收件人填写的地址和号码之后,商家就可以寄出商品了。
③ 这里有一个 receive 方法,这个方法与我们平时的函数用起来不一样。以客户端的 receive 方法为例:
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096); socket.receive(responsePacket);
在上面的代码中,receive 得到的结果并没有作为一个返回值,而是作为一个输出型参数。
这种操作在 Java 中是相对少见的。Java中大部分情况下都是用返回值表示输出,用参数表示输入,很少会用参数表示输出。
什么意思呢?
上面的 receive 方法中,它在接收数据后,将数据写入到 responsePacket 这个参数去,而 responsePacket 却是作为参数传入 receive 方法中的。这和我们平时用参数传入函数,再利用函数返回这个思想完全相反。
实在不行,我们记住 receive 的这种格式即可,因为它真的很少见!
之所以这里要这么做,主要就是为了能够让用户给 DatagramPacket 指定一个缓冲区,那么为什么非得要用户来指定这个缓冲区?
因为缓冲区大小其实不好确定,如果是 DatagramPacket 自己随机指定大小,短了或长了都不合适,所以这是需要根据程序员的需求设定的!
此外,receive 是用来接收数据的,当数据没有过来的时候,receive 就会阻塞等待。
2. 基于 UDP 的 socket 写一个翻译程序
我们来写一个翻译程序:和查字典一样,英译汉。
在客户端的命令行输入 你好,服务器返回 hello.
创建包 demo1, 用 [ UdpDictClient ] 描述客户端,用 [ UdpDictServer ] 描述服务器。
(1) UdpDictServer
package demo1; import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.SocketException; import java.util.HashMap; import java.util.Map; public class UdpDictServer { private DatagramSocket socket = null; public UdpDictServer(int port) throws SocketException { socket = new DatagramSocket(port); } /** * 通过 process 方法来完成具体的查字典逻辑 * 例如:输入 hello,返回 [你好] */ private String process(String request) { //像有道词典这种专业词典,也是通过查表的逻辑来实现的,但也许是将待查的数据全放在了数据库中 Map<String, String > map = new HashMap<>(); map.put("hello", "你好"); map.put("cat", "猫咪"); map.put("computer", "计算机"); return map.getOrDefault(request, "抱歉,你所查询的英文在字典中不存在"); } public void start() throws IOException { System.out.println("服务器启动!"); while (true) { //2. 读取请求并解析请求 DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); socket.receive(requestPacket); String request = new String(requestPacket.getData(), 0, requestPacket.getLength()); //3. 根据请求计算响应 String response = process(request); //4. 构造响应并返回响应 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress() ); socket.send(responsePacket); //顺便打印日志 String log = String.format("[%s, %d] req: %s, resp: %s ", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response); System.out.println(log); } } public static void main(String[] args) throws IOException { UdpDictServer server = new UdpDictServer(9090); server.start(); } }
(2) UdpDIctClient
package demo1; import java.io.IOException; import java.net.*; import java.util.Scanner; public class UdpDIctClient { private String serverIp; private int serverPort; private DatagramSocket socket = null; public UdpDIctClient(String serverIp, int serverPort) throws SocketException { this.serverIp = serverIp; this.serverPort = serverPort; socket = new DatagramSocket(); } public void start() throws IOException { Scanner scanner = new Scanner(System.in); while (true) { System.out.print("输入需要翻译的英文 -> "); String request = scanner.nextLine(); if (request.equals("exit")) { System.out.println("exit!"); return; } //1. 构造请求并发送请求 DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIp), serverPort); socket.send(requestPacket); //5. 尝试获取响应 DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096); socket.receive(responsePacket); //6. 显示响应结果 String response = new String(responsePacket.getData(), 0, responsePacket.getLength()); String result = String.format("req: %s, resp: %s ", request, response); System.out.println(result); } } public static void main(String[] args) throws IOException { UdpDIctClient client = new UdpDIctClient("127.0.0.1", 9090); client.start(); } }
通信结果:
(3) 注意几个点
① 通过回显程序和翻译程序之间的对比,我们可以看到,里面很多代码的写法是固定的。
比如:客户端发送请求和接收响应,服务器读取请求和返回响应,构造 socket 对象…而服务器计算响应、用户和客户端之间的交互有所不同。
② 回显程序和翻译程序的主要代码差异在于服务器计算响应这一步。
上面提到,最复杂、最核心其实就是服务器根据请求来计算响应那一步,因为这不仅需要适应场景来采取不同的策略,还需要根据用户和客户端之间的交互来进行设计。
四、TCP字节流套接字编程流程
TCP socket 中有五个核心的类:
1. ServerSocket 描述一个服务器 2. Socket 描述一个客户端 3. InputStream 读取文件 4. OutputStream 写入文件 5. InetAddress 描述客户端/服务器的 ip 和 port
服务器:
- 创建 ServerSocket 关联上一个端口号,称为 listenSocket
- 调用 ServerSocket 的 accept 方法,accept 的功能是把一个内核建立好的连接给拿到代码中处理,accept 会返回一个 Socket 实例,称为 clientSocket
- 使用 clientSocket 的 getInputStream 和 getOutputStream 方法得到字节流对象,就可以进行读取和写入了。
- 当客户端断开连接之后,服务器就应该要及时关闭 clientSocket ( 否则可能会出现文件资源泄露的情况 )
客户端:
- 创建一个 Socket 对象,创建的同时指定服务器的 ip 和 端口,这个操作就会让客户端和服务器建立 TCP 连接。在这个连接建立的过程,就是传说中的 " 三次握手 ",这个流程是内核完成的,用户代码感知不到。
- 接着,客户端就可以通过 Socket 对象的 getInputStream 和 getOutputStream 来和服务器进行通信了。
1. 基于 TCP 的 socket 来写一个简单的回显程序
(1) TcpEchoServer
package demo1; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.Scanner; public class TcpEchoServer { private ServerSocket listenSocket = null; public TcpEchoServer(int port) throws IOException { listenSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); while (true) { //先尝试建立连接,建立好连接前,accept 方法会阻塞等待, //直到客户端建立连接了,此时 accept 会返回一个 Socket 对象 //接下来服务器与客户端之间的通信就通过 clientSocket 来完成 Socket clientSocket = listenSocket.accept(); processConnection(clientSocket); } } private void processConnection(Socket clientSocket) throws IOException { String log = String.format( "[%s : %d] 客户端上线!", clientSocket.getInetAddress().toString(), clientSocket.getPort() ); System.out.println(log); try( InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream() ) { while (true) { //2. 读取请求并解析 Scanner scanner = new Scanner(inputStream, "utf-8"); if (!scanner.hasNext()) { log = String.format( "[%s : %d] 客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort() ); System.out.println(log); //使用 break 而不使用 return 的原因,是为了让代码走到 finally 语句的 close 方法 break; } //一开始阻塞等待,直到客户端往服务器发送数据 String request = scanner.nextLine(); //3. 根据请求并计算响应 String response = process(request); //4. 构造响应并返回 PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(response); printWriter.flush(); log = String.format("[%s : %d] req: %s; resp: %s", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); System.out.println(log); } }catch (IOException e) { e.printStackTrace(); }finally { //连接结束,关闭资源 clientSocket.close(); } } private String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(9090); server.start(); } }
(2) TcpEchoClient
package demo1; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.Socket; import java.util.Scanner; public class TcpEchoClient { private String serverIp; private int serverPort; private Socket socket = null; public TcpEchoClient(String serverIp, int serverPort) throws IOException { this.serverIp = serverIp; this.serverPort = serverPort; this.socket = new Socket(serverIp, serverPort); } public void start() throws IOException { Scanner scanner = new Scanner(System.in); try(InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()) { while (true) { System.out.println("输入 ->"); String request = scanner.nextLine(); if (request.equals("exit")) { System.out.println("Exit!"); //使用 break 而不使用 return 的原因,是为了让代码走到 finally 语句的 close 方法 break; } //1. 构造请求并发送 PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(request); printWriter.flush(); //5. 尝试获取响应 Scanner respScanner = new Scanner(inputStream, "utf-8"); String response = respScanner.nextLine(); //6. 显示响应结果 String result = String.format("req: %s; resp: %s", request, response); System.out.println(result); } }catch (IOException e) { e.printStackTrace(); } finally { socket.close(); } } public static void main(String[] args) throws IOException { TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090); client.start(); } }
(3) 注意几个点
① TCP 需要先建立连接
UDP 的服务器进入主循坏,就直接尝试 receive 读取请求了。但 TCP 是有连接的,先需要做的是,建立好连接。当服务器运行的时候,当前是否有客户端来建立连接,不确定。如果客户端没有建立连接,accept 方法就会阻塞等待,直到有客户端建立连接了,此时 accept 才会返回一个 Socket 对象,进一步的服务器和客户端之间的交互,就交给 clientSocket 来完成了。
举个例子:我们将这个连接机制比作成 A 给 B 打电话,A 开始拨号,那么就会有【嘟嘟嘟…】的过程,这个过程就是尝试在建立连接,如果 B 接电话了,说明就连接上了,A 与 B 之间就能够通信。反之,如果受电线的影响、号码错误、或者说 B 故意不接电话,这就是连接失败…而当 A 与 B 通话后,B 挂断了电话,说明退出连接了。那么在这个例子中,listenSocket 就用来描述这个拨号过程,这个过程 A 尝试与 B 进行建立预备通信,如果 B 迟迟不接电话,或者因为其他物理原因,那么此时 A 就要进行阻塞等待;直到 B 接通电话,那么 clientSocket 就开始他们后续真正的通信过程。
② TCP 是通过字节流来发送和传输数据的
既然 TCP 与字节流密切相关,那么就不得不使用 InputStream 和 OutputStream 以及他们两之间对应的一些方法。此外,因为与输入输出有关,那么这里也应该注意在程序的末尾使用 close 方法,以防资源泄露的问题。我们可以使用 finally 语句,这样就避免了无论是程序正常运行,还是程序抛出了异常,都会执行 close 方法。
(4) 说明当前服务器的缺点
在上面的服务器代码中,有一个缺点:服务器与客户端之间实际上只能一对一的进行通信。
我们之前将服务器比作加油站,客户端比作没油的汽车,那么我们都知道,汽车的数量是远远大于加油站的。而我们所要做的就是让多辆汽车能够同时加油,所以服务器与客户端也是如此。服务器需要为更多的客户端服务,并且需要同时服务,因为如果做不到同时服务,服务器大部分时间或许就是在阻塞等待中,这就会大大降低效率。
2. 改进服务器
如果要保持两个及多个客户端同时在一台服务器上运行,那么就需要两个及多个线程来跑。
解决方案:主线程中循环调用 accept 方法,每次获取到一个新的客户端连接,就创建一个线程,让这个线程实现服务器为当前的客户端服务。
(1) TcpThreadEchoServer
package demo2; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.Scanner; public class TcpThreadEchoServer { private ServerSocket listenSocket = null; public TcpThreadEchoServer(int port) throws IOException { listenSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); while (true) { //在这个代码中,通过创建线程,就能保证在 accept 被调用完后,立刻有线程再次调用 accept Socket clientSocket = listenSocket.accept(); //创建一个线程来为这个客户端提供服务 Thread t = new Thread() { @Override public void run() { try { processConnection(clientSocket); } catch (IOException e) { e.printStackTrace(); } } }; t.start(); } } private void processConnection(Socket clientSocket) throws IOException { String log = String.format("[%s : %d] 客户端上线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); System.out.println(log); try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream() ){ while (true) { //2. 读取请求并解析 Scanner scanner = new Scanner(inputStream, "utf-8"); if (!scanner.hasNext()) { log = String.format("[%s : %d] 客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); System.out.println(log); break; } String request = scanner.nextLine(); //3. 根据请求并计算响应 String response = process(request); //4. 构造响应并返回响应 PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(response); printWriter.flush(); log = String.format("[%s : %d], req: %s; resp: %s", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); System.out.println(log); } }catch (IOException e) { e.printStackTrace(); }finally { clientSocket.close(); } } private String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpThreadEchoServer tcpThreadEchoServer = new TcpThreadEchoServer(9090); tcpThreadEchoServer.start(); } }
(2) 使用线程池的方式
普通版本 ( 不能处理多个客户端 )
多线程版本 ( 能处理多个客户端,但需要频繁创建销毁线程 )
线程池版本 ( 能处理多个客户端,也不需要频繁创建销毁线程 )
package demo3; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.Scanner; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class TcpThreadPoolEchoServer { private ServerSocket listenSocket = null; public TcpThreadPoolEchoServer(int port) throws IOException { listenSocket = new ServerSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); ExecutorService service = Executors.newCachedThreadPool(); while (true) { Socket clientSocket = listenSocket.accept(); service.submit(new Runnable() { @Override public void run() { try { processConnection(clientSocket); } catch (IOException e) { e.printStackTrace(); } } }); } } private void processConnection(Socket clientSocket) throws IOException { String log = String.format("[%s : %d] 客户端上线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); System.out.println(log); try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { while (true) { //2. 读取请求并解析 Scanner scanner = new Scanner(inputStream, "utf-8"); if (!scanner.hasNext()) { log = String.format("[%s : %d] 客户端下线!", clientSocket.getInetAddress().toString(), clientSocket.getPort()); System.out.println(log); break; } String request = scanner.nextLine(); //3. 根据请求并计算响应 String response = process(request); //4. 构造响应并返回响应 PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(response); printWriter.flush(); log = String.format("[%s : %d], req: %s; resp: %s", clientSocket.getInetAddress().toString(), clientSocket.getPort(), request, response); System.out.println(log); } } catch (IOException e) { e.printStackTrace(); } finally { clientSocket.close(); } } private String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpThreadPoolEchoServer server = new TcpThreadPoolEchoServer(9090); server.start(); } }
3. 基于 TCP 的 socket 写一个翻译程序
由于服务器针对请求计算响应这一步不同,所以我们使用 TcpDictServer 继承TcpThreadPoolEchoServer ,接着重写 process 方法即可。
TcpDictServer
package demo4; import demo3.TcpThreadPoolEchoServer; import java.io.IOException; import java.util.HashMap; import java.util.Map; public class TcpDictServer extends TcpThreadPoolEchoServer { public TcpDictServer(int port) throws IOException { super(port); } //start 方法不变,processConnection 方法不变 //process 方法需要改变 @Override public String process(String request) { Map<String, String> map = new HashMap<>(); map.put("hello", "你好"); map.put("cat", "猫咪"); map.put("computer", "计算机"); return map.getOrDefault(request, "你所查询的英文在字典中不存在"); } public static void main(String[] args) throws IOException { TcpDictServer tcpDictServer = new TcpDictServer(9090); tcpDictServer.start(); } }
注意
假设极端情况下,一个服务器面临很多很多客户端,这些客户端在连上了之后,并没有退出,也就是说:这些客户端正在运行中,那么这个时候服务器这边可能同一时刻,就会存在很多很多线程。比方说,大型的网络游戏,一个区服可能就有上万个线程,甚至更多,因为玩家需要联机作战。这就能解释,为什么双11 期间,或者省级、国家级考试分数下来,学生通过网页在查询数据的时候,迟迟进不去一些网页,甚至网页又是直接崩溃,之后在你的屏幕上显示一串报错代码!
解决上面高并发问题的方案:
① 使用协程来代替线程,完成并发,因为协程的比线程更轻量,( GO 语言 )
② 可以使用 IO 多路复用的机制,完成并发。此方法是从根本上解决服务器处理高并发的问题
在内核里来支持这样的一个功能。
对于网络编程,假设有 5000 个客户端,在服务器这边会用一定的数据结构来把这 5000 个客户端对应的 socket 都存好,不需要一个线程对应一 个客户端,我们只创建几个线程,IO 多路复用机制,就能够做到当某个 socket 上有数据了,就通知到应用程序,让线程来从当前的 socket 中读数据。
③ 使用多个主机,或者说提供更多的硬件资源,这是釜底抽薪的办法。可以简单地理解为:《有钱是万能的,有钱就可以任性…》