介绍基于组件的 API

已发表: 2022-03-10
快速总结 ↬在 API 的世界中,GraphQL 最近让 REST 黯然失色,因为它能够在单个请求中查询和检索所有必需的数据。 在本文中,我将描述一种基于组件的不同类型的 API,它可以从单个请求中获取更多的数据量。

本文于 2019 年 1 月 31 日更新,以回应读者的反馈。 作者在基于组件的 API 中添加了自定义查询功能,并描述了它的工作原理

API 是应用程序从服务器加载数据的通信通道。 在 API 的世界中,REST 一直是更成熟的方法,但最近被 GraphQL 所掩盖,它提供了优于 REST 的重要优势。 REST 需要多个 HTTP 请求来获取一组数据以呈现组件,而 GraphQL 可以在单个请求中查询和检索此类数据,并且响应将完全符合要求,而不会像通常发生在休息。

在本文中,我将描述另一种获取数据的方法,我设计并称为“PoP”(并在此处开源),它扩展了 GraphQL 引入的在单个请求中为多个实体获取数据的想法,并将其作为更进一步,即当 REST 为一个资源获取数据,而 GraphQL 为一个组件中的所有资源获取数据时,基于组件的 API 可以从一个页面中的所有组件中获取所有资源的数据。

当网站本身使用组件构建时,使用基于组件的 API 最有意义,即当网页迭代地由包装其他组件的组件组成时,直到在最顶部,我们获得一个代表页面的组件。 例如,下图中显示的网页是由组件构建的,这些组件用正方形勾勒出来:

基于组件的网页截图
页面是一个组件包裹组件包裹组件,如方块所示。 (大预览)

基于组件的 API 能够通过请求每个组件(以及页面中的所有组件)中所有资源的数据来向服务器发出单个请求,这是通过将组件之间的关系保持在API 结构本身。

除其他外,这种结构提供以下几个好处:

  • 一个包含很多组件的页面只会触发一个请求,而不是很多;
  • 跨组件共享的数据只能从数据库中获取一次,并且在响应中只能打印一次;
  • 它可以大大减少——甚至完全消除——对数据存储的需求。

我们将在整篇文章中详细探讨这些内容,但首先,让我们探讨一下组件实际上是什么,以及我们如何基于这些组件构建站点,最后,探讨基于组件的 API 是如何工作的。

推荐阅读GraphQL 入门:为什么我们需要一种新的 API

跳跃后更多! 继续往下看↓

通过组件构建站点

组件只是一组 HTML、JavaScript 和 CSS 代码组合在一起以创建一个自治实体。 然后,它可以包装其他组件以创建更复杂的结构,并且本身也被其他组件包装。 组件有一个用途,可以是非常基本的东西(例如链接或按钮),也可以是非常复杂的东西(例如轮播或拖放图像上传器)。 当组件是通用的并且通过注入的属性(或“道具”)启用自定义时,组件最有用,因此它们可以服务于广泛的用例。 在最极端的情况下,网站本身成为一个组件。

术语“组件”通常用于指代功能和设计。 例如,在功能方面,React 或 Vue 等 JavaScript 框架允许创建客户端组件,这些组件能够自我渲染(例如,在 API 获取所需数据之后),并使用 props 为其设置配置值包装组件,实现代码可重用性。 在设计方面,Bootstrap 通过其前端组件库标准化了网站的外观和感觉,并且团队创建设计系统来维护他们的网站已成为一种健康的趋势,这允许不同的团队成员(设计师和开发人员,但也营销人员和销售人员)说统一的语言并表达一致的身份。

组件化网站是使网站变得更易于维护的一种非常明智的方法。 使用 JavaScript 框架(如 React 和 Vue)的站点已经是基于组件的(至少在客户端)。 使用像 Bootstrap 这样的组件库并不一定会使网站基于组件(它可能是一大块 HTML),但是,它为用户界面结合了可重用元素的概念。

如果网站一大块 HTML,为了将其组件化,我们必须将布局分解为一系列重复出现的模式,为此我们必须根据功能和样式的相似性来识别和分类页面上的部分,并打破这些将部分细分为尽可能细化的层,试图让每一层都专注于一个目标或行动,并尝试匹配不同部分的公共层。

注意Brad Frost 的“原子设计”是识别这些常见模式和构建可重用设计系统的绝佳方法。

识别元素以组件化网页
Brad Frost 确定了原子设计中用于创建设计系统的五个不同层次。 (大预览)

因此,通过组件构建站点类似于玩乐高。 每个组件要么是一个原子功能,要么是其他组件的组合,要么是两者的组合。

如下图,一个基础组件(头像)由其他组件迭代组成,直到获得最顶部的网页:

创建网页的组件序列
生成的组件序列,从头像一直到网页。 (大预览)

基于组件的 API 规范

对于我设计的基于组件的 API,组件被称为“模块”,因此从现在起“组件”和“模块”这两个术语可以互换使用。

所有模块相互包裹的关系,从最顶层的模块一直到最后一层,称为“组件层次结构”。 这种关系可以通过服务器端的关联数组(key => property 的数组)来表示,其中每个模块将其名称声明为 key 属性,并将其内部模块声明为属性modules 。 然后 API 简单地将此数组编码为 JSON 对象以供使用:

 // Component hierarchy on server-side, eg through PHP: [ "top-module" => [ "modules" => [ "module-level1" => [ "modules" => [ "module-level11" => [ "modules" => [...] ], "module-level12" => [ "modules" => [ "module-level121" => [ "modules" => [...] ] ] ] ] ], "module-level2" => [ "modules" => [ "module-level21" => [ "modules" => [...] ] ] ] ] ] ] // Component hierarchy encoded as JSON: { "top-module": { modules: { "module-level1": { modules: { "module-level11": { ... }, "module-level12": { modules: { "module-level121": { ... } } } } }, "module-level2": { modules: { "module-level21": { ... } } } } } }

