Shanshan Pythoner Love CPP

500 Lines or Less Chapter 22: A Simple Web Server 翻译


500 Lines or Less

介绍

网络在过去二十年里以无数的方式改变了社会,但核心变化很小。大多数系统仍然遵循Tim Berners-Lee在世纪前提出的规则。特别地,大多数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。)数据可以从磁盘上的文件复制,由程序动态生成,或一些混合的两个。

HTTP请求最重要的是它只是文本:可以创建或解析它的程序。 为了理解,该文本必须具有图22.2所示的部分。

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):

版本,标题和正文具有相同的形式和含义。状态码是指示处理请求时发生了什么的数字:200表示“一切正常”,404表示“未找到”,其他代码也有其他含义。状态短语以“OK”或“not found”等可读的短语。

为了本章,我们需要了解有关HTTP的其他两件事情。

第一个是无状态的:每个请求都是自己处理的,服务器在请求和下一个请求之间不记录任何东西。如果应用程序想要跟踪用户身份的内容,那么它本身就必须这样做。

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

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

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

打开套接字,构建HTTP请求和解析响应是乏味的,所以大多数人使用库来完成大部分工作。Python附带了urllib2的库(它是urllib的替代品),但它暴露了大量人们不关心的管道。Request库是urllib2更容易使用的替代方法。这是一个使用它从AOSA图书网站下载页面的示例:

import requests
response = requests.get('http://aosabook.org/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页面)。

Hello Web

我们现在准备编写第一个简单的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的图像文件,它将在地址栏中显示为图标(如果存在)。

Displaying Values

让我们修改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

请注意,即使页面something.html不存在于磁盘上的文件,我们也不会收到404错误。这是因为一个Web服务器只是一个程序,并且可以在获取请求时做任何事情:发回上一个请求中命名的文件,提供随机选择的维基百科页面,或者我们编写的任何其他文件。

Serving Static Pages

明显下一步是从磁盘开始提供页面,而不是快速生成。我们从重写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行结束的字节序列“帮助”我们。 还要注意,在服务时将整个文件读入内存是一个坏主意,在现实生活中,文件可能是几千兆字节的视频数据。 处理这种情况不在本章的范围之内。

要完成这个课程,我们需要编写错误处理方法和错误报告页面的模板:

    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。

罗列Directories

下一步,当URL中的路径是目录而不是文件时,教会Web服务器显示目录内容的列表。我们甚至可以进一步,让它在该目录中查找要显示的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)

第一步是一样的:找出要求的东西的完整路径。之后,代码有很大的不同。不是一系列内联测试,这个版本循环了一组存储在列表中的案例。每种情况都是一个有两种方法的对象:测试,它告诉我们是否能够处理请求并采取行动,这实际上需要采取一些行动 一旦找到正确的案例,我们就让它处理这个请求并突破循环。

这三种情况类再现了我们以前服务器的行为:

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类顶部的case处理程序列表:

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的杂乱。测试检查路径是否是包含index.html页面的目录,并且请求主要请求处理程序来提供该页面。

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

    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添加一个方法来生成目录列表,并从case handler的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)

The CGI Protocol

当然,大多数人不想编辑他们的网络服务器的源,以便添加新功能。为了不这么做,服务器一直支持通用网关接口(CGI)的机制,它为Web服务器运行外部程序以满足请求提供了一种标准方式。

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

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

为了让web服务器为我们运行这个程序,我们添加case处理程序:

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文件的路径,我们只是让它们运行,而不用担心它可以访问什么数据,无论它是否包含无限循环,或其他。

完成这一点,核心思想很简单:

  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)
while the parent class for our case handlers is:

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进行单行更改(将案例处理程序插入到案例列表中),但是我们可以通过使Web服务器读取配置文件并从中加载处理程序类来消除这一点。在这两种情况下,它们都可以忽略大多数较低级别的详细信息,就像BaseHTTPRequestHandler类忽略处理套接字连接和解析HTTP请求的细节一样。

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


Comments

Content