入职10个月零零散散写了很多TCP以及Socket的笔记太乱了,自己也懒得看,不过现在已经熟练掌握了开发的基础,正好在这里整理一份当作纪念
因为是在券商做即时通讯开发,大学的时候又没做过,最开始就是要学习Socket编程,但是学习Socket之前肯定要学习TCP,学习TCP之前肯定就得学习TCP/IP协议族,所以最开始就从TCP/IP网络协议族开始介绍!
第一章 : TCP/IP网络协议簇
对于开发人员,我们其实了解网络协议四层模型就好了
其中最上层是应用层, 负责处理特定的应用程序细节,以HTTP,Telnet协议为主要协议
之后是传输层,主要负责两台主机应用程序端到端(端口)的通信,以TCP,UDP为常用的协议,传输层包装的数据主要就是元端口号,目的端口号等相关数据,其中UDP连接无连接什么也不提供,而TCP连接则是保障数据可到到达也是我们后续最主要介绍的协议.TCP是面向流的,UDP是面向数据报的
再下一层是网络层,主要负责主机到主机,数据是如何传输过去的,比如选路等等,主要协议为IP协议,主要通过路由表完成路径的选择,网络层包装的数据主要是源IP,目的IP,长度,TTL之类的内容
最底层是链路层,主要负责物理接口等硬件细节,包括IP地址与MAC地址的转换(ARP协议),链路层对于数据帧有长度限制MTU,如果IP的数据包过大需要分包传递,TCP是MSS基本就是MTU的大小
在应用协议传输数据时,数据从应用程序层开始层层都需要添加首部信息(应用程序需要加入--我拿到这个数据之后干什么)最终才可以在物理硬件上完成数据的传输
之后以一次完整的上网流程为例,介绍各个协议层如何工作
当你在浏览器键入 pornhub.com 的时候,TCP/IP 协议族发生了什么? 🧐🌐
浏览器第一反应:向 DNS 服务器请求把域名翻译成 IP 地址,比如把 `pornhub.com` 翻译成 `192.168.2.20`。
GET / HTTP/1.1 我们生成应用层报文之后,我们会把他和TCP首部封装起来生成TCP报文
之后TCP就会建立三次握手, 开始传递数据
之后IP协议会把源IP地址, 目标IP地址以及TTL等内容和TCP报文封装起来, 形成IP数据包, 交给链路层
之后的传递过程中, 获得数据包的机器会先检查IP是否在同一个子网[路由表], 如果在就直接转发, 如果不在就转发给网关
如何转发? 先通过IP地址和ARP协议获得MAC地址,再进行转发
ARP协议? 机器进行ARP请求携带IP广播, 之后对应IP的机器会响应, 传递自己的MAC地址
确定了MAC地址之后, 链路层会生成以太网帧, 封装IP数据包和源MAC地址, 目标MAC地址
路由表是路由器上的一个数据结构,存储了如何转发数据包的规则,内容包括:目标网络,下一跳地址,接口
假设路由器 1 的路由表如下:
| 目标网络 子网掩码 下一跳地址
| 192.168.1.0/24 | 255.255.255.0 | 直接连接 - LAN 接口
| 192.168.2.0/24 | 255.255.255.0 | 10.0.0.2 - WAN 接口
| 0.0.0.0/0 | 0.0.0.0 | 默认网关 - WAN 接口
之后会重新封装数据帧, 目标 MAC 地址:192.168.2.0 的 MAC 地址。源 MAC 地址:路由器 1 的 MAC 地址。
IP 数据包:保持不变,仍然是从 `192.168.1.10` 到 `192.168.2.20`。
第二章 : TCP协议
最主要的就是TCP协议的建立,断开以及消息可靠性的保证策略
首先介绍一下三次握手的过程中TCP服务器都在干什么 listen(backlog)
此处正好涉及到TCP连接过程中的优化策略
TCP 三次握手中,客户端和服务端会等待对方的响应,如果迟迟没有收到,会进行重试
net.ipv4.tcp_syn_retries = 6 net.ipv4.tcp_synack_retries = 5
如果是内网通信,网络稳定,可以调小这些值,快速暴露问题!
如果网络环境差,可以调大值,提高连接成功率!
配置服务端的半连接队列大小和处理策略,用于管理三次握手未完成的连接(`SYN_RECV` 状态)。
# 半连接队列的最大长度,默认为 1024
net.ipv4.tcp_max_syn_backlog = 1024
# 是否启用 SYN cookies,避免半连接队列溢出时丢弃连接
net.ipv4.tcp_syncookies = 1
当半连接队列满时,通过计算 SYN cookie 保留连接,不再依赖半连接队列。
控制服务端 `ACCEPT` 队列的大小以及队列溢出后的行为。
net.core.somaxconn = 128
# 当 accept 队列溢出时,是否直接向客户端发送 RST 包
net.ipv4.tcp_abort_on_overflow = 1
net.ipv4.tcp_fastopen 第一次建立连接时,TCP Fast Open(TFO)仍然经历三次握手
客户端在 SYN 包中请求 TFO Cookie。
服务器返回 SYN-ACK 包并附带 TFO Cookie。
客户端发送 ACK 完成建立 之后的请求 客户端携带 Cookie 并发送数据,服务器验证后直接开始数据传输。
反正上面的东西我是没用过,现在还不让写线上的TCP模块,不过看起来还是挺高大上的
之后介绍一下TCP关闭连接的地方吧
TCP关闭连接的时候需要注意有一个TIME_WAIT时间段 时间需要大于 2TTL 为了避免本次的数据传递影响下一次
net.ipv4.tcp_orphan_retries = 0
tcp_orphan_retries 控制在 TCP 连接关闭过程中,如果收不到对方返回的 ACK,内核会重发 FIN 报文的次数。
其次,TCP 有流控功能,当接收方将接收窗口设为 0 时,发送方就不能再发送数据。进而导致连接一直处于 FIN_WAIT1 状态
// 如果发送数据接收方可用缓冲区为0 会发送缓冲区大小告诉发送端, 之后发送端轮询
当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭。
net.ipv4.tcp_max_tw_buckets = 5000
TIME_WAIT 状态下的端口复用 net.ipv4.tcp_tw_reuse = 1
当然,要想使 tcp_tw_reuse 生效,还得把 timestamps 参数设置为 1
时间戳选项,它是用来计算延迟和防止序列号的重用的。
net.ipv4.tcp_timestamps = 1
顺带在这里提一下 以防后续忘记了 发送缓冲区和接收缓冲区的大小都是可以调节的
之后介绍TCP如何保证消息的可靠性送达?
确认机制✅:接收方收到数据后会及时发送确认,确保数据成功接收。 停止等待和[滑动窗口]
数据校验🧐:每个数据段都有校验和,用来验证数据是否完整无误。
顺序保证🔢 & 去重机制🔄:接收端通过缓冲区确保数据按正确顺序交付,并丢弃重复数据。
流量控制💡:通过流量控制避免数据过快发送,保证接收方能及时处理。
拥塞控制: TCP 拥塞控制通过动态调整发送窗口来避免网络过载,确保数据流的平稳传输。可以自己控制算法
其核心机制包括慢启动和拥塞避免。
慢启动阶段,拥塞窗口 `cwnd` 以指数增长,直到达到慢启动阈值(`ssthresh`)。
之后,`cwnd` 进入线性增长阶段。
丢包时,若为超时重传则重置 `cwnd = 1`,而轻微丢包时调整阈值并进入拥塞避免阶段 `cwnd = cwnd/2`。
`ssthresh = cwnd / 2`
最终流量控制和拥塞控制取∩
数据捎带AC 📨+✅:TCP 在发送数据的同时,还可以附带上一个确认消息(ACK)。
这样可以减少网络的负担,提高传输效率。
时延确认 ⏳:接收方并不会每接收到一个数据包就立刻发送确认, 而是会稍作延迟(大约200ms),看看是否可以将多个确认合并一起发送出去。
Nagle算法 🐦💡:Nagle算法减少了小数据包的发送,避免了网络的拥塞。 它会等待前一个数据包的确认后再发送更多数据,直到缓冲区已满或时间超过200ms。
第三章 : Socket编程 与 TCP协议之间的内容, 必须要弄清楚
TCP 是个非常靠谱的小伙子,保证数据的顺序性和完整性。所以,它不允许你跳过缺失的部分直接读取后面的数据!
缓冲区: [1, 2, 3, 4, _, _, _, 8, 9, 10]
中间的 `_` 是“空白”,表示还没收到 `包裹2` 的数据。
此时我们的socketAPI只会读取到TCP的1234,无法读取到8910会一直阻塞,可以说Socket屏蔽了TCP的种种难题,直接提供给我们最简单的调用
第四章 : TCP Socket编程基础实战
import java.io.*;
import java.net.*;
public class TCPEchoClient {
public static void main(String[] args) throws IOException {
// 应用程序设置和参数解析
if ((args.length < 2) || (args.length > 3)) // 检查参数个数是否正确
throw new IllegalArgumentException("参数应为: <服务器> <字符串> [<端口>]");
String server = args[0]; // 服务器名或IP地址
// 使用默认字符编码将字符串参数转换为字节数组
byte[] data = args[1].getBytes();
int servPort = (args.length == 3) ? Integer.parseInt(args[2]) : 7; // 确定回显服务器的端口号
// 创建TCP套接字
Socket socket = new Socket(server, servPort); // 创建套接字并连接到指定端口的服务器
System.out.println("已连接到服务器...正在发送回显字符串");
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
out.write(data); // 发送编码后的字符串到服务器
// 接收服务器的回复
int totalBytesRcvd = 0; // 目前为止收到的总字节数
int bytesRcvd; // 上一次读取的字节数
while (totalBytesRcvd < data.length) {
// 从输入流中读取数据到 'data' 数组中
if ((bytesRcvd = in.read(data, totalBytesRcvd, data.length - totalBytesRcvd)) == -1)
throw new SocketException("连接意外关闭");
totalBytesRcvd += bytesRcvd;
} // 数据数组已填满
// 打印接收到的回显字符串
System.out.println("接收到的回显: " + new String(data));
socket.close(); // 关闭套接字及其流
}
}
这段代码实现了一个基于TCP协议的简单回显服务器,它能够接收客户端的连接请求,将客户端发送的数据原样返回给客户端。
- 创建服务器套接字: 使用 ServerSocket servSock = new ServerSocket(servPort); 创建一个服务器套接字,并指定监听的端口号。
- 接收客户端连接: 使用 servSock.accept() 方法阻塞等待客户端的连接请求,一旦连接建立,返回一个新的 Socket 对象 clntSock,用于与客户端通信。
- 处理数据交换: 使用输入流 InputStream 从客户端接收数据,将其存储到 receiveBuf 缓冲区中;然后使用输出流 OutputStream 将缓冲区中的数据写回客户端。
- 释放资源: 在处理完客户端请求后,调用 clntSock.close() 关闭客户端套接字,释放相关资源。
TCPEchoServer.java
// 导入必要的包 java.net.* 和 java.io.* 用于网络和 I/O 操作
import java.net.*; // for Socket, ServerSocket, and InetAddress
import java.io.*; // for IOException and Input/OutputStream
// 定义 TCPEchoServer 类
public class TCPEchoServer {
// 定义接收缓冲区的大小为 32 字节
private static final int BUFSIZE = 32;
// 主方法,程序入口
public static void main(String[] args) throws IOException {
// 确认传入的参数个数正确
if (args.length != 1)
throw new IllegalArgumentException("Parameter(s): <Port>");
// 从命令行参数获取服务器端口号
int servPort = Integer.parseInt(args[0]);
// 创建一个服务器套接字来接收客户端的连接请求
ServerSocket servSock = new ServerSocket(servPort);
int recvMsgSize; // 接收消息的大小
byte[] receiveBuf = new byte[BUFSIZE]; // 接收缓冲区
// 进入无限循环,持续接收和处理客户端的连接
while (true) {
// 接收客户端连接(阻塞直到有连接到来)
Socket clntSock = servSock.accept();
// 获取客户端的地址和端口信息
SocketAddress clientAddress = clntSock.getRemoteSocketAddress();
System.out.println("Handling client at " + clientAddress); // 打印客户端的地址和端口信息
// 获取客户端套接字的输入流和输出流
InputStream in = clntSock.getInputStream();
OutputStream out = clntSock.getOutputStream();
// 接收并返回数据,直到客户端关闭连接(in.read()返回-1)
while ((recvMsgSize = in.read(receiveBuf)) != -1) {
// 将接收到的数据写回给客户端
out.write(receiveBuf, 0, recvMsgSize);
}
// 关闭客户端套接字,释放资源
clntSock.close();
}
/* NOT REACHED */
}
}
以上是TCP建立连接的重要信息,第二种要的就是帧处理器
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class DelimFramer implements Framer {
private InputStream in; // 数据源
private static final byte DELIMITER = '\n'; // 消息分隔符,换行符
// 构造函数,接受一个输入流作为参数
public DelimFramer(InputStream in) {
this.in = in;
}
// 将消息添加帧信息并写入输出流
public void frameMsg(byte[] message, OutputStream out) throws IOException {
// 确保消息中不包含分隔符
for (byte b : message) {
if (b == DELIMITER) {
// 如果消息中包含分隔符,抛出IOException
throw new IOException("Message contains delimiter");
}
}
// 写入消息字节数组到输出流
out.write(message);
// 写入分隔符到输出流,表示消息结束
out.write(DELIMITER);
// 刷新输出流,确保所有数据被发送
out.flush();
}
// 从输入流中提取消息
public byte[] nextMsg() throws IOException {
// 用于存储消息的缓冲区
ByteArrayOutputStream messageBuffer = new ByteArrayOutputStream();
int nextByte;
// 逐字节读取输入流,直到找到分隔符
while ((nextByte = in.read()) != DELIMITER) {
// 如果到达输入流末尾
if (nextByte == -1) {
// 如果缓冲区为空,返回null,表示所有消息都已接收
if (messageBuffer.size() == 0) {
return null;
} else {
// 如果缓冲区非空但未找到分隔符,抛出EOFException,表示帧错误
throw new EOFException("Non-empty message without delimiter");
}
}
// 将读取到的字节写入缓冲区
messageBuffer.write(nextByte);
}
// 返回缓冲区内容作为字节数组
return messageBuffer.toByteArray();
}
}
这段代码实现了另一种消息帧处理器 LengthFramer,它使用消息长度作为消息的前缀,并从输入流中读取指定长度的消息内容。
LengthFramer.java
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class LengthFramer implements Framer {
public static final int MAXMESSAGELENGTH = 65535;
public static final int BYTEMASK = 0xff;
public static final int SHORTMASK = 0xffff;
public static final int BYTESHIFT = 8;
private DataInputStream in; // 数据输入流的包装器
// 构造方法,初始化输入流的 DataInputStream 包装器
public LengthFramer(InputStream in) throws IOException {
this.in = new DataInputStream(in);
}
// 帧化消息的方法
public void frameMsg(byte[] message, OutputStream out) throws IOException {
// 验证消息长度是否合法
if (message.length > MAXMESSAGELENGTH) {
throw new IOException("message too long");
}
// 写入长度前缀
out.write((message.length >> BYTESHIFT) & BYTEMASK); // 写入高位字节
out.write(message.length & BYTEMASK); // 写入低位字节
// 写入消息内容
out.write(message); // 写入消息主体
out.flush(); // 确保数据立即发送
}
// 从输入流中提取下一个帧化消息的方法
public byte[] nextMsg() throws IOException {
int length;
try {
length = in.readUnsignedShort(); // 读取两字节作为消息长度
} catch (EOFException e) {
// 如果遇到流结束异常,返回 null 表示没有消息可读取
return null;
}
// 创建一个字节数组,用于存储消息内容
byte[] msg = new byte[length];
// 从输入流中读取指定长度的消息内容
in.readFully(msg); // 阻塞直到读满指定长度的消息内容,否则抛出异常
// 返回读取的消息内容
return msg;
}
}
但其实在开发中我们更加常用的是 OnReceived 这样的异步编程哦~ 大概就是这些了!