最近记录了一些java中常踩的坑、设计思路和小知识点,大家可以看看
详细记录一次接入xxl-job的踩坑路径
30s快速解决循环依赖
idea中一个小小的操作竟能解决如此多的问题
docker中的服务接入xxljob需要注意的一点
关于一次fullgc的告警分析
mysql中的int类型竟变成了它?
jpa中的字段总是自己莫名更新?
获取不到类上的注解?空指针?
学会这招,再也不怕依赖冲突!
redis的热点key还能这么处理?
领导让我设计一个任务系统
当服务重启时,大部分人没考虑这点
参数还能这么优雅校验?
文件上传报错,全局异常处理!
常见的点赞功能如何实现,如何防止刷赞
TCP通信原理-Socket套接字编程实现一个简单的客服聊天功能
TCP和UDP
在https的传输过程中,有TCP的三次握手,而且服务器之间的每一次交流,凡是从应用层或者从传输层开始的请求,都会经过传输层(经过上层必定经过下层,经过下层可以不经过上层),就必然会用到TCP或者UDP协议。
而TCP和UDP就是基于Socket概念上拓展出的传输协议
区别就是
TCP是基于连接的,可靠的,有序的,重量级的,适合重要文件等
UDP是无连接的,不可靠的,无序的,轻量级的,允许丢包,适合视频、网络电话等
Socket含义
戛然TCP和UDP都是基于Socket这个概念延伸的协议,那么Socket是什么?
它其实是一个抽象概念,代表网络通信中的一个对象。
而网络中往往一个对象的确认锁定需要两个元素–IP和Port(端口)。
所以从代码角度讲,就是一个主要由ip地址和端口组成的通信对象
Socket也有两种类型,基于流的Stream Socket,对应TCP,基于报文的Datagram Socket,对应UDP。
基于TCP的Socket客服聊天室实例
我们以TCP协议的Socket为例,做一个简单聊天室功能,有了这个例子,会对TCP通信过程有一个实际的概念。
代码的逻辑流程我尽可能详细的写在注释中,感兴趣的朋友,最好实际运行操作一下,对功能做一些拓展,会加深理解。
源码及其注释
服务端如下
package com.test.sf.socket;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 服务端
*/
public class SocketServerDemo {
public static void main(String[] args) {
Socket socket = null;
ServerSocket serverSocket = null;
// 来自客户端的输入流
BufferedReader inFromClient = null;
// 发向客户端的输出流
PrintWriter outForClient = null;
try {
// 1.0 服务端监听本地8080端口
// 如果是本地多网卡的情况,最好手动指定一个ip,不然会随机取一个ip绑定
serverSocket = new ServerSocket(8080);
System.out.println("客服系统运行中...");
// 2.0 服务端开始接受消息,没客户端连接之前一直阻塞在这里
socket = serverSocket.accept();
// 3.0 代码到这里,说明有客户端建立了连接,也就是TCP三次握手成功了,二者可以正常通信
// 从socket获取输入流,用来接受客户端输入的信息
inFromClient = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 从socket获取输出流,用来向客户端输出信息
outForClient = new PrintWriter(socket.getOutputStream(), true);
// 4.0 打印客户端输入的第一条信息,当然如果客户端没输入任何消息,服务端就会一直阻塞在这里
System.out.println(inFromClient.readLine());
// 收到客户端发来的第一条消息(客户端1号建立连接)后,向客户端输出一条自动回复消息
outForClient.println("智能客服:您好!我是智能客服小V,有什么需要帮助的么");
// 继续监听等待客户端传来的输入流
String readline = inFromClient.readLine();
// 5.0 只要客户不输入'bye',服务端就一直循环接受并处理客户端的输入流
while (!"bye".equals(readline)) {
// 模拟服务端的处理逻辑
if (readline.contains("咨询")) {
outForClient.println("智能客服:需要咨询请拨打我们的热线12345");
} else if (readline.contains("客服")) {
outForClient.println("智能客服:我们请不起人工客服");
} else {
outForClient.println("智能客服:你的输入不合法,请重新输入");
}
readline = inFromClient.readLine();
}
// 6.0 跳出了循环,说明客户端输入了bye,服务端向客户端告别
outForClient.println("智能客服:再见");
System.out.println("客服系统关闭!");
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
// 关闭资源
outForClient.close();
inFromClient.close();
serverSocket.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端如下:
package com.test.sf.socket;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
/**
* 客户端
*/
public class SocketClientDemo {
public static void main(String[] args) {
Socket socket = null;
// 来自控制台的输入流
BufferedReader inFromControler = new BufferedReader(new InputStreamReader(System.in));
// 来自服务器的输入流
BufferedReader inFromServer = null;
// 发向服务器的输出流
PrintWriter outForServer = null;
try {
// 客户端请求ip地址为127.0.0.1地址,端口为8080的应用,也就是服务端,如果服务端没启动,执行这行代码会直接报错,connect refuse
socket = new Socket("127.0.0.1", 8080);
inFromServer = new BufferedReader(new InputStreamReader(socket.getInputStream()));
outForServer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
// 执行到这里说明已经成功连接了服务器,通知服务器,此客户端已经连接
// 结合服务器的逻辑,如果这行代码没执行,服务器会一直在readline那里阻塞
outForServer.println("客户1号连接!");
// 接收到服务器传来的欢迎消息,同理服务器没发送消息的话也会一直阻塞
System.out.println(inFromServer.readLine());
// 准备向服务器发送数据,数据来自控制台
String readline = inFromControler.readLine();
while (!"bye".equals(readline)) {
// 只要客户端输入的不是'bye',就循环向服务端发送
outForServer.println(readline);
// 输出服务端发回的消息
System.out.println(inFromServer.readLine());
// 继续等待客户端的输入
readline = inFromControler.readLine();
}
// 将bye发送给服务器
outForServer.println(readline);
// 打印出服务端的结束语
System.out.println(inFromServer.readLine());
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
outForServer.close();
inFromServer.close();
inFromControler.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
执行效果图如下:
代码关键现象分析
- 注意双方通信的几个阻塞点,socket建立连接的时候会阻塞,通信时也会有IO阻塞,如果把代码换一换顺序,很容易进入死锁一样的状态,互相阻塞,至于为什么,下面分析
- 上面只是单线程的,也就是说再加一个客户端也没用,可以结合多线程进行思考
- 注意构建PringWrite输出流的时候,传了一个为true的参数,代表自动刷新缓存,否则还需要手动的out.flush().
Socket通信模型
对于TCP协议
建立连接=三次握手
开始通信=IO操作
结束通信=四次挥手
socket和IO阻塞
在实现上面的聊天室的时候,发现socketServer.accept()会阻塞。
我们用上面的聊天室代码片段、上面的结构图来做结合分析。
假设客户端对应发送端,服务端对应接收端。
- 服务端启动,等待客户端连接,发送数据,此时线程会阻塞
Socket socketServer = serverSocket.accept();
- 客户端连接服务端,服务端停止阻塞,双方的连接在此时已经建立,可以从socket中获取相关的信息。线程不会阻塞。
Socket socketClient = new Socket("127.0.0.1", 8080);
- 客户端发送消息给服务端
为了使流程更清晰,我们将原本的outForServer对象构建时不做自动刷新缓存的操作
outForServer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()));
// "客户1号连接“这句话从客户端的用户层传到传输层,可以理解为执行了上图的send()方法
outForServer.println("客户1号连接!");
// 刷新客户端缓存区,将上述消息刷出去,也就是传出去,线程不会阻塞
outForServer.flush();
- 服务端接收到从客户端缓存区发来的消息,把消息存在服务端缓存区。
// 把数据从服务端缓存区读到服务端应用层,可以理解为执行了上图的recv()方法
// 在缓存区为空的时候,这个方法一直会使线程陷入阻塞,直到服务端缓存区有数据进来
String readline = inFromClient.readLine();
当然,如果接收端一直不读取缓存区的数据,数据会按顺序越来越多,直到占满缓存区,此时接收端会通知发送端,我已经塞不下了,请不要发了。
如果发送端继续发或者说还有一批数据在路上,接收端收到这些数据的时候,发现放不下,会丢弃数据,来保证可靠性
TCP滑动窗口
首先,看一下下面的动画实例
在线操作滑动窗口
看完以后,其实就隐隐明白TCP滑动窗口是什么了
预防场景
A和B通信,B处理数据能力有限,A发送的数据太快太多,超过B的处理能力,发生拥塞,所以需要一种机制,来控制发送的速度和大小。
作用
流量控制和拥塞控制
实现原理
这里回忆三次握手和四次挥手中的seq=x,ack=y这种参数
A向B发送报文时,在请求头中发送一个seq=x,表示自己发送包的首位序列号
B收到报文且接受完以后,给A发送一个回复,ACK=1,表示收到报文了,seq=x+m,表示B希望下次收到的数据包的首位序列号,ack=y表示B相应包的首位序列号。同时窗口向右滑动。
A收到B的报文后,明白B已经收到了,向右滑动m,此时一次传输已经完成
如果A再向B传输的话,会发送一个首位序列号为seq=x+m的包
在这个过程中
- 通过发送的确认来保证传输的速度
- 通过首位序列号来保证传输的大小
从而达到流量控制的目的
功能改进-增加客户端
我们之前只有一个客户端一个服务端,现在稍作改造,增加一个客户端,向实际场景再迈进一小步
Server端改造,主要有三点:
- 把方法拆分,从面向过程走向面向对象
- 之前输入bye,客户端和服务端就都关闭了,现在做改造,输入bye只关闭客户端,以便下一个客户端使用服务端
- 客户端关闭,服务端与之对应的socket也需要关闭,重新监听客户端连接请求
public class SocketServerDemo {
private Socket socket = null;
private ServerSocket serverSocket = null;
// 来自客户端的输入流
private BufferedReader inFromClient = null;
// 发向客户端的输出流
private PrintWriter outForClient = null;
public static void main(String[] args) {
SocketServerDemo socketServerDemo = new SocketServerDemo();
socketServerDemo.startServer();
}
SocketServerDemo(){
// 1.0 服务端监听本地8080端口
// 如果是本地多网卡的情况,最好手动指定一个ip,不然会随机取一个ip绑定
try {
serverSocket = new ServerSocket(8080);
System.out.println("客服系统运行中...");
} catch (IOException e) {
e.printStackTrace();
}
}
public void startServer(){
try {
waitClient();
listenClient();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
// 关闭资源
outForClient.close();
inFromClient.close();
serverSocket.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public void waitClient() throws IOException {
// 2.0 服务端开始接受消息,没客户端连接之前一直阻塞在这里
socket = serverSocket.accept();
// 3.0 代码到这里,说明有客户端建立了连接,也就是TCP三次握手成功了,二者可以正常通信
// 从socket获取输入流,用来接受客户端输入的信息
inFromClient = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 从socket获取输出流,用来向客户端输出信息
outForClient = new PrintWriter(socket.getOutputStream(), true);
// 4.0 打印客户端输入的第一条信息,当然如果客户端没输入任何消息,服务端就会一直阻塞在这里
System.out.println(inFromClient.readLine());
// 收到客户端发来的第一条消息(客户端1号建立连接)后,向客户端输出一条自动回复消息
outForClient.println("智能客服:您好!我是智能客服小V,有什么需要帮助的么");
}
public void listenClient() throws IOException {
// 监听等待客户端传来的输入流
String readline = inFromClient.readLine();
// 只要客户不输入'close',服务端就一直循环接受并处理客户端的输入流
while (!"close".equals(readline)) {
// 模拟服务端的处理逻辑
if (readline.contains("咨询")) {
outForClient.println("智能客服:需要咨询请拨打我们的热线12345");
} else if (readline.contains("客服")) {
outForClient.println("智能客服:我们请不起人工客服");
} else if (readline.contains("bye")) {
outForClient.println("再见!!");
socket.close();
this.waitClient();
} else {
outForClient.println("智能客服:你的输入不合法,请重新输入");
}
readline = inFromClient.readLine();
}
// 跳出了循环,说明客户端输入了close,服务端关闭
outForClient.println("智能客服系统关闭");
System.out.println("客服系统关闭!");
}
}
Client端改造
- 拆分方法
- 增加一个close命令用来关闭服务端,代替原来的bye。原来的bye变成了关闭socket的命令
- 再复制粘贴增加另一个客户端2号
public class SocketClientDemo1 {
private Socket socket = null;
// 来自控制台的输入流
private BufferedReader inFromControler = new BufferedReader(new InputStreamReader(System.in));
// 来自服务器的输入流
private BufferedReader inFromServer = null;
// 发向服务器的输出流
private PrintWriter outForServer = null;
public static void main(String[] args) {
SocketClientDemo1 socketClientDemo = new SocketClientDemo1("127.0.0.1", 8080);
socketClientDemo.StartClient();
}
public void StartClient() {
try {
connectServer();
chatWithServer();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
outForServer.close();
inFromServer.close();
inFromControler.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
SocketClientDemo1(String host, int port) {
// 客户端请求ip地址为host的地址,端口为port的应用,也就是服务端,如果服务端没启动,执行这行代码会直接报错,connect refuse
try {
socket = new Socket(host, port);
} catch (IOException e) {
e.printStackTrace();
}
}
public void connectServer() throws IOException {
inFromServer = new BufferedReader(new InputStreamReader(socket.getInputStream()));
outForServer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
// 执行到这里说明已经成功连接了服务器,通知服务器,此客户端已经连接
// 结合服务器的逻辑,如果这行代码没执行,服务器会一直在readline那里阻塞
outForServer.println("客户1号连接!");
// 接收到服务器传来的欢迎消息,同理服务器没发送消息的话也会一直阻塞
System.out.println(inFromServer.readLine());
}
public void chatWithServer() throws IOException {
// 准备向服务器发送数据,数据来自控制台
String readline = inFromControler.readLine();
while (!"bye".equals(readline) && !"close".equals(readline)) {
// 只要客户端输入的不是'bye',就循环向服务端发送
outForServer.println(readline);
// 输出服务端发回的消息
System.out.println(inFromServer.readLine());
// 继续等待客户端的输入
readline = inFromControler.readLine();
}
// 将bye发送给服务器
outForServer.println(readline);
// 打印出服务端的结束语
System.out.println(inFromServer.readLine());
}
}
运行效果如下:
可以看到,分为以下几步
- 服务端启动,等待客户端连接
- 客户端1启动,与服务短连接成功,开始通信。
- 客户端2启动,陷入等待
- 客户端断开连接,服务端关闭套接字,重新调用accept()
- 客户端2连接成功
- 客户端2断开连接,服务端断开连接
在这个过程中,只能有一个客户端访问服务端,其他客户端陷入阻塞,这种一次只能处理一个TCP请求的叫做迭代服务器
功能改进-增加服务端
针对上述问题,既然一个服务端一次只能服务一个客户端,那再来一个客户端请求只能阻塞,显然不太合适。
那就多创建几个服务端嘛
然而直接copy一个服务端,启动两台服务端,发现报错了
地址已经被使用,其实是端口被占用了
我们知道,某种程度上socket=ip+port,二者都一样的话会冲突
显然一个socket只能有一个客户端和一个服务端做连接,所以直接增加服务端也是不现实的
如果我们更改每个服务端的端口,然后结合线程池,是不是可以达到一些我们预想的效果呢?
我们后文分析
javaIO模型-Socket实现一个简单的客服聊天功能的改造(二)