COMP5416 Assignment:HTTP Caching Proxy 要求与代码详解

COMP5416 Assignment:HTTP Caching Proxy 要求与代码详解

课程:COMP5416/4416 Advanced Network Technologies,Semester 2 2026 截止:2026-10-16(周五,Week 10)23:59;迟交每天扣总分 5%,超过 5 天 0 分 分值:100 分(公开测试 80 + 隐藏测试 20),Ed 提交,根目录必须有 proxy.sh 我的结果:10 月 16 日 12:57 在 Ed 提交 Submission 1,64/64 个公开测试通过,公开部分 80/80 分。隐藏测试的 20 分在截图里看不到,这里不下结论。

这篇把原来的「作业要求梳理」和「实战详解」合成一篇,并且把重点放在代码上:每个函数是干什么的、一行一行在做什么、为什么要这样写。不懂的词先看第二节的小词典。

给老师讲的一段话(demo 用)

下面这段是对着 proxy.py 讲整体流程的口头稿,中英文各一份,大约一分半钟。每一句都能在第四节找到对应的代码,建议先看懂代码再背,被追问时直接翻到对应函数。

中文版

我的代理是一个只用 Python 标准库、直接操作 socket 的单文件程序。主线程监听指定端口,每接受一个客户端连接,就开一个线程运行 handle_client。这个函数先循环 recv,把收到的数据累积到缓冲区里,直到在缓冲区中找到空行 \r\n\r\n,因为 TCP 是字节流,一个请求可能分好几次才到。然后检查请求行是不是合法的完整网址形式的 HTTP/1.1 GET,不合法就回 400;合法就拆出 host、port 和 path,其中 path 保持原始字节,不解码。这三个值组成缓存键。如果缓存里有这个键,就直接把存好的字节原样发回去,不联系源站;没有的话,就连接源站,发一个只带路径、只有一个 Host 头、带 Connection: close 的请求,然后每收到一块数据就立刻转发给客户端,直到 recv 返回空字节,也就是源站关闭了连接。转发的同时我最多攒 51200 字节,如果响应状态码是数字 200 并且没超过上限,就存进缓存。缓存用 OrderedDict 实现 LRU,总量上限 102400 字节,放不下时从最久没用的一端开始淘汰,直到放得下。所有缓存操作都在同一把锁里,但锁里从不做网络操作,所以一个慢源站不会拖住别的客户端。最后关连接时我先 shutdown 再把剩余数据读空才 close,避免对方因为 RST 丢掉最后几个字节。

English version

My proxy is a single Python file that uses only the standard library and raw sockets. The main thread listens on the given port, and for every accepted connection it starts a new thread running handle_client. That function first calls recv in a loop and appends to a buffer until it finds the blank line \r\n\r\n, because TCP is a byte stream and one request can arrive in several pieces. It then checks that the request line is a valid absolute-form HTTP/1.1 GET and returns 400 otherwise; if it is valid, it extracts the host, port and path, keeping the path as raw bytes without decoding. Those three values form the cache key. If the key is in the cache, I send the stored bytes back unchanged without contacting the origin. Otherwise I connect to the origin, send an origin-form request with exactly one Host header and Connection: close, and forward each chunk to the client as soon as it arrives, until recv returns empty bytes, which means the origin closed the connection. While forwarding, I keep up to 51,200 bytes; if the status code is numerically 200 and the response fits, I insert it into the cache. The cache is an OrderedDict used as an LRU with a total limit of 102,400 bytes, and when a new response does not fit I evict from the least-recently-used end until it does. All cache operations happen under one lock, but the lock is never held during network I/O, so a slow origin cannot block other clients. Finally, when closing a client connection I call shutdown and drain any remaining data before close, so that a reset does not make the client lose the last bytes.

一、这个作业到底要做什么

一句话:用 Python 写一个「中间人」程序(代理)。浏览器之类的客户端不直接找网站,而是找你的代理要东西;代理替客户端去找真正存放内容的源站,把东西拿回来交给客户端,同时在自己手里留一份。下次有人要同样的东西,代理直接把留着的那份给他,不再去麻烦源站。

可以想成小区门口的快递代收点:

  • 第一次有人问「我的包裹到了吗」,代收点去仓库(源站)拿,交给你,并且把包裹记录存在本子上;
  • 第二次再有人问同一个包裹,直接从本子上翻出来给,不用再跑仓库;
  • 本子页数有限,写满了就把最久没人翻过的那条划掉(这就是 LRU);
  • 同时来了好几个人,代收点要能一起接待,而且不能把别人的包裹发错、本子也不能写乱(这就是并发)。

