ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用Python标准库实现局域网大文件高速点对点传输工具

用Python标准库实现局域网大文件高速点对点传输工具

这个项目的起因特别朴素:同事让我给他传一个 4GB 的日志压缩包,我翻了半天抽屉没找到空闲 U 盘,打开聊天软件的传输功能又担心文件过期被清理,网盘上传更是慢到让人怀疑人生。折腾一圈之后,我决定花一个下午自己写一个文件传输软件,解决这种“大文件临时互传”的痛点。

这个软件本质上就是两个 Python 脚本:一个负责收,一个负责发。跑通的是局域网内点对点的 TCP 文件传输,支持中文文件名、大文件分块读写、SHA256 完整性校验,还带实时进度提示。没有花哨的界面,没有多余的第三方依赖,纯标准库实现,能在任意一台装了 Python 的机器上直接运行。

如果你也是那种不想为了传一次文件装一堆全家桶的人,或者正在学网络编程、想知道一个文件到底是怎么从一台机器“流”到另一台机器的,这篇文章应该对你有用。我把协议设计、核心代码、踩坑实录都摊开来讲,你照着复现一遍,基本就能跑出自己的版本。

1. 项目缘起:为什么放着现成工具不用,偏要自己写一个

1.1 现有方案的三个痛点

先说结论:市面上能传文件的工具非常多,但当你真的身处一个“两台电脑临时互传一个大文件”的场景时,大部分方案都差点意思。

第一个痛点是物理介质。U 盘、移动硬盘这种方案看着简单,实际上要求你手边刚好有设备,还得考虑文件系统格式和单文件大小限制。日志压缩包、数据库备份、虚拟机镜像这类文件动辄好几个 GB,格式不对根本写不进去。就算写进去了,来回插拔也费时间,传完还得确认两边文件是不是一致,整个流程非常啰嗦。

第二个痛点是聊天软件的传输功能。临时传文件给人,第一反应确实是打开聊天工具拖过去,但这类功能普遍有大小限制,免费情况下大文件要么被拒收,要么被转码压缩。更糟的是它的链路设计:明明是同一间办公室里两台机器互传,数据却要先上公网服务器再绕回来,走完全程的吞吐量比局域网直连差了一个数量级。

第三个痛点是网盘和共享文件夹。网盘理论上能传任意大小的文件,但上传和下载都受公网带宽限制,还要处理账号、权限、隐私一系列问题。局域网共享文件夹其实能解决大部分问题,但配置访问权限、处理跨系统差异、临时开放端口,都让“我就传一次文件”这件事变得异常沉重。

1.2 我当时的真实使用场景

我这次的触发场景很具体:两台机器在同一个办公网络里。一台是长期运行的服务器,上面攒了几个月日志和中间产物,大概 4GB;另一台是我的工作电脑,需要把这些文件拷回去分析。两边都没有公网 IP,也不方便插物理介质,用聊天软件传 4GB 基本等于放弃,网盘绕路不说,还经常连不上去。

这个场景大概是很多开发者的日常:不是没有工具,而是现成工具的重心都不在“局域网内快速点对点传文件”这个小需求上。与其花半小时找工具、注册账号、调权限,不如直接写一个几十行的脚本,用完就扔,留着下次继续用也行。

1.3 需求边界:先做减法再做加法

任何项目动手之前最值得做的一件事,是把需求边界划清楚。我的 v1 版只做四件事:

  • 把单个文件完整地从 A 机器传到 B 机器;
  • 支持任意大小,不做整文件内存加载,而是流式读写;
  • 传完以后能校验文件没有损坏;
  • 给一个看得懂的进度反馈,免得传 4GB 文件时心里没底。

明确砍掉的是:文件夹批量传输、压缩打包、断点续传、图形界面、多客户端并发、加密。这些功能确实有用,但 v1 的目的只是打通一条可靠的通路。先把链路跑通,再谈优化,这是我做很多小工具的通用原则:单点功能做到可信,比铺开一堆半成品有意义得多。

2. 方案选型与协议设计:动手写代码前先想清楚的三件事

2.1 选 TCP 还是 UDP

文件传输第一个绕不开的决策是传输层协议。TCP 和 UDP 的差别,用一句话说就是:TCP 是挂号信,UDP 是平信。挂号信有回执、有编号、丢了会重发、顺序不会乱;平信扔出去就完了,能不能收到全看运气。

