简单的Web服务(Python实现)

介绍

互联网在过去20年改变了人类的生活方式,但是它的核心并没有改变多少。大多数系统仍然遵循Tim Berners-Lee在25年前创建的规则,特别是,大多数Web Server仍以相同的方式处理类似的信息。

原文地址:A Simple Web Server

本节将会探索Web Server的实现。

背景

几乎在互联网上的所有程序都使用互联网协议(IP)这个通信标准。我们最关心的一族协议是TCP/IP协议-它让计算机之间的通信就像读写文件一样。

程序通过套接字(socket)进行通信。一个套接字是点对点通信通道的一端,就像电话连线的一端。一个套接字包含一个IP地址(定位一个特定的机器)和端口号。IP地址由4个8位的数字组成,例如,174.136.14.108;域名系统(DNS)是IP地址的名字映射,如 aosabook.org,它帮助人记忆。

端口号是0-65535之间的一个数字,它可以唯一定位一个主机上的套接字。端口0-1023是操作系统保留使用的,其他应用可以使用余下的端口。

超文本传输协议(HTTP)描述了程序通过 IP 协议交换数据的一种方式。HTTP的设计很简单:客户端通过socket连接发送一个具体请求,服务器对这个请求响应数据。这个数据可以是文件的拷贝、程序动态生成或混合两者。

Screen Shot 2016-06-02 at 16.46.11

关于HTTP请求最重要的东西是,它只是文本(text):任何程序都可以创建请求。如图:

一个简单的Web服务程序

HTTP几乎总是使用“GET”(获得数据)或“POST”(提交数据)方法。URL指定客户端想要的数据;它通常是文件路径,例如 /research/experiments.html,但是也不一定,服务器完全可以决定怎么做。

HTTP的版本通常是“HTTP/1.0” 或 “HTTP/1.1″。

HTTP请求头是键值对,例如:

不想hash表的key,它可以在http头中出现多次。

最后请求体是和请求相关的额外数据。它可以用在web表单数据的提交、上传文件等等。在HTTP请求头和请求体之间必须有一个空行,这个空行代表请求头的结束,请求体的开始。

HTTP响应格式:

一个简单的Web服务程序

版本(version),请求头(header)和消息体(body)有着相同的格式和意义。状态码(status code)是一个数字,用来表示请求被处理后发生了什么:200 表示“一切正常”,404 表示“没找到”,其他的状态码有其他的意义。状态词以人类可读的短语重复该信息,比如“OK”或者“not found”。

对于本节来说,有关 HTTP 我们只需要掌握两件事情。

第一,它是无状态的(stateless):每个请求都只处理自身,并且服务器并不记录有关两次请求的任何信息。如果一个应用想要跟踪什么,比如说用户识别,它必须自行处理。

通常我们用 cookie 来做这件事,cookie 是服务端发送给客户端的一个短字符串,并且稍后客户端会送回给服务端。当用户需要运行一些方法,这些方法需要在几个请求中保存状态,那么服务端会创建一个新的 cookie 存到数据库,然后发送给浏览器。每次浏览器将该 cookie 送回来,服务端都会用它查询信息以了解该用户正在做什么。

我们要知道的第二件事是 URL 能带参数来提供更多的信息。举个例子,如果我们正在使用搜索引擎,我们需要指定搜索关键字。我们可以将它们加到 URL 路径中,但我们应该做的是在 URL 中加入参数。我们的做法是,在 URL 中添加一个“?”,后面跟着以‘&’分隔的‘key=value’键值对。举个例子,URL http://www.google.ca?q=Python 要求 Google 查询与 Python 有关的页面:key是字母‘q’,value是‘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

开启 sockets,构建 HTTP 请求,以及解析响应是很乏味的,因此很多人都使用一些类库来做大多数的工作。Python 有个类似的库叫做 urllib2 (因为它是用来替代早先一个叫 urllib 的库),但它暴露了一些大多数用户从来不想去关注的东西。相比 urllib2Requests 库更易于使用。这里有个使用它来从 AOSA 网站下载页面的例子:

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,这就是下面的小程序所做的事情:

