Skip to content
Linbudu's Blog

【outdated】Express-GraphQL 源码解读

1 min read

前言

最近在“认真”的学习 GraphQL,感觉自己之前学的还是太浅了,很多现在看来是核心特性的当时都不知道...,比如 Subscription 操作,还有 TypeGraphQL 的强大能力(等我再强一点一定要给这个项目做贡献!翻译文档也行!)等等。

会想着看这东西源码是因为目前似乎我能看懂的就这个,其他无论是 Apollo-Server 还是 TypeGraphQL 都是大项目,更别说原生 GraphQLJS 了,真的是一个巨巨巨巨大的项目。

现在还在做两件 GraphQL 相关的事情,GraphQL-ExplorerGraphQL-Lessons,前者熟悉各个相关生态(Apollo、TypeGraphQL、TypeORM、TypeStack 等等)的能力使用,后者在准备系列文章的同时也把基础打扎实。

不过话说回来,GraphQL 的学习成本这么低,真的需要系列教程吗...

fieldResolver

与 TypeGraphQL 中的思路类似,但实际效果不同。如果指定了 fieldResolver,就相当于你要自己处理 resolver 和 field 的字段对应关系了,还有联合类型、嵌套类型的情况也需要自己处理。唯一的用处是一个兜底作用的 resolver,也就是你查询的字段没有对应的 resolver(但仍然必须在 schema 中定义),所以我觉得在使用 Express-GraphQL 的情况下,如果没有对兜底 resolver 的强烈需求,不要指定这个函数,不然真的太太太麻烦了。

TypeGraphQL 中的@FieldResolver是和@Query @Mutation平级的操作,通常是某个需要额外操作(计算/请求数据)的字段,通常会和@Root一起使用,后者用来注入整个查询对象,比如:

1class UserResolver {
2 @FieldResolver()
3 async spAgeField(
4 @Root() user: User,
5 @Arg("param", { nullable: true }) param?: number
6 ): Promise<number> {
7 // ... do sth addtional here
8 return user.age;
9 }
10}

简单的情况也可以直接定义在ObjectType里面,见field-resolver

但在这里的 fieldResolver 就有点麻烦了:

1{
2 // ...
3 fieldResolver: (src, args, context, info) => {
4 const isUnionType = ['A', 'B'].includes(info.path.typename);
5 const isNestedType = ['Nested', 'NestedAgain'].includes(
6 info.path.typename,
7 );
8 if (info.fieldName in src && !isUnionType && !isNestedType) {
9 return src[info.fieldName]();
10 } else if (isUnionType) {
11 return info.path.typename;
12 } else if (isNestedType) {
13 return src[info.fieldName];
14 }
15 return 'DEFAULT_RESOLVER_RESULT';
16 },
17}
  • src即为传入的 rootValue 值(也就是 resolver 组)

  • 联合类型(Union Type)会被处理两次,首次 src 值为 rootValue,第二次为本次联合类型子类型的值,注意,typeResolver会在中间被调用!可以看到这里的处理是判断为联合类型的情况,就直接返回这个值(也就是info.path.typename)。

    比如首次是:

    1{
    2 hello: [Function: hello],
    3 // 联合类型
    4 guess: [Function: guess],
    5 nest: [Function: nest]
    6}

    第二次就是:{ fieldB: 'bbb' }

  • 嵌套类型类似,也会被处理多次(次数和层级有关),并且在最后一级时src就包括了值,直接返回即可。

  • 嵌套类型和联合类型这里都需要根据info.path.typename进行判断,确实很不优雅,所以不建议使用这个。

typeResolver

和 Apollo 中一样,但 Apollo 中是和 Query 和 Mutation 同级的,放在 resolvers 选项中。

使用方式一样,根据 resolver 返回的值判断是属于哪个类型(通常是通过包含的字段来判断)。

1{
2 typeResolver: (value, ctx, info, absType) => {
3 return value.fieldA ? 'A' : 'B';
4 },
5}
  • absType,即AbstractType,本次待解析的联合类型。

源码全流程解析

分为几个部分:

  • 整体架构
  • 参数解析
  • 执行
  • GraphiQL 响应

整体架构

先看看它是怎么被使用的:

1import express from "express";
2import { buildSchema } from "graphql";
3
4import { graphqlHTTP } from "../src";
5
6const schema = buildSchema(`
7 type Query {
8 hello: String
9 }
10`);
11
12const rootValue = {
13 hello: () => "Hello world!",
14};
15
16const app = express();
17
18app.use(
19 "/graphql",
20 graphqlHTTP({
21 schema,
22 rootValue,
23 graphiql: true,
24 })
25);
26app.listen(4000);

很明显,graphqlHTTP()方法返回一个 Express 中间件函数((req, res, next) => {}这样),并且只在/graphql下生效。

它接收schemarootValuegraphiql参数,这里只有rootValue可能会让你感到困惑,实际上你就把它理解为resolvers(Apollo 中的那样)就好了。

顺便把它能接受的参数都看看:

  • schema
  • context: 在所有 resolver(包括 typeResolver 和 fieldResolver)、extensions 的函数中共享的上下文。不要在任意一个地方去修改它
  • rootValue: Apollo 中的 rootValue 和这里的完全不一样。Apollo 中此参数会被作为 resolvers 链的首个成员的 parent 参数(后续成员的 parent 参数则是上一级的 resolver 返回值)。

    rootValue 是{ [key:string]: Function }的形式,它的函数只有三个参数(少了 parent):args context info

  • pretty: 是否格式化输出
  • validationRules customValidateFn,schema 逻辑验证相关
  • customExecuteFn: 这个有丶 🐂,直接覆盖原生 GraphQL 的execute方法
  • customFormatErrorFn formatError: 错误处理相关
  • customParseFn: 覆盖 GraphQL 的 SDL 解析函数
  • extensions: 没啥好说的,就是原生extensions
  • graphiql: 是否开启 GraphiQL 调试面板,没有 Apollo 的 playground 好用

    Gatsby 的 GraphiQL 是在原生基础上增强的,提供了点击字段来添加到查询语句的能力

  • fieldResolver 与 typeResolver 见上面的

入口方法:

接下来的逻辑大部分都在这个方法内

1export function graphqlHTTP(options: Options): Middleware {
2 return async function graphqlMiddleware(
3 request: Request,
4 response: Response
5 ): Promise<void> {};
6}

首先配置参数:

1let params: GraphQLParams | undefined;
2let showGraphiQL = false;
3let graphiqlOptions;
4// 这一方法来自于原生GraphQL导出
5let formatErrorFn = formatError;
6let pretty = false;
7let result: ExecutionResult;

解析参数:getGraphQLParams方法

会把整个请求体塞进来解析、提取、格式化信息,最后返回能交给 GraphQLJS 处理的参数格式。 这里就是普通的请求处理逻辑

1params = await getGraphQLParams(request);
2
3export async function getGraphQLParams(
4 request: Request
5): Promise<GraphQLParams> {
6 const urlData = new URLSearchParams(request.url.split("?")[1]);
7 const bodyData = await parseBody(request);
8
9 // 查询语句
10 let query = urlData.get("query") ?? (bodyData.query as string | null);
11 if (typeof query !== "string") {
12 query = null;
13 }
14
15 // 变量
16 let variables = (urlData.get("variables") ?? bodyData.variables) as {
17 readonly [name: string]: unknown;
18 } | null;
19 if (typeof variables === "string") {
20 try {
21 variables = JSON.parse(variables);
22 } catch {
23 throw httpError(400, "Variables are invalid JSON.");
24 }
25 } else if (typeof variables !== "object") {
26 variables = null;
27 }
28
29 // 操作名称
30 let operationName =
31 urlData.get("operationName") ?? (bodyData.operationName as string | null);
32 if (typeof operationName !== "string") {
33 operationName = null;
34 }
35
36 // 原始信息
37 const raw = urlData.get("raw") != null || bodyData.raw !== undefined;
38
39 return { query, variables, operationName, raw };
40}

请求解析:parseBody

