介绍
网络在过去二十年里以无数的方式改变了社会,但核心变化很小。大多数系统仍然遵循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服务器。基本思路很简单:
-
等待某人连接到我们的服务器并发送HTTP请求;
-
解析该请求;
-
弄清楚它在要求什么
-
获取数据(或动态生成);
-
将数据格式化为HTML; 和
-
发回来
步骤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_error
和send_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文件的路径,我们只是让它们运行,而不用担心它可以访问什么数据,无论它是否包含无限循环,或其他。
完成这一点,核心思想很简单:
-
在子进程中运行程序。
-
捕获任何子处理发送到标准输出。
-
发送给发出请求的客户端。
完整的CGI协议比这更多 —— 特别是它允许URL中的参数,服务器进入正在运行的程序,但这些详细信息不会影响系统的整体架构…
…再次变得相当纠结。RequestHandler
最初有一个方法handle_file
,用于处理内容。现在我们以list_dir
和run_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)
讨论
我们的原始代码和重构版本之间的差异反映了两个重要的想法。第一个是把一个类作为相关服务的集合。RequestHandler
和base_case
不做决定或采取任何行动; 他们提供其他类可以用来做这些事情的工具。
第二个是可扩展性:人们可以通过编写外部CGI程序或添加案例处理程序类来为我们的Web服务器添加新功能。后者需要对RequestHandler
进行单行更改(将案例处理程序插入到案例列表中),但是我们可以通过使Web服务器读取配置文件并从中加载处理程序类来消除这一点。在这两种情况下,它们都可以忽略大多数较低级别的详细信息,就像BaseHTTPRequestHandler
类忽略处理套接字连接和解析HTTP请求的细节一样。
这些想法通常是有用的;看你能否在自己的项目中找到使用它们的方法。