DrillLab
第 10 / 17 节LESSON 10 / 17约 11 分钟~11 min

TODO 2 · Order.shippingInfoTODO 2 · Order.shippingInfo

两行代码,但选错一行就答不到 N+1 这个考点。Two lines of code. Pick the wrong one and you miss the N+1 point entirely.

2 个练习2 exercisesFederation · 第 3 部分Federation · Part 3
这一页有什么On this page9
学完这节你会After this lesson you can
  • 独立写出 Order.shippingInfoWrite Order.shippingInfo without help
  • 解释为什么必须走 loader 而不是直接调数据源Explain why you must go through the loader instead of calling the data source
  • 说清为什么这里要 ?? null 而不是 ?? []Explain why this one needs ?? null and not ?? []
  • 知道测试为什么抓不到「绕过 loader」这个错Know why the tests do not catch a solution that skips the loader
这在考试里考什么What the exam does with this

TODO 原文点名了 DataLoader。这是全项目唯一明确指定实现手段的一处 —— 说明出题人就是要看你会不会用它。The TODO names DataLoader directly. It is the only place in the project that says how to implement something, which means the exam wants to see whether you can use it.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.jsOrder.shippingInfo

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JavaScriptorderResolvers.js源项目From source
1import DataLoader from 'dataloader';
2import { GraphQLError } from 'graphql';
3
4// Custom error codes
5const ErrorCodes = {
6 ORDER_NOT_FOUND: 'ORDER_NOT_FOUND',
7 INVALID_INPUT: 'INVALID_INPUT',
8 INVENTORY_ERROR: 'INVENTORY_ERROR',
9 SERVICE_ERROR: 'SERVICE_ERROR'
10};
11
12// DataLoader for batching shipping info requests
13function createShippingInfoLoader(shippingDataSource) {
14 return new DataLoader(async orderIds => {
15 console.log(`[DataLoader] Batching ${orderIds.length} shipping info requests`);
16
17 const shippingInfos = await Promise.all(
18 orderIds.map(id => shippingDataSource.getShippingInfo(id))
19 );
20
21 return shippingInfos;
22 });
23}
24
25// DataLoader for batching order requests
26function createOrderLoader(orderDataSource) {
27 return new DataLoader(async orderIds => {
28 console.log(`[DataLoader] Batching ${orderIds.length} order requests`);
29
30 const orders = await Promise.all(
31 orderIds.map(id => orderDataSource.getOrderById(id))
32 );
33
34 return orders;
35 });
36}
37
38export const resolvers = {
39 User: {
40 // Reference Resolver - extend User entity from Accounts subgraph
41 __resolveReference(user, { dataSources, loaders }) {
42 return { id: user.id };
43 },
44
45 // Field resolver with caching
46 async orders(user, _, { dataSources, loaders, correlationId }) {
47 // TODO: Implement orders resolver with proper error handling and correlation ID tracing
48 return [];
49 }
50 },
51
52 Order: {
53 // Field resolver for shipping info with DataLoader
54 async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
55 // TODO: Implement shipping info resolver using DataLoader to prevent N+1 queries
56 return null;
57 }
58 },
59
60 Query: {
61 async order(_, { id }, { dataSources, loaders, correlationId }) {
62 // TODO: Implement order query using DataLoader with structured error handling
63 return null;
64 },
65
66 async orders(_, { userId }, { dataSources, correlationId }) {
67 // TODO: Implement orders query with error handling and correlation ID logging
68 return [];
69 }
70 },
71
72 Mutation: {
73 // Mutation provided for reference - candidates focus on Query resolvers
74 async createOrder(_, { userId, items }, { dataSources, correlationId }) {
75 try {
76 console.log(`[${correlationId}] Creating order for userId: ${userId}`);
77
78 if (!userId || !items || items.length === 0) {
79 throw new GraphQLError('Invalid order input', {
80 extensions: {
81 code: ErrorCodes.INVALID_INPUT,
82 correlationId
83 }
84 });
85 }
86
87 const order = await dataSources.orderAPI.createOrder({ userId, items });
88 console.log(`[${correlationId}] Order created: ${order.id}`);
89
90 return order;
91 } catch (error) {
92 console.error(`[${correlationId}] Error creating order:`, error.message);
93 throw new GraphQLError('Failed to create order', {
94 extensions: {
95 code: ErrorCodes.SERVICE_ERROR,
96 correlationId,
97 originalError: error.message
98 }
99 });
100 }
101 }
102 }
103};
104
105export { createShippingInfoLoader, createOrderLoader };
Source: graphql-federation-practice/node-subgraph/src/resolvers/orderResolvers.js
§01