模块之间的关系是按照严格的自上而下的方式定义的:一个模块包装了其他模块并且知道它们是谁,但它不知道——也不关心——哪些模块包装了他。

例如,在上面的 JSON 代码中,模块module-level1知道它包装了模块module-level11 -level11 和module-level12 ,并且,它也知道它包装了module-level121 ; 但是模块module-level11不关心谁在包装它,因此不知道module-level1

有了基于组件的结构,我们现在可以添加每个模块所需的实际信息,这些信息分为设置(例如配置值和其他属性)和数据(例如查询的数据库对象的 ID 和其他属性) ,并相应地放置在条目modulesettingsmoduledata下:

 { modulesettings: { "top-module": { configuration: {...}, ..., modules: { "module-level1": { configuration: {...}, ..., modules: { "module-level11": { repeat... }, "module-level12": { configuration: {...}, ..., modules: { "module-level121": { repeat... } } } } }, "module-level2": { configuration: {...}, ..., modules: { "module-level21": { repeat... } } } } } }, moduledata: { "top-module": { dbobjectids: [...], ..., modules: { "module-level1": { dbobjectids: [...], ..., modules: { "module-level11": { repeat... }, "module-level12": { dbobjectids: [...], ..., modules: { "module-level121": { repeat... } } } } }, "module-level2": { dbobjectids: [...], ..., modules: { "module-level21": { repeat... } } } } } } }

接下来,API 将添加数据库对象数据。 此信息不是放在每个模块下,而是放在名为databases的共享部分下,以避免在两个或多个不同模块从数据库中获取相同对象时重复信息。

此外,API 以关系的方式表示数据库对象数据,以避免当两个或多个不同的数据库对象与一个共同的对象相关时(例如两个具有相同作者的帖子),信息重复。 换句话说,数据库对象数据是标准化的。

推荐阅读为您的静态站点构建无服务器联系表

该结构是一个字典,首先组织在每个对象类型下,然后是对象 ID,我们可以从中获取对象属性:

 { databases: { primary: { dbobject_type: { dbobject_id: { property: ..., ... }, ... }, ... } } }

这个 JSON 对象已经是来自基于组件的 API 的响应。 它的格式本身就是一个规范:只要服务器以所需格式返回 JSON 响应,客户端就可以独立使用 API,而不管它是如何实现的。 因此,API 可以在任何语言上实现(这是 GraphQL 的优点之一:作为规范而不是实际的实现,使它可以在无数种语言中使用。)

注意在即将发表的文章中,我将描述我在 PHP 中实现基于组件的 API(这是 repo 中可用的 API)。

API 响应示例

例如,下面的 API 响应包含一个包含两个模块的组件层次结构, page => post-feed ,其中模块post-feed获取博客文章。 请注意以下事项:

  • 每个模块都从属性dbobjectids (博客文章的 ID 49 )知道哪些是它查询的对象
  • 每个模块从属性dbkeys中知道其查询对象的对象类型(每个帖子的数据都在posts下找到,帖子的作者数据,对应于在帖子的属性author下给出的 ID 的作者,在users下找到)
  • 因为数据库对象数据是相关的,所以属性author包含作者对象的 ID,而不是直接打印作者数据。
 { moduledata: { "page": { modules: { "post-feed": { dbobjectids: [4, 9] } } } }, modulesettings: { "page": { modules: { "post-feed": { dbkeys: { id: "posts", author: "users" } } } } }, databases: { primary: { posts: { 4: { title: "Hello World!", author: 7 }, 9: { title: "Everything fine?", author: 7 } }, users: { 7: { name: "Leo" } } } } }

从基于资源、基于模式和基于组件的 API 中获取数据的差异

让我们看看基于组件的 API(如 PoP)在获取数据时如何与基于资源的 API(如 REST)和基于模式的 API(如 GraphQL)进行比较。

假设 IMDB 有一个页面,其中包含两个需要获取数据的组件:“精选导演”(显示 George Lucas 的描述和他的电影列表)和“为您推荐的电影”(显示诸如《星球大战:第一集》之类的电影) ——幻影威胁终结者)。 它可能看起来像这样:

下一代 IMDB
下一代 IMDB 网站的组件“精选导演”和“为您推荐的电影”。 (大预览)

让我们看看通过每个 API 方法获取数据需要多少个请求。 对于此示例,“精选导演”组件带来了一个结果(“乔治·卢卡斯”),它从中检索了两部电影( 《星球大战:第一集——幻影威胁》《星球大战:第二集——克隆人的进攻》 ),以及每部电影有两名演员(第一部电影为“伊万麦格雷戈”和“娜塔莉波特曼”,第二部电影为“娜塔莉波特曼”和“海登克里斯滕森”)。 “为您推荐的电影”组件带来了两个结果( 《星球大战:第一集 - 幻影威胁》《终结者》 ),然后获取他们的导演(分别为“乔治卢卡斯”和“詹姆斯卡梅隆”)。

使用 REST 渲染组件featured-director ,我们可能需要以下 7 个请求(这个数量可能会根据每个端点提供的数据量而有所不同,即实现了多少过度获取):

 GET - /featured-director GET - /directors/george-lucas GET - /films/the-phantom-menace GET - /films/attack-of-the-clones GET - /actors/ewan-mcgregor GET - /actors/natalie-portman GET - /actors/hayden-christensen

GraphQL 允许通过强类型模式在每个组件的单个请求中获取所有必需的数据。 通过 GraphQL 为组件featuredDirector获取数据的查询如下所示(在我们实现了相应的模式之后):

 query { featuredDirector { name country avatar films { title thumbnail actors { name avatar } } } }

它会产生以下响应:

 { data: { featuredDirector: { name: "George Lucas", country: "USA", avatar: "...", films: [ { title: "Star Wars: Episode I - The Phantom Menace", thumbnail: "...", actors: [ { name: "Ewan McGregor", avatar: "...", }, { name: "Natalie Portman", avatar: "...", } ] }, { title: "Star Wars: Episode II - Attack of the Clones", thumbnail: "...", actors: [ { name: "Natalie Portman", avatar: "...", }, { name: "Hayden Christensen", avatar: "...", } ] } ] } } }

查询“为您推荐的电影”组件会产生以下响应:

 { data: { films: [ { title: "Star Wars: Episode I - The Phantom Menace", thumbnail: "...", director: { name: "George Lucas", avatar: "...", } }, { title: "The Terminator", thumbnail: "...", director: { name: "James Cameron", avatar: "...", } } ] } }

PoP 只会发出一个请求来获取页面中所有组件的所有数据,并对结果进行规范化。 要调用的端点与我们需要获取数据的 URL 相同,只是添加了一个额外的参数output=json来指示以 JSON 格式而不是将其打印为 HTML:

 GET - /url-of-the-page/?output=json

假设模块结构有一个名为page的顶级模块,其中包含模块featured-directorfilms-recommended-for-you ,并且这些模块也有子模块,如下所示:

 "page" modules "featured-director" modules "director-films" modules "film-actors" "films-recommended-for-you" modules "film-director"

单个返回的 JSON 响应将如下所示:

 { modulesettings: { "page": { modules: { "featured-director": { dbkeys: { id: "people", }, modules: { "director-films": { dbkeys: { films: "films" }, modules: { "film-actors": { dbkeys: { actors: "people" }, } } } } }, "films-recommended-for-you": { dbkeys: { id: "films", }, modules: { "film-director": { dbkeys: { director: "people" }, } } } } } }, moduledata: { "page": { modules: { "featured-director": { dbobjectids: [1] }, "films-recommended-for-you": { dbobjectids: [1, 3] } } } }, databases: { primary: { people { 1: { name: "George Lucas", country: "USA", avatar: "..." films: [1, 2] }, 2: { name: "Ewan McGregor", avatar: "..." }, 3: { name: "Natalie Portman", avatar: "..." }, 4: { name: "Hayden Christensen", avatar: "..." }, 5: { name: "James Cameron", avatar: "..." }, }, films: { 1: { title: "Star Wars: Episode I - The Phantom Menace", actors: [2, 3], director: 1, thumbnail: "..." }, 2: { title: "Star Wars: Episode II - Attack of the Clones", actors: [3, 4], thumbnail: "..." }, 3: { title: "The Terminator", director: 5, thumbnail: "..." }, } } } }

让我们分析一下这三种方法在速度和检索数据量方面的比较。

速度

通过 REST,必须获取 7 个请求才能渲染一个组件可能非常慢,主要是在移动和不稳定的数据连接上。 因此,从 REST 跳转到 GraphQL 对速度的影响很大,因为我们能够只用一个请求来渲染一个组件。

PoP,因为它可以在一个请求中获取多个组件的所有数据,所以一次渲染多个组件会更快; 但是,很可能不需要这样做。 让组件按顺序呈现(就像它们出现在页面中一样)已经是一种很好的做法,对于那些出现在折叠下的组件,当然不会急于呈现它们。 因此,基于模式的 API 和基于组件的 API 都已经相当不错,并且明显优于基于资源的 API。

数据量

在每个请求中,GraphQL 响应中的数据可能会重复:女演员“娜塔莉·波特曼”在第一个组件的响应中被提取两次,当考虑两个组件的联合输出时,我们还可以找到共享数据,例如电影星球大战:第一集——幽灵的威胁

另一方面,PoP 对数据库数据进行规范化并只打印一次,但是它带来了打印模块结构的开销。 因此,根据是否具有重复数据的特定请求,基于模式的 API 或基于组件的 API 将具有更小的大小。

总之,GraphQL 等基于模式的 API 和 PoP 等基于组件的 API 在性能方面同样出色,并且优于 REST 等基于资源的 API。

推荐阅读理解和使用 REST API

基于组件的 API 的特殊属性

如果基于组件的 API 在性能方面不一定比基于模式的 API 更好,您可能想知道,那么我想通过这篇文章实现什么目标?

在本节中,我将尝试让您相信这样的 API 具有令人难以置信的潜力,它提供了一些非常理想的特性,使其成为 API 领域的有力竞争者。 我在下面描述并展示了它的每一个独特的强大功能。

可以从组件层次结构中推断要从数据库中检索的数据

当模块显示来自 DB 对象的属性时,模块可能不知道或不关心它是什么对象; 它所关心的只是定义加载对象的哪些属性是必需的。

例如,考虑下图。 一个模块从数据库中加载一个对象(在这种情况下是一个帖子),然后它的后代模块将显示该对象的某些属性,例如titlecontent

显示的数据以不同的间隔定义
一些模块加载数据库对象,而其他模块加载属性。 (大预览)

因此,沿着组件层次结构,“数据加载”模块将负责加载查询的对象(在这种情况下是加载单个帖子的模块),其后代模块将定义需要来自 DB 对象的哪些属性( titlecontent ,在这种情况下)。

可以通过遍历组件层次结构自动获取 DB 对象所需的所有属性:从数据加载模块开始,我们一直迭代其所有后代模块,直到到达新的数据加载模块,或者直到树的末尾; 在每一层,我们获取所有需要的属性,然后将所有属性合并在一起并从数据库中查询它们,所有这些都只需要一次。

在下面的结构中,模块single-post从 DB(ID 为 37 的帖子)中获取结果,子模块post-titlepost-content定义要为查询的 DB 对象加载的属性(分别为titlecontent ); 子模块post-layoutfetch-next-post-button不需要任何数据字段。

 "single-post" => Load objects with object type "post" and ID 37 modules "post-layout" modules "post-title" => Load property "title" "post-content" => Load property "content" "fetch-next-post-button"

要执行的查询是根据组件层次结构及其所需的数据字段自动计算的,其中包含所有模块及其子模块所需的所有属性:

 SELECT title, content FROM posts WHERE id = 37

通过直接从模块中获取要检索的属性,只要组件层次结构发生变化,查询就会自动更新。 例如,如果我们添加子模块post-thumbnail ,它需要数据字段thumbnail

 "single-post" => Load objects with object type "post" and ID 37 modules "post-layout" modules "post-title" => Load property "title" "post-content" => Load property "content" "post-thumbnail" => Load property "thumbnail" "fetch-next-post-button"

然后查询会自动更新以获取附加属性:

 SELECT title, content, thumbnail FROM posts WHERE id = 37

因为我们已经建立了要以关系方式检索的数据库对象数据,所以我们也可以将这种策略应用到数据库对象本身之间的关系中。

考虑下图: 从对象类型post开始,向下移动组件层次结构,我们需要将 DB 对象类型转换为usercomment ,分别对应于帖子的作者和每个帖子的评论,然后,对于每个评论,它必须再次将对象类型更改为评论作者对应的user

从数据库对象移动到关系对象(可能改变对象类型,如post => authorpostuser ,或不改变,如author => follower 从useruser )是我所说的“切换域”。

显示关系对象的数据
将数据库对象从一个域更改为另一个域。 (大预览)

切换到新域后,从组件层次结构的该级别向下,所有必需的属性都将受制于新域:

  • nameuser对象中获取(代表帖子的作者),
  • comment对象中获取content (代表每个帖子的评论),
  • nameuser对象中获取(代表每个评论的作者)。

遍历组件层次结构,API 知道它何时切换到新域,并适当地更新查询以获取关系对象。

例如,如果我们需要显示来自帖子作者的数据,堆叠子模块post-author会将该级别的域从post更改为相应的user ,并且从该级别向下加载到传递给模块的上下文中的 DB 对象是用户。 然后, post-author下的子模块user-nameuser-avatar会加载user对象下的属性nameavatar

 "single-post" => Load objects with object type "post" and ID 37 modules "post-layout" modules "post-title" => Load property "title" "post-content" => Load property "content" "post-author" => Switch domain from "post" to "user", based on property "author" modules "user-layout" modules "user-name" => Load property "name" "user-avatar" => Load property "avatar" "fetch-next-post-button"

导致以下查询:

 SELECT p.title, p.content, p.author, u.name, u.avatar FROM posts p INNER JOIN users u WHERE p.id = 37 AND p.author = u.id

总之,通过适当地配置每个模块,无需编写查询来获取基于组件的 API 的数据。 查询是从组件层次结构本身自动生成的,获取数据加载模块必须加载的对象、每个后代模块定义的每个加载对象要检索的字段以及每个后代模块定义的域切换。

添加、删除、替换或更改任何模块都会自动更新查询。 执行查询后,检索到的数据将正是所需要的——不多也不少。

观察数据并计算附加属性

从组件层次结构的数据加载模块开始,任何模块都可以观察返回的结果并根据它们计算额外的数据项或feedback值,这些数据项放置在入口moduledata下。

例如,模块fetch-next-post-button可以添加一个属性,指示是否有更多结果要获取(基于此反馈值,如果没有更多结果,按钮将被禁用或隐藏):

 { moduledata: { "page": { modules: { "single-post": { modules: { "fetch-next-post-button": { feedback: { hasMoreResults: true } } } } } } } }

所需数据的隐含知识降低了复杂性并使“端点”的概念变得过时

如上所示,基于组件的 API 可以准确地获取所需的数据,因为它具有服务器上所有组件的模型以及每个组件需要哪些数据字段。 然后,它可以隐含所需数据字段的知识。

优点是定义组件需要哪些数据可以只在服务器端更新,而无需重新部署 JavaScript 文件,并且客户端可以变得愚蠢,只要求服务器提供它需要的任何数据,从而降低客户端应用程序的复杂性。

此外,调用 API 来检索特定 URL 的所有组件的数据可以通过简单地查询该 URL 并添加额外参数output=json来指示返回 API 数据而不是打印页面来执行。 因此,URL 成为它自己的端点,或者以不同的方式考虑,“端点”的概念变得过时了。

