500行代码或更少
一个简单的Web服务器

格雷格·威尔逊

格雷格·威尔逊是软件木工的创始人,这是一门针对科学家和工程师的计算技能速成课程。他拥有30年的行业和学术界工作经验,是多本关于计算的书籍的作者或编辑,包括2008年Jolt奖得主《优美的代码》以及《开源应用架构》的前两卷。格雷格于1993年获得爱丁堡大学计算机科学博士学位。

引言

在过去的二十年中,网络以无数种方式改变了社会,但其核心变化很小。大多数系统仍然遵循蒂姆·伯纳斯-李四分之一个世纪前制定的规则。特别是,大多数Web服务器仍然以相同的方式处理与当时相同的邮件类型。

本章将探讨它们是如何做到的。同时,它还将探讨开发人员如何创建无需重写即可添加新功能的软件系统。

背景

网络上的几乎所有程序都运行在一系列称为互联网协议(IP)的通信标准上。我们关注的该系列成员是传输控制协议(TCP/IP),它使计算机之间的通信看起来像读取和写入文件。

使用IP的程序通过套接字进行通信。每个套接字都是点对点通信通道的一端,就像电话是电话呼叫的一端一样。套接字由标识特定机器的IP地址和该机器上的端口号组成。IP地址由四个8位数字组成,例如174.136.14.108;域名系统(DNS)将这些数字与诸如aosabook.org之类的符号名称匹配,这些符号名称更容易被人记住。

端口号是0-65535范围内的数字,用于唯一标识主机上的套接字。(如果IP地址类似于公司的电话号码,那么端口号类似于分机号。)端口0-1023保留供操作系统使用;任何其他人可以使用其余端口。

超文本传输协议(HTTP)描述了一种程序可以通过IP交换数据的方式。HTTP故意设计得很简单:客户端通过套接字连接发送请求,指定它想要什么,服务器则发送一些数据作为响应(图22.1)。数据可以从磁盘上的文件复制,由程序动态生成,或者两者兼而有之。

Figure 22.1 - The HTTP Cycle

图22.1 - HTTP循环

关于HTTP请求最重要的一点是它仅仅是文本:任何想要创建或解析它的程序都可以做到。但是,为了被理解,该文本必须具有图22.2中所示的部分。

Figure 22.2 - An HTTP Request

图22.2 - HTTP请求

HTTP方法几乎总是“GET”(获取信息)或“POST”(提交表单数据或上传文件)。URL指定客户端想要什么;它通常是磁盘上文件的路径,例如/research/experiments.html,但(这是关键部分)服务器完全可以自行决定如何处理它。HTTP版本通常为“HTTP/1.0”或“HTTP/1.1”;两者之间的差异对我们来说并不重要。

HTTP标头是类似于下面所示的三个键/值对

Accept: text/html
Accept-Language: en, fr
If-Modified-Since: 16-May-2005

与哈希表中的键不同,键可以在HTTP标头中出现任意次数。这允许请求执行诸如指定它愿意接受几种内容类型之类的操作。

最后,请求正文是与请求关联的任何额外数据。这在通过Web表单提交数据、上传文件等情况下使用。在最后一个标头和正文开头之间必须有一条空行,以指示标头的结束。

一个名为Content-Length的标头告诉服务器在请求正文中读取多少字节。

