为什么我使用 GraphQL 而放弃 REST API?

前端宇宙

共 9398字,需浏览 19分钟

 ·

2021-05-20 12:28

作者 | Max Desiatov
译者 | 平川
策划 | 万佳
来源 | 前端之巅

本文最初发布于 Max Desiatov 的个人博客,经原作者授权由 InfoQ 中文站翻译并分享。

在大多数移动和 Web 应用中,服务器交互需要花费开发人员大量时间和精力来开发和测试。

在我所开发的那些拥有最复杂 API 应用程序中,网络层设计和维护占去高达 40% 的开发时间,特别是由于我在本文中提到的一些边缘情况。这样实现过几次后,很容易就会发现,有一些不同的模式、工具和框架可以带来帮助。虽然我们很幸运,不必再关心 SOAP,但 REST 也不是历史的终结。

最近,我有机会为自己的项目和客户开发和运行一些使用 GraphQL API 构建的移动和 Web 应用程序。这真是一个很好的体验,尤其要感谢令人惊叹的 PostGraphile 和 Apollo。至此,我再也无法回过头来享受使用 REST 的工作了。

REST 有什么问题吗?
 每个 REST API 都是独特的

公平地说,REST 甚至不是一个标准。维基百科将其定义为:

一种架构风格,基于 HTTP 定义了一组约束和属性。

虽然确实存在像 JSON API 规范这样的东西,但在实践中,我们很少看到有 RESTful 后端实现它。在最好的情况下,你可能会偶然发现一些使用 OpenAPI/Swagger 的东西。即使这样,OpenAPI 也没有指定 API 的形状或格式,它只是一个机器可读的规范,允许(但不是要求)你对 API 运行自动化测试、自动生成文档等。

主要问题仍然存在。你可能会说你的 API 是 RESTful 的,但是对于如何安排端点或是否应该(例如)使用 HTTP 方法PATCH进行对象更新,一般没有严格的规则。

还有一些东西乍一看是 RESTful 的,但如果你仔细看,就不是那么像了:Dropbox HTTP API。

端点接受请求体中的文件内容,因此,它们的参数将以 JSON 的形式在Dropbox-API-Arg请求头或 arg URL 参数中传递。

JSON 在请求头中?

没错,Dropbox API 端点要求你将请求正文留空,并将有效载荷序列化为 JSON,放到一个自定义的 HTTP 头中。为这种特殊情况编写客户端代码很有趣。我们不能抱怨,因为毕竟没有广泛使用的标准。

事实上,下面提到的大多数注意事项都是由于缺乏标准造成的,但是我想强调一下在实践中经常看到的情况。

在一个有经验的团队中,你可以避免这些问题,但是你难道不希望一些问题已经在软件方面得到解决吗?

 没有静态类型意味着要注意类型验证

无论如何努力避免这种情况,你迟早会遇到 JSON 属性拼写错误、发送或接收的数据类型错误、字段丢失等问题。如果你的客户端和 / 或服务器编程语言是静态类型的,并且你不能用错误的字段名或类型构造对象,那可能没问题。如果你的 API 是版本化的,旧 API 的 URL 为/API/v1,新版本的 URL 为/API/v2,那么你可能做得很好。如果有一个 OpenAPI 规范,可以为你生成客户端 / 服务器类型声明,那就更好了。

但你真能负担得起在所有项目中都做到这样吗?当你的团队在冲刺期间决定重命名或重新安排对象字段时,你能负担得起上线/api/v1.99端点的成本吗?即使完成了,团队会不会忘记更新规范并通知客户端开发人员更新内容?

在客户端或服务器上的所有验证逻辑,你确定都是正确的吗?理想情况下,你希望它在两边都得到验证,对吧?维护所有这些自定义代码非常有趣。或者保持 API JSON 模式是最新的。

 分页和过滤并不简单

大多数 API 都使用对象集合。在待办事项列表应用中,列表本身就是一个集合。大多数集合都可以包含 100 多个项。对于大多数服务器来说,在一次响应的一个集合中返回所有项是一个繁重的操作。如果再乘以在线用户的数量,就会产生很大的 AWS 账单。显而易见的解决方案:只返回集合的子集。

