重写 500 Lines or Less 项目 - Web Server

#开源
版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

本文章是 重写 500 Lines or Less 系列项目其中一篇,目标是重写 500 Lines or Less 系列的原有项目:A Simple Web Server。原文章代码是用 Python2 开发的,目前在 Python3 上无法正常运行。同时,部分内容比如 CGI 也已经不太适合目前的时代。因此本次重写主要关注以下方面:

  • Python3

原文代码除了使用 Python2 之外,还保留了一些旧式的代码风格,比如 old style class、小写类名等等。经过重写的代码采用更加现代的代码风格,并且使用了 f-string、类型标注等较新的语法特性。要运行代码,请使用 Python3.6 以上版本。

  • 去掉原理性介绍

原文用了大量篇幅解释 Web 服务器和 HTTP 的基本工作原理。这些内容现在看来也并不过时,如果读者还不太了解的话,可以阅读原文或参考资料部分列出的中文翻译;同时,网络上也可以找到大量相关的资料可以参考,没有必要再重述。因此,本文不再包括原文中的原理讲解部分。

  • 内容调整

原文章涉及到的 CGI 模式在 Web 服务器发展的早期比较常见,但现代 Web 服务器已经有了更高效和先进的模式,CGI 的使用也越来越少见。因此本文不再涉及该话题,而是转而介绍以 Web 服务器常用的处理管线,包括与之匹配的中间件(Middleware)用法。此外,本文还实现了原文没有涉及到的路由组件。

  • 结构调整

概述 部分所述,本文采取分阶段、递进式的写法,逐步完成整个程序,这样有利于读者循序渐进地理解,也更加符合程序开发的现实情况。

文章结构

本文从头开始,同样在 500 行代码的限制下,实现一个略具规模的基本的 Web 服务器。由于本程序注重于教学目的,不是为了实现具有生产级别的服务器,为此会有意忽略一些协议解析的细节及错误处理,而侧重于整体结构的实现以及架构设计。本文按照如下顺序开发:

  • 基本服务 首先实现一个基本的 Web 服务器骨架,了解必备的知识,为进一步的开发做好准备;

  • 中间件 复杂的 Web 服务需要应对多种多样的请求类型,为此,Web 服务器也需要有一个灵活的体系结构。这个阶段将模仿常见的 Web 服务器架构,实现一个可扩展的处理管线,并且通过配置不同的中间件(Middleware)来实现灵活的功能组合。

  • 静态文件 处理管线创建好之后,我们就能动态插入多种功能。Web 服务器要处理的无非是静态或动态请求,尽管 Web 服务器的主要目标是处理动态请求,但处理静态请求的要求也是存在的,同时它也比较简单,所以我们先实现这一部分。

  • 路由 为了支持动态请求,我们需要实现几乎所有 Web 服务器都包含的路由功能。大家可能知道,Python 社区的 Web 服务器对于路由主要有两种实现模式:类似 Django 的集中管理方式或类似 Flask 的分散管理方式,其他实现基本上都是这两者的变种。我们这里的例子采用类似 Flask 的方式实现。如果读者想用 Django 的方式,也是可能的,感兴趣的话不妨作为练习。

示例代码也是按照如上顺序组织的,大家可以在 web_server 目录下找到所有步骤的代码。

示例代码

本文及系列文章的所有代码都开源在 Github 仓库:500lines-rewrite。本文相关的代码位于 web_server 目录,在其下为每个阶段创建了单独的子目录。为了避免为每个步骤创建单独的环境,读者可以将主目录下的 main.py 作为入口,并打开相应的引用部分来运行程序。

下面我们就来编写这个程序。

基本服务

为了实现 Web 服务器,Python 标准库包含了一个模块 http.server,它提供了基本的构建块,但请求具体该如何处理则交由程序员来实现。这个模块在 Python2 里面叫做 BaseHTTPServer(因此从 PY2 升级到 PY3 时要作相应的修改)。

更具体地说,我们需要创建一个 HTTPServer 的实例作为服务器,而服务器又需要一个从 BaseHTTPRequestHandler 派生的对象来处理请求。请求是根据方法(HTTP Method)也叫做动词(HTTP Verb)来分派的,比如说,要让服务器处理 GET 请求,我们需要实现名为 do_GET() 的方法,处理 POST 请求则实现 do_POST(),依此类推。HTTPServer 会自动把请求信息绑定到该对象的字段,比如 pathheaders,要输出内容则使用它的 wfile 属性(类似于文件的可写输出流)。