作业分四个 Part,在同一个程序上一步步叠加,入口始终是 ./proxy.sh <端口号>:

Part 要做什么 分值(公开+隐藏)
1 转发 读懂客户端的请求,去找对的源站,把完整响应原封不动交回去 24+6=30
2 缓存 响应存起来,同一个资源第二次直接给;认清「同一个资源」的标准 19+6=25
3 容量与 LRU 单个响应最多存 51200 字节,总共最多 102400 字节,满了淘汰最久没用的 20+4=24
4 并发 最多 8 个客户端同时用,共用同一个缓存,不能互相卡住 17+4=21

Part 1–3 一个一个顺序处理客户端也能过,Part 4 才要求同时处理。

协议范围(全程适用,也是你可以「不用管」的边界)

方面 规定
传输 客户端↔︎代理、代理↔︎源站,都是 TCP socket
请求 只有 GET,没有请求体;每个客户端连接只发一个请求
客户端发来的请求写法 完整网址形式,带端口:GET http://127.0.0.1:43172/a.bin?v=1 HTTP/1.1
代理发给源站的写法 只带路径:GET /a.bin?v=1 HTTP/1.1,路径和 ? 后面的内容原样保留
源站地址 一定是 127.0.0.1(本机),端口每次可能不同;不需要查 DNS
源站响应什么时候算完 源站关闭连接就算完(响应里不一定有 Content-Length)
响应内容 文字和任意二进制都要一个字节不差地保留
状态码 所有合法响应都要转发,404、500 也一样
非法请求 回 HTTP/1.1 400 Bad Request 然后关连接

明确不用做:持久连接、pipelining、chunked 编码、Cache-Control / ETag / 304 这些完整的 HTTP 缓存规则、缺失或重复的 Host 头、空路径、请求体、规格以外的错误处理。划掉这些,才不会把精力花在没分的地方。

评分、提交与诚信

  • 自动化黑盒测试:评分系统自己启动你的代理,自己造源站和客户端,只看网络上的表现,不看你的代码结构。
  • 公开测试(Ed 上能看到输入输出)占 80 分,隐藏测试占 20 分(只看通过与否)。隐藏测试不加新规则,重点是:TCP 数据被切成小块时能不能读对、缓存「同一个资源」的判断、没见过的请求顺序下的缓存淘汰、并发时缓存是否一致。所以公开测试全过不等于满分。
  • 提交:proxy.sh(根目录)+ 全部源码 + README.md。只能用 Python 标准库;禁止替你解析/转发 HTTP 的库(requests、urllib.request、http.client)。
  • README 要写:语言和版本、运行环境、构建步骤、运行方法、AI 使用声明(用了什么工具、怎么用的)。README 不评分,但没有它就不能对「环境导致的测试失败」申诉。
  • AI 可以用,但要声明;你必须能讲清楚自己提交的每一部分代码。之后会有一次 demo,老师会问你代码怎么工作。

二、先认识这些词(大白话)

词 意思 在代码里
socket(套接字) 程序用来收发网络数据的「电话听筒」。拿起来连上对方,就能说话和听话 socket.socket(...)、conn、origin
TCP 一种可靠的网络传输方式:数据一定按顺序到,但不保证一次到齐 全程
字节流 TCP 送来的数据像水流,没有「一句话一句话」的分界。你说的一句话,对方可能分几次才听完,也可能一次听到半句加下一句的开头 为什么要循环 recv
recv 「听一听」:从 socket 里拿一批数据。拿到多少不一定,拿到空的 b"" 表示对方挂电话了 conn.recv(4096)
bytes(b"...") Python 里「原始字节」类型。图片、压缩包这类二进制也是它。和 str(文字)不是一回事 响应全程用 bytes
decode 把 bytes 翻译成文字。二进制数据翻不成文字,强翻会报错或改坏内容,所以响应不能 decode 代码里没有对响应 decode
EOF End Of File,「没有更多数据了」。这里指源站关了连接,recv 返回空 if not chunk: break
请求头 请求里的「抬头信息」,以一个空行(\r\n\r\n)结束。\r\n 是换行 recv_request_header
host / port / path 主机(127.0.0.1)、端口(如 43172)、路径(/a.bin?v=1)。合起来就是「找谁、哪扇门、要什么」 parse_request
源站(origin) 真正存放内容的服务器 fetch_and_relay
缓存命中 / 未命中 代理手里已经有 / 还没有这个资源 HIT / MISS 日志
缓存键(key) 用来判断「是不是同一个资源」的标签,这里是 (host, port, path) key = (host, port, path)
LRU / MRU Least / Most Recently Used:最久没用过 / 最近刚用过。缓存满了,先淘汰 LRU 那一个 OrderedDict 的两端
线程(thread) 程序里「同时干活的小工人」。一个客户端分一个小工人,互不等待 threading.Thread
锁(Lock) 一把共用的钥匙。拿到钥匙的人才能改缓存,别人排队,防止两个人同时写乱 with self.lock:
RST 一种「粗暴挂断」。对方还有数据没读完时直接关连接,可能让对方丢掉最后几个字节 close_client 要避免它

