news 2026/10/1 3:04:00

异步TCP聊天双端源码实战拆解:从状态机到生产级避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
异步TCP聊天双端源码实战拆解:从状态机到生产级避坑

简介:一套基于异步模式的TCP聊天程序示例,面向网络编程入门及中级开发者,旨在解决同步阻塞模式下高并发连接占用大量线程的问题。压缩包共46个文件,以C#源码(cs)为主,辅以可直接运行的exe、界面布局与资源文件(resx/resources)、项目工程配置等,整体仅86KB,体量轻巧,便于快速阅读。目前已有167人浏览学习。内容包含完整的AsyncTcpServer与AsyncTcpClient实现,以及readme说明文档,覆盖服务端端口监听、客户端连接请求、回调事件处理、消息收发和UI解耦更新等关键环节。借助该示例可深入理解TCP三次握手、滑动窗口、拥塞控制等协议机制在异步场景下的实际应用,也可学习事件驱动模型与回调函数的编码方式,还可借鉴其目录结构与代码组织习惯,快速搭建自己的网络通信工具。

1. 异步 TCP 聊天源码包:一份能直接跑的双端工程,核心不在“异步”而在状态机

前阵子拆一个老项目的网络层,翻出一个名为“TCP.rar_TCP异步”的压缩包。解压后内容很干净:一份 readme.txt,一个 AsyncTcpServer,一个 AsyncTcpClient,没了。看起来就是一套最小可用的异步 TCP 聊天双端程序。很多人一看到“异步”两个字就以为是高并发的银弹,真正动手连上两个客户端收发几条消息就会发现,异步模型把回调撒得到处都是,连接状态、粘包、界面刷新这些问题一个都绕不开。这套源码合适想搞懂 TCP 编程入门的从业者,也合适需要一套能改造成生产级通信基座的熟手。下面按“协议原理→模块职责→跑通流程→排坑→进阶”的顺序,把它拆开讲透。

2. 选异步的账要算清楚:从三次握手到事件回调,TCP 的可靠性与并发模型如何各司其职

2.1 三次握手与四次挥手:连接生命周期决定回调触发时机

AsyncTcpClient 调用连接接口之后,真正干活的是操作系统协议栈。客户端发 SYN,服务器回 SYN+ACK,客户端再回 ACK,这一趟下来连接才进入 ESTABLISHED。整个过程应用层看不见,但 AsyncTcpServer 那个“新连接”完成回调什么时候触发,恰恰就卡在“握手完成”这个时间点上。换句话说,你在回调里拿到的 socket 已经是可读可写的,不需要再额外探测。

连接失败时的表现,是新手最先遇到的一类坑。目标端口没监听,客户端立刻收到类似 ECONNREFUSED 的错误;网络不通或者防火墙丢包,则表现为 ETIMEDOUT。前者通常几毫秒就返回,后者要等操作系统超时,可能几十秒。排查时别只在回调里打印一行“连接失败”,把错误码、目标 IP、目标端口、本地端口一并记下来,才能区分是代码问题还是网络环境问题。

四次挥手比三次握手更容易让异步程序翻车。主动关闭方发 FIN,对端回 ACK,对端再发 FIN,主动方回 ACK,连接才彻底释放。异步模型里,“对端调用了 Close”这个事件和对端最后一批数据到达的顺序,并不是严格保证的。如果应用层协议里没有定义“关闭前先把数据发完”的语义,很可能丢掉最后一条聊天消息。这个问题在同步阻塞模型里也存在,但异步回调打散代码之后,更容易被忽略。

2.2 滑动窗口、确认与重传:可靠性换来的代价是延迟而非丢消息

TCP 的可靠传输经常被误解成“消息不会丢”。准确说法是“不丢、不重、不乱”,靠的是序列号、确认应答、超时重传这套组合。接收方通过滑动窗口的剩余大小告诉发送方还能发多少,这是流量控制;网络拥塞时发送方还要跑慢启动、拥塞避免、快重传快恢复,这是拥塞控制。两者叠加,直接决定了消息什么时候真正到达对端。

