一年校招生带你了解TCP编程

177 阅读13分钟

入职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的大小

image.png

在应用协议传输数据时,数据从应用程序层开始层层都需要添加首部信息(应用程序需要加入--我拿到这个数据之后干什么)最终才可以在物理硬件上完成数据的传输

之后以一次完整的上网流程为例,介绍各个协议层如何工作

当你在浏览器键入 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协议

image.png

最主要的就是TCP协议的建立,断开以及消息可靠性的保证策略

首先介绍一下三次握手的过程中TCP服务器都在干什么 listen(backlog)

image.png

此处正好涉及到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 这样的异步编程哦~ 大概就是这些了!

祝各位一切顺利