学习总结:TCP 粘包拆包 与 SqlSugar 框架


一、TCP 粘包与拆包

1.1 问题背景

TCP 是面向字节流的协议,发送方写入的数据在接收方看来是一条连续不断的字节流,没有边界。因此会出现以下三种现象:

现象说明
粘包多次发送的消息被合并成一次接收(例如两条短消息挤在一个数据包中到达)
拆包一条消息被拆成多次接收(例如大消息被网络层切成多个数据包)
混合既有粘包又有拆包同时发生

注意:粘包拆包不是 TCP 的 Bug,而是字节流协议的固有特性。UDP 是面向报文的协议,不存在此问题。

1.2 常见解包方案

  1. 消息定长:每条消息固定长度,不足补齐。
  2. 特殊分隔符:例如以 \r\n 结尾(FTP、HTTP 头部采用此方式)。
  3. 头部长度字段(本案例采用):在消息体前加一个固定长度的头部,记录消息体的字节长度。

1.3 协议设计(本案例)

本案例采用 “4 字节头 + 变长消息体” 的方案:

+------------+-------------------+
|  Header    |     Body          |
|  4 bytes   |   N bytes         |
| (body长度) |  (UTF8 文本)       |
+------------+-------------------+
  • 头部:4 字节整数,存储消息体的字节长度
  • 字节序:网络字节序(大端),使用 IPAddress.HostToNetworkOrder / NetworkToHostOrder 转换
  • 消息体:UTF-8 编码的字符串

1.4 关键代码

1.4.1 编码:消息 → 字节流
/// <summary>
/// 将字符串编码为「4字节长度头 + 消息体」的字节包
/// </summary>
private byte[] EncodePackage(string msg)
{
    byte[] data = Encoding.UTF8.GetBytes(msg);
    // 将消息体长度转为网络字节序(大端),保证跨平台一致
    byte[] headLength = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(data.Length));
    byte[] package = new byte[4 + data.Length];
    Array.Copy(headLength, package, headLength.Length);      // 头部
    Array.Copy(data, 0, package, 4, data.Length);            // 消息体
    return package;
}
1.4.2 解码:从字节流中循环抽取完整包
/// <summary>
/// 从缓冲区 bsList 中循环解析出完整消息
/// 粘包:一次能解析出多条;拆包:长度不足时跳出等待下次数据
/// </summary>
private void TryParsePackage()
{
    while (bsList.Count >= 4)                         // 至少要有 4 字节头部
    {
        byte[] headBytes = bsList.GetRange(0, 4).ToArray();
        int bodyLength = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(headBytes, 0));
        int totalLength = 4 + bodyLength;

        if (bsList.Count < totalLength) break;        // 拆包:包不完整,等下一次

        byte[] bodyData = bsList.GetRange(4, bodyLength).ToArray();
        string msg = Encoding.UTF8.GetString(bodyData);
        AppendLog("收到消息:" + msg);

        bsList.RemoveRange(0, totalLength);           // 移除已解析部分,继续解析下一个
    }
}
1.4.3 接收数据 + 缓冲
/// <summary>
/// 异步循环读取网络流,累积到 bsList 后尝试解析
/// </summary>
private void ReceiveData(TcpClient client)
{
    cts = new CancellationTokenSource();
    Task.Run(async () =>
    {
        byte[] buffer = new byte[1024];
        while (!cts.IsCancellationRequested)
        {
            NetworkStream stream = client.GetStream();
            int len = await stream.ReadAsync(buffer, cts.Token);
            if (len == 0) { AppendLog("客户端断开连接"); break; }

            byte[] realData = new byte[len];
            Array.Copy(buffer, realData, len);
            bsList.AddRange(realData);                // 关键:累积到缓冲区

            TryParsePackage();
        }
    }, cts.Token);
}

1.5 流程示意

发送端:  msg ──EncodePackage──> [4字节头][body] ──> stream.Write

接收端:  stream.Read ──> bsList.AddRange ──> TryParsePackage (while循环)
                                                │
                                  ┌─────────────┴─────────────┐
                                  │ bsList.Count >= 4 ?        │
                                  │ 取出头部 → bodyLength       │
                                  │ 总长 4+bodyLength          │
                                  │ 不足 → break (拆包等待)     │
                                  │ 足够 → 取body → 处理 → 移除 │
                                  └───────────────────────────┘

1.6 要点小结

  • ✅ 用 List<byte> 作为接收缓冲区,累积所有到达字节
  • ✅ 用 while 循环解析,一次可拆出多个包(解决粘包)
  • ✅ 包不完整时 break 等待下次数据(解决拆包)
  • ✅ 头部使用网络字节序保证收发双方字节序一致
  • ✅ 解析完一个包后必须 RemoveRange 移除已处理部分

二、SqlSugar 第三方 ORM 框架

2.1 框架简介

SqlSugar 是一款老牌国产 .NET ORM 框架,由果糖大数据科技团队维护,开箱即用、易于上手。

2.2 两种使用模式

模式类特点
原生模式SqlSugarClient高性能,每次 new 新对象,禁止单例
单例模式SqlSugarScope对新手友好,解决大部分线程安全问题,必须单例

