本文由 kazaff 翻譯而成,點擊閱讀原文能夠查看做者的博客,感謝做者的優質輸出,讓咱們的技術世界更加美好✌️html
好久以前其實就關注過這個技術,記得當時仍是React剛剛嶄露頭角的時期吧。總之那時候,GraphQL感受還只是概念完備階段,除了FB本身內部大量使用外,好像社區並非很健全,不過你們應該都在瘋狂的討論和跟進吧。過了2年,現在再回過頭來看,已經涌現出各類開源或商用服務專一於這個領域,各類語言的框架和工具也都很完備了,感受是時候從新接觸GraphQL了。若是你的項目正處於技術選型,你正在猶豫選擇一種接口風格的時刻,不妨瞭解一下這個神奇而強大的玩意兒~~前端
本文打算翻譯一篇感受很解惑的文章,主要圍繞着GraphQL的server端實現,由於相比client端,server端包含了更多的內容。後面若是有機會,也會嘗試提供關於client端相關的內容,不過前端同窗能夠先看一下這裏:howtographql[1],這裏有各類最佳實踐,應該總會找到和你正在使用相關的前端框架的整合方案,好像有個對應的中文版[2]~node
關於GraphQL概念的內容,這篇文章並無涉及太多,不過假如你用搜索引擎去搜的話,相信有很是多的相關文章供你學習,這裏就再也不重複了~git
原文在這裏[3],懷疑我翻譯能力的同窗能夠去看原位哦~github
相信讀完整個文章,對於GraphQL Server會有一個完整的瞭解。咱們開始吧~web
目錄
-
目標 -
一切從Schema開始 -
建立一個簡單的GraphQL服務端 -
GraphiQL,一個Graphql領域的postman -
編寫Resolvers -
處理數據依賴關係 -
對接真正的數據庫 -
1+N查詢問題 -
管理自定義Scalar類型 -
錯誤處理 -
日誌 -
認證 & 中間件 -
Resolvers的單元測試 -
查詢引擎的集成化測試 -
Resolvers拆分 -
組織Schemas -
結語
目標
咱們的目標是針對一個移動app端界面顯示所須要的數據,提供支撐,能夠實現單一請求次數下就能夠獲取足夠的數據。咱們將會用Nodejs來完成這個任務,由於這個語言咱們已經在marmelab用了4年了。但你也能夠用任何你想用的語言,例如Ruby,Go,甚至PHP,JAVA或C#。sql
爲了顯示這個頁面,服務端必須能提供下面的響應數據結構:mongodb
{
"data": {
"Tweets": [
{
"id": 752,
"body": "consectetur adipisicing elit",
"date": "2017-07-15T13:17:42.772Z",
"Author": {
"username": "alang",
"full_name": "Adrian Lang",
"avatar_url": "http://avatar.acme.com/02ac660cdda7a52556faf332e80de6d8"
}
},
{
"id": 123,
"body": "Lorem Ipsum dolor sit amet",
"date": "2017-07-14T12:44:17.449Z",
"Author": {
"username": "creilly17",
"full_name": "Carole Reilly",
"avatar_url": "http://avatar.acme.com/5be5ce9aba93c62ea7dcdc8abdd0b26b"
}
},
// etc.
],
"User": {
"full_name": "John Doe"
},
"NotificationsMeta": {
"count": 12
}
}
}
咱們須要模塊化和可維護的代碼,須要作單元測試,聽起來這很難?你會發現藉助於GraphQL工具鏈,這並不比開發Rest客戶端難多少。chrome
一切從Schema開始
當我開發一個GraphQL服務時,我總會從在白板上設計模型開始,而不是上來就寫代碼。我會和產品和前端開發團隊一塊兒來討論須要提供哪些數據類型,查詢或更新操做。若是你瞭解領域驅動設計方法[4],你會很熟悉這個流程。前端開發團隊在拿到服務端返回的數據結構以前是沒有辦法開始編碼的。因此咱們須要先對API達成一致。數據庫
Tip 命名很重要!不要以爲把時間花在爲變量起名字上很浪費。特別是當這些名稱會長期使用的時候 - 記住,GraphQL API並無版本號這回事兒,因此,儘量讓你的Schema具備自解釋特性,由於這是其餘開發人員瞭解項目的入口。
下面是我爲這個項目提供的GraphQL Schema:
type Tweet {
id: ID!
# The tweet text. No more than 140 characters!
body: String
# When the tweet was published
date: Date
# Who published the tweet
Author: User
# Views, retweets, likes, etc
Stats: Stat
}
type User {
id: ID!
username: String
first_name: String
last_name: String
full_name: String
name: String @deprecated
avatar_url: Url
}
type Stat {
views: Int
likes: Int
retweets: Int
responses: Int
}
type Notification {
id: ID
date: Date
type: String
}
type Meta {
count: Int
}
scalar Url
scalar Date
type Query {
Tweet(id: ID!): Tweet
Tweets(limit: Int, sortField: String, sortOrder: String): [Tweet]
TweetsMeta: Meta
User: User
Notifications(limit: Int): [Notification]
NotificationsMeta: Meta
}
type Mutation {
createTweet(body: String): Tweet
deleteTweet(id: ID!): Tweet
markTweetRead(id: ID!): Boolean
}
我在這個系列的前一篇文章中簡短的介紹了Schema的語法。你只須要知道,這裏的type
相似REST裏的resources概念。你能夠用它來定義擁有惟一id鍵的實體(如Tweet
和User
)。你也能夠用它來定義值對象,這種類型嵌套在實體內部,所以不須要惟一鍵(例如Stat
)。
Tip 儘量保證
Type
足夠輕巧,而後利用組合。舉個例子,儘管stats
數據如今看來和tweet
數據關係很近,可是請分開定義它們。由於它們表達的領域不一樣。這樣當有天將stats
數據換其它底層架構來維護,你就會慶幸今天作出的這個決定。
Query
和Mutation
關鍵字有特別的含義,它們用來定義API的入口。因此你不能聲明一個自定義類型用這兩個關鍵字 - 它們是GraphQL預留關鍵字。你可能會對Query
下定義的字段有個困擾,它們老是和實體類型名字同樣 - 但這只是個習慣約定。我就決定把獲取Tweet
類型數據的屬性名稱定義成getTweet
- 記住,GraphQL是一種RPC(譯者注:有別於RESTful的資源概念)。
官方GraphQL提供的schema文檔[5]提供了全部細節,花十分鐘來了解一下對你定義本身的schema會頗有幫助。
Tip 你可能看過有些GraphQL教程使用代碼風格來定義schema,例如
GraphQLObjectType
。別這麼作[6],這種風格顯得很是的囉嗦,也不夠清晰。
建立一個簡單的GraphQL服務端
用Nodejs實現一個HTTP服務端最快的方式是使用express microframework[7]。稍後咱們會在http://localhost:4000/graphql[8]下接入一個GraphQL服務。
> npm install express express-graphql graphql-tools graphql --save
express-graphql
庫會基於咱們定義的schema
和resolver
函數來建立一個graphQL服務。graphql-tools
庫提供了schema
的解析和校驗的獨立包。這兩個庫前者是來自於Facebook,後者源於Apollo。
// in src/index.js
const fs = require('fs');
const path = require('path');
const express = require('express');
const graphqlHTTP = require('express-graphql');
const { makeExecutableSchema } = require('graphql-tools');
const schemaFile = path.join(__dirname, 'schema.graphql');
const typeDefs = fs.readFileSync(schemaFile, 'utf8');
const schema = makeExecutableSchema({ typeDefs });
var app = express();
app.use('/graphql', graphqlHTTP({
schema: schema,
graphiql: true,
}));
app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');
執行下面命令來讓咱們的服務端跑起來:
> node src/index.js
Running a GraphQL API server at localhost:4000/graphql
咱們可使用curl
來簡單請求一下咱們的graphQL服務:
> curl 'http://localhost:4000/graphql' \
> -X POST \
> -H "Content-Type: application/graphql" \
> -d "{ Tweet(id: 123) { id } }"
{
"data": {"Tweet":null}
}
正常!
Graphql服務根據咱們提供的schema
定義,在執行請求攜帶的查詢語句以前進行了必要的校驗,若是咱們的查詢語句中包含了一個沒有聲明過的字段,咱們會獲得一個錯誤提醒:
> curl 'http://localhost:4000/graphql' \
> -X POST \
> -H "Content-Type: application/graphql" \
> -d "{ Tweet(id: 123) { foo } }"
{
"errors": [
{
"message": "Cannot query field \"foo\" on type \"Tweet\".",
"locations": [{"line":1,"column":26}]
}
]
}
Tip
express-graphql
包生成的GraphQL服務端同時支持GET和POST請求。
Tip 世界上還有一個不錯的庫可讓咱們基於express,koa,HAPI或Restify來創建GraphQL服務:apollo-server[9]。使用的方法和咱們用的這個沒有太多差別,因此這個教程一樣適用。
GraphiQL,一個Graphql領域的postman
curl
並非一個很好用的工具來測試咱們的GraphQL服務。咱們使用GraphiQL[10]來作可視化工具。能夠把它想象成是Postman
(譯:用於測試Rest服務的工具,chrome app)。
由於咱們在使用graphqlHTTP
中間件時聲明瞭graphiql
參數,GraphiQL已經啓動了。咱們能夠在瀏覽器訪問http://localhost:4000/graphql[11]就能看到Web界面了。它會從咱們的服務中拿到完整的schema
結構,並建立一個可視化的文檔。能夠點擊頁面右上角的Docs
連接來查看:
有了它,咱們的服務端就至關於有了自動化API文檔生成功能,這就意味着咱們再也不須要Swagger[12]啦~
Tip 文檔中每一個類型和字段的解釋來自於schema中的註釋(以#爲首的行)。儘量提供註釋,其它開發者會聲淚俱下的。
這還不是所有:使用schema
,GraphiQL還提供了自動補全功能:
這種殺手級應用,每一個Graphql開發者都值得擁有。對了,不要忘記在產品環境關閉掉它喲~
Tip 你能夠獨立安裝graphiQL工具,它基於Electron。跨平臺的哦,下載連接[13]
編寫Resolvers
到目前爲止,咱們的服務也只能返回空結果。咱們這裏會添加resolver
定義來讓它返回一些數據。咱們先簡單使用一些直接定義在代碼裏的靜態數據來演示一下:
const tweets = [
{ id: 1, body: 'Lorem Ipsum', date: new Date(), author_id: 10 },
{ id: 2, body: 'Sic dolor amet', date: new Date(), author_id: 11 }
];
const authors = [
{ id: 10, username: 'johndoe', first_name: 'John', last_name: 'Doe', avatar_url: 'acme.com/avatars/10' },
{ id: 11, username: 'janedoe', first_name: 'Jane', last_name: 'Doe', avatar_url: 'acme.com/avatars/11' },
];
const stats = [
{ tweet_id: 1, views: 123, likes: 4, retweets: 1, responses: 0 },
{ tweet_id: 2, views: 567, likes: 45, retweets: 63, responses: 6 }
];
而後咱們來告訴服務如何使用這些數據來處理Tweet
和Tweets
查詢請求。下面列出了resover
映射關係,這個對象按照schema
的結構,爲每一個字段提供了一個函數:
const resolvers = {
Query: {
Tweets: () => tweets,
Tweet: (_, { id }) => tweets.find(tweet => tweet.id == id),
},
Tweet: {
id: tweet => tweet.id,
body: tweet => tweet.body
}
};
// pass the resolver map as second argument
const schema = makeExecutableSchema({ typeDefs, resolvers });
// proceed with the express app setup
Tip 官方
express-graphql
文檔建議使用rootValue
選項來代替使用makeExecutableSchema()
。我不推薦這麼作!
這裏resolver
的函數簽名是(previousValue, parameters) => data
。目前已經足夠咱們的服務來完成基礎查詢了:
// query { Tweets { id body } }
{
data:
Tweets: [
{ id: 1, body: 'Lorem Ipsum' },
{ id: 2, body: 'Sic dolor amet' }
]
}
// query { Tweet(id: 2) { id body } }
{
data:
Tweet: { id: 2, body: 'Sic dolor amet' }
}
內部工做流程是這樣的:服務會由外向內依次處理查詢塊,爲每一個查詢塊執行對應的resolver
函數,並傳遞外層調用是的返回結果爲第一個參數。因此,{ Tweet(id: 2) { id body } }
這個查詢的處理步驟爲:
-
最外層爲 Tweet
,對應的resolver
爲(Query.Tweet)
。由於是最外層,因此調用resolver
函數時第一個參數爲null。第二個參數傳遞的是查詢攜帶的參數{ id: 2 }
。根據schema
的定義,該resolver
函數會返回知足條件的Tweet
類型對象。 -
針對每一個 Tweet
對象,服務會執行對應的(Tweet.id)
和(Tweet.body)
resolver函數。此時第一個參數爲第一步獲得的Tweet
對象。
目前咱們的Tweet.id
和Tweet.body
resolver函數很是的簡單,事實上我根本不須要聲明它們。GraphQL有一個簡單的默認resolver來處理缺乏對應定義的字段。
Mutation resolver的實現並不會難多少,以下:
const resolvers = {
// ...
Mutation: {
createTweet: (_, { body }) => {
const nextTweetId = tweets.reduce((id, tweet) => {
return Math.max(id, tweet.id);
}, -1) + 1;
const newTweet = {
id: nextTweetId,
date: new Date(),
author_id: currentUserId, // <= you'll have to deal with that
body,
};
tweets.push(newTweet);
return newTweet;
}
},
};
Tip 保持
resolver
函數的簡潔。GraphQL一般扮演系統的API網關角色,對後端領域服務提供了一層薄薄封裝。resolver
應該只包含解析請求參數並生成返回數據要求的結構的功能 - 就好像MVC框架中的controller層。其它邏輯應該拆分到對應的層,這樣咱們就能保持GraphQL非侵入業務。
你能夠在Apollo官網找到關於resolvers的完整文檔[14]。
處理數據依賴關係
接下來,最有意思的部分要開始了。如何讓咱們的服務能支持複雜的聚合查詢呢?以下:
{
Tweets {
id
body
Author {
username
full_name
}
Stats {
views
}
}
}
若是是在SQL語言,這可能須要對其它兩個表的joins操做(User和Stat),其背後SQL執行器要運行復雜的邏輯來處理查詢。在GraphQL中,咱們只須要爲Tweet
類型添加合適的resolver
函數便可:
const resolvers = {
Query: {
Tweets: () => tweets,
Tweet: (_, { id }) => tweets.find(tweet => tweet.id == id),
},
Tweet: {
Author: (tweet) => authors.find(author => author.id == tweet.author_id),
Stats: (tweet) => stats.find(stat => stat.tweet_id == tweet.id),
},
User: {
full_name: (author) => `${author.first_name} ${author.last_name}`
},
};
// pass the resolver map as second argument
const schema = makeExecutableSchema({ typeDefs, resolvers });
有了上面的resolvers
,咱們的服務就能夠處理前面的查詢並拿到指望的結果:
{
data: {
Tweets: [
{
id: 1,
body: "Lorem Ipsum",
Author: {
username: "johndoe",
full_name: "John Doe"
},
Stats: {
views: 123
}
},
{
id: 2,
body: "Sic dolor amet",
Author: {
username: "janedoe",
full_name: "Jane Doe"
},
Stats: {
views: 567
}
}
]
}
}
看到這個結果我不知道你們什麼反映,反正我第一次被震到了,這簡直是黑科技。憑什麼這麼簡單的resolver
函數就能讓服務支持這麼複雜的查詢?
咱們再來看一下執行流程:
{
Tweets {
id
body
Author {
username
full_name
}
Stats {
views
}
}
}
-
對於最外層的 Tweets
查詢塊,GraphQL執行Query.Tweets
resolver,第一個參數爲null。resolver函數返回Tweets
數組。 -
針對數組中的每一個 Tweet
,GraphQL併發的執行Tweet.id
、Tweet.body
、Tweet.Author
和Tweet.Stats
resolver函數。 -
注意此次我並無提供關於 Tweet.id
和Tweet.body
的resolver函數,GraphQL使用默認的resolver。對於Tweet.Author
resolver函數,會返回一個User
類型的對象,這是schema
中定義好的。 -
針對 User
類型數據,查詢會併發的執行User.username
和User.full_name
resolver,並傳遞上一步獲得的Author
對象做爲第一個參數。 -
State
處理一樣會使用默認的resolver來解決。
因此,這就是GraphQL的核心,很是的酷炫。它能夠處理複雜的多層嵌套查詢。這就是爲啥成它爲Graph
的緣由吧,此刻你應該頓悟了吧?!啊哈~
你能夠在graphql.org網站找到關於GraphQL執行機制的描述[15]。
對接真正的數據庫
在真實項目中,resolver
須要和數據庫或其它API打交道來獲取數據。這和咱們上面作的事兒沒有本質不一樣,除了須要返回一個promises
外。假如tweets
和authors
數據存儲在PostgreSQL
數據庫,而Stats
存儲在MongoDB
數據庫,咱們的resolver
只要調整一下便可:
const { Client } = require('pg');
const MongoClient = require('mongodb').MongoClient;
const resolvers = {
Query: {
Tweets: (_, __, context) => context.pgClient
.query('SELECT * from tweets')
.then(res => res.rows),
Tweet: (_, { id }, context) => context.pgClient
.query('SELECT * from tweets WHERE id = $1', [id])
.then(res => res.rows),
User: (_, { id }, context) => context.pgClient
.query('SELECT * from users WHERE id = $1', [id])
.then(res => res.rows),
},
Tweet: {
Author: (tweet, _, context) => context.pgClient
.query('SELECT * from users WHERE id = $1', [tweet.author_id])
.then(res => res.rows),
Stats: (tweet, _, context) => context.mongoClient
.collection('stats')
.find({ 'tweet_id': tweet.id })
.query('SELECT * from stats WHERE tweet_id = $1', [tweet.id])
.toArray(),
},
User: {
full_name: (author) => `${author.first_name} ${author.last_name}`
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
const start = async () => {
// make database connections
const pgClient = new Client('postgresql://localhost:3211/foo');
await pgClient.connect();
const mongoClient = await MongoClient.connect('mongodb://localhost:27017/bar');
var app = express();
app.use('/graphql', graphqlHTTP({
schema: schema,
graphiql: true,
context: { pgClient, mongoClient }),
}));
app.listen(4000);
};
start();
注意,因爲咱們的數據庫操做只支持異步操做,因此咱們須要改爲promise寫法。我把數據庫連接句柄對象保存在GraphQL的context
中,context
會做爲第三個參數傳遞給全部的resolver
函數。tontext
很是適合用來處理須要在多個resolver
中共享的資源,有點相似其它框架中的註冊表實例。
如你所見,咱們很容易就作到從不一樣的數據源中聚合數據,客戶端根本不知道數據來自於哪裏 - 這一切都隱藏在resolver
中。
1+N查詢問題
迭代查詢語句塊來調用對應的resolver
函數確實聰明,但性能可能不太好。在咱們的例子中,Tweet.Author
resolver被調用了屢次,針對每一個從Query.Tweets
resolve中獲得的Tweet
。因此咱們請求了1次Tweets
,結果產生了N次Tweet.Author
查詢。
爲了解決這個問題,我使用了另一個庫:Dataloader[16],它也是Facebook提供的。
npm install --save dataloader
DataLoader是一個數據批量獲取和緩存的工具庫。首先咱們會建立一個獲取全部條目並返回promise的函數,而後咱們爲每一個條目建立一個dataloader:
const DataLoader = require('dataloader');
const getUsersById = (ids) => pgClient
.query(`SELECT * from users WHERE id = ANY($1::int[])`, [ids])
.then(res => res.rows);
const dataloaders = () => ({
userById: new DataLoader(getUsersById),
});
userById.load(id)
函數會收集多個單獨的item調用,而後批量的獲取一次。
Tip 若是你不太熟悉PostgreSQL,
WHERE id = ANY($1::int[])
的語法就相似於WHERE id IN($1,$2,$3)
。
咱們把dataloader也保存在context
中:
app.use('/graphql', graphqlHTTP(req => ({
schema: schema,
graphiql: true,
context: { pgClient, mongoClient, dataloaders: dataloaders() },
})));
如今咱們只須要稍微修改一下Tweet.Author
resolver便可:
const resolvers = {
// ...
Tweet: {
Author: (tweet, _, context) =>
context.dataloaders.userById.load(tweet.author_id),
},
// ...
};
大功搞成!如今{ Tweets { Author { username } }
查詢只會執行2次查詢請求:一次用來獲取Tweets
數據,一次用來獲取全部須要的Tweet.Author
數據!
你須要注意一個細節:在graphqlHTTP
配置時,我傳遞進去的是一個函數(graphqlHTTP(req => ({ ... })))
,而非以前的對象(graphqlHTTP({ ... }))
。這是由於Dataloader實例還提供緩存功能,因此我須要確保全部請求使用的是同一個Dataloader對象。
但此次變更會致使前面的代碼報錯,由於pgClient
在getUsersById
函數的上下文中就不存在了。爲了傳遞數據庫連接句柄到dataloader中,這有點繞,看下面的代碼:
const DataLoader = require('dataloader');
const getUsersById = pgClient => ids => pgClient
.query(`SELECT * from users WHERE id = ANY($1::int[])`, [ids])
.then(res => res.rows);
const dataloaders = pgClient => ({
userById: new DataLoader(getUsersById(pgClient)),
});
// ...
app.use('/graphql', graphqlHTTP(req => ({
schema: schema,
graphiql: true,
context: { pgClient, mongoClient, dataloaders: dataloaders(pgClient) },
})));
實際開發中,你可能不得不在全部的resolver
函數中都使用dataloader,無論是否會查詢數據庫。這是產品環境下的必備啊,千萬別錯過它!
管理自定義Scalar類型
你可能注意到了我到如今爲止都沒有獲取tweet.date
數據,那是由於我在schema
中定義了自定義的scalar
類型:
type Tweet {
# ...
date: Date
}
scalar Date
無論你信不信,反正graphQL規範中並無定義Date scalar
類型,須要開發者自行實現。這算是個好的機會咱們來演示一下建立自定義scalar
類型,用來校驗和類型轉換數據。
和其餘類型同樣,scalar
類型也須要resolver
。但它的resolver
函數必須支持將數據從其它resolver
函數中轉換爲響應所需的格式,反之亦然:
const { GraphQLScalarType, GraphQLError } = require('graphql');
const { Kind } = require('graphql/language');
const validateValue = value => {
if (isNaN(Date.parse(value))) {
throw new GraphQLError(`Query error: not a valid date`, [value]);
};
const resolvers = {
// previous resolvers
// ...
Date: new GraphQLScalarType({
name: 'Date',
description: 'Date type',
parseValue(value) {
// value comes from the client, in variables
validateValue(value);
return new Date(value); // sent to resolvers
},
parseLiteral(ast) {
// value comes from the client, inlined in the query
if (ast.kind !== Kind.STRING) {
throw new GraphQLError(`Query error: Can only parse dates strings, got a: ${ast.kind}`, [ast]);
}
validateValue(ast.value);
return new Date(ast.value); // sent to resolvers
},
serialize(value) {
// value comes from resolvers
return value.toISOString(); // sent to the client
},
}),
};
錯誤處理
正是由於我們有schema
,全部錯誤的查詢請求都會被服務端捕獲,並返回一個錯誤提醒:
// query { Tweets { id body foo } }
{
"errors": [
{
"message": "Cannot query field \"foo\" on type \"Tweets\".",
"locations": [
{
"line": 1,
"column": 19
}
]
}
]
}
這讓調試變得易如反掌。客戶端用戶能夠看到到底發生了什麼事兒。
但這種在響應中顯示錯誤信息的簡單處理,並無在服務端記錄錯誤日誌。爲了幫助開發者跟蹤異常,我在makeExecutableSchema
中配置了logger
參數,它必須傳遞一個擁有log
方法的對象:
const schema = makeExecutableSchema({
typeDefs,
resolvers,
logger: { log: e => console.log(e) },
});
若是你打算在響應中隱藏錯誤信息,可使用graphql-errors包[17]。
日誌
除了數據和錯誤外,graphQL的響應中還能夠包含extensions
類信息,你能夠在其中放你想要的任何數據。咱們用它來顯示服務的耗時信息再好不過了。
爲了添加擴展信息,咱們須要在graphqlHTTP
配置中添加extension
函數,它返回一個支持json序列化的對象。
下面我添加了一個timing
到響應中:
app.use('/graphql', graphqlHTTP(req => {
const startTime = Date.now();
return {
// ...
extensions: ({ document, variables, operationName, result }) => ({
timing: Date.now() - startTime,
})
};
})));
如今咱們全部的graphQL響應中都會包含請求的耗時信息:
// query { Tweets { id body } }
{
"data": [ ... ],
"extensions": {
"timing": 53,
}
}
你能夠按你的設想爲你的resolver
函數提供更細顆粒度的耗時信息。在產品環境下,監聽每一個後端響應耗時很是有意義。你能夠參考apollo-tracing-js[18]:
{
"data": <>,
"errors": <>,
"extensions": {
"tracing": {
"version": 1,
"startTime": <>,
"endTime": <>,
"duration": <>,
"execution": {
"resolvers": [
{
"path": [<>, ...],
"parentType": <>,
"fieldName": <>,
"returnType": <>,
"startOffset": <>,
"duration": <>,
},
...
]
}
}
}
}
Apollo公司還提供一個叫Optics[19]的GraphQL監控服務,不妨試試看。
認證 & 中間件
GraphQL規範中並無包含認證受權相關的內容。這意味着你不得不本身來作,可使用express對應的中間件庫(你可能須要passport.js[20])。
一些教程推薦使用graphQL的Mutation來實現註冊和登陸功能[21],而且在resolver
函數中實現認證邏輯。但個人觀點是,這在多數場景中都顯得過火了。
請記住,GraphQL只是一個API網關,它不該該處理太多的業務需求。(譯:但不少成熟API網關服務都提供認證受權服務吧?!但不知爲什麼我挺支持原做者的觀點)
Resolvers的單元測試
resolver
是簡單函數,因此單元測試很是簡單。在這篇教程裏,咱們會使用一樣是Facebook提供的Jest[22],由於它基本上開箱即用:
> npm install jest --save-dev
讓咱們開始爲以前寫的resolver
函數User.full_name
來寫個測試用例。爲了能測試它,咱們須要先把它單獨拆分到本身的文件中:
// in src/user/resolvers.js
exports.User = {
full_name: (author) => `${author.first_name} ${author.last_name}`,
};
// in src/index.js
const User = require('./resolvers/User');
const resolvers = {
// ...
User,
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
// ...
如今就能夠對它寫測試用例了:
// in src/user/resolvers.spec.js
const { User } = require('./resolvers');
describe('User.full_name', () => {
it('concatenates first and last name', () => {
const user = { first_name: 'John', last_name: 'Doe' };
expect(User.full_name(user)).toEqual('John Doe')
});
})
運行./node_modules/.bin/jest
,而後就能夠看到終端顯示的測試結果了。
那些和數據庫打交道的resolver
測試起來可能稍微麻煩一些。不過由於context
會被當作參數,咱們利用它來傳入測試數據集也沒什麼難的。以下:
// in src/tweet/resolvers.js
exports.Query = {
Tweets: (_, _, context) => context.pgClient
.query('SELECT * from tweets')
.then(res => res.rows),
};
// in src/tweet/resolvers.spec.js
const { Query } = require('./resolvers');
describe('Query.Tweets', () => {
it('returns all tweets', () => {
const queryStub = q => {
if (q == 'SELECT * from tweets') {
return Promise.resolve({ rows: [
{ id: 1, body: 'hello' },
{ id: 2, body: 'world' },
]});
}
};
const context = { pgClient: { query: queryStub } };
return Query.Tweets(null, null, context).then(results => {
expect(results).toEqual([
{ id: 1, body: 'hello' }
{ id: 2, body: 'world' }
]);
});
});
})
注意這裏依然須要返回一個promise,而且將斷言語句放在then()
回調中。這樣Jest會知道是異步測試。咱們剛纔是手動編寫測試數據的,在真實產品中,你可能須要一個專業的類庫來幫忙:Sinon.js[23]。
如你所見,測試resolver
就是這麼小菜一碟。把resolver
定位爲一個純函數,是GraphQL設計者們的另外一個明智之舉。
查詢引擎的集成化測試
那麼,如何來測試數據依賴,類型和聚合邏輯呢?這是另外一種類型的測試,通常叫集成測試,須要在查詢引擎上跑。
這須要咱們運行一個http server來進行繼承測試麼?然而並非。你能夠單獨對查詢引擎進行測試而不須要跑一個服務,使用graphql
工具便可。
在集成測試以前,咱們須要調整一下代碼結構:
// in src/schema.js
const fs = require('fs');
const path = require('path');
const { makeExecutableSchema } = require('graphql-tools');
const resolvers = require('../resolvers'); // extracted from the express app
const schemaFile = path.join(__dirname, './schema.graphql');
const typeDefs = fs.readFileSync(schemaFile, 'utf8');
module.exports = makeExecutableSchema({ typeDefs, resolvers });
// in src/index.js
const express = require('express');
const graphqlHTTP = require('express-graphql');
const schema = require('./schema');
var app = express();
app.use('/graphql', graphqlHTTP({
schema,
graphiql: true,
}));
app.listen(4000);
console.log('Running a GraphQL API server at localhost:4000/graphql');
如今我就能夠單獨的測試schema
:
// in src/schema.spec.js
const { graphql } = require('graphql');
const schema = require('./schema');
it('responds to the Tweets query', () => {
// stubs
const queryStub = q => {
if (q == 'SELECT * from tweets') {
return Promise.resolve({ rows: [
{ id: 1, body: 'Lorem Ipsum', date: new Date(), author_id: 10 },
{ id: 2, body: 'Sic dolor amet', date: new Date(), author_id: 11 }
]});
}
};
const dataloaders = {
userById: {
load: id => {
if (id == 10 ) {
return Promise.resolve({ id: 10, username: 'johndoe', first_name: 'John', last_name: 'Doe', avatar_url: 'acme.com/avatars/10' });
}
if (id == 11 ) {
return Promise.resolve({
{ id: 11, username: 'janedoe', first_name: 'Jane', last_name: 'Doe', avatar_url: 'acme.com/avatars/11' });
}
}
}
};
const context = { pgClient: { query: queryStub }, dataloaders };
// now onto the test itself
const query = '{ Tweets { id body Author { username } }}';
return graphql(schema, query, null, context).then(results => {
expect(results).toEqual({
data: {
Tweets: [
{ id: '1', body: 'hello', Author: { username: 'johndoe' } },
{ id: '2', body: 'world', Author: { username: 'janedoe' } },
],
},
});
});
})
這個獨立的graphql查詢引擎的api方法簽名是(schema, query, rootValue, context) => Promise
,(文檔[24])。很簡單對吧?順便說一句,graphqlHTTP
內部就是調用它來工做的。
另外一種Apollo公司比較推薦的測試手段是使用來自graphql-tools
中的mockServer
來測試。基於文本化的schema
,它會建立一個內存數據源,並填充僞造的數據。你能夠在這個教程[25]中看到詳細步驟。然而我並不推薦這種方式 - 它更像是一個前端開發者的工具,用來模擬GraphQL服務,而不是用來測試resolver
。
Resolvers拆分
爲了能測試resolver
和查詢引擎,咱們不得不把代碼拆分到多個獨立的文件中。從開發者角度來看這是一個值得的工做 - 它提供了模塊化和可維護性。讓咱們完成全部resolver
的模塊化拆分。
// in src/tweet/resolvers.js
export const Query = {
Tweets: (_, _, context) => context.pgClient
.query('SELECT * from tweets')
.then(res => res.rows),
Tweet: (_, { id }, context) => context.pgClient
.query('SELECT * from tweets WHERE id = $1', [id])
.then(res => res.rows),
}
export const Mutation = {
createTweet: (_, { body }, context) => context.pgClient
.query('INSERT INTO tweets (date, author_id, body) VALUES ($1, $2, $3) RETURNING *', [new Date(), currentUserId, body])
.then(res => res.rows[0])
},
}
export const Tweet = {
Author: (tweet, _, context) => context.dataloaders.userById.load(tweet.author_id),
Stats: (tweet, _, context) => context.dataloaders.statForTweet.load(tweet.id),
},
// in src/user/resolvers.js
export const Query = {
User: (_, { id }, context) => context.pgClient
.query('SELECT * from users WHERE id = $1', [id])
.then(res => res.rows),
};
export const User = {
full_name: (author) => `${author.first_name} ${author.last_name}`,
};
而後咱們須要在一個地方合併全部的resolver
:
// in src/resolvers
const {
Query: TweetQuery,
Mutation: TweetMutation,
Tweet,
} = require('./tweet/resolvers');
const { Query: UserQuery, User } = require('./user/resolvers');
module.exports = {
Query: Object.assign({}, TweetQuery, UserQuery),
Mutation: Object.assign({}, TweetMutation),
Tweet,
User,
}
就是這樣!如今,模塊化拆分後的代碼結構,更適合理解和測試。
組織Schemas
Resolvers
如今已經結構化了,可是schema
呢?把全部定義都放在一個文件中一聽就不是個好設計。尤爲是對一些大項目,這會致使根本沒法維護。就像resolver
那樣,我也會把schema
拆分到多個獨立的文件中。下面是我推薦的項目文件結構,靠模塊思想來搭建:
src/
stat/
resolvers.js
schema.js
tweet/
resolvers.js
schema.js
user/
resolvers.js
schema.js
base.js
resolvers.js
schema.js
base.js
文件中包含了schema
的基礎類型,和空的query
和mutation
類型聲明 - 其它片斷schema
文件會增長對應的字段到其中。
// in src/base.js
const Base = `
type Query {
dummy: Boolean
}
type Mutation {
dummy: Boolean
}
type Meta {
count: Int
}
scalar Url
scalar Date`;
module.exports = () => [Base];
因爲GraphQL不支持空的類型,因此咱們不得不聲明一個看起來毫無心義的query
和mutation
。注意,文件最後導出的是一個數組而非字符串。後面你就會知道是爲啥了。
如今,在User schema
聲明文件中,咱們如何添加字段到已經存在的query
類型中?使用graphql關鍵字extend
:
// in src/user/schema.js
const Base = require('../base');
const User = `
extend type Query {
User: User
}
type User {
id: ID!
username: String
first_name: String
last_name: String
full_name: String
name: String @deprecated
avatar_url: Url
}
`;
module.exports = () => [User, Base];
正如你看到的,代碼最後並無只是導出User
,也導出了它因此來的Base
。我就是靠這種方法來確保makeExecutableSchema
能拿到全部的類型定義。這就是爲啥我老是導出數組的緣由,快誇我。
Stat
類型也沒有什麼特殊的:
// in src/stat/schema.js
const Stat = `
type Stat {
views: Int
likes: Int
retweets: Int
responses: Int
}
`;
module.exports = () => [Stat];
Tweet
類型依賴多個其它類型,因此咱們要導入全部依賴的類型定義,並最終所有導出:
// in src/tweet/schema.js
const User = require('../user/schema');
const Stat = require('../stat/schema');
const Base = require('../base');
const Tweet = `
extend type Query {
Tweet(id: ID!): Tweet
Tweets(limit: Int, sortField: String, sortOrder: String): [Tweet]
TweetsMeta: Meta
}
extend type Mutation {
createTweet (body: String): Tweet
deleteTweet(id: ID!): Tweet
markTweetRead(id: ID!): Boolean
}
type Tweet {
id: ID!
# The tweet text. No more than 140 characters!
body: String
# When the tweet was published
date: Date
# Who published the tweet
Author: User
# Views, retweets, likes, etc
Stats: Stat
}
`;
module.exports = () => [Tweet, User, Stat, Base];
最後,確保全部類型都在主schema.js
文件中,我簡單的傳遞一個typeDefs
數組:
// in schema.js
const Base = require('./base.graphql');
const Tweet = require('./tweet/schema');
const User = require('../user/schema');
const Stat = require('../stat/schema');
const resolvers = require('./resolvers');
module.exports = makeExecutableSchema({
typeDefs: [
...Base,
...Tweet,
...User,
...Stat,
],
resolvers,
});
不須要擔憂類型重疊問題。每一個類型makeExecutableSchema
只會接受一次。
Tip 子schema導出一個函數而不是一個數組,是由於它要確保不會發生環形依賴問題。
makeExecutableSchema
函數支持傳遞數組和函數參數。
結語
咱們的服務端如今已經搞出來了,而且也進行了測試。是時候放鬆一下了!你能夠從Github[26]上下載這個教程的完整代碼。歡迎使用它來做爲你新項目的腳手架。
其實還有一些我沒有提到的關於服務端GraphQL開發的細節:
-
安全:客戶端能夠隨意的建立複雜查詢,這就增長了服務風險,例如被DoS攻擊。能夠看一下這篇文章: HowToGraphQL: GraphQL Security [27] -
訂閱:不少教程使用 WebSocket
,能夠閱讀 HowToGraphQL: Subscriptions [28]或 Apollo: Server-Side Subscriptions [29]來了解更多細節 -
輸入類型:對於mutations,GraphQL支持有限的輸入類型。能夠從 Apollo: GraphQL Input Types And Client Caching [30]瞭解更多細節 -
Persisted Queries:這個主題會在後續的文章中涉及。
注意:這篇教程中提到的大多數js庫都源自Facebook或Apollo。那麼,Apollo究竟是哪位?它是來自於Meteor團隊的一個項目。這些傢伙爲GraphQL貢獻了不少的高質量代碼。頂他們!但他們同時也靠售賣GraphQL相關服務來盈利,因此在盲目聽從他們提供的教程以前,你最好能有個備選方案。
開發一個GraphQL服務端須要比REST服務端更多的工做,但一樣你也會獲得加倍的回報。若是你在讀這篇教程的時候被太多名詞給嚇到失禁,先別慌着擦,你回憶一下當初你學RESTful的時候(URIs,HTTP return code,JSON Schema,HATEOAS),但如今你已是一個REST開發者了。我以爲多花一兩天你也就掌握GraphQL了。這是很是值得投資的。
警告:這個技術依然很年輕,並無什麼權威的最佳時間。我這裏分享的只是我我的的積累。在我學習的過程當中我看過大量的過期的教程,由於這門技術在不停的發展和進化。我但願這篇教程不會那麼快就過期!
參考資料
howtographql: https://www.howtographql.com/
[2]中文版: http://graphql.cn/
[3]這裏: https://marmelab.com/blog/2017/09/06/dive-into-graphql-part-iii-building-a-graphql-server-with-nodejs.html#show-last-Point
[4]領域驅動設計方法: https://en.wikipedia.org/wiki/Domain-driven_design
[5]schema文檔: http://graphql.org/learn/schema/
[6]別這麼作: https://medium.com/@justin_mandzik/graphql-server-whats-after-hello-world-20adb81f1057#ccbb
[7]express microframework: http://expressjs.com/fr/
[8]http://localhost:4000/graphql: http://localhost:4000/graphql
[9]apollo-server: https://github.com/apollographql/apollo-server
[10]GraphiQL: https://github.com/graphql/graphiql
[11]http://localhost:4000/graphql: http://localhost:4000/graphql
[12]Swagger: https://swagger.io/
[13]下載連接: https://github.com/skevy/graphiql-app/releases
[14]resolvers的完整文檔: http://dev.apollodata.com/tools/graphql-tools/resolvers.html
[15]GraphQL執行機制的描述: http://graphql.org/learn/execution/
[16]Dataloader: https://github.com/facebook/dataloader
[17]graphql-errors包: https://github.com/kadirahq/graphql-errors
[18]apollo-tracing-js: https://github.com/apollographql/apollo-tracing-js
[19]Optics: https://www.apollodata.com/optics/
[20]passport.js: http://passportjs.org/
[21]使用graphQL的Mutation來實現註冊和登陸功能: https://www.howtographql.com/graphql-js/5-authentication/
[22]Jest: https://facebook.github.io/jest/
[23]Sinon.js: http://sinonjs.org/
[24]文檔: http://graphql.org/graphql-js/graphql/#graphql
[25]這個教程: http://graphql.org/blog/mocking-with-graphql/
[26]Github: https://github.com/marmelab/GraphQL-example/tree/master/server
[27]HowToGraphQL: GraphQL Security: https://www.howtographql.com/advanced/4-security/
[28]HowToGraphQL: Subscriptions: https://www.howtographql.com/graphql-js/9-subscriptions/
[29]Apollo: Server-Side Subscriptions: https://dev-blog.apollodata.com/tutorial-graphql-subscriptions-server-side-e51c32dc2951
[30]Apollo: GraphQL Input Types And Client Caching: https://dev-blog.apollodata.com/tutorial-graphql-input-types-and-client-caching-f11fa0421cfd
喜歡本文,點個「在看」告訴我
本文分享自微信公衆號 - 大前端技術沙龍(is_coder)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。