HTTP响应的格式类似于HTTP请求(图22.3

Figure 22.3 - An HTTP Response

图22.3 - HTTP响应

版本、标头和正文具有相同的形式和含义。状态码是一个数字,指示处理请求时发生了什么:200表示“一切正常”,404表示“未找到”,其他代码具有其他含义。状态短语以人类可读的短语(如“OK”或“未找到”)重复该信息。

就本章而言,我们只需要了解HTTP的其他两件事。

首先,它是无状态的:每个请求都是独立处理的,服务器不会记住一个请求和下一个请求之间的任何内容。如果应用程序想要跟踪诸如用户身份之类的东西,它必须自己做到。

通常的做法是使用cookie,cookie是一个短字符字符串,服务器将其发送到客户端,客户端稍后将其返回给服务器。当用户执行某些需要跨多个请求保存状态的功能时,服务器会创建一个新的cookie,将其存储在数据库中,并将其发送到她的浏览器。每次她的浏览器发送回cookie时,服务器都会使用它来查找有关用户正在执行的操作的信息。

我们需要了解的关于HTTP的第二件事是,URL可以补充参数以提供更多信息。例如,如果我们使用搜索引擎,则必须指定我们的搜索词是什么。我们可以将这些添加到URL中的路径中,但我们应该做的是将参数添加到URL中。我们通过在URL中添加“?”来做到这一点,后跟由“&”分隔的“键=值”对。例如,URL http://www.google.ca?q=Python要求Google搜索与Python相关的页面:键是字母“q”,值是“Python”。较长的查询http://www.google.ca/search?q=Python&client=Firefox告诉Google我们正在使用Firefox,依此类推。我们可以传递任何我们想要的参数,但同样,运行在网站上的应用程序可以决定关注哪些参数以及如何解释它们。

当然,如果“?”和“&”是特殊字符,则必须有一种方法来转义它们,就像必须有一种方法将双引号字符放在由双引号分隔的字符字符串中一样。URL编码标准使用“%”后跟两位代码来表示特殊字符,并将空格替换为“+”字符。因此,要搜索Google“grade = A+”(带空格),我们将使用URL http://www.google.ca/search?q=grade+%3D+A%2B

打开套接字、构造HTTP请求和解析响应很繁琐,因此大多数人使用库来完成大部分工作。Python附带了一个名为urllib2的库(因为它替换了名为urllib的早期库),但它公开了大多数人从未想关心的许多管道。 Requests库是urllib2的一个更易于使用的替代方案。以下是一个使用它从AOSA书籍网站下载页面的示例

import requests
response = requests.get('https://aosa.fullstack.org.cn/en/500L/web-server/testpage.html')
print 'status code:', response.status_code
print 'content length:', response.headers['content-length']
print response.text
status code: 200
content length: 61
<html>
  <body>
    <p>Test page.</p>
  </body>
</html>

request.get向服务器发送HTTP GET请求并返回一个包含响应的对象。该对象的status_code成员是响应的状态码;其content_length成员是响应数据中的字节数,而text是实际数据(在本例中为HTML页面)。

你好,网络

我们现在准备编写我们的第一个简单的Web服务器。基本思想很简单

  1. 等待某人连接到我们的服务器并发送HTTP请求;
  2. 解析该请求;
  3. 确定它在请求什么;
  4. 获取该数据(或动态生成它);
  5. 将数据格式化为HTML;以及
  6. 发送回。

步骤1、2和6在各个应用程序中都是相同的,因此Python标准库有一个名为BaseHTTPServer的模块可以为我们完成这些步骤。我们只需要处理步骤3-5,我们在下面的程序中执行此操作

import BaseHTTPServer

class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
    '''Handle HTTP requests by returning a fixed 'page'.'''

    # Page to send back.
    Page = '''\
<html>
<body>
<p>Hello, web!</p>
</body>
</html>
'''

    # Handle a GET request.
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/html")
        self.send_header("Content-Length", str(len(self.Page)))
        self.end_headers()
        self.wfile.write(self.Page)

#----------------------------------------------------------------------

if __name__ == '__main__':
    serverAddress = ('', 8080)
    server = BaseHTTPServer.HTTPServer(serverAddress, RequestHandler)
    server.serve_forever()

库的BaseHTTPRequestHandler类负责解析传入的HTTP请求并确定它包含的方法。如果方法是GET,则该类会调用名为do_GET的方法。我们的类RequestHandler覆盖此方法以动态生成一个简单的页面:文本存储在类级变量Page中,我们在发送200响应代码、Content-Type标头(告诉客户端将我们的数据解释为HTML)和页面的长度后将其发送回客户端。(end_headers方法调用插入将我们的标头与页面本身分隔的空行。)

但是RequestHandler并不是全部内容:我们仍然需要最后三行才能真正启动服务器的运行。这些行中的第一行将服务器的地址定义为元组:空字符串表示“在当前机器上运行”,8080是端口。然后,我们使用该地址和请求处理程序类的名称作为参数创建一个BaseHTTPServer.HTTPServer的实例,然后要求它永远运行(在实践中意味着直到我们使用Control-C杀死它)。

如果我们从命令行运行此程序,它不会显示任何内容

$ python server.py

但是,如果我们然后使用浏览器转到http://localhost:8080,我们会在浏览器中看到以下内容

Hello, web!

以及在我们的shell中

127.0.0.1 - - [24/Feb/2014 10:26:28] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [24/Feb/2014 10:26:28] "GET /favicon.ico HTTP/1.1" 200 -

第一行很简单:因为我们没有请求特定文件,所以我们的浏览器已请求“/”(服务器正在提供服务的任何内容的根目录)。第二行出现是因为我们的浏览器会自动发送对名为/favicon.ico的图像文件的第二个请求,如果该文件存在,它将在地址栏中将其显示为图标。

显示值

让我们修改我们的Web服务器以显示HTTP请求中包含的一些值。(在调试时我们会经常这样做,所以我们不妨练习一下。)为了保持代码简洁,我们将创建页面与发送页面分开

class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):

    # ...page template...

    def do_GET(self):
        page = self.create_page()
        self.send_page(page)

    def create_page(self):
        # ...fill in...

    def send_page(self, page):
        # ...fill in...

send_page与我们之前的内容几乎相同

    def send_page(self, page):
        self.send_response(200)
        self.send_header("Content-type", "text/html")
        self.send_header("Content-Length", str(len(page)))
        self.end_headers()
        self.wfile.write(page)

我们要显示的页面的模板只是一个包含HTML表格和一些格式占位符的字符串

    Page = '''\
<html>
<body>
<table>
<tr>  <td>Header</td>         <td>Value</td>          </tr>
<tr>  <td>Date and time</td>  <td>{date_time}</td>    </tr>
<tr>  <td>Client host</td>    <td>{client_host}</td>  </tr>
<tr>  <td>Client port</td>    <td>{client_port}s</td> </tr>
<tr>  <td>Command</td>        <td>{command}</td>      </tr>
<tr>  <td>Path</td>           <td>{path}</td>         </tr>
</table>
</body>
</html>
'''

填充此内容的方法是

    def create_page(self):
        values = {
            'date_time'   : self.date_time_string(),
            'client_host' : self.client_address[0],
            'client_port' : self.client_address[1],
            'command'     : self.command,
            'path'        : self.path
        }
        page = self.Page.format(**values)
        return page

程序的主体没有改变:与之前一样,它使用地址和此请求处理程序作为参数创建一个HTTPServer类的实例,然后永远为请求提供服务。如果我们运行它并从浏览器发送对http://localhost:8080/something.html的请求,我们会得到

  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

请注意,我们没有收到404错误,即使页面something.html在磁盘上不存在。这是因为Web服务器只是一个程序,它可以在收到请求时执行任何操作:发送回先前请求中命名的文件、随机提供维基百科页面,或我们对其进行编程的其他任何操作。

提供静态页面

接下来的明显步骤是从磁盘开始提供页面,而不是动态生成它们。我们将从重写do_GET开始

    def do_GET(self):
        try:

            # Figure out what exactly is being requested.
            full_path = os.getcwd() + self.path

            # It doesn't exist...
            if not os.path.exists(full_path):
                raise ServerException("'{0}' not found".format(self.path))

            # ...it's a file...
            elif os.path.isfile(full_path):
                self.handle_file(full_path)

            # ...it's something we don't handle.
            else:
                raise ServerException("Unknown object '{0}'".format(self.path))

        # Handle errors.
        except Exception as msg:
            self.handle_error(msg)

此方法假设它被允许提供Web服务器正在运行的目录中或其下方的任何文件(它使用os.getcwd获取)。它将此与URL中提供的路径(库会自动将其放入self.path中,并且该路径始终以领先的“/”开头)结合起来,以获取用户想要的文件的路径。