使用不同 API 获取资源的请求
使用不同 API 获取资源的请求。 (大预览)

检索数据子集:可以为特定模块获取数据,在组件层次结构的任何级别都可以找到

如果我们不需要获取页面中所有模块的数据,而只需获取从组件层次结构的任何级别开始的特定模块的数据,会发生什么? 例如,如果一个模块实现了无限滚动,当向下滚动时,我们必须只为该模块获取新数据,而不是为页面上的其他模块获取新数据。

这可以通过过滤将包含在响应中的组件层次结构的分支来完成,以包含仅从指定模块开始的属性并忽略此级别之上的所有内容。 在我的实现中(我将在下一篇文章中描述),通过将参数modulefilter=modulepaths添加到 URL 来启用过滤,并且通过modulepaths[]参数指示选定的模块(或多个模块),其中“模块路径” 是从最顶层模块开始到特定模块的模块列表(例如module1 => module2 => module3具有模块路径 [ module1 , module2 , module3 ] 并作为 URL 参数作为module1.module2.module3 ) .

例如,在每个模块下方的组件层次结构中,都有一个条目dbobjectids

 "module1" dbobjectids: [...] modules "module2" dbobjectids: [...] modules "module3" dbobjectids: [...] "module4" dbobjectids: [...] "module5" dbobjectids: [...] modules "module6" dbobjectids: [...]

然后请求网页 URL 添加参数modulefilter=modulepathsmodulepaths[]=module1.module2.module5将产生以下响应:

 "module1" modules "module2" modules "module5" dbobjectids: [...] modules "module6" dbobjectids: [...]

本质上,API 从module1 => module2 => module5开始加载数据。 这就是为什么module6下的module5也带来了它的数据,而module3module4没有。

此外,我们可以创建自定义模块过滤器以包含一组预先安排的模块。 例如,使用modulefilter=userstate调用页面可以仅打印那些需要用户状态才能在客户端呈现它们的模块,例如模块module3module6

 "module1" modules "module2" modules "module3" dbobjectids: [...] "module5" modules "module6" dbobjectids: [...]

其中启动模块的信息位于requestmeta部分下,在条目filteredmodules模块下,作为模块路径数组:

 requestmeta: { filteredmodules: [ ["module1", "module2", "module3"], ["module1", "module2", "module5", "module6"] ] }

此功能允许实现简单的单页应用程序,其中站点的框架在初始请求时加载:

 "page" modules "navigation-top" dbobjectids: [...] "navigation-side" dbobjectids: [...] "page-content" dbobjectids: [...]

但是,从它们开始,我们可以将参数modulefilter=page附加到所有请求的 URL,过滤掉框架并只带来页面内容:

 "page" modules "navigation-top" "navigation-side" "page-content" dbobjectids: [...]

与上面描述的模块过滤器用户userstatepage类似,我们可以实现任何自定义模块过滤器并创建丰富的用户体验。

模块是它自己的 API

如上所示,我们可以过滤 API 响应以从任何模块开始检索数据。 因此,每个模块都可以通过将其模块路径添加到包含它的网页 URL 来从客户端到服务器与其自身进行交互。

我希望你能原谅我的过度兴奋,但我真的不能足够强调这个功能是多么美妙。 创建组件时,我们不需要创建一个 API 来与它一起检索数据(REST、GraphQL 或其他任何东西),因为组件已经能够在服务器中与自己对话并加载自己的数据——它是完全自主和自服务的

每个数据加载模块都在datasetmodulemeta部分下的条目dataloadsource下导出 URL 以与其交互:

 { datasetmodulemeta: { "module1": { modules: { "module2": { modules: { "module5": { meta: { dataloadsource: "https://page-url/?modulefilter=modulepaths&modulepaths[]=module1.module2.module5" }, modules: { "module6": { meta: { dataloadsource: "https://page-url/?modulefilter=modulepaths&modulepaths[]=module1.module2.module5.module6" } } } } } } } } } }

获取数据是跨模块和 DRY 解耦的

为了说明在基于组件的 API 中获取数据高度解耦和 DRY 的(我们自己不会重复),我首先需要展示在GraphQL等基于模式的 API 中如何减少解耦和不干燥。

在 GraphQL 中,获取数据的查询必须指明组件的数据字段,其中可能包括子组件,这些也可能包括子组件,等等。 然后,最顶层的组件也需要知道它的每个子组件都需要哪些数据,以获取该数据。

例如,渲染<FeaturedDirector>组件可能需要以下子组件:

 Render <FeaturedDirector>: <div> Country: {country} {foreach films as film} <Film film={film} /> {/foreach} </div> Render <Film>: <div> Title: {title} Pic: {thumbnail} {foreach actors as actor} <Actor actor={actor} /> {/foreach} </div> Render <Actor>: <div> Name: {name} Photo: {avatar} </div>

在这种情况下,GraphQL 查询是在<FeaturedDirector>级别实现的。 然后,如果子组件<Film>被更新,通过属性filmTitle而不是title请求标题,来自<FeaturedDirector>组件的查询也需要更新,以反映这个新信息(GraphQL 有一个版本控制机制可以处理有这个问题,但迟早我们还是应该更新信息)。 这会产生维护复杂性,当内部组件经常更改或由第三方开发人员生产时,可能难以处理。 因此,组件之间没有彻底解耦。

