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() ← 一直等客户端,来一个就开一个线程 |
四、逐段讲代码
4.1 开头:几个常量和两句固定回复
MAX_OBJECT_SIZE = 51200 # 单个响应最大字节数(包含边界) |
- 把数字写成常量,一是好读,二是要改上限时只改一处。
b"..."开头的b表示这是 bytes,因为要直接发到网络上。400是规格要求的;502(源站连不上时回给客户端)是规格没要求的兜底。
4.2
main:等客户端,来一个开一个线程
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
逐行:
- 买一部「电话」:
AF_INET是 IPv4,SOCK_STREAM是 TCP。 SO_REUSEADDR:让你刚关掉代理马上重开时,端口不会因为「还在冷却」而绑定失败。bind(("", port)):占住指定端口,空字符串表示本机所有网卡。listen(64):开始接听,最多允许 64 个人排队等接。accept():停在这里等,有客户端连进来就返回一个新的conn(和这个客户端专用的电话线)和addr(对方地址)。- 每来一个客户端,就开一个新线程去跑
handle_client。daemon=True表示主程序被终止时这些线程不会拖着不让退出。
这就是 Part 4 并发的来源:主线程只负责接电话,每个客户端交给一个独立的小工人。一个小工人在等慢源站,不影响别的小工人。
4.3 handle_client:总指挥
def handle_client(conn, addr): |
读法:
- 读头、解析:任何一步觉得请求不合格就抛出
BadRequest,这里接住,回 400,return结束。 - 查缓存:键是
(host, port, path)。cached不是None就是命中,直接把存好的字节发回去,return。 - 没命中:调用
fetch_and_relay去源站拿。它会边拿边转给客户端,最后把完整响应返回(太大或出错则返回None)。 - 能存才存:响应不为空、而且状态码是 200,才
put进缓存。大小的检查在put里。 finally: close_client(conn):不管上面走了哪条路(正常、return、出错),最后一定会执行,保证每个连接都被关掉。注意:try里的return也会先执行finally再真正返回。- 外层
except OSError:网络出错(比如客户端突然断线)只影响这一个线程,不会让整个程序崩掉。
4.4
recv_request_header:读完请求头
def recv_request_header(conn): |
这是 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): |
以请求 GET http://127.0.0.1:43172/a.bin?v=1 HTTP/1.1
为例,一步一步:
- 按换行拆成多行,第一行是请求行,用空格切成三段:
GET、http://127.0.0.1:43172/a.bin?v=1、HTTP/1.1。不是三段就是格式坏了。 - 方法必须是
GET,版本必须是HTTP/1.1,否则抛BadRequest(POST、HTTP/1.0 都会走到这里变成 400)。 - 其余每一行头都要长得像
名字: 值:里面要有冒号,且不能以空格或冒号开头。 - 目标必须以
http://开头,否则就是「只有路径」的写法,不是代理应该收到的格式。 - 去掉
http://后剩下127.0.0.1:43172/a.bin?v=1,找第一个/:左边127.0.0.1:43172是「主机:端口」,右边/a.bin?v=1是路径(包含?后的内容)。 - 用
rpartition(b":")从最右边的冒号切开主机和端口,端口必须是纯数字且在 1–65535 之间。 - 返回
("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): |
对上面的例子得到:
GET /a.bin?v=1 |
- 请求行只带路径,不能把客户端发来的完整网址原样转发。
Host头恰好一个,值是主机:端口,来自请求目标,而不是客户端自己写的 Host。Connection: close告诉源站「发完就关连接」,代理才能靠「连接被关」判断响应结束。
4.7
fetch_and_relay:去源站拿,边拿边转
def fetch_and_relay(conn, host, port, path): |
这是整个程序最长的函数,分成四块看:
① 连源站: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): |
safe_send:sendall会把数据全部发完;发送失败(对方已断线)时不抛异常,而是返回False,让调用者决定怎么办。close_client为什么不直接close()?如果客户端那边还有数据没读完就直接关,系统可能发出 RST(粗暴挂断),客户端会丢掉最后几个字节。所以分三步:shutdown(SHUT_WR):先告诉对方「我这边说完了」(发 FIN,礼貌挂断);while conn.recv(...):把客户端可能还在发的数据读空,最多等 1 秒;close():最后真正关闭。
4.9
status_is_200:能不能缓存
def status_is_200(response): |
响应第一行长这样: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: |
数据结构:OrderedDict
是一个「记得顺序的字典」。这里约定:最前面 =
最久没用(LRU),最后面 = 刚用过(MRU)。total
单独记录当前一共存了多少字节。
get(查):
with self.lock:先拿钥匙,防止别的线程同时改。- 找到了就
move_to_end(key):把它挪到最后面,表示「刚用过」。这就是规格里「命中后变成 MRU」。 - 没找到返回
None。
put(存),一步步:
- 先判断大小:
size > 51200直接return False,连锁都不拿、什么都不改。这满足「超大响应不能改变已有条目、总字节数和 LRU 顺序」。(所以不能先淘汰旧的再发现放不下。) - 拿锁。
pop(key, None):如果这个资源已经在缓存里(比如两个客户端同时请求,先后各存了一次),先把旧的删掉、把旧的字节数从total里减掉。这样同一个资源永远只占一份、只算一次大小。 - 淘汰循环:只要「当前总量 + 新响应 > 102400」就
popitem(last=False)踢掉最前面(最久没用)的那个,并把它的大小从total里减掉。一次可能踢掉不止一个。 - 把新响应放到最后面(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 字节)。
- 第一次
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挂断。 - 第二次同样的请求:
cache.get返回之前存的 3000 字节(日志HIT),原样发给客户端,没有连源站,return之后finally挂断。 - 请求
/missing,源站回 404:转发给客户端,status_is_200为False,不存。下次再请求还要找源站。 - 请求一个 60000 字节的 200 响应:转发时
kept_size超过 51200,kept变None,不攒;转发正常完成,返回None,不进缓存、缓存其他内容完全不动。 - 客户端发了
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:老师会让你解释代码怎么写、流程怎么走。建议你至少能不看稿说清楚:
- 整体流程:主线程等客户端,每个客户端一个线程;读头 → 解析 → 查缓存 → 命中直接回,未命中就去源站边收边转 → 200 且不超过 51200 就存 → 挂断。
- 为什么要循环
recv:TCP 是字节流,一次不一定收齐。 - 怎么判断响应读完:源站带
Connection: close,recv返回空就是结束。 - 为什么不
decode:二进制数据会被改坏。 - 缓存键为什么是三元组、不解码:不同端口可能是不同内容,
%2F与/要区分。 - LRU
怎么实现:
OrderedDict,最前面最久没用,move_to_end表示刚用过,popitem(last=False)淘汰最久没用的。 - 并发怎么做、锁放哪:一个线程一个客户端;锁只在
get/put里,不包网络操作。 - 同时请求同一个没缓存的资源怎么办:各自去取,后存的替换先存的,只占一份。
你要提前知道的几个取舍(被问到时直接说清楚):代码没有限制线程数(规格要求「支持最多 8 个」,不限制也满足);没写端口时默认 80、502 回复、64 KB 头上限都是规格之外的兜底;缓存只在内存里,重启即清空,这是规格要求。
相关笔记
- COMP5416 Week 03 应用层讲课总结(HTTP 报文格式、Web 缓存/代理)
- COMP5416 Week 05 传输层讲课总结(TCP 字节流)
- COMP5416 Week 05 Tutorial:DNS 与 HTTP 时延