三、代码地图

提交的东西只有三个文件:

文件 作用
proxy.sh 入口,一行:exec python3 "$(dirname "$0")/proxy.py" "$@"。exec 让 Python 直接取代脚本进程,评测系统发出的终止信号会直接作用到代理;$(dirname "$0") 保证从哪个目录运行都能找到 proxy.py;"$@" 把端口号原样传过去
proxy.py 全部实现,约 300 行,只用 socket、threading、collections.OrderedDict、sys
README.md 运行说明和 AI 使用声明

proxy.py 里的函数按「一个请求的旅程」排列:

main()                       ← 一直等客户端,来一个就开一个线程
└─ handle_client() ← 这个线程的总指挥
├─ recv_request_header() 读完请求头
├─ parse_request() 检查格式,拆出 host / port / path
├─ cache.get() 查缓存(命中就直接回,结束)
├─ fetch_and_relay() 没命中:去源站拿,边拿边转给客户端
│ └─ build_origin_request() 造发给源站的请求
├─ status_is_200() 判断能不能存
├─ cache.put() 存进缓存(必要时淘汰旧的)
└─ close_client() 优雅地挂断

四、逐段讲代码

4.1 开头:几个常量和两句固定回复

MAX_OBJECT_SIZE = 51200      # 单个响应最大字节数(包含边界)
MAX_CACHE_SIZE = 102400 # 缓存总大小上限(包含边界)
MAX_HEADER_SIZE = 65536 # 请求头最大 64 KB,防止被超大请求撑爆内存
RECV_SIZE = 4096 # 每次 recv 最多拿 4096 字节

BAD_REQUEST = (b"HTTP/1.1 400 Bad Request\r\n"
b"Content-Length: 0\r\n"
b"Connection: close\r\n\r\n")
BAD_GATEWAY = (b"HTTP/1.1 502 Bad Gateway\r\n" ... )
  • 把数字写成常量,一是好读,二是要改上限时只改一处。
  • b"..." 开头的 b 表示这是 bytes,因为要直接发到网络上。
  • 400 是规格要求的;502(源站连不上时回给客户端)是规格没要求的兜底。

4.2 main:等客户端,来一个开一个线程

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("", port))
server.listen(64)
while True:
conn, addr = server.accept()
t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True)
t.start()

逐行:

  1. 买一部「电话」:AF_INET 是 IPv4,SOCK_STREAM 是 TCP。
  2. SO_REUSEADDR:让你刚关掉代理马上重开时,端口不会因为「还在冷却」而绑定失败。
  3. bind(("", port)):占住指定端口,空字符串表示本机所有网卡。
  4. listen(64):开始接听,最多允许 64 个人排队等接。
  5. accept():停在这里等,有客户端连进来就返回一个新的 conn(和这个客户端专用的电话线)和 addr(对方地址)。
  6. 每来一个客户端,就开一个新线程去跑 handle_client。daemon=True 表示主程序被终止时这些线程不会拖着不让退出。

这就是 Part 4 并发的来源:主线程只负责接电话,每个客户端交给一个独立的小工人。一个小工人在等慢源站,不影响别的小工人。

4.3 handle_client:总指挥

def handle_client(conn, addr):
try:
try:
header = recv_request_header(conn)
host, port, path = parse_request(header)
except BadRequest as e:
log(addr, "400:", e)
safe_send(conn, BAD_REQUEST)
return