落到 AsyncTcpServer 的数据回调上,这些机制对你透明,但有几个结论值得刻在脑子里。第一,“Send 成功”只代表数据进了本机内核的发送缓冲区,不保证对端应用层已经读到,更不代表对方已经处理完。第二,TCP 没有消息边界,你 Send 两次,对端 Receive 可能一次收到,这就是后面避坑章节要处理的粘包问题。第三,RTT 大、重传多的时候,收发延迟会明显上升,这时候异步模型也救不了物理链路。

做聊天程序时,我会在 UI 里区分“已发送”和“对方已读”:客户端收到服务器回执之后,再把消息状态改成已读。这个逻辑看起来小,却是 TCP 语义落到产品体验的关键一步。把“发送成功”直接当成“对方已读”,是新手最容易在聊天程序里翻车的地方。选型上也要想清楚,TCP 优先保证可靠性和顺序;如果业务能容忍丢包、更看重实时性,那 UDP 或基于 UDP 的自定义协议才值得考虑。

2.3 事件循环与回调:同步与异步的差别不在快慢,在线程占用

同步 TCP 的经典写法是一个连接一个线程,逻辑直白,但连接数到几千,线程上下文切换就会吃掉大量 CPU。异步模型反向操作:把“等待 I/O 完成”这件事交给操作系统,应用层注册回调,I/O 完成后再由事件循环唤醒。Windows 上常见 IOCP,Linux 上常见 epoll,C++ 的 Boost.Asio 底层也是这套思路。同步和异步的区别从来不在快慢,在线程占用和连接数的规模上限:一个进程用同步模型撑几千连接已经很吃力,异步模型撑几万连接也是常见的事。

这份源码包里 AsyncTcpServer 和 AsyncTcpClient 的命名,说明它们走的是同一套异步 socket 接口。常见做法是维护一个线程池跑事件循环,每个循环同时盯着一批 socket 描述符,哪个有事件就处理哪个。聊天场景下,一条连接上的数据本来就是串行到达,异步的真正价值在于服务器同时挂几千个客户端时线程数不膨胀。类似 Modbus TCP 网关要同时维护上百条设备连接的场景,异步模型几乎是标配。

但异步的代价是代码逻辑被打散。一个连接的状态分散在多个回调函数里,维护状态机比同步代码难得多。这也是为什么这份源码适合做学习材料:它足够小,能让你完整看到异步 TCP 的回调链条,而不至于淹没在具体业务里。

3. 拆开这份双端源码:AsyncTcpServer 与 AsyncTcpClient 的职责划分和回调链条

3.1 文件清单与模块边界:readme.txt 以外你需要知道的划分逻辑

压缩包顶层文件很少:readme.txt、AsyncTcpServer 相关文件、AsyncTcpClient 相关文件。readme.txt 通常只写编译和运行方式,不会把模块边界讲透,但实际划分很清楚:

文件/模块职责关键外部接口
readme.txt编译顺序、端口说明、运行注意事项无
AsyncTcpServer监听端口、接受连接、维护连接列表、转发消息Start / Stop / SendTo
AsyncTcpClient发起连接、收发消息、处理断开事件Connect / Send / Close

服务器端和客户端的边界在于“谁主动建连”。服务器只监听,不主动连接;客户端只连接,不监听。聊天场景里,消息要经过服务器转发,所以 Server 端会维护一个“连接 ID 到 socket”的映射,Client 端只需要维护自己这一条连接。这个区别直接决定了两个类内部的数据结构:Server 端用 map 管理一堆连接,Client 端只需要一个 socket 成员加一个 connected_ 标志。

连接 ID 的生成也有讲究。我见过直接用 socket 句柄当 ID 的写法,简单但复用性差,句柄被系统回收后可能误伤新连接。更稳妥的方式是用自增计数器,每接受一个新连接就分配一个从未重复的值,连接关闭后也不回收这个编号。聊天程序规模下,32 位整数足够跑很久。

3.2 AsyncTcpServer 的状态流转:从 Bind 到 Accept 再到数据收发

服务器端核心状态只有三个:监听中、已接受连接、连接已断开。异步实现通常会把 accept 和 recv 都投递成“未完成 I/O”,等事件循环通知完成。用类 C++ 的伪代码看结构是这样的:

class AsyncTcpServer { // 监听 socket,backlog 决定内核排队队列的上限 SOCKET listen_sock_; std::map<int, Connection*> conns_; void Start(short port, int backlog = 64) { listen_sock_ = socket(AF_INET, SOCK_STREAM, 0); bind(listen_sock_, port); listen(listen_sock_, backlog); PostAccept(); // 投递一个异步 accept 请求 } void OnAccept(SOCKET accepted_sock, int client_id) { conns_[client_id] = new Connection(accepted_sock); PostRecv(client_id); // 投递异步 recv,等待第一条消息 PostAccept(); // 立刻再投递一个 accept,继续接受新连接 } void OnRecv(int client_id, const char* data, int len) { // 处理粘包:先封帧,再交给聊天逻辑 DispatchMessage(client_id, data, len); PostRecv(client_id); // 继续投递 recv,保持数据链路不断 } };

逻辑说明:Start 之后立刻 PostAccept,这是异步服务器最重要的习惯。accept 请求永远挂一个在那里,来一个新连接触发一次 OnAccept,处理完马上再补投。OnRecv 里处理完数据也立刻重新投递 recv,保证同一连接上的后续数据能被持续接收。

参数说明:backlog 值在 Windows 上默认通常够用,但高并发场景我会调到 128 以上。PostAccept 返回失败时,基本可以断定监听 socket 已经失效,这时候要重启监听而不是继续傻等。连接列表 conns_ 的增删操作,在单事件循环模型里是串行执行的,不用加锁;如果开了多线程跑事件循环,就必须加锁或把修改操作全部投递到同一个循环里执行。

3.3 AsyncTcpClient 的连接与收发:回调链路上的三个关键点

客户端比服务器简单很多,但三个关键点不能漏:连接超时、断线重连、发送队列。

class AsyncTcpClient { SOCKET sock_; bool connected_; void Connect(const char* ip, short port, int timeout_ms = 3000) { sock_ = socket(AF_INET, SOCK_STREAM, 0); PostConnect(ip, port); StartConnectTimer(timeout_ms); // 异步 connect 必须自己管超时 } void OnConnectSuccess() { connected_ = true; PostRecv(); StopConnectTimer(); } void OnConnectTimeout() { closesocket(sock_); ScheduleReconnect(backoff_secs_); // 指数退避,别每秒死磕 } void Send(const char* data, int len) { if (!connected_) return; send_queue_.push_back(copy(data, len)); // 先入队再投递 PostSend(); } };

逻辑说明:异步 connect 必须自己管理超时,因为完成回调可能在很久之后才触发,甚至永远不触发。Send 接口先入队再投递 send,而不是直接调 send,这是异步客户端避免发送缓冲区被覆盖的标准做法。发完一个再投递下一个,队列保证顺序。

参数说明:timeout_ms 建议设 3000 到 5000,低于 2000 在弱网环境容易误伤正常建连。重连退避我一般用 1 秒、2 秒、4 秒指数增长,封顶 30 秒,避免服务器故障时客户端疯狂抖动。send_queue_ 也要设上限,比如 2000 条,超过就丢最老的消息或主动断开,否则内存会被异常对端拖垮。

4. 把聊天跑起来:编译、启动与收发验证的完整过程和参数怎么调

4.1 编译前要改的三个地方:端口、IP 与缓冲区大小

拿到源码先别急着编译。第一件事是看 readme.txt 里写的目标平台和依赖库。异步 socket 在 Windows 下需要链接 ws2_32 库,在 Linux 下要看是不是 epoll 实现。改代码之前确认三件事:

端口要统一。Server 端监听的端口、Client 端连接的端口必须一致。代码里一般写死一个端口,比如 9000,改成自己规划的值时两边要一起改,否则客户端连不上,日志里看起来又像“服务器没起来”。

IP 地址要分清。同一台机器调试用 127.0.0.1,跨机器调试改成服务器实际的局域网 IP 或公网 IP。注意服务器监听地址如果是 127.0.0.1,外部机器是连不进来的;要对外服务,监听地址应该用 0.0.0.0 或具体的网卡地址。收发缓冲区默认 4KB 到 8KB 在聊天场景够用,但如果要发图片或大报文,要调到 64KB 以上,同时应用层做好分片。

我常用的本地联调参数组合是:Server 监听 9000,backlog 设 64,Client 连 127.0.0.1:9000,连接超时 3000ms。这套组合能覆盖大部分功能验证需求。

4.2 先启服务器再启客户端:完整验证路径

编译通过后,按下面顺序跑:

# 终端1:先启动服务器,监听 9000 端口 ./tcp_server 9000 # 终端2:再启动客户端,连接本机 9000 端口 ./tcp_client 127.0.0.1 9000

逻辑说明:服务器必须先于客户端启动,否则客户端 connect 会立刻被拒绝。第一次联调务必按“先 Server 后 Client”的顺序来。如果做断线重连测试,可以反过来让客户端先启动,等服务器起来后由重连逻辑自动挂上,但那属于进阶验证,不适合第一步。

参数说明:命令行传端口和 IP 是更可维护的做法。如果源码里端口是写死的宏,要么改成读命令行参数,要么改宏后重新编译。硬编码端口的问题在于换环境就要改代码,很容易漏改。改完端口记得用端口占用检查命令确认没有其他进程占着。

4.3 验证收发与断开:看的指标不是日志而是状态机

跑通之后,按下面这条路径验证:

验证步骤操作预期结果
1启动 Server 监听 9000控制台输出“listening on 9000”
2启动 Client A 连接 127.0.0.1:9000Server 触发新连接回调,连接数变为 1
3Client A 发送“hello”Server 收到并显示 hello
4启动 Client BServer 连接数变为 2
5Client A 发消息给 BB 能收到,消息顺序正确
6强制断开 Client BServer 触发断开回调,连接数变回 1

验证的要点不是“消息有没有显示出来”,而是断开事件有没有触发清理逻辑。异步程序最常见的隐性故障是连接已经断了,服务器端的连接对象还挂在列表里,看起来在线,实际上数据发不出去。如果发现这个现象,说明断开回调没有正确执行移除操作,这是上生产环境前必须解决的问题。

日志埋点也有讲究。建议在回调的入口和出口各打一行,入口记录事件类型,出口记录处理结果。比如“OnRecv enter client_id=3 len=28”和“OnRecv exit client_id=3 handled=true”。这样出了问题,翻日志就能定位是没触发回调,还是回调内部逻辑挂了。

4.4 联调时用到的排查命令:端口、连接状态与抓包

连接不上或者连接异常时,光看程序日志不够,系统命令能给你更客观的信息。

# 查看 9000 端口是否在监听 netstat -ano | findstr 9000 # Windows ss -lntp | grep 9000 # Linux # 查看当前机器上的 TCP 连接状态 netstat -ano | findstr ESTABLISHED # Windows ss -tnp | grep ESTABLISHED # Linux

逻辑说明:netstat 和 ss 都能看到 LISTENING、ESTABLISHED、CLOSE_WAIT、TIME_WAIT 这些状态。如果客户端显示 ESTABLISHED 但服务器列表里没有对应连接,说明两侧状态机已经不一致。CLOSE_WAIT 堆积是服务端常见的异常信号,说明对端关闭连接后,本地没有正确调用 close 完成关闭流程。

参数说明:抓包排查时用 tcpdump 或 Wireshark 过滤 TCP 端口,重点看三次握手的 SYN、SYN+ACK、ACK 有没有正常往返,以及连接断开时是 FIN 还是 RST。看到 RST 基本就能断定某一端在发送缓冲区还有数据时强行关了 socket,直接对应第 5 章的关闭顺序问题。

5. 异步 TCP 实战避坑:四个高频翻车点与排查方法

5.1 粘包与半包:Recv 一次收到两次 Send 的数据

现象:客户端连续 Send 两条消息,服务器 OnRecv 只触发了一次,data 里是两条消息拼接在一起。或者反过来,一条大消息分成两次触发 OnRecv,每次只有一半数据。

原因:TCP 是流协议,没有消息边界。内核只保证字节流按序交付,怎么切分是应用层的事。异步模型里,recv 完成回调每次返回的字节数不固定,可能小于你要的数据,也可能远大于一条消息。

解决:应用层协议里加消息头。常见做法是 4 字节长度头加 N 字节负载。发送方先写长度再写内容,接收方先收满 4 字节拿到长度,再循环收满 N 字节。解析骨架如下:

void OnRecv(int client_id, const char* data, int len) { // recv_buf_ 是每个连接独立的接收缓冲区 recv_buf_.Append(data, len); while (recv_buf_.size() >= 4) { int msg_len = recv_buf_.ReadInt32(); // 读长度头 if (recv_buf_.size() < 4 + msg_len) break; // 还没收全,等下一波 std::string msg = recv_buf_.Read(msg_len); // 取出一条完整消息 HandleMessage(client_id, msg); } }

逻辑说明:外层判断缓冲区是否够 4 字节长度头,内层判断是否够一条完整消息,不够就 break 等下一轮 recv。HandleMessage 拿到的 msg 一定是一条完整消息,不会再出现半包。

参数说明:长度头用无符号 32 位整数,理论单条消息上限 2GB。实际使用时我一定会加一个上限检查,比如单条不超过 1MB,超过直接断开连接。否则对端发了恶意超大长度头,接收方会一直等着收满数据,把内存拖垮。

5.2 界面刷新卡顿:异步回调里直接操作 UI 控件

现象:客户端收到消息后,直接在 OnRecv 回调里调用文本框的 AppendText 方法,结果界面频繁卡顿,操作多了还会闪退。

原因:异步回调跑在工作线程,UI 控件只能在 UI 线程操作。跨线程访问控件轻则闪烁,重则直接运行时错误。这个坑在本地调试时偶尔不报错,因为线程切换的时序凑巧没撞上,但一上压力就原形毕露。

解决:工作线程只把数据丢进队列,通过 PostMessage 或者事件循环的通知机制切回 UI 线程再刷新界面。具体到这份源码里的 Client,收到消息后应该立即返回,只抛一个“消息到达”事件到 UI 线程的消息队列。UI 线程消费队列时批量刷新,既线程安全又减少绘制次数。

5.3 服务器主动重置连接:问题出在关闭顺序

现象:客户端正常退出,服务器端收到的是“连接被重置”而不是“正常关闭”,日志里表现为 RST 而非 FIN。

原因:关闭 socket 时,如果发送缓冲区里还有没发完的数据,直接 closesocket 会向对端发 RST 而不是 FIN。对端看到的就是异常重置。常见于客户端收到一条消息后马上关闭,而底层 ACK 还没处理完。

解决:关闭连接前先 shutdown 发送方向,调 shutdown(SD_SEND) 让对方收到 FIN,再确认数据都处理完,最后 closesocket 释放本地资源。异步代码里,建议在 UI 层“退出”按钮的处理里先发一条退出消息,等服务端回执后再关连接,而不是直接杀进程。

5.4 连接对象泄漏:断开回调没清理干净

现象:服务器跑了一晚上,内存持续上涨,连接列表越来越长,但活跃用户数并没有增加。

原因:断开回调里只打了日志,没有把连接对象从 conns_ 里移除,也没有 close socket。异步事件循环里,一个连接对象如果被回调持有引用,即使业务上已经断开,回收机制也拿它没办法,等于每个断开的连接都在泄漏。

解决:把“断开”看成两个阶段。先标记连接失效,停止再投递 recv;再执行清理,移除映射、关闭 socket、释放接收缓冲区。所有清理逻辑收敛到一个函数,任何回调想退出连接时都统一走这个入口,避免清理代码散落在多个回调里。写代码时顺手在清理函数入口打印当前连接总数,隔一段时间看这个数是否只降不升。

6. 从聊天程序到生产级网络服务:心跳、背压与优雅关闭三个硬技巧

聊天程序能跑通,离生产还差一步。生产级 TCP 服务里有大量“看起来不重要、出事就要命”的细节,这里挑三个最值得提前做的。

心跳保活。TCP 协议栈自带的 KeepAlive 默认两个多小时才探测一次,应用层根本等不起。方案是应用层心跳:客户端每 30 秒发一个 Ping 包,服务器如果在 90 秒内没收到任何数据,就判定连接死亡并清理。心跳包要走业务协议的帧格式,比如长度头加消息类型,不能裸发,否则接收方还要在业务逻辑里单独处理非业务报文。聊天场景里,心跳还能顺带解决 NAT 超时导致连接静默断开的问题。

背压处理。客户端发送速度超过服务器处理速度时,Server 的发送队列会越来越长。常见做法是为每个连接设置发送队列上限,比如 2000 条,超过就断开或丢弃最老的消息。这个参数直接决定内存占用的上界。忽略背压的服务器,内存会随着一两个异常客户端的行为无限增长,直到整个进程被系统杀掉。压测时特意模拟一个不读数据的慢客户端,观察背压逻辑是否按预期触发,这一步很值。

优雅关闭。生产环境服务器升级时,不能直接杀进程,否则在线用户全部闪断。典型做法是:先停止 accept 新连接,再给存量连接一个宽限期,比如 30 秒处理完剩余消息,最后强制清理超时连接。异步模型里,这需要连接状态机里加一个 Closing 状态,拒绝新消息但继续把已排队的数据发完。

验证方法上,本地联调通过不算数。我会用两个小工具做压力验证:一个脚本同时开几百个客户端连接,不停发送随机消息,观察 Server 的句柄数和内存是否线性增长;另一个模拟慢客户端故意不读数据,观察背压逻辑是否按预期触发。这两组验证跑过一遍,才敢把服务挂到真实环境。

从那以后,我每次写异步 TCP 服务端都会强制走一遍同一套检查清单:粘包封帧、断开清理、心跳超时、发送队列上限,缺一项就觉得心里不踏实。回头再看这份 TCP.rar,它最大的价值不是让你抄一段异步收发代码,而是在最小的规模里看全异步 TCP 的所有关键节点。建议你下载后照着第 4 章的路径跑一遍,再按第 5 章的坑逐个排查,最后把第 6 章的心跳和背压补进去,这套源码就能真正长成你自己的东西。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 3:04:00

BERT微调实战:Keras实现多标签文本分类的完整指南

简介&#xff1a;面向NLP初学者的文本多标签分类实战资源&#xff0c;以Keras与Keras-bert为基础&#xff0c;通过对BERT进行微调来完成多标签分类任务。项目选用2020语言与智能技术竞赛的事件抽取任务数据作为样例&#xff0c;覆盖数据预处理、模型训练、评估与预测等关键环节…

作者头像 李华
网站建设 2026/10/1 3:03:32

16QAM数字通信系统仿真:从星座图到误码率曲线的完整链路详解

简介&#xff1a;一套16QAM数字通信系统MATLAB仿真资源&#xff0c;面向通信工程专业学生、科研人员及算法工程师&#xff0c;帮助理解数字调制解调、上下变频及高斯白噪声对系统性能的影响。资源完整实现了二进制数据流生成、16QAM符号映射、上变频发射、加噪传输、下变频接收…

作者头像 李华
网站建设 2026/10/1 3:03:26

从回测到实盘:量化策略上线的三步实战拆解

做了这么多年程序开发&#xff0c;身边不少同事都心动过量化投资。程序员搞量化确实有天然优势&#xff1a;能写代码、能清洗数据、能自动化跑重复劳动&#xff0c;但大多数人卡在了从“写了个策略”到“策略在实盘账户里自动交易”这一步。回测跑得再漂亮&#xff0c;一上实盘…

作者头像 李华
网站建设 2026/10/1 3:02:56

日志排查太慢?用这组grep组合拳提升效率

一个人翻日志文件能慢到什么程度&#xff1f;我之前在工位上见过一次真实的&#xff1a;后端同事排查一个定时任务没执行的问题&#xff0c;他打开一个接近1GB的日志文件&#xff0c;先用编辑器硬扛着翻了好几分钟&#xff0c;然后开始CtrlF一个关键词&#xff0c;没搜到&#…

作者头像 李华
网站建设 2026/10/1 3:02:12

基于Transformer的遥感影像变化检测全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 3:01:20

行业动态:假期观察:药膳月饼走红,轻养生背后的“食”之有道:公开信息与可核验事实梳理

这里写自定义目录标题欢迎使用Markdown编辑器新的改变功能快捷键合理的创建标题&#xff0c;有助于目录的生成如何改变文本的样式插入链接与图片如何插入一段漂亮的代码片生成一个适合你的列表创建一个表格设定内容居中、居左、居右SmartyPants创建一个自定义列表如何创建一个注…

作者头像 李华