JAVAEE初阶第六节——网络编程套接字
系列文章目录
JAVAEE初阶第六节——网络编程套接字
文章目录
- 系列文章目录
- JAVAEE初阶第六节——网络编程套接字
- 一. 网络编程基础
- 1. 为什么需要网络编程
- 2. 什么是网络编程
- 3.网络编程中的基本概念
- 3.1 发送端和接收端
- 3.2 请求和响应
- 3.3 客户端和服务端
- 4. 常见的客户端服务端模型
- 5. TCP和UDP特点上的差别(初识)
- 二.Socket套接字
- 1. 概念
- 2. 分类
- 3. Socket编程注意事项
- 三.UDP数据报套接字编程
- 1.DatagramSocket API
- 2.DatagramPacket API
- 3. InetSocketAddress API
- 4. 示例一:回显服务器,客户端
- 4.1 回显服务器
- 4.2 回显客户端
- 4.3 小结
- 5. 示例二:词典服务器
- 四.TCP流套接字编程
- 1.ServerSocket API
- 2. Socket API
- 3. 示例一:回显服务器和客户端
- 3.1 回显服务器
- 3.2 回显客户端
- 3.3 解决上面服务器的问题
初识网络原理
- 网络编程基础
- Socket套接字
- UDP数据报套接字编程
- TCP流套接字编程
一. 网络编程基础
1. 为什么需要网络编程
用户在浏览器中,打开在线视频网站,如哔哩哔哩看视频,实质是通过网络,获取到网络上的一个视频资源。
与本地打开视频文件类似,只是视频文件这个资源的来源是网络。
相比本地资源来说,网络提供了更为丰富的网络资源。
所谓的网络资源,其实就是在网络中可以获取的各种数据资源。
而所有的网络资源,都是通过网络编程来进行数据传输的。
2. 什么是网络编程
网络编程,指网络上的主机,通过不同的进程,以编程的方式实现网络通信(或称为网络数据传输)。
当然,只要满足进程不同就行;所以即便是同一个主机,只要是不同进程,基于网络来传输数据,也属于网络编程。
特殊的,对于开发来说,在条件有限的情况下,一般也都是在一个主机中运行多个进程来完成网络编程。
但是网络编程目的是提供网络上不同主机,基于网络来传输数据资源:
- 进程A:编程来获取网络资源
- 进程B:编程来提供网络资源
网络编程本质上就是学习传输层给应用层提供的APL,就可以写代码,把数据交给传输层,进一步的通过层层封装就可以把数据通过网卡发送出去了,要学的不仅仅是API也是网络程序的基本工作流程。
3.网络编程中的基本概念
3.1 发送端和接收端
在一次网络数据传输时:
发送端:数据的发送方进程,称为发送端。发送端主机即网络通信中的源主机。
接收端:数据的接收方进程,称为接收端。接收端主机即网络通信中的目的主机。
收发端:发送端和接收端两端,也简称为收发端。
注意:发送端和接收端只是相对的,只是一次网络数据传输产生数据流向后的概念。
3.2 请求和响应
一般来说,获取一个网络资源,涉及到两次网络数据传输:
- 第一次:请求数据的发送
- 第二次:响应数据的发送
3.3 客户端和服务端
服务端:在常见的网络数据传输场景下,把提供服务的一方进程,称为服务端,可以提供对外服务。
客户端:获取服务的一方进程,称为客户端。
客户端给服务器发送的数据,称为"请求"(request)
服务器给客户端返回的数据,称为"响应"(response)
对于服务来说,一般是提供:
- 客户端获取服务资源
- 客户端保存资源在服务端
同一个程序在不同的场景中,可能是客户端也可能是服务器
上图中的淘宝入口服务器在不同的场景下就可以是服务器也可以是客户端
客户端和服务器之间交互,也是有很多种模式的
- “一问一答” : 一个请求对应一个响应,一对一。最常见。后续进行"网站开发",都是这种一问一答的形式。
- 一个请求对应多个响应,这个场景主要是涉及到"下载”的场景中。
- 一个请求可能对应多个响应,一个响应也可能对应多个请求,这个场景主要涉及到"远程控制/远程桌面"。
4. 常见的客户端服务端模型
最常见的场景,客户端是指给用户使用的程序,服务端是提供用户服务的程序:
- 客户端先发送请求到服务端
- 服务端根据请求数据,执行相应的业务处理
- 服务端返回响应:发送业务处理结果
- 客户端根据响应数据,展示处理结果(展示获取的资源,或提示保存资源的处理结果)
5. TCP和UDP特点上的差别(初识)
TCP的特点:
- 有连接
- 可靠传输
- 面向字节流
- 全双工
UDP的特点:
- 无连接
- 不可靠传输
- 面向数据报
- 全双工
连接。此处说的"连接"不是物理意义的连接,而是抽象,虚拟的连接。
例如:打电话是一种有连接的通信方式,一边拨号,另一边得接通了,才能说话。另一边可以选择接通或者不接通。连接首先的特点得是双方都能认同,无连接则是发微信/发短信,无论另一边是否同意,都能发过去。可靠传输/ 不可靠传输
网络上存在的"异常情况"是非常多的,无论使用什么样的软硬件的技术手段无法100%保证网络数据能够从A一定传输到。此处谈到的"可靠传输",尽可能的完成数据传输。虽然无法确保数据到达对方,至少可以知道,当前这个数据对方是不是收到了。
此处谈到的可靠传输,主要指的是发的数据到没到,发送方能够清楚的感知到。面向字节流/面向数据报
面向字节流:此处谈到的字节流和文件中的字节流完全一致—— TCP(网络中传输数据的基本单位就是字节)
面向数据报:每次传输的基本单位是一个数据报(由一系列的字节构成的)特定的结构。—— UDP全双工/半双工
全双工:一个信道,可以双向通信。
半双工:只能单向通信。
二.Socket套接字
1. 概念
Socket套接字,是由系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程
2. 分类
Socket套接字主要针对传输层协议划分为如下三类:
- 流套接字:使用传输层TCP协议
TCP,即Transmission Control Protocol(传输控制协议),传输层协议。
以下为TCP的特点(细节后续再学习)
- 有连接
- 可靠传输
- 面向字节流
- 全双工
- 有接收缓冲区,也有发送缓冲区
- 大小不限
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
2.数据报套接字:使用传输层UDP协议
UDP,即User Datagram Protocol(用户数据报协议),传输层协议。
以下为UDP的特点(细节后续再学习):
- 无连接
- 不可靠传输
- 面向数据报
- 全双工
- 有接收缓冲区,无发送缓冲区
- 大小受限:一次最多传输64k
对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一次发送,接收也必须一次接收100个字节,而不能分100次,每次接收1个字节。
3.原始套接字
原始套接字用于自定义传输层协议,用于读写内核没有处理的IP协议数据。
3. Socket编程注意事项
- 客户端和服务端:开发时,经常是基于一个主机开启两个进程作为客户端和服务端,但真实的场
景,一般都是不同主机。- 注意目的IP和目的端口号,标识了一次数据传输时要发送数据的终点主机和进程
- Socket编程我们是使用流套接字和数据报套接字,基于传输层的TCP或UDP协议,但应用层协议,也需要考虑,这块我们在后续来说明如何设计应用层协议。
- 关于端口被占用的问题
如果一个进程A已经绑定了一个端口,再启动一个进程B绑定该端口,就会报错,这种情况也叫端口被占用。对于java进程来说,端口被占用的常见报错信息如下:
此时需要检查进程B绑定的是哪个端口,再查看该端口被哪个进程占用。以下为通过端口号查进程的方式:
- 在cmd输入 netstat -ano | findstr 端口号 ,则可以显示对应进程的pid。如以下命令显示了8888进程的pid
- 在任务管理器中,通过pid查找进程
解决端口被占用的问题:
如果占用端口的进程A不需要运行,就可以关闭A后,再启动需要绑定该端口的进程B如果需要运行A进程,则可以修改进程B的绑定端口,换为其他没有使用的端口。
三.UDP数据报套接字编程
1.DatagramSocket API
DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。 负责对socket文件读写,也就是借助网卡发送接收数据。
操作系统中有一类文件,就叫做socket文件,它抽象表示了"网卡"这样的硬件设备
进行网络通信最核心的硬件设备网卡:
通过网卡发送数据,就是写socket文件。
通过网卡接收数据,就是读socket文件。
DatagramSocket 构造方法
方法签名 | 方法说明 |
---|---|
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口 (一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用 于服务端) |
DatagramSocket 方法:
方法签名 | 方法说明 |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻 塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close( ) | 关闭此数据报套接字 |
2.DatagramPacket API
DatagramPacket是UDP Socket发送和接收的数据报
UDP面向数据报,每次发送接收数据的基本单位,就是一个UDP数据报
DatagramPacket 构造方法:
DatagramPacket(byte[ ] buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的数 字节数组(第一个参数buf)中,接收指定长度(第二个 length) |
---|---|
DatagramPacket(byte[ ] buf, int offset, int length, SocketAddress address) | 构造一个DatagramPacket以用来发送数据报,发送的数 数组(第一个参数buf)中,从0到指定长度(第二个参 length)。address指定目的主机的IP和端口号 |
DatagramPacket 方法:
方法签名 | 方法说明 |
---|---|
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取 接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获 取接收端主机端口号 |
byte[ ] getData() | 获取数据报中的数 |
构造UDP发送的数据报时,需要传入 SocketAddress ,该对象可以使用 InetSocketAddress 来创
建。
3. InetSocketAddress API
InetSocketAddress ( SocketAddress 的子类 )构造方法:
方法签名 | 方法说明 |
---|---|
InetSocketAddress(InetAddress addr, int port) | 创建一个Socket地址,包含IP地址和端口号 |
4. 示例一:回显服务器,客户端
回显服务器是网络编程中,最简单的程序了。客户端发啥请求返回啥响应(没有啥业务逻辑)
- socket api的使用
- 典型的客户端服务器基本工作流程
4.1 回显服务器
- 需要先创建DatagramSocket对象,接下来要操作网卡,操作网卡都是通过socket对象来完成的。
- 这个程序一启动就需要关联上/绑定上一个操作系统中的端口号,端口号也是一个整数,用来区分一个主机上进行网络通信的程序。(一个主机上的一个端口号只能被一个进程绑定,反过来,一个进程可以绑定多个端口。)
端口号和socket对象是一一对应的如果一个进程中多个socket对象自然就能绑定上多个端口。
- 启动服务器
- 循环接收请求,对于服务器来说,需要不停的收到请求,返回响应,收到请求,返回响应…。服务器往往是7 * 24小时运行的,要持续不断的运行下去。这里的while true也没有退出的必要,如果你确实想重启服务器昨办,可以直接杀进程即可。
- 读取请求并解析
构造一个DatagramPacket类型的requestPacket对象接收请求
调用socket的receive方法来解析请求
此处receive就从网卡能读取到一个UDP数据报,就被放到了requestPacket对象中,其中UDP数据报的载荷部分就被放到requestPacket内置的字节数组中了。另外报头部分,也会被requestPacket的其他属性保存,除了UDP报头之外,还有其他信息,比如通过requestPacket还能知道数据从哪里来的(源ip源端口)。
如果执行到receive的时候,此时还没有客户端发来请求呢?receivel咋办,读啥?这时候receive就会先阻塞等待。
-
读到的字节数组,转成String方便后续的逻辑处理
-
根据请求计算响应(对于回显服务器来说,这一步啥都不用做)
这个代码,要根据请求构造响应,通过process方法来完成这个工作。
由于此处是回显服务器所以就只是单纯的return,如果是一些具有特定业务的服务器,process就可以写其他任何想要的逻辑了.
- 把响应返回到客户端
构造一个DatagramPacket作为响应对象,构造这个对象的时候,还需要指定一个socketAddress进去(getSocketAddress).这样就能得到INetAddress对象,这个对象就包含了ip和端口号和服务器通信的对端(对应的客户端)的IP和端口。(把请求中的源IP,源端口,作为响应的目的IP和目的端口。此时就可以做到把消息返回给客户端这样的效果了)
调用socket的send方法来发送响应,send也是需要有一个DatagramPacket作为参数的。
- 打印日志
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
//创建对象的时候手动指定一个端口号
//程序一启动就需要关联上/绑定上一个操作系统中的端口号
socket = new DatagramSocket(port);
}
//服务器的启动逻辑
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
//每次循环,就是处理一个请求-响应过程,
//1.读取请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//读到的字节数组,转成String方便后续的逻辑处理
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
//2,根据请求计算响应(对于回显服务器来说,这一步啥都不用做)
String response = process(request);
//3,把响应返回到客户端
//构造一个DatagramPacket作为响应对象
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
//打印日志
System.out.printf("[%s:%d]req:%s,resp:%s\n",requestPacket.getAddress().toString(),
requestPacket.getPort(),request,response);
}
}
public String process(String request) {
return request;
}
main方法:
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090); //可以指定1024 - 65535
//(但是也有前提,确保你当前这个端口在你的机器上没有被别的进程占用)
server.start();
}
上述代码中,可以看到,UDP是无连接的通信,UDP socket自身不保存对端的IP和端口。而是在每个数据报中有一个另外代码中也没有"建立连接""接受连接"操作。
不可靠传输:代码中体现不到的。
面向数据报:send和receive都是以DatagramPacket为单位。
全双工:一个socket既可以发送又可以接收。
4.2 回显客户端
- 首先也是要创建socket对象。但是此处不需要手动指定端口号。
服务器这边,要手动指定端口。客户端这边,一般不要手动指定。不手动指定,也有端口,系统自动分配一个空闲的端口号。( 代码中手动指定端口号,才能保证端口是始终固定的,如果不手动指定,依赖系统自动分配,导致服务器每次重启之后,端口号可能就变了,一旦变了,客户端可能就找不到这个服务器在哪里了)
客户端来说,端口号就让系统随机分配。系统分配一个空闲的。主要就是无法确保手动指定的端口,是可用的(可能被别的进程占用了)
- 使用构造方法指定好服务器的IP(目的IP)和服务器的端口(目的端口)
客户端需要主动给服务器发起请求。发起请求的前提就是知道服务器在哪。请求的源IP就是客户端的本机IP,源端口就是系统分配的空闲端口。
- 启动客户端
- 循环处理请求。
( 1 )控制台读取要发送的请求数据
从控制台读取,使用scanner读取字符串,最好使用next而不是nextLine(如果从文件读取就无所谓)
如果使用nextLine读取,需要手动输入换行符(enter)来进行控制,由于enter键不仅仅会产生\n还会产生其他字符,就会导致读取到的内容就容易出问题。使用next其实是以"空白符"作为分隔符,包括不限于换行,回车,空格,制表符,垂直制表符…
(2)构造请求并发送
(3)读取服务器的响应
(4)把响应显示到控制台上
4.3 小结
整个过程中,首先一定是服务器先启动
- 服务器启动。启动之后,立即进入while 循环,执行到receive,进入阻塞。此时没有任何客户端发来请求。
- 客户端启动。启动之后,立即进入 while 循环,执行到 hasNext 这里,进入阻塞。此时用户没有在控制台输入任何内容。
- 用户在客户端的控制台中输入字符串,按下回车。此时 hasNext 阻塞解除,next 会返回刚才输入的内容。
基于用户输入的内容,构造出一个 DatagramPacket 对象,并进行 send 。send 执行完毕之后,继续执行到receive 操作,等待服务器返回的响应数据(此时服务器还没返回响应呢,这里也会阻塞) - 服务器收到请求之后,就会从 receive 的阻塞中返回。返回之后,就会根据读到的 DatagramPacket 对象,构造 String request,通过process 方法构造一个 String response。再根据response 构造一个 DatagramPacket 表示响应对象,再通过 send 来进行发送给客户端。
`执行这个过程中,客户端也始终在阻塞等待 - 客户端从receive 中返回执行。就能够得到服务器返回的响应。
5. 示例二:词典服务器
start方法完全从父类这里继承下来即可。
process方法要进行重写,加入翻译的业务逻辑。
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
//此次ip使用的字符串,使用的是点分十进制的风格。“192.168.2.100”
public UdpEchoClient(String serverIp,int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while (true) {
//要做四件事
System.out.print("-> ");//提示用户输入下面的内容
//1.从控制台读取要发送的请求数据
if(!scanner.hasNext()) break;//判断是否读取结束并退出循环
String request = scanner.next();//用request(String)接收请求
//2.构造请求并发送
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(serverIp),serverPort);//字符串类型的点分十进制IP要进行转换
socket.send(requestPacket);
//3.读取服务器的响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
//4.把响应显示到控制台上
String response = new String(responsePacket.getData(),0,responsePacket.getLength());
System.out.println(response);
}
}
四.TCP流套接字编程
1.ServerSocket API
ServerSocket 是创建TCP服务端Socket的API。
ServerSocket 构造方法:
方法签名 | 方法说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
ServerSocket 方法:
方法签 名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket 对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
2. Socket API
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。
不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。
Socket 构造方法:
方法签名 | 方法说明 |
---|---|
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的 进程建立连接 |
Socket 方法:
方法签名 | 方法说明 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
3. 示例一:回显服务器和客户端
3.1 回显服务器
TCP是面相字节流的。传输的基本单位是字节
- 创建一个服务端ServerSocket,用于收发TCP报文
然后不停的等待客户端连接,并通过accept方法建立起于客户端的连接。然后才能开始处理请求
当应用程序代码中调用对应的api和服务器尝试建立连接,内核就会发起与服务器建立连接的流程。
此时服务器的内核就会配合客户端这边的工作,来完成连接的建立。这个连接建立的过程,就相当于电话这边在拨号,另外一边就在响铃。但是需要等到用户再次点击了确认接听,才能进行后续通信,(内核建立的连接不是决定性的。还需要用户程序,把这个连接进行"接听"/accept操作然后才能进行后续的通信,后续讲TCP网络原理的“三次挥手”再详细讲解)
accept也是一个可能会产生阻塞的操作。如果当前没有客户端连过来,此时accept就会阻塞有一个客户端连过来了,accept一次就能返回一次。有若干个客户端连过来了,accept就需要执行多次。
- 创建一个Socket类型的clientSocket来接受accept的返回结果,并将clientSocket传入处理请求的函数。
此时的serverSocket和clientSocket,它们的职责有所不同。其中serverSocket的作用只是类似于餐厅的服务员,将客户端“请进”服务器后,它就会继续“请”下一个客户端。而clientSocket就类似餐厅内的服务员,针对客户端的请求进行具体的处理。
- 处理请求
- 先打印客户端上线的信息(包括客户端的IP和端口号)
- 使用try with resources的try来对请求进行读取和写入
TCP是面相字节流的。这里的字节流和文件中的字节流完全一致的。使用和文件操作一样的类和方法完成针对tcp socket的读写。
循环地读取客户端的请求并返回响应
(1)使用scanner来读取请求,当请求读取完毕后,客户端断开连接,就会显示读取完毕 。
此处进行读操作完全可以通过read来完成,read是把收到的数据放到byte数组中了。后续根据请求处理响应还需要把这个byte数组转成String。所以使用Scanner就能更简单地实现。
(2)读取请求并解析.
这里注意隐藏的约定,next读的时候要读到空白符才会结束.(用字符串接收请求)因此就要求客户端发来的请求必须带有空白符结尾:比如\n或者空格。相当于此时约定,每个请求都是以空白符作为分隔的,如果你发的数据包含空格,就需要通过多次请求来完成发送.
(3)根据请求计算响应(用字符串接收经过process处理后的响应)
(4)把响应返回给客户端,
使用PrintWriter将输出流包装一下方便更容易进行写入。这里的操作也相当于是把字节流转成字符流。
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true){
//通过accept方法来“接听电话",然后才能进行通信
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
//通过这个方法来处理一次连接。连接建立的过程中就会涉及到多次的请求响应交互。
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d] 客户端上线",clientSocket.getInetAddress(),clientSocket.getPort());
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream =clientSocket.getOutputStream()) {
while(true){ // 循环的读取客户端的请求并返回响应
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()){//读取完毕,客户端断开连接,就会显示读取完毕
System.out.printf("[%s:%d] 客户端下线",clientSocket.getInetAddress(),clientSocket.getPort());
break; }
//1,读取请求并解析.这里注意隐藏的约定,next读的时候要读到空白符才会结束.
//因此就要求客户端发来的请求必须带有空白符结尾:比如\n或者空格。
String request = scanner.next();//用字符串接收请求
//2,根据请求计算响应
String response = process(request);//用字符串接收经过process处理后的响应
//3,把响应返回给客户端
//通过这种方式可以写回,但是这种二进制写的方式不方便给返回的响应中添加
//outputstream.write(response.getBytes(),0,response.getByte().length);
//也可以给outputstream套上一层,完成更方便的写入
PrintWriter printWriter = new PrintWriter(outputStream);//将输出流包装一下方便更容易进行写入
printWriter.println(response);//会在末尾自动加换行(\n)
System.out.printf("[%s:%d]req:%s,resp:%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 server = new TcpEchoServer(9090);
server.start();
}
上述代码把TCP回显服务器基本写完了。之所以是"基本",当前代码中还存在两个比较严重的问题。
3.2 回显客户端
- 首先也是要创建socket对象。但是此处不需要手动指定端口号
- 使用构造方法将服务器的IP(目的IP)和服务器的端口(目的端口)直接传进socket中。
由于tcp是有连接的。因此socket里面就会保存好这俩信息。 因此此处TcpEchoC1ient类就不必保存.
- 启动客户端
- 使用try with resources的try来对请求进行读取和写入。
- 循环处理请求。
( 1 )从控制台读取输入的字符串
从控制台读取,使用scanner读取字符串,最好使用next而不是nextLine(如果从文件读取就无所谓)
如果使用nextLine读取,需要手动输入换行符(enter)来进行控制,由于enter键不仅仅会产生\n还会产生其他字符,就会导致读取到的内容就容易出问题。使用next其实是以"空白符"作为分隔符,包括不限于换行,回车,空格,制表符,垂直制表符…
(2)构造请求并把请求发给服务器,这里需要使用printIn来发送。为了让发送的请求末尾带有\n
(3)读取服务器的响应,这里也是和服务器返回响应的逻辑对应。
(4)把响应显示到控制台上
3.3 解决上面服务器的问题
- 没有进行flash
当回显服务器和客户端都启动后,在客户端输入请求后,服务器没有返回1,。
仔细分析上述代码,就能大溉猜到问题所在,出现上述情况,要么是客户端请求没有正确发送出去,要么服务器的响应没有正确返回回来。由于服务器收到请求之后会打印日志。所以应该是客户端的请求没有发送出去。
之所以出现上述的情况,本质原因在于PrintWriter内置的缓冲区在作崇。
IO操作都是比较低效的操作,于是就希望能够让低效操作,进行的尽量少一些。引入缓冲区(内存先把要写入网卡的数据放到内存缓冲区中,等到数量够多后一波之后再统一进行发送。
(把多次IO合并成一次了)但是也有个问题,如果发送的数据很少。此时由于缓冲区还没满,数据就待在缓冲区里,没有被真正发送出去。
缓冲区,对于程序的影响其实是很大的,此时手动刷新缓冲区。使用flush方法,起到冲刷缓冲区的效果。此时在服务器和客户端发送数据后都加上flush就可以了
服务器的改动(其他不变)
printWriter.println(response);//会在末尾自动加换行(\n)
//通过这个f1ush主动刷新缓冲区,确保数据真的发出去了
printWriter.flush();
客户端的改动(其他不变)
writer.println(request);
//通过这个f1ush主动刷新缓冲区,确保数据真的发出去了
writer.flush();
2. 没有进行close
并且当交互结束后需要针对clientSocket进行close操作。
finally {
try {
clientSocket.close();//需要针对clientSocket进行close操作。
} catch (IOException e) {
throw new RuntimeException(e);
}
}
这个代码中,需要针对clientSocket进行close操作,而serverSocket不需要。因为serverSocket整个程序只有唯一的一个对象。并且这个对象的生命周期很长是要跟随整个程序的。这个对象无法提前关闭。只要程序退出,随着进程的销毁一起被释放即可。(不需要手动进行)
调用socket.close本质上也是关闭文件,释放文件描述符表。这里进程销毁,文件描述符表当然就没了。因此写UDP的程序都没有close.
但是tcp的client socket是每个客户端都有一个。随着客户端越来越多,这里消耗的socket也会越来越多(如果不加释敬,就很可能把文件描述符表给占满)
这次连接处理完毕(processConnection结束),就可以close clientSocket.
释放了socket对象,即使上述流对象不释放,也问题不大。这俩流对象内部不持有文件描述符,只是持有一些内存结构。内存结构可以被gc释放。但是只是释放流对象,不释放socket,就不行了。socket持有了文件描述符,本质还是要释放文件描述符资源。
这里只是关闭了流对象,而没有释放文件本题,这俩流对象都是socket对象给我们创建出来的。
- 多个客户端不能同时访问服务器
同时开启两个客户端,但是第二个客户端在输入后没有得到服务器的响应
并且服务器只提示了有一个客户端上线
分析原因:
第一个客户端连上服务器之后,服务器就会从accept这里返回。(解除阻塞)进入到processConnection中了。
接下来就会再scanner.hasNext这里阻塞,等待客户端的请求,客户端请求到达之后,scanner.hasNext返回,继续执行,读取请求,根据请求计算响应,返回响应给客户端。…执行完上述一轮操作之后,循环回来继续再hasNext阻塞,等待下一个请求。直到客户端退出之后,连接结束,此时循环才会退出。这样就会导致服务器代码在循环中处理请求的时候,无法再次执行到serverSocket的accept。这样就会导致虽然第二个客户端和服务器在内核层面上建立了tcp连接了,但是应用程序这里无法把连接拿到应用程序里处理。(人家给你打电话,你手机一直在响,但是你就是没接)
如果第一个客户端退出了,第二个客户端之前发的请求之所以立即被处理而没被丢掉,是因为当前TCP在内核中,每个socket都是有缓冲区的客户端发送的数据确实是发了,服务器也收到了,只不过数据是在服务器的接收缓冲区中,一旦第一个客户端退出了, 回到第一层循环,继续执行第二次accept,继续执行next就能把之前缓冲区的内容给读出来。
解决方法就是使用多线程,单个线程,无法既能给客户端循环提供服务,有能去快速调用到第二次accept。简单的办法就是引入多线程主线程就负责执行accept.每次有一个客户端连上来,就分配一个新的线程,由新的线程负责给客户端提供服务。
public void start() throws IOException {
System.out.println("服务器启动!");
while (true){
//通过accept方法来“接听电话",然后才能进行通信
Socket clientSocket = serverSocket.accept();
Thread thread = new Thread(()->{
processConnection(clientSocket);
});
thread.start();
}
}
此时,把processConnection操作交给新的线程来负责了。主循环就会快速的执行完一次之后,回到accept这里阻塞等待新的客户端到来。
上述问题,不是TCP引起的,而是咱们代码没写好,两层循环嵌套引起的,UDP服务器,只有一层循环,就不涉及到这样的问题。之前UDP服务器天然就可以处理多个客户端的请求。
- 服务器频繁创建消耗线程
每次来一个客户端,就会创建一个新的线程。每次这个客户端结束,就要销毁这个线程。如果客户端比较多,就会使服务器频繁创建销毁线程。这时就可以使用线程池来解决这个问题
public void start() throws IOException {//多线程(线程池)
System.out.println("服务器启动!");
ExecutorService pool = Executors.newCachedThreadPool();
while (true){
//通过accept方法来“接听电话",然后才能进行通信
Socket clientSocket = serverSocket.accept();
pool.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
线程池,解决的是频繁创建销毁的问题,如果,当前的场景是线程频繁创建,但是不销毁呢。每个客户端如果处理过程很短(网站),这种情况是没有问题的。如果每个客户端处理过程都很长呢(游戏服务器)这时继续使用线程池/多线程,此时就会导致当前的服务器上一下积累了大量的线程,此时对于服务器的负担会非常重。
- 积累线程过多
解决方法:
- 协程:轻量级线程。本质上还是一个线程,用户态可以通过手动调度(省去系统调度的开销了)的方式让这一个线程"并发"的做多个任务。
- IO多路复用:系统内核级别的机制,本质上是让一个线程同时去负责处理多个socket.本质在于这些socket数据并非是同一时刻都需要处理。
IO多路复用的关键在于,虽然有多个socket但是同一时刻活跃的socket,只是少数需要读写数据的socket,大部分socket都是在等。使用一个线程来等多个socket。
IO多路复用属于操作系统提供的机制,也有对应的API。Java中对上述AP!也进行封装了。