如何写一个web服务器
目录 · Table of Contents
我们将用 Python 从零实现一个可扩展的 Web 服务器,并一步步为它加上动态页面渲染、静态文件托管、目录列表、CGI 脚本执行,以及基于责任链模式的架构重构。
完整代码可以在文末查看,也可以直接下载 web_server.py。
1. Hello, Web
先来写一个最简单的 Web 服务器。基本工作流程只有六步:
- 等待客户端连接服务器,并发送一个 HTTP 请求;
- 解析这个请求;
- 找出它请求的具体目标;
- 获取对应的数据(从磁盘读取文件,或由程序动态生成);
- 将数据格式化为 HTML;
- 把响应写回客户端。
其中第 1、2、6 步对几乎所有 HTTP 服务器来说都是一样的,Python 标准库中的 http.server 模块(在 Python 2 中叫 BaseHTTPServer)已经帮我们处理好了底层 Socket 连接与 HTTP 报文解析。我们只需要关注第 3~5 步。
下面是一个最小实现:
BaseHTTPRequestHandler 类会帮我们解析传入的 HTTP 请求,并根据请求方法调用对应的处理函数——例如收到 GET 请求时,它就会自动调用 do_GET。
我们的 RequestHandler 重写了 do_GET 方法来返回保存在类变量 Page 中的 HTML:
send_response(200)发送状态码200 OK;send_header("Content-Type", ...)告诉客户端返回的内容是 HTML;send_header("Content-Length", ...)声明响应体的字节长度(注意在 Python 3 中需要先通过.encode("utf-8")转为字节流bytes);end_headers()插入一个空行,用来分隔 HTTP 响应头与响应正文;self.wfile.write(content)通过输出流把网页内容发送给客户端。
最后三行代码用来启动服务器:('', 8080) 表示在当前机器的所有网卡上监听 8080 端口;接着创建 HTTPServer 实例并调用 serve_forever() 持续循环(按 Ctrl-C 停止)。
在命令行运行它:
用浏览器打开 http://localhost:8080,页面上会显示:
同时命令行终端会打印出两条访问日志:
第一条是因為我们没有指定具体路径,浏览器默认请求了服务器根目录 /;第二条是因为浏览器会自动请求 /favicon.ico 用来显示标签页图标。
2. 渲染动态数据
接下来我们修改代码,让服务器把当前 HTTP 请求的详细信息动态展示出来。为了保持代码整洁,我们把“生成页面内容”和“发送响应”拆分成两个方法:create_page 和 send_page:
启动代码完全不需要改动。如果现在用浏览器访问 http://localhost:8080/something.html,会看到如下表格:
| Header | Value |
|---|---|
| Date and time | Mon, 24 Feb 2014 17:17:12 GMT |
| Client host | 127.0.0.1 |
| Client port | 54548 |
| Command | GET |
| Path | /something.html |
注意,此时磁盘上根本没有 something.html 这个文件,但服务器依然返回了 200 OK 而不是 404——因为 HTTP 服务器本质上只是一个接收请求并返回字节流的程序,至于返回什么完全由我们的代码决定。
3. 托管静态文件与错误处理
现在我们让服务器真正去读取磁盘上的文件并返回给客户端。
首先定义一个内部异常类 ServerException:
接着修改 do_GET 方法:
这里假定服务器启动时的当前工作目录(os.getcwd())就是网站根目录,并将它与 URL 路径 self.path(总是以 / 开头)拼接起来。
- 如果路径不存在,或者不是普通文件,就抛出
ServerException,并在except块中交给handle_error处理; - 如果文件存在,就调用
handle_file读取二进制内容('rb'),再通过send_content发送给客户端:
再来编写错误处理方法 handle_error 和通用的响应发送方法 send_content。注意出错时应当返回 404 状态码,而不是默认的 200:
在 send_content 中,我们兼容了 str 和 bytes 两种输入:如果是字符串(如错误页面),自动编码成 UTF-8 字节;如果是从文件直接读出的 bytes,则直接发送。
4. 目录列表与条件处理器(Case Handlers)
下一步,我们希望当 URL 指向一个目录而不是文件时,服务器也能正确处理:
- 如果该目录下存在
index.html,就返回这个index.html; - 如果没有
index.html,就自动生成一个展示该目录下所有文件的 HTML 列表页。
如果直接把这些判断全部塞进 do_GET 里,do_GET 很快就会变成一团纠缠不清的 if ... elif ... else 面条代码。更优雅的做法是退一步,把“判断当前请求属于哪种情况(test)”和“针对该情况执行什么动作(act)”抽离成独立的处理器对象(即设计模式中的责任链模式)。
重构后的 do_GET 变得非常简短:
每个条件处理器(case_*)都包含两个方法:
test(self, handler):检查当前请求是否命中该分支,返回布尔值;act(self, handler):实际执行对应的处理动作。
一旦某个 case.test(self) 返回 True,就执行它的 act(self) 并跳出循环。我们先用三个处理器类还原上一节的行为:
然后在 RequestHandler 顶部按顺序注册它们:
表面上看,代码行数变多了,还多了一层间接调用。但这种架构的威力在我们接下来添加“目录处理”时立刻就体现出来了——do_GET 一行都不用改,只需要新增两个 case 类!
第一个是目录下存在 index.html 的情况:
第二个是目录下没有 index.html 的情况——它的 test 条件只需把上面的第二项加上 not,act 则调用 handler.list_dir 来渲染目录列表:
然后在 RequestHandler 中添上 Listing_Page 模板和 list_dir 方法,并将两个新处理器放进 Cases 列表:
5. CGI 协议:运行外部脚本
当然,大多数人不会想每次增加一个新页面都去修改 Web 服务器本身的源码。为了将“服务器”和“业务逻辑”解耦,Web 服务器很早就支持了一种名为 CGI(Common Gateway Interface,通用网关接口) 的机制:它提供了一种标准方式,让 Web 服务器可以通过运行外部程序来生成响应。
举个例子,假设我们想在网页上显示当前服务器的本地时间,完全可以写一个独立的 Python 小脚本 time.py:
怎样让我们的 Web 服务器在收到 /time.py 请求时自动运行这个脚本,并把它的输出返回给浏览器呢?
得益于上一节的 Cases 设计,我们只需新增一个 case_cgi_file 处理器,判断文件路径是否以 .py 结尾:
注意要把 case_cgi_file() 放在 case_existing_file() 之前,否则 .py 文件会被当作普通静态文件直接把源码发送出去。
在 RequestHandler 中实现 run_cgi 的核心思路只有三步:
- 在子进程中运行该程序;
- 捕获子进程打印到标准输出(stdout)的内容;
- 将捕获到的内容发送回客户端。
安全提示:让服务器直接执行 URL 指向的任意
.py文件在实际生产环境中是非常危险的。如果攻击者能猜到服务器上某个 Python 脚本的路径(或者通过..路径穿越访问上级目录),就能诱导服务器直接执行它。在文末的完整代码中,我们加入了基于os.path.realpath的根目录边界校验,防止通过..访问网站根目录以外的文件。
6. 提炼公共基类 base_case 与架构总结
随着功能增加,代码里出现了一些重复与错位:
case_directory_index_file和case_directory_no_index_file里都写了一遍完全相同的index_path方法;handle_file方法放在RequestHandler中,但实际上只有case_existing_file和case_directory_index_file会调用它。
解决办法很简单:为所有 case_* 处理器创建一个公共基类 base_case,把多个处理器共享的工具方法(如 handle_file 和 index_path)提炼到 base_case 中,并声明默认的 test 和 act 接口:
这样一来,每个具体的处理器子类都只剩下了最纯粹的几行代码。例如 case_existing_file:
而主类 RequestHandler 也变得非常清爽:
回顾从初版代码到最终重构版的变化,可以看到两个非常实用的设计思想:
- 把类看作一组内聚服务的集合:
RequestHandler和base_case本身不做复杂的业务分支判断,而是提供基础工具(发送内容、读取文件、格式化错误页),由具体的处理器组合使用; - 高可扩展性:任何人想给这个服务器扩展新功能,要么直接丢一个外部
.pyCGI 脚本进去,要么新增一个几行代码的case_*类并加进Cases列表(甚至可以让服务器从配置文件动态加载Cases),完全不必改动底层连接、HTTP 解析或文件读取的逻辑——正如标准库的BaseHTTPRequestHandler帮我们屏蔽了底层 Socket 细节一样。
7. 完整代码
下面是整理后的完整 web_server.py(基于 Python 3 标准库,同时补充了 MIME 类型自动识别与目录穿越防护):