2.3 核心配置(本案例采用原生模式)

/// <summary>
/// 窗体加载时初始化 SqlSugarClient
/// </summary>
private void Form1_Load(object sender, EventArgs e)
{
    db = new SqlSugarClient(new ConnectionConfig()
    {
        ConnectionString = "server = localhost,64345; database = sugarDemo.db; uid = sa; pwd = 123123;Encrypt=True;TrustServerCertificate=True",
        DbType = DbType.SqlServer,
        IsAutoCloseConnection = true,           // 自动关闭连接,省去手动 using
    },
    db =>
    {
        // AOP:执行 SQL 时打印原生 SQL,方便调试
        db.Aop.OnLogExecuting = (sql, pars) =>
        {
            Console.WriteLine(UtilMethods.GetNativeSql(sql, pars));
        };
    });
}

2.4 实体定义

[SugarTable]                                              // 标记为数据库表
public class Student
{
    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] // 主键 + 自增
    public int Id { get; set; }
    public string Name { get; set; }
    public int? Age { get; set; }                          // 可空类型对应数据库可空字段
}

2.5 数据库与表初始化

// 创建数据库(若不存在)
db.DbMaintenance.CreateDatabase();

// CodeFirst:根据实体类自动生成/同步表结构
db.CodeFirst.InitTables(typeof(Student));

2.6 核心 API(CRUD)

操作API示例
增db.Insertable<T>(entity).ExecuteCommand()db.Insertable(list).ExecuteCommand()
删db.Deleteable<T>(entity).ExecuteCommand()db.Deleteable<Student>(stu).ExecuteCommand()
改db.Updateable<T>(entity).ExecuteCommand()db.Updateable<Student>(stu).ExecuteCommand()
查db.Queryable<T>()...ToList()db.Queryable<Student>().Single(s => s.Id == id)
2.6.1 批量插入
List<Student> list = new List<Student>()
{
    new Student(){Name = "张三1", Age = 100},
    new Student(){Name = "张三2", Age = 200},
    new Student(){Name = "张三3", Age = 300},
};
int row = db.Insertable(list).ExecuteCommand();
2.6.2 条件查询 + 动态拼接

使用 Expressionable<T> 动态拼接 where 条件,避免写大量 if-else 分支 SQL。

/// <summary>
/// 根据界面输入条件动态拼接查询条件并分页绑定 DataGridView
/// </summary>
private void BindView()
{
    if (db == null) return;
    var express = Expressionable.Create<Student>();
    // AndIF:仅当条件成立时追加 AND 过滤
    express.AndIF(!string.IsNullOrWhiteSpace(textBox1.Text), s => s.Name.Contains(textBox1.Text));
    express.AndIF(!string.IsNullOrWhiteSpace(textBox2.Text), s => s.Age >= int.Parse(textBox2.Text));
    express.AndIF(!string.IsNullOrWhiteSpace(textBox3.Text), s => s.Age <= int.Parse(textBox3.Text));

    var list = db.Queryable<Student>()
                 .Where(express.ToExpression())
                 .ToPageList(pageNumber, pageSize, ref totalCount, ref totalPages)
                 .ToList();

    dataGridView1.DataSource = list;
    label2.Text = $"{pageNumber}/{totalPages}";
}
2.6.3 修改与删除(按主键定位)
// 修改:先查后改
int id = int.Parse(dataGridView1.SelectedRows[0].Cells["Id"].Value!.ToString()!);
Student stu = db.Queryable<Student>().Single(s => s.Id == id);
stu.Name = "三生三世";
stu.Age = 100;
db.Updateable<Student>(stu).ExecuteCommand();

// 删除
Student stu = db.Queryable<Student>().Single(s => s.Id == id);
db.Deleteable<Student>(stu).ExecuteCommand();

2.7 分页查询要点

  • ToPageList(pageNumber, pageSize, ref totalCount, ref totalPages) 一次返回当前页数据 + 总数 + 总页数
  • 翻页只需修改 pageNumber 后重新调用 BindView()
  • 每页条数变更时,应重置 pageNumber = 1,避免越界

2.8 要点小结

  • ✅ IsAutoCloseConnection = true 让连接管理自动化,避免资源泄漏
  • ✅ Aop.OnLogExecuting 是调试 SQL 的利器
  • ✅ Expressionable<T> 适合动态条件查询场景
  • ✅ CodeFirst.InitTables 实现"代码先行",实体变更直接同步表结构
  • ✅ CRUD 全部链式调用,统一以 ExecuteCommand()(增删改)或 ToList()/Single()(查)收尾

三、关联知识点

  • ADO.NET 五大对象:SqlConnection、SqlCommand、SqlDataAdapter、SqlDataReader、DataSet,SqlSugar 底层即基于此封装。
  • IOC / DI:SqlSugarClient 推荐使用 Scope 或瞬发注入,禁止单例。
  • 网络字节序:TCP 自定义协议应统一使用大端序(HostToNetworkOrder / NetworkToHostOrder)。

更多推荐