1、从实体设计出发
根据需要定义、引用、继承实体。Object和Interface可以通过添加 @key 变为一个实体。@key 修饰符给实体定义了主键,fields参数可以包含一个或多个该实体的字段。 下面的例子中,Product实体的主键是他的upc字段。
Products Subgraph
type Product @key(fields: "upc") {
upc: String!
name: String!
description: String
}
换句话说,把upc设为key意味着其他subgraph想要使用这个实体就需要知道upc这个字段。这个字段是可以用来确定唯一Product的一个字段。避免在subgraph之间传递动态数据的场景。
在一个schema中定义好一个实体后,其他subgraph可以在他们的schema中引用这个实体。为了使这个引用有效,在这个schema中必须有那个实体的副本。
Reviews Subgraph
type Review {
rating: Int
product: Product
}
extend type Product @key(fields: "upc") {
upc: String! @external
}
extend关键字表示这个实体定义在其他subgraph中。@key修饰符表示 reviews service 可以通过upc字段找到唯一的一个Product,而且reviews service不需要知道除了upc字段意外的其他的信息。@external 修饰符表示这个字段定义在其他 service里面。
可以在继承的实体里面添加原始实体里面没有的字段。
Reviews Subgraph
type Review {
rating: Int
product: Product
}
extend type Product @key(fields: "upc") {
upc: String! @external
reviews: [Review]
}
需要注意的是,原始实体所在的service并不会知道继承实体中额外添加的字段的存在。另外,实体中的每个字段只能被定义一次,否则gateway会报错。
2、以需求为导向、抽象的方式设计schema
federation允许你设计schema的时候表达清楚实体之间的原始关系。比如,在没有federation的分布式GraphQL架构中,subgraph的schema中关联两个实体的时候必须要用到类似外键的东西。
type Review {
id: ID!
productID: ID
}
但是有了federation,只需要这么写
extend type Product @key(fields: "id") {
id: ID! @external
}
type Review {
id: ID!
product: Product
}
另一个暴露接口细节的例子,以下是 REST API命名接口的方式
extend type Mutation {
postProduct(name: String!, description: String): Product
patchProduct(
id: ID!,
name: String,
description: String
): Product
}
更好的方式如下:
extend type Mutation {
createProduct(name: String!, description: String): Product
updateProductName(id: ID!, name: String!): Product
updateProductDescription(
id: ID!,
description: String!
): Product
}
优化后的Mutation更好的描述了客户端想要做的事情,并且提供了更细粒度的更新product的方式。用两个单独的mutation也消除了当客户端调用patchProduct mutation的时候不传 name 和 description 的歧义,也避免了 subgraph去处理这些运行时错误。
3、优先考虑schema的表达性
分页规范
1、必要的时候才加入分页。当一个基本的list就满足要求的时候不要用分页。
2、当分页是必要的时候,把整合当做一个机会,努力规范支持分页的类型系统元素。比如参数和分页相关的对象类型和枚举。
3、分页标准化并不意味着倾向于某一种分页规范。选择合适的分页方案,但是保证在不同服务之间的分页方案是一致的。
4、公司内部应该统一严格执行分页标准保证对客户端的一致性。
细粒度的方法也适用于更新相关的 mutation。例如预期用一个单独的 updateAccount mutation去更新账户信息,不如用目的性更强的某个字段的mutation。例如用下列的mutations更新一个用户的账户信息。
extend type Mutation {
addSecondaryEmail(email: String!): Account
changeBillingAddress(address: AddressInput!): Account
updateFullName(name: String!): Account
}
选择细粒度的mutation有助于避免在运行时做一些额外的校验参数的工作,可能由于提交参数带来的不正确的结果。尽管一个设计良好的schema是表达很清晰的,但是使用SDL的注释可以更好的告诉使用者这些类型、字段、参数怎么使用。
extend type Query {
"""
Fetch a paginated list of products based on a filter.
"""
products(
"How many products to retrieve per page."
first: Int = 5
"Begin paginating results after a product ID."
after: Int = 0
"""
Filter products based on a type.
Products with any type are returned by default.
"""
type: ProductType
): ProductConnection
}
不能给继承类型添加注释,包括继承的 Query 和 Mutation,因为GraphQL 规定只有 type 定义可以有注释,继承的 type 不行。
4、明确定义可以为null的字段
GraphQL中所有的字段默认都是可以为null的。非空字段和参数是一个很重要的特性,它帮助提升schema的表达性和可预测性。非空字段对客户端来说也有好处,因为他们在处理响应的时候会明确的知道什么地方有值。
向后兼容
当客户端期望得到一个之前非空字段的值的时候,定义非空的字段和参数使schema变得很难扩展。比如User类型上有个非空的字段email变为可以为空以后,更新schema后客户端未必会处理这个可能为空的值。虽然一开始设计schema的时候就要考虑周全,但将来改造schema的时候不可避免的会面对这个问题。
尽量减少可以为空的参数和input字段
前面提到把一个可以为空的字段变为非空会对客户端造成巨大影响。所以把mutaion的参数声明为非空可以避免这些影响。这种方法也提高了schema的可读性,并且提高了参数的透明度。并且它减少了使用者获取他们想要的结果时猜测字段的负担。另一个建议是,给参数提供一个默认值来提高schema整体的可读性,使schema的默认行为更加透明。
extend type Query {
"Fetch a paginated list of products based on a filter."
products(
# ...
"Filter products based on a type."
type: ProductType = ALL
): ProductConnection
}
5、谨慎使用抽象类型
GraphQL目前提供了两种抽象类型,interfaces和unions。他们都是描述类型之间关系的强大的工具。但是,当添加interfaces和unions的时候,尤其是在federated schema中,理解长远的影响很重要。作为一个使用抽象类型的先决条件,subgraph必须能够返回所有可能的类型作为输出。
创建语法上有意义的接口
接口的一种最常见的滥用是用来简单的表示两个类型之间共享的字段。他们应该只用于当某个字段需要返回一个对象或一组对象并且这些对象可能代表不同的类型并且这些类型有一些公共字段。例如:
interface Pet {
breed: String
}
type Cat implements Pet {
breed: String
extraversionScore: Int
}
type Dog implements Pet {
breed: String
activityLevelScore: Int
}
type Query {
familyPets: [Pet]
}
在这个schema中 familyPets 这个query返回cats或者dogs的数组,breed字段确保在 Cat和Dog类型上都会有。客户端可以这样查询:
query GetFamilyPets {
familyPets {
breed
... on Cat {
extraversionScore
}
... on Dog {
activityLevelScore
}
}
}
因为接口是抽象类型,他们应该最终代表一些定义在schema中的具体的关系,他们应该暗示一些继承他们的类型之间的类似的行为。
帮助客户端应对schema的变化
interfaces和unions 被添加到schema后,应该仔细考虑以后的改动,因为微妙的变化可能对使用API的人产生巨大影响。例如,客户端可能没准备好处理被添加到interfaces和unions中的新的类型,这可能会导致在已有的操作中出现意外错误。继续之前的例子,一个新的 GoldFish 的类型也实现了 Pet接口:
type Goldfish implements Pet {
breed: String
lifespan: Int
}
之前的 GetFamilyPet 的query可能现在会返回包括 goldfish的结果,但是客户端目前只有处理 cats和dogs的方法。所以添加类型的时候跟客户端沟通很重要。
6、利用 SDL和工具管理废弃的字段
使用 @deprecated 系统指令:
第一步,当字段或者枚举值废弃时,可以使用@deprecated 指令。它有个reason参数可以告诉客户端应该怎么处理。比如之前的例子中,我们可以像这样废弃 topProducts 的query:
extend type Query {
"""
Fetch a simple list of products with an offset
"""
topProducts(
"How many products to retrieve per page."
first: Int = 5
): [Product] @deprecated(reason: "Use products instead.")
"""
Fetch a paginated list of products based on a filter type.
"""
products(
"How many products to retrieve per page."
first: Int = 5
"Begin paginating results after a product ID."
after: Int = 0
"Filter products based on a type."
type: ProductType = LATEST
): ProductConnection
}
7、用对客户端友好的方式处理错误
因为GraphQL是基于需求导向来构建API的。所以在执行操作中发生错误的时候也应该用以客户端为中心的方式来处理错误。目前有两种主要的方式来处理错误并且发送错误信息给客户端、第一种是利用GraphQL规范的错误行为。第二种是把错误当成数据直接定义在schema中。
当真的发生错误的时候,使用内置的错误处理
GraphQL规范在响应中确实存在错误处理程序。GraphQL有个独特的特性,他允许你同时返回结果数据和错误。
在一个最小的例子中,errors数组中的一个error的map会包含一个message的key,代表了错误的描述,但他也可能包含location和path这两个key如果这个错误可以被定位到在某个文件里。例如:
query GetUserByLogin {
user(login: "incorrect_login") {
name
}
}
在响应中的data会包含一个null的user和errors的key:
{
"data": {
"user": null
},
"errors": [
{
"type": "NOT_FOUND",
"path": [
"user"
],
"locations": [
{
"line": 7,
"column": 3
}
],
"message": "Could not resolve to a User with the login of 'incorrect_login'."
}
]
}
很多GraphQL的服务器包括 Apollo Server都会在extensions中给errors数组里的错误提供额外的关于错误的信息。例如Apollo Server在extensions中提供一个一个 stacktrace的字段。
最佳实践是,堆栈信息应该在生产环境的错误信息里被移除。可以通过在Apollo Server的构造器中设置 debug选项为false。或者设置NODE_ENV为production或test。
把错误当成数据返回
有时候用户可以选择性的忽略发生在执行GraphQL过程中的错误。例如一个新用户可能会出发一个mutation来创建一个新的账号,但是发送了一个已经存在的用户名参数。在正常情况下,会抛出一个错误。
在这些情况下,一种把错误当成数据返回的方式更适合座位响应。使用这种方式意味着错误直接写在了GraphQL的schema里面,并且错误信息会在data字段里面被返回而不是 在errors里面。
定义错误的方式有很多种,其中一种常用的方式是用联合类型代表一个操作可能返回的多种状态。例如:
Accounts Subgraph
type User @key(fields: "id") {
id: ID!
firstName: String
lastName: String
description: String
}
extend type Query {
me: User
}
Products Subgraph
type Product @key(fields: "sku") {
sku: String!
name: String
price: Float
}
type ProductRemovedError {
reason: String
similarProducts: [Product]
}
union ProductResult = Product | ProductRemovedError
extend type User @key(fields: "id") {
id: ID! @external
suggestedProducts: [ProductResult]
}
extend type Query {
products: [Product]
}
以上, ProductResult 类型是一个联合类型,代表Product的两种状态:要么存在要么被移除了。在这个例子中,一个商品被移除了,相关的商品会展现给用户。一个给当前用户推荐商品的query如下:
query GetSuggestedProductsForUser {
me {
suggestedProducts {
__typename
... on Product {
name
sku
}
}
... on ProductRemovedError {
reason
similarProducts {
name
sku
}
}
}
}
因为我们在查询一个联合类型,__typename字段用来帮助客户端根据返回类型渲染页面。
8、仔细管理横切关注点
subgraph之间的共享类型(scalars, objects, interfaces, enums, unions, and inputs)和 directives会产生横切关注点。通常来说,如果subgraph共享了类型,那么这些类型必须在名字内容和逻辑方面都统一,否则gateway会抛出构建错误。
在某些情况下,subgraph会需要共享类型而不是在一个service中定义好之后作为一个实体暴露出去。例如:当一个GraphQL API支持 relay 方式的分页时,有可能需要把 PageInfo 对象类型在多个service之间共享:
type PageInfo {
endCursor: String
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
}
把 PageInfo作为实体暴露出去没什么意义,因为他没有主键。而且这个对象中的字段很长时间应该都不会变化,相对稳定。
当给 federated GraphQL API新增一个类型的时候,没有一个简单的公式可以计算开销。但是这个很考验一个团队管理和迭代graph的能力,因为服务是分布式部署的,如果类型很少变化,长期的维护开销会变得很小。