
简介这份PDF文档聚焦TCP与UDP Socket编程面向具备Python语法基础和简单网络概念的学习者适合课程配套实验或自学实践。文档从PyCharm安装与环境配置讲起完整梳理Socket开发流程重点演示UDP套接字的数据发送接收、超时设置以及Ping应用中的丢包模拟同时给出TCP客户端与服务端的连接创建、数据收发和关闭套接字的全流程代码。读者按步骤操作可以独立写出UDP Pinger客户端和TCP通信程序直观对比两种协议在连接建立、可靠性、传输效率等方面的差异。文中提供可直接运行的参考代码和关键注释便于排查常见错误。资源仅包含1个PDF文件压缩包体积735KB内容精炼、章节清晰目前已吸引170人学习可作为实验报告参考或教学演示材料帮助读者在较短时间内掌握网络编程的核心方法并为后续深入学习奠定基础。1. TCP和UDP在Python里差的不是API是数据边界接手过一个用Python写的数据采集服务局域网里跑得好好的一上跨网段就“玄学”断连。后来确认是TCP连接被防火墙静默重置而对面设备只支持UDP。这件事让我意识到TCP与UDP的Socket编程表面是同一个socket模块的几句调用实际是两个完全不同的调试世界。TCP有严格连接状态、有重传、有粘包问题UDP无连接、无边界之外的任何保证。这篇笔记面向要用Python做数据采集、设备通信、后端消息转发的从业者按“先立规则、再写代码、最后排坑”的顺序把可直接照抄的实现和必须知道的参数陷阱一起讲清楚。适合新手照着写也适合熟手确认自己没踩漏是否处理了半包、心跳、端口复用、UDP无回包这三种最容易翻车的场景。2. 先跑通TCP用Python验证三次握手并拆解阻塞模型写TCP socket代码前需要把三个状态搞清楚socket()只是拿到一个文件描述符connect()返回时TCP栈已经走完三次握手accept()拿到的连接则是已经完成握手的成品。很多人把accept看成“创建连接”其实它更像“从队列里取出连接”。我先写最小实现再讲状态和超时。2.1 最小服务端bind、listen、accept背后发生了什么import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 9000)) server.listen(10) print(TCP server listening on 0.0.0.0:9000) while True: conn, addr server.accept() print(faccept from {addr}) data conn.recv(1024) print(frecv {len(data)} bytes: {data!r}) conn.sendall(bpong) conn.close()这段代码能跑但它有几个关键点值得掰开说。socket(AF_INET, SOCK_STREAM)组合固定了“IPv4 TCP”这个协议族SOCK_STREAM就是“基于字节流的可靠传输”如果想改用UDP把SOCK_STREAM换成SOCK_DGRAM后半段代码全部要改这点后面再展开。setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)的作用是允许TIME_WAIT状态下端口被重新绑定第5章会专门讲避坑但开发环境建议直接写上。bind的“0.0.0.0”表示监听所有网卡换成“127.0.0.1”则只能本机访问做端口测试时最容易在这个地方看反。listen(10)里的10经常被误会成“最大并发连接数”实际上它只控制内核里已完成三次握手的队列长度。客户端完成握手而服务端还没accept时连接会堆在这个队列里队列满了之后新的连接请求会被内核直接丢弃或返回拒绝表现就是客户端connect超时但进程明明还活着。所以业务代码里要么快速accept要么accept完立即交给线程池不要让握手成功的连接在队列里等太久。accept()返回的conn是服务端一侧的新socket负责这条连接上收发原server socket只负责继续接收新连接这个“一服务一连接”的关系也是初学最容易晕的点。2.2 客户端connect与sendall握手完成但recv仍然没有消息边界客户端的代码更短import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.settimeout(3) client.connect((192.168.1.50, 9000)) client.sendall(bping) try: data client.recv(1024) except socket.timeout: print(recv timeout, close it) client.close() else: print(recv:, data) client.close()connect()成功返回只代表三次握手完成了本端发SYN、对端回SYNACK、本端回ACK这三次交换已经结束。这时候如果立刻抓包会看到连接状态是ESTABLISHED但这只表示“TCP栈认为连接可用”不代表对端应用已经准备好消息。connect可能抛出的异常有三种值得记ConnectionRefusedError说明对端回了RST通常是端口没服务TimeoutError说明SYN发出后没人理常见于防火墙丢弃OSError里最常见的子类是“Network is unreachable”或“Operation now in progress”前者是路由问题后者多半是在非阻塞socket上重复connect。sendall和send是另一个高频坑。send()未必一次发完所有字节返回值是本次实际写入内核发送缓冲区的字节数sendall()内部循环发送保证全部字节进入缓冲区但同recv一样“发送成功”不代表对端收到了只代表本端内核收了。TCP的可靠传输由内核的ACK/重传机制保证应用层能观测到的可靠信号只有对端recv返回非空、或者对端正常close。recv(1024)在阻塞模式下会一直等到本连接上有数据或对端关闭对端关闭后recv返回空字节串这个信号后面要单独讲。阻塞模式还有一个必须接受的事实一个recv只能属于一个线程。上面这版服务端在while True里accept后阻塞在recv第二个客户端连上来时即使三次握手已经完成accept也取不出来因为第一个客户端还没断开。这就是为什么生产级TCP服务端不能用这种写法。看到这里不要急着学并发先记住这个瓶颈第3章会把帧协议和心跳补上第6章再写事件驱动。2.3 用抓包和空字符串确认握手、挥手时序想在代码层面“看”到三次握手最直接的办法是抓回环包。服务端和客户端在同一台机器时用tcpdump能精确看到整个过程tcpdump -i lo -nn tcp port 9000运行服务端和客户端后输出里会出现SYN、SYNACK、ACK三行这就是三次握手随后如果客户端先close会看到FIN、ACK、FIN、ACK四行也就是常说的四次挥手。这里有一个容易误导新手的现象服务端代码里看不到任何握手状态因为握手由TCP协议栈自动完成但这不代表“没有握手”只是内核替你做了。排错时与其盯着客户端打印不如用ss -tnp看连接状态LISTEN表示服务端还在监听ESTABLISHED表示握手完成TIME_WAIT表示主动关闭方进入的2MSL等待。挥手阶段的代码语义也要对齐客户端close后服务端下一次recv会拿到b这不是数据是EOF信号。很多人在recv里没判断空串直接拿空数据去解析然后就翻车。所以处理TCP关闭的正确姿势是recv返回空串 - 对端已关闭 - 服务端也执行close释放连接如果服务端在收到EOF前直接close而客户端还在等回包可能触发RST而不是优雅的FIN挥手。这里提到的空串判断会和RST问题一起在第5章展开。3. 把TCP做稳帧协议、粘包处理与三层保活参数TCP能保证字节顺序和最终交付但它不关心你的消息边界。你要发一句“hello”和一句“world”内核可能把8个字节连续放在缓冲区里接收方一次读走这就是粘包反过来一条“helloworld”也可能被拆成两次读这是拆包。解决思路只有一个在应用层定义“帧”让接收方知道一条消息从哪里开始、在哪里结束。3.1 粘包与拆包先弄清内核缓冲区和应用缓冲区先解释现象发送端连续sendall三个100字节的数据接收端一次recv(1024)可能收到300字节也可能收到70字节。原因不是“TCP把数据粘在一起”而是recv()的语义是“从内核接收队列里取出不超过指定长度的字节”内核队列里有多少字节和你的sendall次数没有对应关系。TCP是字节流协议它只保证顺序和可靠性不保证把每条sendall当作独立消息这就是为什么很多从UDP转过来的人刚写TCP就会翻车。另一个容易混淆的点是“tcp协议包如何修改”这类问题。粘包是应用层边界问题改不了TCP头也不能通过调整TCP_NODELAY完全消除。TCP_NODELAY只关闭Nagle算法减少小包延迟但不等同于消息边界真正决定边界的是你自己的帧格式。tcp dup ack机制则是TCP栈在丢包重传时的ACK行为跟粘包无关调试时别把这两件事搅在一起。选择帧格式时固定长度最简单但短包要补零浪费带宽分隔符适合文本协议但payload里不能出现分隔符要转义长度前缀最通用适合二进制协议。Modbus TCP的MBAP头就是典型长度前缀结构“事务标识协议标识长度单元标识”4个字段里长度字段让接收方知道后面PDU有多少字节如果你要对接PLC设备几乎绕不开这套结构。3.2 长度前缀帧协议send_frame / recv_frame直接复制import socket import struct def send_frame(sock: socket.socket, payload: bytes): header struct.pack(!I, len(payload)) sock.sendall(header payload) def recv_exact(sock: socket.socket, length: int) - bytes: chunks b while len(chunks) length: piece sock.recv(length - len(chunks)) if not piece: raise ConnectionError(peer closed during frame) chunks piece return chunks def recv_frame(sock: socket.socket) - bytes: header recv_exact(sock, 4) (length,) struct.unpack(!I, header) if length 4 * 1024 * 1024: raise ValueError(frame too large, refuse it) return recv_exact(sock, length)这个封装是TCP应用层协议常见做法。struct.pack(!I, len(payload))把长度压成4字节大端整数大端网络序是约定俗成跨机器不需要考虑字节序recv_exact循环读满length字节是因为底层recv一次未必返回所有字节拆包会被这里吸收掉。recv_frame里对length做上限校验是生产环境必须加的如果不限制攻击方只要发一个声明长度为4GB的帧头你的recv_exact就会一直空等或申请巨大内存把进程拖死。4MB不是硬标准但一定要有。帧格式可以列成一张小表字段长度字节序用途length4字节大端记录payload字节数payload0~4MB-实际业务数据服务端主循环里配合使用server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 9000)) server.listen(16) while True: conn, addr server.accept() print(connected:, addr) try: while True: payload recv_frame(conn) print(addr, -, payload.decode(errorsreplace)) except (ConnectionError, ValueError): pass finally: conn.close()这里把ConnectionError和ValueError都当作“断开或非法帧”因为一次非法帧后应用层状态已经不可信直接关闭连接比试图恢复更稳。如果业务需要区分是掉线还是非法数据可以分别except后再打日志。send_frame里的sendall会循环发送所以上层不需要关心写缓冲满的问题真正需要关心的是sendall也可能长时间阻塞配合后面的settimeout才能避免一个慢客户端拖死服务端。3.3 心跳与断线检测SO_KEEPALIVE、TCP_KEEPIDLE、业务心跳TCP连接断开应用层不是立刻知道的。如果对端直接断电或网线断开本端可能一直觉得连接还在直到recv超时或发送失败才反应。内核内置的TCP保活能缓解但默认2小时才开始探测太慢了。可以用setsockopt调参数import socket s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) if hasattr(socket, TCP_KEEPIDLE): s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) if hasattr(socket, TCP_KEEPINTVL): s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5) if hasattr(socket, TCP_KEEPCNT): s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)参数含义TCP_KEEPIDLE是空闲多久开始探测TCP_KEEPINTVL是每次探测间隔TCP_KEEPCNT是连续失败几次判定断开。设成空闲60秒、每5秒探测、3次失败后判定死连接一共耗时75秒左右比默认2小时快得多。但内核保活只能保证“TCP栈层面探测到对端消失”应用层业务仍然可能卡在处理逻辑里所以更可靠的是应用级心跳。业务心跳常见做法连接建立后双方约定一个心跳帧比如每5秒客户端发PING服务端回PONG或者只记录最后活跃时间服务端每次收到任意帧都更新last_seen后台线程每10秒扫一遍发现超过阈值就close。这样即使对端不响应也能在应用层主动断开。底层的SO_KEEPALIVE可以继续开着但它作用有限不要当作唯一保活手段。调参时还可以留意Windows上netsh interface tcp show global查看系统级keepalive配置但应用层心跳参数不受它控制两者是独立的。4. 切到UDP收发、端口探测与多客户端会话管理UDP在Python里的代码量比TCP少但坑一点不少。少的是connect、listen、accept这些连接状态管理多的是收发边界、端口探测和广播。它的定位是“接受数据报丢了再补”的场景比如设备状态上报、日志传输、视频流、以及很多控制协议的广播发现。4.1 UDP的recvfrom与sendto无连接模型下的最小实现import socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind((0.0.0.0, 8000)) print(UDP listening on 0.0.0.0:8000) while True: data, addr s.recvfrom(2048) print(ffrom {addr}: {data!r}) s.sendto(back, addr)这里没有listen没有accepts就是唯一的socket。recvfrom返回两个值data是这一条数据报的内容addr是来源(ip, port)sendto把应答发回同一个addr就能天然回给那个客户端。这跟TCP完全不同TCP每个连接有自己的socketUDP所有包都从同一个socket进。第二个参数2048是单次接收缓冲区上限超过的部分会被内核丢弃这会造成“收到了包但不是完整包”的假象。如果业务字段可能超过1500字节建议把缓冲区调到4096或更高但别盲目调大超过路径MTU的包仍可能在IP层被分片。UDP选型还要知道它的“假更快”UDP少了握手和重传单包延迟低但应用层自己补重传、排序、去重的成本并不低。做端口测试时UDP的“无状态”也是一把双刃剑——TCP connect能立刻告诉你端口通不通UDP只能靠应用回包判断。另外UDP socket其实也可以调用connect()它的作用是记录默认目标地址之后sendto可以只传数据但recvfrom仍然能从任何来源收包这个行为不要和TCP的connect混为一谈。4.2 UDP端口探测为什么“没回包”不等于不通import socket def udp_probe(host, port, timeout1.0): s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(timeout) s.sendto(bprobe, (host, port)) try: data, _ s.recvfrom(2048) return True except socket.timeout: return None它先发一条“probe”给目标等回包如果目标主机不存在或者端口没有服务常见情况下内核会回ICMP Port Unreachable但Python的UDP socket默认收不到这个ICMP因为ICMP属于另一层协议所以recvfrom只能等超时。不能把“无回包”直接判定为“端口不通”因为防火墙可以静默丢弃UDP目标应用也可以选择不回应任何未知数据。这个脚本真正的用途是“探测服务是否存活”如果目标服务设计为必须响应特定UDP报文回包就是活着的证据如果只是往空端口灌数据只能得到“未知”这个结论。这个差异在做udp端口测试时非常关键。TCP端口测试只要connect()收到RST就能确定端口不可达UDP没有等价物。所以生产环境做UDP连通性监控时要在应用层设计一个握手包客户端发特定魔数服务端必须回另一个魔数双方约定好探测结论才可信。UDP网络调试工具也是这个思路先确认对端会不会回应再谈丢包率。4.3 广播与多客户端地址字典和SO_BROADCAST的用法广播在局域网设备发现里很常用。发送广播包需要显式打开SO_BROADCASTimport socket s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.sendto(bDISCOVER, (255.255.255.255, 8000))服务端收到DISCOVER后用recvfrom得到的addr回包客户端就能知道设备在哪。注意广播只能覆盖同一广播域路由器不会转发跨网段要改用组播或单播探测。嵌入式设备做以太网UDP测试时通常也是先用广播发现设备再转单播传数据。多客户端会话管理在UDP下有点特殊没有连接对象只能靠地址字典。常见做法是维护一个dictkey是addr元组value是会话状态和最后活跃时间sessions {} while True: data, addr s.recvfrom(2048) sessions[addr] time.time() if data bkeepalive: s.sendto(balive, addr) now time.time() for a in [a for a, t in sessions.items() if now - t 15]: del sessions[a]注意dict的key必须是recvfrom返回的addr原样不能自己拼“ip:port”再当作元组。另外客户端在NAT后面时这个addr是NAT网关分配的临时映射长时间不发包会被回收所以UDP应用层心跳间隔要比NAT超时短很多局域网内则不用太担心。清理会话时要避免在遍历dict时删除先取待删地址快照再删除代码里我用列表推导式生成待删地址。5. 避坑清单端口占用、空串返回与RST重连的排查记录这一章是我自己踩过不少次的坑每一条都按“现象-原因-解决”来写方便你遇到问题直接对号入座。5.1 bind失败Address already in use / Windows套接字地址已使用现象服务端代码第二次启动时报OSError: [Errno 98] Address already in use或者Windows下报“通常每个套接字地址(协议/网络地址/端口)只允许使用一次。”原因通常有三类上一个进程还没退出进程退出了但连接还在TIME_WAIT另一个程序占用了同一端口。解决先用lsof -i :9000Linux或netstat -ano | findstr :9000Windows查占用进程确认没有僵尸进程后再换端口重试。代码层面bind前设置SO_REUSEADDR能解决TIME_WAIT导致的重复绑定但要注意Windows上SO_REUSEADDR语义和Linux不完全一样。Linux上SO_REUSEADDR允许在TIME_WAIT状态下重用端口Windows上多个socket同时设置它也能绑定同一端口后果是数据可能被随机分给其中一个socket。所以Windows上不要为了省事给所有socket都开SO_REUSEADDR只有明确需要“TIME_WAIT后立刻重启服务端”时才开。如果要用多进程负载均衡Linux的SO_REUSEPORT是另一个选项不能和SO_REUSEADDR混为一谈。顺带说一句部署到容器环境时见过的“error response from daemon: ports are not available: exposing port tcp 0.0.0.0”本质也是端口被占用或未释放不是网络问题。在宿主机上用ss -lntp看监听端口确认没有冲突再启动比反复重启容器有效。5.2 recv返回空字符串是正常关闭还是被重置现象服务端recv()返回b日志上没有异常客户端进程也还在跑。原因这是对端正常调用close()后FIN到达本端recv返回EOF信号。它不是一个错误是“连接关闭”的标准通知。解决把b当成“关闭连接”分支不要交给业务解析。继续在这个连接上recv会一直返回b毫无意义。如果还想区分“正常关闭”和“异常RST”可以尝试再写一次写的时候抛BrokenPipeError或ConnectionResetError说明对端已经发送了RST写成功但recv还是空串则可能是对端只关闭了读半端。实际业务里不需要太纠结记一条warn日志即可。这里最容易翻车的写法是data conn.recv(1024); if data:然后省略else导致关闭信号被误认为空消息处理。5.3 客户端重连被RST先查未读数据和close顺序现象客户端断开后立即重连connect抛ConnectionResetError或服务端accept到的连接一收就报ECONNRESET。原因常见的是上一次连接中一端close时另一端还有未读取的数据TCP栈于是不回FIN而是回RST把连接强制重置。解决改close顺序。规范做法是“对端先close本端读到空串后再close”或者本端先shutdown(SHUT_WR)告诉对端“我不再发数据”等对端close后再close。不要两端同时执行close尤其不要在自己还有未读数据时直接close。客户端重连也一样如果老的conn已经处于异常状态重新new一个socket不要试着把旧的conn再connect一遍。客户端重连时报地址已在使用本质也是旧连接还占着本地端口新连接想复用相同四元组会被拒绝所以重连逻辑一定要“换socket、换本地端口”不要复用旧fd。5.4 UDP丢包与乱序先确认缓冲区再怀疑网络现象UDP客户端每秒发100个包服务端只收到70个收到的顺序还有跳号。原因可能有两个层次应用太慢收包循环没及时从内核队列取数据内核缓冲区溢出后到的包被丢弃或者网络路径本身丢包。UDP不保证顺序乱序是正常的丢包则要看是发生在哪一侧。解决先调大接收缓冲区s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)注意Linux上内核会把设置值翻倍或者受net.core.rmem_max限制设完可以用getsockopt看一下实际生效值。然后把“收包”和“业务处理”解耦recvfrom循环只负责把包放进queue.Queue业务线程再消费这样收包循环永远不堵。乱序则要靠应用层加序号接收端检查序号缺口并做缓存重排不要假设先到先处理。排查时用netstat -su看UDP buffer errors如果这个计数器在增长说明本端丢包是应用处理慢导致的跟网络没关系。5.5 shutdown socket创建失败同机端口池耗尽的长尾问题现象某些服务框架停机时日志出现“failed to create server shutdown socket on address [localhost] and port [802]”之类的报错看起来像网络崩溃随后端口迟迟起不来。原因很多框架会在本机临时端口上创建控制socket用来通知停机如果这个端口被其他进程占用或者系统端口池因为大量TIME_WAIT连接耗尽创建就会失败。解决先查端口占用再查TIME_WAIT数量。Linux上用ss -tan state time-wait | wc -l看堆积Windows下netsh interface tcp show global只能看全局TCP参数查TIME_WAIT还是用netstat -ano | findstr TIME_WAIT更直观。更实际的做法是给服务设置固定的shutdown端口并通过配置文件下发避免每次启动随机撞端口同时启动前先尝试绑定绑定失败就快速失败并给出明确错误而不是让应用内部把网络故障误报成“无法创建shutdown socket”。这个问题日志上很吓人但解决起来就是端口管理四件事谁占用谁释放谁在等待谁在复用。6. 进阶用selectors做事件驱动再用UDP打流验证吞吐6.1 selectors替换多线程阻塞模型当连接数到几百线程池反而是瓶颈每个线程默认8MB栈空间上下文切换吃CPU还要处理线程安全的收发缓冲。用事件驱动可以让一个线程管住上千连接。Python标准库的selectors模块在这里最合适它底层按平台选select/poll/epollWindows也能跑import selectors import socket sel selectors.DefaultSelector() def on_accept(server): conn, addr server.accept() conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, on_read) def on_read(conn): data conn.recv(1024) if not data: sel.unregister(conn) conn.close() return conn.sendall(becho: data) server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 9000)) server.listen(64) server.setblocking(False) sel.register(server, selectors.EVENT_READ, on_accept) while True: for key, mask in sel.select(timeout1): key.data(key.fileobj)这段代码里每个socket注册了一个回调select()返回可读事件后由回调处理。非阻塞模式下recv不会傻等所以一个线程就能循环服务多个连接。selectors模块不是性能最强的但足够覆盖绝大多数业务且跨平台。6.2 半包缓冲是事件驱动的分水岭上面这个echo例子埋了一个雷on_read里如果一次recv只读到半个长度前缀帧直接把data当完整消息处理协议就错了。事件驱动模式下on_read会被反复触发所以必须有一个半包缓冲器class FrameBuffer: def __init__(self): self.buf b def feed(self, chunk): self.buf chunk frames [] while len(self.buf) 4: length, struct.unpack(!I, self.buf[:4]) if len(self.buf) 4 length: break frames.append(self.buf[4:4 length]) self.buf self.buf[4 length:] return frames每次on_read把recv到的chunk丢进feed拿出来的frames是已经收完整的若干帧不够一帧就留在self.buf里等下一次事件。这正是阻塞模型里由recv_exact循环做的事事件模型下必须自己管理剩余状态。我最开始写selectors时没加这一层半包一来就乱拼日志里全是坏帧后来才意识到事件循环不等于自动处理拆包帧边界只能靠应用状态维护。6.3 用UDP打流验证设计TCP性能可以直接用iperf3压但UDP更需要自己定义“设计验证”打流前先约定回包格式接收端统计到达包的序号。在Python里做局域网UDP打流发送端给每个包带自增序号接收端每秒打印收到的总数和最大连续缺口。用iperf3时习惯iperf3 -u -c 192.168.1.50 -b 100M来压网卡但应用层丢包率还是要自己的序号统计才准因为iperf3测的是内核栈能收多少而Python应用还要考虑GIL和业务耗时。我最常犯的错是用阻塞模型搭完就上线等到并发上来才补selectors结果半包、超时一起炸。现在无论多小的工具我都会先把帧缓冲器和超时策略写进去再开始写业务逻辑。希望这些实现和踩坑记录能帮到你少走这几段弯路。本文还有配套的精品资源点击获取