— 1 min read
最近在“认真”的学习 GraphQL,感觉自己之前学的还是太浅了,很多现在看来是核心特性的当时都不知道...,比如 Subscription 操作,还有 TypeGraphQL 的强大能力(等我再强一点一定要给这个项目做贡献!翻译文档也行!)等等。
会想着看这东西源码是因为目前似乎我能看懂的就这个,其他无论是 Apollo-Server 还是 TypeGraphQL 都是大项目,更别说原生 GraphQLJS 了,真的是一个巨巨巨巨大的项目。
现在还在做两件 GraphQL 相关的事情,GraphQL-Explorer 和 GraphQL-Lessons,前者熟悉各个相关生态(Apollo、TypeGraphQL、TypeORM、TypeStack 等等)的能力使用,后者在准备系列文章的同时也把基础打扎实。
不过话说回来,GraphQL 的学习成本这么低,真的需要系列教程吗...
与 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?: number6 ): Promise<number> {7 // ... do sth addtional here8 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
进行判断,确实很不优雅,所以不建议使用这个。
和 Apollo 中一样,但 Apollo 中是和 Query 和 Mutation 同级的,放在 resolvers 选项中。
使用方式一样,根据 resolver 返回的值判断是属于哪个类型(通常是通过包含的字段来判断)。
1{2 typeResolver: (value, ctx, info, absType) => {3 return value.fieldA ? 'A' : 'B';4 },5}
absType
,即AbstractType
,本次待解析的联合类型。分为几个部分:
先看看它是怎么被使用的:
1import express from "express";2import { buildSchema } from "graphql";3
4import { graphqlHTTP } from "../src";5
6const schema = buildSchema(`7 type Query {8 hello: String9 }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
下生效。
它接收schema
、rootValue
、graphiql
参数,这里只有rootValue
可能会让你感到困惑,实际上你就把它理解为resolvers
(Apollo 中的那样)就好了。
顺便把它能接受的参数都看看:
rootValue 是
{ [key:string]: Function }
的形式,它的函数只有三个参数(少了 parent):args
context
info
execute
方法extensions
Gatsby 的 GraphiQL 是在原生基础上增强的,提供了点击字段来添加到查询语句的能力
入口方法:
接下来的逻辑大部分都在这个方法内
1export function graphqlHTTP(options: Options): Middleware {2 return async function graphqlMiddleware(3 request: Request,4 response: Response5 ): 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: Request5): 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: Request3): 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 nothing27 }28 }29 // status error properties30 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: ParsedMediaType5): 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; // 100kb24
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: string52): 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, but8 // provide it to GraphiQL so that the requester may perform it9 // 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 formattedResult19 );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~
这个确实牛皮,比如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.js16 ${loadFileStaticallyFromNPM('promise-polyfill/dist/polyfill.min.js')}17 </script>18 <script>19 // unfetch/dist/unfetch.umd.js20 ${loadFileStaticallyFromNPM('unfetch/dist/unfetch.umd.js')}21 </script>22 <script>23 // react/umd/react.production.min.js24 ${loadFileStaticallyFromNPM('react/umd/react.production.min.js')}25 </script>26 <script>27 // react-dom/umd/react-dom.production.min.js28 ${loadFileStaticallyFromNPM('react-dom/umd/react-dom.production.min.js')}29 </script>30 <script>31 // graphiql/graphiql.min.js32 ${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: true27 };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 );