TCP流套接字编程
1. TCP & UDP 的区别
TCP 的核心特点是面向字节流,读写数据的基本单位是字节 byte
2 API介绍
2.1 ServerSocket
定义
ServerSocket 是创建 TCP 服务端 Socket 的API。
构造方法
方法签名 | 方法说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
所以服务器启动,需要绑定端口号
方法
方法签名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
2.2 Socket
- Socket 是客户端 Socket ,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。
- 不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。
构造方法
方法签名 | 方法说明 |
---|---|
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接 |
这个方法的两个参数,都是服务器的IP & 端口,这个版本的构造方法,就是给客户端用的,服务器怎么通过这个类来构造对象呢?后续再来看;
方法
方法签名 | 方法说明 |
---|---|
InetAddress getlnetAddress() | 返回套接字所连接的地址 |
InputStream getlnputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
TCP没有 send(),receive() 这样的操作,但是 TCP 调用 getlnputStream() 会得到个 InputStream 对象;调用 getOutputStream(),得到一个 OutputStream 对象,这两个对象是字节流对象;
虽然 Socket 自身没有读写操作,但是 Socket 可以拿到字节流对象,就可以通过字节流对象,来进行读写操作;
3. 通过TCP实现回显服务器
TCP Echo Server
创建关联对象
通过构造方法,绑定关联的端口号 (和 UDP类似,都是在构造对象的时候,绑定端口号)
实现 start()
处理客户端发送的连接
TCP 和 UDP 服务器 start() 的主循环的第一步有所区别:
- UDP 进入主循环,就可以直接处理请求,根据请求计算响应,把响应返回客户端;
- TCP服务器 ,进入主循环后,因为 TCP 是有连接的,所以第一步是先处理客户端发来的连接;
- 这个连接就类似于打电话,在客户端打电话给服务器时,服务器要先接通电话,才可以进行后续的正常通信;
所以TCP服务器进入主循环的第一步,就是进行接通电话的操作,而拨号操作,是客户端来完成的;
调用 ServerSocket 对象底下的 accept() 方法,起到接听电话的作用;
还需要接收 accept() 方法的返回值:
- 如果客户端和服务器确实已经建立连接了,那 accept() 是可以拿到这个请求连接的;
- 如果客户端没有发起连接,那么 accept() 就会产生阻塞 ,和前面的 receive() 类似;
TCP 服务器后续通过对clientSocket 进行读写数据,来和客户端进行通信;
进一步理解 ServerSocket 和 Socket 的职责划分
处理一个客户端的连接
处理连接的过程比较复杂,因此我们把这个操作封装成一个方法;
可能会涉及到多个客户端的请求和响应,如果服务器接收到多个请求,就要返还给客户端多个响应;
服务器与客户端成功连接,打印日志:
这两个方法可以拿到对端(客户端)的 IP & 端口号;
获取输入流对象&输出流对象
打印出客户端的IP&端口号后,就需要进一步地处理客户端的请求和响应,需要借助 Socket 类内置的 InputStream & OutputStream 来处理这些请求和响应
这里获取到的是输入流对象,后续提供这个对象,来读取客户端的请求;
接下来获取输出流对象,并且处理异常:
拿到输入流对象&输出流对象后,后续读取请求,就使用输入流对象;返回响应,就把响应的内容写入输出流对象中;
接下来,在 try 的代码块中,实现读取请求和返回响应的操作,这些操作分成三步
- 读取请求并解析
- 根据请求计算响应
- 返回响应给客户端
因为在一次连接中,这三个操作可能会解析多次,所以我们提供 while 循环来处理
读取请求
下列操作,读取到的请求是一个字节数组,还需要手动把字节数组再转成字符串,才方便后续的处理和打印:
我们可以借助 Scanner 来进行更简单直接的读取操作,既可以读请求,读出来的请求又已经是一个字符串;
- 把刚刚从 Socket 中拿到的 InputStream 填入 Scanner 中,后续通过 Scanner 直接读取请求中的内容;
- 如果 Scanner 没有再读取到数据,说明连接断开,就可以结束循环了
所以读取请求,可以直接借助 read(),也可以借助 Scanner 来辅助完成
补充
- Scanner 可以控制处理台输入,又可以控制处理文件的输入,还可以控制处理网络的输入;
- Scanner 的构造方法:
- Scanner 的构造方法,填入的是一个InputStream 对象
根据请求计算响应
- 当前编写的是一个回显服务器代码,所以可以直接在计算响应的逻辑返回请求即可
返回响应给客户端
下列写法,会直接拿到 response 中的字节数组,然后通过 outputStream 提供的 write(),来写入输出流对象即可;
这种计算响应的方法,是提供字节的方式填充输出流对象;
除了上述写法,我们还可以利用字符流的方式:
这里的 writer 和 System.out 起到的的效果类似,所以 println,printf 等等都可以通过调用
打印返回响应的日志和连接断开的日志
服务器一次连接可处理多个请求的原理
服务器 start() 的代码块中, process() 方法处理请求返回响应的逻辑,相当于嵌套了两层 while 循环:
因此,可以在服务器与客户端的一次连接中,服务器处理多个请求;
如果在一次连接中,客户端发送多次请求,服务器就返还多个响应(打一次电话可以说一句话或者很多句话);
补充
- 一个连接一个请求(短连接),一个连接多个请求(长连接);
- 因为连接过程的开销非常大,所以在日常开发中,更主流的是长连接,一个连接处理多个请求;
- 就好比锁消除,针对要加锁的多个逻辑,每个逻辑都进行加锁,开销非常大,所以更科学的做法是把这些逻辑合在一起,只进行一次加锁;(和领导汇报工作成果,应该在一次电话中一次性汇总完毕,而不是打多次电话。每次电话只汇报一个成果)
TCP Echo Client
创建 Socket 对象
Socket 在客户端和服务器都可以使用,服务器的 Socket 通过调用accept()拿到,但是客户端的 Socket 就需要通过实例来创建对象;
实现客户端构造方法
在客户端的构造方法中,传入服务器的IP和端口号;
传到构造方法中的字符串IP地址(类似127.0.0.1这样的字符串),不需要任何转换;
对比UDP 的客户端,TCP客户端在构造方法在实例Socket对象后,就会在底层和对端建立TCP连接,连接好后,服务器会记录对端的信息(实例化Socket对象时传入的IP和端口号);
因此,服务器的IP和端口号,在TCP客户端中就不需要再创建变量来保存了;
从控制台中读取请求,发送给服务器
从控制台中读取用户输入信息作为请求
为了实现客户端能够和服务器在一次连接的情况下,发送多次请求,我们设置一个循环:
这步操作可以读取刚刚输入控制台的一行信息,读取到的信息作为客户端的请求;
拿到输入流&输出流
之后就把这个请求写入 Socket 对象中,写的时候也需要拿到Socket对象的 InputStream 输入流& OutputStream 输出流;
为了使用方便,可以对拿到的输入流和输出流再套一层壳
完善循环逻辑
所以在主循环中,第一步操作是从控制台中读取用户输入,把读取到的输入设置为请求:
第二步就是把请求发送给服务器
第三步,就是读取服务器返回的响应,并且把读取到的响应打印到控制台
客户端与服务器交互过程
区分客户端与服务器的 Socket 对象
下列服务器和客户端的两个 socket 对象,分别在不同进程中,甚至在不同主机中,因此绝对不是同一个对象;
这两个对象存在密切的关联关系,可以把这两个 socket 对象理解为两部电话:
- 接通这两部电话后,从A听筒说话,B可以听见;从B听筒说话,A可以听见(从一边对Socket对象写数据,另一边的 Socket 对象就可以读到);
- 但是这两个对象绝对不是同一部电话;
处理细节问题
问题一:冲刷缓冲区
完善 main 方法
程序运行结果
关掉客户端:
再启动一次客户端,并且发送一个数据,并且一敲回车,发现没有反应:
为什么没有反应呢?因为其实刚刚客户端代码,并没有真的把请求发送出去:
这个操作只是把数据放到 "发送缓冲区" 中,还没有真正写入网卡里;
- 发送缓冲区其实就是一块内存空间,对网络/硬盘写数据是一个非常低效的操作,如果频繁地调用这些比较低效的操作,程序运行是非常缓慢的;
- 为了提高效率,就引入一个内存缓冲区,把要写入的数据都放入缓冲区中,再统一进行发送,这样可以减小写硬盘和写网络的次数;
- 但是提高效率的同时,也会产生副作用,就是调用 writer.println 这样的操作,并没有真正地触发发送数据操作,而只是把数据写入缓冲区;
- 当然,把数据写入缓冲区,而不是直接发送这样的行为,是 PrintWriter的行为,如果不套壳,是可以直接发送的;
- 但是在实际开发中,广泛使用了缓冲区这样的概念,调用flush()来刷新缓冲区这个操作是非常关键的;
如何真正地把数据发送出去呢?我们要使用刷新操作(调用 flush() 方法来冲刷缓冲区),把缓冲区的数据强制写入 IO 设备中:
客户端服务器交互结果
问题二:针对 hasNext 对一个完整请求/响应设置标识符
println 的操作,会自动加上一个 \n :
但是如果在这个代码中不加这个 \n,直接使用 print行不行呢?
我们重新启动一下客户端,并且发送内容,发现客户端又没有反应了,并且服务器也没有读到信息:
造成上述原因,是因为 next() 的问题,修改成 print 后,客户端输入的数据也是发送到服务器上了,并且服务器也收到了,但是服务器并没有真正处理,因为服务器有一个hasNext()判断:
补充
- hasNext() 的行为是,判断当前收到的数据是否包含"空白符",什么是空白符呢?
- 换行,回车,空格,制表符,翻页符......都是空白符;
- 遇到空白符,hasNext()才会认为是一个完整的 next,否则在遇到空白符之前,hasNext() 都会阻塞。
所以刚刚在修改成 print 之后,发送的内容是不包含空白符的内容,hasNext() 就会阻塞而无法进入下面读取请求的逻辑;
总结
- 使用 println,是在约定一个请求/响应,是在使用 \n 作为结束标记,对端在读取数据的时候,也会在读取到 \n 时,判断读取到一个完整的请求/响应;
- 这是我们在使用TCP时,特别需要注意的事项,并且和UDP不一样;
- UDP是以 DatagramPacket 作为单位的,但是TCP则是以字节为单位,但是实际上一个请求,往往是以多个字节构成的;
- 到底多少个字节为一个完整的请求/响应,就需要程序员想办法标记出来,引入分割符是标记一次完整请求/响应的典型方式,不一定是换行,也可以是其他分割符;
问题三:根据不同Socket的生命周期判断是否需要手动关闭
在TCP服务器刚刚编写的代码中,涉及两种Socket:
- ServerSocket的生命周期,贯彻整个服务区进程,不需要手动关闭
- clientSocket 的生命周期是一次连接,而不是整个服务器进程;
- 所以每个客户端连接,都会创建一个新的 clientSocket,每个客户端断开,这个对象就应该 close() 了,但是当前代码并没有对 clientSocket 进行 close() 释放;
- 没有在每次连接结束后,对 clientSocket 进行关闭,就会造成文件资源泄漏的问题(文件一直在打开,而不进行关闭,在打开到一定程度,会把文件描述表耗尽,就无法继续打开新文件)
- 在 poccessConnection() 方法的逻辑执行完毕之后,我们就可以对 clientSocket 进行关闭
总结
- 对于是否需要手动关闭 Socket ,需要我们分析请求它的生命周期是跟随整个进程,还是跟随某个环节;
- 如果是跟随整个进程,那么可以不手动关闭 Socket;如果是每个请求都会创建应该 Socket,或者每一次连接都会创建一个 Socket,或者某一个环节的执行周期,会创建一个Socket,这样的情况,就需要我们手动关闭 Socket;
问题四:服务器无法同时等待 accept() &等待已连接的客户端发送响应
一个服务器能同时给多个客户端提供服务,那么刚刚编写的TCP服务器也可以处理多个请求吗?我们关掉客户端,再重新启动多个客户端:
补充:修改同时启动多个客户端的IDEA设置
IDEA 会默认只启动一个客户端,再启动别的客户端,会先关闭上一个启动的客户端;我们通过这里的设置,就可以让 IDEA 启动多个客户端;
设置好后,我们再来重新启动两个客户端, 并且先后发送请求:
结果在第二个客户端发送请求时,又卡住了,并且第一个创建的客户端多次发送请求的操作是没问题的,服务器都会有一个正常响应,但是第二个客户端无论怎么发送请求,都不会有响应:
并且我们通过服务器日志,可以发现,在第二个客户端上线时,并没有再次打印日志;
如果我们关掉第一个客户端,第二个客户端发送的请求,会马上被服务器接收,并且返回响应
所以当前代码,TCP服务器在同一时刻,只能处理一个客户端发送的请求;
针对上面出现的问题,我们对服务器的处理请求,返回响应这一块代码的关键逻辑进行分析:
如果同一时刻,有多个客户端对一个服务器进行连接,那么第一个和服务器连接的客户端1:
所以当前这个服务器代码,如果已经在处理一个客户端的请求,就没办法处理另一个客户端的请求,服务器代码会卡在循环中,无法重新调用 accept()连接新的服务器,直到连接的客户端退出,导致循环终止;
总结
- 当前服务器代码,无法同时等待 accept 和 等待用户请求;在等待客户端发送请求的时候,没办法等待 accept() ,这个时候,如果有新的客户端连接,也无法接听电话(一个专业销售没办法在给先来的顾客讲解产品的时候,又去接待低级销售后面揽入店的顾客);
- 因此,服务器的代码,导致服务器一次只能处理一个客户端发送的请求,这个代码是不合理的;
4. 服务器引入多线程
如果只是单个线程,无法同时响应多个客户端;为了解决这个问题,此处给每个客户端都分配一个线程;
在服务器主线程中,就只是进行 accept(),每次有新的客户端 accept() 连接成功,就创建一个新的线程,又新线程负责完成,后续对客户端 Socket 对象的引用 clientSocket 的读写操作;
引入线程之后,重新启动服务器和两个客户端,可以发现服务器打印了两个带着不同端口号的日志;
因此,在引入线程之后,服务器可以一边等待请求,一边等待 accept() 连接新的客户端;
5. 服务器引入线程池
客户端连接,服务器就会创建新线程,客户端断开连接,客户端就会销毁线程,为了避免频繁创建销毁线程,也可以引入线程池;
对于在服务器引入线程池,一般不会使用 newFixedThreadPool,因为会创建一个固定线程数的线程池,意味着同时处理的客户端连接数目就固定了;
把任务都交到线程池中,线程池已经预先创建好了一些线程,提前创建好的线程就可以立刻投入工作,从而减少再去创建线程的开销;
线程不是越多越好,如果线程数量过多,CPU的利用率无法再被提高,还会导致系统调度速度下降;并且创建线程也是需要系统资源的,系统总体资源是有限的(一个主机差不多只能创建几千个线程);
无论是多线程,还是线程池,一个线程都对应一个客户端,并且一个主机创建的线程数目是有上限的,那如果有成千上万个客户端,同时访问一个服务器(一台主机),该怎么办呢?
IO多路复用/IO多路连接(这里不重点讲解,因为JVM中没有原生的 IO 多路复用 API,而是把API重新封装,已经不仅仅是多路复用了,后续会详细讲解针对 Java 的 IO多路复用所使用的 NIO,Netty 等知名网络框架,当前讲的IO是 BIO/Blocking IO);
扩展
基于BIO(同步阻塞IO)的长连接会一直占用系统资源。对于并发要求很高的服务端系统来说,这样的消耗是不能承受的。
- 由于每个连接都需要不停的阻塞等待接收数据,所以每个连接都会在一个线程中运行。
- 一次阻塞等待对应着一次请求、响应,不停处理也就是长连接的特性:一直不关闭连接,不停的处理请求。
实际应用时,服务端一般是基于NIO(即同步非阻塞IO)来实现长连接,性能可以极大的提升。
6. 长短连接
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接;
长连接和短连接的概念
- 短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
- 长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
长连接和短连接区别
- 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时的,长连接效率更高。
- 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
- 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏等。