key = (host, port, path)
cached = cache.get(key)
if cached is not None:
log(addr, "HIT ", host, port, path)
safe_send(conn, cached)
return

log(addr, "MISS", host, port, path)
response = fetch_and_relay(conn, host, port, path)
if response is not None and status_is_200(response):
cache.put(key, response)
except OSError as e:
log(addr, "connection error:", e)
finally:
close_client(conn)

读法:

  1. 读头、解析:任何一步觉得请求不合格就抛出 BadRequest,这里接住,回 400,return 结束。
  2. 查缓存:键是 (host, port, path)。cached 不是 None 就是命中,直接把存好的字节发回去,return。
  3. 没命中:调用 fetch_and_relay 去源站拿。它会边拿边转给客户端,最后把完整响应返回(太大或出错则返回 None)。
  4. 能存才存:响应不为空、而且状态码是 200,才 put 进缓存。大小的检查在 put 里。
  5. finally: close_client(conn):不管上面走了哪条路(正常、return、出错),最后一定会执行,保证每个连接都被关掉。注意:try 里的 return 也会先执行 finally 再真正返回。
  6. 外层 except OSError:网络出错(比如客户端突然断线)只影响这一个线程,不会让整个程序崩掉。

4.4 recv_request_header:读完请求头

def recv_request_header(conn):
buf = b""
while True:
end = buf.find(b"\r\n\r\n")
if end != -1:
return buf[:end]
if len(buf) > MAX_HEADER_SIZE:
raise BadRequest("header too large")
chunk = conn.recv(RECV_SIZE)
if not chunk:
raise BadRequest("incomplete header")
buf += chunk

这是 Part 1 隐藏测试最爱考的地方。 因为 TCP 是字节流,测试会故意把请求切成很小的块,甚至切在 \r\n\r\n 中间。

  • buf 是累积的缓冲区,每次收到的新数据都接在后面。
  • 每一轮循环先在整个 buf 里找 \r\n\r\n,找到就说明请求头收齐了,返回头部(不含结尾的空行)。
  • 为什么要在整个 buf 里找,而不是只看刚收到的那块?假设 \r\n 在上一块的末尾、\r\n 在下一块的开头,只看新块就永远找不到。
  • 没找到就再 recv。如果 recv 返回空(对方没发完就挂了),当作非法请求。
  • 超过 64 KB 还没结束,也当作非法请求,防止被恶意撑爆内存。

4.5 parse_request:检查请求并拆出 host / port / path

def parse_request(header):
lines = header.split(b"\r\n")
parts = lines[0].split(b" ")
if len(parts) != 3:
raise BadRequest("malformed request line")
method, target, version = parts
if method != b"GET":
raise BadRequest("method not allowed")
if version != b"HTTP/1.1":
raise BadRequest("unsupported version")
for line in lines[1:]:
if b":" not in line or line[:1] in (b" ", b"\t", b":"):
raise BadRequest("malformed header field")

prefix = b"http://"
if target[:len(prefix)].lower() != prefix:
raise BadRequest("not absolute-form")
rest = target[len(prefix):]

slash = rest.find(b"/")
if slash == -1:
authority, path = rest, b"/"
else:
authority, path = rest[:slash], rest[slash:]
...
host, sep, port_bytes = authority.rpartition(b":")
...
port = int(port_bytes)
...
return host_str, port, path

以请求 GET http://127.0.0.1:43172/a.bin?v=1 HTTP/1.1 为例,一步一步:

  1. 按换行拆成多行,第一行是请求行,用空格切成三段:GET、http://127.0.0.1:43172/a.bin?v=1、HTTP/1.1。不是三段就是格式坏了。
  2. 方法必须是 GET,版本必须是 HTTP/1.1,否则抛 BadRequest(POST、HTTP/1.0 都会走到这里变成 400)。
  3. 其余每一行头都要长得像 名字: 值:里面要有冒号,且不能以空格或冒号开头。
  4. 目标必须以 http:// 开头,否则就是「只有路径」的写法,不是代理应该收到的格式。
  5. 去掉 http:// 后剩下 127.0.0.1:43172/a.bin?v=1,找第一个 /:左边 127.0.0.1:43172 是「主机:端口」,右边 /a.bin?v=1 是路径(包含 ? 后的内容)。
  6. 用 rpartition(b":") 从最右边的冒号切开主机和端口,端口必须是纯数字且在 1–65535 之间。
  7. 返回 ("127.0.0.1", 43172, b"/a.bin?v=1")。

