这是《深入理解计算机系统》配套实验之一:代理实验

其实在两年前,我已经完成了这个代理程序,后来《深入理解计算机系统》出了第三版,而代理实验内容也加入了缓存的要求。实验中只要求实现HTTP/1.0的GET方法多线程代理和简单的缓存机制,但其实如果只实现了实验内的要求的话,这个代理服务程序还是无法适应目前这个网络环境的,所以本文介绍的是关于代理实验的增强版本。

原理

时至今日,HTTP协议已经非常复杂,一些内容不太常见,本程序也就不会实现。在开始设计代理服务程序之前,需要了解一下常见的HTTP协议的内容:

HTTP隧道

现在越来越多的网站从HTTP转向HTTPS,HTTPS的内容对于除了服务器和客户端之外的任何第三者都是不可见的,显然代理程序是无法也不能知道消息的内容,解决的方法是使用“隧道”[RFC7231]。隧道的创建过程如下:

  • 客户端发送建立隧道请求:
CONNECT proxy.example.com:80 HTTP/1.1
Host: proxy.example.com:80

通常隧道请求的端口是HTTPS端口443,请求体可能还是包含其他内容,这里就不需要关注了。

  • 代理返回消息:如果没有问题,那么代理返回代码2XX,返回其他代码则告知客户端无法建立隧道。
  • 消息传输:代理需要做的事情就是把客户端发送的数据发送给服务器端,以及把服务器端的数据发送给客户端,数据需要立刻转发,所以这里必须使用非阻塞I/O。
  • 关闭隧道:当代理检测到客户端/服务器任何一端关闭了连接,那么代理将端口与双方的连接,隧道关闭。

HTTP请求转发

对于未加密的HTTP连接,代理需要读取HTTP请求,根据请求中的信息来进行连接,代理最需要知道的就是需要连接的服务器的地址。代理收到HTTP请求部通常是这样的:

[方法] [URL] [HTTP版本]
...
Host: proxy.example.com
...

在没有代理的情况下,Host对应的是服务器的地址,但是在代理模式下,这个值被代理服务器地址占用,所以服务器地址只能从URL中获取。所以在将请求转发给服务器之前,需要将绝对URL(例如:http://[服务器地址][相对URL])中的服务器地址作为Host,将头部的URL换成相对URL,然后再转发给服务器。

由于互联网上大部分HTTP的请求使用GET和POST,所以程序只关心GET和POST方法:

GET方法

GET方法只有请求头而没有额外的数据,请求头的结束标志就是两个CRLF,所以当接受客户端的GET请求时遇到单独一行的CRLF,说明请求接收结束,代理改写请求头,将请求头发生给服务器。

POST方法

POST方法和POST方法的区别就是在请求头后跟了一些数据,数据的大小由头部的Content-Length决定。所以代理除了改写转发请求头之外,还需要根据头部指定的数据大小来转发数据。

HTTP应答转发

HTTP应答的头部同样以两个CRLF结束,但是数据大小的指定方法就比较多了,主要有三种方法:

EOF

是服务器向客户端传输数据,传输完了之后直接断开连接,简单粗暴。

Content-Length

在应答头中的Content-Length指出数据的大小(类似于POST请求),所以只要在读取应答头的时候把数据大小读取出来,然后转发一定数量的数据即可。

Chunked transfer encoding

如果服务器在发生回应的时候不确定数据大小,同时又不希望断开连接,那么可以使用分块传输[维基百科]。当应答头的Transfer-Encoding字段中出现chunked时,数据将采用分块传输。每一块数据第一行是一个表示块数据大小的十六进制数以及一个CRLF,接着是这一数据块的数据,然后每块数据以两个CRLF数据。当块大小为0的时候,说明数据传输结束。

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

25
This is the data in the first chunk

1C
and this is the second one

3
con

8
sequence

0

HTTP持久连接

从HTTP/1.1[RFC2616]开始,HTTP协议引入了持久连接并作为默认选项。也就是说,如果没有特别说明,客户端需要假定服务器会保持持久连接。这样一来,多次HTTP请求——响应可以在一个TCP连接中完成。HTTP也提供了关闭持久连接的选项,也就是再HTTP头中加入:

Connection: close

如果是客户端和代理服务器的通信的话,需要使用:

Proxy-Connection: close

如果客户端和代理之间、代理和服务器之间采用持久连接,就避免了每次请求创建一个TCP连接的浪费,提高了数据传输效率。由于客户端——代理之间的持久连接容易实现,而代理——服务器之间的持久连接非常复杂,所以本项目只实现了客户端——代理之间的持久连接

代理缓存

按照实验要求,需要实现一个代理的缓存。代理通常用于反向代理,而很少用于正向代理,所以这里只会实现一个非常简单的代理机制,并且只针对GET请求:

URL哈希值
URL哈希值
大小
大小
数据
数据
666
666
233
233
HTTP/1.1 ...
HTTP/1.1 ...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
...
URL
URL
http://sine-x.com
http://sine-x.com
...
...
...
...
...
...
...
...

程序维护了一张如上所示的表格,只有两个操作:

  • 查找:当收到请求后,程序需要在缓存中查找对应URL是否被缓存。由于URL往往比较长,比较字符串非常浪费时间,所以每次先根据哈希值查找,当哈希值相同后再比较字符串。
  • 存储:如果缓存中没有找到对象,那么需要将新的对象加入缓存。缓存使用FIFO替换策略,缓存维护了一个循环指针,每次将对象放入指针所指的位置中,然后将指针向后移动即可。

由于这里的缓存非常小,设计得比较简易。如果缓存非常巨大的话,需要将表格替换为哈希表,并且使用LRU替换策略来改善性能

实现

由于代码量有些大,所以就不贴出,代码见GitHub

代理服务器采用多线程方式,对于客户端的每一个连接请求,主线程创建一个新的线程来处理。

main
main
dispatcher
dispatcher
dispatcher
dispatcher
dispatcher
dispatcher
Pthread_create
<span>Pthread_create</span>
Pthread_create
<span>Pthread_create</span>
Pthread_create
<span>Pthread_create</span>
connect
connect

创建新线程之后,程序的主要逻辑如下:

开始
开始
CONNECT
CONNECT
POST/GET
POST/GET
判断请求类型
判断请求类型
读取一个请求
读取一个请求
结束
结束
隧道方式代理
隧道方式代理
转发方式代理
转发方式代理
是否保持连接?
是否保持连接?
读取一个请求
读取一个请求

参考