我所设想的 Web,我们还没有真正看到。未来仍然比过去大得多。
——Tim Berners-Lee
预览
从前,Web 很小,也很简单。开发者们曾经非常开心地把 PHP、HTML 和 MySQL 调用都扔进单个文件里,然后自豪地告诉所有人去看看他们的网站。但随着时间推移,Web 增长到了数以万亿计,不,不止,是数不清的页面。早期的游乐场变成了一个由主题公园组成的元宇宙。
在本章中,我会指出一些对现代 Web 越来越重要的领域:
服务和 API
并发
分层
数据
下一章会展示 Python 在这些领域提供了什么。之后,我们将深入 FastAPI Web 框架,看看它能提供什么。
服务和 API
Web 是一张非常强大的连接织物。尽管大量活动仍然发生在内容侧,比如 HTML、JavaScript、图像等等,但人们越来越重视把事物连接起来的应用程序编程接口,也就是 API。
通常,一个 Web 服务负责低层级的数据库访问和中层级的业务逻辑,这两者通常被统称为后端;而 JavaScript 或移动应用则提供丰富的顶层前端,也就是交互式用户界面。这前后两个世界已经变得更加复杂,也更加分化,通常要求开发者专精其中一方。现在要成为一名全栈开发者,已经比过去更难了。¹
这两个世界通过 API 相互对话。在现代 Web 中,API 设计与网站本身的设计同样重要。API 是一种契约,类似于数据库模式。定义和修改 API,如今已经是一项重要工作。
API 的种类
每个 API 都定义以下内容:
协议
控制结构
格式
内容结构
随着技术从孤立机器,发展到多任务系统,再到联网服务器,多种 API 方法也逐渐发展出来。你很可能在某个时候遇到其中一种或多种。因此,在进入 HTTP 及其朋友们之前,先做一个简要总结;HTTP 也是本书的重点。
在网络出现之前,API 通常意味着一种非常紧密的连接,比如在与你的应用程序相同语言编写的库中调用一个函数。比如,在数学库中计算平方根。
远程过程调用,也就是 RPC,被发明出来,用于调用其他进程中的函数。这些进程可以在同一台机器上,也可以在其他机器上,但调用起来就像这些函数位于当前调用应用程序中一样。一个当前流行的例子是 gRPC。
消息传递会在进程之间的管道中发送小块数据。消息可以是类似动词的命令,也可以只是表示某个类似名词的、值得关注的事件。当前流行的消息传递方案,从工具包到完整服务器不等,包括 Apache Kafka、RabbitMQ、NATS 和 ZeroMQ。通信可以遵循不同模式:
请求-响应
一对一,就像 Web 浏览器调用 Web 服务器。
发布-订阅,或 pub-sub
发布者发出消息,订阅者根据消息中的某些数据,比如主题,对每条消息采取行动。
队列
类似 pub-sub,但只有订阅者池中的一个订阅者会获取该消息并处理它。
这些方法中的任何一种都可以与 Web 服务一起使用。例如,执行一个较慢的后端任务,比如发送电子邮件或创建缩略图。
HTTP
Berners-Lee 为他的万维网提出了三个组成部分:
HTML
一种用于展示数据的语言
HTTP
一种客户端-服务器协议
URL
一种用于 Web 资源的寻址方案
尽管回头看,这些东西似乎显而易见,但事实证明,它们是一个极其有用的组合。随着 Web 的演进,人们不断试验,一些想法,比如 IMG 标签,在达尔文式的竞争中存活了下来。随着需求变得更加清晰,人们也开始认真定义标准。
REST(ful)
Roy Fielding 的博士论文中有一章定义了表述性状态转移,也就是 Representational State Transfer,简称 REST。它是一种 HTTP 使用方式的架构风格。² 虽然它经常被引用,但很大程度上也一直被误解。
后来,一种大致共享的改编方式逐渐发展出来,并主导了现代 Web。它被称为 RESTful,具有以下特征:
使用 HTTP 和客户端-服务器协议
无状态,每个连接都是独立的
可缓存
基于资源
资源是你可以区分并对其执行操作的数据。Web 服务会为它想要暴露的每个功能提供一个端点,也就是一个独立的 URL 和 HTTP 动词,也就是动作。端点也被称为路由,因为它会把 URL 路由到一个函数。
数据库用户熟悉 CRUD 这个过程缩写:创建、读取、更新、删除。HTTP 动词也相当 CRUD:
POST
创建,写入
PUT
完整修改,替换
PATCH
部分修改,更新
GET
嗯,获取,读取,检索
DELETE
呃,删除
客户端会向一个 RESTful 端点发送请求,数据会位于 HTTP 消息中的以下区域之一:
Headers
URL 字符串
查询参数
Body 值
相应地,一个 HTTP 响应会返回以下内容:
一个整数状态码,用来表示:
100 段
信息,继续
200 段
成功
300 段
重定向
400 段
客户端错误
500 段
服务器错误
各种 headers
一个 body,它可以为空,可以是单个整体,也可以是分块的,也就是以连续片段形式返回。
至少有一个状态码是彩蛋:418,I’m a teapot,也就是“我是一个茶壶”。如果一个连接到 Web 的茶壶被要求冲咖啡,它就应该返回这个状态码。
你会找到许多关于 RESTful API 设计的网站和书,它们都提供了有用的经验法则。本书会在推进过程中逐步给出其中一些。
JSON 和 API 数据格式
前端应用程序可以与后端 Web 服务交换普通 ASCII 文本,但你该如何表达“某些东西的列表”这样的数据结构呢?
差不多就在我们真正开始需要它的时候,JavaScript Object Notation,也就是 JSON,出现了。这又是一个简单的想法,解决了一个重要问题,而且事后看来显而易见。虽然 J 代表 JavaScript,但它的语法看起来也很像 Python。
JSON 已经在很大程度上取代了 XML 和 SOAP 这类较早的尝试。在本书其余部分,你会看到 JSON 是 Web 服务默认的输入和输出格式。
JSON:API
RESTful 设计和 JSON 数据格式的组合现在已经很常见。但仍然存在一些模糊空间,也还有一些程序员之间的争执。最近的 JSON:API 提案旨在稍微收紧规范。本书将使用较宽松的 RESTful 方法,但如果你遇到较大的争议,JSON:API 或类似的严格方案可能会有用。
GraphQL
对于某些用途来说,RESTful 接口可能会比较笨重。Facebook,也就是现在的 Meta,设计了 Graph Query Language,也就是 GraphQL,用来指定更灵活的服务查询。本书不会深入讲 GraphQL,但如果你发现 RESTful 设计无法满足你的应用程序需求,可能值得研究一下它。
并发
除了服务化方向的发展之外,连接到 Web 服务的连接数量快速增长,也要求更好的效率和扩展能力。
我们希望减少以下内容:
延迟
前置等待时间
吞吐量
服务与其调用者之间每秒传输的字节数
在早期 Web 时代,³ 人们梦想的是支持数百个同时连接,后来又为“10K 问题”而焦虑,而现在则默认假设同一时间会有数百万连接。
“并发”这个词并不意味着完全并行。多个处理过程并不是在同一个 CPU 中的同一个纳秒内发生。相反,并发主要是为了避免忙等待,也就是让 CPU 闲置在那里,直到响应被送达。CPU 很快,但网络和磁盘要慢上成千上万倍,甚至数百万倍。所以,每当我们与网络或磁盘通信时,我们都不希望只是呆坐在那里,面无表情地等它响应。
普通的 Python 执行是同步的:一次做一件事,按照代码指定的顺序进行。有时我们希望是异步的:先做一点这件事,再做一点另一件事,然后回到第一件事,如此往复。如果我们所有代码都在使用 CPU 进行计算,也就是 CPU 密集型任务,那么实际上没有什么空闲时间可以用于异步。但如果我们执行的是某种会让 CPU 等待外部事物完成的操作,也就是 I/O 密集型任务,我们就可以使用异步。
异步系统提供一个事件循环:对慢操作的请求会被发送并记录下来,但我们不会阻塞 CPU 去等待它们的响应。相反,在循环的每一轮中会执行一些即时处理,而在这段时间内返回的任何响应,都会在下一轮循环中被处理。
这种效果可能非常显著。在本书后面,你会看到 FastAPI 对异步处理的支持如何使它比典型的 Web 框架快得多。
异步处理并不是魔法。你仍然必须小心,避免在事件循环期间执行过多 CPU 密集型工作,因为这会拖慢所有事情。在本书后面,你会看到 Python 的 async 和 await 关键字的用法,以及 FastAPI 如何让你混合同步和异步处理。
分层
《怪物史莱克》的粉丝可能还记得,史莱克曾提到他有多层人格,而驴子回答说:“像洋葱一样?”
好吧,如果怪物和让人流泪的蔬菜都可以有层,那么软件当然也可以有层。为了管理规模和复杂性,许多应用程序长期以来都使用所谓的三层模型。⁴ 这并不是什么特别新的东西。术语可能有所不同,⁵ 但在本书中,我会使用下面这组简单的术语划分,见图 1-1:
Web
HTTP 之上的输入/输出层,它组装客户端请求,调用服务层,并返回响应。
Service
业务逻辑层,在需要时调用数据层。
Data
对数据存储和其他服务的访问。
Model
由所有层共享的数据定义。
Web client
Web 浏览器或其他 HTTP 客户端软件。
Database
数据存储,通常是 SQL 或 NoSQL 服务器。
图 1-1 垂直分层
这些组件将帮助你扩展你的网站,而不必从头开始。它们不是量子力学定律,因此可以把它们看作本书讲解中的指导原则。
这些层通过 API 相互对话。这些 API 可以是对独立 Python 模块的简单函数调用,但也可以通过任何方法访问外部代码。正如我前面展示的,这可能包括 RPC、消息等。在本书中,我假设使用单个 Web 服务器,Python 代码导入其他 Python 模块。模块负责处理分离和信息隐藏。
Web 层是用户通过客户端应用程序和 API 能看到的那一层。我们通常讨论的是一个 RESTful Web 接口,包含 URL,以及 JSON 编码的请求和响应。但也可以在 Web 层旁边构建替代的文本客户端,或者命令行接口,也就是 CLI 客户端。Python Web 代码可以导入 Service 层模块,但不应该导入 Data 模块。
Service 层包含这个网站所提供内容的实际细节。这个层本质上看起来像一个库。它导入 Data 模块来访问数据库和外部服务,但不应该知道这些细节。
Data 层通过文件或对其他服务的客户端调用,为 Service 层提供数据访问。也可以存在替代的 Data 层,与同一个 Service 层通信。
Model 这个框并不是一个真正的层,而是各层共享的数据定义来源。如果你在它们之间传递的是 Python 内置数据结构,那么它并不是必需的。正如你将看到的,FastAPI 对 Pydantic 的引入,使你能够定义具备许多有用特性的数据结构。
为什么要做这些划分?原因有很多。每一层都可以:
由专门人员编写。
单独测试。
被替换或补充:比如,你可能在一个 Web 层旁边,再添加第二个 Web 层,使用另一种 API,比如 gRPC。
遵循《捉鬼敢死队》中的一条规则:不要让射线交叉。也就是说,不要让 Web 细节泄漏出 Web 层,也不要让数据库细节泄漏出 Data 层。
你可以把层想象成一个垂直堆栈,就像《英国家庭烘焙大赛》里的蛋糕一样。⁶
下面是分离各层的一些原因:
如果你不分离各层,那就等着一个神圣的 Web 梗出现吧:现在你有两个问题了。
一旦各层混在一起,之后再分离就会非常困难。
如果代码逻辑变得混乱,你就需要了解两个或更多专业领域,才能理解它并编写测试。
顺便说一句,尽管我把它们称为“层”,你并不需要假设某一层在另一层“上面”或“下面”,也不需要假设命令会随着重力流动。垂直主义偏见!你也可以把层看作横向通信的盒子,见图 1-2。
图 1-2 横向通信的盒子
无论你如何可视化它们,盒子或层之间唯一的通信路径都应该是箭头,也就是 API。这对于测试和调试非常重要。如果工厂里存在没有记录的门,夜班保安迟早会被吓一跳。
Web client 和 Web 层之间的箭头使用 HTTP 或 HTTPS,主要传输 JSON 文本。Data 层和数据库之间的箭头使用数据库特定协议,并携带 SQL 或其他文本。层与层之间的箭头则是函数调用,携带数据模型。
此外,建议通过箭头流动的数据格式如下:
Client ⇔ Web
RESTful HTTP 与 JSON
Web ⇔ Service
模型
Service ⇔ Data
模型
Data ⇔ Databases and services
特定 API
基于我自己的经验,这就是我选择在本书中组织主题的方式。它是可行的,并且已经扩展到相当复杂的网站,但它并不是神圣不可侵犯的。你可能有更好的设计!无论你怎么做,重要点在于:
分离特定领域的细节。
定义各层之间的标准 API。
不要作弊;不要泄漏。
有时,决定哪一层最适合放置某段代码是一个挑战。例如,第 11 章会讨论认证和授权需求,以及如何实现它们:是作为 Web 和 Service 之间的额外一层,还是放在其中某一层内部。软件开发有时既是艺术,也是科学。
数据
Web 经常被用作关系型数据库的前端,尽管已经演化出了许多其他存储和访问数据的方式,比如 NoSQL 或 NewSQL 数据库。
但除了数据库之外,机器学习,也可以说深度学习,或者直接说 AI,正在从根本上重塑技术版图。大型模型的开发需要大量与数据打交道,而这在传统上被称为抽取、转换、加载,也就是 ETL。
作为一种通用服务架构,Web 可以帮助处理机器学习系统中许多繁琐的小细节。
回顾
Web 使用许多 API,尤其是 RESTful API。异步调用可以带来更好的并发性,从而加快整体过程。Web 服务应用程序通常足够庞大,因此需要划分成不同层。数据已经成为一个独立的重要领域。所有这些概念都会在下一章即将介绍的 Python 编程语言中得到体现。