分页相对简单。在查询参数中传递类似offsetlimit这样的值:/todos?Limit =10&offset=20以获得从 20 开始的 10 个对象。每个人对这些参数的命名都不一样,有些人喜欢countskip,而我喜欢offsetlimit,因为它们直接对应于 SQL 修饰符。

一些后端数据库会暴露要传递给下一页查询的游标或标记。请查看 Elasticsearch API,该 API 建议在需要依次浏览大量结果文档时使用scroll调用。还有一些 API 在头中传递相关信息。参见 GitHub REST API(至少不是在头中传递 JSON)。

说到过滤,就有趣多了……需要按一个字段过滤吗?没问题,可能是/todos?filter=key%3Dvalue,也可能是可读性更好的/todos?filterKey=key&filterValue=value。那么按两个值过滤呢?这应该很简单,对吧?使用 URL 编码,查询看起来是这个样子:/todos?filterKeys=key1%2Ckey2&filterValue=value。但通常,我们没有办法阻止特性蔓延,可能会出现使用AND/OR操作符进行高级过滤的需求。或者复杂的全文搜索查询和复杂的过滤。迟早你会看到一些 API 发明了自己的过滤 DSL。URL 查询组件已经不够用了,但是GET请求中的请求体也不太好,这意味着你最终要在POST请求中发送非可变查询(Elasticsearch 就是这样做的)。至此,API 还是 RESTful 的吗?

无论哪种方式,客户端和服务器都需要特别注意解析、格式化和验证所有这些参数。如此多的乐趣!举例来说,如果没有恰当的验证且存在未初始化的变量,你就很容易地得到类似这样的东西:/todos?offset=undefined

 不容易记录和测试

上面提到的 Swagger 可能是目前最好的工具,但其应用还不够广泛。根据我的观察,更常见的情况是,API 文档单独维护。对一个稳定且广泛使用的 API 来说,这没什么大不了的,但是在敏捷流程的开发过程中,这就比较糟糕了。文档单独存储意味着,它经常不会更新,特别是当更改是一个小的、但会破坏客户端的更改时。

如果你不使用 Swagger,这可能意味着你需要维护专门的测试基础设施。与单元测试相比,你对集成测试(即同时测试客户端和服务器端代码)的需求会更多。

 关系查询和批量查询会让人更加沮丧

对于比较大的 API,这就成了一个问题,因为你可能有许多相关的集合。让我们进一步来看一个待办事项列表应用程序的例子:假设每个待办事项也可以属于一个项目。你是否总是希望一次获取所有相关的项目?可能不需要,但是还需要添加更多的查询参数。也许你不想一次获取所有对象字段。如果应用程序需要项目有所有者,并且除了每个集合有单独的视图显示外,还有一个视图显示所有这些数据的聚合?它要么是三个独立的 HTTP 请求,要么是一个复杂的请求,同时获取所有数据用于聚合。

无论哪种方式,都存在复杂性和性能上的权衡,在不断发展的应用程序中维护这些请求会带来更多令人头痛的问题。

 你需要同时在服务器和客户端上实现每个端点

还有大量的库可以在 ORM 或直接数据库自省的帮助下自动生成 REST 端点。即使使用了这样的库,它们通常也不是很灵活或可扩展的。也就是说,如果需要自定义参数、高级过滤行为或对请求 / 响应有效负载的一些更智能的处理,就需要从头重新实现端点。

另一项任务是在客户端代码中使用这些端点。如果有的话,最好使用代码生成,但是它似乎不够灵活。即使是使用像 Moya 这样的辅助库,也会遇到同样障碍:有许多自定义行为需要处理,这是由前面提到的边缘情况引起的。

如果开发团队不是全栈的,那么服务器和客户端团队之间的沟通就至关重要,在没有机器可读的 API 规范的情况下更是如此。

GraphQL 如何做得更好?

对于所有讨论过的问题,我倾向于认为,在 CRUD 应用程序中,有一种标准方式来生成和使用 API 会非常棒。通用的工具和模式、集成测试和文档基础设施将有助于解决技术和组织问题。