类似地,我们可能希望直接渲染某些特定电影的<Film>组件,然后我们还必须在此级别实现 GraphQL 查询,以获取电影及其演员的数据,这会添加冗余代码:相同的查询将存在于组件结构的不同级别。 所以GraphQL 不是 DRY

因为基于组件的 API 已经知道它的组件是如何在自己的结构中相互包装的,所以这些问题就完全避免了。 一方面,客户端能够简单地请求所需的数据,无论这些数据是什么; 如果子组件数据字段发生变化,整个模型已经知道并立即适应,而无需在客户端修改对父组件的查询。 因此,模块之间是高度解耦的。

For another, we can fetch data starting from any module path, and it will always return the exact required data starting from that level; there are no duplicated queries whatsoever, or even queries to start with. Hence, a component-based API is fully DRY . (This is another feature that really excites me and makes me get wet.)

(Yes, pun fully intended. Sorry about that.)

Retrieving Configuration Values In Addition To Database Data

Let's revisit the example of the featured-director component for the IMDB site described above, which was created — you guessed it! — with Bootstrap. Instead of hardcoding the Bootstrap classnames or other properties such as the title's HTML tag or the avatar max width inside of JavaScript files (whether they are fixed inside the component, or set through props by parent components), each module can set these as configuration values through the API, so that then these can be directly updated on the server and without the need to redeploy JavaScript files. Similarly, we can pass strings (such as the title Featured director ) which can be already translated/internationalized on the server-side, avoiding the need to deploy locale configuration files to the front-end.

Similar to fetching data, by traversing the component hierarchy, the API is able to deliver the required configuration values for each module and nothing more or less.

The configuration values for the featured-director component might look like this:

 { modulesettings: { "page": { modules: { "featured-director": { configuration: { class: "alert alert-info", title: "Featured director", titletag: "h3" }, modules: { "director-films": { configuration: { classes: { wrapper: "media", avatar: "mr-3", body: "media-body", films: "row", film: "col-sm-6" }, avatarmaxsize: "100px" }, modules: { "film-actors": { configuration: { classes: { wrapper: "card", image: "card-img-top", body: "card-body", title: "card-title", avatar: "img-thumbnail" } } } } } } } } } } }

Please notice how — because the configuration properties for different modules are nested under each module's level — these will never collide with each other if having the same name (eg property classes from one module will not override property classes from another module), avoiding having to add namespaces for modules.

Higher Degree Of Modularity Achieved In The Application

According to Wikipedia, modularity means:

The degree to which a system's components may be separated and recombined, often with the benefit of flexibility and variety in use. The concept of modularity is used primarily to reduce complexity by breaking a system into varying degrees of interdependence and independence across and 'hide the complexity of each part behind an abstraction and interface'.

Being able to update a component just from the server-side, without the need to redeploy JavaScript files, has the consequence of better reusability and maintenance of components. I will demonstrate this by re-imagining how this example coded for React would fare in a component-based API.

Let's say that we have a <ShareOnSocialMedia> component, currently with two items: <FacebookShare> and <TwitterShare> , like this:

 Render <ShareOnSocialMedia>: <ul> <li>Share on Facebook: <FacebookShare url={window.location.href} /></li> <li>Share on Twitter: <TwitterShare url={window.location.href} /></li> </ul>

But then Instagram got kind of cool, so we need to add an item <InstagramShare> to our <ShareOnSocialMedia> component, too:

 Render <ShareOnSocialMedia>: <ul> <li>Share on Facebook: <FacebookShare url={window.location.href} /></li> <li>Share on Twitter: <TwitterShare url={window.location.href} /></li> <li>Share on Instagram: <InstagramShare url={window.location.href} /></li> </ul>

In the React implementation, as it can be seen in the linked code, adding a new component <InstagramShare> under component <ShareOnSocialMedia> forces to redeploy the JavaScript file for the latter one, so then these two modules are not as decoupled as they could be.

但是,在基于组件的 API 中,我们可以很容易地使用 API 中已经描述的模块之间的关系将模块耦合在一起。 虽然最初我们会有这样的回应:

 { modulesettings: { "share-on-social-media": { modules: { "facebook-share": { configuration: {...} }, "twitter-share": { configuration: {...} } } } } }

添加 Instagram 后,我们将获得升级后的响应:

 { modulesettings: { "share-on-social-media": { modules: { "facebook-share": { configuration: {...} }, "twitter-share": { configuration: {...} }, "instagram-share": { configuration: {...} } } } } }

只需迭代modulesettings["share-on-social-media"].modules下的所有值,即可升级组件<ShareOnSocialMedia>以显示<InstagramShare>组件,而无需重新部署任何 JavaScript 文件。 因此,API 支持添加和删除模块,而不会影响其他模块的代码,从而获得更高程度的模块化。

本机客户端缓存/数据存储

检索到的数据库数据在字典结构中进行规范化和标准化,以便从dbobjectids上的值开始,只需按照条目dbkeys指示的路径即可访问databases下的任何数据,无论其结构方式如何. 因此,组织数据的逻辑已经是 API 本身的原生逻辑。

我们可以通过多种方式从这种情况中受益。 例如,可以将每个请求的返回数据添加到客户端缓存中,该缓存包含用户在整个会话期间请求的所有数据。 因此,可以避免向应用程序添加诸如 Redux 之类的外部数据存储(我的意思是关于数据的处理,而不涉及其他功能,例如撤消/重做、协作环境或时间旅行调试)。