1export async function parseBody(
2 req: Request
3): Promise<{ [param: string]: unknown }> {
4 const { body } = req;
5 // contentType.parse(req.headers['content-type'])的简写
6 // 解析结果包括type与parameters 如
7 // 'image/svg+xml; charset=utf-8' -> { type: 'image/svg+xml', parameters: {charset: 'utf-8'} }
8 const typeInfo = contentType.parse(req);
9
10 // application/graphql似乎不是常用的MIME类型 官方文档也把这个说明移除了
11 // 应当被appilication/json替代
12 if (typeof body === "string" && typeInfo.type === "application/graphql") {
13 return { query: body };
14 }
15
16 // 获取原始body内容,对不同的请求头使用不同的解析方式
17 const rawBody = await readBody(req, typeInfo);
18 switch (typeInfo.type) {
19 case "application/graphql":
20 return { query: rawBody };
21 case "application/json":
22 if (jsonObjRegex.test(rawBody)) {
23 try {
24 return JSON.parse(rawBody);
25 } catch {
26 // Do nothing
27 }
28 }
29 // status error properties
30 throw httpError(400, "POST body sent invalid JSON.");
31 case "application/x-www-form-urlencoded":
32 // parse(str) foo=bar&abc=xyz&abc=123 ->
33 // {
34 // foo: 'bar',
35 // abc: ['xyz', '123']
36 // }
37 return querystring.parse(rawBody);
38 }
39
40 return {};
41}

获取原始 body 内容:readBody

1async function readBody(
2 req: Request,
3 // {type:"xxx", parameters:"xxx"}
4 typeInfo: ParsedMediaType
5): Promise<string> {
6 // 获取mime的chartset属性
7 const charset = typeInfo.parameters.charset?.toLowerCase() ?? "utf-8";
8
9 // Get content-encoding (e.g. gzip)
10 // 内容编码格式 gzip deflate identity(没有对实体进行编码) ...
11 // 服务器会依据此信息进行解压
12 // 服务端返回未压缩的正文时 不允许返回此字段
13 const contentEncoding = req.headers["content-encoding"];
14
15 const encoding =
16 typeof contentEncoding === "string"
17 ? contentEncoding.toLowerCase()
18 : // 这种情况是没有带上content-enconding头 也就是没有处理
19 "identity";
20
21 // 正文未压缩时直接读取正文长度
22 const length = encoding === "identity" ? req.headers["content-length"] : null;
23 const limit = 100 * 1024; // 100kb
24
25 // 这个方法把请求解压后塞到流里
26 const stream = decompressed(req, encoding);
27
28 // 再从流里读出来请求体内容
29 try {
30 // charset 默认为utf-8 使用对应的content-encoding解码
31 // length 流的长度 目标长度没有达到时会报400错误 默认为null 在编码identity时为content-length的值
32 // limit 100kb body的字节数限定 如果body超出这个大小 会报413错误
33 return await getBody(stream, { encoding: charset, length, limit });
34 } catch (rawError) {
35 const error = httpError(
36 400,
37 rawError instanceof Error ? rawError : String(rawError)
38 );
39
40 error.message =
41 error.type === "encoding.unsupported"
42 ? `Unsupported charset "${charset.toUpperCase()}".`
43 : `Invalid body: ${error.message}.`;
44 throw error;
45 }
46}
47
48// 解压流
49function decompressed(
50 req: Request,
51 encoding: string
52): Request | Inflate | Gunzip {
53 switch (encoding) {
54 case "identity":
55 return req;
56 case "deflate":
57 // readable.pipe(writable)
58 return req.pipe(zlib.createInflate());
59 case "gzip":
60 return req.pipe(zlib.createGunzip());
61 }
62 throw httpError(415, `Unsupported content-encoding "${encoding}".`);
63}

到这里已经 get 了本次请求的参数,并转化为了 GraphQL 能够解析的格式。

解析配置:

1// 有可能接收Promise类型或返回Promise类型的参数 这里就是简单地等待其执行完毕
2// 比如TypeGraphQL的buildSchem默认就是异步的
3const optionsData: OptionsData = await resolveOptions(params);
4
5const schema = optionsData.schema;
6const rootValue = optionsData.rootValue;
7const validationRules = optionsData.validationRules ?? [];
8// ... 类似的逻辑

判断请求方法:只支持 GET 和 POST 方法:

1if (request.method !== "GET" && request.method !== "POST") {
2 throw httpError(405, "GraphQL only supports GET and POST requests.", {
3 headers: { Allow: "GET, POST" },
4 });
5}

判断下是否要返回 GraphiQL(或者说通过 GraphiQL 进行数据返回) 这里在后面的专门部分讲