文件传输对可靠性的要求是刚性的:文件是二进制数据,中间丢一个字节、顺序乱一段,整个文件就废了。所以我一开始就没考虑直接用 UDP 裸传。UDP 不是不能用,像视频流、语音通话这类允许少量丢包的应用,UDP 配合专门的协议才是主流方案;游戏位置同步、直播推流也大量用 UDP。但文件传输需要逐字节完整复原,TCP 已经把丢包重传、拥塞控制、流量控制这些脏活累活全处理好了,是成本最低的路线。自己基于 UDP 做可靠传输不是不行,要处理的事情非常多,几乎等于把 TCP 已经解决了十几年的事情再重新发明一遍。

对比如下:

对比维度TCPUDP
可靠性有确认和重传,数据完整有序不保证送达,可能丢包、乱序
传输效率有握手和拥塞控制,链路利用率高无握手,开销低
适用场景文件、网页、数据库连接音视频流、游戏同步、日志上报
实现复杂度直接用 socket 即可可靠传输需自实现一堆机制

这张表看完,选 TCP 基本没有悬念。真正需要花心思的是应用层的协议设计。

2.2 应用层协议:一条 JSON 头加上二进制流

TCP 是字节流,没有天然的“消息边界”,这是新手最容易卡住的地方。你在客户端写了一次 sendall,服务端并不能假定一次 recv 就能拿全;反之,文件数据可能被拆成很多个 TCP 分段到达,也可能多个分段粘在一起。所以应用层必须自己约定:一段数据从哪里开始、到哪里结束。

我用的方案很朴素:先用换行符作为分隔边界,传一行 JSON 元数据,紧接着传原始二进制文件流。JSON 里放三个字段:

  • filename:文件名
  • filesize:文件大小(字节数)
  • checksum:文件的 SHA256 摘要

这个设计有三个好处。第一,JSON 无状态、可读,调试时随手 print 就能看到双方约定了什么。第二,以换行符结尾,客户端发完头部后不用额外告诉服务端“头部有多长”,服务端读到换行就停止。第三,元数据和文件数据分离开,头部用文本编码,数据保持二进制原样,互不干扰。

有人会问,为什么不把头部做成固定长度?固定长度头部的问题是:文件名一长就装不下,短文件名又浪费带宽,还要定义编码、处理字节对齐。JSON 加换行的方案在灵活性上明显胜出,付出的代价不过是几十个字节的头部开销,对 GB 级的文件传输来说可以忽略不计。

2.3 分块读写与校验的必要性

确定协议之后还要解决一个工程问题:怎么处理大文件。如果直接把整个文件读进内存再 sendall,一个 4GB 的文件就能把机器内存吃穿。正确做法是分块读写:客户端每次从文件里读一小段,通过 TCP 发出去;服务端每次也只读一小段,写入磁盘。

我选的块大小是 64KB,这是实测里比较稳的折中值。太小会导致系统调用开销变大、CPU 空转;太大又会在内存里囤积过多数据,遇到慢速网络时容易造成缓冲区堆积,反而拖慢发送速度。

校验方面,我对每个文件发送前计算一次 SHA256,把摘要放进头部;服务端收完后对落盘文件重新计算 SHA256 做比对。文件传输最怕的不是速度慢,而是“传完了,但文件是坏的”。没有校验环节,坏文件会带着假象进入下游工作流,那才是真正的灾难。

3. 核心代码实现:服务端与客户端的完整落地

3.1 服务端:精确读字节是基本功

服务端代码看起来不多,但最核心的难点在一个函数:recv_exact。Python 的 socket.recv(n) 语义是“尽力读最多 n 个字节”,它可能只返回一小段。很多初学网络编程的人在这里踩坑:写一个 recv(1024) 就以为对方发来的 1024 字节一次全收到了,实际上可能只收到 200 字节。所以必须先实现一个保证“读满指定长度”的封装,不断循环 recv,直到凑够为止。