这一问在要求什么What this task asks for

TODO 原文:Implement shipping info resolver using DataLoader to prevent N+1 queries

「using DataLoader」是硬性指定。四个 TODO 里只有这一个规定了实现手段 —— 别的都只说「实现 + 错误处理」。 这说明 N+1 就是这一问的全部考点。

schema 那边:shippingInfo: ShippingInfo可空。所以「这个订单没有物流信息」 返回 null 是正确行为。

The TODO, word for word: Implement shipping info resolver using DataLoader to prevent N+1 queries.

“using DataLoader” is a hard requirement. Of the four TODOs this is the only one that dictates how to implement it — the others just say “implement it, handle errors”. Which tells you N+1 is the whole point of this question.

From the schema: shippingInfo: ShippingInfo, nullable. So returning null for “this order has no shipping info” is the correct behaviour.

§02

先想再写Think before you write

先别写代码 · 先回答这几个问题Before you write code · answer these first
1.输入是什么?—— parent,也就是那个 order 对象。所以 parent.id 是订单 id。
2.输出是什么?—— ShippingInfo 或 null(schema 说可空)。
3.该用 context 里的哪个东西?—— loaders.shippingInfoLoader,不是 dataSources。
4.loader 的方法叫什么?—— .load(key),DataLoader 的标准接口。
5.找不到物流信息时返回什么?—— null,而不是 [] 或 {}。
先别写代码 · 先回答这几个问题Before you write code · answer these first
1.What is the input? — parent, which is the order object. So parent.id is the order id.
2.What is the output? — a ShippingInfo, or null (the schema says nullable).
3.Which thing in context? — loaders.shippingInfoLoader, not dataSources.
4.What is the loader's method called? — .load(key), DataLoader's standard interface.
5.What do you return when there is no shipping info? — null, not [] and not {}.
§03

两种写法都能过测试,但只有一种答对了Both versions pass the tests, but only one answers the question

这是本项目最典型的「测试抓不到」的地方。This is the clearest case in this project of something the tests cannot catch.

对比这两种写法:

测试为什么抓不到区别?因为测试是直接调 resolver 函数的:

它一次只调一个 order,所以「有没有合并」根本体现不出来。 两种写法都返回正确的物流信息,两条测试都过。

那怎么知道自己写对了?看日志。走 loader 的写法会打印[DataLoader] Batching N shipping info requestsN 个订单只打一行。 直接调数据源的写法一行都不打。

所以验证方式是:用真实的 GraphQL 查询(不是单元测试)查一个有多个订单的用户, 然后数日志行数。

Compare the two versions:

Why can the tests not tell them apart? Because the tests call the resolver function directly:

They pass one order at a time, so “did anything get batched” never shows up. Both versions return the right shipping info, and both tests go green.

So how do you know you got it right? Read the logs. The loader version prints [DataLoader] Batching N shipping info requests, and N orders produce a single line. The version that calls the data source directly prints nothing at all.

Which makes the check: run a real GraphQL query (not a unit test) against a user who has several orders, then count the log lines.