GraphQL 有一个 RFC 规范草案 和一个参考实现。此外,请参阅 GraphQL 教程,它描述了你需要了解的大多数概念。有针对不同平台的实现,也有许多可用的开发工具,其中最著名的是 GraphiQL,它捆绑了一个很好的、具有自动完成功能的 API 浏览器,以及一个文档浏览器,可以浏览从 GraphQL 模式自动生成的文档。

事实上,我发现 GraphiQL 是不可或缺的。它可以帮助解决我前面提到的客户端和服务器团队之间的沟通问题。只要 GraphQL 模式中有任何更改,你就可以在 GraphQL 浏览器中看到它,就像嵌入式 API 文档。现在,客户端和服务器团队可以以一种更好的方式在 API 设计上开展合作,缩短迭代时间,共享自动生成的文档,它们让每次 API 更新对每个人都可见。要了解这些工具是如何工作的,请查看 Star Wars API 示例,它可以作为 GraphiQL 的在线演示。

能指定从服务器请求的对象字段让客户端可以根据需要只获取需要的数据。不再有多个重量级的查询发送到一个刚性的 REST API,为了让客户端可以在应用程序 UI 中一次性显示它。你不再受限于一组端点,而是有一个可以查询和修改的模式,能够挑选客户端指定的字段和对象。服务器只需以这种方式实现顶级模式对象。

 一个简单的例子

GraphQL 模式定义了可用于在服务器和客户端之间通信的类型。有两种特殊类型,它们同时也是 GraphQL 的核心概念:QueryMutation。在大多数情况下,向 GraphQL API 发出的每个请求要么是没有副作用的Query实例,要么是会修改存储在服务器上的对象的Mutation实例。

现在,继续我们待办事项列表应用程序的例子,考虑下面这个 GraphQL 模式:
type Project {
  id: ID
  name: String!
}
type TodoItem {
  id: ID
  description: String!
  isCompleted: Boolean!
  dueDate: Date
  project: Project
}
type TodoList {
  totalCount: Int!
  items: [TodoItem]!
}
type Query {
  allTodos(limit: Int, offset: Int): TodoList!
  todoByID(id: ID!): TodoItem
}
type Mutation {
  createTodo(item: TodoItem!): TodoItem
  deleteTodo(id: ID!): TodoItem
  updateTodo(id: ID!, newItem: TodoItem!): TodoItem
}
schema {
  query: Query
  mutation: Mutation
}
底部的schema块是特定的,定义了前面描述的根类型QueryMutation。此外,它非常简单:type块定义新的类型,每个块包含具有自己类型的字段定义。类型可以是非可选的,例如String!字段不能有空值,而String可以。字段也可以有命名参数,所以TodoList!类型的字段allTodos(limit: Int, offset: Int): TodoList!接受两个可选参数,而其本身的值是非可选的,这意味着它将始终返回一个不能为空的TodoList实例。然后,要查询所有待办事项的id和名称,你可以编写这样一个查询:
query {
  allTodos(limit: 5) {
    totalCount
    items {
      id
      description
      isCompleted
    }
  }
}
GraphQL 客户端库根据模式自动解析和验证查询,然后将其发送到 GraphQL 服务器。请注意,allTodos字段的offset参数是缺失的。作为可选项,它的缺失意味着它有null值。如果服务器提供这种模式,文档中可能会声明,null偏移量意味着默认情况下应该返回第一页。响应可能是这样的:
{
  "data": {
    "allTodos": {
      "totalCount": 42,
      "items": [
        {
          "id": 1,
          "description": "write a blogpost",
          "isCompleted": true
        },
        {
          "id": 2,
          "description": "edit until looks good",
          "isCompleted": true
        },
        {
          "id": 2,
          "description": "proofread",
          "isCompleted": false
        },
        {
          "id": 4,
          "description": "publish on the website",
          "isCompleted": false
        },
        {
          "id": 5,
          "description": "share",
          "isCompleted": false
        }
      ]
    }
  }
}

