前言
在上一篇文章基礎篇中,我們介紹了GraphQL的語法以及類型系統,算是對GraphQL有個基本的認識。在這一篇中,我們將會介紹GraphQL的實現原理。說到原理,我們就不得不依托於GraphQL的規范:GraphQL
概述
GraphQL規范主體部分有6大部分,除去我們在上一節講到的類型系統(Type System)和語言(Language),剩下的便是整個GraphQL的主流程。也就是如下圖所示的:
根據規范的章節,也就是GraphQL的實現流程,我們原理篇一一來看看規范到底定義了些什么,以及在實際的使用中,是如何貼近到規范的實現的。
Js語言的實現版本是: graphql-js
流程總覽
首先我們肯定會在客戶端上書寫查詢語句,查詢語句在發送到服務端之前會轉換為標准的請求體。以之前的demo為例子,當我們發起如下的請求的時候:
客戶端發起的請求體應該具備以下三個字段(POST請求):
{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}
截圖如下:
這些參數表達了客戶端的訴求:調用哪個方法,傳遞什么樣的參數,返回哪些字段。
服務端拿到這段Schema之后,通過事先定義好的服務端Schema接收請求參數,校驗參數,然后執行對應的resolver函數,執行完成返回數據。
在express-graphql這個包我們可以看到服務端的整體處理流程,縮略如下:
... function graphqlHTTP(options: Options): Middleware { ... // 返回express的中間件形式的函數 return function graphqlMiddleware(request: $Request, response: $Response) { ... // 處理request的參數,解析出來 return getGraphQLParams(request) .then( graphQLParams => {}, // 解析成功 error => {} // 解析失敗 ) .then( optionsData => { ... // GraphQL只支持GET/POST方法 if (request.method !== 'GET' && request.method !== 'POST') { response.setHeader('Allow', 'GET, POST'); throw httpError(405, 'GraphQL only supports GET and POST requests.'); } ... // 校驗服務端這邊定義的Schema const schemaValidationErrors = validateSchema(schema); ... // 根據query生成GraphQL的Source const source = new Source(query, 'GraphQL request'); // 根據Source生成AST try { documentAST = parseFn(source); } catch (syntaxError) { // Return 400: Bad Request if any syntax errors errors exist. response.statusCode = 400; return { errors: [syntaxError] }; } // 校驗AST const validationErrors = validateFn(schema, documentAST, [ ...specifiedRules, ...validationRules, ]); ... // 檢查GET請求方法是否在Query操作符上 if (request.method === 'GET') {...} // 執行resolver函數 } ) .then(result => { ... // 處理GraphQL返回的響應體,做些額外的工作。 }) } }
更多細節請查看源碼。
自省(Introspection)
GraphQL服務器支持根據自己的schema進行自省。這對於我們想要查詢一些關心的信息很有用。比如我們可以查詢demo的一些關心的類型:
根據規范,有兩類自省系統:類型名稱自省(typename)和schema自省(schema和__type)。
__typename
GraphQL支持在一個查詢中任何一個節點添加對類型名稱的自省,在識別Interface/Union類型實際使用的類型的時候比較常用,在上圖演示,我們可以看到每個節點都可以添加__typename
,返回的類型也有很多:__Type
、__Field
、__InputValue
、__Schema
帶有__
的都是GraphQL規范內部定義的類型,屬於保留名稱。開發者自定義的類型不允許出現__
字符,否則在語法校驗的時候會失敗。
舉個例子:
將demo中的type Message
改為type __Message
,然后會報此類錯誤:
Name \"__Message\" must not begin with \"__\", which is reserved by GraphQL introspection.
schema&type
__schema
可以用來查詢系統當前支持的所有語法,包括query語法、mutation語法,看它的結構就知道了:
type __Schema { types: [__Type!]! => 查詢系統當前定義的所有類型,包括自定義的和內部定義的所有類型 queryType: __Type! => 查詢 type Query {} 里面所有的查詢方法 mutationType: __Type => 查詢 type Mutation {} 里面所有的mutation方法 subscriptionType: __Type => 查詢 type Subscription {} 里面所有subscription方法 directives: [__Directive!]! => 查詢系統支持的指令 }
而__type
則是用來查詢指定的類型屬性。關於這些類型的內部定義請參考:Schema Introspection
上圖基於的Message類型是這樣的:
"""消息列表"""
type Message {
"""文章ID"""
id: ID!
"""文章內容"""
content: String
"""作者"""
author: String
"""廢棄的字段"""
oldField: String @deprecated(reason: "Use \`newField\`.")
}
Tips: 因為有了自省系統,GraphiQL才有可能在你輸入查詢信息地時候進行文字提示,因為在頁面加載的時候GraphiQL會去請求這些內容,請求的內容可以看這個文件:introspectionQueries.js
校驗
在上面的流程總覽中提到,客戶端發起的請求query字段帶有查詢的語法,這段語法要先經過校驗,我們以下面最簡單的一次查詢為例:
{ getMessage { content author } }
解析出來的請求參數數據是:
{ query: '{\n getMessage {\n content\n author\n }\n}', variables: null, operationName: null, raw: false }
之后先是校驗服務端定義的schema:validateSchema(schema)
,上一節的那個錯誤就是在這邊拋出的。
接着將客戶端的query轉為Source
類型的結構:
{ body: '{\n getMessage {\n content\n author\n }\n}', name: 'GraphQL request', locationOffset: { line: 1, column: 1 } }
接着轉為AST:graphql.parse
,graphql-js
根據特征字符串:
export const TokenKind = Object.freeze({ SOF: '<SOF>', EOF: '<EOF>', BANG: '!', DOLLAR: '$', AMP: '&', PAREN_L: '(', PAREN_R: ')', SPREAD: '...', COLON: ':', EQUALS: '=', AT: '@', BRACKET_L: '[', BRACKET_R: ']', BRACE_L: '{', PIPE: '|', BRACE_R: '}', NAME: 'Name', INT: 'Int', FLOAT: 'Float', STRING: 'String', BLOCK_STRING: 'BlockString', COMMENT: 'Comment', });
對source逐一解析生成lexer,再執行parseDocument
生成解析階段的產出物document
:
{ "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "variableDefinitions": [], "directives": [], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "getMessage", "loc": { "start": 4, "end": 14 } }, "arguments": [], "directives": [], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "content", "loc": { "start": 21, "end": 28 } }, "arguments": [], "directives": [], "loc": { "start": 21, "end": 28 } }, { "kind": "Field", "name": { "kind": "Name", "value": "author", "loc": { "start": 33, "end": 39 } }, "arguments": [], "directives": [], "loc": { "start": 33, "end": 39 } } ], "loc": { "start": 15, "end": 43 } }, "loc": { "start": 4, "end": 43 } }], "loc": { "start": 0, "end": 45 } }, "loc": { "start": 0, "end": 45 } }], "loc": { "start": 0, "end": 45 } }
其中AST支持的kind可以參考這里的定義: kinds.js
如果同時有多段語法,比如:
query gm($id: ID) { all: getMessage { content author } single: getMessage(id: $id) { content author } } mutation cr($input: MessageInput) { createMessage(input: $input) { id } }
那么生成的documentAST就是:
[ { kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'gm', loc: [Object] }, variableDefinitions: [ [Object] ], directives: [], selectionSet: { kind: 'SelectionSet', selections: [Array], loc: [Object] }, loc: { start: 0, end: 128 } }, { kind: 'OperationDefinition', operation: 'mutation', name: { kind: 'Name', value: 'cr', loc: [Object] }, variableDefinitions: [ [Object] ], directives: [], selectionSet: { kind: 'SelectionSet', selections: [Array], loc: [Object] }, loc: { start: 129, end: 210 } } ]
這種情況下,必須提供一個operationName來確定操作的是哪個document!該字段也就是我們在最開始說的請求的數據中的operationName
,這些校驗都發聲在源碼的buildExecutionContext
方法內
接着執行校驗的最后一個步驟:校驗客戶端語法並給出合理的解釋, graphql.validate(schema, documentAST, validationRules)
,比如我在將query語句變更為:
{ getMessage1 { content author } }
graphql-js
就會校驗不通過,並且給出對應的提示:
{ "errors": [ { "message": "Cannot query field \"getMessage1\" on type \"Query\". Did you mean \"getMessage\"?", "locations": [ { "line": 2, "column": 3 } ] } ] }
這種結構化的報錯信息也是GraphQL的一大特點,定位問題非常方便。只要語法沒問題校驗階段就能順利完成。
執行階段
graphql.execute
是實現GraphQL規范的Execute
章節的內容。根據規范,我們將執行階段分為:
每個階段解釋一下:
- Validating Requests:到這個階段的校驗其實已經很少了,在源碼實現上只需要校驗入參是否符合規范即可,對應源碼的方法是:
assertValidExecutionArguments
- Coercing Variable Values:檢查客戶端請求變量的合法性,需要和schema進行比對,對應源碼的方法是:
getVariableValues
- Executing Operations:執行客戶端請求的方法與之對應的resolver函數。對應源碼的方法是:
executeOperation
- Executing Selection Sets:搜羅客戶端請求需要返回的字段集合,對應源碼的方法是:
collectFields
- Executing Fields:執行每個字段,需要進行遞歸,對應源碼的方法是:
executeFields
接下去我們大概講解下每個過程的一些要點
Validating Requests
源碼中校驗了入參的三個:schema/document/variables
Coercing Variable Values
如果該操作定義了入參,那么這些變量的值需要強制與方法聲明的入參類型進行比對。比對不通過,直接報錯,比如我們將getMessage
改為這樣:
query getMessage($id: ID){ getMessage(id: $id) { content author } }
然后變量是:
{ "id": [] }
那么經過這個函數將會報錯返回:"Variable \"$id\" got invalid value []; Expected type ID; ID cannot represent value: []"
Executing Operations => Executing Selection Sets => Executing Fields
在該流程上區分operation是query還是mutation,二者執行的方式前者是並行后者是串行。
整體流程如下所示:
在圖中標注的輸出的第一次執行的數據如下,僅供參考,流程圖以demo中的getMessage
為例子所畫,其中粉紅色是第一次波執行的流程,也就是解析getMessage這個字段所走的流程,以completeValueCatchingError為界限是開始遍歷[Message]中的Message,這個流程以紫色線標注,如此往復遞歸,遍歷完客戶端要求的所有數據為止
- collectFields{ getMessage: [{ kind: 'Field', alias: undefined, name: [Object], arguments: [], directives: [], selectionSet: [Object], loc: [Object] }] }
- resolveFieldValueOrError 其入參第一次傳進去的source是:{ getMessage: [Function: getMessage], createMessage: [Function: createMessage], updateMessage: [Function: updateMessage] }
第一次執行返回的結果:[ { id: 0, content: 'test content', author: 'pp' }, { id: 1, content: 'test content 1', author: 'pp1' } ] - completeValueCatchingError[ { content: 'test content', author: 'pp' }, { content: 'test content 1', author: 'pp1' } ]
整個流程以getMessage這個字段名稱為起點,執行resolver函數,得到結果,因為返回類型是[Message]
,所以會對該返回類型先進行數組解析,再解析數組里面的字段,以此不斷重復遞歸,直到獲取完客戶端指定的所有字段。圖形化的流程我在圖中用標號和顏色標注,應該很容易看懂整個流程的。
執行resolver函數的選擇
在這里回答demo中提到的問題,一種寫法是將schema和resolve分別傳入schema和rootValue兩個字段內,另外一種寫法是使用graphql-tools
將typedefs和resolvers轉換成帶有resolve
字段的schema,二者寫法都是可行的,原因如下:
首先代碼會給系統默認的fieldResolver
賦值一個defaultFieldResolver
函數,如果fieldResolver
沒有傳值的話,這里明顯沒有傳值。
之后在選擇resolver函數執行的時候有這么一段代碼來實現了上述兩種寫法的可行性(resolveField.js
):
const resolveFn = fieldDef.resolve || exeContext.fieldResolver;
這樣就優先使用schema定義的resolve函數,沒有的話就使用了rootValue傳遞的resolver函數了。因此執行的不一樣的話導致resolver函數獲取參數的方式略微不同:
第一種入參是:(args, contextValue, info) 第二種入參是:(source, args, contextValue, info) => 也就是此時你想要獲取參數的話得從第二個字段開始
Response
Response步驟就很簡單了,定義了4個規則:
1、響應體必須是一個對象
2、如果執行operation錯誤的時候,那么errors
必須存在,否則不應該有這個字段
2.1. `error`字段是一個數組對象,對象里面必須包含一個`message`的字段來描述錯誤的原因以及一些提示
2.2. 另外可能包含的字段有`location`、`path`、`extensions`來提示開發者錯誤的具體信息。
3、如果執行operation沒有錯誤,那么data
字段必須有值
4、其他自定義的信息可以定義在extensions
這個字段內。
最后
至此,整個GraphQL實現的流程到這里就結束了。更多細節請查看源碼和規范,我們將在下一篇文章中聊聊GraphQL的實際項目應用GraphQL學習之實踐篇
參考
1、 GraphQL