BaseHTTPRequest 库中的 BaseHTTPRequestHandler 类处理解析收到的 HTTP 请求并且判断请求使用的是哪种方法。如果是 GET,就是调用一个叫 do_GET 的方法。我们的类 RequestHandler 重载了该方法来动态生成一个简单的页面:文本存储在类变量 Page 中,在发送 200 响应码之后,我们将它发送给客户端,Content-Type 头用来告诉客户端以 HTML 的方式解析我们的数据,以及长度。(调用 end_headers 会插入空白行将响应头和消息主体分开。)

但是 RequestHandler 并不够:我们仍然需要最后三行来实际启动一个服务器。第一行以元组定义了服务端的地址:空字符串表示“在当前机器上运行”,端口是8080。然后我们用该地址和我们的请求处理类作为参数,创建一个 BaseHTTPServer.HTTPServer 的实例,然后让它永远运行(这意味着除非我们用 Control-C 停止它)。

如果在命令行运行这个程序,不会有任何信息显示:

然后我们在浏览器上访问 http://localhost:8080,我们会在浏览器中看到:

以及在 shell 中看到如下信息:

第一行很直观:由于我们并没有请求具体的文件,因此浏览器请求‘/’(服务器服务的根目录)。出现第二行是因为浏览器自动发送了/favicon.ico图像文件的请求,如果存在的话,它将在地址栏显示一个小图标。

显示值

我们来修改我们的 web 服务器来显示一些包含在 HTTP 请求中的值。(在 debug 的时候,我们会经常这么做,因此同时也能练习一下。)为了保持代码的整洁,我们将创建页面和发送页面分离开:

sent_page 跟之前的差不多:

我们要显示的模板,是一个字符串,它是一个含有一些格式占位符的 HTML 表格:

用来填充它的方法:

程序的主体并没有改变:跟之前一样,创建一个 HTTPServer 的实例,然后永久提供请求服务。如果我们用浏览器请求 http://localhost:8080/something.html,我们将看到:

请注意我们并没有看到 404 错误,尽管 something.html 页面并不存在。那是因为 web 服务器只是一个程序,在拿到一个请求后,可以做任何事情:送回上一次请求中的文件,随机选择服务一个维基页面,或者我们事先编码的东西。

为静态页面提供服务

很明显,下一步是开始服务从磁盘上拿到的页面,而不是随便生成的东西。我们将从重写 do_GET 开始:

这个方法假定我们允许服务 web 服务器运行目录下(由 os.getcwd 获得)的任何文件。它将这个同 URL 中提供的路径(类库自动将它放在 self.path 中,并使用以‘/’开头)来得到用户需要的文件的路径。

如果路径不存在,或者它不是一个文件,方法将抛出异常并报告一个错误。如果路径匹配到一个文件,它会调用一个叫 handle_file 的方法来读取并返回文件的内容。这个方法用来读取文件并且使用已有的 send_content 方法将它送回给客户端:

注意我们以二进制模式打开文件 — ‘rb’中的‘b’ — 因此 Python 不会“帮助”我们改变字节序,比如说向 Windows 那样的行编码。还要注意,在现实生活中,将整个文件读入内存中并不是一个好主意,尤其是这个文件可能是有几个G的视频文件。处理这样的情况不在本文的讨论范围。

来让我们完成整个类,我们需要编写错误处理的方法和错误报告页面的模板:

程序可以工作啦。但有一个问题是它总是返回 200 状态码,即使请求了一个并不存在的页面。页面被返回是以防有错误信息,但由于浏览器并不懂英语,它并不知道事实上这个请求是失败的。为了让这种情况更清楚明白,我们需要修改 handle_errorsent_content

注意当一个文件找不到的时候,我们并没有抛出 ServerException,而是生成了错误页面作为代替。ServerException 意味着在服务端出现了内部错误,比如说,我们的程序出错了。而 handle_error 生成的错误页面,意味着用户那边出错了,比如说,用户请求了一个文件并不存在的 URL。

列出目录

下一步,如果给出的 URL 中的路径是一个目录而不是文件的话,我们能让 web 服务器列出目录的内容。我们甚至可以更进一步,去显示该目录的 index.html 文件,只有当该文件不存在的时候,才列出目录内容。

但是将这些规则写进 do_GET 是不合适的,因为修改后的方法将会由一个很长的 if 语句来控制特殊行为。正确的做法是回到上一个处理 URL 的方法来解决这个通用的问题。下面是重写后的 do_GET 方法:

