0%

建立 GraphQL API (6)

訂閱 Subscription

即時更新是現代 web 與行動 app 不可或缺的功能。目前可在網路與行動 app 之間即時傳送資料的技術是 WebSocket。

你可以使用 WebSocket 協定在 TCP 通訊端開啟雙向通訊通道。這意味著網頁與 app 可以透過一個連結(connection)來傳送與接收資料。這項技術可讓你即時且直接從伺服器推送更新到客戶端網頁上。

到目前為止,我們都用 HTTP 協定來實作 GraphQL 查詢與 mutation。HTTP 提供了在用戶端與伺服器之間傳送與接收資料的手法,但它無法協助我們連接伺服器與監聽狀態的改變。在 WebSocket 出現前,監聽伺服器上的狀態改變唯一的方式就是不斷傳送 HTTP 請求給伺服器來確定是不是有所改變,也就是所謂的輪詢 (Long Polling)。

使用訂閱

在 GraphQL,我們要使用訂閱來監聽 API 以得知特定資料的改變。Apollo Server 已經支援訂閱了。它包裝了一些 npm 套件,可用來設定 GraphQL app 的 WebSocket: graphql-subscriptionssubscriptions-transport-ws

graphql-subscriptions 套件是一種 npm 套件,它提供了一種發佈者/訂閱者 (publisher / subscriber) 設計模式的實作,PubSub。PubSub 是發佈資料變動好讓訂閱的用戶端接收的工具。subscriptions-transport-ws 是一種 WebSocket 伺服端與用戶端,可讓你在 WebSocket 上傳送訂閱。Apollo Server 在一開始就會自動加入這兩個套件來支援訂閱。

在預設情況下,Apollo Server 會在 ws://localhost:4000 設定 WebSocket,所以如果你使用簡單的 Apollo Server 組態,那它已經是個支援 WebSocket 組態了。

因為我們使用 apollo-server-express,所以必須執行一些步驟來讓訂閱開始運作。我們必須修改專案根目錄下的 index.js。 首先從 Node.js 原生的 http 模組匯入 createServer 函式:

index.js
1
const { createServer } = require('http');

Apollo Server 會自動設定訂閱的支援,但為了執行這項工作,它需要一個 HTTP 伺服器。我們將使用 createServer 來建立一個。在最下端將程式碼改為:

index.js
1
2
3
4
5
6
7
8
9
...

const httpServer = createServer(app);
server.installSubscriptionHandlers(httpServer);

httpServer.listen({ port: 4000 }, () => {
console.log(`GraphQL Server running @ http://localhost:4000${server.graphqlPath}`);
console.log(`GraphQL Subscriptions running @ ws://localhost:4000${server.subscriptionsPath}`);
});

首先,我們用 Express app 實例來建立一個新的 httpServer。依照目前的 Express 組態,這個 httpServer 已經可以處裡收到的所有 HTTP 請求了。我們也有一個伺服器實例,可以在裡面加入 WebSocket 支援。

server.installSubscriptionHandlers(httpServer) 是讓 WebSocket 運作的程式。這是 Apollo Server 加入必要的處裡程式來支援使用 WebSocket 的所有訂閱的地方。除了 HTTP 伺服器之外,我們的後端現在已經可以接收 ws://localhost:4000/graphql 的請求了。

讓伺服器支援訂閱之後,接下來我們要實作訂閱。

實作訂閱

我們想要知道使用者何時張貼照片,這是一個很好的訂閱使用案例。如同 GraphQL 的其它型態,為了實作訂閱,我們必須從 schema 開始著手。我們要在 schema 子目錄下新增一個程式檔 subscription.js 專們來擺放訂閱,這裡要加入一個 subscription 型態:

schema/subscription.js
1
2
3
4
5
6
7
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Subscription {
newPhoto: Photo!
}
`;

有了新的 schema 定義,我們也需要修改 schema 目錄下的 index.js 將 subscription.js 匯入,並併接起來 :

schema/index.js
1
2
3
4
...
const subscriptionSchema = require('./subscription');
...
module.exports = [linkSchema, userSchema, photoSchema, subscriptionSchema];

我們會在照片被加入時使用 newPhoto subscription 來將資料推送到客戶端。我們可以在客戶端用下列的 GraphQL 查詢語言來傳送訂閱操作:

GraphQL Playground newPhoto subscription
1
2
3
4
5
6
7
8
9
10
11
subscription {
newPhoto {
name
url
category
postedBy {
appLogin
name
}
}
}

這個 subscription 會將新照片的資料推送到用戶端。如同 Query 與 Mutation,GraphQL 可讓我們用選擇組來請求關於特定欄位的資料。

當客戶端的 subscription 訂閱被送到伺服器時,連結會保持開啟,它會監聽資料的改變,每當照片被加入時,就會將資料傳給訂閱者。當你用 GraphQL Playground 設置訂閱時,會看到 Play 按鈕變成紅色的 Stop 按鈕。

Stop 按鈕代表訂閱目前是開啟的,並且正在監聽資料。當你按下 Stop 按鈕時,訂閱就會被取消,它會停止監聽資料的改變。

再來我們需要修改 postPhoto mutation,目前這個 mutation 是將新照片加入資料庫。我們想要在這同時將新照片的資料發佈給 subscription:

resolvers/photo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
Mutation: {
async postPhoto(parent, args, { db : { photos }, currentUser, pubsub }) {
if (!currentUser) {
throw new Error("only an authorized user can post a photo");
}

let newPhoto = {
...args.input,
appUser: currentUser.appLogin,
created: new Date()
};
const insertedPhoto = await photos.insert("", newPhoto);

pubsub.publish('photo-added', { newPhoto: insertedPhoto});

return insertedPhoto;
}
},
...

這個解析函式第 3 行我們在傳入的 context 中多了一個 pubsub 實例,我們會在稍後來作這件事。pubsub 是一種可以發佈事件並傳送資料給訂閱解析函式的機制。它就像 Node.js EventEmitter。你可以用它來發佈事件,並將資料傳給每一個訂閱某個事件的處裡程式。

在這裡第 15 行,我們在將新照片加入資料庫之後發佈一個 photo-added 事件,並將 insertedPhoto 資料傳給訂閱 photo-added 事件的每一個處裡程式。

接下來加入 Subscription 解析函式,它的用途是訂閱 photo-added 事件。與 schema 一樣,我們在 resolvers 子目錄下新建立一個 subscription.js 程式檔:

resolvers/subscription.js
1
2
3
4
5
6
7
8
module.exports = {
Subscription: {
newPhoto: {
subscribe: (parent, args, { pubsub }) =>
pubsub.asyncIterator("photo-added")
}
}
};

一樣要將它匯入 resolvers 目錄下的 index.js:

resolvers/index.js
1
2
3
4
...
const subscriptionResolvers = require('./subscription');

module.exports = [userResolvers, photoResolvers, subscriptionResolvers];

Subscription 解析函式是根解析函式。你要將它直接加到 Query 與 Mutation 解析函式一樣的物件中。在 Subscription 解析函式中,我們要定義每個欄位的解析函式。因為我們在 schema 裡面定義了 newPhoto 欄位,所以必須確保解析函式裡面有 newPhoto。

與 Query 和 Mutation 不同的是,Subscription 解析函式有一個 subscribe 訂閱方法,與任何其它解析函式一樣,這個 subscribe 方法可接收 parent、args 與 context,我們在這個 subscribe 方法裡面訂閱特定的事件。在本例中,我們使用 pubsub.asyncIterator 來訂閱 photo-added 事件。每當 pubsub 發出 photo-added 事件時,它就會被傳給這個 newPhoto subscription。

postPhoto mutation 解析函式與 newPhoto subscription 解析函式都期望收到 context 中的 pubsub 實例,我們要修改 context 來加入 pubsub 實例。我們要修改專案根目錄下的 index.js,修改後會如下:

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const { createServer } = require('http');
const express = require('express');
const { ApolloServer, PubSub } = require('apollo-server-express');
const cors = require('cors');
const db = require('./models/dbs');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');
const userService = require('./services/user.service');

const app = express();
app.use(cors());

const context = async ({ req, connection }) => {
let currentUser = null;

try {
const token = req
? req.headers.authorization.split(" ")[1]
: connection.context.Authorization.split(" ")[1];

const payload = token && userService.jwtVerify(token);
currentUser = payload && (await db.users.findOne(payload.appLogin));
} catch {
currentUser = null;
}

return {
db,
userService,
currentUser,
pubsub
};
};

const pubsub = new PubSub();

const server = new ApolloServer({
typeDefs,
resolvers,
context
});

server.applyMiddleware({ app });

app.get('/', (req, res) => res.end("Welcome to the GraphQL Sample API"));

const httpServer = createServer(app);
server.installSubscriptionHandlers(httpServer);

httpServer.listen({ port: 4000 }, () => {
console.log(`GraphQL Server running @ http://localhost:4000${server.graphqlPath}`);
console.log(`GraphQL Subscriptions running @ ws://localhost:4000${server.subscriptionsPath}`);
});

首先在第 3 行從 apollo-server-express 套件匯入 PubSub 建構式,在第 35 行我們使用這個建構式建立一個 pubsub 實例,再來就要修改 context 函式將 pubsub 加入 context。

在第 13 行我們加入了一個 connection 引數,query 與 mutation 仍然使用 HTTP 協定,當我們傳送這些操作 (operation) 給 GraphQL 伺服器時,HTTP 請求會帶有兩個引數物件 req 與 res,這裡 req 會被送到 context 函式;但是當操作是 Subscription 時沒有 HTTP 請求,所以 req 引數會是 null,在此會改用 WebSocket connection 物件當成引數傳給 context 函式。所以當我們有訂閱時,必須透過 connection 的 context 來傳送授權 (token) 資料,而不是透過 HTTP 請求標頭 (HTTP headers) 的 Authorization。所以同時得修改第 17 行的 token 設定值。

現在可以使用 GraphQL Playground 來試一下訂閱功能了。

GraphQL Playground newPhoto subscription
1
2
3
4
5
6
7
8
9
10
11
12
subscription {
newPhoto {
name
url
category
created
postedBy {
appLogin
name
}
}
}

開啟另外一個 GraphQL Playground 頁籤執行 postPhoto mutation:

GraphQL Playground postPhoto mutation
1
2
3
4
5
6
7
8
9
mutation newPhoto($input: PostPhotoInput!) {
postPhoto(input: $input) {
id
name
url
description
category
}
}

記得在 QUERY VARIABLES 加入 JSON 資料:

GraphQL Playground QUERY VARIABLES:
1
2
3
4
5
6
{
"input": {
"name": "範例照片 ABCD EFG",
"description": "我們資料庫的範例照片"
}
}

每當你執行這個 mutation 時,就會看到新的照片資料被送到訂閱。

接續下一篇 建立 GraphQL API (7)