DrillLab
第 05 / 17 节LESSON 05 / 17约 13 分钟~13 min

subgraph 是怎么跑起来的How a subgraph starts up

buildSubgraphSchema 做了什么,为什么它会凭空多出两个字段。What buildSubgraphSchema does, and why two fields appear that you never wrote.

2 个练习2 exercisesFederation · 第 2 部分Federation · Part 2
这一页有什么On this page6
学完这节你会After this lesson you can
  • 读懂 index.js 的启动流程Read the startup flow in index.js
  • 说清 buildSubgraphSchema 和普通 makeExecutableSchema 的区别Explain the difference between buildSubgraphSchema and plain makeExecutableSchema
  • 知道 _service 和 _entities 这两个字段从哪来Know where the two fields _service and _entities come from
  • 会用进程内方式验证 subgraph(不需要起服务器)Check a subgraph from inside the same process, with no server running
这在考试里考什么What the exam does with this

启动流程决定了 context 长什么样(你的 resolver 全靠它)。而 _service / _entities 是本地唯一能验证 federation 部分的手段。The startup flow decides what context looks like, and every resolver you write depends on it. _service and _entities are the only way to check the Federation part locally.

这节课要看的真实文件Real files this lesson looks at2 项 · 2 个可以展开看原文2 items · 2 can be opened
graphql-federation-practice/node-subgraph/src/index.js启动流程与 context 构造The startup sequence and how the context is built
JavaScriptindex.js源项目From source
1import { ApolloServer } from '@apollo/server';
2import { startStandaloneServer } from '@apollo/server/standalone';
3import { buildSubgraphSchema } from '@apollo/subgraph';
4import { readFileSync } from 'fs';
5import { fileURLToPath } from 'url';
6import { dirname, join } from 'path';
7import gql from 'graphql-tag';
8import { resolvers, createShippingInfoLoader, createOrderLoader } from './resolvers/orderResolvers.js';
9import { OrderDataSource, InventoryDataSource, ShippingDataSource } from './dataSources/orderDataSource.js';
10
11const __filename = fileURLToPath(import.meta.url);
12const __dirname = dirname(__filename);
13
14const typeDefs = gql(readFileSync(join(__dirname, 'schema.graphql'), { encoding: 'utf-8' }));
15const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
16
17const server = new ApolloServer({
18 schema,
19 formatError: formattedError => {
20 console.error('GraphQL Error:', {
21 message: formattedError.message,
22 code: formattedError.extensions?.code,
23 path: formattedError.path,
24 correlationId: formattedError.extensions?.correlationId
25 });
26 return formattedError;
27 }
28});
29
30const { url } = await startStandaloneServer(server, {
31 listen: { port: 4000, host: '0.0.0.0' },
32 context: async ({ req }) => {
33 const correlationId = req.headers['x-correlation-id'] ||
34 `corr-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
35
36 const orderDataSource = new OrderDataSource();
37 const inventoryDataSource = new InventoryDataSource();
38 const shippingDataSource = new ShippingDataSource();
39
40 const shippingInfoLoader = createShippingInfoLoader(shippingDataSource);
41 const orderLoader = createOrderLoader(orderDataSource);
42
43 return {
44 dataSources: { orderDataSource, inventoryDataSource, shippingDataSource },
45 loaders: { shippingInfoLoader, orderLoader },
46 correlationId
47 };
48 }
49});
50
51console.log(`Subgraph ready at ${url}`);
52console.log(`Federation SDL available at ${url}?query={_service{sdl}}`);
Source: graphql-federation-practice/node-subgraph/src/index.js
graphql-federation-practice/node-subgraph/package.jsonstart / test script 与 federation 依赖The start and test scripts, and the federation dependency
JSONpackage.json源项目From source
1{
2 "name": "order-subgraph",
3 "version": "1.0.0",
4 "description": "GraphQL Federation Subgraph for Order Management",
5 "main": "src/index.js",
6 "type": "module",
7 "scripts": {
8 "start": "node src/index.js",
9 "test": "NODE_OPTIONS=--experimental-vm-modules jest",
10 "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
11 },
12 "dependencies": {
13 "@apollo/server": "^4.10.0",
14 "@apollo/subgraph": "^2.7.0",
15 "graphql": "^16.8.1",
16 "graphql-tag": "^2.12.6",
17 "dataloader": "^2.2.2"
18 },
19 "devDependencies": {
20 "jest": "^29.7.0",
21 "@jest/globals": "^29.7.0"
22 },
23 "jest": {
24 "testEnvironment": "node",
25 "transform": {},
26 "testMatch": ["**/__tests__/**/*.test.js"]
27 }
28}
Source: graphql-federation-practice/node-subgraph/package.json
§01

启动的五步The five startup steps

  1. 读 schema 文件。readFileSync(join(__dirname, 'schema.graphql')), 然后 gql(...) 把字符串解析成 AST。
    __dirname 在 ESM 里不是内置的, 所以上面用 fileURLToPath(import.meta.url)手动算了一个 —— 这是 ESM 项目的标准写法。)
  2. 组装 schema。buildSubgraphSchema([{ typeDefs, resolvers }])—— 这是 federation 的关键一步,下一段细说。
  3. 建 ApolloServer,带一个formatError 钩子,把错误的 message / code / path / correlationId 打到服务端日志。
  4. 起服务器,监听 4000。
  5. 每个请求构造 context(上一模块讲过)。

formatError 那段值得注意:它原样返回formattedError,只是顺手打了日志。 也就是说你在 resolver 里放进extensions 的东西会被客户端看到 —— 这是「结构化错误」能起作用的前提。

  1. Read the schema file. readFileSync(join(__dirname, 'schema.graphql')), then gql(...) parses that string into an AST.
    (__dirname is not built in under ESM, so the code above computes one by hand with fileURLToPath(import.meta.url) — the standard move in an ESM project.)
  2. Assemble the schema. buildSubgraphSchema([{ typeDefs, resolvers }]) — the key federation step, covered in the next section.
  3. Create the ApolloServer with a formatError hook that logs each error’s message, code, path and correlationId on the server side.
  4. Start the server, listening on 4000.
  5. Build a context for every request (covered in the previous module).

That formatError block deserves a close look: it returns formattedError unchanged and only logs on the way past. Which means whatever you put into extensions inside a resolver reaches the client — the precondition for “structured errors” doing any good at all.

§02

buildSubgraphSchema 凭空加了两个字段buildSubgraphSchema adds two fields you never wrote

这是 subgraph 和普通 GraphQL 服务唯一的技术差别。This is the only technical difference between a subgraph and a plain GraphQL service.

普通 GraphQL 服务用 makeExecutableSchema。 subgraph 用 buildSubgraphSchema(来自 @apollo/subgraph)。 后者多做三件事:

  1. 认识 federation 的 directive@key@external@shareable 等。 普通 schema 遇到它们会报「未知指令」。
  2. 自动加一个 _service 字段, 返回本 subgraph 的 federation SDL。 Router 启动时就是靠查这个字段来收集 schema 的。
  3. 自动加一个 _entities 字段, 接收一批 entity representation,返回对应的对象。 Router 在运行时靠它做跨服务的实体解析。

这两个字段你不用写,也不该写。但你要知道它们存在 —— 因为它们是本地验证 federation 的唯一入口。

A plain GraphQL service uses makeExecutableSchema. A subgraph uses buildSubgraphSchema (from @apollo/subgraph). The second one does three extra things:

  1. It understands the federation directives: @key, @external, @shareable and friends. A plain schema reports them as unknown directives.
  2. It adds a _service field for you, returning this subgraph’s federation SDL. That field is how the Router collects schemas at startup.
  3. It adds an _entities field for you, which takes a batch of entity representations and returns the matching objects. The Router uses it at runtime for cross-service entity resolution.

You do not write those two fields, and you should not. But you need to know they are there — they are the only door into verifying federation locally.

JavaScript关键的两行The two lines that matter源项目From source
1import { buildSubgraphSchema } from '@apollo/subgraph';
2
3const typeDefs = gql(readFileSync(join(__dirname, 'schema.graphql'), { encoding: 'utf-8' }));
4const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
Source: graphql-federation-practice/node-subgraph/src/index.js
§03

本地验证:两种办法Checking it locally: two ways

审计时端口 4000 被占,所以我用了第二种 —— 它其实更好用。During the audit port 4000 was taken, so I used the second way. It turns out to be the more useful one.

办法一:起服务器 + curl。npm start 之后服务器在 4000,index.js 最后还贴心地打印了 SDL 的查询地址。

办法二:进程内执行,不起服务器。直接用 buildSubgraphSchema 造出 schema, 再用 graphql() 执行查询。好处是不占端口、不需要等服务器起来、 可以在一个脚本里跑一串查询。

下面这个脚本是审计时我实际写的验证工具。 它把 federation 的关键路径全跑了一遍 —— 包括 _entities,也就是 Router 会发的那个请求。做完 Task 1 之后强烈建议你也写一个类似的。

Way one: start the server and curl it. After npm start the server is on 4000, and index.js even prints the URL that queries the SDL.

Way two: execute in-process, no server at all. Build the schema with buildSubgraphSchema and run queries through graphql(). No port to occupy, no waiting for a server to come up, and you can run a whole series of queries from one script.

The script below is the verification tool I actually wrote during the audit. It walks every important federation path — including _entities, the request the Router would send. After you finish Task 1, write yourself something like it.

Terminal办法一Way one已跑通Verified
1cd node-subgraph
2npm install
3npm start
4# → Subgraph ready at http://0.0.0.0:4000/
5# → Federation SDL available at http://0.0.0.0:4000/?query={_service{sdl}}
6
7# 另一个终端:
8curl -X POST http://localhost:4000/ \
9 -H 'Content-Type: application/json' \
10 -d '{"query":"{ _service { sdl } }"}'
1cd node-subgraph
2npm install
3npm start
4# → Subgraph ready at http://0.0.0.0:4000/
5# → Federation SDL available at http://0.0.0.0:4000/?query={_service{sdl}}
6
7# In another terminal:
8curl -X POST http://localhost:4000/ \
9 -H 'Content-Type: application/json' \
10 -d '{"query":"{ _service { sdl } }"}'
JavaScript办法二(审计时实际用的脚本)Way two (the script actually used in the audit)已跑通Verified
1// verify-schema.mjs —— 放在 node-subgraph/ 下,node verify-schema.mjs
2import { buildSubgraphSchema } from '@apollo/subgraph';
3import { graphql } from 'graphql';
4import gql from 'graphql-tag';
5import { readFileSync } from 'fs';
6import { resolvers, createShippingInfoLoader, createOrderLoader } from './src/resolvers/orderResolvers.js';
7import { OrderDataSource, InventoryDataSource, ShippingDataSource } from './src/dataSources/orderDataSource.js';
8
9const typeDefs = gql(readFileSync('./src/schema.graphql', 'utf-8'));
10const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
11
12function ctx() {
13 const orderDataSource = new OrderDataSource();
14 const inventoryDataSource = new InventoryDataSource();
15 const shippingDataSource = new ShippingDataSource();
16 return {
17 dataSources: { orderDataSource, inventoryDataSource, shippingDataSource },
18 loaders: {
19 shippingInfoLoader: createShippingInfoLoader(shippingDataSource),
20 orderLoader: createOrderLoader(orderDataSource),
21 },
22 correlationId: 'verify-1',
23 };
24}
25
26const run = (source, variableValues) =>
27 graphql({ schema, source, contextValue: ctx(), variableValues });
28
29// ① federation SDL 出得来吗
30const sdl = await run('{ _service { sdl } }');
31console.log('SDL:', !!sdl.data?._service?.sdl, '| errors:', sdl.errors?.length ?? 0);
32
33// ② 普通查询
34const q1 = await run('{ orders(userId:"123") { id status shippingInfo { status } } }');
35console.log('orders:', JSON.stringify(q1.data), q1.errors ?? '');
36
37// ③ Router 会发的那个请求:_entities
38const q2 = await run(
39 'query($r:[_Any!]!){ _entities(representations:$r) { ... on User { id orders { id } } } }',
40 { r: [{ __typename: 'User', id: '123' }] }
41);
42console.log('_entities:', JSON.stringify(q2.data), q2.errors ?? '');
1// verify-schema.mjs — put it in node-subgraph/, run: node verify-schema.mjs
2import { buildSubgraphSchema } from '@apollo/subgraph';
3import { graphql } from 'graphql';
4import gql from 'graphql-tag';
5import { readFileSync } from 'fs';
6import { resolvers, createShippingInfoLoader, createOrderLoader } from './src/resolvers/orderResolvers.js';
7import { OrderDataSource, InventoryDataSource, ShippingDataSource } from './src/dataSources/orderDataSource.js';
8
9const typeDefs = gql(readFileSync('./src/schema.graphql', 'utf-8'));
10const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
11
12function ctx() {
13 const orderDataSource = new OrderDataSource();
14 const inventoryDataSource = new InventoryDataSource();
15 const shippingDataSource = new ShippingDataSource();
16 return {
17 dataSources: { orderDataSource, inventoryDataSource, shippingDataSource },
18 loaders: {
19 shippingInfoLoader: createShippingInfoLoader(shippingDataSource),
20 orderLoader: createOrderLoader(orderDataSource),
21 },
22 correlationId: 'verify-1',
23 };
24}
25
26const run = (source, variableValues) =>
27 graphql({ schema, source, contextValue: ctx(), variableValues });
28
29// ① does the federation SDL come out
30const sdl = await run('{ _service { sdl } }');
31console.log('SDL:', !!sdl.data?._service?.sdl, '| errors:', sdl.errors?.length ?? 0);
32
33// ② a plain query
34const q1 = await run('{ orders(userId:"123") { id status shippingInfo { status } } }');
35console.log('orders:', JSON.stringify(q1.data), q1.errors ?? '');
36
37// ③ the request the Router will send: _entities
38const q2 = await run(
39 'query($r:[_Any!]!){ _entities(representations:$r) { ... on User { id orders { id } } } }',
40 { r: [{ __typename: 'User', id: '123' }] }
41);
42console.log('_entities:', JSON.stringify(q2.data), q2.errors ?? '');
注意 _entities 的参数类型是 [_Any!]!,每个 representation 必须带 __typename 和 @key 声明的字段。这段脚本在审计时真实跑通了全部三项。Note the argument type of _entities is [_Any!]!, and every representation must carry __typename plus the fields named in @key. This script really ran all three checks during the audit.
Text审计时的真实输出(参考解法下)The real output from the audit (with the reference answer)已跑通Verified
1$ node verify-schema.mjs
2
3== SDL emitted: true | errors: 0
4== SDL has @key: true
5== Query.orders + Order.shippingInfo: {"orders":[
6 {"id":"order-456","status":"SHIPPED","totalAmount":299.99,
7 "shippingInfo":{"status":"IN_TRANSIT","trackingNumber":"TRACK123456"}},
8 {"id":"order-457","status":"DELIVERED","totalAmount":89.99,
9 "shippingInfo":{"status":"DELIVERED","trackingNumber":"TRACK123457"}}]} | errors: []
10== Query.order not found code: [ 'ORDER_NOT_FOUND' ]
11== _entities User.orders: {"_entities":[{"id":"123","orders":[
12 {"id":"order-456","status":"SHIPPED"},{"id":"order-457","status":"DELIVERED"}]}]} | errors: []
13== Mutation.createOrder: {"createOrder":{"id":"order-1785737900978","userId":"789",
14 "status":"PENDING","totalAmount":299.98,
15 "items":[{"productId":"prod-789","quantity":2,"price":149.99}]}} | errors: []
16== createOrder empty items code: [ 'INVALID_INPUT' ]
这是 DrillLab 用来确认参考答案正确的证据。做完 Task 1 之后,你的实现应该能得到同样的输出。This is the evidence DrillLab used to confirm the reference answer is right. After you finish Task 1, your implementation should print the same thing.
§04

两个 ESM 细节Two ESM details

  • import 要带 .jsfrom './resolvers/orderResolvers.js' —— 这个项目是原生 ESM("type": "module"), 不走打包器,所以扩展名必须写。Foundations 那门课有个 Debug Lab 专门练这个。
  • 顶层 await 可以用。const { url } = await startStandaloneServer(...)写在模块顶层 —— 这是 ESM 才有的能力,CommonJS 里做不到。

另外注意 npm test 那条 script 里的NODE_OPTIONS=--experimental-vm-modules —— jest 要跑 ESM 就得带上它。这些配置不用你改,但要认得。

  • Imports need the .js. from './resolvers/orderResolvers.js' — this project is native ESM ("type": "module") with no bundler, so the extension is mandatory. The Foundations course has a Debug Lab just for this.
  • Top-level await works. const { url } = await startStandaloneServer(...) sits at module top level — an ESM-only ability, impossible in CommonJS.

Also notice the NODE_OPTIONS=--experimental-vm-modules in that npm test script — jest needs it to run ESM. None of this config is yours to change, but you should recognise it.

练习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_entities 这个字段是谁加的Who adds the _entities field

schema.graphql 里从头到尾没有出现_entities,但 Router 能查它。它从哪来?

_entities appears nowhere in schema.graphql, yet the Router can query it. Where does it come from?

先选一个选项Pick an option first
L1认出来Spot it本地怎么验证 federation 部分How to check the Federation part locally

仓库里没有 Router。你想确认自己的 User.orders在 federation 链路里能被正确调用。最直接的办法?

There is no Router in the repository. You want to confirm your User.orders is called correctly along the federation path. What is the most direct way?

先选一个选项Pick an option first
迁移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.

写 subgraphYou are writing a subgraph
用 buildSubgraphSchema,不是 makeExecutableSchemaUse buildSubgraphSchema, not makeExecutableSchema
想在本地验 federation 但没有 RouterYou want to check Federation locally but have no Router
进程内执行 _service 和 _entities 查询Run the _service and _entities queries inside the same process
context 里的键名不确定You are not sure of a key name in context
读 index.js 的 context 函数Read the context function in index.js
ESM 项目里 import 报 MODULE_NOT_FOUNDAn import throws MODULE_NOT_FOUND in an ESM project
补 .js 扩展名Add the .js extension
这节的要点What to take away
  1. 启动五步:读 schema → buildSubgraphSchema → 建 server → 监听 → 每请求造 context。Five startup steps: read the schema, call buildSubgraphSchema, create the server, listen, then build a context for each request.
  2. buildSubgraphSchema 认识 federation directive,并自动加 _service 和 _entities 两个字段。buildSubgraphSchema understands the Federation directives and adds the two fields _service and _entities for you.
  3. formatError 原样返回错误,所以你放进 extensions 的东西客户端能看到。formatError returns errors unchanged, so whatever you put in extensions reaches the client.
  4. 本地验证优选「进程内执行」:不占端口,能一次跑一串查询,包括 _entities。Prefer running queries inside the process: it needs no port and lets you run several queries in a row, including _entities.
  5. 原生 ESM:import 带 .js,顶层 await 可用,jest 需要 --experimental-vm-modules。Native ESM: imports need the .js extension, top-level await works, and jest needs --experimental-vm-modules.

接下来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 lessonentity、@key 与 __resolveReferenceentity, @key and __resolveReference
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 为什么会有 FederationWhy Federation exists