使用 GraphQL 设计 API
学习如何使用 GraphQL API 设计接口。
设计一个优秀的 API 是一项具有挑战性的任务,需要不断迭代、实验,并对业务领域有深入的理解。
在本节课中,我们假设自己在一家新一代披萨公司工作。目前我们已有一个基于现有商业模式的 GraphQL API,用于支持现有的功能。现在,我们希望引入一项新功能。从后端来看,该功能需要满足以下实现要求:
- 所有披萨都必须具备基本属性,例如
pizza和description,并能指明其所搭配的topping(配料)或sauce(酱料)。 - 某些类型的披萨需要支持自定义的独有属性。例如,我们可以自定义那不勒斯披萨(Neapolitan pizza)中的
sauce,以及圣路易斯披萨(St. Louis pizza)中的crust(饼底)。
在明确这些需求之后,我们可以开始思考用于实现新功能的 API 设计。
鸟瞰视角
根据上述需求,一个简单的模式设计可能如下所示:
1 type Pizza { 2 id: Int! 3 pizza: String! 4 description: String! 5 } 6 7 type NeapolitanPizza { 8 id: Int! 9 pizza: String! 10 description: String! 11 sauce: String! 12 } 13 14 type StLouisPizza{ 15 id: Int! 16 pizza: String! 17 description: String! 18 crust: String! 19 }
即使只有五个对象,这个结构已经显得复杂。这种模式的主要缺点是:如果未来业务新增更多披萨类型,我们就需要不断添加新的对象,尽管每次可能只有一两个属性发生变化。
一个 GraphQL API 可能包含大量对象,每个对象又有数十个字段,并且彼此之间存在关联。以这种方式构建 API 容易出错。更好的做法是从更高层次的视角出发,专注于类型及其关系,而不急于定义具体字段或变更操作。这类似于为 GraphQL 量身定制的实体-关系模型。如果我们缩小模式范围,可以得到如下简化版本:
1interface IPizza { 2 id: Int! 3 pizza: String! 4 description: String! 5 crust: [Crust!] 6 sauce: [Sauce!] 7 } 8 9 type Sauce { 10 id: Int! 11 name: String! 12 description: String! 13 } 14 15 type Crust { 16 id: Int! 17 name: String! 18 description: String! 19 } 20 21 type Pizza implements IPizza{ 22 id: Int! 23 pizza: String! 24 description: String! 25 crust: [Crust!] 26 sauce: [Sauce!] 27 }
要实现这种简化的表示方式,需要注意以下几个关键点:
-
我们需要找出各类披萨之间的共性属性,并尝试创建合适的接口。通过接口,我们可以确保未来创建的所有披萨类型在结构上保持一致且可预测。
-
大多数披萨都有薄底或厚底饼皮,以及某种酱料(通常是番茄酱)。因此,将饼底和酱料数据拆分为独立的对象(即
Crust和Sauce类型),再与披萨对象建立关联,是一种良好的设计选择。
在定义完所有支撑披萨类型的辅助对象后,我们需要将它们整合到一个实现了 IPizza 接口的主对象中,并与 Crust 和 Sauce 类型建立连接。
业务领域理解
在这一抽象层级上,合理的 API 设计依赖于我们对所建模问题领域的深刻理解。因此,在设计上述问题的 API 时,重要的是提出问题,而不是盲目地照搬业务需求。
优秀的 API 并不会直接映射用户界面。客户端可以通过实现和 UI 来获取关于 API 设计的启发,但最终决策应始终基于业务领域本身。
更重要的是,我们不应局限于复制已有 REST API 的设计选择。GraphQL 与 REST 的设计理念截然不同,不能简单假设适用于 REST API 的方案也适用于 GraphQL API。
添加细节
现在我们已经有了清晰的类型模型结构,可以重新加入具体的字段。
在添加任何字段之前,我们都应先自问:这项新增是否真正必要?不要仅仅因为数据库表中有某一列、模型中有某个属性或 REST 接口中存在某个字段就盲目添加。
我们应该仅基于实际需求和使用场景来暴露模式元素(如字段、参数、类型等)。GraphQL 模式很容易通过新增功能进行扩展,但修改或删除已有内容则困难得多。
标量类型处理
列表与布尔值
最佳实践是让 List 类型及其元素均为非空。如果确实需要允许列表为空(null),则必须确保“空列表”与“null”之间存在明确的语义区别。
1 toppings: [Topping!]!
类似地,布尔字段几乎总是设为非空。若需使用可为空的布尔值,必须有充分理由区分三种状态:null、false、true。
1 isAvailable: Boolean!
字符串
GraphQL 区分空字符串 ("") 和 null 值 (null)。因此,当我们需要一个可为空的字段时,必须确认“不存在”(null)与“存在但为空”("")之间是否有实际的语义差异。
注意:我们可以将空字符串理解为“适用但未填写”,而 null 表示“不适用”。
ID 与关联关系
假设我们现在需要为每种披萨展示图片,于是引入了 imageId 字段。这正是将 REST 设计套用到 GraphQL 上的典型反例。
1 interface IPizza { 2 id: Int! 3 pizza: String! 4 imageId: String! 5 } 6 7 type Pizza implements IPizza{ 8 id: Int! 9 pizza: String! 10 description: String! 11 imageId: String! 12 }
在 REST API 中,通常会在响应中包含其他对象的 ID 来建立关联,这是一种常见做法。但在 GraphQL 中,这是反模式。与其提供一个 ID 让客户端再去发起额外请求获取信息,不如直接将相关信息嵌入图结构中——这正是 GraphQL 的优势所在。GraphQL 的通信机制非常高效,因为客户端必须显式查询每一个字段,否则服务器不会返回任何内容。
一般原则是:我们设计中的 ID 字段应仅用于标识对象自身。其他 ID 字段只能作为对象引用存在。应用这一原则后,我们的模式变为:
1 interface IPizza { 2 id: Int! 3 pizza: String! 4 description: String! 5 images: [Image!]! 6 } 7 8 type Image { 9 id: Int! 10 fileLocation: String! 11 } 12 13 type Pizza implements IPizza{ 14 id: Int! 15 pizza: String! 16 description: String! 17 status: PizzaStatus! 18 images: [Image!]! 19 }
命名与标量类型优化
披萨模式中的最后一个字段是 image 下的 fileLocation。改进此 API 的第一步是将其重命名为 file,这是一个更精确的名称。字段命名应基于语义合理性,而非沿用旧系统中的命名。
其次,应将其设为非空字段。正如标题字段所示,“null”与“空字符串”之间并无实际意义的区别,因此不应在 API 中暴露这种差异。即使数据库允许该字段为空,我们也可以在实现层进行抽象处理。
最后,我们需要思考 String 是否是该字段的最佳类型。GraphQL 提供了一组内置标量类型(如 String、Int、Boolean 等),但也允许我们自定义标量类型。自定义标量能为客户端提供更多上下文和语义价值。例如,当客户端上传图片后,服务器返回文件 URL,此时定义一个 URL 标量类型是合理的。这样客户端可以确信,每当服务端返回一个 URL 类型的字符串时,它一定是有效的。
枚举类型
模式中最后一个需要处理的标量字段是 status。每种状态代表披萨的当前状态,如烘烤中、打包中等。这些字段适合转换为枚举类型。最终完成的模式设计如下:
1 enum PizzaStatus { 2 COOKING, 3 PACKING, 4 DELIVERING 5 } 6 7 interface IPizza { 8 id: Int! 9 pizza: String! 10 description: String! 11 images: [Image!]! 12 status: PizzaStatus! 13 toppings: [Topping!]! 14 crusts: [Crust!]! 15 sauces: [Sauce!]! 16 } 17 18 type Sauce { 19 id: Int! 20 name: String! 21 description: String! 22 } 23 24 type Crust { 25 id: Int! 26 name: String! 27 description: String! 28 } 29 30 type Topping { 31 id: Int! 32 topping: String! 33 } 34 35 type Image { 36 id: Int! 37 fileLocation: String! 38 } 39 40 type Pizza implements IPizza{ 41 id: Int! 42 pizza: String! 43 description: String! 44 status: PizzaStatus! 45 images: [Image!]! 46 toppings: [Topping!]! 47 crusts: [Crust!]! 48 sauces: [Sauce!]! 49 }
请参考下图,以全面理解我们最终的 GraphQL 披萨模式。
业务逻辑封装
现在,我们已经构建了一个简洁而设计良好的披萨 GraphQL API。当然,还有很多未处理的需求,比如排序顺序等。但只要我们以此为基础继续扩展,后续字段的设计都可以遵循类似的模式。
我们可以从一个具体用例入手,假设我们的 API 客户端开发者需要知道某款披萨当前是否处于促销状态。
虽然我们可以简单地添加一个新字段来满足这一需求,但这种短期决策会带来两个严重问题:第一,效率低下——服务器可能返回数百万条记录,客户端难以遍历处理;第二,增加了客户端的代码负担。软件设计的一条重要原则是:服务器应负责所有业务逻辑。API 的存在是为了服务多个客户端,如果每个客户端都需要重复实现相同的业务逻辑,就会导致代码冗余,增加维护成本和出错风险。
处理此类用例的最佳方式是提供一个专门解决该问题的新字段。实际实现如下:
1 interface IPizza { 2 id: Int! 3 pizza: String! 4 description: String! 5 images: [Image!]! 6 status: PizzaStatus! 7 toppings: [Topping!]! 8 crusts: [Crust!]! 9 sauces: [Sauce!]! 10 isDiscount: Boolean! 11 }
但需注意一点:提供业务逻辑字段并不意味着禁止客户端访问原始数据。如果客户端需要自行处理逻辑,我们也应允许其获取底层数据。毕竟我们无法预知客户端可能需要的所有逻辑。
如果发现某些业务逻辑难以融入现有模型,可能说明我们的基础模型设计不合理。我们不应让业务逻辑字段主导整个 API 的结构。
变更操作(Mutations)
GraphQL 模式设计的最后一环是支持创建、更新和删除披萨数据的能力。这部分设计与查询操作类似,我们也应从高层视角开始。我们可以先列出希望提供的各种变更操作,暂不关注其具体输入或输出。虽然 CRUD 范式(增删改查)是一个不错的起点,但对于成熟的 GraphQL API 来说还不够。
拆分逻辑操作
如果我们将所有 CRUD 操作都暴露出来,变更操作会迅速变得臃肿。这不仅会使更新简单标量值或执行添加/删除等操作变得复杂,也会让服务器实现和客户端理解变得更加困难。相反,我们可以利用 GraphQL 的特性,将操作拆分为更细粒度的逻辑动作。最终的变更操作列表可能包括:
- 创建披萨
- 删除披萨
- 更新披萨
1 type Mutation { 2 createPizza(pizza: String, toppings: [ToppingInput!]!): Pizza! 3 updatePizza(id: Int!, pizza: String, toppings: [ToppingInput]): Pizza! 4 }
变更操作命名规范
尽管 CRUD 动词通常能准确描述操作行为,但我们不应直接使用 create、delete、update 这类通用动词作为变更操作的名称。更佳的做法是结合上下文、业务领域和操作目的选择更贴切的动词。如果有更合适的词汇,就应当使用它。例如,如果操作的实际效果是隐藏某款披萨而非彻底删除,就不应命名为 deletePizza,而应使用 hidePizza。
输入类型设计
明确了需要实现的变更操作后,接下来需要定义它们的输入结构。假设我们正在参考某个公开可用的真实生产环境模式。
对于大多数简单变更操作,至少需要一个 ID。针对披萨相关操作,我们可以明确以下输入需求:
deletePizza类操作只需一个披萨 ID;createPizza和updatePizza则需要披萨 ID 及其相关信息(如配料、酱料等)。
以创建披萨功能为例,输入结构可能最初看起来像我们最初的简单模型。但基于前文优化后的模式,我们的变更操作定义应如下所示:
1type Mutation { 2 createPizza(pizza: String!, toppings: [ToppingInput!]!, description: String!, status: PizzaStatus!, images:[Upload!]!): Pizza! 3 updatePizza(id: ID!, pizza: String!, toppings: [ToppingInput!]!, description: String!, status: PizzaStatus!, images:[Upload!]!): Pizza! 4 }