关键细节:path 一直是 bytes,从来没有解码或改写。因为它马上要当缓存键的一部分,而作业要求「/file%2Fname 和 /file/name 是不同资源」,一旦解码成同一个就错了。

4.6 build_origin_request:造发给源站的请求

def build_origin_request(host, port, path):
return (b"GET " + path + b" HTTP/1.1\r\n"
+ b"Host: " + ("%s:%d" % (host, port)).encode("ascii") + b"\r\n"
+ b"Connection: close\r\n\r\n")

对上面的例子得到:

GET /a.bin?v=1 HTTP/1.1
Host: 127.0.0.1:43172
Connection: close

  • 请求行只带路径,不能把客户端发来的完整网址原样转发。
  • Host 头恰好一个,值是 主机:端口,来自请求目标,而不是客户端自己写的 Host。
  • Connection: close 告诉源站「发完就关连接」,代理才能靠「连接被关」判断响应结束。

4.7 fetch_and_relay:去源站拿,边拿边转

def fetch_and_relay(conn, host, port, path):
try:
origin = socket.create_connection((host, port), timeout=30)
except OSError as e:
log("origin connect failed:", host, port, e)
safe_send(conn, BAD_GATEWAY)
return None

kept = []
kept_size = 0
client_ok = True
try:
origin.settimeout(None)
origin.sendall(build_origin_request(host, port, path))
while True:
chunk = origin.recv(RECV_SIZE)
if not chunk:
break
if client_ok:
client_ok = safe_send(conn, chunk)
if kept is not None:
kept_size += len(chunk)
if kept_size > MAX_OBJECT_SIZE:
kept = None
else:
kept.append(chunk)
except OSError as e:
log("origin error:", host, port, e)
return None
finally:
origin.close()

if kept is None:
return None
return b"".join(kept)

这是整个程序最长的函数,分成四块看:

① 连源站:create_connection 拨通源站的电话(最多等 30 秒)。连不上就给客户端回 502、返回 None(表示没东西可存)。

② 发请求:连上后把超时取消(settimeout(None),允许慢源站慢慢发),用 4.6 造好的请求 sendall 出去。

③ 边收边转(核心循环):

  • origin.recv(4096) 拿一块数据;拿到空就 break,说明源站关连接了,响应收完。注意:不能因为「这次拿到的不足 4096 字节」就认为结束,它只是这一批碰巧小。
  • safe_send(conn, chunk):拿到一块就立刻转给客户端,不用等全部收完。这样大响应也不占很多内存。
  • client_ok 记录客户端是否还在听。如果客户端中途挂了,发送失败,client_ok 变 False,后面不再发,但源站这边还继续收到底,这样收完后仍然可以放进缓存。

④ 攒一份用来缓存:

  • kept 是一个列表,把每块数据都收进去,kept_size 记录总字节数。
  • 一旦超过 51200,说明这个响应注定不能缓存,就把 kept 设成 None,之后不再攒,省内存。(但仍然继续转发给客户端。)
  • 收完后:kept 是 None 就返回 None(太大,不缓存);否则 b"".join(kept) 把所有块拼成一整份完整响应(状态行 + 头 + 空行 + body)返回。
  • 中途网络出错(except OSError)返回 None:不完整的响应绝不缓存。
  • finally: origin.close():无论如何都把和源站的连接关掉。

全程没有 decode:收到什么字节就转什么字节,二进制文件也不会被改坏。

4.8 safe_send 与 close_client

def safe_send(conn, data):
try:
conn.sendall(data)
return True
except OSError:
return False

def close_client(conn):
try:
conn.shutdown(socket.SHUT_WR)
conn.settimeout(1.0)
while conn.recv(RECV_SIZE):
pass
except OSError:
pass
finally:
conn.close()
  • safe_send:sendall 会把数据全部发完;发送失败(对方已断线)时不抛异常,而是返回 False,让调用者决定怎么办。
  • close_client 为什么不直接 close()?如果客户端那边还有数据没读完就直接关,系统可能发出 RST(粗暴挂断),客户端会丢掉最后几个字节。所以分三步:
    1. shutdown(SHUT_WR):先告诉对方「我这边说完了」(发 FIN,礼貌挂断);
    2. while conn.recv(...):把客户端可能还在发的数据读空,最多等 1 秒;
    3. close():最后真正关闭。

