0%

GraphQL 用戶端 (3)

訂閱 Subscription

即時更新是現代 web 與行動 app 不可或缺的功能。目前可在網路與行動 app 之間即時傳送資料的技術是 WebSocket。你可以使用 WebSocket 協定在 TCP 通訊端開啟雙向通訊通道。這意味著網頁與 app 可以透過一個連結來傳送與接收資料。這項技術可讓你即時且直接從伺服器推送更新到網頁上。

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

但是如果我們想要充分利用新的網路技術,除了 HTTP 請求之外, GraphQL 也必須能夠支援 WebSocket 上的即時資料傳輸。GraphQL 的解決方案就是訂閱 (subscription)

之前我們已經實作了伺服端支援 Photos 的訂閱了,這裡我們要從客戶端訂閱 newPhoto。訂閱要透過 WebSocket 來運行,為了在客戶端啟用 WebSocket,我們需要再安裝一些套件:

npm install
1
npm install apollo-link-ws apollo-utilities subscriptions-transport-ws --save

我們要在 Apollo Client 組太中加入 WebSocket 連結,我們要修改起始的檔案 main.js:

src/main.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
54
55
56
57
58
59
60
61
62
63
64
import Vue from 'vue'
import App from './App.vue'
import VueApollo from 'vue-apollo'
import {ApolloClient, InMemoryCache, split, HttpLink, ApolloLink } from 'apollo-boost'
import { persistCache } from 'apollo-cache-persist'
import { WebSocketLink } from 'apollo-link-ws'
import { getMainDefinition } from 'apollo-utilities'

Vue.config.productionTip = false
Vue.use(VueApollo);

const cache = new InMemoryCache();
persistCache({
cache,
storage: localStorage
});

if (localStorage['apollo-cache-persist']) {
let cacheData = JSON.parse(localStorage['apollo-cache-persist']);
cache.restore(cacheData);
}

const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
});

const wsLink = new WebSocketLink({
uri: 'ws://localhost:4000/graphql',
options: {
reconnect: true
}
});

const authLink = new ApolloLink((operation, forward) => {
operation.setContext(context => ({
headers: {
...context.headers,
Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6InNjb3R0IiwibmFtZSI6IuiAgeiZjiBTY290dCIsImlhdCI6MTU3NDkwMzA5MSwiZXhwIjoxNTc0OTQ2MjkxfQ.AssjGPY21khE5Xl7K2ZmTVeSerQzrXyOvXKMbEpPL0k'
}
}));
return forward(operation);
});

const httpAuthLink = authLink.concat(httpLink);

const link = split(
({ query }) => {
const { kind, operation } = getMainDefinition(query);
return kind === "OperationDefinition" && operation === "subscription";
},
wsLink,
httpAuthLink
);

const client = new ApolloClient({ cache, link });

const apolloProvider = new VueApollo({
defaultClient: client
});

new Vue({
apolloProvider,
render: h => h(App),
}).$mount('#app')

因修改的蠻多的,所以列出整個 main.js。

第 4 行從 apollo-boost 匯入 split,我們要用它來分開 HTTP 請求與 WebSocket 的 GraphQL 操作。如果操作是 mutation 或 query,Apollo Client 會送出 HTTP 請求。如果操作是 subscription,用戶端會連接 WebSocket。

在 Apollo Client 底層,網路請求是用 ApolloLink 來管理的。在目前 app 中,它負責傳送 HTTP 請求給 GraphQL 伺服器。每當我們用 Apollo Client 傳送操作時,那個操作就會被送到一個 Apollo Link 來處理網路請求。我們也可以使用 Apollo Link 來處理 WebSocket 上的網路。

在第 23 與 27 行,我們需要設定兩種連結來支援 WebSocket: HttpLink 與 WebSocketLink。httpLink 可透過網路傳送 HTTP 請求給 http://localhost:4000/graphql,而 wsLink 可連接 ws://localhost:4000/graphql 以及透過 WebSocket 接收資料。