# server.py import hashlib import json import socket HOST = '0.0.0.0' # 监听所有网卡 PORT = 8765 # 与客户端约定好的端口 CHUNK_SIZE = 64 * 1024 # 64KB 分块 def recv_exact(conn, length): data = b'' while len(data) < length: packet = conn.recv(length - len(data)) if not packet: raise ConnectionError("连接意外断开") data += packet return data def recv_until_newline(conn): buf = b'' while not buf.endswith(b'\n'): ch = conn.recv(1) if not ch: raise ConnectionError("头部读取失败") buf += ch return buf def handle_client(conn, addr): print(f"[连接] {addr}") try: header = recv_until_newline(conn) meta = json.loads(header.decode("utf-8")) filename = meta["filename"] filesize = meta["filesize"] checksum = meta["checksum"] received = 0 with open(filename, "wb") as f: while received < filesize: chunk = recv_exact(conn, min(CHUNK_SIZE, filesize - received)) f.write(chunk) received += len(chunk) print(f"\r[接收] {received}/{filesize} bytes", end="") print() local_checksum = hashlib.sha256() with open(filename, "rb") as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break local_checksum.update(chunk) if local_checksum.hexdigest() == checksum: print(f"[校验] {filename} SHA256 一致") else: print(f"[校验] {filename} SHA256 不一致,文件损坏") except Exception as e: print(f"[错误] {addr} -> {e}") finally: conn.close() def main(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(5) print(f"接收服务已启动,监听 {PORT} 端口") while True: conn, addr = srv.accept() handle_client(conn, addr) if __name__ == "__main__": main()

这里有一个容易被忽略的细节:接收文件时的 while 循环条件是 received < filesize,每次读取长度取 min(CHUNK_SIZE, filesize - received)。这样最后一个不完整的分块能被正确处理,既不会多读,也不会因为简单地把 CHUNK_SIZE 当固定值而读超。

3.2 客户端:发送头部、数据与进度

客户端的逻辑和服务端镜像对称:先算好元数据和校验值,连接后发头部,再流式发送文件数据。

# client.py import hashlib import json import os import socket import sys CHUNK_SIZE = 64 * 1024 def file_sha256(path): h = hashlib.sha256() with open(path, "rb") as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break h.update(chunk) return h.hexdigest() def send_file(server_ip, port, path): filesize = os.path.getsize(path) filename = os.path.basename(path) checksum = file_sha256(path) client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((server_ip, port)) header = json.dumps({ "filename": filename, "filesize": filesize, "checksum": checksum, }) + "\n" client.sendall(header.encode("utf-8")) sent = 0 with open(path, "rb") as f: while sent < filesize: chunk = f.read(CHUNK_SIZE) if not chunk: break client.sendall(chunk) sent += len(chunk) print(f"\r[发送] {sent}/{filesize} bytes", end="") print() client.close() print(f"[完成] {filename} 已发送,SHA256: {checksum[:16]}...") if __name__ == "__main__": if len(sys.argv) != 4: print("用法: python client.py <服务端IP> <端口> <文件路径>") sys.exit(1) send_file(sys.argv[1], int(sys.argv[2]), sys.argv[3])

客户端最需要记住的 API 是 sendall,不是 send。send 不能保证一次把数据全部发出去,它只返回实际发送的字节数;而 sendall 内部会循环发送直到全部完成,或者直接抛异常。可以把 sendall 理解成“发送端的 recv_exact”,两边思路完全对称。

进度条用 \r 回车符做原地刷新。注意 print 默认的 end 参数是换行,这里手动改成空字符串,让同一行被反复覆盖,终端上看起来就是原地跳动的数字。

3.3 校验闭环:发送前算一次,接收后算一次

整个过程里,同一个文件会被计算两次 SHA256。发送端在读取文件发送前算一次,接收端在落盘后算一次,只有两个值一致,整个传输链路才算闭环。

SHA256 的计算是流式的:用 hashlib 对象反复 update 分块数据,而不是把整个文件一次性塞进内存。这和文件传输本身的分块策略是配套的。对大文件来说,一次性 update 和流式 update 结果完全一样,但内存占用天差地别。这个习惯也延续到我后来写的所有工具里:凡是处理大文件,一律默认流式,绝不做整文件载入。

4. 实操全记录:从启动到拿到第一个文件

4.1 环境准备:确认 IP 和端口

动手之前要做两件小检查。第一,确认两台机器在同一局域网,能互相 ping 通;第二,查一下服务端机器的内网 IP。命令行里执行 ipconfig 或者 ifconfig 就能看到结果,一般 192.168.x.x 开头的就是局域网的地址。

端口需要两端约定一致。我随机挑了 8765,避开常见的 80、443 和其他容易被占用的端口。如果 8765 被占,换成 8000-9999 之间的任意值都可以,只要两端一致就行。为了减少麻烦,最好固定写好,别临时起意改。

