GraphQL學習之原理篇


前言

在上一篇文章基礎篇中,我們介紹了GraphQL的語法以及類型系統,算是對GraphQL有個基本的認識。在這一篇中,我們將會介紹GraphQL的實現原理。說到原理,我們就不得不依托於GraphQL的規范:GraphQL

概述

GraphQL規范主體部分有6大部分,除去我們在上一節講到的類型系統(Type System)和語言(Language),剩下的便是整個GraphQL的主流程。也就是如下圖所示的:

 

 

根據規范的章節,也就是GraphQL的實現流程,我們原理篇一一來看看規范到底定義了些什么,以及在實際的使用中,是如何貼近到規范的實現的。

Js語言的實現版本是: graphql-js

流程總覽

首先我們肯定會在客戶端上書寫查詢語句,查詢語句在發送到服務端之前會轉換為標准的請求體。以之前的demo為例子,當我們發起如下的請求的時候:

 

 

客戶端發起的請求體應該具備以下三個字段(POST請求):

{
  "query": "...",
  "operationName": "...",
  "variables": { "myVariable": "someValue", ... }
}

截圖如下:

 

 

參考Serving over HTTP

這些參數表達了客戶端的訴求:調用哪個方法,傳遞什么樣的參數,返回哪些字段。

服務端拿到這段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.parsegraphql-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章節的內容。根據規范,我們將執行階段分為:

 

 

每個階段解釋一下:

  1. Validating Requests:到這個階段的校驗其實已經很少了,在源碼實現上只需要校驗入參是否符合規范即可,對應源碼的方法是:assertValidExecutionArguments
  2. Coercing Variable Values:檢查客戶端請求變量的合法性,需要和schema進行比對,對應源碼的方法是:getVariableValues
  3. Executing Operations:執行客戶端請求的方法與之對應的resolver函數。對應源碼的方法是:executeOperation
  4. Executing Selection Sets:搜羅客戶端請求需要返回的字段集合,對應源碼的方法是:collectFields
  5. 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,這個流程以紫色線標注,如此往復遞歸,遍歷完客戶端要求的所有數據為止

  1. collectFields{ getMessage: [{ kind: 'Field', alias: undefined, name: [Object], arguments: [], directives: [], selectionSet: [Object], loc: [Object] }] }
  2. 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' } ]
  3. 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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM