网络编程 - 4 ( TCP )
目录
TCP 流套接字编程
API 介绍
SeverSocket
Socket
用 TCP 实现一个回显服务器
服务端
客户端
运行调试
第一个问题:PrintWriter 内置的缓冲区 - flush 刷新解决
第二个问题:上述代码中,需要进行 close 操作吗?
第三个问题:多个客户端来访问服务器
梳理代码:
最终版本代码~
流程图
文字解释:
图文解释~
完!
TCP 流套接字编程
API 介绍
SeverSocket
SeverSocket 是创建 TCP 服务端 Socket 的 API
ServerSocket 构造方法:
ServerSocket 方法:
Socket
Socket 是客户端 Socket,或服务器端中接收到客户端建立连接(accept 方法)的请求之后,返回的服务端 Socket。
不管是客户端还是服务端 Socket,都是双方建立连接以后,保存的对端信息,以及用来与对方收发数据的。
Socket 构造方法:
Socket 方法:
用 TCP 实现一个回显服务器
服务端
TCP 是面向字节流的,传输的基本单位是字节~
先是创建一个 ServerSocket 然后是构造方法:
TCP 是有连接的,和打电话一样,需要客户端拨号,服务器来接听,所以在服务端有一个 accept 方法去“接听”。
客户端的内核发起建立连接的流程之后,服务器的内核就会配合客户端那边的工作,来完成连接的建立。这个连接建立的过程,就相当于是,电话的这边在拨号,另一边就在响铃。但是需要等到用户点击了接听之后,才能进行后续通信。(内核建立的连接不是决定性的,还需要用户程序,把这个连接进行“接听”(accept)操作,然后才能进行后续的通信~)
accept 也是一个可能产生阻塞的操作,如果当前没有客户端连接过来,此时的 accept 就会阻塞。(有一个客户端连接过来,accept 一次就能返回一次。如果有多个客户端连接过来,accept 就需要执行多次~)
accept 有一个 Soceket 类型的返回值,我们 Socket 一个 clientSocket 来接收。
那问题就来了,在服务器这里,为什么有两个 Socket 呀,一个类型是 ServerSocket,一个类型是 Socket,但我们可能会晕晕傻傻搞不明白~
举个栗子来解释一下 serverSocket 和 clientSocket 的作用~~~
不知道大家是否有买房的经历,就假设,我们现在要买一套房子~~~
我们刚一出出租屋,就看到一位西装革履的帅哥在马路牙子上站着,看我们走出来,就凑上来问我们,“要买房子吗??? 他们有个 xx 楼盘,这几天正好开盘,如果我们感兴趣,可以带我们过去看一看~~”。我们正好对这个 xx 楼盘有意向,也就说 OK!帅哥拿起电话,五分钟就来了一辆车给我们拉到那个售楼部了。
去了之后,那个帅哥一招手,就过来了一个 OL 的小姐姐,帅哥介绍说“这个小姐姐就是我们的专业顾问小姐姐,由她来给我们介绍楼盘的详细情况~~~”。
趁着这个小姐姐给我们介绍的过程中,帅哥人就不见了,又回到马路牙子上去继续揽客了~~
我们上述代码的 severSocket 变量,就是我们上述例子中的西装小哥,负责在外面揽客~~
我们上述代码的 clientSocket 变量,就是我们上述例子中的专业顾问小姐姐,负责给我们进行后续的通信交互。
每一个客户端,都会分配一个 clientSocket(专业顾问)
销售帅哥,就负责拉客,拉到的客人就会交给对应的专业顾问小姐姐~
当把客人拉来以后,专业顾问小姐姐就可以在 processConnnection 方法中给我们介绍啦~
我们前面介绍了 TCP 是有连接的,所以 TCP 中的 socket 中就会保存对端的信息!!!
所以就可以直接在 clientSocket 变量中调用 getInetAddress 方法和 getPort 方法
然后我们就可以进行循环的读取客户端的请求并返回响应了,因为 TCP 的面向字节流的,我们仍按需要使用到 IO 操作中的一些做法,即使用 try - with - resources 方法
其中 inputStream 变量,用于从网卡上读数据,outputStream 变量用于从网卡上写数据。
TCP 是面向字节流的,这里的字节流 和 文件操作中的字节流是完全一致的~~~使用和文件操作中一样的类和方法完成对 TCP socket 的读写~~~
接下来进行读操作,这里的读操作,可以通过 read 方法来完成,read 是把收到的数据放到 byte 数组中了,但我们后续根据请求处理响应,还需要把这个 byte 数组再转换成 String 类型
我们在这里可以使用更简单的方法来完成 --> Scanner
Scanner scanner = new Scanner(inputStream); 这段代码中,new 的 Scanner 对象,传入的参数是 inputStream ==》 Scanner 后面参数传的那个对象,就从那里读数据~ (这个 Scanner 对象可以创建在 while 外面,可以创建在 while 里面)
仍然是三步:
1. 读取请求并解析
2. 根据请求计算响应
3. 把响应返回给客户端
String request 就完成了我们的第一步读取请求并解析。在 scanner.next() 中,我们要注意隐藏的约定,next 读的时候,要读到空白字符才会结束!因此就要求客户端发来的请求必须要带有空白符的结尾,比如 \n 或者 空格。
我们也可以用 if 判断,如果读取完毕,客户端断开连接,就会产生读取完毕的提示信息~
第二步:根据请求计算响应
因为我们还是回显服务器,所以在业务方法这里,还是简单的 return request 即可~
第三步:把响应返回给客户端
在这里,我们可以直接 outputStream.write(response.getBytes(), 0, response.getBytes().length); 通过这段代码进行写回,但我们要考虑到的是,在客户端也是用 Scanner 来接收服务端的响应,这种形式是以二进制进行返回了,没办法直接带一个 \n
我们可以像 Scanner 包装输入流一样,对输出流进行一个包装~
还可以在最后加一个打印日志:
再跟上 main 方法
到此,上述代码把 TCP 回显服务器基本就写完了,说是“基本”,即代码还存在三个比较严重的问题,我们待会解决~
代码:
package network;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 serverSocket = null;public TcpEchoServer(int port) throws IOException {serverSocket = new ServerSocket(port);}public void start() throws IOException {System.out.println("服务器启动!");while (true) {Socket clientSocket = serverSocket.accept();processConnection(clientSocket);}}private void processConnection(Socket clientSocket) {System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());try (InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {Scanner scanner = new Scanner(inputStream);while (true) {// 读取完毕,客户端断开连接,就会产生读取完毕的提示信息if (!scanner.hasNext()) {System.out.printf("[%s:%d] 客户下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());break;}// 1. 读取请求并解析。这里注意隐藏的约定,next 读的时候要读到空白字符才会结束// 因此就要求客户端发来的请求必须要带有空白符的结尾,比如 \n 或者 空格String request = scanner.next();// 2. 根据请求计算响应String response = process(request);// 3. 把响应返回给客户端// 用 PrintWriter 对 outputStream 进行一层包装~PrintWriter printWriter = new PrintWriter(outputStream);printWriter.println(request);System.out.printf("[%s:%d] request:%s, response:%s \n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);}} catch (IOException e) {throw new RuntimeException(e);}}private String process(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
客户端
首先创建 Socekt 类型的对象用来与对方收发数据。
因为 TCP 的特点是有连接的,因此 socket 里面就会保存好我们传入的服务器的 ip 和端口,因此,此处就不用像 UDP 一样在类中保存了。
注意,客户端的 socket 对象,和,服务器端的 clientSocket 不是一个对象。
在客户端中执行代码:socket = new Socket(serverIp, serverPort); 就会和对应的服务器进行 TCP 的连接建立流程(系统内核完成)。当客户端中把内核中连接的流程走完之后,服务器这边的 clientSocket 就能从 accept 中的阻塞状态返回。(他们并不是同一个对象,他们是打电话的时候一方的“听筒”和另一方的“话筒”的关系~)
接下来就是 strat 方法:
还是打印提示信息,同样的,TCP 是面向字节流的,所以仍然是 try - with - resources。有了刚刚服务器的前车之鉴,我们可以直接在这里想到封装相关的方法 Scanner 和 PrintWirter 方法
在 strat 中有四步:
1. 从控制台读取输入的字符串
2. 把请求发送给服务器
3. 从服务器读取响应,这里也是和服务器返回响应的逻辑进行对应
4. 把响应显示出来
第一步:从控制台读取输入的字符串
这里的流程和 UDP 的客户端的类似
这里也可以假如 if 判断,来判断是否有信息
第二步:把请求发送给服务器
这里我们发送的时候,需要使用 println 来发送,为了让发送的请求末尾带有 \n,这里和服务器的 scanner.next 响应
第三步:从服务器读取响应
第四步:把响应显示出来
最后再加一个 main 方法
客户端代码:
package network;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 Socket socket = null;public TcpEchoClient(String serverIp, int serverPort) throws IOException {// 此处可以把这里的 ip 和 port 直接传给 socket 对象// 由于 TCP 是有连接的,因此 socket 里面就会保存好这俩信息socket = new Socket(serverIp, serverPort);}public void start() {System.out.println("客户端启动!");try (InputStream inputStream = socket.getInputStream();OutputStream outputStream = socket.getOutputStream()) {Scanner scannerConsole = new Scanner(System.in);Scanner scannerNetwork = new Scanner(inputStream);PrintWriter writer = new PrintWriter(outputStream);while (true) {// 这里的流程和 UDP 的客户端类似// 1. 从控制台读取输入的字符串System.out.println("->");if (!scannerConsole.hasNext()) {break;}String request = scannerConsole.next();// 2. 把请求发给服务器,这里需要使用 println 来进行发送,是为了让发送的请求末尾带有 \n// 和服务器中的 scanner.next 对应~writer.println(request);// 3. 从服务器读取响应String response = scannerNetwork.next();// 4. 把响应显示出来System.out.println(response);}} catch (IOException e) {throw new RuntimeException(e);}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);tcpEchoClient.start();}
}
运行调试
当我们信心满满的像 UDP 一样运行,在客户端输入 hello 的时候,发现,唉,TCP 并没有像我们预期一样实现功能:
重复启动几次客户端也无济于事,服务端倒是很给力的打印出了一些提示信息:
这里的提示信息,是客户端退出的时候,就会触发 TCP 的“断开连接”流程(服务器这边的代码能够感知到,对应的 Scanner 就能够在 hasNext 这里返回 false)
我们再仔细阅读上面的两份代码,就能大概猜到问题所在,出现上述没有反应的情况,那么就是客户端的请求没有正确的发送出去,要么是服务器的响应没有正确的返回回来(服务器收到请求最起码会打印日志)
第一个问题:PrintWriter 内置的缓冲区 - flush 刷新解决
之所以会出现上述的情况,本质上原因是在于 PrintWriter 内置的缓冲区在作祟~~
IO 操作都是比较低效的操作,我们作为一个程序员,就希望能够让低效操作,进行的尽量少一些~~所以在 PrintWriter 的方法中引入了缓冲区(内存),先把要写入的网卡的数据放入到内存缓冲区,等攒一波之后,再统一进行发送~~~(这个操作,把多次 IO 操作合并成一次了~)
但这也引出了一个问题,就像我们刚刚,仅仅输入了一个 hello,如果发送的数据很少,此时由于缓冲区还没满,数据就会只呆在缓冲区中,没有真正的被发送出去~~~
如何解决呢? 我们可以手动刷新以下缓冲区 --> flush 方法 实现刷新缓冲区的效果 。
在服务器和客户端的 PrinterWriter 对象调用完 println 方法之后,分别加上 flush 方法就好啦!!!
第二个问题:上述代码中,需要进行 close 操作吗?
其实是需要的~~
在服务器端的 try - with - resources 中,需要在最后再添加一个 finally 来针对 clientSocket 进行 close 操作~
serverSocket 整个程序只有唯一一个对象,并且这个对象的生命周期很长,是要跟随整个程序的,这个对象是无法提前进行关闭的,只要程序退出,随着进程的销毁一起被释放即可(不需要我们手动进行释放)
调用 socket.close 本质上也是关闭文件,释放文件操作符表。这里进程销毁,自然文件操作符表就没有了。因此我们在写 UDP 的程序的时候,并没有进行 close 操作~
但是 TCP 中的 clientSocket 是每个客户端都有一个。随着客户端越来越多,这里消耗的 socket 也会越来越多。(如果我们加入一个资源释放,就很可能把文件描述符表给占满)
这次连接处理完毕(processConnection 结束),就可以 close clientSocket了~~
注意:我们的 try - with - sources 中只是关闭了流对象,并没有释放文件本体~ 这俩流对象,都是 socket 对象给我们创建出来的~
释放了 socket 对象之后,即使上述流对象不进行释放,问题也不大,这俩流对象内部不持有文件描述符表,只是持有一些内存结构,而在 Java 中,内存文件可以被 gc 释放掉~~~
但如果只释放流对象,不释放 socket 就不行了,socket 持有了文件描述符,本质上还是要释放文件标识符的~~~
第三个问题:多个客户端来访问服务器
服务器需要能支持多个客户端同时访问,这是天经地义应该的~~~
IDEA 默认情况下,貌似一个进程运行两次,需要小小设置一下:
找到我们要运行的进程,选中即可
然后将两个客户端启动,发现,咦,第二个启动的客户端,又出 bug 了~~~
但是第一个启动的客户端,还是可以正常运行的~
我们的服务器端,也只检测到了第一个启动的客户端。
让我们去代码层面分析分析~
当第一个客户端连接上服务器之后,服务器就会从 accept 这里返回(解除阻塞),进入到 processConnection 中了。
接下来就会在 scanner.hasNext 这里阻塞,等待客户端的请求。
当客户端的请求到达之后,从 scanner.hasNext 处返回(解除阻塞),继续执行,读取请求,根据请求计算响应,返回相应给客户端~
执行完上述一轮操作之后,服务器循环回来再在 hashNext 处阻塞,等待下一个客户端的请求。
直到客户端退出之后,连接结束,此时 while(true) 循环才会退出。
服务器在里层(第二个 while(true) )中循环转圈的时候,是无法执行到外层 while(true) 中的 accept 方法的。
对然第二个客户端和服务在内核层面上,建立了 TCP 连接了,但是应用程序这里,是无法把连接拿到应用层面里面(processConnection 方法)处理。(人家一直给你打电话,你的手机一直在响,但你就是一直没有电话)
那,如果第一个客户端退出了,第二个客户端之前发送的请求为什么就马上就被处理了,而不是被丢掉呢???
当前 TCP 在内核中,每个 socket 都是由缓冲区的。客户端发送的数据确实是发了,服务器也收到了,只不过数据是在服务器的接收缓冲区中。
一旦第一个客户端退出了,回到第一层循环,继续执行第二次 accept,继续执行 next 就能把之前缓冲区的内容给读出来~(就相当于,快递小哥把包裹拿到了小区门口,我们没时间取,于是他就把包裹放到了菜鸟驿站~)
如何解决呢??? ==》 多线程~
单个线程,是无法既能给客户端循环提供服务,又能去快速调用第二次 accept。
简单的办法就是引入多线程。
主线程就负责执行 accept,每次有一个客户端连上来,就分配一个新的线程,由新的线程负责给客户端提供服务。
此时,就会把 processConnection 提交给新的线程来负责了。主循环就会快速的执行完一次之后,回到 accept 的位置阻塞等待新的客户端到来~~
这样就可以实现多个客户端连接服务器啦!!!
上述问题,并不是由 TCP 引起的,而是我们程序员自己的代码没有写好,出现了两层循环嵌套引起的。
但 UDP 服务器就可以实现多个客户端直接连接服务器,是因为 UDP 服务器中的代码,只有一层循环,就不会涉及到上面这样的问题,UDP 服务器天然就可以处理多个客户端的请求~
我们上面的引入线程,的确是可以解决我们此时的问题。但,再想想,每次来一个客户端,就会创建一个线程,每次这个客户端结束,就要销毁这个线程。如果客户端比较多,就会使得服务器频繁创建和销毁线程,造成资源浪费~~ ==》 线程池~~
这样也解决了频繁创建和销毁线程所造成的开销了~~~
线程池,解决的是频繁创建和销毁线程的问题。如果,当前的场景是线程频繁创建,但是不销毁呢???
我们可以想出两种情况的客户端:
每个客户端处理过程非常短(例如搜索网站的搜索页~)
每个客户端处理过程非常长(游戏服务器) --> 打一局王者荣耀 / 吃鸡 / LOL....
如果继续使用线程池 / 多线程,此时就会导致当前的服务器上一下积累了大量的线程~~ ==》此时对于服务器的负担就会非常重!!!
我们前面在哲学家吃面问题中也探讨多,如果哲学家太多,桌子是没办法让那么多哲学家同时上桌吃饭的,此时就会引起调度成本的增加。
为了解决上述的问题,还可以引入其他的方案:
1. 协程
即轻量级线程,本质上还是一个线程,用户态可以通过手动调度(省去系统调度的开销了~~)的方式让着一个线程“并发”的做多个任务~~
2. IO 多路复用
系统内核级别的机制,本质上是让一个线程同时去负责多个 socket。(本质上是因为这些 socket 数据并不是需要同一时刻需要同时进行处理)
举个栗子~
晚上吃什么??? 爸爸想吃米,妈妈想吃面,我想吃饺子~~~楼下有一个小吃街,就会有很多小摊~~~
方案一:爸爸一个人去买
1)先去买米,等米好了之后,再去第二个小摊
2)再买面,等面好了之后,再去第三个小摊
3)再去买饺子,等好了之后,就齐活回家~~
方案二:三个人一起出动
1)爸爸买米
2)妈妈买面
3)我买饺子
方案三:还是爸爸一个人去买
1)先买米,点完单,付完钱,不等了,直接去下一个小摊
2)再去买面,也是不等了,直接去下一个小摊
3)再去买饺子
把上面的三个单都点完之后,站在这三个小摊的中间位置(花一份时间,同时等待三个任务~~)上述哪个任务号了,对应的小摊老板可以喊爸爸,他就直接过去把对应的餐拿走就行~~
上面的栗子中的三个方案,就对应类我们解决编程问题中的三个方案
方案一,对应的就是我们的单线程,这个方案的效率是比较低的。
方案二,对应的就是我们的多线程,这个方案,大大提升了效率,但是系统的开销也变大了~~
方案三:对应的就是我们的 IO 多路复用,基本点在于,虽然有多个 socket 但是同一时刻,活跃的 socket 只是少数的(需要读写数据的 socket 是少数的)大部分 socket 都是在等,所以就可以使用一个线程来等多个 socket。 ==》 这样效率也不会很低,系统开销也不会很高。
IO 多路复用操作属于操作系统提供的机制,也有对应的 API~ Java 中也对操作系统提供的 API 进行了封装,但这些 API 使用起来比较麻烦,我们这里暂时不介绍了。想要了解的老铁可以搜索 NIO 去仔细研究一下~~~
梳理代码:
最终版本代码~
先在这里贴一下最终的两个类的代码:
服务器:
package network;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;
import java.util.concurrent.ThreadPoolExecutor;public class TcpEchoServer {private ServerSocket serverSocket = null;public TcpEchoServer(int port) throws IOException {serverSocket = new ServerSocket(port);}public void start() throws IOException {System.out.println("服务器启动!");ExecutorService pool = Executors.newCachedThreadPool();while (true) {Socket clientSocket = serverSocket.accept();// Thread thread = new Thread(() -> {
// processConnection(clientSocket);
// });
// thread.start();pool.submit(new Runnable() {@Overridepublic void run() {processConnection(clientSocket);}});}}private void processConnection(Socket clientSocket) {System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress(),clientSocket.getPort());try (InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {Scanner scanner = new Scanner(inputStream);while (true) {// 读取完毕,客户端断开连接,就会产生读取完毕的提示信息if (!scanner.hasNext()) {System.out.printf("[%s:%d] 客户下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());break;}// 1. 读取请求并解析。这里注意隐藏的约定,next 读的时候要读到空白字符才会结束// 因此就要求客户端发来的请求必须要带有空白符的结尾,比如 \n 或者 空格String request = scanner.next();// 2. 根据请求计算响应String response = process(request);// 3. 把响应返回给客户端// 用 PrintWriter 对 outputStream 进行一层包装~PrintWriter printWriter = new PrintWriter(outputStream);printWriter.println(request);printWriter.flush();System.out.printf("[%s:%d] request:%s, response:%s \n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);}} catch (IOException e) {throw new RuntimeException(e);} finally {try {clientSocket.close();} catch (IOException e) {throw new RuntimeException(e);}}}private String process(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
客户端:
package network;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 Socket socket = null;public TcpEchoClient(String serverIp, int serverPort) throws IOException {// 此处可以把这里的 ip 和 port 直接传给 socket 对象// 由于 TCP 是有连接的,因此 socket 里面就会保存好这俩信息socket = new Socket(serverIp, serverPort);}public void start() {System.out.println("客户端启动!");try (InputStream inputStream = socket.getInputStream();OutputStream outputStream = socket.getOutputStream()) {Scanner scannerConsole = new Scanner(System.in);Scanner scannerNetwork = new Scanner(inputStream);PrintWriter writer = new PrintWriter(outputStream);while (true) {// 这里的流程和 UDP 的客户端类似// 1. 从控制台读取输入的字符串System.out.print("->");if (!scannerConsole.hasNext()) {break;}String request = scannerConsole.next();// 2. 把请求发给服务器,这里需要使用 println 来进行发送,是为了让发送的请求末尾带有 \n// 和服务器中的 scanner.next 对应~writer.println(request);writer.flush();// 3. 从服务器读取响应String response = scannerNetwork.next();// 4. 把响应显示出来System.out.println(response);}} catch (IOException e) {throw new RuntimeException(e);}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);tcpEchoClient.start();}
}
流程图
文字解释:
1. 服务器端启动:
服务器程序运行 TcpEchoServer 类的 main 方法,调用构造函数,TcpEchoServer(int port),传入一个端口号,这里传入的是 9090,创建一个 ServerSocket 对象,并绑定到该端口,开始监听客户端的连接请求。
接着调用 strat() 方法,输出“服务器启动”的提示信息,创建一个可缓存线程池 ExecutorService 用于管理处理客户端连接的线程。
进入第一层 while(true) 无限循环,调用 serverSocket.accept() 阻塞等待客户端的连接请求
2. 客户端启动:
客户端程序运行 TcpEchoClient 类的 main 方法,调用构造函数,TcpEchoClient(String serverIp, int serverPort) 向客户端传入服务器的 IP 地址是 127.0.0.1(即本机)和端口号 9090,创建一个 Socket 对象,尝试与服务器建立 TCP 连接(系统内核完成)。
调用 start() 方法,输出“客户端启动”的提示信息,获取与服务器连接的输入流 InputStream 和 输出流 OutputStream。
创建两个 Scanner 对象,一个 scannerConnsole 用于读取控制台输入,另一个 scannerNetwork 用于读取服务器的响应。
创建 PrintWirter 对象 writer ,用与向服务器发送请求
3. 服务器接收到客户端的连接
当客户端发起连接请求的时候,服务器的 serverSocket.accept() 方法返回一个 Socket 对象 clientSocket,代表与该客户端的连接。
服务器使用线程池 pool 提交一个新的任务,任务内容是调用 processConnection(Socket clientSocket) 方法来处理客户端的连接。
4. 客户端与服务器交互
客户端进入循环,持续与服务器进行交互:
输出提示信息 -> ,使用 scannerConsole.hasNext() 检查控制台是否有输入,如果没有输入就跳出循环。
使用 scannerConsole.next() 从控制台读取用户输入的请求
使用 writer.println(request) 将请求发送给服务器,这里是用 println 也是确保了请求末尾带有换行符 \n,以便服务器的 scanner.next() 方法能正确读取。
使用 writer.flush() 确保请求数据立即发送。
5. 服务器处理客户端连接
在 processConnection 方法中,服务器输出客户端上线的提示信息,格式为:[客户端IP:客户端端口号] 客户端上线!
获取与客户端连接的输入流 InputStream 和输出流 OutputStream,并使用 Scanner 从输入流读取客户端的请求
进入第二层 while(true) 循环,持续读取客户端的请求。
使用 scanner.hasNext() 检查是否还有数据可读,如果没有数据,则说明客户端断开连接,输出客户端下线的提示信息,格式为[客户端 IP 地址:客户端端口号]客户端下线!,然后跳出循环~
使用 scanner.next() 读取客户端的请求,这里要求客户端发来的请求必须以空白符(\n 或者 空格)结尾
调用 process(String request) 方法,根据请求计算响应,这里是简单的返回请求本身。
使用 PrintWriter 将相应返回给客户端,调用 printWriter.println(request) 发送响应(注意这里是 println 发送响应自然结尾就带了 \n),并调用 printWriter.flush() 确保数据立即发送。
输出请求和响应的详细信息,格式为:[客户端IP地址:客户端端口号]request:请求内容,response:相应内容.
6. 客户端得到响应
使用 scannerNetwork.next()从服务器读取响应
将响应显示在控制台上
7. 连接关闭
当客户端或服务器出现异常(网络中断等等),或者客户端主动关闭连接,会触发响应的异常处理逻辑。
服务器在 processConnection 方法中的 finally 块中关闭于客户端的连接,调用 clientSocket.colse() 方法
客户端在 start() 方法的 try 块结束时候,由于使用了 try - with - resources 语句,会自动关闭InputStream 和 OutputStream
8. 持续交互或者结束
如果客户端没有关闭连接,就会继续循环等待用户输入新的请求,重复步骤 4 中的交互过程。
如果客户端关闭连接,服务器会继续在 strat() 方法中的循环中等待新的客户端连接请求。
图文解释~
1. 服务器启动,阻塞在 accept,等待客户端连接
2. 客户端启动
这里构造方法中的 new 操作,触发了和服务器之间的建立连接的操作(内核中操作~),此时服务器就会从 accept 中返回。
3. 服务器从 accept 返回,进入到 processConnection 方法中
执行到 hashNext 处,产生阻塞,此时虽然连接接建立了,但是客户端还没有发来任何的请求,hashNext 阻塞等待请求到达(此处相当于是,电话接通了,但是没有人说话~)
4. 客户端继续执行到 hasNext,等待用户向控制台写入内容
5. 用户真正输入内容之后,此时 hasNext 就返回了,继续执行这里的发送请求的逻辑
这楼里就会把请求发出去,同时客户端等待服务的相应返回,next 也会产生阻塞
6. 服务器从 hasNext 返回,读取到请求内容并进行处理
读取到请求,构造出响应,把响应写回客户端了。
服务器就结束这次循环了,开始了下一轮循环,继续阻塞在 hasNext 处等待下一个请求
7. 客户端读取到响应,并且打印出来
结束这次循环,进行下一次循环,继续阻塞在 hasNext 等待用户继续输入数据~
回合制~~~