通过实际代码会看得更清楚一些:

from http.server import BaseHTTPRequestHandler, HTTPServer

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        resp_text = f"""<h1>Hello World!</h1>
            <p>Client address: {self.client_address}</p>
            <p>Command: {self.command}</p>
            <p>Path: {self.path}</p>
            <p>Headers: {self.headers}</p>
        """
        resp_data = resp_text.encode('utf8')
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", len(resp_data))
        self.end_headers()
        self.wfile.write(resp_data)

def main():
    addr = ('', 8080)
    server = HTTPServer(addr, RequestHandler)
    server.serve_forever()

if __name__ == '__main__':
    main()

以上代码将 Web 服务器运行在本机的 8080 端口。我们这个示例同时也演示了如何从请求中提取必要的信息,包括客户端地址、请求路径、HTTP 头部等等。动态 Web 服务器存在的最大理由就是它需要根据客户端请求做出处理,并返回不同的应答信息,因此我们有必要了解有哪些属性是可用的。同时,输出应答也是有要求的,我们必须先输出头部信息,然后才能写入内容体。

运行程序,然后用浏览器访问 http://localhost:8080。我们应该看到类似这样的内容:

浏览器输出

同时,在控制台应该有 HTTPServer 自动输出的日志:

命令行输出

这里之所以记录了两个请求,是因为浏览器会在服务器上寻找 favicon.ico,找到的话则作为该网站的图标显示。这是客户端的处理细节,我们可以不用去关心它。

如果你多刷新几次页面,会发现 Client Address 部分的端口是变化的。Web 服务器绑定的端口几乎都是固定的,但客户端连接的时候并不需要指定端口,因此它会使用本机上一个随机的可用端口。当然,一般情况下我们也不用关心客户端的端口,不过看到它的值有助于帮助我们理解幕后发生的事情。

现在我们有了一个基本的 Web 服务器骨架,接下来要考虑如何让它实现更为复杂的功能。

中间件

首先说明,从这个阶段开始,代码会变得有些复杂,并且为了节约篇幅,文章中只给出重点代码,有意忽略掉部分注释和已经出现过的部分。建议读者在阅读的同时参考代码库中的完整代码。

此外,示例代码也使用了 Python 的新功能:类型标注。加入类型标准让 Python 有点类似于强类型的语言,同时也会让代码略显冗长。对于它喜欢与否是个人风格的问题,我个人相信它更符合 Python 之禅,即:显式优于隐式。如果有同学觉得它比较碍眼,并且你所使用的 IDE 是 PyCharm 的话,那么你可以考虑在 PyCharm 的语法高亮方案中为 Type Annotations 选择一个比较黯淡的色调,这样你就能在视觉上快速跳过它们。

要让 Web 服务器处理其他类型的请求,我们可以在 RequqestHandler 中根据请求信息增加相应的处理代码。但是相信大家从上个例子也会意识到,这部分代码膨胀得很快,如果不加以控制的话,很快就会变得无法管理。

现代 Web 服务器基本上都是按照类似如下的思路设计的:

CLI Output

在这种设计下,会有一个输入组件(通常叫做分派者 Dispatcher 或者前端控制器 Front Controller)接收请求并放入一个处理管线(Pipeline)。这个管线类似于工厂流水线的组织形式,其中每个组件只关心某项特定的工作,而通过动态添加管线或调整结构,Web 服务器就能在不修改基本架构的前提下,不断增加新的功能,从而获得了一个高度灵活的体系结构。用架构设计的术语说,这种设计很好地体现了单一职责原则以及开闭原则

管线上的处理单位在不同的 Web 框架中有不同的叫法,比如中间件(Middleware)、处理器(Handler)或者过滤器(Filter)都是比较流行的名称。本实例将使用中间件(Middleware)这个名称。

一般来说,Web 服务器会有这些类型的中间件:

  • 处理和具体业务关联不大的通用功能,如用户验证/授权、日志、跨域、压缩等;
  • 返回静态文件,如原始 HTML、CSS、JS、图像;
  • 根据网站设计者规定的 URL 规则动态分派和处理客户请求。在大多数 Web 框架中,这个组件会叫做路由(Routing);
  • 处理错误。如果某个请求没有任何中间件来处理,那么我们通常希望返回 HTTP 404,表示目标不存在;或者在处理过程中出现异常,按照协议标准,我们应该返回 HTTP 500(或其他 5xx 系列的标准错误码)。