1const { query, variables, operationName } = params;
2
3showGraphiQL = canDisplayGraphiQL(request, params) && graphiql !== false;
4if (typeof graphiql !== "boolean") {
5 graphiqlOptions = graphiql;
6}
7
8if (query == null) {
9 if (showGraphiQL) {
10 return respondWithGraphiQL(response, graphiqlOptions);
11 }
12 throw httpError(400, "Must provide query string.");
13}

验证 schema 是否合法、解析 AST、验证 AST 是否合法,这里就不展示代码了。

自定义规则会和内置规则进行合并

对于请求方法为 GET 的情况再进行一次处理

只有 query 操作可以通过 GET 执行,但通常也不会使用

这里的逻辑主要是检测操作是否是除了 query 以外的类型,如果不是 query,就直接把 params 塞进 GraphiQL 返回,给请求者自己执行(在其中执行操作都是以 POST 请求)。

如果不能展示 GraphiQL,就报错

1// Only query operations are allowed on GET requests.
2// GET请求只能走query操作,类似RESTFul规范
3if (request.method === "GET") {
4 // Determine if this GET request will perform a non-query.
5 const operationAST = getOperationAST(documentAST, operationName);
6 if (operationAST && operationAST.operation !== "query") {
7 // If GraphiQL can be shown, do not perform this query, but
8 // provide it to GraphiQL so that the requester may perform it
9 // themselves if desired.
10 // PUZZLE: 如果此时开启了GraphiQL选项 那么就把内容返回给GraphiQL 供请求者自己执行
11 if (showGraphiQL) {
12 return respondWithGraphiQL(response, graphiqlOptions, params);
13 }
14
15 // Otherwise, report a 405: Method Not Allowed error.
16 throw httpError(
17 405,
18 `Can only perform a ${operationAST.operation} operation from a POST request.`,
19 { headers: { Allow: "POST" } }
20 );
21 }
22}

然后就是最最最重要的执行部分了,这里的代码反而很少,因为要么执行成功拿到结果,要么执行失败报错就完事了

类似的,extension处理起来也很简单。

1try {
2 result = await executeFn({
3 schema,
4 document: documentAST,
5 rootValue,
6 contextValue: context,
7 variableValues: variables,
8 operationName,
9 fieldResolver,
10 typeResolver,
11 });
12} catch (contextError) {
13 throw httpError(400, "GraphQL execution context error.", {
14 graphqlErrors: [contextError],
15 });
16}
17
18if (extensionsFn) {
19 const extensions = await extensionsFn({
20 document: documentAST,
21 variables,
22 operationName,
23 result,
24 context,
25 });
26
27 if (extensions != null) {
28 result = { ...result, extensions };
29 }
30}

然后后面主要就是错误处理了,有几个地方还是需要注意一下:

1// 空数据表示运行时查询错误
2if (response.statusCode === 200 && result.data == null) {
3 response.statusCode = 500;
4}
5
6// 请求中可能带着错误,比如部分字段查询错误
7const formattedResult: FormattedExecutionResult = {
8 ...result,
9 errors: result.errors?.map(formatErrorFn),
10};
11
12// 在能显示GraphiQL时通过其返回
13if (showGraphiQL) {
14 return respondWithGraphiQL(
15 response,
16 graphiqlOptions,
17 params,
18 formattedResult
19 );
20}

然后就可以返回请求了:

1if (!pretty && typeof response.json === "function") {
2 response.json(formattedResult);
3} else {
4 const payload = JSON.stringify(formattedResult, null, pretty ? 2 : 0);
5 sendResponse(response, "application/json", payload);
6}
7
8function sendResponse(response: Response, type: string, data: string): void {
9 const chunk = Buffer.from(data, "utf8");
10 response.setHeader("Content-Type", type + "; charset=utf-8");
11 response.setHeader("Content-Length", String(chunk.length));
12 response.end(chunk);
13}

然后就 done~

GraphiQL

这个确实牛皮,比如loadFileStaticallyFromNPM这个方法,在返回的模板字符串里面直接执行:

核心还是来自于graphiql这个包,我还以为是写在里面的...