如果该文件不存在,或者它不是文件,则该方法会通过引发和捕获异常来报告错误。另一方面,如果路径与文件匹配,它会调用名为handle_file的辅助方法来读取并返回内容。此方法只是读取文件并使用我们现有的send_content将其发送回客户端

    def handle_file(self, full_path):
        try:
            with open(full_path, 'rb') as reader:
                content = reader.read()
            self.send_content(content)
        except IOError as msg:
            msg = "'{0}' cannot be read: {1}".format(self.path, msg)
            self.handle_error(msg)

请注意,我们以二进制模式打开文件——'rb'中的'b'——这样 Python 就不会试图通过更改看起来像 Windows 行结束符的字节序列来“帮助”我们。另请注意,在实际应用中,当服务文件时将整个文件读入内存是一个糟糕的主意,因为文件可能是数 GB 的视频数据。处理这种情况超出了本章的范围。

为了完成这个类,我们需要编写错误处理方法和错误报告页面的模板。

    Error_Page = """\
        <html>
        <body>
        <h1>Error accessing {path}</h1>
        <p>{msg}</p>
        </body>
        </html>
        """

    def handle_error(self, msg):
        content = self.Error_Page.format(path=self.path, msg=msg)
        self.send_content(content)

这个程序可以运行,但如果我们不仔细查看就会发现问题。问题在于它总是返回 200 的状态码,即使请求的页面不存在。是的,在这种情况下发送回的页面包含错误消息,但由于我们的浏览器无法读取英文,因此它不知道请求实际上失败了。为了使这一点明确,我们需要修改handle_errorsend_content,如下所示

    # Handle unknown objects.
    def handle_error(self, msg):
        content = self.Error_Page.format(path=self.path, msg=msg)
        self.send_content(content, 404)

    # Send actual content.
    def send_content(self, content, status=200):
        self.send_response(status)
        self.send_header("Content-type", "text/html")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)

请注意,当找不到文件时,我们不会引发ServerException,而是生成一个错误页面。ServerException旨在指示服务器代码中的内部错误,即我们犯的错误。另一方面,由handle_error创建的错误页面出现在用户犯错时,即向我们发送了不存在文件的 URL。1

列出目录

作为我们的下一步,我们可以教 Web 服务器在 URL 中的路径是目录而不是文件时显示目录内容的列表。我们甚至可以更进一步,让它在该目录中查找index.html文件以显示,并且仅在该文件不存在时显示目录内容的列表。

但是将这些规则构建到do_GET中将是一个错误,因为由此产生的方法将是一堆控制特殊行为的if语句的混乱。正确的解决方案是退一步解决一般问题,即弄清楚如何处理 URL。以下是do_GET方法的重写

    def do_GET(self):
        try:

            # Figure out what exactly is being requested.
            self.full_path = os.getcwd() + self.path

            # Figure out how to handle it.
            for case in self.Cases:
                handler = case()
                if handler.test(self):
                    handler.act(self)
                    break

        # Handle errors.
        except Exception as msg:
            self.handle_error(msg)

第一步相同:确定正在请求的事物的完整路径。但是,在此之后,代码看起来完全不同。此版本没有使用一堆内联测试,而是循环遍历存储在列表中的一组情况。每个情况都是一个具有两种方法的对象:test,它告诉我们它是否能够处理请求,以及act,它实际上采取一些操作。一旦我们找到正确的情况,我们就让它处理请求并退出循环。

这三个情况类重现了我们先前服务器的行为

class case_no_file(object):
    '''File or directory does not exist.'''

    def test(self, handler):
        return not os.path.exists(handler.full_path)

    def act(self, handler):
        raise ServerException("'{0}' not found".format(handler.path))


class case_existing_file(object):
    '''File exists.'''

    def test(self, handler):
        return os.path.isfile(handler.full_path)

    def act(self, handler):
        handler.handle_file(handler.full_path)


class case_always_fail(object):
    '''Base case if nothing else worked.'''

    def test(self, handler):
        return True

    def act(self, handler):
        raise ServerException("Unknown object '{0}'".format(handler.path))

以下是我们在RequestHandler类的顶部构建情况处理程序列表的方式