如果你从查询中删除isCompleted字段,它将从结果中消失。或者你可以添加project字段,用其idname来遍历关系。将offset参数添加到allTodos字段进行分页,这样allTodos(count: 5, offset: 5)将返回第二页。结果中提供了totalCount字段,这很有用,因为现在你知道总共有42 / 5 = 9页。但显然,如果不需要totalCount,你可以忽略它。查询可以完全控制将要接收的实际信息,但是底层的 GraphQL 基础设施还必须确保所有必需的字段和参数都在那里。如果你的 GraphQL 服务器足够聪明,它将不会对你不需要的字段运行数据库查询,而且有些库好到免费提供这种查询。此模式中的其他变体和查询也是如此:对输入进行类型检查和验证,并且基于查询,GraphQL 服务器知道期望的结果形状。本质上,所有通信都通过服务器上一个预定义的 URL(通常是/graphql)运行,借助一个简单的POST请求,其中包含序列化为 JSON 有效负载的查询。但是,你几乎从来都不需要接触如此低的抽象层。

总体来说还不错:我们已经解决了类型级别的验证问题,分页看起来也不错,并且在需要时可以轻松地遍历实体关系。如果使用一些现成的 GraphQL->数据库查询翻译库,你甚至不需要在服务器上编写大多数数据库查询。客户端库可以很容易地将 GraphQL 响应自动解包为所需类型的对象实例,因为从模式和查询可以提前知道响应形状。

 GraphQL 是个时髦的东西,是一种时尚,对吗?

虽然 Netflix falcor 似乎在解决类似问题,它比 GraphQL 早几个月发布在 GitHub 上,也更早地引起我的注意,但很明显,似乎 GraphQL 赢了。良好的工具和强大的行业支持使其非常有吸引力。

除了一些客户端库中存在的一些小问题(现在已经解决了)之外,我强烈推荐你仔细看看 GraphQL 在你的技术栈中可以提供什么。它已经出技术预览四年多了,而且这个生态系统正在变得更加强大。在 Facebook 设计 GraphQL 的同时,我们也看到越来越多的大公司在他们的产品中使用它:GitHub、Shopify、Khan Academy、Coursera,而且 这个列表还在不断增长。

有很多流行的开源项目都在使用 GraphQL:这个博客是基于静态站点生成器 Gatsby,它将 GraphQL 查询的结果转换成数据,然后呈现到 HTML 文件中。如果你使用的是 WordPress,也有 GraphQL API 可以使用。Reaction Commerce 是 Shopify 的开源替代方案,同样是基于 GraphQL。

另外值得一提的两个 GraphQL 库是 PostGraphile 和 Apollo。

如果你使用 PostgreSQL 作为后端数据库,PostGraphile 能够扫描 SQL 模式并自动生成一个带有实现的 GraphQL 模式。你可以将所有常见的 CRUD 操作暴露为所有表的查询和修改。它可能看起来像 ORM,但它不是:你可以完全控制如何设计数据库模式,以及使用什么索引。

最妙的是,PostGraphile 还以查询和修改的方式暴露视图和函数,所以如果有特别复杂的 SQL 查询需要映射到 GraphQL 字段,只需创建 SQL 视图或函数,它就会自动出现在 GraphQL 模式中。通过像行级安全这样的高级 Postgres 特性,你可以通过编写少量 SQL 策略实现复杂的访问控制逻辑。PostGraphile 甚至还有模式文档这样的东西,可以从 Postgres 注释自动生成。

相应地,Apollo 提供了多个平台的客户端库,以及在最流行的编程语言(包括 TypeScript 和 Swift)中生成类型定义的代码生成器。

总的来说,我发现,Apollo 比 Relay 等更简单和易于使用。由于 Apollo 客户端库架构简单,我能够将一个使用 React.js 与 Redux 的应用慢慢过渡到 React Apollo,一个组件一个组件的,只在有意义的时候才这样做。与原生 iOS 应用一样,Apollo iOS 是一个相对轻量级的、易于使用的库。

 延伸阅读

https://desiatov.com/why-graphql/?fileGuid=cGOKAr3CJtY4Y9Rh

浏览 38
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报