1export function renderGraphiQL(
2 data: GraphiQLData,
3 options?: GraphiQLOptions,
4): string {
5 // ...
6 return `
7<!DOCTYPE html>
8<html>
9<head>
10 <style>
11 /* graphiql/graphiql.css */
12 ${loadFileStaticallyFromNPM('graphiql/graphiql.css')}
13 </style>
14 <script>
15 // promise-polyfill/dist/polyfill.min.js
16 ${loadFileStaticallyFromNPM('promise-polyfill/dist/polyfill.min.js')}
17 </script>
18 <script>
19 // unfetch/dist/unfetch.umd.js
20 ${loadFileStaticallyFromNPM('unfetch/dist/unfetch.umd.js')}
21 </script>
22 <script>
23 // react/umd/react.production.min.js
24 ${loadFileStaticallyFromNPM('react/umd/react.production.min.js')}
25 </script>
26 <script>
27 // react-dom/umd/react-dom.production.min.js
28 ${loadFileStaticallyFromNPM('react-dom/umd/react-dom.production.min.js')}
29 </script>
30 <script>
31 // graphiql/graphiql.min.js
32 ${loadFileStaticallyFromNPM('graphiql/graphiql.min.js')}
33 </script>
34</head>`

然后在这之间其实还进行了一些逻辑:

1// data 供渲染的数据
2const queryString = data.query;
3// 这里的变量与结果是用于呈现的
4const variablesString =
5 data.variables != null ? JSON.stringify(data.variables, null, 2) : null;
6const resultString =
7 data.result != null ? JSON.stringify(data.result, null, 2) : null;
8const operationName = data.operationName;
9const defaultQuery = options?.defaultQuery;
10const headerEditorEnabled = options?.headerEditorEnabled;

返回的 html 中实际上包括了 JS:

1var parameters = {};
2
3 // 处理URL中的参数,比如在IQL加载时URL就带着query参数的
4 window.location.search.substr(1).split('&').forEach(function (entry) {
5 var eq = entry.indexOf('=');
6 if (eq >= 0) {
7 parameters[decodeURIComponent(entry.slice(0, eq))] =
8 decodeURIComponent(entry.slice(eq + 1));
9 }
10 });
11
12 // 组装成一次本地查询
13 function locationQuery(params) {
14 return '?' + Object.keys(params).filter(function (key) {
15 return Boolean(params[key]);
16 }).map(function (key) {
17 return encodeURIComponent(key) + '=' +
18 encodeURIComponent(params[key]);
19 }).join('&');
20 }
21
22
23 var graphqlParamNames = {
24 query: true,
25 variables: true,
26 operationName: true
27 };
28
29 var otherParams = {};
30 for (var k in parameters) {
31 if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
32 otherParams[k] = parameters[k];
33 }
34 }
35 var fetchURL = locationQuery(otherParams);
36
37
38 // 负责获取数据的函数
39 function graphQLFetcher(graphQLParams, opts) {
40 return fetch(fetchURL, {
41 method: 'post',
42 headers: Object.assign(
43 {
44 'Accept': 'application/json',
45 'Content-Type': 'application/json'
46 },
47 opts && opts.headers,
48 ),
49 body: JSON.stringify(graphQLParams),
50 credentials: 'include',
51 }).then(function (response) {
52 return response.json();
53 });
54 }
55
56 function onEditQuery(newQuery) {
57 parameters.query = newQuery;
58 updateURL();
59 }
60
61 function onEditVariables(newVariables) {
62 parameters.variables = newVariables;
63 updateURL();
64 }
65
66 function onEditOperationName(newOperationName) {
67 parameters.operationName = newOperationName;
68 updateURL();
69 }
70
71 function updateURL() {
72 history.replaceState(null, null, locationQuery(parameters));
73 }
74
75 // 渲染GraphiQL组件 好家伙!
76 ReactDOM.render(
77 React.createElement(GraphiQL, {
78 fetcher: graphQLFetcher,
79 onEditQuery: onEditQuery,
80 onEditVariables: onEditVariables,
81 onEditOperationName: onEditOperationName,
82 query: ${safeSerialize(queryString)},
83 response: ${safeSerialize(resultString)},
84 variables: ${safeSerialize(variablesString)},
85 operationName: ${safeSerialize(operationName)},
86 defaultQuery: ${safeSerialize(defaultQuery)},
87 headerEditorEnabled: ${safeSerialize(headerEditorEnabled)},
88 }),
89 document.getElementById('graphiql')
90 );