class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
    '''
    If the requested path maps to a file, that file is served.
    If anything goes wrong, an error page is constructed.
    '''

    Cases = [case_no_file(),
             case_existing_file(),
             case_always_fail()]

    ...everything else as before...

现在,从表面上看,这使我们的服务器变得更加复杂,而不是更简单:文件已从 74 行增长到 99 行,并且存在额外的间接级别,而没有任何新功能。好处在于,当我们回到本章开始的任务并尝试教我们的服务器在目录存在时提供index.html页面,在不存在时提供目录列表时。前者的处理程序是

class case_directory_index_file(object):
    '''Serve index.html page for a directory.'''

    def index_path(self, handler):
        return os.path.join(handler.full_path, 'index.html')

    def test(self, handler):
        return os.path.isdir(handler.full_path) and \
               os.path.isfile(self.index_path(handler))

    def act(self, handler):
        handler.handle_file(self.index_path(handler))

在这里,辅助方法index_path构造index.html文件的路径;将其放入情况处理程序中可以防止RequestHandler中的混乱。test检查路径是否为包含index.html页面的目录,而act要求主请求处理程序提供该页面。

RequestHandler唯一需要的更改是将case_directory_index_file对象添加到我们的Cases列表中

    Cases = [case_no_file(),
             case_existing_file(),
             case_directory_index_file(),
             case_always_fail()]

对于不包含index.html页面的目录呢?测试与上面的测试相同,并战略性地插入了not,但act方法呢?它应该做什么?

class case_directory_no_index_file(object):
    '''Serve listing for a directory without an index.html page.'''

    def index_path(self, handler):
        return os.path.join(handler.full_path, 'index.html')

    def test(self, handler):
        return os.path.isdir(handler.full_path) and \
               not os.path.isfile(self.index_path(handler))

    def act(self, handler):
        ???

看来我们把自己逼到了一个角落。从逻辑上讲,act方法应该创建并返回目录列表,但我们现有的代码不允许这样做:RequestHandler.do_GET调用act,但不会期望或处理来自它的返回值。现在,让我们向RequestHandler添加一个方法来生成目录列表,并从情况处理程序的act中调用该方法

class case_directory_no_index_file(object):
    '''Serve listing for a directory without an index.html page.'''

    # ...index_path and test as above...

    def act(self, handler):
        handler.list_dir(handler.full_path)


class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):

    # ...all the other code...

    # How to display a directory listing.
    Listing_Page = '''\
        <html>
        <body>
        <ul>
        {0}
        </ul>
        </body>
        </html>
        '''

    def list_dir(self, full_path):
        try:
            entries = os.listdir(full_path)
            bullets = ['<li>{0}</li>'.format(e) 
                for e in entries if not e.startswith('.')]
            page = self.Listing_Page.format('\n'.join(bullets))
            self.send_content(page)
        except OSError as msg:
            msg = "'{0}' cannot be listed: {1}".format(self.path, msg)
            self.handle_error(msg)

CGI 协议

当然,大多数人不会希望为了添加新功能而编辑其 Web 服务器的源代码。为了避免他们这样做,服务器始终支持一种称为通用网关接口 (CGI) 的机制,该机制提供了一种标准方式,使 Web 服务器能够运行外部程序以满足请求。

例如,假设我们希望服务器能够在 HTML 页面中显示本地时间。我们可以在一个只有几行代码的独立程序中做到这一点

from datetime import datetime
print '''\
<html>
<body>
<p>Generated {0}</p>
</body>
</html>'''.format(datetime.now())

为了让 Web 服务器为我们运行此程序,我们添加了此情况处理程序

class case_cgi_file(object):
    '''Something runnable.'''

    def test(self, handler):
        return os.path.isfile(handler.full_path) and \
               handler.full_path.endswith('.py')

    def act(self, handler):
        handler.run_cgi(handler.full_path)

测试很简单:文件路径是否以.py结尾?如果是,RequestHandler将运行此程序。

    def run_cgi(self, full_path):
        cmd = "python " + full_path
        child_stdin, child_stdout = os.popen2(cmd)
        child_stdin.close()
        data = child_stdout.read()
        child_stdout.close()
        self.send_content(data)

