用Python拆解FTP协议:从socket实现到断点续传实战
发布时间:2026/9/28 8:58:16来源:尧图网络
简介这是一套基于Python实现的FTP协议通信示例面向具备Python基础、希望入门网络编程或理解应用层协议实际工作的开发者。压缩包内含服务端和客户端两个Python脚本分别实现服务器与客户端的核心逻辑服务端可监听多个用户连接并响应请求客户端则负责发起连接、浏览远程文件以及执行上传和下载操作。脚本共同覆盖文件与文件夹的双向传输支持Windows环境运行并通过多线程机制实现并发下载能力同时设计了简洁的交互界面实时显示当前程序的运行状态便于观察各步骤的通信过程。资源共2个文件均为py格式源码压缩包仅7KB结构精简、无多余依赖适合逐行阅读和二次修改。已有552人学习下载对想掌握FTP交互细节、学习socket编程、多线程并发处理或GUI集成方法的读者这套代码提供了清晰可运行的参考实现并可在其基础上继续扩展断点续传、权限控制、日志记录等实用功能。1. 用 Python 拆解 FTP 协议一份可运行的服务器与客户端FTP 协议听起来老掉牙但真要在 Windows 上把一台 Python 写的 FTP 服务器和客户端跑起来你会发现坑比想象的多。很多人天天在用文件传输却没有真正理解控制连接和数据连接是两条独立的 TCP 链路一旦防火墙策略不对命令通而数据不通界面显示已连接却传不了文件。这份代码包把服务端和客户端放在一起实现了文件和文件夹的上传下载支持多用户、多线程并发下载还带一个运行状态可视化界面。它适合三类人一类是想入门 Python 网络编程的大学生可以拿它对照协议学 socket一类是需要在局域网内快速搭建临时文件传输服务的工程师还有一类是准备面试基础协议的求职者用这份代码把主动/被动模式、命令响应码这些概念串起来。我的建议是先跑通再读协议反着来很容易被 RFC 959 里的术语吓退。2. 协议与代码结构控制连接、数据连接和文件操作是怎么落地的2.1 FTP 协议栈与 Python socket 编程基础FTP 是典型的“一个控制连接 多条数据连接”协议。客户端先连服务器的 21 端口这条连接只跑命令和响应文件内容完全走另一条数据连接。Python 中你用 socket 模块就能模拟这个过程。服务端创建一个监听 socketaccept 后得到一个连接对象循环读取一行命令再根据命令去开新的数据连接。常见做法是每个客户端连接丢给一个线程处理避免一个人传大文件卡住其他人。import socket import threading def handle_client(conn, addr): print(f[连接] {addr} 已接入) conn.sendall(b220 Welcome\r\n) while True: data conn.recv(1024) if not data: break line data.decode(utf-8).strip() print(f[命令] {line}) response handle_ftp_command(line) # 控制连接按 CRLF 换行FTP 命令是一行文本 conn.sendall(f{response}\r\n.encode(utf-8)) conn.close() def start_server(host0.0.0.0, port21): server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((host, port)) server.listen(5) print(f[服务] 监听 {host}:{port}) while True: conn, addr server.accept() threading.Thread(targethandle_client, args(conn, addr), daemonTrue).start()host 绑定 0.0.0.0 是为了让局域网其他机器能连进来如果只绑 127.0.0.1就只有本机能访问。listen(5) 的 5 是未 accept 的连接队列长度不是最大连接数。sendall 保证一次调用中的所有字节都被发出比 send 更稳而 recv(1024) 针对控制连接没问题因为命令文本很短。这段代码里的 handle_ftp_command 暂时占位下一节我会完整展开。2.2 服务端命令分发USER、PASS、SYST 和文件列表FTP 的控制连接本质是文本协议服务端按行读取命令。登录阶段客户端会发 USER、PASS服务端依次回 331 和 230之后客户端可能发 SYST、PWD、TYPE、PASV、LIST、RETR、STOR。有了这个命令分发循环后面的功能都是往里面加分支。代码包里服务端脚本的骨架大致是这样def handle_ftp_command(command_line): command, *args command_line.split( , 1) arg args[0] if args else cmd command.upper() if cmd USER: if arg test: return 331 Password required return 530 Invalid username elif cmd PASS: if arg 123456: return 230 User logged in return 530 Login incorrect elif cmd SYST: return 215 UNIX Type: L8 elif cmd PWD: return 257 / is current directory elif cmd TYPE: if arg.upper() I: return 200 Type set to I return 200 Type set to A elif cmd PASV: return handle_pasv() elif cmd LIST: return handle_list() elif cmd RETR: return handle_retr(arg) elif cmd STOR: return handle_stor(arg) elif cmd QUIT: return 221 Goodbye else: return 502 Command not implemented这里为了看懂流程我把会话隔离简化了真正的实现需要给每个函数传入 session 对象但命令流转顺序是一样的。命令参数用 split( , 1) 只拆第一个空格这样带空格的参数不会被错误切破。PASV 返回一串通过逗号分隔的 IP 和端口客户端解析后才能建立数据连接这是整个 FTP 协议里最容易写错的地方后面避坑章节专门讲。TYPE I 和 TYPE A 的区别一定要搞清楚默认很多客户端发 TYPE A但传输可执行文件、压缩包时必须切到二进制否则文件会损坏。下面这张常见命令表建议你贴在屏幕旁边。命令作用典型响应USER提交用户名331 / 530PASS提交密码230 / 530SYST查询系统类型215TYPE I二进制模式200PASV被动模式227LIST列目录150 → 226RETR下载文件150 → 226STOR上传文件150 → 226QUIT登出221响应码结构也很好记第一位 1 表示中间应答2 表示成功3 表示需要更多信息4 表示暂时失败5 表示永久失败。看日志时先看第一位就能猜出大概。2.3 客户端上传下载STOR、RETR 与文件夹递归客户端如果自己用 socket 写流程是连接 21读欢迎信息发送 USER/PASS 登录然后发 PASV 拿到数据端口再发 STOR 或 RETR 命令同时连上数据端口传输。文件夹递归必须在客户端遍历本地目录逐个传文件。如果只是验证服务端能不能用我一般先用 Python 标准库 ftplib 做一次快速登录测试因为 ftplib 把 PASV 细节都封装好了。from ftplib import FTP ftp FTP() # connect 的 timeout10 是 10 秒无数据则抛异常避免假死 ftp.connect(192.168.1.10, 21, timeout10) ftp.login(test, 123456) # 显式使用被动模式Windows 下主动模式容易被防火墙拦 ftp.set_pasv(True) ftp.cwd(/files) # 中文目录名不乱码的关键 ftp.encoding utf-8 # retrbinary 下载RETR 之后服务端通过数据连接传内容 with open(config.json, wb) as f: ftp.retrbinary(RETR config.json, f.write, blocksize8192) # storbinary 上传第二个参数是文件对象或回调 with open(local.txt, rb) as f: ftp.storbinary(STOR remote.txt, f, blocksize8192) ftp.quit()参数说明set_pasv(True) 对应服务端要支持 PASV 命令。blocksize 我一般用 8192网络环境差时可以降到 4096但它不是越小越稳太小会导致内核缓冲频繁切换。远程文件名和本地文件名可以不同STOR 后的 path 是上传到服务器之后的路径。文件夹递归上传的常见写法在后面我把每个本地文件的相对路径拼成远程路径然后逐个传import os def upload_dir(ftp, local_root, remote_root/): for dirpath, dirnames, filenames in os.walk(local_root): for name in filenames: local_path os.path.join(dirpath, name) rel_path os.path.relpath(local_path, local_root) # 统一把 windows 的 \ 转成 /FTP 目录分隔符用 / remote_path f{remote_root.rstrip(/)}/{rel_path.replace(os.sep, /)} remote_dir remote_path.rsplit(/, 1)[0] try: # 目录不存在时先建目录已存在时忽略 ftp.mkd(remote_dir) except Exception: pass with open(local_path, rb) as f: ftp.storbinary(fSTOR {remote_path}, f, blocksize8192) print(f上传 {local_path} - {remote_path})os.walk 返回三元组dirpath 是当前目录filenames 是普通文件名。rel_path 计算后Windows 下是反斜杠分隔所以要 replace(os.sep, /)。remote_dir 用 rsplit 取最后一个 / 前的部分。mkd 遇到目录已存在会抛异常我这里直接吞掉这是上传目录的常用处理。文件夹上传本质就是“逐文件上传 自动建目录”很多人把它想复杂了。3. 运行环境与界面设计让服务器状态可视化3.1 Windows 环境跑通的配置Python 版本与 vscode 环境配置压缩包要求在 Windows 下运行其实只要装了 Python 3.6 以上就能直接跑代码没有依赖第三方库。如果你还没装 Python去官网下载一个 3.10 的安装包安装时一定要勾选“Add Python to PATH”否则在命令行敲 python 会提示 python was not found。装完之后打开终端执行 python --version 确认版本再用 vscode 打开源码目录等 Python 插件识别解释器左下角状态栏能看到版本就能开始打断点调试。安装 Python 3勾选 Add Python to PATH。在 vscode 里按 CtrlShiftP输入 Python: Select Interpreter 选对解释器。终端里分别运行服务端脚本和客户端脚本先在本机自测 127.0.0.1再把客户端的地址改成局域网 IP。如果绑定 21 端口时提示 Permission denied说明端口被占用或被系统保留换一个高位端口比如 2121。Windows 下 21 端口经常被 IIS 或其它服务占用直接用 2121 省心。vscode 调试配置可以这么写{ version: 0.2.0, configurations: [ { name: Python: FTP Server, type: debugpy, request: launch, program: ${file}, console: integratedTerminal } ] }debugpy 是 vscode 默认的调试适配器旧版配置里 type 写 python新版会自动迁移成 debugpy。program 指向要启动的脚本用集成终端而不是外部控制台这样你可以同时看到 print 日志和 tkinter 窗口。实际跑之前先把客户端脚本里的主机地址从 127.0.0.1 改成服务端的局域网 IP别只改服务端绑定地址那是两码事。3.2 界面实现tkinter 显示运行状态设计界面显示运行状态最常见的方案是标准库 tkinter 加一个 ScrolledText 控件当滚动日志窗。服务端每 accept 一个新客户端、每收到一条命令、每完成一次传输就往这个窗口追加一行。注意 tkinter 不能在子线程里直接操作控件否则会卡死需要用 queue 把日志消息传给主线程再在主线程用 after 定时轮询。下面是一段日志窗骨架。import queue import tkinter as tk from tkinter import scrolledtext class FtpStatusWindow: def __init__(self): self.root tk.Tk() self.root.title(FTP 服务状态) self.log_queue queue.Queue() self.txt scrolledtext.ScrolledText(self.root, height25, width80) self.txt.pack() self.root.after(100, self._drain_queue) def _drain_queue(self): # 只在主线程操作 txt队列空了就结束本次刷新 try: while True: msg self.log_queue.get_nowait() self.txt.insert(end, msg \n) self.txt.see(end) except queue.Empty: pass self.root.after(100, self._drain_queue) def log(self, message): # 任意线程调用 log 都安全这里只入队 self.log_queue.put(message)after(100, ...) 的 100ms 是刷新间隔日志量大可以降到 50ms但不要低于 20ms否则 tkinter 主循环忙不过来。log 方法在子线程调用时不会直接操作控件所以不会引发 TclError。如果运行后发现窗口关闭但线程还在可以在窗口关闭协议里加一个 stopping 标志这是服务端进程优雅退出最简单的办法。注意 ifname main: 里要同时启动服务端线程和窗口线程否则 accept 循环会卡住主线程导致界面不刷新。3.3 多用户多线程并发的实现要点多用户并发不能靠一个主循环挨个处理命令。服务端每 accept 一个连接就应该开一个新线程线程里再处理该用户后续的所有命令。并发下载时多个客户端可能同时触发 PASV每个线程要用独立的数据监听 socket千万不要共用一个全局 socket。我对这份代码的改造建议是给每个客户端连接维护一个 FtpSession 对象记录用户名、当前目录、数据监听 socket再开一个线程跑会话。class FtpSession: def __init__(self, conn, addr): self.conn conn self.addr addr self.user None self.cwd / self.data_sock None def handle_client(conn, addr): session FtpSession(conn, addr) while True: line read_command(session) if line is None: break response dispatch(session, line) write_response(session, response) conn.close()说明read_command 和 write_response 是封装后的读写函数内部仍然走 conn.recv/sendall。session 对象是每个连接一个命令分发函数需要传入 session而不是只知道命令文本。PASV 命令进来session.data_sock 记录新开的监听端口LIST 命令进来用 session.data_sock 接受连接再发送目录清单。这个设计能避免端口串台。还有一个细节多线程写日志文件或操作共享的用户列表时要加锁。用 threading.Lock 包裹写日志那段避免 print 和 insert 出现交错。这是多线程服务最容易忽略的问题。4. 避坑指南FTP 调试最容易翻车的五个问题4.1 现象客户端显示登录成功但下载回来的文件是 0 字节原因数据连接没有建立成功。最典型的是被动模式下PASV 命令返回的 IP 是 127.0.0.1客户端拿着这个地址去连当然连不上服务端真正监听的内网地址。还有可能是防火墙只放行了 21 端口没有放行数据端口TCP 三次握手根本没建立。解决服务端生成 PASV 响应时要返回客户端能访问的 IP。简单方案是取 socket 的本机地址例如 socket.gethostbyname(socket.gethostname())更稳的方案是接受连接后用 conn.getsockname()[0] 拿到服务端地址。同时把被动模式端口范围在防火墙里放行。诊断时解析 227 响应的内网地址是不是 127.0.0.1import re response 227 Entering Passive Mode (192,168,1,10,195,100) m re.search(r\((\d),(\d),(\d),(\d),(\d),(\d)\), response) host ..join(map(str, m.groups()[:4])) port int(m.group(5)) * 256 int(m.group(6)) print(f客户端将要连接 {host}:{port})这段代码用正则把括号里的六段数字拿出来前四段拼 IP后两段拼端口。m.group(5) 乘 256 加 m.group(6) 是因为协议规定端口用高 8 位和低 8 位分开传。提示如果服务端有多个网卡用 getsockname()[0] 拿到的 IP 通常就是客户端实际访问的那个网卡地址比 gethostbyname 更可靠。4.2 现象上传大文件时界面卡死传输进度条不动原因服务端使用 recv 一直读但客户端已经发完数据并关闭了数据连接服务端却没等到 EOF因为循环条件写成了data conn.recv(4096); if data is None: break而 recv 返回空字节串才表示对端关闭。解决数据接收循环以data conn.recv(4096); if not data: break为退出条件。另外给控制连接和数据连接都设置 timeout比如服务端数据 socket 设置 settimeout(60)60 秒无数据自动断开。不要用 settimeout(None)那会把程序彻底挂死在系统调用里。出问题时先抓包看 FIN 包有没有发出来就知道是客户端没关还是服务端没读到 EOF。4.3 现象文件夹上传之后层级整个颠倒了多了一层同名目录原因递归目录时客户端把本地根路径也拼进了远程路径。比如本地目录是 D:\docs代码里直接 fSTOR {local_path} 把 D:\docs\a.txt 写到远程根目录下的 docs\a.txt导致多了一层。解决先做一次 os.path.realpath再用 os.path.relpath 把本地文件转成相对路径。我在前面 2.3 节给的 upload_dir 函数里关键就是 rel_path 和 replace(os.sep, /)。远程目录一律以 / 为根按照相对路径逐级创建。判断技巧是上传后看服务端根目录如果出现了和本地根目录同名的文件夹基本就是本地根路径被拼进去了。我一般会在客户端代码里加一个 debug 日志打印每次“远程文件路径 xxx”目录层级一眼就能对出来。4.4 现象中文文件名全部变成问号或上传后客户端报编码错误原因Windows 控制台默认用 GBK而 FTP 命令通道普遍默认 UTF-8。服务端 decode 时用了 gbk客户端又用 utf-8 解两边对不上文件名落到磁盘就成了 ???。解决服务端和客户端统一编码。Python 代码文件头部声明 utf-8读取命令时固定 line.decode(utf-8)响应也 encode(utf-8)。客户端 ftplib 设置 ftp.encoding utf-8。如果 Windows 控制台仍然显示乱码先用 chcp 65001 切到 UTF-8 代码页再判断是不是代码问题。# 代码文件头部声明 UTF-8Windows 下运行不乱码 # -*- coding: utf-8 -*- import sys sys.stdout.reconfigure(encodingutf-8, errorsreplace)reconfigure 是 Python 3.7 以后支持的写法旧版本可以用 sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8)。这样中文日志至少不会在控制台变成乱码文件名的传输编码问题仍然要从协议层统一。还有一个细节中文文件名在部分 FTP 客户端里会做 NFC/NFD 归一化Ubuntu 上传的中文目录名到 Windows 下可能会拆成两个字符这种跨平台问题只能在项目规范里约定文件名不要带特殊字符。4.5 现象多线程并发下载同一个文件下载完校验哈希不一致原因多个线程各自从相同偏移位置写本地文件没有用文件锁也没有把偏移量单独计算。FTP 协议中 RETR 是支持 REST 断点续传的哪怕是同一个远程文件每个线程也要告诉服务端从哪个字节开始传。解决客户端在 RETR 前发 REST offset告诉服务端从 offset 开始本地文件打开方式用 rb 并配合 seek(offset)。下载完成后用 hashlib.md5 对整个文件做一次校验不一致就重新下载失败段。我用的是下面的分块校验方式避免一次性把大文件读进内存import hashlib def verify_file(path): h hashlib.md5() with open(path, rb) as f: while True: block f.read(65536) if not block: break h.update(block) return h.hexdigest()这里用 64KB 分块读取既控制了内存占用也保证哈希结果和一次性读完整文件一致。校验通过后再把这几个线程的下载日志汇总就可以判定文件是否完整。5. 动手改造从示例代码到可复用文件传输服务5.1 配置化管理把 IP、端口、用户信息抽离代码包里默认是硬编码的测试账号直接拿来用可以但作为服务上线不方便。我建议抽一个 config.ini用 Python 内置 configparser 读取这样改端口、加用户都不用动代码。下面是我常用的配置样例和加载代码[server] host0.0.0.0 port2121 pasv_min50000 pasv_max50100 root_dirD:/ftp_root [users] test123456 zhangsanabc123import configparser config configparser.ConfigParser() config.read(config.ini, encodingutf-8) host config.get(server, host) port config.getint(server, port) pasv_min config.getint(server, pasv_min) pasv_max config.getint(server, pasv_max) root_dir config.get(server, root_dir) users dict(config.items(users))说明config.getint 会自动做字符串到 int 的转换写配置文件时不需要再包一层 int()。pasv_min 和 pasv_max 定义被动模式动态端口池服务端在范围内随机挑一个监听。Windows 防火墙如果只放行 21 端口PASV 传文件照样失败所以要把这两个端口范围和 2121 一起加进放行规则。注意 config.read 的 encoding 参数Windows 默认 gbk配置文件里写中文时要用 utf-8 保存否则读取会报错。5.2 增加断点续传用 REST 和 APPE 命令断点续传的核心是控制连接上的 REST 命令。REST 只在 RETR 之前有效作用是告诉服务端“从指定字节开始发”STOR 的追加写法是 APPE服务端收到 APPE 后会在远程文件尾部继续写。客户端实现时要注意发送顺序先 PASV 拿数据端口再 REST offset最后 RETR。顺序错了很多服务端会返回 503。from ftplib import FTP def download_resume(ftp, remote_path, local_path, offset0): f open(local_path, ab if offset else wb) if offset: f.seek(offset) # 先发 REST服务端进入指定偏移模式然后 RETR ftp.voidcmd(fREST {offset}) ftp.retrbinary(fRETR {remote_path}, f.write, blocksize8192) f.close()参数说明offset 是已经下载的字节数可以通过 os.path.getsize(local_path) 拿到。APPE 的用法类似对应 ftp.storbinary(APPE remote.txt, f)服务端会直接在文件末尾追加适合断点续传上传。注意 REST 偏移不能超过远程文件大小否则服务端会返回 554 或类似错误码这时要先发 SIZE 命令确认远程文件长度。服务端收到 REST 后需要在内存里记录 session.resume_offsetRETR 时用这个值 seek 到对应位置再发送数据。5.3 目录缓存与权限控制直接把 root_dir 暴露给所有用户不安全。我做权限控制的常见做法是每个用户登录后在内存里绑定一个虚拟根目录所有 cd 和 RETR、STOR 操作的路径都经过 realpath 校验绝对不允许跳出虚拟根。实现要点是把真实路径和虚拟路径的换算集中到一个函数里import os def to_real_path(session_root, virtual_path): safe virtual_path.lstrip(/) real os.path.realpath(os.path.join(session_root, safe)) if not real.startswith(os.path.realpath(session_root)): raise PermissionError(Path escape blocked: virtual_path) return real说明session_root 是登录时根据用户分配的根目录例如 root_dir/zhangsan。virtual_path 是从客户端命令里拿到的原始路径。startswith 判断在 Linux 和 Windows 上都要做因为 Windows 路径不区分大小写建议统一转小写再比较。这一层权限校验做好之后恶意客户端连续发 cd ../../ 也无法读到目录外文件。还有一个细节每次校验前对 session_root 也 realpath 一次避免用户配置里带了 .. 或者符号链接。目录权限做好后文件夹上传、下载的递归逻辑不用改因为所有路径都会过这一个函数。6. 验证与上线用自动化脚本做最后的回归检查上线前我不会手点着客户端一遍遍试而是写一个 30 行的回归脚本把登录、上传、下载、删除这四件事按顺序跑一遍跑完就出通过结论。脚本放在项目根目录以后每次改服务端代码都先跑它。import os import hashlib from ftplib import FTP def main(): ftp FTP() ftp.connect(127.0.0.1, 2121, timeout5) ftp.login(test, 123456) ftp.set_pasv(True) ftp.encoding utf-8 content os.urandom(1024 * 1024) with open(reg_test.bin, wb) as f: f.write(content) with open(reg_test.bin, rb) as f: ftp.storbinary(STOR reg_test.bin, f) with open(reg_down.bin, wb) as f: ftp.retrbinary(RETR reg_test.bin, f.write) src_md5 hashlib.md5(open(reg_test.bin, rb).read()).hexdigest() dst_md5 hashlib.md5(open(reg_down.bin, rb).read()).hexdigest() assert src_md5 dst_md5, 文件内容不一致 ftp.delete(reg_test.bin) ftp.quit() print(回归通过)说明我一般会故意在上传前加一个等待模拟传输中断场景配合 REST 断点续传逻辑做二次验证。哈希对比不能省略光看文件大小一致说明不了问题。还有一个自己项目里养成的习惯每台新电脑部署前先跑一遍这个脚本再改配置不然环境差异会吃掉你一上午。从那以后我每次改完服务端代码都强制把回归脚本和服务端启动对齐跑不通绝不上线希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网