接下来我们来设计这个管线。

我们希望中间件有一个统一的接口,这样管线才能够一致地对待它们。同时,中间件有时候会有一些共享的功能。因此,可以将中间件设计为抽象类:

class Middleware:
    def handle(self, ...):
        pass

大家可能会想到一个问题:中间件肯定需要一些途径方法从请求和应答中获取信息,而这些信息目前只能通过 RequestHandler 提供的接口来访问。从设计角度讲,我们希望中间件最好只关注自己的功能,不要与 RequestHandler 耦合在一起。为此,首先创建几个辅助类,把请求和应答的信息抽象出来:

class Request:
    def __init__(self, handler: BaseHTTPRequestHandler):
        self._handler = handler

class Response:
    def __init__(self, handler: BaseHTTPRequestHandler):
        self._handler = handler

class HttpContext:
    def __init__(self, handler: BaseHTTPRequestHandler):
        self.request = Request(handler)
        self.response = Response(handler)
        self.error = None

我们分别用 Request/Response 来代表请求和应答。由于每个中间件都需要它们,因此再添加一个 HttpContext 来汇总这些信息,以简化接口。它还提供了一个字段来记录处理管线中出现的异常,稍后我们在处理 HTTP 500 的时候会用到它。当然,目前这些接口基本上是空的。虽然我们可以基于猜测去添加一些接口,但是我将坚持 KISS (Keep It Simple Stupid)的指导思想,不要预测,而是等到确实需要的时候再去添加它们。

有了这些辅助类,我们把中间件设计为抽象类:

class Middleware:
    def handle(self, ctx: HttpContext) -> bool:
        raise NotImplementedError()

返回值是一个结构上的优化:如果某个中间件处理了请求,它应该返回 True,这样管线就可以到这里停止,不必再浪费时间遍历所有中间件。

对于本示例而言,为了验证管线的处理逻辑是否正常,我们实现这样几个中间件:

  • 模拟通用类型的中间件,为输出加上一个通用的头部信息
  • 普通的请求处理中间件,对首页请求(/)输出普通的 HTML 文本。为了测试目的,如果请求 /?err=1,我们让它抛出异常,以便检查后面的错误处理中间件是否正确执行
  • 处理 HTTP 错误 404(文件不存在)以及 500(服务器异常)

为了更加匹配现在的用途,我们把前面创建的 RequestHandler 重命名为 RequestDispatcher,并增加处理管线的逻辑:

class RequestDispatcher(BaseHTTPRequestHandler):
    def __init__(self, request, client_address, server):
        self._middlewares = [
            ServerHeader(),
            Index(),
            NotFound(),
        ]
        self._catchall = GenericError()
        super(RequestDispatcher, self).__init__(request, client_address, server)

    def do_GET(self):
        ctx = HttpContext(self)
        try:
            for middleware in self._middlewares:
                if middleware.handle(ctx):
                    ctx.response.send()
                    break
        except Exception as e:
            ctx.error = e
            self._catchall.handle(ctx)
            ctx.response.send()

我们在构造函数中定义所有需要执行的中间件。其中 _catchall 比较特殊,它只有在出错的情况下才会介入并接管处理过程,所以单独定义。

以上设计还有一个值得注意的点。通过第一节的代码我们已经知道,RequestHandler 输出顺序是有严格要求的,必须先输出头部再写入正文。但在每个中间件都有机会处理请求的场景下,强制它们组合起来还能遵守顺序是不太现实的。因此我们这样来设计 Response:把所有要输出的头部和内容都缓存下来,当调用 send() 的时候按照规定的顺序统一输出。因此,Response 的实现大概是这样的:

class Response:
    """Provide interface to write HTTP response"""
    def __init__(self, handler: BaseHTTPRequestHandler):
        self._handler = handler
        self._status = 200
        self._headers = {}
        self._data = BytesIO()

    def header(self, key: str, value: str):
        self._headers[key] = value
        return self

    def status(self, code: int):
        self._status = code
        return self

    def html(self, text: str):
        self._data.write(text.encode('utf8'))
        self._headers.setdefault('Content-Type', 'text/html; charset=utf-8')
        return self

    def send(self):
        self._handler.send_response(self._status)
        resp_data = self._data.getvalue()
        self._headers.setdefault('Content-Length', len(resp_data))
        for k, v in self._headers.items():
            self._handler.send_header(k, v)
        self._handler.end_headers()
        self._handler.wfile.write(resp_data)