这非常不安全:如果有人知道我们服务器上 Python 文件的路径,我们只是让他们运行它,而不必担心它可以访问哪些数据、它是否可能包含无限循环或任何其他内容。2

撇开这一点不谈,核心思想很简单

  1. 在子进程中运行程序。
  2. 捕获该子进程发送到标准输出的所有内容。
  3. 将其发送回发出请求的客户端。

完整的 CGI 协议比这丰富得多——特别是,它允许 URL 中的参数,服务器将这些参数传递给正在运行的程序——但这些细节不会影响系统的整体架构……

……这再次变得相当复杂。RequestHandler最初有一个方法handle_file用于处理内容。我们现在已以list_dirrun_cgi的形式添加了两种特殊情况。这三种方法实际上不属于它们所在的位置,因为它们主要由其他方法使用。

修复方法很简单:为所有情况处理程序创建一个父类,并将其他方法移动到该类(当且仅当两个或多个处理程序共享它们时)。完成后,RequestHandler类如下所示

class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):

    Cases = [case_no_file(),
             case_cgi_file(),
             case_existing_file(),
             case_directory_index_file(),
             case_directory_no_index_file(),
             case_always_fail()]

    # How to display an error.
    Error_Page = """\
        <html>
        <body>
        <h1>Error accessing {path}</h1>
        <p>{msg}</p>
        </body>
        </html>
        """

    # Classify and handle request.
    def do_GET(self):
        try:

            # Figure out what exactly is being requested.
            self.full_path = os.getcwd() + self.path

            # Figure out how to handle it.
            for case in self.Cases:
                if case.test(self):
                    case.act(self)
                    break

        # Handle errors.
        except Exception as msg:
            self.handle_error(msg)

    # Handle unknown objects.
    def handle_error(self, msg):
        content = self.Error_Page.format(path=self.path, msg=msg)
        self.send_content(content, 404)

    # Send actual content.
    def send_content(self, content, status=200):
        self.send_response(status)
        self.send_header("Content-type", "text/html")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)

而我们情况处理程序的父类是

class base_case(object):
    '''Parent for case handlers.'''

    def handle_file(self, handler, full_path):
        try:
            with open(full_path, 'rb') as reader:
                content = reader.read()
            handler.send_content(content)
        except IOError as msg:
            msg = "'{0}' cannot be read: {1}".format(full_path, msg)
            handler.handle_error(msg)

    def index_path(self, handler):
        return os.path.join(handler.full_path, 'index.html')

    def test(self, handler):
        assert False, 'Not implemented.'

    def act(self, handler):
        assert False, 'Not implemented.'

以及现有文件的处理程序(只是随机选择一个示例)是

class case_existing_file(base_case):
    '''File exists.'''

    def test(self, handler):
        return os.path.isfile(handler.full_path)

    def act(self, handler):
        self.handle_file(handler, handler.full_path)

讨论

我们原始代码和重构版本之间的差异反映了两个重要的思想。第一个是将类视为相关服务的集合。RequestHandlerbase_case不做出决定或采取行动;它们提供其他类可以用来做这些事情的工具。

第二个是可扩展性:人们可以通过编写外部 CGI 程序或添加情况处理程序类来向我们的 Web 服务器添加新功能。后者确实需要对RequestHandler进行一行更改(将情况处理程序插入Cases列表中),但我们可以通过让 Web 服务器读取配置文件并从该配置文件加载处理程序类来消除这种情况。在这两种情况下,他们都可以忽略大多数较低级别的细节,就像BaseHTTPRequestHandler类的作者允许我们忽略处理套接字连接和解析 HTTP 请求的细节一样。

这些想法通常很有用;看看您是否可以在自己的项目中找到使用它们的方法。

  1. 我们将在本章的几个地方多次使用handle_error,包括几个状态码404不合适的情况。在您继续阅读时,请尝试思考如何扩展此程序,以便可以在每种情况下轻松提供状态响应代码。

  2. 我们的代码还使用了popen2库函数,该函数已被弃用,取而代之的是subprocess模块。但是,在本文档中,popen2是使用起来不太分散注意力的工具。