你不知道的 GraphQL


本文由 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鍵的實體(如TweetUser)。你也能夠用它來定義值對象,這種類型嵌套在實體內部,所以不須要惟一鍵(例如Stat)。

Tip 儘量保證Type足夠輕巧,而後利用組合。舉個例子,儘管stats數據如今看來和tweet數據關係很近,可是請分開定義它們。由於它們表達的領域不一樣。這樣當有天將stats數據換其它底層架構來維護,你就會慶幸今天作出的這個決定。

QueryMutation關鍵字有特別的含義,它們用來定義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庫會基於咱們定義的schemaresolver函數來建立一個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,
    graphiqltrue,
}));
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}]
}
]
}

Tipexpress-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 = [
    { id1body'Lorem Ipsum'datenew Date(), author_id10 },
    { id2body'Sic dolor amet'datenew Date(), author_id11 }
];
const authors = [
    { id10username'johndoe'first_name'John'last_name'Doe'avatar_url'acme.com/avatars/10' },
    { id11username'janedoe'first_name'Jane'last_name'Doe'avatar_url'acme.com/avatars/11' },
];
const stats = [
    { tweet_id1views123likes4retweets1responses0 },
    { tweet_id2views567likes45retweets63responses6 }
];

而後咱們來告訴服務如何使用這些數據來處理TweetTweets查詢請求。下面列出了resover映射關係,這個對象按照schema的結構,爲每一個字段提供了一個函數:

const resolvers = {
    Query: {
        Tweets() => tweets,
        Tweet(_, { id }) => tweets.find(tweet => tweet.id == id),
    },
    Tweet: {
        idtweet => tweet.id,
        bodytweet => 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 } }這個查詢的處理步驟爲:

  1. 最外層爲 Tweet,對應的 resolver(Query.Tweet)。由於是最外層,因此調用 resolver函數時第一個參數爲null。第二個參數傳遞的是查詢攜帶的參數 { id: 2 }。根據 schema的定義,該 resolver函數會返回知足條件的 Tweet類型對象。
  2. 針對每一個 Tweet對象,服務會執行對應的 (Tweet.id)(Tweet.body)resolver函數。此時第一個參數爲第一步獲得的 Tweet對象。

目前咱們的Tweet.idTweet.bodyresolver函數很是的簡單,事實上我根本不須要聲明它們。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,
                datenew 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
}
}
}
  1. 對於最外層的 Tweets查詢塊,GraphQL執行 Query.Tweetsresolver,第一個參數爲null。resolver函數返回 Tweets數組。
  2. 針對數組中的每一個 Tweet,GraphQL併發的執行 Tweet.idTweet.bodyTweet.AuthorTweet.Statsresolver函數。
  3. 注意此次我並無提供關於 Tweet.idTweet.body的resolver函數,GraphQL使用默認的resolver。對於 Tweet.Authorresolver函數,會返回一個 User類型的對象,這是 schema中定義好的。
  4. 針對 User類型數據,查詢會併發的執行 User.usernameUser.full_nameresolver,並傳遞上一步獲得的 Author對象做爲第一個參數。
  5. State處理一樣會使用默認的resolver來解決。

因此,這就是GraphQL的核心,很是的酷炫。它能夠處理複雜的多層嵌套查詢。這就是爲啥成它爲Graph的緣由吧,此刻你應該頓悟了吧?!啊哈~

你能夠在graphql.org網站找到關於GraphQL執行機制的描述[15]

對接真正的數據庫

在真實項目中,resolver須要和數據庫或其它API打交道來獲取數據。這和咱們上面作的事兒沒有本質不一樣,除了須要返回一個promises外。假如tweetsauthors數據存儲在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,
        graphiqltrue,
        context: { pgClient, mongoClient }),
    }));
    app.listen(4000);
};

start();

注意,因爲咱們的數據庫操做只支持異步操做,因此咱們須要改爲promise寫法。我把數據庫連接句柄對象保存在GraphQL的context中,context會做爲第三個參數傳遞給全部的resolver函數。tontext很是適合用來處理須要在多個resolver中共享的資源,有點相似其它框架中的註冊表實例。

如你所見,咱們很容易就作到從不一樣的數據源中聚合數據,客戶端根本不知道數據來自於哪裏 - 這一切都隱藏在resolver中。

1+N查詢問題

迭代查詢語句塊來調用對應的resolver函數確實聰明,但性能可能不太好。在咱們的例子中,Tweet.Authorresolver被調用了屢次,針對每一個從Query.Tweetsresolve中獲得的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 = () => ({
    userByIdnew 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,
    graphiqltrue,
    context: {  pgClient, mongoClient, dataloaders: dataloaders() },
})));

如今咱們只須要稍微修改一下Tweet.Authorresolver便可:

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對象。

但此次變更會致使前面的代碼報錯,由於pgClientgetUsersById函數的上下文中就不存在了。爲了傳遞數據庫連接句柄到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 => ({
    userByIdnew DataLoader(getUsersById(pgClient)),
});
// ...
app.use('/graphql', graphqlHTTP(req => ({
    schema: schema,
    graphiqltrue,
    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: { loge => 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 }) => ({
          timingDate.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: [
                    { id1body'hello' },
                    { id2body'world' },
                ]});
            }
        };
        const context = { pgClient: { query: queryStub } };
        return Query.Tweets(nullnull, context).then(results => {
            expect(results).toEqual([
                { id1body'hello' }
                { id2body'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,
    graphiqltrue,
}));
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 = {
    QueryObject.assign({}, TweetQuery, UserQuery),
    MutationObject.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的基礎類型,和空的querymutation類型聲明 - 其它片斷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不支持空的類型,因此咱們不得不聲明一個看起來毫無心義的querymutation。注意,文件最後導出的是一個數組而非字符串。後面你就會知道是爲啥了。

如今,在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了。這是很是值得投資的。

警告:這個技術依然很年輕,並無什麼權威的最佳時間。我這裏分享的只是我我的的積累。在我學習的過程當中我看過大量的過期的教程,由於這門技術在不停的發展和進化。我但願這篇教程不會那麼快就過期!

參考資料

[1]

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源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索