連結是可組合的,也就是說,你可以將它們接在一起以建立自訂的管道來處理 GraphQL 操作。除了傳送一個操作給一個 ApolloLink 之外,我們也可以透過可重複使用的連結鏈來傳送一個操作,在操作到達連結鏈的最後一個節點之前,每一個節點都可以處理它,最後一個節點則負責處理請求並回傳結果。

在第 34 到 44 行我們加入了一個自訂的 Apollo Link,以 httpLink 建立一個連結鏈,它的工作是為操作加上一個授權標頭。

我們將 httpLink 接到 authLink 來處理 HTTP 請求的使用者授權。請留意,這個 .contact 函式與你在 JavaScript 串接陣列中看到的函式不同。這是個串接 Apollo Link 的特殊函式。串接之後,我們幫這個連結取一個比較好辨識的名稱 httpAuthLink,來更清楚的描述它的行為。當操作被送到這個連結時,它會先被傳給 authLink,在那裡加上授權標頭,再傳給 httpLink 來處理網路請求。如果你熟悉 Express 的中介軟體 (middleware),它們都有類似的模式。

第 46 行,實作了一個 split 連結,它會檢查我們的操作是不是 subscription,如果它是 subscription,就用 wsLink 來處理網路,否則使用 httpLink。第 1 個引數是條件式,它使用 getMainDefinition 函式來檢查操作的 query AST,如果這個操作是個 subscription,條件式會回傳 true。當條件式回傳 true 時,link 會回傳 wsLink。當條件式回傳 false 時,link 會回傳 httpAuthLink。

最後,第 55 行我們更改了 Apollo Client 組態,透過將 link 與 cache 傳給它來使用自訂的連結。

現在用戶端可以開始處裡訂閱了,我們將要用 Apollo Client 送出第一個訂閱操作。

監聽新照片

我們要在 Vue 元件中建立一個 subscribe

src/components/HelloWorld.vue
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
...
mounted() {
const observer = this.$apollo.subscribe({
query: gql`subscription newPhoto {
newPhoto {
id
name
description
category
postedBy {
name
}
}
}`,
variables: {}
});

observer.subscribe({
next (data) {
console.log(data)
},
error (error) {
console.error(error)
}
});
},
...

這裡使用標準的 $apollo.subscribe( ) 方法來訂閱 GraphQL subscripton,該訂閱將在銷毀組件時會自動終止。目前我們只將它顯示在瀏覽器的 console,現在將收到的新照片直接更新客戶端的快取,vue-apollo 則會連動 Vue 屬性的綁定,重新喧染 UI。

