1. 问题背景与整体实现思路
做网络编程这块,很多人入门时都写过一对简单的 socket 回显 Demo,客户端发一句字符串、服务端收完原样返回,跑通就觉得完事了。可真到要做一个能用的文件传输工具,问题马上就会暴露出来。Python3 的 socket 库本身非常基础,TCP 协议也只是一个可靠的字节流管道,你会在它上面遇到一堆"课堂里不会教、文档里也搜不到"的细节问题:粘包、半包、大文件内存爆炸、传输中断后从头再来、进度条不知怎么算才算准……这个项目就是用纯标准库(socket + os + json + hashlib)把文件上传做完整,顺带解决上面这些问题。
我用的核心思路很简单:TCP 只管保证字节不丢不乱,至于这些字节代表什么含义、边界在哪,全部由应用层自己定协议来解决。整个系统只分两大部分——客户端负责读取本地文件、分包发送、显示进度、支持断点续传;服务端负责接收文件、拼接落盘、校验完整性、响应续传请求。难点不在于 socket 本身怎么用,而在于你把"业务逻辑"放到"字节流"上时,有没有能力把传输状态管理得足够清晰。
如果你只是自己练手,这个项目一次跑通并不难;但如果你将来要把文件传输做到真实产品里,这些人人都能搜到的教科书代码是远远不够的。所以在下面每一节里,除了完整代码,我会把"为什么这么做"拆开讲透,尤其是那些坑。我当年第一次写这类东西的时候,被人问到"你这个程序断了网还能继续传吗",满脸问号,然后老老实实回去重新设计。希望这篇文章能让你少走这个弯路。
2. 协议设计与关键技术点拆解
2.1 为什么必须自己定义应用层协议
TCP 是一个"流"协议,不是"消息"协议。它只保证你发送的字节按顺序到达,但不保证每次 send 的数据块和对方的 recv 恰好是一一对应的。比如你连续 send 两次,对方可能一次 recv 就把两段内容都收走了,这叫粘包;你 send 一个很大很大的数据块,对方可能分好几次 recv 才拿完,这叫半包。
所以实现文件传输的第一步,就是定义一套"帧格式"。我的做法非常简单,但非常实用:先发一个定长的 JSON 头部,里面存文件信息;再发文件内容本身。JSON 头部虽然不节省空间,但胜在开发效率高、容易调试,你不需要手工拼二进制结构体。对方先读满定长头部字节数,再用 json.loads 解析,拿到载荷长度后精确读取后续数据,这样一来粘包半包问题就天然规避了。
这背后其实是一个极其经典的套路:把不确定变成确定。头部长度固定,我用 4 个字节以网络字节序(big-endian)存一个 int 来代表"头部本身有多长"?还是干脆头部固定为 1024 字节、不够的用空格补齐?我最后选了前者,即先收 4 字节拿到头部长度,再收对应长度的 JSON。原因有两个:一是头部信息如果以后要扩展,不用动长度常量;二是节省流量,小文件的头部只有一两百字节就够了,不需要每次都填满 1024。
2.2 文件校验与进度计算的工程考量
除了粘包,文件上传还有数据正确性的隐忧。局域网环境相对干净,但 WiFi 丢包、磁盘坏道、代码里某个字节被错误处理,都可能让落盘文件损坏。所以我加了一个 MD5 校验:客户端算出整个文件的 md5,放进 JSON 头部;服务端接收完后重新计算落盘文件的 md5,如果不一致就返回失败。实际测试里,这个设计救过我一次——有一次客户端读文件时用了文本模式,Windows 下换行符被转换,传输几百 MB 后校验失败,一眼就定位到了问题。
进度条的计算没有太多玄学,但有一个小坑。传输总量是你提前算好的总字节数,传输进度则是每次 send 之后累计已发送字节数,两者一除就有百分比。但网络 send 并不保证一次全部发完,所以你必须关心 send 的返回值:
def send_all(sock, data): total = len(data) sent = 0 while sent < total: n = sock.send(data[sent:]) if n == 0: raise RuntimeError("连接断开") sent += n很多人第一次写 socket 发送时直接sock.send(data),以为一次就发完了。但 send 只是把数据交给操作系统缓冲区,缓冲区可能暂时放不下全部数据,所以实际发送的字节数小于 len(data) 是常态,尤其在数据块较大的时候。这个send_all函数是整个文件传输稳定性的地基,后面所有分块发送都会复用它。
2.3 内存管理:为什么大文件必须分块
如果一次性把整个文件 read 进内存再发送,小文件跑得很欢,一旦变成几个 GB 的大文件,内存占用直接爆炸,甚至导致程序被系统杀掉。正确姿势是设定一个固定大小的分块,比如 64KB,循环读取、循环发送。这里有个经验值问题:
- 分块太小(比如 1KB),循环次数太多,每次都有函数调用和 socket 开销,吞吐上不去。
- 分块太大(比如 16MB),虽然循环次数少了,但单次要卡在 send,而且如果网络抖动导致 send 长时间阻塞,进度条就卡死不动,体验很差。
- 我测试下来,4KB~256KB 之间对局域网和公网都算合理。10240 字节(10KB)是我个人比较喜欢的一个值,因为 TCP 的 MSS 大约是 1460 字节,10KB 不算大,不会因为超过缓冲区水位导致额外的延迟;同时循环次数又可控。
读取分块还有一个细节:f.read(chunk_size)在文件末尾会返回不足 chunk_size 的字节数,甚至返回空串,这时候要判断if not data: break,避免死循环。还有文件打开模式统一用rb/wb,二进制模式不参与任何编码转换,这一点在 Windows 上尤其重要。
3. 核心代码实现与实操过程
3.1 服务端完整实现
服务端我选择用threading.Thread为每个客户端开一个子线程,这样主线程可以继续 accept 新的连接。当然你也可以用socketserver.ThreadingTCPServer,但自己手写更能看清每个环节。
import socket, os, json, hashlib, threading HEADER_LEN = 4 CHUNK_SIZE = 10240 # 10KB def recv_exact(conn, n): chunks = [] received = 0 while received < n: data = conn.recv(min(n - received, 65536)) if not data: raise ConnectionError("连接被关闭") chunks.append(data) received += len(data) return b''.join(chunks) def handle_client(conn, addr): print(f"[连接] {addr} 已上线") try: header_len = int.from_bytes(recv_exact(conn, HEADER_LEN), 'big') header = json.loads(recv_exact(conn, header_len).decode('utf-8')) filename = os.path.basename(header['filename']) filesize = header['filesize'] file_md5 = header['md5'] offset = header.get('offset', 0) save_path = os.path.join('uploads', filename) os.makedirs('uploads', exist_ok=True) # 响应客户端:是否接受续传,以及下一字节的位置 if os.path.exists(save_path): exist_size = os.path.getsize(save_path) if exist_size >= filesize: conn.sendall(json.dumps({'code': 0, 'offset': filesize}).encode()) return conn.sendall(json.dumps({'code': 1, 'offset': exist_size}).encode()) else: conn.sendall(json.dumps({'code': 1, 'offset': 0}).encode()) # 收到确认后再接收文件数据 ack = conn.recv(4) if not ack: return mode = 'ab' if offset > 0 else 'wb' with open(save_path, mode) as f: remaining = filesize - offset while remaining > 0: data = conn.recv(min(CHUNK_SIZE, remaining)) if not data: raise ConnectionError("传输中断") f.write(data) remaining -= len(data) # 校验 md5 = hashlib.md5() with open(save_path, 'rb') as f: for block in iter(lambda: f.read(CHUNK_SIZE), b''): md5.update(block) if md5.hexdigest() == file_md5: conn.sendall(b'OK') print(f"[完成] {filename} 大小 {filesize} 校验通过") else: conn.sendall(b'FAIL') print(f"[失败] {filename} MD5 不匹配") finally: conn.close() print(f"[断开] {addr}") def main(): 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(5) print("服务器监听 9000 端口") while True: conn, addr = server.accept() t = threading.Thread(target=handle_client, args=(conn, addr)) t.start() if __name__ == '__main__': main()服务端逻辑有几点值得展开:
recv_exact是一个循环接收函数,保证收满指定字节数。socket 的 recv 一次最多返回缓冲区里当前可读的数据,可能小于你请求的长度,所以必须循环累加。response = {'code': 1, 'offset': exist_size}这一设计是整个断点续传的核心。客户端收到 code=0 表示服务端认为文件已经完整存在;code=1 表示允许上传,offset 是服务端本地已存在文件的字节数。- 不管新上传还是续传,客户端都必须把文件的 md5 发过来。续传完成后,服务端校验的是整个文件,而不是只校验新传来的那一段,这能发现本地已有部分是否损坏的极端情况。
3.2 客户端完整实现
客户端需要处理三个交互:发送文件信息、处理服务端响应、分包发送数据。断点续传时还要带上本地已经传过的 offset,只发送剩余部分。
import socket, os, json, hashlib, sys, time HEADER_LEN = 4 CHUNK_SIZE = 10240 def send_all(sock, data): total = len(data) sent = 0 while sent < total: n = sock.send(data[sent:]) if n == 0: raise RuntimeError("连接断开") sent += n def get_md5(filepath): md5 = hashlib.md5() with open(filepath, 'rb') as f: for block in iter(lambda: f.read(CHUNK_SIZE), b''): md5.update(block) return md5.hexdigest() def upload(filepath, host='127.0.0.1', port=9000): filesize = os.path.getsize(filepath) filename = os.path.basename(filepath) file_md5 = get_md5(filepath) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) header = { 'filename': filename, 'filesize': filesize, 'md5': file_md5, 'offset': 0 } header_bytes = json.dumps(header).encode('utf-8') sock.sendall(len(header_bytes).to_bytes(HEADER_LEN, 'big')) sock.sendall(header_bytes) resp = json.loads(sock.recv(1024).decode('utf-8')) if resp['code'] == 0: print("文件已存在,跳过上传") return offset = resp['offset'] header = { 'filename': filename, 'filesize': filesize, 'md5': file_md5, 'offset': offset } header_bytes = json.dumps(header).encode('utf-8') sock.sendall(len(header_bytes).to_bytes(HEADER_LEN, 'big')) sock.sendall(header_bytes) sock.recv(4) # 等待服务端确认 with open(filepath, 'rb') as f: f.seek(offset) sent_total = offset start_time = time.time() while sent_total < filesize: data = f.read(CHUNK_SIZE) if not data: break send_all(sock, data) sent_total += len(data) percent = sent_total / filesize * 100 elapsed = time.time() - start_time speed = (sent_total - offset) / elapsed / 1024 if elapsed > 0 else 0 print(f"\r进度: {percent:.1f}% 已传 {sent_total}/{filesize} {speed:.1f} KB/s", end='') print() result = sock.recv(4) if result == b'OK': print("上传成功,校验通过") else: print("上传失败,文件校验不匹配") sock.close() if __name__ == '__main__': if len(sys.argv) < 2: print("用法: python client.py <文件名>") sys.exit(1) upload(sys.argv[1])这段代码里有一点和网上的很多示例不一样:我发了两次头部。第一次是查询服务端本地文件状态,第二次才是真正开始传数据。为什么要多这一次握手?因为续传流程需要先知道服务端已有的 offset,这个值不会凭空产生,必须用一个请求去换一个响应。如果你把 offset 写死进第一次请求,那服务端响应的 offset 也就是你发过去的那个,没有任何意义,续传就变成了多次覆盖式重传。
这种"先协商再传输"的模式,其实就是 HTTP 里Range请求的缩小版。你完全可以类比理解:第一次请求相当于HEAD+If-Match,服务端根据已有文件返回 206 的起始位置;第二次才是真正的GET。理解了这一层,以后你再去做任何基于 socket 的传输系统,都会自然而然地先设计一个控制面再设计数据面,而不是一上来就裸传文件内容。
3.3 文件名安全与异常处理的多重保障
服务端那行filename = os.path.basename(header['filename'])不是多余的。客户端发过来的文件名如果包含../../etc/passwd这种路径,直接拼接到保存路径就可能造成路径穿越。虽然这是你自己写的客户端,不会故意捣乱,但代码一旦被别人拿去用,收到的数据就不再可信。os.path.basename只保留路径最后一段,一劳永逸地避免了这个漏洞。
还有连接异常的问题。无论是客户端还是服务端,只要对端 socket 关闭,你正在进行的 recv/send 就会抛ConnectionResetError或触发一个空字节返回。服务端捕获 ConnectionError 后关闭连接、打印日志即可。客户端如果传一半崩溃,其实不需要做太多处理——下次启动时,服务端检查到本地已有文件的大小,会自然告诉你该从哪个位置续传。这里有个经验我踩过坑的:续传的 offset 不能只看文件是否存在,还要看大小是否大于 0。如果上次传到一半服务端崩了,磁盘上留下一个 0 字节文件,你判断"文件存在"就直接返回 code=0 跳过,那等于把一个空文件当成品交付了。所以我在服务端用了exist_size >= filesize才算完整,否则一律允许重新上传,文件会以追加模式从已存在的长度继续写。
3.4 进度条刷新机制与传输速率的平滑计算
进度条的实现关键在两点:\r回车符和flush。绝大多数终端里,\n换行会另起一行,你想看到"原地刷新"的百分比,必须用\r回到行首,再用end=''阻止默认换行。同时 print 默认会有缓冲,不实时刷新可能直到程序结束才一次性输出,所以要么print(..., flush=True),要么在循环里显式sys.stdout.flush()。
速率计算的常规做法是已传字节数 / 总耗时,这算出来是从开始到现在的平均速率。但实际体验中,平均速率在刚开始的几秒内特别虚高,因为 TCP 拥塞窗口还在爬升,瞬间吞吐可能骗过平均值。我建议做"滑动窗口"统计:只记录最近 N 秒内发送的字节数,再用这段时间增量除以时间窗口:
window_data = [] window_start = time.time() window_bytes = 0 # 在循环里: window_bytes += len(data) now = time.time() window_data.append((now, window_bytes)) window_data = [(t, b) for t, b in window_data if now - t <= 3] if len(window_data) >= 2: speed = (window_data[-1][1] - window_data[0][1]) / (window_data[-1][0] - window_data[0][0])这种做法虽然代码略长,但进度条看起来"诚实"。用户看到的是最近 3 秒的真实速率,而不是一个被历史数据平均后的平滑值。实测下来,尤其是在网络环境波动较大的场景里,这种瞬时速率比平均值更有参考价值。
4. 断点续传的原理与实现细节
4.1 续传的核心机制:位置协商与 seek 定位
断点续传,说白了就是解决"我该从哪里开始发、你该从哪里开始收"的问题。TCP 的字节流是没有"游标"概念的,连接一断,之前传过的内容就彻底没了。文件传输想要续传,只能靠双方各自维护一个"已经传了多少字节"的状态,并在重连之后对齐这个状态。
我的方案分三步走:
- 客户端连接后,先发一个带文件信息的请求(含文件名、大小、md5)。
- 服务端查本地文件,如果已经存在一部分,就把
exist_size作为 offset 返回。 - 客户端拿到 offset 后用
f.seek(offset)定位到对应的文件位置,之后只发送剩余部分;服务端以ab模式打开文件,从末尾继续追加。
这里面容易被忽略的是"备份文件是否完整"的判断。如果上次传到一半,服务端本地文件的长度比客户端文件的 size 还大,说明大概率是上一个不同版本的文件,此时直接从头重传,而不是贸然续传。判断逻辑在服务端已经有了:if exist_size >= filesize: code=0跳过或重置。
还有一个细节:客户端在获取 offset 之后重新构造了 header 并再次发送。有朋友看完代码问我,为什么第二次头部里的 offset 要用服务端返回的值?因为客户端从不同服务端续传时,offset 不一定是本地某个可预测的值,必须以服务端视野为准。你本地可能因为上次程序异常退出,记录了一个错误的 offset,但服务端磁盘上真实存在的长度才算数。这么一设计,客户端崩溃、甚至换一台电脑重新传,只要服务端不丢文件,都能对接上。
4.2 校验失败的兜底策略:md5 不匹配怎么办
服务端在文件接收完毕后检查 md5,如果不匹配会返回 FAIL。但这样有一个问题:文件已经写进磁盘了,是一个损坏版本。下一次客户端再来续传,服务端会认为文件存在(因为大小对得上),然后 code=0 直接跳过,那就永远都是损坏文件。
我在实际部署时,对校验不通过的文件做了重命名,把它后缀改成.part,下次客户端来查询时,服务端看到.part文件不算有效文件,返回 offset=0 让客户端重新完整传输。这一招虽然简单,但免去了很多"文件一直传但永远校验失败"的诡异问题:
if md5.hexdigest() == file_md5: os.rename(save_path, save_path) # 正常完成,文件不动 else: os.rename(save_path, save_path + '.part')当然你也可以更激进,校验完直接删除损坏文件,但我建议先保留.part方便出问题时排查,等重传成功后让代码自动清理旧文件。两种方式没有绝对优劣,核心是:状态管理必须显式化,不能让"文件存在但内容损坏"这个中间态永远潜伏在系统里。
4.3 断点续传前后端联调踩坑实录
我写出第一版时,断点续传一直有个诡异问题:传大文件到 40% 左右时,手动断开客户端进程,再重新运行,服务端返回的 offset 总是比实际已写入的长度少一截。排查了很久才发现罪魁祸首不在 socket,而在文件写入的缓冲。
Python 的文件对象默认有缓冲层,f.write(data)并不一定立刻把数据刷到磁盘,而是先攒在内存里。客户端断开时,服务端进程没有正常关闭文件对象,缓冲区的数据就丢失了。于是磁盘上文件的物理大小 < 客户端已发送总量,下次续传自然从更早的位置开始。
解决办法有两个,二选一即可:
- 写文件时用
open(save_path, 'ab', buffering=0),关闭缓冲,每块数据直接落盘。 - 或者每次循环
f.flush()显式刷新。
我选了后者,因为完全关闭缓冲会大幅增加磁盘写入次数,降低吞吐。每传一块(10KB)flush 一次,对性能影响很小,却保证了"已写入文件长度"和"已发送字节数"几乎同步。这件事给我的教训是:socket 传输和文件写入是两个独立的缓冲系统,它们之间的状态一致性,全靠开发者自己保证。
5. 常见问题与排查技巧实录
5.1 粘包半包问题:现象与对策速查
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 先发的文件名和后发的文件内容混在一起 | 两次 send 被 TCP 合并成一个包,服务端一次 recv 拿走了全部字节 | 固定长度头部 + 循环 recv_exact,按头部里的长度精确切割载荷 |
| 文件内容只在服务端拿到一半,程序卡死 | recv 一次返回的数据量小于请求量 | 所有接收都用 recv_exact 循环读取,直到收满 n 字节 |
| 收到的 JSON 解析报错 | 头部长度字段用的本机字节序,不同机器解析不一致 | 统一用int.to_bytes(4, 'big')和int.from_bytes(..., 'big') |
| 上传完的文件 md5 不一致 | 读取客户端文件用了文本模式,Windows 换行被转换 | 统一用rb/wb,任何文件内容不经过字符串编码层 |
这个表格基本涵盖了我自己遇到的所有传输边界问题。新手最常见的就是第一条,以为 send 一次就是发一条消息,事实上 TCP 不是消息边界协议。把"定长头部 + 载荷"这个套路刻进脑子里,以后无论做什么协议设计,都能踩在坚实的土地上。
5.2 断点续传中"文件大小正确但 MD5 错误"的玄学
这里还有个值得单独拎出来的坑。有一次续传一个大文件,服务端显示文件大小和客户端完全一致,但 md5 就是不对。排查到后面才发现,服务端用wb模式在续传场景下打开文件,导致从头覆盖而不是追加。这个问题其实在代码里特别隐蔽——续传路径用的是ab,但新上传路径是wb。如果同一函数里两个分支写得不小心,就可能让续传走了新上传的逻辑,把前面传过的内容全部覆盖。
我的项目代码里使用的是同一个with open(save_path, mode),mode 由 offset 决定,这样就不会分叉。但如果你自己封装函数时把"打开文件"和"写入位置"分成两处,务必让打开方式跟 offset 强绑定。记住一个原则:续传语义下,文件打开模式必须与写入起始位置保持一致,这是排查这类问题时最快的审视路径。
5.3 网络异常后的重试策略与幂等性设计
网络传输不可能保证 100% 成功,但你的程序可以通过重试来逼近这个目标。最简单可用的重试策略是:客户端检测到 socket 异常后,最多重试 3 次,每次重试前重新连接、重新协商 offset,从上次失败的位置继续。这个策略建立在 4.1 节设计的"服务端返回真实 offset"之上,每次重试天然是幂等的——传了一半再重试,不会产生重复数据,因为服务端永远告诉客户端"从我这个位置开始"。
稍微讲究一点的做法是把重试写成指数退避:第一次失败等 1 秒、第二次等 2 秒、第三次等 4 秒,而不是疯狂重连给服务器压力。断点续传最大的优点就是重试成本极低,你不需要回滚任何状态。我遇到过在弱网环境传一个 2GB 文件,连续重试了 11 次才传完,但每次从断点继续,最终总耗时比一口气传完大不了多少。这种"低成本的接近成功的尝试"才是断点续传理念的最大价值。
5.4 多客户端并发上传时的服务端瓶颈
当两个客户端同时往服务端传文件,你很快会碰到一个新的性能问题:每个客户端线程都在频繁写磁盘,磁盘 IO 成为瓶颈。我实测同一个目录下两个线程同时写文件,SSD 上还不明显,换成机械硬盘就开始掉速。解决方案是给每个上传连接分配独立的子目录,或者用命名规范区分并发上下文中的文件归属。
真正的瓶颈往往在于recv之后立刻write + flush。如果你对实时落盘的要求没那么高,可以调整 flush 的频率,比如每传完 1MB 才 flush 一次,这样磁盘 IO 次数从"每 10KB 一次"降到"每 1MB 一次",并发写入压力会小很多。代价是服务端进程如果中途崩溃,最多丢失最后 1MB 未落盘数据。不过这部分数据在 md5 校验阶段一定会暴露,下次续传从更早的 offset 开始,也不会造成静默损坏,所以这个代价完全可以接受。
6. 实测结果与实际效果分享
6.1 同一局域网下的吞吐表现
我在两台局域网机器上实测了一个 4.2GB 的压缩包,客户端和服务端都跑在同一波段的千兆交换机下。默认 10KB 块大小,单线程传输,平均吞吐大约在 62MB/s,CPU 单核占用 35% 左右,内存占用恒定在 20MB 上下(因为始终只读 10KB 进内存)。
把块大小调到 256KB 后,吞吐略微提升到 64MB/s,但内存占用涨到了约 260MB。这说明在千兆局域网内,10KB 的块大小已经不会对吞吐造成明显瓶颈,TCP 窗口、网卡中断频率才是决定因素。对大多数场景来说,10KB 是更稳妥的选择,尤其是当你将来要把这个客户端跑在树莓派、路由器这类小内存设备上时,内存优势是实打实的。
6.2 弱网环境下的断点续传效果
我还用无线网络模拟了弱网环境,人为在传文件过程中切断 Wi-Fi 再重连。第一次传到 37% 断开,客户端重试后,服务端返回 offset 为 1.55GB,客户端直接从该位置继续发送,并没有从头开始。从整体耗时来看,第二次续传只花了总传输时间的 62%,与文件的剩余比例基本一致。那个体验上的差异是决定性的——大文件传输最怕的不是慢,而是"前功尽弃"。断点续传的核心价值在于把传输过程变成了可恢复的资源,网络波动不再是灾难,只是需要多一些耐心而已。
6.3 进度条与速率数据的可视化表现
实际运行时进度条大概长这样:
进度: 78.4% 已传 3294381056/4200000000 58.2 MB/s\r让这一行在原地不断刷新。这里再提醒一次:如果你更注重实时输出的视觉效果,可以同时把总耗时、剩余时间估算打上去,计算方式是剩余字节 / 最近窗口速率,比平均速率估算出来的 ETA 准确得多。
我自己实际使用中,还会把进度数据并行写到一份日志文件里,每一秒记录一档。这样传完之后可以回溯整个传输过程中速率的曲线,排查问题的时候尤其是定位"哪个时间段速率掉下来了"非常有用。
7. 扩展与后续优化方向
功能本身已经完整了,但这个系统离"产品级"还有一段距离。如果你感兴趣,以下几个方向都是我实际踩过坑之后觉得值得延伸的点。
并发传输。当前代码是单文件上传。如果要支持同时传多个文件,可以在客户端为每个文件创建一个发送线程,服务端不需要做任何改动,因为每个连接都是独立线程。但要注意控制并发数量,Windows 上 socket 句柄数过多会引发资源问题。
限速功能。公司的网络往往不只给你一个人用,上传时最好加上带宽限制。实现方式是每次 send 数据之前按目标速率计算间隔,time.sleep控制节奏。如果目标速率是 1MB/s,分块是 10KB,那大约每 10ms 发一块,用滑动窗口对实际速率做反馈调节,而不是死板地 sleep。
加密需求。这个系统裸传文件内容,没有加密。如果你要过公网,建议走 TLS。Python 内置的ssl库可以直接给 socket 加一层加密,代码改动极小:客户端ssl.create_default_context()包住原始 socket,服务端加载证书后context.wrap_socket即可。这里我特别提醒一点:证书校验记得做,不要为了省事直接设为CERT_NONE,否则等于没加密。
服务端管理界面。等到你开始频繁传输,就会想要一个"正在上传哪些文件、当前进度、历史成功率"的控制台。这个项目里我已经把服务端的各类事件以print的方式输出,后续可以换成日志模块logging,再按严重级别分类,方便对接监控告警。
单连接多文件。如果在一个 TCP 连接里上传多个文件,就需要更复杂的帧协议来区分"文件头部"和"文件数据",可以在头部里加一个msg_type字段,通过状态机驱动接收逻辑。这比"每个文件开一个连接"少很多握手开销,但复杂度也成倍上升。如果没有明确的性能瓶颈,建议不要过早引入。
这些扩展方向我在不同时间点都试过或者部分试过,工程上最有价值的反而是最简单的几个:并发、限速、日志。加密虽然重要,但通常不是自制小工具的第一需求。如果你按我的代码做完这个项目,再想更进一步,我建议优先加限速和日志,这两个对实际使用体验的提升是最显著的。