代码有点长,不过逻辑还是很简单的。为了简化调用者的工作同时避免出错,我们还添加了逻辑来自动处理某些信息,比如根据输出内容自动生成 Content-Type 以及 Content-Length 头部。

下面我们就可以实现各个中间件了。首先是 ServerHeader:

class ServerHeader(Middleware):
    def handle(self, ctx: HttpContext) -> bool:
        ctx.response.header('X-Server-Type', '500lines server (testonly)')

它对于任何请求都生成一个固定的头部信息。这里的逻辑很简单,但它确实是有实际意义的,很多现实的 Web 服务都会用基于某种规则生成以 X- 开头的自定义头部。比如说,Github API 会在头部中包含 X-Ratelimit-Limit,告诉你还有多少调用余额可用。

然后是响应对首页(/)的请求:

class Index(Middleware):
    def handle(self, ctx: HttpContext) -> bool:
        if ctx.request.path == '/':
            if ctx.request.query_string('err', '0') == '1':
                raise Exception('test error')
            else:
                ctx.response.html('<h1>Index</h1>')
            return True
        return False

这里的中间件使用了从 Request 提取参数信息的辅助方法。这是协议的处理细节,实现代码就不再给出了,大家可以参考源码。

如果没有任何中间件处理请求,处理逻辑会来到最末的 NotFound,它会生成 HTTP 404 作为应答:

class NotFound(Middleware):
    def handle(self, ctx: HttpContext) -> bool:
        ctx.response.status(404).html('<h1>File Not Found</h1>')
        return True

最后,处理一般性错误:

class GenericError(Middleware):
    def handle(self, ctx: HttpContext) -> bool:
        if ctx.error:
            logging.getLogger('server').error(str(ctx.error))
        ctx.response.status(500).html('<h1>Internal Server Error</h1>')
        return True

大家会看到,这里每个中间件的逻辑都是相当简单的(在生产级别的代码中它们肯定会更加复杂一些)。然而,通过把这些中间件串联起来,Web 服务器能够完成非常复杂的任务,同时核心逻辑仍然保持稳定。

代码编写完成,我们可以测试一下。运行程序,用浏览器访问 ·http://localhost:8080/·,我们会看到如下输出:

Browser Output

注意浏览器开发工具的响应部分也显示了我们添加的自定义 HTTP 头。

如果我们访问一个不存在的地址,比如 http://localhost:8080/nopage,就会看到 File Not Found 信息,说明 404 的处理也已经生效了。

再访问 http://localhost:8080/?err=1。我们应该在浏览器看到 Internal Server Error 的字样,并且服务器控制台会输出:

Error Output

到此,HTTP 处理管线已经实现好了,后续的工作基本就是实现各种中间件。首先要处理的是比较简单的静态文件。

静态文件

对于生产级的 Web 服务来说,处理静态文件通常会委托给更加专业且高效的应用,比如 Nginx 或者 Apache。但返回静态内容的需求在一些场合下仍然是存在的,并且如果希望将 Web 服务器作为开发用途,那么程序员通常会希望它能够在本地直接处理静态文件,尽量避免把搭建开发环境的工作搞得过于复杂。

返回本地文件的内容并不难实现,如果你写过用 Python 读写文件的程序的话,应该已经猜到该怎么做了。为了让问题更有趣一点,我们尝试模仿常见的静态服务器行为,概括来说:

  • 如果请求指向某个具体的文件,则返回文件内容
  • 如果请求指向某个目录
  • 若目录下存在公认的“入口”文件,比如 index.html 等等,则返回该文件内容
  • 否则,显示该目录下的文件列表

我们分部实现以上要求。处理静态文件的组件仍然是一个中间件:

class StaticFile(Middleware):
    def __init__(self, root_path: str):
        self._root_path = root_path

class RequestDispatcher(BaseHTTPRequestHandler):
    def __init__(self, request, client_address, server):
        self._middlewares = [
            StaticFile(os.path.dirname(__file__) + '/static'),
            # Index(),
            NotFound(),
        ]
        ...

我们希望静态文件处理器具有一定的灵活性————能够让使用者指定使用哪个目录来存放静态文件。因此,把初始目录作为参数传入构造函数。同时,去掉上一节作为测试的 Index 中间件,代之以新创建的 StaticFile 中间件。