4.2 开两个终端跑起来

我用两台机器试一遍。第一台机器(接收端)先启动服务端:

python server.py 接收服务已启动,监听 8765 端口

第二台机器(发送端)执行:

python client.py 192.168.1.100 8765 ./big_log.tar.gz [发送] 2104534523/2104534523 bytes [完成] big_log.tar.gz 已发送,SHA256: 3f6a9b...

同一时刻,接收端的终端会打印出连接来源、接收的字节数,以及校验结果:

[连接] ('192.168.1.100', 54321) [接收] 2104534523/2104534523 bytes [校验] big_log.tar.gz SHA256 一致

整个过程没有任何多余依赖,Python 自带的标准库就完成了全部功能。文件越大,越能体会到这个方案比聊天软件和网盘省心的地方:数据不绕路,速度由局域网带宽决定。

4.3 确认结果:不只看日志,还要亲手验证

程序打印校验一致不代表万事大吉,我还会在接收端额外做一次文件对比。最朴素的办法是看文件大小是否一致,更稳妥的是手工算一次哈希:

sha256sum big_log.tar.gz

如果两边独立算出的值一样,传输才算终极闭环。这一步是我的个人习惯:宁可多花几秒钟验证,也不把错误带进后面的分析流程。真实工作里,文件损坏引发的排查成本远高于这几次校验的耗时。

5. 常见问题与排查实录

5.1 文件传一半就卡住,进度数字不动

第一次拿真实的大文件测试时,我撞上过进度条卡住的情况。排查下来有两个常见原因。

第一个原因是发送端把 sendall 想当然了,以为一次调用就能把全部数据推给内核。实际上 TCP 的发送窗口和接收端的接收窗口是动态的,网络拥堵时 sendall 内部会阻塞等待,这是正常现象,不代表程序死了,需要多一点耐心观察。

第二个原因才是真正的环境问题:如果两边用的是无线网络,信号差或者信道干扰会让 TCP 重传退避,大文件的尾部经常出现长时间无进展。这个不是代码能根治的,优先建议换有线连接测试。同样的文件,有线网络跑和无线网络跑,体感差非常多。

还有一个常见误操作是服务端写 recv(filesize) 想一次性读完整个大文件。TCP 不是这么工作的,超大参数的 recv 一样可能只返回一部分数据,丢了数据就会卡在读不满的循环里。必须走 recv_exact 的循环封装。

5.2 中文文件名保存出来是乱码

第一次跨环境传文件,接收端保存的中文文件名显示成乱码。原因很简单:源系统默认以本地编码处理文件名,目标系统以 UTF-8 解码 JSON,两边约定不一致。

解决办法是把文件名强制统一。客户端发送前用 os.path.basename 拿到名字后,直接放进 JSON 字符串,不要做任何额外的手工编码转换;服务端读取头部时统一用 UTF-8 解码。JSON 本身支持 Unicode,只要两端都用 UTF-8 来编解码整个头部,中文名就不会出问题。顺带提醒,源文件和目标文件的存储路径也尽量保持在同样的编码环境里,避免后续文件系统层面的二次乱码。

5.3 接收端保存出来的文件比源文件小

这个现象通常意味着文件数据没有完全到达。常见原因有两个。

一是 recv_exact 里没有处理连接异常的情况。对方中途断开会返回空字节,如果检测不到就会陷入死循环,或者提前返回不完整的数据。解决方式是每次读到空字节都抛异常,宁可失败也要让调用方知道链路断了。

二是客户端在发完文件后立刻调用 close(),而 TCP 是全双工的,close 之前必须保证所有数据都已经成功写入内核缓冲区。正常情况下 sendall 完成后 close 没有问题,但如果中间有任何一步出错被吞掉,文件就会悄悄少一截。我的建议是:客户端要把每个系统调用的异常都暴露出来,别静默吞掉错误。“传错了”不可怕,可怕的是“传着传着悄悄不吭声”。

5.4 报错:端口被占用

服务端第二次启动时报 address already in use,但明明上一个进程已经退出了。原因是 TCP 连接关闭后端口会进入 TIME_WAIT 状态,过一会儿才彻底释放。解决办法是在服务端代码里加一行:

srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

这行设置允许端口在 TIME_WAIT 期间内被重用,开发调试阶段几乎必加。不加这行,每次重启服务端都可能要等几十秒,体验非常差。