JavaScript示意Illustrative
1// ✓ 走 loader —— 答到了 N+1 考点
2async shippingInfo(parent, _, { loaders }) {
3 return loaders.shippingInfoLoader.load(parent.id);
4}
5
6// ✗ 直接调数据源 —— 测试也能过,但每个 order 一次请求
7async shippingInfo(parent, _, { dataSources }) {
8 return dataSources.shippingDataSource.getShippingInfo(parent.id);
9}
1// ✓ goes through the loader — this answers the N+1 question
2async shippingInfo(parent, _, { loaders }) {
3 return loaders.shippingInfoLoader.load(parent.id);
4}
5
6// ✗ calls the data source directly — tests still pass, but one request per order
7async shippingInfo(parent, _, { dataSources }) {
8 return dataSources.shippingDataSource.getShippingInfo(parent.id);
9}
JavaScript两个相关测试The two related tests源项目From source
1it('should return shipping info for an order', async () => {
2 const order = { id: 'order-456' }; // ← 只有一个 order
3 const shippingInfo = await resolvers.Order.shippingInfo(order, {}, context);
4
5 expect(shippingInfo).toBeDefined();
6 expect(shippingInfo).toHaveProperty('status');
7 expect(shippingInfo).toHaveProperty('trackingNumber');
8});
9
10it('should return null for order without shipping info', async () => {
11 const order = { id: 'order-999' };
12 const shippingInfo = await resolvers.Order.shippingInfo(order, {}, context);
13 expect(shippingInfo).toBeNull(); // ← 注意是 toBeNull
14});
1it('should return shipping info for an order', async () => {
2 const order = { id: 'order-456' }; // ← just one order
3 const shippingInfo = await resolvers.Order.shippingInfo(order, {}, context);
4
5 expect(shippingInfo).toBeDefined();
6 expect(shippingInfo).toHaveProperty('status');
7 expect(shippingInfo).toHaveProperty('trackingNumber');
8});
9
10it('should return null for order without shipping info', async () => {
11 const order = { id: 'order-999' };
12 const shippingInfo = await resolvers.Order.shippingInfo(order, {}, context);
13 expect(shippingInfo).toBeNull(); // ← note it is toBeNull
14});
Source: graphql-federation-practice/node-subgraph/__tests__/resolvers.test.js
§04

为什么必须显式 ?? nullWhy you must write ?? null explicitly

第二个测试用的是 toBeNull(),不是 toBeUndefined()。The second test uses toBeNull(), not toBeUndefined().

ShippingDataSource.getShippingInfo('order-999')的实现是查一个对象字面量,找不到时return shippingData[orderId] || null —— 它已经返回 null 了

所以严格说 ?? null 是多余的。但还是要写,理由和上一节一样:

  • 测试断言的是 toBeNull()undefined 不等于 null, 这条断言会失败。
  • DataLoader 的 batch 函数如果返回的数组某个位置是undefined(比如未来数据源换实现),load() 就会 resolve 成 undefined。 显式兜底能挡住这种情况。

习惯:可空字段显式 ?? null, 非空列表显式 ?? []两行都写,不依赖下游实现细节。

ShippingDataSource.getShippingInfo('order-999') looks up an object literal and, on a miss, does return shippingData[orderId] || null — so it already returns null.

Strictly speaking that makes ?? null redundant. Write it anyway, for the same reasons as the last lesson:

  • The test asserts toBeNull(). undefined is not null, so that assertion would fail.
  • If a DataLoader batch function ever returns undefined at some position (say the data source changes implementation later), load() resolves to undefined. An explicit fallback blocks that.

Make it a habit: nullable fields get an explicit ?? null, non-null lists get an explicit ?? []. Write both lines and stop depending on what the layer below happens to do.

§05

完整答案The full answer

加上 TODO 没明说但一致的错误处理(和另外三个 resolver 保持同样的结构):

With the error handling the TODO does not spell out but consistency wants — the same structure as the other three resolvers:

JavaScriptOrder.shippingInfo(参考答案,实测通过)Order.shippingInfo (reference answer, measured to pass)已跑通Verified
1async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
2 try {
3 // 通过 loader 批量,是这个字段防 N+1 的关键
4 const shippingInfo = await loaders.shippingInfoLoader.load(parent.id);
5
6 // ShippingInfo 在 schema 里可空 -> null 是合法答案
7 return shippingInfo ?? null;
8 } catch (error) {
9 if (error instanceof GraphQLError) throw error;
10
11 console.error(`[${correlationId}] Error resolving Order.shippingInfo:`, error.message);
12 throw new GraphQLError('Failed to fetch shipping info', {
13 extensions: {
14 code: ErrorCodes.SERVICE_ERROR,
15 correlationId,
16 orderId: parent.id,
17 originalError: error.message
18 }
19 });
20 }
21}
1async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
2 try {
3 // going through the loader is what keeps this field from causing N+1
4 const shippingInfo = await loaders.shippingInfoLoader.load(parent.id);
5
6 // ShippingInfo is nullable in the schema -> null is a legal answer
7 return shippingInfo ?? null;
8 } catch (error) {
9 if (error instanceof GraphQLError) throw error;
10
11 console.error(`[${correlationId}] Error resolving Order.shippingInfo:`, error.message);
12 throw new GraphQLError('Failed to fetch shipping info', {
13 extensions: {
14 code: ErrorCodes.SERVICE_ERROR,
15 correlationId,
16 orderId: parent.id,
17 originalError: error.message
18 }
19 });
20 }
21}
§06

验证合并真的发生了Checking that the calls really were merged

用真实查询(不是单元测试),查一个有两个订单的用户:

关键是数那行 [DataLoader] Batching两个订单只出现一行N=2, 说明合并生效。如果出现两行 N=1, 说明两次 load() 不在同一个 tick 里 (通常是因为你在 resolver 里加了不必要的 await 把它们错开了)。 如果一行都没有,说明你根本没走 loader。

Use a real query, not a unit test, against a user who has two orders:

The thing to count is that [DataLoader] Batching line. Two orders should produce one line with N=2, which means batching worked. Two lines with N=1 mean the two load() calls did not land in the same tick (usually because an unnecessary await in your resolver pulled them apart). No line at all means you never went through the loader.

GraphQL SDL验证用的查询The query used to check it已跑通Verified
1{
2 orders(userId: "123") {
3 id
4 status
5 shippingInfo { status trackingNumber }
6 }
7}
Text合并生效的证据Evidence the batching worked已跑通Verified
1# 期望的日志(一行,N=2)
2[corr-...] Query.orders userId: 123
3[DataLoader] Batching 2 shipping info requests
4
5# 审计实测的返回(参考解法下)
6{"orders":[
7 {"id":"order-456","status":"SHIPPED",
8 "shippingInfo":{"status":"IN_TRANSIT","trackingNumber":"TRACK123456"}},
9 {"id":"order-457","status":"DELIVERED",
10 "shippingInfo":{"status":"DELIVERED","trackingNumber":"TRACK123457"}}]}
1# The log you want (one line, N=2)
2[corr-...] Query.orders userId: 123
3[DataLoader] Batching 2 shipping info requests
4
5# The response measured in the audit (with the reference answer)
6{"orders":[
7 {"id":"order-456","status":"SHIPPED",
8 "shippingInfo":{"status":"IN_TRANSIT","trackingNumber":"TRACK123456"}},
9 {"id":"order-457","status":"DELIVERED",
10 "shippingInfo":{"status":"DELIVERED","trackingNumber":"TRACK123457"}}]}
练习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.

L2填空Fill the blanks补全 Order.shippingInfoFill in Order.shippingInfo

三个空。第 1 个决定你答不答得到 N+1 考点, 第 3 个决定第二条测试过不过。

Three blanks. The first decides whether you answer the N+1 question at all; the third decides whether the second test passes.

JSsrc/resolvers/orderResolvers.js3 个空3 blanks
1async shippingInfo(parent, _, { dataSources, loaders, correlationId }) {
2 try {
3 const shippingInfo = await .shippingInfoLoader.load(parent.);
4
5 return shippingInfo ?? ;
6 } catch (error) {
7 if (error instanceof GraphQLError) throw error;
8 console.error(`[${correlationId}] Error:`, error.message);
9 throw new GraphQLError('Failed to fetch shipping info', {
10 extensions: { code: ErrorCodes.SERVICE_ERROR, correlationId, orderId: parent.id }
11 });
12 }
13}
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
L1认出来Spot it为什么不能直接调数据源Why you cannot just call the data source