然后,实现返回静态文件的逻辑:

    def handle(self, ctx: HttpContext) -> bool:
        full_path = os.path.normpath(self._root_path + ctx.request.path)
        if os.path.isfile(full_path):
            self.send_file(ctx.response, full_path)
            return True
        return False

    def send_file(self, resp: Response, file_path: str):
        content_type = mimetypes.guess_type(file_path)[0] or 'application/octec-stream'
        with open(file_path, 'rb') as f:
            resp.header('Content-Type', content_type).data(f.read())

为了让客户端能够正确显示文件,服务器需要设置响应的 Content-Type 头部。Python 内置模块 mimetypes 提供了方便的获取内容类型的方法,所以我们直接调用它。其他就是简单的文件访问代码了。返回文件的处理逻辑在后面还会用到,所以我们把它封装成 send_file() 方法。

继续扩展该逻辑,加入访问目录时的处理:

    def handle(self, ctx: HttpContext) -> bool:
        full_path = os.path.normpath(self._root_path + ctx.request.path)
        if os.path.isfile(full_path):
            self.send_file(ctx.response, full_path)
            return True
        elif os.path.isdir(full_path):
            if self.process_index(ctx.response, full_path):
                return True
            else:
                html = self.build_dir_html(full_path)
                ctx.response.html(html)
                return True
        return False

process_index() 辅助方法查找可能的首页文件,如果找到则返回其内容:

    def process_index(self, resp: Response, dir_path: str) -> bool:
        index_names = ['index.html', 'index.htm', 'default.html', 'default.htm']
        for name in index_names:
            index_path = os.path.join(dir_path, name)
            if os.path.isfile(index_path):
                self.send_file(resp, index_path)
                return True
        return False

如果没有合适的文件,那么我们根据目录下的文件列表动态生成 HTML 内容。这段代码比较长,但都是简单的 HTML 拼接,为了节约篇幅只显示主要实现。读者可以从代码库找到完整的实现。

    def build_dir_html(self, dir_path: str):
        lines = []
        lines.append(f"<h1>Direcdtory of {os.path.split(dir_path)[1]}:</h1>")
        lines.append("<hr/>")
        lines.append("<table>")
        ...
        for file_name in os.listdir(dir_path):
            full_path = os.path.join(dir_path, file_name)
            lines.append("<tr>")
            ...
            lines.append("</tr>")
        lines.append("</tbody></table>")
        return '\n'.join(lines)

代码完成后,为了看到效果,我们在程序目录下创建 static 子目录,其中包含首页文件(index.html),以及它所引用的图像和 CSS 文件。现在,我们启动程序并访问 http://localhost:8080/,就会看到 index.html 呈现的内容:

Static Index

你也可以访问 /img 或者 /css,检查动态生成文件列表的效果。

路由

下面来处理 Web 服务器的重头戏:动态请求。通常,Web 服务器会通过路由机制,把不同的请求路径分派到对应的方法去处理。以常见的 Flask 框架为例,它用来定义路由的语法是这样的:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello World!'

本阶段也试图实现一个类似的机制,不过我们会基于中间件而不是 App。

class Routing(Middleware):
    def __init__(self):
        self._routes = []

    def handle(self, ctx: HttpContext) -> bool:
        for pattern, handler in self._routes:
            kwargs = self.match(ctx.request.path, pattern)
            if kwargs is not None:
                handler(ctx.request, ctx.response, **kwargs)
                return True
        return False

    def match(self, url_path: str, pattern: str) -> dict:
        re_pattern = '^' + re.sub(r'<(\w+)>', r'(?P<\1>\\w+)', pattern) + '$'
        m = re.match(re_pattern, url_path)
        return m.groupdict() if m else None

    def route(self, path: str):
        def wrapper(f):
            self._routes.append((path, f))
            return f
        return wrapper

代码看上去不算长,但是信息量比较大。首先,我们希望路由格式能够支持类似 Flask 的变量。为了更直观地理解这一点,我们先看看路由函数的定义:

routing = Routing()

@routing.route('/')
def index(req, resp):
    resp.html('<h1>Index</h1>')

@routing.route('/user/<name>')
def username(req, resp, name):
    resp.html(f"<h1>Hello {name}!</h1>")