5.5 防火墙拦截,客户端 connect 超时

局域网环境下最常见的连接失败原因是防火墙。服务端程序首次监听端口时,系统会弹出网络权限提示,如果没放行,客户端的 connect 会一直超时。

排查方法是先在本机测试回环地址 127.0.0.1,确认服务端本身没问题;然后从客户端机器上测试一下服务端的 IP 和端口:

telnet 192.168.1.100 8765

能通说明网络路径没问题,不通就要去检查防火墙规则。在受控的实验网络里测试时,可以先临时放行对应端口,跑通后再把规则细化。

把这一阶段踩过的坑汇总成一张表,方便以后快速定位:

现象可能原因快速解法
connect 超时防火墙拦截、IP 错误、端口不一致本机 127.0.0.1 测试,再 telnet 对端端口
文件小一截recv 循环不完整 / 异常被吞用 recv_exact 并抛出连接异常
中文名乱码编码约定不一致头部统一 UTF-8
二次启动报错端口 TIME_WAIT加上 SO_REUSEADDR
进度条长时间不动无线网络不稳定、TCP 退避换有线网络再试

这五行是我踩坑经历的浓缩版,每次换新环境部署这个脚本,我都会先扫一遍再做大面积传输。

6. 优化方向:这版做完还能怎么改

6.1 多客户端并发接收

现在的服务端是串行处理的:一个连接没处理完,后面的连接都要排队。改进方式非常直接,把 handle_client 丢进线程池就行:

from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=4) while True: conn, addr = srv.accept() executor.submit(handle_client, conn, addr)

注意两点。一是文件写入路径要避免并发冲突,不同客户端传同名文件时最好加会话前缀或时间戳。二是线程池的大小不要盲目开大,文件传输属于 IO 密集型任务,4 到 8 个线程已经非常够用,开太多反而增加调度开销。

6.2 断点续传

断点续传是文件传输工具里最实用的进阶功能。实现思路是:客户端先向服务端发一个查询请求,探测目标目录里是否已有同名文件、已有多少字节;服务端返回已有长度后,客户端从源文件的偏移位置开始读取,头部带上 offset 字段;服务端则把文件打开方式改成 r+b,从已有位置继续写。

这里有个隐性问题:只知道大小,并不能证明已有的内容是完整的。所以断点续传通常要配合分块校验,比如把文件切成固定大小的块,逐块计算哈希,服务端比对后只请求缺失或损坏的那部分。这个复杂度比 v1 大很多,但也正是真正生产环境的传输工具必备的能力。

6.3 自动发现对端设备

现在还需要手工填服务端 IP,在地址经常变化的办公网里确实不够方便。可以加一个 UDP 组播发现机制:服务端启动时周期性地向约定好的组播地址发一条“我在这里,端口 8765”的广播消息,客户端在局域网监听这个消息,就能自动列出可用的接收端。

注意,这个 UDP 组播只是做发现,真正传文件仍然走 TCP。把“控制面”和“数据面”分开,是网络软件里很常见的设计思想:控制面要求轻、快、容错好,数据面要求稳、准、不丢。这个思路在很多系统里都适用。

6.4 加密传输

如果文件要穿越公网或者走不受信的网络,建议在 TCP 之上再套一层加密。Python 标准库里的 ssl 模块可以把现有 socket 包装成 TLS 连接,改造量不大:服务端用 SSLContext 加载证书,客户端用同样的上下文连接即可。唯一麻烦的是证书管理,小规模场景可以考虑自签证书加双方提前交换证书指纹,用指纹做身份校验,防止中间人攻击。

加密会带来一定性能损耗,但在公网传输场景下,数据安全远比速度重要。局域网内部使用可以不开,跨网络部署建议默认打开。

最后再说一点我在这个项目里最深的体会:工具类软件的价值,不在于功能堆得有多全,而在于它在关键时刻能稳定地完成一件小事。我给这个传输脚本保留了最朴素的命令行界面,没有任何花活,但之后每次需要跨机器传几个 GB 的文件时,它都稳稳地完成了任务,从不掉链子。

如果你也想写一个类似的小工具,我的建议是:第一次动手就把“异常处理”和“校验闭环”想清楚。这两个东西在 demo 里可以省掉,在真实的文件传输里却会直接决定工具可不可信。代码写得再漂亮,传出来的文件是坏的,一切都是白费。

返回列表