第一步是相同的:找出被请求对象的完整路径。之后,代码看着有些不一样了。代码没有继续使用一系列的条件语句,取而代之的是遍历存在列表中的一组用例。没一组用例都是一个包含两个方法的对象:test,告诉我们它是否能处理这次请求,act是用来实现具体的功能。一旦我们找到了正确的用例,我们会让它处理请求并且跳出循环。

以下三个类重现了之前服务端的行为:

然后,我们在 RequestHandler 类的顶层定义了一个包含有用例处理对象的列表:

表面上看,以上这些让我们的服务端变得更复杂了:代码文件已经从74行涨到了99行,而且并没有增加额外的新功能。但这样的好处是,如本章开头的任务,我们想让我们的服务器在文件目录有 index.html 文件的时候,加载 index.html 文件,在没有的时候列出目录内容。那么处理程序可以这么写:

index_path 方法将路径与 index.html 一起构建成新的路径。test 用于检查该路径是否是一个包含 index.html 的目录,act 告诉主请求处理程序加载该页面。

RequestHandler 要做的改动就是将 case_directory_index_file 对象加到 Cases 列表中:

如果目录里不含 index.html 呢?test 部分跟上面一样,只是多了一个 not 条件,但是 act 方法呢?我们该怎么做?

似乎我们让自己陷入了困境。逻辑上,act 方法应该创建并且返回目录内容,但是我们现在的代码并不允许我们那么做:RequestHandler.do_GET 调用 act,但并不需要或者处理得到的返回值。现在,让我们为 RequestHandler 添加一个新的函数来生成目录列举,然后在用例的 act 方法中调用它:

CGI 协议

当然,大多数人并不希望为了增加新的功能而修改 web 服务器的源码。为了避免让大家那样做,服务器支持一种叫通用网关接口(Common Gateway Interface,CGI)的机制,它为 web 服务器提供了一种标准方式来运行外部程序以及处理请求。

举个例子,假设我们想让服务器在 HTML 页面中显示本地时间。我们可以写一个只有几行代码的独立程序来做这件事:

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

test 方法很简单:检查文件是以 .py 结尾的么?act 同样很简单:告诉 RequestHandler 运行这个程序。

这是非常不安全的:如果有人知道我们服务器上的一个 Python 文件的路径,这会让他们可以直接运行它,而没有考虑程序访问的是什么数据,是不是包含有死循环,或者别的东西。

先不考虑这些,代码的核心思想很简单:

  1. 以子进程的方式运行程序。
  2. 捕获子进程送给标准输出的内容。
  3. 将该内容送回给发起请求的客户端。

完整的 CGI 协议比这丰富的多-尤其是,它允许在 URL 中带有参数,服务器会将它传给运行的程序—但这些细节并不会影响系统的整体架构。。。它又一次变得更复杂。RequestHandler 最初只有一个函数,handler_file,来处理内容。而现在我们添加了两个新的情况 list_dirrun_cgi。但这三个方法其实并不属于 RequestHandler,它们主要被其他地方调用。

修正的方法很直观:为所有的处理类创建一个父类,并且当有且仅有函数被两个或两个以上类处理程序共用时,我们需要将它移到父类中。我们完成后,RequestHandler 将长这样:

而用例处理类的父类长这样:

存在文件的处理类(只是随机选择一个例子):

讨论

我们最初的代码和重构后的版本之间的不同点反映了两个重要的思想。第一个是要善于将类作为相关服务的集合。 RequestHandlerbase_case 并不做决定或者执行人物;它们提供其他类可以使用的工具。

第二件事是可扩展性:人们可以往我们的 web 服务器增加新的功能,无论是编写外部 CGI 程序,或是增加新的用例处理类。后者需要对 RequestHandler 做一行修改(将用例处理类加到用例列表),但我们可以通过让 web 服务器读取配置文件的方式来避免这样做,并且让 web 服务器从中加载操作类。在两个例子当中,我们都可以忽略很多底层的细节,就像 BaseHTTPRequestHandler 类的作者允许我们忽略处理 socket 连接和解析 HTTP 请求一样。

这些想法是通用的,看你在你自己的项目中是否有机会用到它。

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注