src/components/HelloWorld.vue
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
54
55
...
mounted() {
const client = this.$apollo.getClient();

const LISTEN_FOR_PHOTOS = gql`subscription newPhoto {
newPhoto {
id
name
description
category
postedBy {
name
}
}
}`;

const LISTPHOTOS_QUERY = gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`;

const observer = this.$apollo.subscribe({
query: LISTEN_FOR_PHOTOS,
variables: {}
});

observer.subscribe({
next ( {data: { newPhoto }} ) {
const cacheData = client.readQuery({
query: LISTPHOTOS_QUERY
});

cacheData.totalPhotos += 1;
cacheData.allPhotos.push({
id: newPhoto.id,
name: newPhoto.name,
description: newPhoto.description,
__typename: newPhoto.__typename
});

client.writeQuery({
query: LISTPHOTOS_QUERY,
data: cacheData
});
},
error (error) {
console.error(error)
}
});
},
...

第 3 行取得 Apollo Client 實例。

第 5 行定義 GraphQL subscription。當在使用 Apollo Client 時,習慣上會將 query 定義在一個常數上,如果有許多不同的元件共用,也可將它模組化,集中在一個檔案中。

第 17 行定義 GraphQL query,與 subscription 一樣將它定義在一個常數上。

第 33 行抓取 GraphQL query listPhotos 的快取資料。

第 37、38 行更改從快取抓出來的資料。

第 45 行寫回快取。

除了使用 Apollo subscribe 的標準模式外,也可以使用 Vue Apollo 的另一種簡易模式:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
apollo: {
...
$subscribe: {
newPhoto: {
query: LISTEN_FOR_PHOTOS,
result(result, key) {
console.log(result);
console.log(key);
},
error(err) {
console.lot(err.message);
}
}
}
},
...

這是最後 src/components/HelloWorld.vue 最後的樣子,僅供參考。你如果在這個元件介面中按下新增照片,你的照片數量會增加兩個,這當然是錯的,但為什麼,自己想想吧!

src/components/HelloWorld.vue
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<h1>{{ helloTainan }}</h1>
<button @click="addPhoto">Click to add new photo</button>
<p />
<button @click="clearUsers">Click to clear users</button>
<h1>Total Users: {{ totalUsers }} Total Photos: {{ totalPhotos }}</h1>
<ul>
<li v-for="user in allUsers" :key="user.appLogin">
{{ user.appLogin }} {{ user.name }}
</li>
</ul>
</div>
</template>

<script>
import { gql } from 'apollo-boost'

const LISTEN_FOR_PHOTOS = gql`subscription newPhoto {
newPhoto {
id
name
description
category
postedBy {
name
}
}
}`;

const LISTPHOTOS_QUERY = gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`;

export default {
name: 'HelloWorld',
props: {
msg: String
},
data() {
return {
helloTainan: undefined,
totalUsers: undefined,
totalPhotos: undefined,
loading: true
}
},
apollo: {
helloTainan: {
query: gql`query hello {
helloTainan: sayHello
}`
},
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
result ({ data, loading }) {
this.totalUsers = data && data.totalUsers;
this.loading = loading;
},
fetchPolicy: 'cache-and-network'
},
allPhotos: {
query: LISTPHOTOS_QUERY,
result({ data, loading }) {
this.totalPhotos = data.totalPhotos;
this.loading = loading;
},
fetchPolicy: 'cache-and-network'
},
$subscribe: {
newPhoto: {
query: LISTEN_FOR_PHOTOS,
result(result, key) {
console.log(result);
console.log(key);
},
error(err) {
console.lot(err.message);
}
}
}
},
methods: {
addPhoto() {
const newPhoto = {
name: "範例照片 from Vue mutation !",
description: `我們資料庫的範例照片 ${new Date().toISOString()}`
};

this.$apollo.mutate({
mutation: gql`mutation addPhoto($input: PostPhotoInput!) {
postPhoto(input: $input) {
id
name
description
}
}`,
variables: {
input: newPhoto,
},
update: (store, { data: { postPhoto }}) => {
const LISTPHOTOS_QUERY = gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`;
const cacheData = store.readQuery({
query: LISTPHOTOS_QUERY
});

cacheData.totalPhotos += 1;
cacheData.allPhotos.push(postPhoto);

store.writeQuery({
query: LISTPHOTOS_QUERY,
data: cacheData
});

console.log(postPhoto);
console.log(cacheData);
},
optimisticResponse: {
__typename: 'Mutation',
postPhoto: {
__typename: 'Photo',
id: -1,
...newPhoto
}
}
}).then((data) => {
console.log(data);
//this.$apollo.queries.allPhotos.refetch();
}).catch((error) => {
console.error(error);
});
},
clearUsers() {
const client = this.$apollo.getClient();
client.writeQuery({
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
data: {
totalUsers: 0,
allUsers: []
}
});
}
},
mounted() {
const client = this.$apollo.getClient();

const observer = this.$apollo.subscribe({
query: LISTEN_FOR_PHOTOS,
variables: {}
});

observer.subscribe({
next ( {data: { newPhoto }} ) {
const cacheData = client.readQuery({
query: LISTPHOTOS_QUERY
});

cacheData.totalPhotos += 1;
cacheData.allPhotos.push({
id: newPhoto.id,
name: newPhoto.name,
description: newPhoto.description,
__typename: newPhoto.__typename
});

client.writeQuery({
query: LISTPHOTOS_QUERY,
data: cacheData
});
},
error (error) {
console.error(error)
}
});
},
created() {
const client = this.$apollo.getClient();

const data = client.readQuery({
query: LISTPHOTOS_QUERY
});
console.log(data);
}
}
</script>

事實上,這個元件並不需要 subscription,我們先前實作 mutation 時有實作 Optimistic UI,快取中已加入了新增的照片。這裡的 subscription 又加了一次。

除了標準的 Apollo subscription 與簡易的 subscription 之外,如果需要從訂閱更新智能查詢 (Smart Query) 結果,最好的方法是使用 subscribeToMore 智能查詢 (Smart Subscriptions) 方法。我們可以將 query allPhotos 與 subscription newPhoto 結合:

src/components/HelloWorld.vue
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
...
allPhotos: {
query: LISTPHOTOS_QUERY,
result({ data, loading }) {
this.totalPhotos = data.totalPhotos;
this.loading = loading;
},
fetchPolicy: 'cache-and-network',
subscribeToMore: {
document: LISTEN_FOR_PHOTOS,
variables: { },
updateQuery: (previousResult, { subscriptionData }) => {
const newPhoto = {
id: subscriptionData.data.newPhoto.id,
name: subscriptionData.data.newPhoto.name,
description: subscriptionData.data.newPhoto.description,
__typename: subscriptionData.data.newPhoto.__typename,
};

return {
totalPhotos: previousResult.totalPhotos + 1,
allPhotos: [
...previousResult.allPhotos,
newPhoto
]
};
},
}
},
...

或是另外一種寫法:

src/component/WorldHello.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
// Smart Subscriptions
this.$apollo.queries.allPhotos.subscribeToMore({
document: LISTEN_FOR_PHOTOS,
variables: { },
updateQuery: ({ totalPhotos, allPhotos }, { subscriptionData: { data: { newPhoto } } }) => {
const { id, name, description, __typename } = newPhoto;

return {
totalPhotos: totalPhotos + 1,
allPhotos: [
...allPhotos,
{ id, name, description, __typename }
]
};
},
});
...

改變一下第 6 行 updateQuery 引數的寫法,讓程式碼看起來清爽些。使用 JavaScript 的解構賦值 (Destructuring assignment) 語法。

在這個例子,我們將新增照片所需授權的權杖寫死在 main.js 中,我們可以加個認證按鈕,認證成功後可將權杖儲存到 localStorege 中:

src/components/HelloWorld.vue
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
...
methods: {
...
authentication() {
this.$apollo.mutate({
mutation: gql`mutation login($login: String! $password: String) {
appAuth(login: $login password: $password) {
user {
appLogin
name
}
token
}
}`,
variables: {
login: "scott",
password: "tiger"
}
}).then( ({ data }) => {
console.log('Authentication successful');
localStorage.setItem('token', data.appAuth.token);
}).catch (err => {
console.log(err.message);
localStorage.removeItem('token');
});
}
},
...

在 template 中加個按鈕。 這裡的 login 與 password 寫死在程式碼中,你應該實作一個 HTML Form Input 來讓使用者輸入。

src/components/HelloWorld.vue
1
2
3
...
<button @click="authentication">Click to authentication</button>
...

最後修改 main.js,從 localStorege 種抓取權杖。

src/main.js
1
2
3
4
5
6
7
8
9
10
11
...
const authLink = new ApolloLink((operation, forward) => {
operation.setContext(context => ({
headers: {
...context.headers,
Authorization: `Bearer ${localStorage.getItem('token')}`
}
}));
return forward(operation);
});
...

祝有個美好的一天與體驗!