DrillLab
第 01 / 17 节LESSON 01 / 17约 15 分钟~15 min

GraphQL 是什么:一份 schema 加一堆 resolverWhat GraphQL is: one schema plus a set of resolvers

读真实的 schema.graphql,把 type / field / Query / Mutation 一次讲清。Read the real schema.graphql and cover type / field / Query / Mutation in one pass.

3 个练习3 exercisesFederation · 第 1 部分Federation · Part 1
这一页有什么On this page8
学完这节你会After this lesson you can
  • 说清 schema 在 GraphQL 里的地位Explain what role the schema plays in GraphQL
  • 读懂 type / field / 标量 / enum / input 各是什么Read a schema and say what type / field / scalar / enum / input each mean
  • 分清 Query 和 MutationTell Query and Mutation apart
  • 知道「客户端决定返回形状」意味着什么Know what it means that the client decides the shape of the response
这在考试里考什么What the exam does with this

这份 schema 决定了你的 resolver 必须返回什么形状。审计发现有两处细节(双重非空、input 里没有 price)直接决定实现对错 —— 不读 schema 就写 resolver,必错。The schema decides what shape each resolver must return. Two details in it (a doubly non-null type, and an input with no price field) decide whether your code is right or wrong. If you write resolvers without reading the schema, you will get them wrong.

这节课要看的真实文件Real files this lesson looks at1 项 · 1 个可以展开看原文1 items · 1 can be opened
graphql-federation-practice/node-subgraph/src/schema.graphql整个 subgraph 的契约The contract for the whole subgraph
GraphQL SDLschema.graphql源项目From source
1extend schema
2 @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable", "@external"])
3
4type User @key(fields: "id") {
5 id: ID! @external
6 orders: [Order!]!
7}
8
9type Order {
10 id: ID!
11 userId: ID!
12 status: OrderStatus!
13 totalAmount: Float!
14 items: [OrderItem!]!
15 createdAt: String!
16 shippingInfo: ShippingInfo
17}
18
19type OrderItem {
20 productId: ID!
21 quantity: Int!
22 price: Float!
23}
24
25type ShippingInfo {
26 status: String!
27 estimatedDelivery: String
28 trackingNumber: String
29}
30
31enum OrderStatus {
32 PENDING
33 PROCESSING
34 SHIPPED
35 DELIVERED
36 CANCELLED
37}
38
39type Query {
40 order(id: ID!): Order
41 orders(userId: ID!): [Order!]!
42}
43
44type Mutation {
45 createOrder(userId: ID!, items: [OrderItemInput!]!): Order!
46}
47
48input OrderItemInput {
49 productId: ID!
50 quantity: Int!
51}
Source: graphql-federation-practice/node-subgraph/src/schema.graphql
§01

GraphQL 服务只有两半A GraphQL service has only two halves

一半是「有什么」,一半是「怎么拿到」。One half says what exists. The other half says how to fetch it.

schema 声明「这个服务提供哪些类型、 每个类型有哪些字段、字段是什么类型、能不能为空」。 它是一份契约,用一种叫SDL(Schema Definition Language)的语法写。

resolver 是一堆函数,负责「某个字段的值实际怎么算出来」。 schema 说 Order 有个 shippingInfo 字段, resolver 负责真的去物流服务把它取回来。

两半必须对得上。schema 里有的字段没有 resolver → 返回 null;resolver 的名字和 schema 里的字段名不一致 → 这个 resolver 永远不会被调用,而且不报错。后者是 GraphQL 最难查的一类 bug,后面 Debug Lab 会专门练。

The schema declares what types this service offers, what fields each type has, what type each field is, and whether it can be null. It is a contract, written in a syntax called SDL (Schema Definition Language).

The resolvers are a pile of functions that say how each field’s value actually gets produced. The schema says Order has a shippingInfo field; a resolver is what really goes to the shipping service and fetches it.

The two halves have to line up. A field that exists in the schema with no resolver returns null; a resolver whose name does not match the schema field name is never called at all — and nothing complains. The second one is the hardest class of GraphQL bug to track down, and a later Debug Lab drills it.

§02

读真实的 schema.graphqlReading the real schema.graphql

