当我第一次开始构建网站时,其理念非常基础:获取内容(可能存储在某种形式的数据库中,也可能没有),并将其作为 HTML 页面传递给人们的浏览器。多年来,无数产品利用这种简单的模型,为网络上的内容管理和交付提供一站式解决方案。
快进十年左右,开发人员面临的是一个截然不同的现实。 随着大量设备消费数字内容,现在必须考虑如何不仅向 Web 浏览器交付内容,还要向原生移动应用程序、物联网 设备以及未来出现的其他媒介交付内容。
即使在 Web 浏览器领域,情况也发生了变化:客户端应用程序变得越来越普遍,在内容交付方面带来了传统服务器端渲染页面中不存在的挑战。
应对这些挑战的答案几乎总是涉及创建 API——一种以可以被几乎任何类型的系统(无论其底层技术栈如何)请求和操作的方式公开数据的方法。 以 JSON 等通用格式表示的内容很容易在各种系统之间传递,例如从移动应用程序到服务器,从服务器到客户端应用程序,以及其他任何系统。
采用这种 API 范式也面临着一系列挑战。 设计、构建和部署 API 并非易事,对于经验不足的开发人员或只想学习如何从他们的 React/Angular/Vue/等应用程序中使用 API 而不想接触数据库引擎、身份验证或数据备份的前端开发人员来说,这实际上是一项艰巨的任务。
回到基础
我喜欢静态网站的简洁性,我尤其喜欢这个静态网站生成器的新时代。 网站使用一组扁平文件作为数据存储的想法也对我很有吸引力,使用像 GitHub 这样的东西意味着可以将数据集作为公共存储库提供在一个平台上,该平台允许任何人都可以轻松地做出贡献,拉取请求和问题是审核和讨论的绝佳工具。
想象一下,有一个网站,人们在文章中发现了一个错别字,并提交了一个包含更正的拉取请求,或者通过一个开放的讨论论坛接受新的内容提交,社区本身可以过滤和验证最终发布的内容。 对我来说,这非常强大。
我开始考虑将这些原则应用于构建 API 而不是网站的过程——如果像 Jekyll 或 Hugo 这样的程序获取一堆扁平文件并从中创建 HTML 页面,我们能否构建一些东西来代替将它们转换为 API?
静态数据存储
让我向您展示两个我最近遇到的将 GitHub 代码库用作数据存储的示例,以及一些关于它们如何构建的想法。
第一个示例是ESLint 网站,其中列出了每个 ESLint 规则以及它的选项和正确和不正确代码的相关示例。 每个规则的信息都存储在一个 Markdown 文件中,并使用 YAML 前置 matter 部分进行注释。 将内容存储在这种对人类友好的格式中,使人们易于创作和维护,但对于其他应用程序以编程方式使用来说并不简单。
第二个示例是 MDN 的browser-compat-data,它是一本关于 CSS、JavaScript 和其他技术的浏览器兼容性信息的汇编。 数据存储为 JSON 文件,与 ESLint 的情况相反,JSON 文件很容易以编程方式使用,但人们难以编辑,因为 JSON 非常严格,人为错误很容易导致文件格式错误。
数据分组方式也存在一些限制。 ESLint 每个规则一个文件,因此无法获取特定于 ES6 的所有规则的列表,除非将它们全部放入同一个文件中,这在实际操作中非常不切实际。 MDN 使用的结构也存在同样的问题。
静态网站生成器解决了普通网站的这两个问题——它们获取对人类友好的文件(如 Markdown)并将它们转换为其他系统可以使用的格式,通常是 HTML。 它们还通过其模板引擎提供方法,以获取原始文件并以任何可想象的方式对渲染输出进行分组。
类似地,相同的概念应用于 API——静态 API 生成器?——需要做同样的事情,允许开发人员将数据保存在较小的文件中,使用他们习惯的格式进行简单的编辑过程,然后以可以创建具有不同粒度级别的多个端点的方式处理它们,并转换为 JSON 等格式。
构建静态 API 生成器
想象一个包含电影信息的 API。 每个标题都应包含有关运行时间、预算、收入和流行度的信息,条目应按语言、类型和上映年份进行分组。
为了将此数据集表示为扁平文件,我们可以使用 YAML 或任何其他数据序列化语言将每部电影及其属性存储为文本。
budget: 170000000
website: http://marvel.com/guardians
tmdbID: 118340
imdbID: tt2015381
popularity: 50.578093
revenue: 773328629
runtime: 121
tagline: All heroes start somewhere.
title: Guardians of the Galaxy
为了对电影进行分组,我们可以将文件存储在语言、类型和上映年份子目录中,如下所示。
input/
├── english
│ ├── action
│ │ ├── 2014
│ │ │ └── guardians-of-the-galaxy.yaml
│ │ ├── 2015
│ │ │ ├── jurassic-world.yaml
│ │ │ └── mad-max-fury-road.yaml
│ │ ├── 2016
│ │ │ ├── deadpool.yaml
│ │ │ └── the-great-wall.yaml
│ │ └── 2017
│ │ ├── ghost-in-the-shell.yaml
│ │ ├── guardians-of-the-galaxy-vol-2.yaml
│ │ ├── king-arthur-legend-of-the-sword.yaml
│ │ ├── logan.yaml
│ │ └── the-fate-of-the-furious.yaml
│ └── horror
│ ├── 2016
│ │ └── split.yaml
│ └── 2017
│ ├── alien-covenant.yaml
│ └── get-out.yaml
└── portuguese
└── action
└── 2016
└── tropa-de-elite.yaml
无需编写任何代码,我们就可以通过简单地使用 Web 服务器提供上述 `input/` 目录来获得某种 API(尽管它不是一个非常有用的 API)。 要获取有关电影的信息,例如《银河护卫队》,使用者将访问
http://localhost/english/action/2014/guardians-of-the-galaxy.yaml
并获取 YAML 文件的内容。
使用这个非常粗略的概念作为起点,我们可以构建一个工具——一个静态 API 生成器——以这样一种方式处理数据文件,使其输出类似于典型 API 层的行为和功能。
格式转换
上面解决方案的第一个问题是,为数据文件选择创作的格式可能不是输出的最佳格式。 对人类友好的序列化格式(如 YAML 或 TOML)应该使创作过程更容易且不易出错,但 API 使用者可能期望 XML 或 JSON 等格式。
我们的静态 API 生成器可以轻松地解决这个问题,它会访问每个数据文件并将内容转换为 JSON,将结果保存到一个新文件中,该文件与源文件具有完全相同的路径,只是父目录不同(例如 `output/` 而不是 `input/`),保留原始文件不变。
这导致了源文件和输出文件之间的 1 对 1 映射。 如果我们现在提供 `output/` 目录,使用者可以通过访问以下地址以 JSON 格式获取《银河护卫队》的数据:
http://localhost/english/action/2014/guardians-of-the-galaxy.json
同时仍然允许编辑者使用 YAML 或其他格式创作文件。
{
"budget": 170000000,
"website": "http://marvel.com/guardians",
"tmdbID": 118340,
"imdbID": "tt2015381",
"popularity": 50.578093,
"revenue": 773328629,
"runtime": 121,
"tagline": "All heroes start somewhere.",
"title": "Guardians of the Galaxy"
}
聚合数据
现在使用者能够以最合适的格式使用条目,让我们看看如何创建将多个条目的数据组合在一起的端点。 例如,想象一个列出特定语言和特定类型的全部电影的端点。
静态 API 生成器可以通过访问用于聚合条目的级别上的所有子目录并将其子树递归保存到放置在所述子目录根目录中的文件来生成此端点。 这将生成如下端点:
http://localhost/english/action.json
这将允许使用者列出所有英语动作片,或者
http://localhost/english.json
获取所有英语电影。
{
"results": [
{
"budget": 150000000,
"website": "http://www.thegreatwallmovie.com/",
"tmdbID": 311324,
"imdbID": "tt2034800",
"popularity": 21.429666,
"revenue": 330642775,
"runtime": 103,
"tagline": "1700 years to build. 5500 miles long. What were they trying to keep out?",
"title": "The Great Wall"
},
{
"budget": 58000000,
"website": "http://www.foxmovies.com/movies/deadpool",
"tmdbID": 293660,
"imdbID": "tt1431045",
"popularity": 23.993667,
"revenue": 783112979,
"runtime": 108,
"tagline": "Witness the beginning of a happy ending",
"title": "Deadpool"
}
]
}
为了使事情更有趣,我们还可以使其能够生成一个聚合来自多个不同路径的条目的端点,例如特定年份上映的所有电影。 起初,它可能看起来只是上面所示示例的另一种变体,但事实并非如此。 对应于任何给定年份上映的电影的文件可能位于不确定的数量的目录中——例如,2016 年的电影位于 `input/english/action/2016`、`input/english/horror/2016` 和 `input/portuguese/action/2016` 中。
我们可以通过创建数据树的快照并根据需要对其进行操作来实现这一点,根据选择的聚合器级别更改树的根,从而使我们能够拥有诸如http://localhost/2016.json
之类的端点。
分页
就像传统的 API 一样,控制添加到端点的条目数量非常重要——随着我们的电影数据增长,列出所有英语电影的端点可能会有数千个条目,这会导致有效负载变得非常大,从而导致传输速度缓慢且成本高昂。
为了解决这个问题,我们可以定义端点可以具有的最大条目数,并且每次静态 API 生成器即将将条目写入文件时,它都会将它们分成批次并保存到多个文件中。如果英语动作片太多,无法容纳在
http://localhost/english/action.json
我们将有
http://localhost/english/action-2.json
等等。
为了方便导航,我们可以添加一个元数据块,通知使用者条目和页面的总数,以及在适用的情况下前一页和下一页的 URL。
{
"results": [
{
"budget": 150000000,
"website": "http://www.thegreatwallmovie.com/",
"tmdbID": 311324,
"imdbID": "tt2034800",
"popularity": 21.429666,
"revenue": 330642775,
"runtime": 103,
"tagline": "1700 years to build. 5500 miles long. What were they trying to keep out?",
"title": "The Great Wall"
},
{
"budget": 58000000,
"website": "http://www.foxmovies.com/movies/deadpool",
"tmdbID": 293660,
"imdbID": "tt1431045",
"popularity": 23.993667,
"revenue": 783112979,
"runtime": 108,
"tagline": "Witness the beginning of a happy ending",
"title": "Deadpool"
}
],
"metadata": {
"itemsPerPage": 2,
"pages": 3,
"totalItems": 6,
"nextPage": "/english/action-3.json",
"previousPage": "/english/action.json"
}
}
排序
能够按任何属性对条目进行排序很有用,例如按受欢迎程度降序对电影进行排序。这是一个简单的操作,发生在聚合条目的地方。
整合
在完成所有规范后,是时候构建实际的静态 API 生成器应用程序了。我决定使用 Node.js 并将其发布为 npm 模块,以便任何人都可以获取其数据并轻松地启动 API。我将该模块称为static-api-generator
(很原始,对吧?)。
要开始,请创建一个新文件夹并将您的数据结构放在子目录中(例如,前面提到的input/
)。然后初始化一个空白项目并安装依赖项。
npm init -y
npm install static-api-generator --save
下一步是加载生成器模块并创建一个 API。启动一个名为server.js
的空白文件并添加以下内容。
const API = require('static-api-generator')
const moviesApi = new API({
blueprint: 'source/:language/:genre/:year/:movie',
outputPath: 'output'
})
在上面的示例中,我们首先定义 API 蓝图,这本质上是命名各个级别,以便生成器仅通过查看其深度就能知道目录是否表示语言或类型。我们还指定了生成的文件将写入到的目录。
接下来,我们可以开始创建端点。对于一些基本操作,我们可以为每部电影生成一个端点。以下将为我们提供诸如/english/action/2016/deadpool.json
之类的端点。
moviesApi.generate({
endpoints: ['movie']
})
我们可以在任何级别聚合数据。例如,我们可以为类型生成额外的端点,例如/english/action.json
。
moviesApi.generate({
endpoints: ['genre', 'movie']
})
为了从同一父级的多个发散路径聚合条目,例如所有动作电影,无论其语言如何,我们都可以为数据树指定一个新的根。这将为我们提供诸如/action.json
之类的端点。
moviesApi.generate({
endpoints: ['genre', 'movie'],
root: 'genre'
})
默认情况下,给定级别的端点将包含有关其所有子级别的信息——例如,类型的端点将包含有关语言、年份和电影的信息。但我们可以更改此行为并指定要包含哪些级别以及要绕过哪些级别。
以下将为类型生成端点,其中包含有关语言和电影的信息,完全绕过年份。
moviesApi.generate({
endpoints: ['genre'],
levels: ['language', 'movie'],
root: 'genre'
})
最后,键入npm start
以生成 API 并观察文件被写入输出目录。您的新 API 已准备好提供服务 - 尽情享受吧!
部署
此时,此 API 由本地磁盘上的一堆平面文件组成。我们如何将其上线?以及如何使上面描述的生成过程成为内容管理流程的一部分?当然,我们不能要求编辑每次想要更改数据集时都手动运行此工具。
GitHub Pages + Travis CI
如果您使用 GitHub 存储库来托管数据文件,那么GitHub Pages 是一个完美的竞争者来服务它们。它的工作原理是获取提交到特定分支的所有文件,并使其可以通过公共 URL 访问,因此,如果您获取上面生成的 API 并将文件推送到gh-pages
分支,则可以访问您的 API 在http://YOUR-USERNAME.github.io/english/action/2016/deadpool.json
上。
我们可以使用 CI 工具(如 Travis)来自动化此过程。它可以侦听源文件将保存在哪个分支(例如master
)上的更改,运行生成器脚本并将新文件集推送到gh-pages
。这意味着 API 将在几秒钟内自动获取对数据集的任何更改——对于静态 API 来说还不错!
在注册 Travis 并连接存储库后,转到“设置”面板并向下滚动到“环境变量”。创建一个名为GITHUB_TOKEN
的新变量,并插入一个具有对存储库的写入访问权限的GitHub 个人访问令牌——不用担心,令牌将是安全的。
最后,在存储库的根目录上创建一个名为.travis.yml
的文件,内容如下。
language: node_js
node_js:
- "7"
script: npm start
deploy:
provider: pages
skip_cleanup: true
github_token: $GITHUB_TOKEN
on:
branch: master
local_dir: "output"
就是这样。要查看它是否有效,请将新文件提交到master
分支,并观察 Travis 构建和发布您的 API。啊,GitHub Pages 完全支持 CORS,因此使用 Ajax 请求从前端应用程序使用 API 将非常简单。
您可以查看我的电影 API 演示存储库,并查看一些端点的实际操作
与 Staticman 形成闭环
也许使用静态 API 最明显的后果是它本质上是只读的——如果服务器上没有处理它的逻辑,我们就不能简单地设置一个 POST 端点来接受新电影的数据。如果这是您的 API 的一项强需求,那么这表明静态方法可能不是您项目的最佳选择,就像为具有大量用户生成内容的站点选择 Jekyll 或 Hugo 可能不是理想选择一样。
但是,如果您只需要某种基本形式的接受用户数据,或者您感觉很疯狂并且想全力以赴地进行此静态 API 冒险,那么有一些方法可以满足您的需求。去年,我创建了一个名为Staticman的项目,该项目试图解决向静态站点添加用户生成内容的确切问题。
它由一个服务器组成,该服务器接收从普通表单提交或通过 Ajax 以 JSON 有效负载发送的 POST 请求,并将数据作为平面文件推送到 GitHub 存储库。对于每个提交,都会创建一个拉取请求供您批准(或者如果您禁用审核,则会直接提交文件)。
您可以配置它接受的字段,添加验证、垃圾邮件防护,还可以选择生成文件的格式,例如 JSON 或 YAML。
这非常适合我们的静态 API 设置,因为它允许我们创建一个面向用户的表单或一个基本 CMS 接口,用户可以在其中添加新的类型或电影。当表单提交新条目时,我们将有
- Staticman 接收数据,将其写入文件并创建拉取请求
- 合并拉取请求后,将更新包含源文件的分支(
master
) - Travis 检测到更新并触发 API 的新构建
- 更新后的文件将被推送到公共分支(
gh-pages
) - 实时 API 现在反映了提交的条目。
结语
需要明确的是,本文并非试图彻底改变生产 API 的构建方式。它更像是在现有且广受欢迎的静态生成网站的概念基础上,将其转化为 API 的上下文,希望保持与该范式相关的简单性和稳健性。
在 API 成为任何现代数字产品如此基本组成部分的时代,我希望此工具能够使设计、构建和部署 API 的过程民主化,并消除不太有经验的开发人员的入门障碍。
这个概念可以进一步扩展,引入诸如自定义生成字段的概念,这些字段由生成器根据用户定义的逻辑自动填充,该逻辑不仅考虑正在创建的条目,还考虑整个数据集——例如,想象一个电影的rank
字段,其中一个数值是通过将条目的popularity
值与全局平均值进行比较计算出来的。
如果您决定使用此方法,并且有任何反馈/问题要报告,或者更好的是,如果您确实用它构建了一些东西,我非常乐意收到您的来信!
优秀。谢谢!将查看此内容和 Staticman。