第二个方法就是所谓的动态路由,学过 Flask 的同学应该对这段代码相当熟悉。我们希望服务器能够处理任意合法的用户名,因此需要路由格式能够识别尖括号中的变量,并自动把它传递给处理方法的对应参数。为了实现这一点,我们使用了一个较为复杂的正则表达式:

re_pattern = '^' + re.sub(r'<(\w+)>', r'(?P<\1>\\w+)', pattern) + '$'
m = re.match(re_pattern, url_path)
return m.groupdict() if m else None

该表达式的作用是:识别尖括号中的分组,并把它转换为命名的分组表达式,比如 /user/(?P<name>\w+)。用该结果再去匹配路径,如果成功的话,则结果的 groupdict() 就会包含匹配到的参数信息。假如我们使用请求路径 /user/dave 去尝试的话,那么应该返回参数和对应的值 {'name': 'dave'}

如果读者觉得这段代码理解起来有困难的话,可以把它复制到 Python 解释器中,尝试去处理不同的输入路径,看会得到什么样的结果。

接下来是声明路由的装饰器:

def route(self, path: str):
    def wrapper(f):
        self._routes.append((path, f))
        return f
    return wrapper

装饰器是一个高级的 Python 语法构造,我们这里不打算详细介绍它的细节,读者如果觉得有必要,有很多 Python 的进阶学习资料会讲到这个话题。这里的重点是,我们要把声明的路径及其对应的处理函数记录下来,在处理请求时会查找该记录。除此之外,装饰器没有必要修改处理器的实现,因此直接返回原函数即可。

记录下来的路由映射会保存在成员变量 _routes 中。当请求到来时,我们查找该表,确定应该由哪个函数来处理该请求:

def handle(self, ctx: HttpContext) -> bool:
    for pattern, handler in self._routes:
        kwargs = self.match(ctx.request.path, pattern)
        if kwargs is not None:
            handler(ctx.request, ctx.response, **kwargs)
            return True
    return False

我们使用 Python 的可变参数语法,把提取到的路径变量自动映射到函数中对应的参数。因此,如果我们请求 /user/dave 的话,框架会把它映射到函数调用 username(req, res, 'dave')

最后,把路由添加到处理管线中。通常来说,我们应当把它放到静态文件(StaticFile)的前面,否则我们会看到 index.html,而不是动态处理的结果:

class RequestDispatcher(BaseHTTPRequestHandler):
    def __init__(self, request, client_address, server):
        self._middlewares = [
            routing,
            StaticFile(os.getcwd() + '/static'),
            NotFound(),
        ]

现在,我们请求首页(http://localhost:8080/),会看到如下响应:

Static Index

如果请求任意用户名,比如 http://localhost:8080/user/John,则会看到:

Static Index

总结

到此,我们的 Web 服务器实现了一个灵活的处理管线,并在此基础上提供了对静态文件以及动态路由的支持,已经具备一个 Web 服务器应有的基本功能了。通过定义新的路由(Routing),我们就能支持各种请求,而无需对服务器内部逻辑作任何修改。

当然,我们实现的还不能算一个产品级别的代码,这里所展现的也不是唯一可行的设计。本文中的例子采用了类似 Flask 的设计风格,如果你更喜欢 Django 的话,那么设计方案肯定会有很大差别。

我们同时也略去了一些细节问题。比如说在处理静态文件的时候,代码使用了读取全部内容的方式。如果客户端要访问的文件很大的话,那么这种方式在速度和内存占用上都是有问题的。此外,如果要支持客户端的流式读取,我们可能要根据 HTTP 的 Range 头部返回相应的内容。还需要说明的是,示例代码并未考虑安全问题;如果服务器运行在生产环境,那么我们应该有一定的安全性检查,不能让客户端随意访问服务器上的任意文件,否则就可能被黑客所利用。不过,这些处理已经超过了本文的范围。如果读者愿意的话,也可以把这些课题作为自己的练习。

如果本文的代码对你来说已经不成问题,那么我建议你再往前迈出一步:阅读一个真正的、产品级别的开源 Web 服务器源码。这里仍然要推荐 Flask,因为它是 Python 社区公认的代码风格比较清晰、一致和 Pythonic 的,同时也不会过于庞大(当然比这里的代码还是要复杂得多)。Django 也是一个可能的选择,不过它的架构复杂度更高,阅读难度也更大。如果你真的有兴趣挑战的话,建议的方法是选一个主题来重点突破,不要试图面面俱到。当然,如果能找到有同好的小伙伴们来一起学习,共同进步,就更好了。

文章索引