我们将分步进行,从最简单的原理开始,到一个健壮、可用的客户端/服务器程序。

核心原理
文件上传的本质是:将一个文件在客户端被读取,然后通过网络(Socket)发送,最后在服务器端接收并写入磁盘。
这就像我们通过一个“管道”把一个“水桶”(文件)从客户端运送到服务器。
关键问题在于,如何确保文件传输的完整性和可靠性?主要有两个挑战:
- 大文件处理:我们不能一次性把一个几百MB甚至几个GB的文件读入内存,那样会导致程序崩溃,必须采用分块读取和发送的方式。
- 数据边界问题:Socket 是一个流式协议,它只保证数据按顺序到达,但不保证数据包的边界,服务器端如何知道一个数据包的结束和另一个的开始?尤其是在我们发送完文件内容后,如何告诉服务器“文件已经发完了”?
解决方案:

- 分块读写:使用
read()方法指定一个块大小(如 4096 字节)来循环读取和发送文件。 - 定义协议:在发送文件内容之前,先发送一个“头信息”(Header),告诉服务器文件名、文件大小等信息,在文件内容发送完毕后,发送一个明确的“结束标志”。
简单但存在缺陷的版本(不推荐,但有助于理解)
这个版本直接发送文件内容,不包含任何元数据,它只能用于已知文件名和大小的情况,且容易出错。
客户端代码 (simple_client.py)
import socket
# --- 客户端配置 ---
HOST = '127.0.0.1' # 服务器IP地址,本地回环地址
PORT = 65432 # 服务器端口
FILE_TO_SEND = 'my_file.txt' # 要上传的文件名
# --- 创建Socket并连接 ---
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
print(f"已连接到服务器 {HOST}:{PORT}")
# --- 发送文件 ---
with open(FILE_TO_SEND, 'rb') as f:
# 循环读取并发送文件内容
while True:
data = f.read(4096) # 每次读取4KB
if not data:
break # 文件读取完毕
s.sendall(data) # 发送数据
print(f"文件 '{FILE_TO_SEND}' 发送完毕。")
服务器端代码 (simple_server.py)
import socket
# --- 服务器配置 ---
HOST = '127.0.0.1' # 监听本地地址
PORT = 65432 # 监听端口
# --- 创建Socket并开始监听 ---
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
print(f"服务器正在监听 {HOST}:{PORT}...")
conn, addr = s.accept() # 等待客户端连接
with conn:
print(f"已连接来自 {addr} 的客户端")
# --- 接收文件 ---
received_data = b''
while True:
data = conn.recv(4096) # 每次接收4KB
if not data:
break # 当recv返回空数据时,表示连接已关闭
received_data += data
# --- 将接收到的数据写入文件 ---
# 问题:服务器不知道文件名,只能固定保存
with open('received_file.txt', 'wb') as f:
f.write(received_data)
print("文件接收完毕。")
这个方案的缺点:
- 服务器不知道要接收的文件名,只能硬编码保存名。
- 如果文件很大,
received_data会占用大量内存,可能导致内存溢出。 - 客户端和服务器之间没有明确的“结束”信号,完全依赖
recv()返回空数据来判断,这在某些复杂网络环境下可能不可靠。
健壮且实用的版本(推荐)
我们引入一个自定义的协议头来解决这个问题,协议头包含文件名和文件大小,服务器根据这个信息来正确地接收和保存文件。
协议头设计
我们可以用一个简单的字符串来定义协议头,格式为:filename:filesize。
my_photo.jpg:1234567 (文件名是 my_photo.jpg,大小是 1234567 字节)。
客户端代码 (robust_client.py)
import socket
import os
# --- 客户端配置 ---
HOST = '127.0.0.1'
PORT = 65432
FILE_TO_SEND = 'my_large_file.zip' # 可以准备一个大文件来测试
def send_file():
# --- 获取文件信息 ---
filename = os.path.basename(FILE_TO_SEND)
filesize = os.path.getsize(FILE_TO_SEND)
# --- 创建Socket并连接 ---
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
print(f"已连接到服务器 {HOST}:{PORT}")
# --- 1. 发送协议头 ---
# 协议头格式: "filename:filesize"
header = f"{filename}:{filesize}".encode('utf-8')
# 为了让服务器知道协议头的长度,我们先发送头长度,再发送头内容
s.sendall(len(header).to_bytes(4, 'big')) # 发送4字节的头长度
s.sendall(header) # 发送头内容
print(f"已发送协议头: {header.decode('utf-8')}")
# --- 2. 发送文件内容 ---
with open(FILE_TO_SEND, 'rb') as f:
sent_bytes = 0
while True:
data = f.read(4096)
if not data:
break
s.sendall(data)
sent_bytes += len(data)
# 打印进度
print(f"\r发送进度: {sent_bytes / filesize * 100:.2f}%", end='')
print("\n文件发送完毕。")
if __name__ == '__main__':
send_file()
服务器端代码 (robust_server.py)
import socket
# --- 服务器配置 ---
HOST = '0.0.0.0' # 0.0.0.0 表示监听所有可用的网络接口
PORT = 65432
def receive_file():
# --- 创建Socket并开始监听 ---
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, PORT))
s.listen()
print(f"服务器正在监听 {HOST}:{PORT}...")
conn, addr = s.accept()
with conn:
print(f"已连接来自 {addr} 的客户端")
# --- 1. 接收协议头 ---
# 先接收4字节,获取协议头的长度
header_length_bytes = conn.recv(4)
if not header_length_bytes:
return # 连接已断开
header_length = int.from_bytes(header_length_bytes, 'big')
# 再接收协议头内容
header_bytes = b''
while len(header_bytes) < header_length:
chunk = conn.recv(header_length - len(header_bytes))
if not chunk:
return # 连接异常中断
header_bytes += chunk
# 解析协议头
header = header_bytes.decode('utf-8')
filename, filesize_str = header.split(':')
filesize = int(filesize_str)
print(f"准备接收文件: {filename}, 大小: {filesize} 字节")
# --- 2. 接收文件内容 ---
received_bytes = 0
with open(filename, 'wb') as f:
while received_bytes < filesize:
# 每次最多接收4096字节,但也要考虑剩余文件大小
data_to_read = min(4096, filesize - received_bytes)
data = conn.recv(data_to_read)
if not data:
# 连接提前关闭,文件不完整
print("\n错误:连接提前关闭,文件接收不完整!")
return
f.write(data)
received_bytes += len(data)
# 打印进度
print(f"\r接收进度: {received_bytes / filesize * 100:.2f}%", end='')
print(f"\n文件 '{filename}' 接收完毕。")
if __name__ == '__main__':
receive_file()
如何运行
- 准备文件:在你的工作目录下创建一个要上传的文件,
my_large_file.zip。 - 启动服务器:在一个终端中运行
python robust_server.py,你会看到 "服务器正在监听..." 的提示。 - 启动客户端:在另一个终端中运行
python robust_client.py。 - 观察结果:
- 客户端会显示连接成功和发送进度。
- 服务器会显示连接信息和接收进度。
- 传输完成后,在服务器端所在的目录下,会出现一个与客户端文件同名的
my_large_file.zip。
代码解析与关键点
-
协议头 (
Header)- 为什么需要? 它是客户端和服务器之间的“合同”,告诉对方要传输什么、传多少。
- 如何发送? 我们采用“先发长度,再发内容”的方式,服务器先读4个字节,知道头有多长,然后再循环读取直到把整个头内容读完,这完美解决了数据边界问题。
-
分块读写 (
read(4096),recv(4096))- 为什么需要? 避免一次性将大文件读入内存,实现流式处理,内存占用极小。
- 大小 4096 是什么? 这是一个常见的块大小(4KB),可以根据网络状况调整。
-
进度显示 (
\r和end='')print(..., end='')让print不会在末尾换行。\r是回车符,将光标移动到行首,这样下一次print就会覆盖掉上一次的内容,从而实现一个动态更新的进度条效果。
-
os.path.basename()和os.path.getsize()basename用于从完整路径中提取文件名,防止路径信息被错误地当作文件名的一部分。getsize用于精确获取文件大小,这对于计算传输进度和验证文件完整性至关重要。
-
HOST = '0.0.0.0'(服务器端)- 在服务器端,使用
0.0.0作为绑定地址意味着它将监听本机所有网络接口(包括0.0.1和本机的局域网IP),这使得其他设备也可以通过局域网IP访问该服务器。
- 在服务器端,使用
这个健壮的方案已经可以满足绝大多数文件上传场景的需求,你可以基于此进行扩展,例如增加用户认证、多文件上传、断点续传等更高级的功能。