4.9 status_is_200:能不能缓存

def status_is_200(response):
line_end = response.find(b"\r\n")
status_line = response if line_end == -1 else response[:line_end]
parts = status_line.split(b" ", 2)
return (len(parts) >= 2 and parts[0].startswith(b"HTTP/")
and parts[1] == b"200")

响应第一行长这样:HTTP/1.1 200 OK。按空格最多切成三段:HTTP/1.1、200、OK。只看第二段是不是 200。

为什么不简单写 b"200 OK" in 第一行?因为 HTTP/1.1 200(没有 OK)也是合法的 200,作业要求这种也要能缓存。404、500 等其他状态码照常转发,但这里返回 False,不会进缓存。

4.10 LRUCache:带容量的缓存

class LRUCache:
def __init__(self, max_object=MAX_OBJECT_SIZE, max_total=MAX_CACHE_SIZE):
self.max_object = max_object
self.max_total = max_total
self.entries = OrderedDict() # key -> 完整响应 bytes
self.total = 0 # 所有条目字节数之和
self.lock = threading.Lock()

def get(self, key):
with self.lock:
data = self.entries.get(key)
if data is not None:
self.entries.move_to_end(key)
return data

def put(self, key, data):
size = len(data)
if size > self.max_object:
return False
with self.lock:
old = self.entries.pop(key, None)
if old is not None:
self.total -= len(old)
while self.entries and self.total + size > self.max_total:
_, evicted = self.entries.popitem(last=False)
self.total -= len(evicted)
self.entries[key] = data
self.total += size
return True

数据结构:OrderedDict 是一个「记得顺序的字典」。这里约定:最前面 = 最久没用(LRU),最后面 = 刚用过(MRU)。total 单独记录当前一共存了多少字节。

get(查):

  • with self.lock: 先拿钥匙,防止别的线程同时改。
  • 找到了就 move_to_end(key):把它挪到最后面,表示「刚用过」。这就是规格里「命中后变成 MRU」。
  • 没找到返回 None。

put(存),一步步:

  1. 先判断大小:size > 51200 直接 return False,连锁都不拿、什么都不改。这满足「超大响应不能改变已有条目、总字节数和 LRU 顺序」。(所以不能先淘汰旧的再发现放不下。)
  2. 拿锁。pop(key, None):如果这个资源已经在缓存里(比如两个客户端同时请求,先后各存了一次),先把旧的删掉、把旧的字节数从 total 里减掉。这样同一个资源永远只占一份、只算一次大小。
  3. 淘汰循环:只要「当前总量 + 新响应 > 102400」就 popitem(last=False) 踢掉最前面(最久没用)的那个,并把它的大小从 total 里减掉。一次可能踢掉不止一个。
  4. 把新响应放到最后面(MRU),total 加上它的大小。

用规格里的例子走一遍:缓存里有 A(40000, 最久)、B(30000)、C(20000, 最新),共 90000。来了 D(40000):

  • 先判断:40000 ≤ 51200,合格;
  • 90000 + 40000 = 130000 > 102400 → 踢 A,剩 50000;
  • 50000 + 40000 = 90000 ≤ 102400 → 停;
  • 放入 D,顺序变成 B → C → D,总量 90000。

两个上限都是包含边界:正好 51200 能存(判断用 > 不是 >=),总量正好 102400 也行。

4.11 并发与「锁」到底在保护什么

多个线程同时跑 handle_client,它们共用同一个 cache。如果没有锁,两个线程同时执行 put,可能都读到同一个 total、都以为还放得下,结果总量超标,或者字典被改到一半被另一个线程读到。所以 get 和 put 里所有碰缓存的操作都在 with self.lock: 里。

但有一条更重要的规矩:锁里不能做网络操作。

  • 想象线程 A 拿着锁去等一个很慢的源站(要 2 秒)。线程 B 只想读缓存里现成的东西,却要等 A 把锁放掉,被拖了 2 秒。
  • 代码是怎么避免的:cache.get() 内部拿锁、查完马上放;recv、sendall、连源站全部在锁外;拿到完整响应后,才调用 cache.put() 短暂拿一下锁。锁只包住「内存里的几行操作」,用时极短。

还有一点:淘汰谁是在 put 的那一刻按当时的顺序决定的,不是请求开始时。所以线程 A 在等源站的时候,线程 B 命中了某个条目(把它挪到了最后面),A 之后插入时的淘汰顺序会自然反映这一点,符合规格 §6.3。