先整体看一遍,再逐块拆。Read it once as a whole, then take it apart block by block.

这是 node-subgraph/src/schema.graphql 的全文, 一个字没改。第 1–2 行的 extend schema @link是 Federation 专用的,下一个模块再讲,先跳过:

This is the whole of node-subgraph/src/schema.graphql, not one character changed. Lines 1–2, the extend schema @link part, are Federation-only — the next module covers them, so skip them for now:

GraphQL SDLsrc/schema.graphql(全文)src/schema.graphql (full file)源项目From source
1extend schema
2 @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@shareable", "@external"])
3
4type User @key(fields: "id") {
5 id: ID! @external
6 orders: [Order!]!
7}
8
9type Order {
10 id: ID!
11 userId: ID!
12 status: OrderStatus!
13 totalAmount: Float!
14 items: [OrderItem!]!
15 createdAt: String!
16 shippingInfo: ShippingInfo
17}
18
19type OrderItem {
20 productId: ID!
21 quantity: Int!
22 price: Float!
23}
24
25type ShippingInfo {
26 status: String!
27 estimatedDelivery: String
28 trackingNumber: String
29}
30
31enum OrderStatus {
32 PENDING
33 PROCESSING
34 SHIPPED
35 DELIVERED
36 CANCELLED
37}
38
39type Query {
40 order(id: ID!): Order
41 orders(userId: ID!): [Order!]!
42}
43
44type Mutation {
45 createOrder(userId: ID!, items: [OrderItemInput!]!): Order!
46}
47
48input OrderItemInput {
49 productId: ID!
50 quantity: Int!
51}
Source: graphql-federation-practice/node-subgraph/src/schema.graphql
§03

type 和 fieldtype and field

type Order { ... } 声明了一个对象类型,花括号里每一行是一个字段(field),格式是字段名: 类型

字段的类型分三种:

  • 标量(scalar) —— 内置的叶子类型, 不能再往下展开。GraphQL 内置 5 个:IDStringIntFloatBoolean。 这份 schema 里 totalAmount: Float!quantity: Int! 都是。
    注意 createdAt: String! —— 时间被存成了字符串,不是什么 DateTime 类型。 GraphQL 没有内置日期标量。
  • 对象类型 —— 可以继续往下查。items: [OrderItem!]!shippingInfo: ShippingInfo 都是。
  • 枚举(enum) ——status: OrderStatus!,值只能是PENDING / PROCESSING /SHIPPED / DELIVERED /CANCELLED 五个之一。返回一个不在列表里的字符串会报错。

ID 值得单说:它序列化成字符串, 但语义是「这是个标识符,不要拿它做算术」。 所以 getOrdersByUserId("123") 里那个 userId 是字符串 "123" 而不是数字 123 —— 这一点和 React 那门考试里 Note.idnumber 正好相反,别混。

type Order { ... } declares an object type. Every line inside the braces is a field, written as fieldName: Type.

Field types come in three kinds:

  • Scalars — the built-in leaf types, nothing to expand further. GraphQL ships five: ID, String, Int, Float, Boolean. In this schema totalAmount: Float! and quantity: Int! are both scalars.
    Look at createdAt: String! — the timestamp is stored as a string, not as some DateTime type. GraphQL has no built-in date scalar.
  • Object types — you can keep querying downward. items: [OrderItem!]! and shippingInfo: ShippingInfo are both object types.
  • Enumsstatus: OrderStatus!, where the value can only be one of PENDING / PROCESSING / SHIPPED / DELIVERED / CANCELLED. Return a string that is not on that list and you get an error.

ID deserves its own note: it serialises to a string, but what it means is “this is an identifier, do not do arithmetic on it”. So the userId in getOrdersByUserId("123") is the string "123", not the number 123 — the exact opposite of the React exam, where Note.id is a number. Do not mix the two up.

§04

Query 和 Mutation:两个特殊的入口类型Query and Mutation: the two special entry types