此外,基于组件的结构促进了缓存:组件层次结构不取决于 URL,而是取决于该 URL 中需要哪些组件。 这样, /events/1//events/2/下的两个事件将共享相同的组件层次结构,并且可以在它们之间重用需要哪些模块的信息。 因此,所有属性(除了数据库数据)都可以在获取第一个事件后缓存在客户端上并从那时起重新使用,因此必须仅获取每个后续​​事件的数据库数据,而不是其他任何内容。

可扩展性和再利用

API 的databases部分可以扩展,从而能够将其信息分类为自定义的子部分。 默认情况下,所有数据库对象数据都放在条目primary下,但是,我们也可以创建自定义条目来放置特定的数据库对象属性。

例如,如果前面描述的组件“为您推荐的电影”在film数据库对象上的属性friendsWhoWatchedFilm下显示登录用户观看过这部电影的朋友列表,因为该值会根据登录而改变用户然后我们将这个属性保存在用户userstate条目下,所以当用户注销时,我们只从客户端缓存数据库中删除这个分支,但所有primary数据仍然保留:

 { databases: { userstate: { films: { 5: { friendsWhoWatchedFilm: [22, 45] }, } }, primary: { films: { 5: { title: "The Terminator" }, } "people": { 22: { name: "Peter", }, 45: { name: "John", }, }, } } }

此外,在一定程度上,API 响应的结构可以重新调整用途。 特别是,数据库结果可以打印在不同的数据结构中,例如数组而不是默认字典。

例如,如果对象类型只有一个(例如films ),它可以被格式化为一个数组,直接输入到 typeahead 组件中:

 [ { title: "Star Wars: Episode I - The Phantom Menace", thumbnail: "..." }, { title: "Star Wars: Episode II - Attack of the Clones", thumbnail: "..." }, { title: "The Terminator", thumbnail: "..." }, ]

支持面向方面的编程

除了获取数据,基于组件的 API 还可以发布数据,例如创建帖子或添加评论,并执行任何类型的操作,例如登录或注销用户、发送电子邮件、日志记录、分析、等等。 没有任何限制:底层 CMS 提供的任何功能都可以通过模块在任何级别调用。

沿着组件层次结构,我们可以添加任意数量的模块,每个模块都可以执行自己的操作。 因此,并非所有操作都必须与请求的预期操作相关,例如在 REST 中执行 POST、PUT 或 DELETE 操作或在 GraphQL 中发送突变时,但可以添加以提供额外功能,例如发送电子邮件当用户创建新帖子时向管理员发送。

因此,通过依赖注入或配置文件定义组件层次结构,可以说 API 支持面向切面的编程,“一种旨在通过允许分离横切关注点来增加模块化的编程范式”。

推荐阅读使用功能策略保护您的网站

增强的安全性

模块的名称在输出时不一定是固定的,但可以缩短、修改、随机更改或(简而言之)以任何预期的方式可变。 虽然最初考虑缩短 API 输出(以便模块名称carousel-featured-postsdrag-and-drop-user-images可以缩短为基本 64 表示法,例如a1a2等,用于生产环境),此功能允许出于安全原因频繁更改 API 响应中的模块名称。

例如,输入名称默认命名为其对应的模块; 然后,名为usernamepassword的模块在客户端中分别呈现为<input type="text" name="{input_name}"><input type="password" name="{input_name}"> ,可以为其输入名称设置不同的随机值(例如今天的zwH8DSeGQBG7m6EF ,以及明天的c3oMLBjoc46oVgN6 ),从而使垃圾邮件发送者和机器人更难瞄准该站点。

通过替代模型实现多功能性

模块的嵌套允许分支到另一个模块以添加对特定介质或技术的兼容性,或者更改一些样式或功能,然后返回到原始分支。

例如,假设网页具有以下结构:

 "module1" modules "module2" modules "module3" "module4" modules "module5" modules "module6"

在这种情况下,我们想让网站也适用于 AMP,但是模块module2module4module5不兼容 AMP。 我们可以将这些模块分支成类似的 AMP 兼容模块module2AMPmodule4AMPmodule5AMP ,之后我们继续加载原始组件层次结构,因此只有这三个模块被替换(仅此而已):

 "module1" modules "module2AMP" modules "module3" "module4AMP" modules "module5AMP" modules "module6"

这使得从单个代码库生成不同的输出变得相当容易,只根据需要在这里和那里添加分支,并且始终限定和限制到单个模块。

演示时间

如本文所述,实现 API 的代码可在此开源存储库中找到。

出于演示目的,我在https://nextapi.getpop.org下部署了 PoP API。 该网站在 WordPress 上运行,因此 URL 永久链接是 WordPress 的典型链接。 如前所述,通过向它们添加参数output=json ,这些 URL 成为它们自己的 API 端点。

