我们将不再讨论探索性数据分析的主题,而是关注 web 服务器和 web 服务。web 服务器在某种程度上是一系列功能的级联。我们可以应用许多功能设计模式来解决呈现 web 内容的问题。我们的目标是寻找我们能够实现表征状态转移(REST的方法。我们希望使用功能设计模式构建 RESTful web 服务。
我们不需要再发明另一个 Python web 框架。我们也不想从可用的框架中进行选择。Python 中有许多 web 框架可用,每个框架都有一组独特的特性和优势。
本章的目的是介绍一些可应用于大多数可用框架的原则。这将使我们能够利用功能设计模式来呈现 web 内容。
当我们查看非常大或复杂的数据集时,我们可能需要一个支持子集或搜索的 web 服务。我们可能还需要一个网站,可以下载多种格式的子集。在这种情况下,我们可能需要使用功能设计来创建 RESTful web 服务,以支持这些更复杂的需求。
交互式 web 应用程序通常依赖于有状态会话,以使网站更易于用户使用。用户的会话信息通过 HTML 表单提供的数据更新,从数据库获取,或从以前交互的缓存中调用。由于有状态数据必须作为每个事务的一部分进行获取,因此它更像是一个输入参数或结果值。即使存在 cookie 和数据库更新,这也会导致功能式编程。
在本章中,我们将讨论几个主题:
- HTTP 请求和响应模型的总体思想。
- Python 应用程序使用的Web 服务网关接口(WSGI标准)。
- 利用 WSGI,可以将 web 服务定义为函数。这符合无状态服务器的 HTTP 思想。
- 我们还将研究授权客户端应用程序使用 web 服务的方法。
HTTP 协议几乎是无状态的:用户代理(或浏览器)发出请求,服务器提供响应。对于不涉及 cookie 的服务,客户端应用程序可以查看协议的功能视图。我们可以使用http.client或urllib库构建客户机。HTTP 用户代理基本上执行以下类似的操作:
import urllib.request
def urllib_demo(url):
with urllib.request.urlopen(url) as response:
print(response.read())
urllib_demo("http://slott-softwarearchitect.blogspot.com")像wget或curl这样的程序使用作为命令行参数提供的 URL 进行此类处理。浏览器这样做是为了响应用户的指向和单击;URL 取自用户的操作,通常是单击链接文本或图像的操作。
然而,用户体验(UX)设计的实际考虑导致了一些有状态的实现细节。当客户机必须跟踪 cookie 时,它将变为有状态。响应头将提供 cookie,后续请求必须将 cookie 返回到服务器。稍后我们将更详细地了解这一点。
HTTP 响应可以包括需要用户代理执行其他操作的状态代码。300-399 范围内的许多状态代码表示请求的资源已被移动。然后,用户代理需要保存Location头中的详细信息,并请求一个新的 URL。401状态码表示需要认证;用户代理必须使用包含访问服务器凭据的Authorization头发出另一个请求。urllib库实现处理这种有状态的客户端处理。http.client库不会自动遵循3xx重定向状态代码。
用户代理处理3xx和401代码的技术可以通过简单的递归来处理。如果状态没有指示重定向,则这是基本情况,函数将有一个结果。如果需要重定向,可以使用重定向地址递归调用函数。
从协议的另一面看,静态内容服务器可能是无状态的。为此,我们可以使用http.server库,如下所示:
from http.server import HTTPServer, def server_demo():
httpd = HTTPServer(
('localhost', 8080), SimpleHTTPRequestHandler)
while True:
httpd.handle_request()
httpd.shutdown()我们创建了一个服务器对象,并将其分配给httpd变量。我们提供了用于侦听连接请求的地址和端口号。TCP/IP 协议将在单独的端口上生成连接。HTTP 协议将从另一个端口读取请求,并创建处理程序的实例。
在本例中,我们提供了SimpleHTTPRequestHandler作为每个请求的实例化类。此类必须实现一个最小的接口,该接口将发送头,然后将响应的主体发送给客户端。这个特殊的类将提供来自本地目录的文件。如果我们想定制它,我们可以创建一个子类,它实现了do_GET()和do_POST()等方法来改变行为。
通常,我们使用serve_forever()方法,而不是编写自己的循环。我们在这里展示了这个循环,以澄清如果我们需要停止服务器,通常服务器必须崩溃。
添加 cookie 会将客户端和服务器之间的整体关系更改为有状态。有趣的是,它不涉及对 HTTP 协议本身的更改。状态信息通过请求和应答上的头进行通信。服务器将在响应头中向用户代理发送 cookie。用户代理将在请求头中保存和回复 cookies。
用户代理或浏览器需要保留作为响应一部分提供的 cookie 值缓存,并在后续请求中包含适当的 cookie。web 服务器将在请求标头中查找 Cookie,并在响应标头中提供更新的 Cookie。其效果是使 web 服务器无状态;状态更改仅在客户端中发生。服务器将 cookie 视为请求中的附加参数,并在响应中提供附加详细信息。
Cookies 可以包含任何内容。它们通常是加密的,以避免将 web 服务器详细信息暴露给客户端计算机上运行的其他应用程序。传输较大的 cookie 会减慢处理速度。优化这种状态处理最好由现有框架来处理。我们将忽略 cookie 和会话管理的详细信息。
会话的概念是 web 服务器的一个功能,而不是 HTTP 协议。会话通常定义为具有相同 cookie 的一系列请求。当发出初始请求时,没有可用的 cookie,将创建一个新的会话 cookie。每个后续请求都将包含 cookie。登录用户的会话 cookie 中将包含其他详细信息。只要服务器愿意接受 cookie,会话就可以持续:cookie 可以永远有效,也可以在几分钟后过期。
web 服务的 REST 方法不依赖于会话或 cookie。每个 REST 请求都是不同的。这使得它不像互动网站那样用户友好,互动网站使用 cookies 简化用户交互。我们将重点关注 RESTful web 服务,因为它们非常适合功能设计模式。
无会话 REST 进程的一个结果是每个单独的 REST 请求都经过单独的身份验证。如果需要认证,则表示 REST 流量必须使用安全套接字层(SSL协议);https方案可用于安全地将凭证从客户端传输到服务器。
HTTP 背后的一个核心思想是服务器的响应是请求的函数。从概念上讲,web 服务应该有一个顶级实现,可以概括如下:
response = httpd(request)然而,这是不切实际的。事实证明,HTTP 请求不是一个简单、单一的数据结构。它有一些必需的部分和一些可选的部分。请求可能有头、方法和路径,并且可能有附件。附件可能包括表格或上传的文件,或两者兼而有之。
为了使事情更加复杂,浏览器的表单数据可以作为查询字符串发送到GET请求路径中。或者,它可以作为POST请求的附件发送。虽然存在混淆的可能性,但大多数 web 应用程序框架都会创建 HTML 表单标记,通过<form>标记中的"method=POST"参数提供数据;表单数据将作为附件包含在请求中。
HTTP 响应和请求的头都与正文分开。请求还可以包含一些附加的表单数据或其他上传。因此,我们可以想象这样的 web 服务器:
headers, content = httpd(headers, request, [form or uploads]) 请求头可能包括 cookie 值,这可以看作添加了更多参数。此外,web 服务器通常依赖于运行它的操作系统环境。可以将此 OS 环境数据视为作为请求的一部分提供的更多参数。
有大量但定义合理的内容。多用途 Internet 邮件扩展(MIME类型定义 web 服务可能返回的内容类型。这可以包括纯文本、HTML、JSON、XML 或网站可能提供的各种非文本媒体。
当我们更仔细地研究构建对 HTTP 请求的响应所需的处理时,我们将看到一些我们希望重用的常见特性。这种可重用元素的思想导致了 web 服务框架的创建,这些框架涵盖了从简单到复杂的范围。功能设计允许我们重用功能的方式表明,功能方法可以帮助构建 web 服务。
我们将通过研究如何创建服务响应的各种元素的管道来研究 web 服务的功能设计。我们将通过嵌套请求处理的函数来实现这一点,以便内部元素不受外部元素提供的一般开销的影响。这还允许外部元素充当过滤器:无效请求可能会产生错误响应,从而允许内部函数狭隘地关注应用程序处理。
我们可以将 web 请求处理视为许多嵌套上下文。例如,外部上下文可能涉及会话管理:检查请求以确定这是现有会话中的另一个请求还是新会话中的另一个请求。内部上下文可以提供用于表单处理的令牌,该令牌可以检测跨站点请求伪造(CSRF。另一个上下文可能处理会话中的用户身份验证。
上述功能的概念视图如下所示:
response = content(
authentication(
csrf(
session(headers, request, forms)
)
)
)这里的想法是,每个函数都可以建立在前一个函数的结果之上。每个函数要么充实请求,要么拒绝请求,因为请求无效。例如,session()函数可以使用头来确定这是现有会话还是新会话。csrf()函数将检查表单输入,以确保使用了正确的令牌。CSRF 处理需要有效的会话。authentication()函数可以为缺少有效凭证的会话返回错误响应;当存在有效凭据时,它可以使用用户信息丰富请求。
content()功能不用担心会话、伪造和未经身份验证的用户。它可以专注于解析路径,以确定应该提供什么类型的内容。在更复杂的应用程序中,content()函数可能包括从路径元素到确定适当内容的函数的相当复杂的映射。
然而,嵌套函数视图仍然不太正确。问题是,每个嵌套上下文可能还需要调整响应,而不是调整请求,或者除了调整请求之外。
我们真的想要这样的东西:
def session(headers, request, forms):
pre-process: determine session
content = csrf(headers, request, forms)
post-processes the content
return the content
def csrf(headers, request, forms):
pre-process: validate csrf tokens
content = authenticate(headers, request, forms)
post-processes the content
return the content这个概念指向一种功能设计,通过嵌套的函数集合创建 web 内容,这些函数提供丰富的输入或丰富的输出,或两者兼而有之。只要稍微聪明一点,我们就应该能够定义一个简单、标准的接口,各种功能都可以使用。一旦我们标准化了一个界面,我们就可以以不同的方式组合功能并添加特性。我们应该能够满足我们的函数式编程目标,即拥有提供 web 内容的简洁而富有表现力的程序。
Web 服务器网关接口(WSGI)定义了一种相对简单、标准化的设计模式,用于创建对 Web 请求的响应。这是大多数基于 Python 的 web 服务器的通用框架。以下链接提供了大量信息:http://wsgi.readthedocs.org/en/latest/ 。
WSGI 的一些重要背景可在中找到 https://www.python.org/dev/peps/pep-0333 。
Python 库的wsgiref包包括 WSGI 的参考实现。每个 WSGI应用都有相同的接口,如下图:
def some_app(environ, start_response):
return content environ参数是一个字典,在一个统一的结构中包含请求的所有参数。用于表单或文件上载的头、请求方法、路径和任何附件都将在环境中。除此之外,还提供了操作系统级上下文以及作为 WSGI 请求处理一部分的一些项目。
start_response参数是必须用于发送响应的状态和标题的函数。最终负责构建响应的 WSGI 服务器部分将使用给定的start_response()函数,并将构建响应文档作为返回值。
从 WSGI 应用程序返回的响应是一系列字符串或类似字符串的文件包装,将返回给用户代理。如果使用 HTML 模板工具,则序列可能只有一个项目。在某些情况下,例如Jinja2模板,该模板可以作为文本块序列延迟呈现。这允许服务器将模板填充与下载交错到用户代理。
我们可以对 WSGI 应用程序使用以下类型提示:
from typing import (
Dict, Callable, List, Tuple, Iterator, Union, Optional
)
from mypy_extensions import DefaultArg
SR_Func = Callable[
[str, List[Tuple[str, str]], DefaultArg(Tuple)], None]
def static_app(
environ: Dict,
start_response: SR_Func
) -> Union[Iterator[bytes], List[bytes]]:SR_Func类型定义是start_response函数的签名。请注意,该函数有一个可选参数,需要来自mypy_extensions模块的函数来定义此功能。
整体 WSGI 功能static_app()需要环境和start_response()功能。结果是字节序列或字节迭代器。来自static_app()函数的返回类型的并集可以扩展为包含BinaryIO和List[BinaryIO],但本章中的任何示例都没有使用它们。
截至发布日期,wsgiref包没有一套完整的类型定义。具体而言,wsgiref.simple_server模块缺少适当的存根定义,将导致mypy发出警告。
每个 WSGI 应用程序都设计为一组函数。可以将集合视为嵌套函数或转换链。链中的每个应用程序要么返回错误,要么将请求交给另一个将确定最终结果的应用程序。
通常,URL 路径用于确定将使用许多备选应用程序中的哪一个。这将形成一个 WSGI 应用程序树,这些应用程序可能共享公共组件。
下面是一个非常简单的路由应用程序,它采用 URL 路径的第一个元素,并使用该元素定位另一个提供内容的 WSGI 应用程序:
SCRIPT_MAP = {
"demo": demo_app,
"static": static_app,
"index.html": welcome_app,
}
def routing(environ, start_response):
top_level = wsgiref.util.shift_path_info(environ)
app = SCRIPT_MAP.get(top_level, welcome_app)
content = app(environ, start_response)
return content 此应用程序将使用wsgiref.util.shift_path_info()功能调整环境。更改是请求路径中项目上的头/尾拆分,可在environ['PATH_INFO']字典中找到。到达第一个"/"的路径的头部将被移动到环境中的SCRIPT_NAME项中;PATH_INFO项将更新为具有路径尾部。返回的值也将是路径的头部,与environ['SCRIPT_NAME']的值相同。如果没有要解析的路径,则返回值为None,不进行环境更新。
routing()函数使用路径上的第一项在SCRIPT_MAP字典中定位应用程序。我们使用welcome_app作为默认值,以防请求的路径不符合映射。这似乎比 HTTP404 NOT FOUND错误要好一点。
此 WSGI 应用程序是一个在许多其他 WSGI 函数之间进行选择的函数。请注意,路由函数不返回函数;它为所选 WSGI 应用程序提供修改后的环境。这是将工作从一个功能转移到另一个功能的典型设计模式。
很容易看出框架如何使用正则表达式概括路径匹配过程。我们可以想象用一系列的正则表达式(REs)和 WSGI 应用程序来配置routing()函数,而不是从字符串映射到 WSGI 应用程序。增强的routing()功能应用程序将评估每个重新查找匹配项的情况。在匹配的情况下,在调用请求的应用程序之前,可以使用任何match.groups()函数来更新环境。
WSGI 应用程序的一个中心特性是,链上的每个阶段都负责过滤请求。其思想是在处理过程中尽早拒绝错误的请求。Python 的异常处理使这一点特别简单。
我们可以定义一个提供静态内容的 WSGI 应用程序,如下所示:
def static_app(
environ: Dict,
start_response: SR_Func
) -> Union[Iterator[bytes], List[bytes]]:
log = environ['wsgi.errors']
try:
print(f"CWD={Path.cwd()}", file=log)
static_path = Path.cwd()/environ['PATH_INFO'][1:]
with static_path.open() as static_file:
content = static_file.read().encode("utf-8")
headers = [
("Content-Type", 'text/plain;charset="utf-8"'),
("Content-Length", str(len(content))),
]
start_response('200 OK', headers)
return [content]
except IsADirectoryError as e:
return index_app(environ, start_response)
except FileNotFoundError as e:
start_response('404 NOT FOUND', [])
return [
f"Not Found {static_path}\n{e!r}".encode("utf-8")
]此应用程序从当前工作目录和作为请求 URL 的一部分提供的路径元素创建一个Path对象。路径信息是 WSGI 环境的一部分,位于带有'PATH_INFO'键的项中。由于路径的解析方式,它将有一个前导的"/",我们使用environ['PATH_INFO][1:]将其丢弃。
此应用程序尝试以文本文件的形式打开请求的路径。有两个常见问题,都作为例外处理:
- 如果文件是目录,我们将使用不同的应用程序
index_app来显示目录内容 - 如果文件根本找不到,我们将返回一个
HTTP 404 NOT FOUND响应
此 WSGI 应用程序引发的任何其他异常都不会被捕获。调用此应用程序的应用程序应设计为具有一些通用错误响应功能。如果应用程序不处理异常,将使用通用 WSGI 故障响应。
Our processing involves a strict ordering of operations. We must read the entire file so that we can create a proper HTTP Content-Length header.
这里有两种处理异常的方法。在一种情况下,调用了另一个应用程序。如果需要向其他应用程序提供其他信息,则必须使用所需信息更新环境。这就是如何为复杂网站构建标准化错误页面的方法。
另一个案例调用了start_response()函数并返回了一个错误结果。这适用于独特的本地化行为。最终内容以字节形式提供。这意味着 Python 字符串必须正确编码,我们必须向用户代理提供编码信息。甚至错误信息repr(e)在下载之前也已正确编码。
WSGI 标准的目的不是定义一个完整的 web 框架;其目的是定义一组最低限度的标准,允许 web 相关处理的灵活互操作性。框架可以采用完全不同的方法来提供 web 服务。最外层的接口应该与 WSGI 兼容,以便可以在各种上下文中使用。
Apachehttpd和Nginx等 Web 服务器具有适配器,可提供从 Web 服务器到 Python 应用程序的 WSGI 兼容接口。有关 WSGI 实现的更多信息,请访问:https://wiki.python.org/moin/WSGIImplementations 。
将我们的应用程序嵌入到一个更大的服务器中,可以使我们有一个整洁的关注点分离。我们可以使用 apachehttpd 来提供完全静态的内容,例如.css、.js和图像文件。不过,对于 HTML 页面,像 NGINX 这样的服务器可以使用uwsgi模块将请求传递给单独的 Python 进程,该进程只处理 web 内容中感兴趣的 HTML 部分。
将静态内容与动态内容分离意味着我们必须创建一个单独的媒体服务器,或者将我们的网站定义为具有两组路径。如果采用第二种方法,某些路径将具有完全静态的内容,并且可以由 Nginx 处理。其他路径将具有动态内容,这些内容将由 Python 处理。
在使用 WSGI 函数时,需要注意的是,我们不能以任何方式修改或扩展 WSGI 接口。关键是与应用程序的外部可见层完全兼容。内部结构和处理不必符合 WSGI 标准。外部接口必须毫无例外地遵循这些规则。
WSGI 定义的一个结果是,environ字典经常使用附加的配置参数进行更新。通过这种方式,一些 WSGI 应用程序可以充当网关,用从 cookie、配置文件或数据库中提取的信息丰富环境。
我们将看一看 RESTful web 服务,它可以对数据源进行分割,并以 JSON、XML 或 CSV 文件的形式提供下载。我们将提供一个与 WSGI 兼容的包装器。执行应用程序实际工作的函数不会受到严格限制,以符合 WSGI 标准。
我们将使用一个包含四个子集合的简单数据集:Anscombe 四方。我们在第 3 章、函数、迭代器和生成器中研究了读取和解析这些数据的方法。这只是一小部分数据,但可以用来展示 RESTful web 服务的原理。
我们将把应用程序分为两层:一层是 web 层,它是一个简单的 WSGI 应用程序;另一层是数据服务层,它是更典型的功能编程。我们将首先看一下 web 层,这样我们就可以专注于提供有意义结果的函数式方法。
我们需要向 web 服务提供两条信息:
- 我们想要的四重奏:这是一个切片和骰子操作。其思想是通过过滤和提取有意义的子集来分割信息。
- 我们想要的输出格式。
数据选择通常通过请求路径完成。我们可以要求/anscombe/I/或/anscombe/II/从四重奏中选取特定数据集。其思想是,URL 定义了一个资源,而 URL 没有任何改变的理由。在这种情况下,数据集选择器不依赖于日期、某些组织批准状态或其他外部因素。URL 是永恒和绝对的。
输出格式不是 URL 的一级部分。它只是一种序列化格式,而不是数据本身。在某些情况下,通过 HTTPAccept头请求格式。这在浏览器中很难使用,但在使用 RESTful API 的应用程序中很容易使用。从浏览器提取数据时,通常使用查询字符串指定输出格式。我们将使用路径末尾的?form=json方法来指定 JSON 输出格式。
我们可以使用的 URL 如下所示:
http://localhost:8080/anscombe/III/?form=csv 这将请求第三个数据集的 CSV 下载。
首先,我们将使用一个简单的 URL 模式匹配表达式来定义应用程序中唯一的路由。在更大或更复杂的应用程序中,我们可能有多个这样的模式:
import re
path_pat= re.compile(r"^/anscombe/(?P<dataset>.*?)/?$")此模式允许我们在路径的顶层定义 WSGI 意义上的整体脚本。在本例中,脚本为anscombe。我们将把路径的下一级作为数据集,从 Anscombe 四重奏中进行选择。数据集值应为I、II、III或IV中的一个。
我们使用命名参数作为选择标准。在许多情况下,RESTful API 使用语法进行描述,如下所示:
/anscombe/{dataset}/ 我们将这个理想化的模式转换为适当的正则表达式,并在路径中保留数据集选择器的名称。
以下是一些示例 URL 路径,演示了此模式的工作原理:
>>> m1 = path_pat.match( "/anscombe/I" )
>>> m1.groupdict()
{'dataset': 'I'}
>>> m2 = path_pat.match( "/anscombe/II/" )
>>> m2.groupdict()
{'dataset': 'II'}
>>> m3 = path_pat.match( "/anscombe/" )
>>> m3.groupdict()
{'dataset': ''}每个示例都显示了从 URL 路径解析的详细信息。命名特定系列时,该系列位于路径中。如果未命名任何序列,则模式会找到一个空字符串。
以下是整个 WSGI 应用程序:
import traceback
import urllib.parse
def anscombe_app(
environ: Dict, start_response: SR_Func
) -> Iterable[bytes]:
log = environ['wsgi.errors']
try:
match = path_pat.match(environ['PATH_INFO'])
set_id = match.group('dataset').upper()
query = urllib.parse.parse_qs(environ['QUERY_STRING'])
print(environ['PATH_INFO'], environ['QUERY_STRING'],
match.groupdict(), file=log)
dataset = anscombe_filter(set_id, raw_data())
content_bytes, mime = serialize(
query['form'][0], set_id, dataset)
headers = [
('Content-Type', mime),
('Content-Length', str(len(content_bytes))),
]
start_response("200 OK", headers)
return [content_bytes]
except Exception as e: # pylint: disable=broad-except
traceback.print_exc(file=log)
tb = traceback.format_exc()
content = error_page.substitute(
title="Error", message=repr(e), traceback=tb)
content_bytes = content.encode("utf-8")
headers = [
('Content-Type', "text/html"),
('Content-Length', str(len(content_bytes))),
]
start_response("404 NOT FOUND", headers)
return [content_bytes] 此应用程序将从请求中提取两条信息:环境字典中的PATH_INFO和QUERY_STRING键。PATH_INFO请求将定义要提取的集合。QUERY_STRING请求将指定输出格式。
需要注意的是,查询字符串可能非常复杂。我们使用urllib.parse模块来正确定位查询字符串中的所有名称-值对,而不是假设它只是一个类似于?form=json的字符串。从查询字符串中提取的字典中带有'form'键的值可以在query['form'][0]中找到。这应该是定义的格式之一。如果不是,将引发异常,并显示错误页面。
定位路径和查询字符串后,应用程序处理以粗体突出显示。这两条语句依赖三个函数来收集、筛选和序列化结果:
raw_data()函数从文件中读取原始数据。结果是一个包含Pair对象列表的字典。anscombe_filter()函数接受一个选择字符串和原始数据字典,并返回一个Pair对象列表。- 然后通过
serialize()函数将对列表序列化为字节。序列化程序预期将生成字节,然后可以使用适当的头将其打包并返回。
我们选择生成一个 HTTPContent-Length头作为结果的一部分。此标题不是必需的,但对于大型下载来说是礼貌的。因为我们决定发出这个头,所以我们被迫创建一个带有数据序列化的 bytes 对象,这样我们就可以计算字节数了。
如果我们选择省略Content-Length头,我们可以极大地改变这个应用程序的结构。每个序列化程序都可以更改为生成器函数,在生成字节时生成字节。对于大型数据集,这可能是一个有用的优化。然而,对于观看下载的用户来说,这可能并不令人愉快,因为浏览器无法显示下载完成的程度。
一种常见的优化方法是将事务分为两部分。第一部分计算结果并将文件放入Downloads目录。响应是一个带有Location头的302 FOUND,用于标识要下载的文件。通常,大多数客户机将根据此初始响应请求文件。该文件可由 Apachehttpd或Nginx下载,无需涉及 Python 应用程序。
对于本例,所有错误都被视为404 NOT FOUND错误。这可能会产生误导,因为很多事情可能会出错。更复杂的错误处理可以提供更多的try:/except:块,以提供更多信息反馈。
出于调试目的,我们在生成的网页中提供了 Python 堆栈跟踪。在调试环境之外,这是一个非常糟糕的想法。来自 API 的反馈应该只足以修复请求,仅此而已。堆栈跟踪为潜在的恶意用户提供了太多信息。
raw_data()函数类似于第 3 章中的示例,函数、迭代器和生成器。我们包括了一些重要的变化。下面是我们用于此应用程序的内容:
from Chapter_3.ch03_ex5 import (
series, head_map_filter, row_iter)
from typing import (
NamedTuple, Callable, List, Tuple, Iterable, Dict, Any)
RawPairIter = Iterable[Tuple[float, float]]
class Pair(NamedTuple):
x: float
y: float
pairs: Callable[[RawPairIter], List[Pair]] \
= lambda source: list(Pair(*row) for row in source)
def raw_data() -> Dict[str, List[Pair]]:
with open("Anscombe.txt") as source:
data = tuple(head_map_filter(row_iter(source)))
mapping = {
id_str: pairs(series(id_num, data))
for id_num, id_str in enumerate(
['I', 'II', 'III', 'IV'])
}
return mappingraw_data()函数打开本地数据文件,并应用row_iter()函数返回解析为一行单独项目的文件的每一行。我们应用了head_map_filter()函数从文件中删除标题。结果创建了一个列表结构的元组,该元组被分配了变量data。这将处理将输入解析为有用的结构。结果结构是NamedTuple类的Pair子类的一个实例,其中两个字段的类型提示为float。
我们使用字典理解来构建从id_str到由series()函数结果组合而成的对的映射。series()函数从输入文档中提取(x、y对)。在文档中,每个系列位于两个相邻的列中。名为I的系列在第 0 列和第 1 列中;series()函数提取相关列对。
pairs()函数被创建为lambda对象,因为它是一个带有单个参数的小型生成器函数。此函数根据series()函数创建的匿名元组序列构建所需的NamedTuple对象。
由于raw_data()函数的输出是一个映射,我们可以像下面的例子那样按名称选择一个特定的序列:
>>> raw_data()['I']
[Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ...给定一个键,例如,'I',序列是一个Pair对象的列表,该列表中的每个项都有 x,y 值。
在这个应用程序中,我们使用一个非常简单的过滤器。整个过滤过程体现在以下功能中:
def anscombe_filter(
set_id: str, raw_data_map: Dict[str, List[Pair]]
) -> List[Pair]:
return raw_data_map[set_id]出于三个原因,我们将这个微不足道的表达式变成了一个函数:
- 函数表示法比下标表达式更一致,也更灵活
- 我们可以很容易地扩展过滤功能来做更多的工作
- 我们可以在该函数的 docstring 中包含单独的单元测试
虽然一个简单的lambda可以工作,但测试起来并不那么方便。
对于错误处理,我们什么也没做。我们关注的是有时被称为快乐之路:一个理想的事件序列。此函数中出现的任何问题都将引发异常。WSGI 包装函数应该捕获所有异常,并返回适当的状态消息和错误响应内容。
例如,set_id方法在某些方面可能是错误的。我们将只允许 Python 抛出一个异常,而不是纠结于它可能出错的所有方式。实际上,此函数遵循 Python 的建议,寻求原谅比请求许可更好。此建议通过避免权限寻求在代码中具体化:没有任何准备性的if语句试图将参数限定为有效。只有宽恕处理:将在 WSGI 包装中引发并处理异常。这个基本建议适用于前面的原始数据和我们现在将看到的序列化。
序列化是将 Python 数据转换为适合传输的字节流。每个格式最好由一个简单的函数来描述,该函数只序列化一种格式。然后,顶级泛型序列化程序可以从特定序列化程序列表中进行选择。选择序列化程序将导致以下函数集合:
Serializer = Callable[[str, List[Pair]], bytes]
SERIALIZERS: Dict[str, Tuple[str, Serializer]]= {
'xml': ('application/xml', serialize_xml),
'html': ('text/html', serialize_html),
'json': ('application/json', serialize_json),
'csv': ('text/csv', serialize_csv),
}
def serialize(
format: str, title: str, data: List[Pair]
) -> Tuple[bytes, str]:
mime, function = SERIALIZERS.get(
format.lower(), ('text/html', serialize_html))
return function(title, data), mime整个serialize()函数在SERIALIZERS字典中定位一个特定的序列化程序,它将一个格式名称映射到一个两元组。元组具有 MIME 类型,必须在响应中使用该类型来描述结果。元组还有一个基于Serializer类型提示的函数。此函数将把名称和Pair对象列表转换为可下载的字节。
serialize()函数不进行任何数据转换。它只是将一个名称映射到一个进行艰苦转换的函数。返回函数允许整个应用程序管理内存或文件系统序列化的详细信息。序列化到文件系统虽然速度较慢,但允许处理较大的文件。
我们将查看下面的各个序列化程序。序列化程序分为两组:产生字符串的序列化程序和产生字节的序列化程序。生成字符串的序列化程序需要将字符串编码为字节以供下载。产生字节的序列化程序不需要任何进一步的工作。
对于产生字符串的序列化程序,我们可以使用带有标准化转换为字节函数的函数组合。这是一个可以标准化字节转换的装饰器:
from typing import Callable, TypeVar, Any, cast
from functools import wraps
def to_bytes(
function: Callable[..., str]
) -> Callable[..., bytes]:
@wraps(function)
def decorated(*args, **kw):
text = function(*args, **kw)
return text.encode("utf-8")
return cast(Callable[..., bytes], decorated)我们创建了一个名为@to_bytes的小装饰师。这将评估给定函数,然后使用 UTF-8 对结果进行编码以获得字节。注意,decorator 将修饰后的函数从返回类型str更改为返回类型bytes。我们还没有正式声明修饰函数的参数,使用了...而不是细节。我们将展示如何将其与 JSON、CSV 和 HTML 序列化程序一起使用。XML 序列化程序直接生成字节,不需要使用此附加函数进行组合。
我们也可以在serializers映射的初始化中进行功能组合。我们可以修饰对函数对象的引用,而不是修饰函数定义。以下是序列化程序映射的替代定义:
SERIALIZERS = {
'xml': ('application/xml', serialize_xml),
'html': ('text/html', to_bytes(serialize_html)),
'json': ('application/json', to_bytes(serialize_json)),
'csv': ('text/csv', to_bytes(serialize_csv)),
} 这将在构建此映射数据结构时用装饰替换函数定义站点的装饰。推迟装修似乎有潜在的混乱。
JSON 和 CSV 序列化程序类似,因为它们都依赖 Python 的库进行序列化。这些库本质上是必需的,因此函数体是严格的语句序列。
以下是 JSON 序列化程序:
import json
@to_bytes
def serialize_json(series: str, data: List[Pair]) -> str:
"""
>>> data = [Pair(2,3), Pair(5,7)]
>>> serialize_json( "test", data )
b'[{"x": 2, "y": 3}, {"x": 5, "y": 7}]'
"""
obj = [dict(x=r.x, y=r.y) for r in data]
text = json.dumps(obj, sort_keys=True)
return text我们创建了一个 dict 结构列表,并使用json.dumps()函数创建了一个字符串表示。JSON 模块需要一个物化的list对象;我们不能提供惰性生成器函数。sort_keys=True参数值有助于单元测试。但是,应用程序不需要它,这会带来一些开销。
以下是 CSV 序列化程序:
import csv
import io
@to_bytes
def serialize_csv(series: str, data: List[Pair]) -> str:
"""
>>> data = [Pair(2,3), Pair(5,7)]
>>> serialize_csv("test", data)
b'x,y\\r\\n2,3\\r\\n5,7\\r\\n'
"""
buffer = io.StringIO()
wtr = csv.DictWriter(buffer, Pair._fields)
wtr.writeheader()
wtr.writerows(r._asdict() for r in data)
return buffer.getvalue()CSV 模块的读写器是命令式和函数式元素的混合体。我们必须创建作者,并按照严格的顺序正确创建标题。我们已经使用了Pairnamedtuple 的_fields属性来确定编写器的列标题。
编写器的writerows()方法将接受惰性生成器函数。在本例中,我们使用每个Pair对象的_asdict()方法返回适合 CSV 编写器使用的词典。
我们将研究一种使用内置库实现 XML 序列化的方法。这将从单个标记生成文档。一种常见的替代方法是使用 Python 内省来检查 Python 对象和类名,并将其映射到 XML 标记和属性。
下面是我们的 XML 序列化:
import xml.etree.ElementTree as XML
def serialize_xml(series: str, data: List[Pair]) -> bytes:
"""
>>> data = [Pair(2,3), Pair(5,7)]
>>> serialize_xml( "test", data )
b'<series name="test"><row><x>2</x><y>3</y></row><row><x>5</x><y>7</y></row></series>'
"""
doc = XML.Element("series", name=series)
for row in data:
row_xml = XML.SubElement(doc, "row")
x = XML.SubElement(row_xml, "x")
x.text = str(row.x)
y = XML.SubElement(row_xml, "y")
y.text = str(row.y)
return cast(bytes, XML.tostring(doc, encoding='utf-8'))我们创建了一个顶级元素<series>,并将<row>子元素放置在该顶级元素下面。在每个<row>子元素中,我们创建了<x>和<y>标记,并为每个标记分配了文本内容。
使用ElementTree库构建 XML 文档的接口往往非常重要。这使得它不适合其他功能设计。除了命令式样式之外,请注意,我们还没有创建 DTD 或 XSD。我们没有为标记正确分配名称空间。我们还省略了<?xml version="1.0"?>处理指令,它通常是 XML 文档中的第一项。
XML.tostring()函数有一个类型提示,表示它返回str。这通常是正确的,但是当我们提供encoding参数时,结果类型将更改为bytes。没有简单的方法可以根据参数值形式化变量返回类型的概念,因此我们使用显式的cast()通知mypy实际类型。
在这里,更复杂的序列化库可能会有所帮助。有很多可供选择。访问https://wiki.python.org/moin/PythonXml 获取备选方案列表。
在序列化的最后一个示例中,我们将了解创建 HTML 文档的复杂性。这种复杂性的产生是因为在 HTML 中,我们需要为整个网页提供大量的上下文信息。以下是解决此 HTML 问题的一种方法:
import string
data_page = string.Template("""\
<html>
<head><title>Series ${title}</title></head>
<body>
<h1>Series ${title}</h1>
<table>
<thead><tr><td>x</td><td>y</td></tr></thead>
<tbody>
${rows}
</tbody>
</table>
</body>
</html>
""")
@to_bytes
def serialize_html(series: str, data: List[Pair]) -> str:
"""
>>> data = [Pair(2,3), Pair(5,7)]
>>> serialize_html("test", data) #doctest: +ELLIPSIS
b'<html>...<tr><td>2</td><td>3</td></tr>\\n<tr><td>5</td><td>7</td></tr>...
"""
text = data_page.substitute(
title=series,
rows="\n".join(
"<tr><td>{0.x}</td><td>{0.y}</td></tr>".format(row)
for row in data)
)
return text我们的序列化函数有两部分。第一部分是一个包含基本 HTML 页面的string.Template()函数。它有两个占位符,可以在其中将数据插入模板。${title}方法显示可插入标题信息的位置,${rows}方法显示可插入数据行的位置。
该函数使用简单的格式字符串创建单个数据行。它们被连接成一个较长的字符串,然后替换到模板中。
虽然这对于前面的示例这样的简单情况是可行的,但对于更复杂的结果集来说并不理想。有许多更复杂的模板工具来创建 HTML 页面。其中许多包括在模板中嵌入循环的能力,这与初始化序列化的函数不同。访问https://wiki.python.org/moin/Templating 获取备选方案列表。
许多公开可用的 API 需要使用API 密钥。API 供应商要求您注册并提供电子邮件地址或其他联系信息。作为交换,它们提供了一个 API 密钥,用于激活 API。
API 密钥用于对访问进行身份验证。它还可用于授权特定功能。最后,它还用于跟踪使用情况。这可能包括在给定时间段内过于频繁地使用 API 密钥时限制请求。
商业模式的变化是多种多样的。例如,API 密钥的使用可能是一个计费事件,并且会产生费用。对于其他业务,流量必须达到某个阈值才能要求付款。
重要的是不否认 API 的使用。这反过来意味着创建可以作为用户身份验证凭据的 API 密钥。钥匙必须很难伪造,并且相对容易验证。
创建 API 密钥的一种简单方法是使用加密随机数生成难以预测的密钥字符串。secrets模块可用于生成可分配给客户端的唯一 API 键值,以跟踪活动:
>>> import secrets
>>> secrets.token_urlsafe(18*size)
'kzac-xQ-BB9Wx0aQoXRCYQxr'对随机字节使用 base64 编码来创建字符序列。长度使用三的倍数将避免在基 64 编码中出现任何尾随的=符号。我们使用了 URL 安全的 base 64 编码,它不会在结果字符串中包含/或+字符。这意味着密钥可以用作 URL 的一部分,也可以在标头中提供。
The more elaborate methods won't lead to more random data. The use of secrets assures that no one can counterfeit a key assigned to another user.
另一种选择是使用uuid.uuid4()创建一个随机的通用唯一标识符(UUID。这将是一个 36 个字符的字符串,具有 32 个十六进制数字和四个“-”标点符号。随机 UUID 将很难伪造。
另一种选择是使用itsdangerous包创建 JSON web 签名。它们使用简单的加密系统使它们对客户端不透明,但对服务器仍然有用。参见http://pythonhosted.org/itsdangerous/ 了解更多信息。
RESTful web 服务器需要一个带有有效密钥的小型数据库,可能还需要一些客户机联系信息。如果 API 请求包含数据库中的密钥,则相关用户负责该请求。如果 API 请求不包含已知密钥,则可以通过简单的401 UNAUTHORIZED响应拒绝该请求。由于密钥本身是一个 24 个字符的字符串,因此数据库将非常小,并且可以很容易地缓存在内存中。
这个小数据库可以是一个简单的文件,服务器加载该文件以将 API 密钥映射到授权权限。可以在启动时读取该文件,并检查修改时间,以查看缓存在服务器中的版本是否仍然是当前版本。当新密钥可用时,文件将更新,服务器将重新读取该文件。
普通的日志刮取可能足以显示给定密钥的用法。更复杂的应用程序可能会将 API 请求记录在单独的日志文件或数据库中,以简化分析。
在本章中,我们研究了如何将功能设计应用于使用基于 REST 的 web 服务提供内容的问题。我们研究了 WSGI 标准如何使整个应用程序具有某种功能。我们还研究了如何通过从请求中提取元素以供应用程序功能使用,从而将功能更强大的设计嵌入到 WSGI 上下文中。
对于简单的服务,问题通常分解为三种不同的操作:获取数据、搜索或筛选,然后序列化结果。我们用三个函数来解决这个问题:raw_data()、anscombe_filter()和serialize()。我们将这些函数包装在一个简单的 WSGI 兼容应用程序中,以将 web 服务与提取和过滤数据的实际处理分离开来。
我们还研究了 web 服务的功能如何专注于快乐路径,并假设所有输入都是有效的。如果输入无效,普通 Python 异常处理将引发异常。WSGI 包装函数将捕获错误并返回适当的状态代码和错误内容。
我们没有研究与上传数据或从表单接受数据以更新持久数据存储相关的更复杂的问题。这些并不比获取数据和序列化结果复杂得多。但是,可以用更好的方式解决这些问题。
对于简单的查询和数据共享,小型 web 服务应用程序可能会有所帮助。我们可以应用功能设计模式,并确保网站代码简洁且富有表现力。对于更复杂的 Web 应用程序,我们应该考虑使用一个正确处理细节的框架。
在下一章中,我们将介绍一些可用的优化技术。我们将从Chapter 10、功能工具模块扩展@lru_cache装饰器。我们还将介绍在Chapter 6、递归和约简中介绍的一些其他优化技术。