QueryMutation 是两个约定的入口类型。客户端只能从它们的字段开始查。

  • Query = 读。这份 schema 提供两个入口:order(id: ID!): Order(按 id 取一条,可空 —— 找不到就返回 null)和orders(userId: ID!): [Order!]!(按用户取列表,不可空)。
  • Mutation = 写。这里只有 createOrderGraphQL 不强制你把写操作放 Mutation, 但这是所有人都遵守的约定 —— 而且 Mutation 的多个字段 是串行执行的,Query 的字段是并行的。

圆括号里的是参数(argument)order(id: ID!) 意思是「调这个字段必须给一个非空的 ID」。 参数在 resolver 里通过第二个参数 args 拿到。

input OrderItemInput输入类型。 它和 type 的区别是:input 只能当参数用, 字段只能是标量、enum 或别的 input,不能有 resolver。这个 input 后面会成为一个大坑, 下一节专门讲。

Query and Mutation are two entry types fixed by convention. A client can only start from their fields.

  • Query = read. This schema offers two entries: order(id: ID!): Order (one order by id, nullable — not found means null) and orders(userId: ID!): [Order!]! (a list for one user, not nullable).
  • Mutation = write. There is only createOrder here. GraphQL does not force you to put writes under Mutation, but everybody follows the convention — and the fields of one Mutation run one after another, while Query fields run in parallel.

What sits inside the parentheses is an argument: order(id: ID!) means “calling this field requires a non-null ID”. A resolver reads arguments from its second parameter, args.

input OrderItemInput is an input type. The difference from type: an input can only be used as an argument, its fields can only be scalars, enums or other inputs, and it has no resolvers. This one input becomes a big trap later — the next lesson is all about it.

§05

客户端决定返回形状The client decides the shape of the response

这是 GraphQL 和 REST 最本质的区别。This is the deepest difference between GraphQL and REST.

REST 里 GET /api/orders/456 返回什么字段, 是服务端定的。GraphQL 里客户端自己写:

这带来两个后果,都跟考试有关:

  1. 你的 resolver 可能根本不被调用。客户端没查 shippingInfo,那个 resolver 就不执行 —— GraphQL 是按需调用 resolver 的。
  2. N+1 问题的根源。客户端查了 100 个 order 的 shippingInfo, 执行器就会调 100 次 Order.shippingInfo。 客户端一句话,后端 100 次请求。 这就是为什么这个项目里要用 DataLoader。

In REST, the server decides which fields GET /api/orders/456 returns. In GraphQL the client writes that itself:

Two consequences, and both of them show up in the exam:

  1. Your resolver may never run. The client did not ask for shippingInfo, so that resolver is never executed — GraphQL calls resolvers on demand.
  2. This is where N+1 comes from. Ask for the shippingInfo of 100 orders and the executor calls Order.shippingInfo 100 times. One sentence from the client, 100 requests on the backend. That is why this project needs DataLoader.
GraphQL SDL同一个入口,两种形状One entry point, two shapes已跑通Verified
1# 只要 id 和 status —— 服务端不会去查物流
2{
3 orders(userId: "123") {
4 id
5 status
6 }
7}
8
9# 要物流信息 —— 这时 Order.shippingInfo 的 resolver 才会被调用
10{
11 orders(userId: "123") {
12 id
13 status
14 totalAmount
15 shippingInfo {
16 status
17 trackingNumber
18 }
19 }
20}
1# Only id and status — the server never looks up shipping
2{
3 orders(userId: "123") {
4 id
5 status
6 }
7}
8
9# Ask for shipping — only now does the Order.shippingInfo resolver run
10{
11 orders(userId: "123") {
12 id
13 status
14 totalAmount
15 shippingInfo {
16 status
17 trackingNumber
18 }
19 }
20}
第二个查询是审计时进程内真实执行过的,返回了 order-456(IN_TRANSIT / TRACK123456)和 order-457(DELIVERED / TRACK123457)。The second query really ran in-process during the audit. It returned order-456 (IN_TRANSIT / TRACK123456) and order-457 (DELIVERED / TRACK123457).
§06

一次查询的完整执行流程The full execution flow of one query

把上面的知识串起来。下面这张图是第二个查询(带 shippingInfo 的那个) 在服务端的完整旅程,七步:

