静态文件服务器

最后更新时间: 2018-07-22 | 作者: AberSheeran | 捐助

最近在造轮子,于是用Socket标准库造了一个服务器来模拟GithubPage。造轮子的过程里,遇到很多有意思的坑,由于对HTTP协议并不熟悉,作此文以记录。

简介

抛开更底层的TCP/IP协议,只看客户端和服务端的交互。那么HTTP其实只分两个部分。

sequenceDiagram opt request Client->>Server: GET /sitemap HTTP/1.1 ... end opt response Server->>Client: HTTP/1.1 200 OK ... end

客户端的请求由三部分组成,

  1. 请求行:类似于GET / HTTP/1.1

    分三个部分:

    1. 请求方法:一般常用的是GET与POST
    2. 请求资源:URI,基本上可以当作URL去掉域名和协议之后的剩下的部分
    3. 请求协议:现在常见的是HTTP/1.1,不过HTTP/2.0越来越流行。
  2. 请求头:类似于HOST: blog.cathaysian.cn,一般的Web框架或者爬虫框架,都把这个部分解析成一个Map。

  3. 请求体:一般是提交数据存放的地方,当然也见过骚操作把数据放进请求头的。

服务端的响应分三部分,

  1. 状态行:类似于HTTP/1.1 200 OK

    分三个部分:

    1. 响应协议:同上,常见的为HTTP/1.1
    2. 状态码:也就是所谓的200,302,404,502等等
    3. 状态码的文本描述:例如,200对应的是OK,404对应的是NOT FOUND
  2. 响应头:类似于请求头,在封装好的框架里也是一个Map,这个里面不仅指定了响应内容的类型,是否压缩等等,也会设置cookie,session等用以标识身份的玩意。所谓的登陆,其实就是截取了这部分,在之后的请求中发送过去,让服务器知道“Who are you?”

  3. 响应体:这部分也就是服务器回复的给你的内容。譬如 HTML页面

一个模拟GithubPage的静态服务器理论上的流程如下:

GithubPage流程图

细节

理论我们已经知道了,响应与请求都是通过Socket发送,那么它们实际上是什么格式呢?

Request:

1
GET /about.html HTTP/1.1\r\nHost:127.0.0.1\r\n...

可以注意到每一“行”都由\r\n隔开,由于此处为GET请求,所以没有Body。事实上请求体和请求头中间需要两个\r\n隔开。

同样的
Response:

1
HTTP/1.1 200 OK\r\nServer:Preview Server\r\n\r\n<!DOCTYPE HTML><html></html>

响应体与响应头之间也该由两个\r\n隔开。

编写一个服务器,哪怕是简易的,我们都应该使用函数来处理它们,这有助于代码的可读性。

作为习惯,我们一般把请求头和响应头作为一个Dict来处理。

使用Python处理起来很简单,只需要先编码为UTF-8(因为Socket传输的都是bytes),然后调用str的函数splitlines——这个函数的作用就是按分隔符来切割字符串,刚好符合我们的需求!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def __deal_request(self, request: str):
    # 会出现奇怪的空白访问,啥也没有,try一下,不处理
    try:
        # 获取请求的路径
        uri = self.pattern.match(request.splitlines()[0]).group("uri")
        uri = unquote(uri)
    except IndexError:
        sys.exit()
    headers = dict()
    body = ""
    for line in request.splitlines()[1:]:
        line.strip()
        try:
            value = line.split(":")[-1]
            key = line.replace(":"+value, "")
            headers[key.strip()] = value.strip()
        except IndexError:
            body += line
    return uri, headers, body

而由于此处只是一个静态文件服务器,所以不考虑POST等请求,只需要处理GET请求。同样的道理,也只需要处理404(文件不存在)和200(正确反馈文件)两种情况。响应是同样的道理,需要解码为bytes。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def __deal_response(self, status_code: int, header: dict, body):
    if status_code == 200:
        response_code = b"HTTP/1.1 200 OK\r\n"
    elif status_code == 404:
        response_code = b"HTTP/1.1 404 NOT Found\r\n"
    elif status_code == 403:
        response_code = b"HTTP/1.1 403 Forbidden\r\n"
    elif status_code == 302:
        response_code = b"HTTP/1.1 302 Moved Temporarily\r\n"
    # TODO 增加其他状态码
    response_header = b"Server: My Static Page Preview Server\r\n"
    for key, value in header.items():
        response_header += ("{}: {}\r\n".format(key, value)).encode("UTF-8")
    try: # 处理body
        body = body.encode("UTF-8")
    except AttributeError:
        pass
    response = response_code + response_header + b"\r\n" + body
    return response
1
2
3
4
5
6
def __set_response_file_type(self, path):
    uri_suffix = path.split(".")[-1]
    uri_type = mimetypes.types_map.get("."+uri_suffix)
    if uri_type:
        return uri_type
    return None

然后把这几个函数拼起来,作为每个请求的处理流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def __link(self, sock):
    request = sock.recv(2048)
    request = request.decode("UTF-8")
    uri, headers, body = self.__deal_request(request)
    # 打开文件,并设置响应内容
    # 如果最后是/,按照GithubPage的规则,解析到index.html上
    if uri[-1] == "/":
        uri += "index.html"
    # 切掉第一个/, 否则拼接路径会出错
    request_file = os.path.join(self.path, uri[1:])
    try:
        file = open(request_file, "rb")
        file_data = file.read()
        file.close()
    except FileNotFoundError:
        logging.warning("404 Not Found {}".format(uri))
        response = self.__deal_response(404, {"mood":"What the fuck are your request?"}, b"The file not found!")
    except PermissionError:
        logging.info("302 Redirect {}".format(uri))
        response = self.__deal_response(302, {"Location": uri +"/"}, b'May be you should append a "/" in url.')
    else:
        logging.info("Send file '{}'".format(uri))
        response_header = {"Content-Type": self.__set_response_file_type(uri)}
        response = self.__deal_response(200, response_header, file_data)

    sock.send(response)
    sock.close()

标准库实现

但有句话说得好,别自己造轮子(特别是这种没啥意义的)。所以我们直接拿标准库实现一下就行了

http.server

这是个十分好用的库,在命令行里,直接python -m http.server就可以建起一个静态文件服务器,但没有自定义的404和其他功能。所以还是需要我们自己写一点代码的

1
2
3
from http.server import HTTPServer, BaseHTTPRequestHandler
class Request(BaseHTTPRequestHandler):
    pass

按照上述的需求,需要重载的是do_GET(self)函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def do_GET(self):
    path = unquote(self.path)
    path = path.split('?', 1)[0]
    path = path.split('#', 1)[0]
    if path[-1] == "/":
        path += "index.html"
    if path[0] == "/":
        path = path[1:]
    request_file = os.path.join(BASE_PATH, path)
    try:
        page = readfile(request_file, True)
        self.send_response(200)
        self.send_header(
            'Content-type', self.__set_response_file_type(path))
    except FileNotFoundError:
        try:
            page = readfile(os.path.join(BLOG_PATH, "404.html"), True)
        except FileNotFoundError:
            self.send_error(404)
            return
        self.send_response(404)
        self.send_header('Content-type', "text/html")
    except PermissionError:
        self.send_response(302)
        self.send_header("Location", self.path + "/")
        page = b""

    self.send_header('Content-type', self.__set_response_file_type())
    self.end_headers()
    # Send the html message
    self.wfile.write(page)
    return

然后建立一个HTTPServer使用这个Handler

1
2
3
4
5
6
if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8000), Request)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass

完整代码可以参考Maltose.preview

标签: PythonHTTP
收录于#杂记