该站点由来自 PoP Demo 网站的相同数据库支持,因此可以通过查询其他网站中的相同 URL 来完成组件层次结构和检索数据的可视化(例如访问https://demo.getpop.org/u/leo/解释了来自https://nextapi.getpop.org/u/leo/?output=json的数据)。

下面的链接演示了前面描述的案例的 API:

  • 主页、单个帖子、作者、帖子列表和用户列表。
  • 一个事件,从特定模块过滤。
  • 一个标签,过滤模块,需要用户状态和过滤才能从单页应用程序中只带来一个页面。
  • 一组位置,用于输入预输入。
  • “我们是谁”页面的替代模型:正常、可打印、可嵌入。
  • 更改模块名称:原始与损坏。
  • 过滤信息:只有模块设置、模块数据加数据库数据。
API 返回的 JSON 代码示例
API 返回的 JSON 代码示例。 (大预览)

结论

一个好的 API 是创建可靠、易于维护和强大的应用程序的垫脚石。 在本文中,我描述了支持基于组件的 API 的概念,我相信它是一个非常好的 API,我希望我也能说服你。

到目前为止,API 的设计和实现已经经历了多次迭代,耗时五年多——而且还没有完全准备好。 但是,它处于相当不错的状态,尚未准备好投入生产,而是作为稳定的 alpha 版本。 这些天来,我仍在努力; 致力于定义开放规范、实现附加层(例如渲染)和编写文档。

在即将发表的文章中,我将描述我的 API 实现是如何工作的。 在那之前,如果你对此有任何想法——无论是积极的还是消极的——我很乐意在下面阅读你的评论。

更新(1 月 31 日):自定义查询功能

Alain Schlesser 评论说,无法从客户端自定义查询的 API 毫无价值,将我们带回 SOAP,因此它无法与 REST 或 GraphQL 竞争。 经过几天的思考后,我不得不承认他是对的。 然而,我并没有将基于组件的 API 视为一种善意但尚未完全实现的努力,而是做了一些更好的事情:我必须为它实现自定义查询功能。 它就像一个魅力!

在以下链接中,资源或资源集合的数据通常通过 REST 进行获取。 但是,通过参数fields ,我们还可以指定要为每个资源检索哪些特定数据,从而避免数据过度或不足:

  • 单个帖子和帖子集合添加参数fields=title,content,datetime时间
  • 一个用户和一组用户添加参数fields=name,username,description

上面的链接演示了仅为查询的资源获取数据。 他们的关系呢? 例如,假设我们要检索包含字段"title""content"的帖子列表,包含字段"content""date"的每个帖子的评论,以及包含字段"name"和“的每个评论的作者"url" 。 为了在 GraphQL 中实现这一点,我们将实现以下查询:

 query { post { title content comments { content date author { name url } } } }

对于基于组件的 API 的实现,我将查询翻译成相应的“点语法”表达式,然后可以通过参数fields提供。 查询“post”资源,该值为:

 fields=title,content,comments.content,comments.date,comments.author.name,comments.author.url

或者它可以被简化,使用| 对应用于同一资源的所有字段进行分组:

 fields=title|content,comments.content|date,comments.author.name|url

在单个帖子上执行此查询时,我们会准确获取所有相关资源所需的数据:

 { "datasetmodulesettings": { "dataload-dataquery-singlepost-fields": { "dbkeys": { "id": "posts", "comments": "comments", "comments.author": "users" } } }, "datasetmoduledata": { "dataload-dataquery-singlepost-fields": { "dbobjectids": [ 23691 ] } }, "databases": { "posts": { "23691": { "id": 23691, "title": "A lovely tango", "content": "<div class=\"responsiveembed-container\"><iframe loading="lazy" width=\"480\" height=\"270\" src=\"https:\\/\\/www.youtube.com\\/embed\\/sxm3Xyutc1s?feature=oembed\" frameborder=\"0\" allowfullscreen><\\/iframe><\\/div>\n", "comments": [ "25094", "25164" ] } }, "comments": { "25094": { "id": "25094", "content": "<p><a class=\"hashtagger-tag\" href=\"https:\\/\\/newapi.getpop.org\\/tags\\/videos\\/\">#videos<\\/a>\\u00a0<a class=\"hashtagger-tag\" href=\"https:\\/\\/newapi.getpop.org\\/tags\\/tango\\/\">#tango<\\/a><\\/p>\n", "date": "4 Aug 2016", "author": "851" }, "25164": { "id": "25164", "content": "<p>fjlasdjf;dlsfjdfsj<\\/p>\n", "date": "19 Jun 2017", "author": "1924" } }, "users": { "851": { "id": 851, "name": "Leonardo Losoviz", "url": "https:\\/\\/newapi.getpop.org\\/u\\/leo\\/" }, "1924": { "id": 1924, "name": "leo2", "url": "https:\\/\\/newapi.getpop.org\\/u\\/leo2\\/" } } } }

因此,我们可以以 REST 方式查询资源,并以 GraphQL 方式指定基于模式的查询,我们将准确获得所需的内容,而不会过度或不足获取数据,并对数据库中的数据进行规范化,以免数据重复。 有利的是,查询可以包括任意数量的关系,嵌套在深处,并且这些关系通过线性复杂度时间来解决:最坏的情况是 O(n+m),其中 n 是切换域的节点数(在这种情况下为 2: commentscomments.author ),m 是检索结果的数量(在本例中为 5:1 个帖子 + 2 个评论 + 2 个用户),平均情况为 O(n)。 (这比 GraphQL 更有效,GraphQL 的多项式复杂度时间为 O(n^c),并且随着级别深度的增加,执行时间也会增加)。

最后,该 API 还可以在查询数据时应用修饰符,例如过滤检索到的资源,例如可以通过 GraphQL 完成。 为了实现这一点,API 简单地位于应用程序之上,并且可以方便地使用其功能,因此无需重新发明轮子。 例如,添加参数filter=posts&searchfor=internet将从帖子集合中过滤所有包含"internet"的帖子。

这个新特性的实现将在下一篇文章中描述。