dataSources.shippingDataSource.getShippingInfo(parent.id) 能让两条测试都通过。为什么还是错的?

dataSources.shippingDataSource.getShippingInfo(parent.id) makes both tests pass. So why is it still wrong?

先选一个选项Pick an option first
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

JavaScript示意Illustrative
1// ✗ 在 resolver 里手动 await 每一个,破坏了合并
2async shippingInfo(parent, _, { loaders }) {
3 await new Promise((r) => setTimeout(r, 0)); // 多余的一跳
4 return loaders.shippingInfoLoader.load(parent.id);
5}
1// ✗ awaiting each one inside the resolver breaks the merge
2async shippingInfo(parent, _, { loaders }) {
3 await new Promise((r) => setTimeout(r, 0)); // an extra hop for nothing
4 return loaders.shippingInfoLoader.load(parent.id);
5}
DataLoader 靠「同一个 tick 里的 load 攒起来」实现合并。 中间插一个 await 会把各个 order 的load() 推到不同的 tick, 于是变成 N 次 batch,每次 N=1。
症状:日志出现多行 Batching 1 ... requests合并没了,但测试还是过的。
DataLoader merges calls by collecting every load() made in the same tick. An extra await in between pushes each order's load() into a different tick, so you get N batches of one instead of one batch of N.
What you see: several Batching 1 ... requests lines in the log.The merging is gone, but the tests still pass.
JavaScript示意Illustrative
1// ✗ 用错了 loader
2async shippingInfo(parent, _, { loaders }) {
3 return loaders.orderLoader.load(parent.id);
4}
1// ✗ the wrong loader
2async shippingInfo(parent, _, { loaders }) {
3 return loaders.orderLoader.load(parent.id);
4}
orderLoader 取的是 order,不是物流信息。 这会返回那个 order 本身, 然后 GraphQL 试图把它当 ShippingInfo 用 ——status 字段恰好都有(值是 SHIPPED 而不是 IN_TRANSIT),trackingNumber 是 undefined。
第一条测试会挂在toHaveProperty('trackingNumber') 上。context 里有两个 loader,看清名字。
orderLoader returns an order, not shipping information. You get the order object back, and GraphQL then reads it as a ShippingInfo: status happens to exist (its value is SHIPPED, not IN_TRANSIT) and trackingNumber is undefined.
The first test fails on toHaveProperty('trackingNumber').There are two loaders in context. Read the names carefully.
迁移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.

TODO 指定了实现手段A TODO names the way to implement it
那个手段本身就是考点,别用别的方式绕过That way is the point being tested; do not work around it
列表里每项都要查关联数据Every item in a list needs related data fetched
loader.load(parent.id)loader.load(parent.id)
可空字段A nullable field
?? null,别让 undefined 漏出去?? null, so undefined never gets through
想确认 DataLoader 生效You want to confirm DataLoader is working
数日志里 Batching 的行数和 NCount the Batching lines in the log against N
这节的要点What to take away
  1. TODO 点名了 DataLoader —— 这是四个 TODO 里唯一指定实现手段的,考点就在这。The TODO names DataLoader. It is the only one of the four TODOs that says how to implement it, and that is the point being tested.
  2. 走 loaders.shippingInfoLoader.load(parent.id),不是 dataSources.shippingDataSource。Use loaders.shippingInfoLoader.load(parent.id), not dataSources.shippingDataSource.
  3. 两种写法都能过测试,因为测试一次只调一个 order —— 抓不到合并与否。Both versions pass, because each test calls only one order, so the tests cannot tell whether the calls were merged.
  4. 可空字段要显式 ?? null,因为测试断言的是 toBeNull(),undefined 会挂。A nullable field needs an explicit ?? null, because the test asserts toBeNull() and undefined fails it.
  5. resolver 里别插多余的 await,会把 load() 推到不同 tick,合并失效。Do not add an extra await in the resolver. It pushes load() into a different tick and the merging stops working.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lessonTODO 3 & 4 · Query.order 与 Query.ordersTODO 3 & 4 · Query.order and Query.orders
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: TODO 1 · User.ordersTODO 1 · User.orders