五、跟着一个例子走一遍

假设代理监听 52000,源站在 43172 上,有一个 /a.bin(200,3000 字节)。

  1. 第一次 GET http://127.0.0.1:43172/a.bin HTTP/1.1: main 接到连接 → 开线程 → recv_request_header 收齐请求头 → parse_request 得到 ("127.0.0.1", 43172, b"/a.bin") → cache.get 返回 None(未命中,日志 MISS)→ fetch_and_relay 连 43172,发 GET /a.bin ...,边收边转给客户端,收完得到 3000 字节 → 状态是 200 → cache.put 存入 → close_client 挂断。
  2. 第二次同样的请求:cache.get 返回之前存的 3000 字节(日志 HIT),原样发给客户端,没有连源站,return 之后 finally 挂断。
  3. 请求 /missing,源站回 404:转发给客户端,status_is_200 为 False,不存。下次再请求还要找源站。
  4. 请求一个 60000 字节的 200 响应:转发时 kept_size 超过 51200,kept 变 None,不攒;转发正常完成,返回 None,不进缓存、缓存其他内容完全不动。
  5. 客户端发了 POST http://...:parse_request 在检查方法时抛 BadRequest → 回 400 → finally 挂断,代理继续等下一个客户端。

六、最容易错的地方

坑 后果 代码里怎么避免的
一次 recv 就当作读完 分片测试、大响应失败 请求读到 \r\n\r\n,响应读到 recv 返回空
只在新收到的块里找 \r\n\r\n 被切开时永远找不到 每轮在整个 buf 里找
对响应 decode 二进制出错或被改 响应全程 bytes
把完整网址原样发给源站 违反规格 build_origin_request 重新构造
缓存键只用 path,或先解码 不同端口、%2F 的测试失败 键是 (host, port, 原始 path)
只存 body 命中回放字节不一致 存完整原始响应
用 "200 OK" in 行 判断 没有 OK 的 200 不被缓存 只比较第二段是否等于 200
先淘汰旧的,才发现新的放不下 超大响应改变了缓存 put 开头先比大小
边界写成 >= 正好 51200 / 102400 的测试失败 都用 >
同一资源存两次 总字节数算重 put 先 pop 同键旧条目
锁里等源站 并发测试失败 网络操作全在锁外
直接 close() 客户端丢尾部数据 shutdown 后读空再关

七、自测清单

自己写个脚本:在 127.0.0.1 随机端口开几个「假源站」(能数被访问了几次、能分小块慢慢发、能延迟、能返回指定大小),再用最原始的 socket 当客户端,逐条验证:

凑精确字节数的响应时,别忘了状态行和头也算在内,这是最容易算错的地方。

八、提交与 demo 答辩

提交:Ed 上传 proxy.sh、proxy.py、README.md;确认 proxy.sh 有执行权限(必要时 chmod +x proxy.sh);点提交看自动反馈。最终成绩取最后一次提交,尽早交,别压线。

demo:老师会让你解释代码怎么写、流程怎么走。建议你至少能不看稿说清楚:

  1. 整体流程:主线程等客户端,每个客户端一个线程;读头 → 解析 → 查缓存 → 命中直接回,未命中就去源站边收边转 → 200 且不超过 51200 就存 → 挂断。
  2. 为什么要循环 recv:TCP 是字节流,一次不一定收齐。
  3. 怎么判断响应读完:源站带 Connection: close,recv 返回空就是结束。
  4. 为什么不 decode:二进制数据会被改坏。
  5. 缓存键为什么是三元组、不解码:不同端口可能是不同内容,%2F 与 / 要区分。
  6. LRU 怎么实现:OrderedDict,最前面最久没用,move_to_end 表示刚用过,popitem(last=False) 淘汰最久没用的。
  7. 并发怎么做、锁放哪:一个线程一个客户端;锁只在 get / put 里,不包网络操作。
  8. 同时请求同一个没缓存的资源怎么办:各自去取,后存的替换先存的,只占一份。

你要提前知道的几个取舍(被问到时直接说清楚):代码没有限制线程数(规格要求「支持最多 8 个」,不限制也满足);没写端口时默认 80、502 回复、64 KB 头上限都是规格之外的兜底;缓存只在内存里,重启即清空,这是规格要求。

相关笔记