一次 GraphQL 查询的执行流程第 1 / 7 步Step 1 of 7
客户端
发出 query
{ orders(userId:"123") { id status shippingInfo { status } } }
服务器
按 schema 校验
执行器
逐字段调 resolver
数据源
DataSource
响应
按查询形状组装
客户端只发一个字符串。注意它自己决定了要哪些字段 —— 这就是 GraphQL 和 REST 最大的区别:形状由调用方说。

Now string all of it together. The diagram below is the full server-side journey of that second query (the one that asks for shippingInfo), in seven steps:

一次 GraphQL 查询的执行流程第 1 / 7 步Step 1 of 7
客户端
发出 query
{ orders(userId:"123") { id status shippingInfo { status } } }
服务器
按 schema 校验
执行器
逐字段调 resolver
数据源
DataSource
响应
按查询形状组装
客户端只发一个字符串。注意它自己决定了要哪些字段 —— 这就是 GraphQL 和 REST 最大的区别:形状由调用方说。
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L1认出来Spot it哪些字段是标量Which fields are scalars

看真实 schema 里的 type Order。 下面哪些字段的类型是标量(不能再往下展开)?(多选)

Look at type Order in the real schema. Which of these fields have a scalar type — one that cannot be expanded any further? (Select all that apply.)

GraphQL SDL源项目From source
1type Order {
2 id: ID!
3 userId: ID!
4 status: OrderStatus!
5 totalAmount: Float!
6 items: [OrderItem!]!
7 createdAt: String!
8 shippingInfo: ShippingInfo
9}
Source: graphql-federation-practice/node-subgraph/src/schema.graphql

这题是多选。More than one answer is correct.

先选一个选项Pick an option first
L1认出来Spot it这个操作该放哪Where does this operation belong

真实 schema 里 createOrder 放在type Mutation 下。如果把它挪到type Query 下会怎样?

In the real schema, createOrder sits under type Mutation. What happens if you move it under type Query?

先选一个选项Pick an option first
L2填空Fill the blanks补全 schema 的关键声明Fill in the key declarations of the schema

照真实 schema.graphql 补全。 三个空分别关系到「入口类型」「枚举」「输入类型」。

Fill this in from the real schema.graphql. The three blanks are the entry type, the enum and the input type.

GRAPHQLsrc/schema.graphql3 个空3 blanks
1 OrderStatus {
2 PENDING
3 PROCESSING
4 SHIPPED
5 DELIVERED
6 CANCELLED
7}
8
9type {
10 order(id: ID!): Order
11 orders(userId: ID!): [Order!]!
12}
13
14 OrderItemInput {
15 productId: ID!
16 quantity: Int!
17}
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

拿到一个 GraphQL 项目You are handed a GraphQL project
先读 schema,它是唯一的契约Read the schema first; it is the only contract
「这个字段能为空吗」Asking whether a field can be null
看有没有 !,这决定 resolver 能不能返回 nullLook for !; it decides whether the resolver may return null
resolver 写了但返回 nullA resolver is written but the field comes back null
查名字和 schema 字段名是否一字不差Check that the resolver name matches the schema field name exactly
看到 enumYou see an enum
返回值必须是列出来的那几个之一,大小写敏感The value must be one of the listed ones; case sensitive
这节的要点What to take away
  1. GraphQL = schema(有什么)+ resolver(怎么拿到),两半必须对得上。GraphQL = schema (what exists) + resolver (how to fetch it). The two halves must match.
  2. 字段类型分标量 / 对象 / enum;ID 序列化成字符串,别当数字用。Field types are scalar, object or enum. ID is serialized as a string, so do not treat it as a number.
  3. Query 是读入口(字段并行),Mutation 是写入口(字段串行)。Query is the read entry point (its fields run in parallel). Mutation is the write entry point (its fields run one after another).
  4. input 只能当参数,不能有 resolver —— 而这个项目的 OrderItemInput 里没有 price。An input can only be used as an argument and cannot have resolvers. In this project OrderItemInput has no price.
  5. 客户端决定返回形状,所以 resolver 是按需调用的,也因此产生 N+1 问题。The client decides the shape of the response, so resolvers are called only when needed. That is also where the N+1 problem comes from.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lessonresolver 的四个参数The four arguments of a resolver
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?