0%

Mutation

Mutation 會更改 Apollo 伺服器服務的數據狀態。基本的運作方式與 query 沒甚麼不同:

在實作 mutation 之前,我們先新加入一個 query 來顯示照片的變化:

src/component/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
allPhotos: {
query: gql`query listPhotos {
totalPhotos
allPhotos {
id
name
description
}
}`,
result({ data, loading }) {
this.totalPhotos = data.totalPhotos;
this.loading = loading;
}
}
...

現在我們在 Vue 的 methods 增加一個 addPhoto 的操作方法:

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: {
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,
}
}).then((data) => {
console.log(data);
this.$apollo.queries.allPhotos.refetch();
}).catch((error) => {
console.error(error);
});
},
},
...
  • 第 4 行的新照片,在實際運作上應該是要由用戶輸入的,這裡讓他單純化,避免失焦。
  • 第 9 行用 this.$apollo.mutate( ) 方法送出 GraphQL mutation,語法與 query 差不多,這裡我們使用一個變數 $input。this.$apollo.mutate( ) 會返回一個 Promise,他返回的值就是你在第 11 行 postPhoto 時所定義的,這裡就是 Photo 的三個屬性 id、name 與 description。
  • 第 22 行我們並重新抓取 allPhotos 的資料,Vue 則會同時更新畫面。

接下來我們要在使用者介面上新增一個按鈕來觸發這個 mutation,並新增一個訊息顯示照片的總數量:

src/components/HelloWorld.vue
1
2
3
4
...
<button @click="addPhoto">Click to add new photo</button>
<h1>Total Users: {{ totalUsers }} Total Photos: {{ totalPhotos }}</h1>
...

現在可以測試看看了。

先前談過 Apollo Client 支援積極 UI 更新 (optimistic UI update),因此 mutation 也提供了一些 Hook,主要的就是 update 與 optimisticResponse Hook。我們不一定要用積極 UI 更新,但了解它可讓我們了解使用者 UI 的技術。

Optimistic UI update

當我們新增或更新資料成功後,伺服器都會返回一個結果,以便我們更新使用者的介面 (UI),但我們為了讓使用者感覺反應快速,就會使用送出的數據,樂觀的當成伺服器更新成功後返回的數據來更新使用者介面,並在伺服器真正返回結果時,再來更新錯誤。

現在修改一下 mutation,加入 update 與 optimisticResponse Hook:

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
...
this.$apollo.mutate({
mutation: gql`mutation addPhoto($input: PostPhotoInput!) {
postPhoto(input: $input) {
id
name
description
}
}`,
variables: {
input: newPhoto,
},
update: (store, { data }) => {
console.log('mutation update begin');
console.log(data);
console.log('mutation update end');
},
optimisticResponse: {
__typename: 'Mutation',
postPhoto: {
__typename: 'Photo',
id: -1,
...newPhoto
}
}
}).then((data) => {
console.log(data);
this.$apollo.queries.allPhotos.refetch();
}).catch((error) => {
console.error(error);
});
...
  • 第 13 行加入了 update Hook,update( ) 函式這裡接受兩個引數 store 與儲存資料的物件,每一個 mutation 它總是會被調用兩次,第一次使用 optimisticResponse 返回的樂觀資料,第二次調用則會是實際返回的資料。這裡我們只是把它顯示出來,沒有做其他的處裡。store 實際上是 Apollo Client 的 cache,因此可以用這調用的時機更新 cache 的資料,可以在第二次調用時回滾 (Rollback) 第一次的樂觀資料用實際返回的資料取代。因此這個功能也必須與 Apollo Client 的快取一起使用。我們將在稍後談到 Apollo Client Cache。
  • 第 18 行 optimisticResponse 在設定樂觀的資料,此時 id 還沒有產生,暫時用 -1。如果這裡沒有設定 optimisticResponse 屬性,則 update( ) 只會被調用一次。

如果你只想在完成 mutation 後執行一些操作,也不需要積極 UI 更新,使用 Promise then 就可以了。

使用快取 Cache

身為開發者,我們應該盡量減少網路請求。我們不希望讓使用者被迫發出無關的請求。為了盡量減少 app 送出的網路請求數量,Apollo Client 提供了 Cache 機制。

抓取策略 Fetch Policy

在預設情況下,Apollo Client 會在一個本地的 JavaScript 變數中儲存資料。每當我們建立用戶端時,Apollo Client 就會為我們建立一個快取。每當我們傳送操作時,回應就會被存到本地快取。fetchPolicy 可告訴 Apollo Client 該去哪裡尋找解析操作所需要的資料,究竟是本地快取還是網路請求。預設的 fetchPolicy 是 cache-first。這代表用戶端會在本地的快取尋找資料來解析操作。如果用戶端可以在不傳送網路請求的情況下解析操作,它就會這樣做。但是,如果解析 query 所需的資料不在快取裡面,用戶端會傳送網路請求給 GraphQL 服務。

另一種類型的 fetchPolicy 是 cache-only。這種作法要求用戶端只在快取中尋找資料,永遠不要傳送網路請求。如果快取裡面沒有滿足 query 的所有資料,就丟出錯誤。

我們修改一下程式,來改變各個 query 的抓取策略:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
result ({ data, loading }) {
this.totalUsers = data.totalUsers;
this.loading = loading;
},
fetchPolicy: 'cache-only'
},
...

第 14 行加入了 fetchPolicy,並設定為 cache-only,當你重新整理瀏覽器時應該會看不到 Users 的資料,因為 Apollo Client 只會在快取裡面尋找資料來解析 query,但是 app 啟動時,那些資料並不存在。為了清除這個錯誤,我們將抓取策略改成 cache-and-network。

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
result ({ data, loading }) {
this.totalUsers = data && data.totalUsers;
this.loading = loading;
},
fetchPolicy: 'cache-and-network'
},
...

這個 app 又可以正常運作了。 cache-and-network 策略一定會立刻用快取來解析查詢,同時也會傳送網路請求以取得最新的資料。如果本地快取不存在,例如當 app 啟動時,這個策略只會從網路接收資料。在第 11 行我們也做了一些變更,否則當抓不到快取資料時,data 值會是 undefined,瀏覽器的 console 總是會出現錯誤。這也是一個良好的習慣,當要抓取某個物件中屬性的值時,先檢查這個物件是否存在。

其他的策略包括:

  • network-only 一定會傳送網路請求來解析 query

  • no-cache 一定會傳送網路請求來解析 query,不會將回應結果存放在快取內。

保存快取

在預設情況下,Apollo Client 會在一個本地的 JavaScript 變數中儲存資料。每當我們建立用戶端時,Apollo Client 就會為我們建立一個快取。但我們可以在用戶端儲存快取,這可以完全發揮 cache-first 策略的威力,因為當使用者回到 app 時,永遠都會有快取。在這裡,cache-first 策略會立刻解析既有本地快取的資料,而且完全不會用網路傳送請求。

為了在本地除存快取資料,我們要安裝一個 npm 套件:

npm install
1
npm install apollo-cache-persist --save

apollo-cache-persist 套件有一個函式可以改善快取,它的做法是當快取改變時,就將它存放在本地端。為了實作快取保存,我們要建立自己的 cache 物件,並且在設置 app 時將他加入 client。

src/main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
import ApolloClient, { InMemoryCache } from 'apollo-boost'
import { persistCache } from 'apollo-cache-persist'
...
const cache = new InMemoryCache();
persistCache({
cache,
storage: localStorage
});
...
const client = new ApolloClient({
cache,

...

});
...

第 5 行我們用 apollo-boost 提供的 InMemoryCache 建構式來建立自己的快取實例,並連同 storage 位置一起將它傳給 persistCache 方法。第 12 行將 cache 加入 ApolloClient。

我們選擇將快取存放在瀏覽器的 localStorage,啟動 app 後,你可以從瀏覽器 console 中檢查它:

或者直接檢查 localStorege:

下一步是在啟動時檢查 localStorege,看看快取是否已經被儲存了。如果有,在建立用戶端之前,我們要用那些資料來初始化本地的 cache:

src/main.js
1
2
3
4
5
6
7
8
9
10
11
12
...
const cache = new InMemoryCache();
persistCache({
cache,
storage: localStorage
});

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

現在我們的 app 會在啟動前載入任何已被快取的資料。如果我們有資料存放在 Key apollo-cache-persist 之下,就要使用 cache.restore(cacheData) 方法來將它加入 cache 實例。

我們已經用 Apollo Client 的快取成功的將送到 GraphQL 服務的網路請求減到最少了。再來,我們將看看如何將資料直接寫入快取。

更新快取

可以直接從快取讀出資料,這是讓 cache-only 這類的策略得以執行的要素。我們也能夠與 Apollo Cache 直接互動。我們可以從快取讀出目前的資料,或將資料直接寫入快取。每當我們改變快取內的資料時,vue-apollo 就會發現那項改變,並重新喧染所有被影響的元件。我們只要修改快取,UI 就會隨著改變,自動更新。

Apollo Cache 的資料是用 GraphQL query 寫入的,所以也必須用 GraphQL query 讀取,我們來看先前 allUsers 的 GraphQL query:

src/components/HolloWorld.vue
1
2
3
4
5
6
7
8
9
10
...
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
...

這是 allUsers 的 GraphQL query,因此目前 Apollo Cache 的資料就是用這段 GraphQL query 寫入的,我們必須用相同的 GraphQL query 從 cache 讀取。

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
created() {
const client = this.$apollo.getClient();
const { totalUsers, allUsers } = client.readQuery({
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`
});

console.log(totalUsers ? totalUsers : 0);
console.log(allUsers ? allUsers: []);
}
...

第 3 行我們取得 Apollo Client 實例。
第 4 行用 client.readQuery( ) 讀取資料。

這樣我們就取得存放在快取裡面的 totalUsers 與 allUsers 的值。

我們也可以使用 cache.writeQuery 方法來將資料直接寫入這個 GraphQL query 的 totalUsers 與 allUsers 欄位:

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
...
methods: {
...
clearUsers() {
const client = this.$apollo.getClient();
client.writeQuery({
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
data: {
totalUsers: 0,
allUsers: []
}
});
}
},
...

第 6 行使用 client.writeQuery( ) 方法直接更改快取,這裡清除 totalUsers 與 allUsers 欄位。

現在在使用者介面增加一個按鈕來觸發 clearUsers( ):

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

觸發按鈕修改快取,UI 就會隨著改變,自動更新。

但是這裡要注意的是,因為我們有將 cache 存入 localStorage,當下次重新啟動 app 時,我們之前有在啟動 app 時,將 localStorege 的資料 restore 回 Apollo Cache,所以如果你使用的是預設的 fetchPolicy cache-first 資料將不會更新,會保持在被清除的狀態。所以必須將 fetchPolicy 設定為 cache-and-network。

src/components/HelloTainan.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 ...
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
result ({ data, loading }) {
this.totalUsers = data && data.totalUsers;
this.loading = loading;
},
fetchPolicy: 'cache-and-network'
},
...

以下是有效的 fetchPolicy 值:

  • cache-first:這是預設值,始終嘗試先從快取中讀取數據。如果滿足查詢所需的所有數據都在快取中,則返回該數據。**僅當沒有快取結果時,Apollo 才會從網絡獲取。 此抓取策略旨在最大程度地減少渲染組件時發送的網絡請求的數量。

  • cache-and-network:此抓取策略將使 Apollo 首先嘗試從快取中讀取數據。如果滿足查詢所需的所有數據都在快取中,則返回該數據。但是,無論快取中是否有完整數據,該 fetchPolicy 始終會通過網絡執行查詢,這與 cache-first 不同,後者僅在查詢數據不在快取中時才執行查詢。 此抓取策略優化了用戶的響應速度,同時還嘗試使快取的數據與服務伺服器器數據保持一致,但以額外的網絡請求為代價。

  • network-only:此抓取策略將永遠不會從快取中返回初始數據。 相反,它將始終使用網絡向服務器發出請求。這種抓取策略可優化與服務器的數據一致性,但是會以對用戶的即時響應為代價。

  • cache-only:此抓取策略將永遠不會使用網絡執行查詢。 相反,它將始終嘗試從快取中讀取。如果用於查詢的數據在=快取中不存在,則將引發錯誤。此抓取策略僅允許與本地客戶端快取中的數據進行交互,而不會發出任何的網絡請求,以保持客戶端的組件保持快速的運行,但是這意味著本地數據可能與服務器上的數據不一致。如果只對與 ApolloClient 快取中的數據進行交互感興趣,可使用 ApolloClient 實例的 readQuery( )和 writeQuery( ) 方法。

  • no-cache:此抓取策略將永遠不會從快取中返回初始數據。相反,它將始終使用網絡向服務器發出請求。network-only 策略不同,查詢完成後,它也不會將任何數據寫入快取

之前我們實作 mutation 時談到了 Optimistic UI update,現在我們可以搭配快取來實作,看看如何改善客戶端的響應。

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
...
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);
});
},
...
}
...

這裡需要花一些時間理解。我們在 this.$apollo.mutate( ) 方法中加入了兩個 hook,分別是 update 與 optimisticResponse。

第 20 行 update 是一個函式,第一個引數 store 就是 Apollo Cache 實例,第二個引數則是執行 mutation 時被回傳的資料。
第 30 行從快取中讀取資料,34、35 直接修改讀出來的資料。
第 37 行把修改後的資料寫回 Apollo Cache 快取中。
第 42、43 把它顯示在 console,我們可以來觀察它們的操作。
第 45 行 optimisticResponse 用來提供樂觀的資料。
第 55 行我們不再需要從伺服器抓取資料來刷新。

現在可以新增一張新照片,然後從瀏覽器的 console 觀察看看:

之前談過,如果 update 與 optimisticResponse 一起用,則 update 會被調用兩次,但是再第二次調用時,第一次調用的資料會被回滾 (rollback),這裡我們做了兩次 cache 的變動,但第一次的資料自動被回滾了!

完成 GraphQL 伺服器後,接下來要在用戶端設定 GraphQL。基本上,用戶端只是一個與伺服器溝通的 app。因為 GraphQL 的靈活性,你或許會幫網路瀏覽器建構 app、也可能會幫手機建構原生 app,你可以在用戶端用任何一種語言編寫 GraphQL 服務。

使用 GraphQL API

最簡單的起步方式就是直接發一個 HTTP 請求給 GraphQL 端點。

抓取請求

我們可以用 cURL 來傳送請求給 GraphQL 服務,很多的硬體都有內建 cURL 程式,這意味著,我們可能可以幫冰箱的螢幕建構 GraphQL 服務。你只要設定一些不同的值即可:

cURL request
1
2
3
4
curl -X POST \
-H "Content-Type: application/json" \
--data '{ "query": "{totalUsers, totalPhotos}" }' \
http://10.11.xx.xxx:4000/graphql

傳送這個請求後,你應該會在終端機裡面看到正確的 JSON 資料結果:

cURL request result
1
{"data":{"totalUsers":3,"totalPhotos":45}}

因為我們使用 cURL,所以可以使用 “可傳送 HTTP 請求” 的任何東西。我們改用 fetch 來建立一個在瀏覽器內運作的用戶端:

Browser fetch
1
2
3
4
5
6
7
8
9
10
11
12
13
const query = `{totalUsers, totalPhotos}`;
const url = 'http://localhost:4000/graphql';

const opts = {
method: 'POST',
headers: { 'Content-Type' : 'application/json' },
body: JSON.stringify({ query })
};

fetch(url, opts)
.then(res => res.json())
.then(console.log)
.catch(console.error);

抓取資料後,我們可以從流覽器的主控台看到預期的結果:

Browser fetch result
1
2
3
4
5
6
{
"data": {
"totalPhotos": 45,
"totalUsers": 3
}
}

我們可以使用結果資料來建立 app,直接更改 DOM 列出 totalUsers 與 totalPhotos:

GraphQL request
1
2
3
4
5
6
7
8
fetch(url, opts)
.then(res => res.json())
.then(({data}) => `
<h1>photos: ${data.totalPhotos}</h1>
<h1>users: ${data.totalUsers}</h1>
`)
.then(text => document.body.innerHTML = text)
.catch(console.error);

請小心,當請求完成後,這會覆寫原本在內文中的任何東西。

當你知道如何用你最喜歡的用戶端來傳送 HTTP 請求時,你就擁有一項工具可建立 “可與任何 GraphQL API 溝通的用戶端 app” 了。 但是我們有更強大的 GraphQL 用戶端可用。

Apollo Client

使用 REST 有一個很大的好處在於它可以方便我們處裡快取。使用 REST 時,你可以將請求回應的資料存放在之前用來使用該請求的 URL 底下的快取,這沒甚麼問題。但是在 GraphQL 使用快取有點麻煩。一個 GraphQL API 沒有多個路由,每一個請求都是用單一的端點來傳送與接收的,所以我們無法將路由回傳的資料放在用來請求它的 URL 底下。

為了建立穩健、高性能的 app,我們必須設法將 query 與它們產生的物件存入快取。當我們想要建立快速、高效的 app 時,使用本地化的快取解決方案是必要的做法。我們可以自行建立這類的機制,也可以信賴一些已經經過嚴格審查的用戶端套件。

目前最引人注目的 GraphQL 用戶端解決方案是 Relay 與 Apollo Client。Relay 只與 Facebook 的 React 和 React Native 相容,所以我們選擇使用更廣泛的 Apollo Client。

Apollo Client 是 Meteor Development Group 開發的,它是一種社群驅動的專案,目的是建立靈活的 GraphQL 用戶端解決方案來處理諸如快取、積極 UI 更新 (optimistic UI update) 等工作。這個團隊建立了支援綁定 (binding) 的套件供 React、Angular、Ember、Vue、iOS 與 Android 使用。

我們已經在伺服器用了一些 Apollo 團隊創造的工具了,但 Apollo Client 特別關注請求碼在用戶端與伺服器之間的傳送與接收。它使用 Apollo Link 來處理網路請求,用 Apollo Cache 來處理所有快取。接下來 Apollo Client 會將連結 (Apollo Link) 與快取 (Apollo Cache) 包起來,並高效的管理與 GraphQL 服務的所有互動。

Apollo Client 和 Vue

Apollo Client 是一個 NPM 套件,要在 Web 瀏覽器或移動應用程序中使用此 Apollo Client 客戶端,您需要一個能夠在客戶端上加載 NPM 套件的構建系統。 一些常見的選擇包括 Browserify,Webpack 和 Meteor 1.3+。這理我們選擇使用 Vue 來作為使用者介面程式庫,所以將使用 Vue CLI 來建立 Vue 專案,Vue CLI 使用 WebPack 來做編譯與打包,產生瀏覽器看得懂的部署檔案。

設定專案

首先使用 Vue CLI 建立一個專案:

create Vue Project
1
vue create vue-apollo-sample

接著切換捯 vue-apollo-sample 資料夾並執行 npm run serve 來啟動 app,現在打開流覽器 http://localhost:8080/。 Vue app 已可以運作。

設置 Apollo Client

為了使用 Apollo 工具來建立 GraphQL 用戶端,你必須安裝一些套件。首先,你需要 graphql 套件,它裡面有 GraphQL 語言解析函式。接著需要一個稱為 apollo-boost 的套件。Apollo Boost 包含建立 Apollo Client 以及傳送操作 (operation) 給伺服器所需的 Apollo 套件。最後需要 vue-apollo。Vue Apollo 是一個 NPM 程式庫,可以整合到 Vue 實例中。我們要利用其中的 Vue 元件與 Apollo 來建構使用者介面。

npm install
1
npm install graphql apollo-boost vue-apollo

現在可以開始建立用戶端了。apollo-boost 裡面的 ApolloClient 建構式可以用來建立第一個用戶端。打開 HelloWorld.vue,我們就用 Vue CLI 產生的頁面來測試,刪除一些不需要的訊息,然後加入一些程式碼,首先先使用 ApolloClient,稍後再加入 vue-apollo 套件:

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
<template>
<div class="hello">
<h1>{{ msg }}</h1>
</div>
</template>

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

export default {
name: 'HelloWorld',
props: {
msg: String
},
created() {
const client = new ApolloClient({uri: 'http://localhost:4000/graphql'});

const query = gql`
{
totalUsers
totalPhotos
}
`;

client.query({ query })
.then(({ data }) => console.log(data))
.catch(console.error);
}
}
</script>

第 16 行我們使用 ApolloClient 建構式建立一個新的 client 實例。這個 client 已經可以處裡和 http://localhost:4000/graphql 上的 GraphQL 服務之間的所有網路通訊了。這裡使用 client 傳送 query 來查詢使用者與照片的總數量。gql 函式屬於 graphql-tag 套件,它會隨著 apollo-boost 套件一起被加入,它的用途是將 query 解析成 AST 或抽象語法樹。第 26 行,我們只是將結果顯示在瀏覽器的 console。

如果你的瀏覽器出現如下的錯誤,那是因為 Vue CLI 建立的專案 ESLint 預設禁止使用 console.log

Failed to compile.

./src/components/HelloWorld.vue
  Module Error (from ./node_modules/eslint-loader/index.js):
  error: Unexpected console statement (no-console) at src\components\HelloWorld.vue:26:27:
    24 | 
    25 |     client.query({ query })
  > 26 |       .then(({ data }) => console.log(data))
       |                           ^
    27 |       .catch(console.error);
    28 |   }
    29 | }

可修改 package.json eslintConfig:

"eslintConfig": {
    ...
    "rules": {
      "no-console": "off"
    },
    ...
  }

client.query({ query }) 會用 HTTP 請求 (request) 將 query 送給 GraphQL 服務伺服器,以及解析服務回傳的資料。我們在瀏覽器的主控台應該會有如下的回應:

Chrome console
1
{totalUsers: 3, totalPhotos: 45}

也可以在 HTTP Headers 加入權杖 (token):

ApolloClient
1
2
3
4
5
6
7
8
9
10
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
request: operation => {
operation.setContext({
headers: {
Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6InNjb3R0IiwibmFtZSI6IuiAgeiZjiBTY290dCIsImlhdCI6MTU3MzYwNDUwOSwiZXhwIjoxNTczNjQ3NzA5fQ.3PEtLsXVZqoamXbIO10KTLgwaYBQAGKB4AH0kJXQ4bg'
},
});
}
});

除了處裡送到 GraphQL 服務的網路請求之外,用戶端也會將回應存到本地記憶體快取中。無論何時,我們都可以藉由呼叫 client.extract( ) 來查看快取:

1
2
3
4
5
6
7
...
console.log('cache', client.extract());

client.query({query})
.then(() => console.log('cache', client.extract()))
.catch(console.error);
...

第 2 行我們在送出之前查看快取,並且在第 5 行 query 被解析之後再度查看快取,你可以看到,現在結果已經被存在一個由用戶端管理的本地物件裡面了:

Client cache
1
2
3
4
5
6
{
ROOT_QUERY: {
totalUsers: 3,
totalPhotos: 45
}
}

下一次我們傳送 query 給用戶端來索取這筆資料時,它會從快取讀出資料,而不是向 GraphQL 服務傳送另一個網路請求。 Apollo Client 可讓我們指定何時以及每隔多久透過網路傳送 HTTP 請求。稍後會討論這些選項。

上面的例子我們只用到 apollo-boost 套件,而且是直接在 Vue 的元件中送出 GraphQL 請求,但是如果你在大部分的 Vue 元件都會使用到 GraphQL 服務,你必須重複產生 ApolloClient 實例,這顯然不是好的方式,你可把 ApolloClient 實例加入 Vue 的全域範圍,但是我們現在要使用一個更強大的套件 Vue Apollo 套件。

這裡我們會建立一個用戶端,並且用一個稱為 apolloProvider 的實例將它加到 Vue 使用者介面。因為我們要將它加入 Vue 的全域範圍,所以要從修改 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
import Vue from 'vue'
import App from './App.vue'
import VueApollo from "vue-apollo"
import ApolloClient from "apollo-boost"

Vue.config.productionTip = false;

Vue.use(VueApollo);

const client = new ApolloClient({
uri: "http://localhost:4000/graphql",
request: operation => {
operation.setContext({
headers: {
Authorization:
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6InNjb3R0IiwibmFtZSI6IuiAgeiZjiBTY290dCIsImlhdCI6MTU3MzYwNDUwOSwiZXhwIjoxNTczNjQ3NzA5fQ.3PEtLsXVZqoamXbIO10KTLgwaYBQAGKB4AH0kJXQ4bg"
}
});
}
});

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

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

第 8 行我們調用 Vue.use( ) 全局方法來使用 VueApollo 插件 (Plugin)。第 10 行建立一個客戶端,這與先前的範例沒甚麼不同,接著第 22 行使用 VueApollo 建構式產生一個 apolloProvider 實例,並在第 27 行將它加入 Vue 的全域範圍內,這樣在所有的 Vue 子元件都可以使用這個用戶端,也就是都可以透過 Apollo Client 從 GraphQL 服務接收資料了。

接著我們來看如何從子元件中使用 apolloProvider,我們修改一下 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
import { gql } from 'apollo-boost'

export default {
name: 'HelloWorld',
props: {
msg: String
},
created() {
const query = gql`
{
totalUsers
totalPhotos
}
`;

this.$apollo.query({ query })
.then(({ data }) => console.log(data))
.catch(console.error);

this.$apollo.provider.defaultClient.query({ query })
.then(({ data }) => console.log(data))
.catch(console.error);

this.$apollo.getClient().query({ query })
.then(({ data }) => console.log(data))
.catch(console.error);
}
}

這裡我們不再需要產生 ApolloClient 實例,第 16 行我們直接調用 Vue 全域範圍中的 ApolloClient,你也可以使用第 20 行的 this.$apollo.provider.defaultClient 或第 24 行的 this.$apollo.getClient( ) 方法取得 ApolloClient 實例,現在你可以在所有的 Vue 元件中共用這個 ApolloClient 元件。

這裡我們直接在 Vue 元件的 Created( ) Hook 中送出 GraphQL 請求。但它還有更強大的功能,能與 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
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<h1>{{ sayHello }}</h1>
</div>
</template>

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

export default {
name: 'HelloWorld',
props: {
msg: String
},
apollo: {
sayHello: {
query: gql`query hello {
sayHello
}`
}
},
...
}
</script>

第 16 行我們直接在 Vue 元件中加入 apollo 屬性,然後在第 17 行中再加入一個 GraphQL query sayHello 物件,接下來在第 4 行,就可以在 Template 中綁定這個值,這就像使用 Vue 的計算屬性 (computed) 一樣,可以直接把它當成 data 物件的屬性來存取。

但如果 sayHello 屬性需要初始值,也可以明確的定義在 data 物件中:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
props: {
msg: String
},
data() {
return {
sayHello: undefined
}
},
apollo: {
sayHello: {
query: gql`query hello {
sayHello
}`
}
},
...

這裡要注意的是 Vue 的屬性名稱 sayHello 要與 GraphQL 的 Query 同名稱,但如果需要也可以變換名稱,這裡我們將 Vue 的屬性改成 helloTainan:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
data() {
return {
helloTainan: undefined
}
},
apollo: {
helloTainan: {
query: gql`query hello {
helloTainan: sayHello
}`
}
},
...

重點在於第 10 行,不要忘記也要修改 Template 的綁定。

使用 GraphQL 的其中一個好處是你可以使用一個 HTTP 請求建構 UI 所需的每一個東西,並且用一個回應來接收所有的資料,現在我們來加一個同時請求多個 GraphQL 的請求:

src/components/HelloWorld.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
apollo: {
helloTainan: {
query: gql`query hello {
helloTainan: sayHello
}`
},
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`
}
},
...

在 apollo 中新增一個屬性 allUsers,對應到 GraphQL 的一個 query 請求,這個請求同時包含有兩個 GraphQL Query,totalUsers 與 allUsers,這裡要注意,只有 allUsers Query 名稱對應到 Vue 的屬性 allUsers,所以 Vue allUsers 的值會自動對應到 GraphQL Query allUsers 的返回值,那如何取得 totalUsers 的值? Apollo 提供了一個 result Hook:

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
...
data() {
return {
helloTainan: undefined,
totalUsers: undefined,
loading: true
}
},
apollo: {
...
allUsers: {
query: gql`query listUsers {
totalUsers
allUsers {
appLogin
name
}
}`,
result ({ data, loading }) {
this.totalUsers = data.totalUsers;
this.loading = loading;
}
}
...

這裡我們在第 19 行加入了一個 result Hook,他會接受一個物件當引數,其中 data 屬性就包含所有 GraphQL query 的回應資料,這裡會包含兩個 GraphQL Query totalUsers 與 allUsers 的回應資料,而 loading 屬性則告訴我們操作是否正在載入。

定義在 Vue apollo 屬性中的每個查詢都會被創建成一個智能查詢物件 (SmartQuery),它包含了一些屬性與方法,例如上面的 loading 就是這個物件的一個屬性,還有包含一些方法可被調用:

SmartQuery
1
this.$apollo.queries.helloTainan.refetch();

你可以加入一個按鈕調用它,會將另一個請求送到 GraphQL 端點來重新抓取資料。

輪詢 (polling) 也是 SmartQuery 物件提供的另一個選擇,當我們在 SmartQuery 物件加入輪詢時,就可以自動每隔一段指定的時間自動重複抓取資料:

startPolling and stopPolling
1
2
3
4
5
this.$apollo.queries.helloTainan.startPolling(1000); //ms

setTimeout(() => {
this.$apollo.queries.helloTainan.stopPolling();
}, 5000);

這裡每秒輪詢一次,5 秒鐘後會停止輪詢。

另外你的 GraphQL 服務如果有提供分頁功能,則可以使用 fetchMore( ) 抓取下一頁的資料。

訂閱 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)

認證 Authentication

GraphQL 中的身份驗證是一個熱門話題。沒有固定的做法,但許多應用程序都有這樣的需求。GraphQL 本身並不反對認證,因為它只是一種查詢語言。如果您想在 GraphQL 中進行身份驗證,請考慮使用 GraphQL mutations。

接下來,我們要使用簡約方法向 GraphQL 伺服務器添加身份驗證,這可以讓使用者登錄 (sign in) 到您的應用程序。

首先我們要新增一個使用者認證服務,並在原來的使用者資料庫中新增一個 password 欄位用來做認證用與一個 roles 欄位做授權。

新增一個子目錄 services 並新增一個程式檔案 user.service.js。這裡須要使用兩個 Node.js 套件,分別用來做密碼與權杖的加密與驗證:

npm install
1
2
npm install bcryptjs --save
npm install jsonwebtoken --save
services/user.service.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
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
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const jwtSecretKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
const { users } = require("../models/dbs");

function authenticate(username, password) {
if (typeof username !== "string" || typeof password !== "string") {
return Promise.reject(new Error("Username or password is incorrect"));
}

return users.findOne(username)
.then(result => {
if (result.appLogin === username) {
return passwordVerify(result, password)
.then(result => {
const token = jwt.sign(
{
appLogin: result.appLogin,
name: result.name
},
jwtSecretKey,
{ expiresIn: "12h" }
);

return {
user: {
appLogin: result.appLogin,
name: result.name,
roles: result.roles
},
token: token
};
})
.catch(err => {
throw new Error("Username or password is incorrect");
});
}
})
.catch(err => {
throw new Error("Username or password is incorrect");
});
}

function passwordVerify(user, password) {
return new Promise((resolve, reject) => {
if (!user) {
return reject(new Error("Parameters error"));
}
if (typeof password !== "string") {
return Promise.reject(new Error("Parameters error"));
}

if (validPassword(password, user.password)) {
return resolve(user);
} else {
return reject(new Error("Username or password is incorrect"));
}
});
}

function validPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}

function jwtVerify(token) {
let payload;

if (!token) {
throw new Error("Authentication failed");
}

try {
payload = jwt.verify(token, jwtSecretKey);
} catch (err) {
if (err.name === "TokenExpiredError") {
throw new Error("Token Expired");
} else {
throw new Error("Authentication failed");
}
}
return payload;
}

function resetPassword(id, password) {
if (!id || !password) {
return Promise.reject(new Error("Parameters error"));
}

return users.findOne(id)
.then(result => {
result["password"] = setPassword(password);
return users.update(result.appLogin, result);
})
.then(() => "Password changed")
.catch(err => {
throw err;
});
}

function setPassword(password) {
return bcrypt.hashSync(password, 10);
}

module.exports = {
authenticate,
jwtVerify,
setPassword,
resetPassword
}

現在可以使用這個服務來將資料庫中的使用者新增一個 password 欄位與 roles 欄位。

因為我們的 LevelDB 是崁入式的資料庫,不能同時由兩個 Node.js 行程開啟資料庫,所以必須先停下 Apollo Server,再執行下面程式碼更新資料庫 users 資料庫:

models/useful/dbs-update-users.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
const { users } = require('../dbs');
const { setPassword } = require('../../services/user.service');

const data = {
blake: { password: 'changeonlogin', roles: ['developer', 'demo']},
james: { password: 'changeonlogin', roles: ['user', 'demo']},
scott: { password: 'tiger', roles: ['admin']},
}

users.findAll().then(result => {
result.map(value => {
const user = {
...value,
password: setPassword(data[value.appLogin].password),
roles: data[value.appLogin].roles
}
return user;
})
.forEach(value => {
users.update(value.appLogin, value)
.then(console.log)
.catch(console.log);
});
});

第 7 行,新增一個 password 欄位,預設值設為 “changeonlogin”,加密儲存回資料庫。

現在資料庫裡的資料應該如下:

LevelDB usersDb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[
{
id: 'blake',
appLogin: 'blake',
name: '葛蘭特 Blake',
password: '$2a$10$OqyTUxbgA.qds4Cn8tUXa.xgK2OtYUTuombk/Mu.bEXti3LkTG3bu',
roles: [ 'developer', 'demo' ]
},
{
id: 'james',
appLogin: 'james',
name: '麥克 James',
password: '$2a$10$v8ee6guXTOgMWkz6oy0Nr.F1G8Io4o0vk1V2BB5WEDka/nudj31nK',
roles: [ 'user', 'demo' ]
},
{
id: 'scott',
appLogin: 'scott',
name: '老虎 Scott',
password: '$2a$10$o1cpCMZJvNyeO63hFqGvC.plFrNUA9pigbWmOTdijVOLN4z2DC5p6',
roles: [ 'admin' ]
}
]

因為我們在資料庫中加入了 roles 欄位,因此也需要修改 User 使用者型態將 roles 欄位加入,roles 欄位是一個內涵 String 的陣列,而我們就用他預設的解析函式就可以了:

schema/user.js
1
2
3
4
5
6
7
8
9
10
...
type User {
appLogin: ID!
name: String
avatar: String
roles: [String!]!
postedPhotos: [Photo!]!
inPhotos: [Photo!]!
}
...

現在我們可以來加入認證的機制,首先將認證服務 userService 加入 context 中,這要修改專案根目錄下的 index.js:

index.js
1
2
3
4
5
6
7
8
...
const userService = require('./services/user.service');
...
const context = {
db,
userService
};
...

我們要用 mutation 來處裡使用者認證,在 User schema 中設計一個自訂的承載類型 (payload type) AuthPayload 與 appAuth mutation:

schema/user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
...

extend type Mutation {
appAuth(login: String! password: String): AuthPayload!
}

...

type AuthPayload {
user: User!
token: String!
}
...

在對應的解析函式加入實際的執行碼:

resolves/user.js
1
2
3
4
5
6
7
8
...
Mutation: {
appAuth(parent, { login: username, password }, { userService }) {
return userService.authenticate(username, password)
.then(results => results);
}
},
...

現在可從 GraphQL Playground 客戶端測試看看:

GraphQL Playground appAuth mutation
1
2
3
4
5
6
7
8
9
mutation authentication {
appAuth(login: "blake" password: "changeonlogin") {
user {
appLogin
name
}
token
}
}
GraphQL Playground results
1
2
3
4
5
6
7
8
9
10
11
{
"data": {
"appAuth": {
"user": {
"appLogin": "blake",
"name": "葛蘭特 Blake"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6ImJsYWtlIiwibmFtZSI6IuiRm-iYreeJuSBCbGFrZSIsImlhdCI6MTU3MzE3MDgwMSwiZXhwIjoxNTczMjE0MDAxfQ.eqrwtifdL-ZdOLL8deS547HhirWK59UbDOTOYUshgTc"
}
}
}

我們將使用認證成功後返回的資料中所含的權杖 token 來做驗證與授權。

在未來的請求中證明自己的身分,你必須在每個請求的 HTTP Headers 的 Authorization 中傳送你自己的權杖,這個權杖會被用來查看使用者的資料庫紀錄來辨識與授權。

GraphQL Playground 有個地方可讓你為每個請求加入標頭 (HTTP Headers)。在左下方角落的 “QUERY VARIABLES” 旁邊有個 “HTTP HEADERS” 標籤,你可以使用這個標籤在請求中加入 HTTP Headers。用 JSON 來傳送標頭:

HTTP Headers
1
2
3
{
"Authorization": "Bearer <YOUR_TOKEN>"
}

請將 <YOUR_TOKEN> 換成 appAuth mutation 回傳的權杖。現在你可以連同每一個 GraphQL 請求一起來識別你。GraphQL 伺服器會使用權杖的資料從資料庫中找到你的帳號並將它加入 GraphQL 的 context 中,解析函式就可以用來判斷使用者的授權權限。

接下來,我們要建立一個參考我們自己的使用者資訊的 query: me query。這個 query 會根據 HTTP 標頭傳送的權杖來回傳當前登入的使用者。如果目前沒有使用者登入,這個 query 會回傳 null。這將會是代表經過身份驗證的用戶。

在這個程序一開始,用戶端同時傳送 GraphQL query me 和保護使用者資訊的 Authorization: token。接下來 API 會捕捉 Authorization 標頭,並使用權杖在資料庫中查看當前使用者的紀錄資料,也會將當前使用者帳號加入 context。帳號被加入 context 後,所有的解析函式就可以讀取當前的使用者了。

我們要改變 context 物件的建構方式,使用函式來處理 context,而不是直接使用物件。

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
...
const context = async ({ req }) => {
let currentUser = null;

try {
let token = req.headers.authorization.split(" ")[1];
let payload = token && userService.jwtVerify(token);
currentUser = payload && (await db.users.findOne(payload.appLogin));
} catch {
currentUser = null;
}

return {
db,
userService,
currentUser
};
};

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

context 可以是物件或函式,它會在每一次有請求時設定 context。當 context 是函式時,每當有 GraphQL 請求時,它都會被呼叫。這個函式回傳的物件是被送給解析函式的 context。

context 設定好之後,就可加入 me query 與對映的解析函式:

schema/user.js
1
2
3
4
5
6
7
...
extend type Query {
me: User
...
}
}
...

me 的解析函式:

resolvers/user.js
1
2
3
4
5
6
...
Query: {
me: (parent, args, { currentUser }) => currentUser,
...
},
...

現在可來使用 me query 傳送一個請求來取得關於你自己的資料,記得 HTTP Headers 裡面要有正確的權杖,使用錯誤的權杖或沒有授權標頭時,你會看到 me query 是 null。

GraphQL Playground query me
1
2
3
4
5
6
query currentUser {
me {
appLogin
name
}
}
GraphQL Playground HTTP HEADERS
1
2
3
{
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBMb2dpbiI6ImJsYWtlIiwibmFtZSI6IuiRm-iYreeJuSBCbGFrZSIsImlhdCI6MTU3MzE4Mzg3MSwiZXhwIjoxNTczMjI3MDcxfQ.n8msSCdRNlj9lM99Iqw7Mey-wec_zroqBEUKYojC8-A"
}
GraphQL Playground query me result
1
2
3
4
5
6
7
8
{
"data": {
"me": {
"appLogin": "blake",
"name": "葛蘭特 Blake"
}
}
}

你甚至可以查你自己其它的資料:

GraphQL Playground query me
1
2
3
4
5
6
7
8
9
10
11
12
query currentUser {
me {
appLogin
name
postedPhotos {
name
}
inPhotos {
name
}
}
}
GraphQL Playground query me result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"data": {
"me": {
"appLogin": "blake",
"name": "葛蘭特 Blake",
"postedPhotos": [
{
"name": "Dropping the Heart Chute"
}
],
"inPhotos": [
{
"name": "Dropping the Heart Chute"
},
{
"name": "Enjoying the sunshine"
}
]
}
}
}

postPhoto mutation

使用者必須先登入才能將照片貼到我們的 app,postPhoto mutation 可以藉由檢查 context 來確定登入的是誰。我們來修改 postPhoto mutation:

resolvers/photo.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
Mutation: {
async postPhoto(parent, args, { db : { photos }, currentUser }) {
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);
return insertedPhoto;
}
},
...

第 4 行我們判斷是不是有效的使用者,如果不是,拋出一個錯誤,程序即會中斷執行,這是最簡單的授權驗證,可加入角色 (roles) 做更細微的控管。

最後的 resolvers/photo.js 如下:

resolvers/photo.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
const { GraphQLScalarType } = require("graphql");

module.exports = {
Query: {
totalPhotos: (parent, args, { db: { photos } }) => photos.allCount().then(value => value),
allPhotos: (parent, args, { db: { photos } }) => photos.findAll()
},
Mutation: {
async postPhoto(parent, args, { db : { photos }, currentUser }) {
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);
return insertedPhoto;
}
},
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: (parent, args, { db: { users } }) => {
return users.findOne(parent.appUser).then(value => value);
},
taggedUsers: (parent, args, { db: { users, tags } }) => {
return tags.findAll().then(result => {
return result
.filter( tag => tag.photoID === parent.id)
.map(tag => tag.userID)
.map(userID => users.findOne(userID));
});
}
},
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
})
};

現在可以將新照片貼到 GraphQL 服務了。

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

我們也必須在 Query Variables 面板中傳送對應的 PostPhotoInput JSON:

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

從客戶端使用 GraphQL API

到目前為止我們都是從 GraphQL Playground 測試 GraphQL 伺服器,現在看看如何從 Web app 將新照片貼到 GraphQL 服務。

首先需要認證,這是一段認證的範例函式:

authentication function appAuth
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
function appAuth(username, password) {
const query = `mutation login {
appAuth(login: "${username}" password: "${password}") {
user {
name
appLogin
}
token
}
}`;

const endpoint = "http://localhost:4000/graphql";
const opts = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query })
};

fetch(endpoint, opts)
.then(res => res.json())
.then(({ data: { appAuth } }) => localStorage.setItem('token', appAuth.token))
.catch(console.error);
}

如果認證成功,會將權杖存到 localStorage。然後就可以使用此權杖執行 mutation postPhoto 貼上新照片。

以下則是一段貼上新照片的範例函式:

post new Photo function
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
function postPhoto (newPhoto) {
const query = `mutation newPhoto($input: PostPhotoInput!) {
postPhoto(input: $input) {
id
name
url
description
category
created
postedBy {
name
}
}
}`;

const url = "http://localhost:4000/graphql";
const opts = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
query: query,
variables: { input: newPhoto }
})
};

fetch(url, opts)
.then(res => res.json())
.then(console.log)
.catch(console.error);
}

現在可以執行這兩段函式:

post photo
1
2
3
4
5
6
7
8
9
10
// authentication
appAuth('blake', 'changeonlogin');

// post new photo
const newPhoto = {
name: "範例照片從瀏覽器使用 ajax",
description: "我們資料庫的範例照片 from browser using ajax"
};

postPhoto(newPhoto);

你已經建立一個 GraphQL 伺服器了! Go! GraphQL!

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

context

context 是存儲 GraphQL 伺服器全域值以供所有解析函式讀取的地方。context 很適合儲存身分驗證、資料庫細節、本地資料快取,以及任何其他需要解析 GraphQL 操作的物件。

你可以直接在解析函式裡面呼叫 REST API 與資料庫,但是我們通常會將這個邏輯放在 context 裡面的物件內,來分開關注點,以及方便稍後的重構。

你也可以使用 context 從 Apollo Data Source 讀取 REST 資料。不過就這個範例的目的而言,我們要結合 context 來處理應用程序的一些限制。首先,我們目前將資料放在記憶體內,這種做法的擴展性不好。接下來要讓資料庫來處理資料儲存,我們的解析函式將會從 context 存取這個資料庫。

安裝資料庫 LevelDB

為了讓這個範例可以獨立運作,這裡將使用鍵-值存儲嵌入式資料庫 LevelDB,重要的是理解 context 的用法。稍後會有使用 Oracle 資料庫的範例。

這裡須要使用兩個 Node.js 套件,level-sublevel 可以讓我們在同一個 LevelDB 中存儲兩組以上的資料集,你也可以將不同的資料集存入各自的 LevelDB 中。

1
2
npm install level --save
npm install level-sublevel --save

首先將 levelDB 的 CRUD 操作包裝一個抽象層,在專案子目錄 models 下建立 database.js 文件:

models/database.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
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
const timestamp = require('monotonic-timestamp');

function Database(db) {
this._db = db;
}

Database.prototype.findAll = function( opts = {}) {
return new Promise((resolve, reject) => {
const options = { ...opts };
let _this = this;
let results = [];
try {
_this._db
.createValueStream( options )
.on("data", value => results.push(value))
.on("end", () => resolve(results))
.on("error", err => reject(err));
} catch (err) {
reject(err);
}
});
};

Database.prototype.findAllwithKey = function( opts = {}) {
return new Promise((resolve, reject) => {
const options = { ...opts };
let _this = this;
let results = [];
try {
_this._db
.createReadStream( options )
.on("data", data => results.push(data))
.on("end", () => resolve(results))
.on("error", err => reject(err));
} catch (err) {
reject(err);
}
});
};

Database.prototype.findOne = function(id) {
return new Promise((resolve, reject) => {
if (typeof id === "undefined") return reject(new Error("Invalid ID."));

let _this = this;
_this._db.get(id, (err, value) => {
if (err) return reject(err);
resolve(value);
});
});
};

Database.prototype.insert = function(id, newValue) {
return new Promise((resolve, reject) => {
if (typeof id === "undefined") return reject(new Error("Invalid ID"));
if (!newValue) return reject(new Error("Invalid input"));

// 當 id 是空字串,自動產生 id
id = id.toString().length !== 0 ? id : timestamp().toString();

let _this = this;
_this._db.get(id, function(err) {
if (err) {
if (!err.notFound) return reject(err);

_this._db.put(id, { id, ...newValue }, err => {
if (err) return reject(err);
_this._db.get(id, (err, value) => {
if (err) return reject(err);
return resolve(value);
});
});
} else {
reject(new Error("Data already exists"));
}
});
});
};

Database.prototype.update = function(id, newValue) {
return new Promise((resolve, reject) => {
if (typeof id === "undefined") return reject(new Error("Invalid ID"));
if (!newValue) return reject(new Error("Invalid input"));

let _this = this;

_this._db.get(id, function(err) {
if (err) {
if (err.notFound) return reject(new Error("Data not found"));
return reject(err);
}

_this._db.put(id, newValue, err => {
if (err) return reject(err);
_this._db.get(id, (err, value) => {
if (err) return reject(err);
return resolve(value);
});
});
});
});
};

Database.prototype.delete = function(id) {
return new Promise((resolve, reject) => {
if (typeof id === "undefined") return reject(new Error("Invalid ID"));

let _this = this;

_this._db.get(id, function(err) {
if (err) {
if (err.notFound) return reject(new Error("Data not found"));
return reject(err);
}
_this._db.del(id, err => {
if (err) return reject(err);
resolve("Data deleted");
});
});
});
};

Database.prototype.allCount = function( opts = {}) {
return new Promise((resolve, reject) => {
const options = { ...opts };
let _this = this;
let count = 0;
try {
_this._db
.createValueStream( options )
.on("data", value => count += 1)
.on("end", () => resolve(count))
.on("error", err => reject(err));
} catch (err) {
reject(err);
}
});
};

module.exports = Database;

這裡將 level 原始的 API 包裝成一般的資料庫操作,包含查詢、新增、更新與刪除,你可以用其它的資料庫 API 取代。 再來就產生實際的資料庫。在 models 目錄產生另一個檔案 dbs.js

models/dbs.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const path = require("path");
const level = require("level");
const sublevel = require("level-sublevel");
const db = sublevel(level(path.resolve(__dirname, "example-db")), {
valueEncoding: "json"
});
const Database = require('./database');

const usersDb = db.sublevel("users");
const photosDb = db.sublevel("photos");
const tagsDb = db.sublevel("tags");

module.exports = {
users: new Database(usersDb),
photos: new Database(photosDb),
tags: new Database(tagsDb)
};

現在我們可以使用這個資料庫抽象層程式將原先放在記憶體的資料移入資料庫。產生一段臨時的程式碼來初始化這些資料,將程式碼放在 models 目錄下的 useful 目錄:

models/useful/dbs-init.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const db = require('../dbs');
const { users, photos, tags } = require("../data");

users.forEach(value => {
db.users.insert(value.appLogin, value);
});

photos.forEach(value => {
db.photos.insert(value.id, value);
});

tags.forEach(value => {
db.tags.insert(`${value.photoID}-${value.userID}`, value);
});

Database.insert 的第一個引數將被設為 Key, User 我們使用 appLogin 當 Key, 這裡要注意 tags 的 key 值設定,避免資料重複,這是多對多資料 Intersection Table 的要件,Primary Key 要包含所有的欄位。寫入資料庫後可以查詢看看資料庫內的資料。

執行完 dbs-init.js 後,models 目錄下會產生一個 example-db 目錄,這就是 LevelDB 資料庫的實體位置。

現在可以來看看資料庫內的資料內容:

models/useful/dbs-test.js
1
2
3
4
5
6
7
const db = require("../dbs");

db.users.findAll().then(value => console.log(value));

db.photos.findAll().then(value => console.log(value));

db.tags.findAllwithKey().then(value => console.log(value));

將資料庫加入 context

接下來要將 GraphQL 連接資料庫,首先建立一個 context 物件,這需要修改專案目錄的 index.js,再重新啟動 GraphQL API 伺服器:

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
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const cors = require('cors');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');
const db = require('./models/dbs');

const app = express();

app.use(cors());

const context = { db };

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

server.applyMiddleware({ app });

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

app.listen({ port: 4000 }, () => {
console.log(`GraphQL Server running @ http://localhost:4000${server.graphqlPath}`);
});
  • 第 6 行將資料庫加進來
  • 第 12 行產生 context 物件
  • 第 17 行將 context 物件加入 ApolloServer

接著我們要修改 query 解析函式,從 schema/user.js User 類型開始新增兩個 Query 欄位 totalUsers 與 allUsers。

schema/user.js
1
2
3
4
5
extend type Query {
sayHello: String!
totalUsers: Int!
allUsers: [User!]!
}

接著修改解析函式 resolvers/user.js:

resolvers/user.js
1
2
3
4
5
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。",
totalUsers: (parent, args, { db }) => db.users.allCount().then(value => value),
allUsers: (parent, args, { db }) => db.users.findAll()
}

注意這裡解析函式引入 context 物件的方式,它是解析函式的第三個參數。GraphQL 解析函式的簽章可以是:

GraphQL resolver function signature
1
(parent, args, context, info) => { ... }

還要注意解析函式使用 Promise 的方式。 你也可以在這裡使用 async / await。

resolvers/user.js
1
allUsers: async (parent, args, { db: { users } }) => await users.findAll()

我們使用 context 物件階層式的解構賦值方式,這裡我們只需要 users 資料庫

接下來修改 postedPhotos 與 inPhotos:

resolvers/user.js
1
2
3
4
5
6
7
8
9
10
11
User: {
postedPhotos: (parent, args, { db: { photos } }) => {
return photos.findAll().then(results => results.filter(value => value.appUser === parent.appLogin));
},
inPhotos: (parent, args, { db: { photos, tags } }) => {
return tags.findAll().then(tags => tags
.filter(tag => tag.userID === parent.appLogin)
.map(tag => photos.findOne(tag.photoID))
);
}
}

現在可以從 GraphQL Playground 客戶端送出一個查詢:

query
1
2
3
4
5
6
7
8
9
10
11
12
13
query listUsers {
allUsers {
appLogin
name
postedPhotos {
name
description
}
inPhotos {
name
}
}
}

我們也需要修改 Photo 的解析函式 totalPhotos、allPhotos、postedBy 與 taggedUsers:

resolvers/photo.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
...
Query: {
totalPhotos: (parent, args, { db: { photos } }) => photos.allCount().then(value => value),
allPhotos: (parent, args, { db: { photos } }) => photos.findAll()
},

...

Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: (parent, args, { db: { users } }) => {
return users.findOne(parent.appUser).then(value => value);
},
taggedUsers: (parent, args, { db: { users, tags } }) => {
return tags.findAll().then(result => {
return result
.filter( tag => tag.photoID === parent.id)
.map(tag => tag.userID)
.map(userID => users.findOne(userID));
});
}
},

...

到此 User 使用者型態與 Photo 照片型態的部分都只是查詢,致於要將照片加入資料庫,使用者必須先登入才能將照片加入,所以要先加入認證與授權。

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

自訂純量 Custom Scalars

GraphQL 有一群預設的純量型態可以在任何欄位中使用。Int、Float、String、Boolean 與 ID 之類的純量可以在大部分的情況下使用,但有時你可能需要建立自訂的純量型態來滿足資料的需求。

當我們實作自訂純量時,必須建立一些關於如何序列化和驗證型態的規則。例如,當我們建立 DateTime 型態時,也要定義怎樣的 DateTime 才可以視為有效的。

接下來要在 typeDefs 裡面加入這個自訂的 DateTime 純量型態,並且在 Photo 型態的 created 欄位使用它。我們用 created 欄位來存儲照片被貼出的日期與時間。

index.js
1
2
3
4
5
6
7
8
9
10
const typeDefs = `
...
scalar DateTime

type Photo {
...
created: DateTime!
}
...
`

schema 的每一個欄位都要對應一個解析函式。created 欄位要對應一個 DateTime 型態的解析函式。為 DataTime 建立自訂純量型態的原因是我們想要將任何使用這個純量的欄位解析為 JavaScript Date 型態並加以驗證。

考慮各種用字串來表示日期與時間的方式,以下的字串都代表有效的日期:

  • “7/30/2019”
  • “7/30/2019 1:08:23 PM”
  • “Tue Jul 30 2019 13:08:23 GMT+0800 (Taipei Standard Time)”
  • “2019-07-30T05:08:23.960Z”

我們可以用 JavaScript 將上面的任何字串做成 datetime 物件:

1
2
3
var d = new Date("7/30/2019");
console.log(d.toISOString());
// 2019-07-29T16:00:00.000Z 這是 ISO 格式,注意它的日期

上面的程式用一種格式建立一個新的日期物件,接著將那個 datetime 字串轉換成 ISO 格式的日期字串。

這個 JavaScript Date 不瞭解的東西都是無效的。你可以試著解析下面的資料:

1
2
3
var d = new Date("Tuesday July");
console.log(d);
// Invalid Date

我們想要在查詢照片的 created 欄位時,確定這個欄位回傳的值含有 ISO 日期時間格式的字串。當欄位回傳日期值時,我們會將那個值 serialize (序列化) 為 ISO 格式的字串:

1
const serialize = value => new Date(value).toISOString();

序列化函式會從物件取出欄位值,只要那個欄位含有 JavaScript 物件格式的日期,或任何有效的 datetime 字串
,GraphQL 就會用 ISO datetime 格式回傳它。

當你在 schema 實作自訂的純量之後,就可以在 query 中將它當成引數來使用。假設我們為 allPhotos query 建立一種過濾器。這個 query 可以回傳在指定的日期之後拍攝的照片串列:

1
2
3
4
type Query {
...
allPhotos(after: DateTime): [Photo!]!
}

有這個欄位時,用戶端就可以傳送一個含有 DateTime 值的 query:

1
2
3
4
5
6
query recentPhotos($after: DateTime) {
allPhotos(after: $after) {
name
url
}
}

這可以使用查詢變數來傳送 $after 引數:

1
2
3
{
"after": "7/30/2018"
}

我們想要在 after 引數被傳送到解析函式之前確保它已經被解析成 JavaScript Date 物件了:

1
const parseValue = value => new Date(value);

我們可以使用 parseValue 函式來解析與 query 一起送來的字串的值。 parseValue 函式回傳的值都會被傳給解析函式的引數 args:

1
2
3
4
5
6
7
8
const resolvers = {
Query: {
allPhotos: (parent, args) => {
args.after // JavaScript Date 物件
...
}
}
}

自訂純量必須能夠序列化與解析日期。

我們還有一個地方需要處理日期字串: 當用戶端直接在 query 本身加入日期字串時

1
2
3
4
5
6
query {
allPhotos(after: "7/30/2018") {
name
url
}
}

如果 after 引數不是用查詢變數來傳遞的,它已經被直接加入查詢文件了。我們必須在這個值被解析成抽象語法
樹 (AST) 之後,從 query 取出它才能解析它。在解析這些值之前,我們使用 parseLiteral 函式從查詢文件中取出它們:

1
const parseLiteral = ast => ast.value;

我們用 parseLiteral 函式來取得被直接加入查詢文件的日期值。在本例中,我們只要回傳那個值即可,但在必要時,我們也可以在這個函式內執行額外的解析步驟。

當我們建立自訂純量時,需要使用為了處理 DateTime 值而設計的三個函式。我們加入自訂純量 DateTime 的解析函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { GraphQLScalarType } = require('graphql');
...
const resolvers = {
Query: { ... },
Mutation: { ... },
Photo: { ... },
User: { ... },
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
})
}

我們使用 GraphQLScalarType 建構式來建立 “自訂純量” 的解析函式。我們將 DateTime 解析函式放在解析函式清單中。當我們建立純量型態時,必須加入三個函式: serializeparseValueparseLiteral,它們會處理任何實作 DateTime 純量的欄位或引數。

我們也要在樣本資料中加入 created 鍵與日期值,使用任何一個有效的日期字串或物件都可以,因為我們建立的欄位會先被序列化再回傳:

models/data.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
exports.photos = [
{
...
created : "3-28-2018"
},
{
...
created : "1-2-2015"
},
{
...
created : "2019-07-30T05:08:23.960Z"
}
]

現在,當我們在選擇組加入 created 欄位時,可以看到這些日期與型態都被格式化成 ISO 日期字串了:

1
2
3
4
5
6
query listPhotos {
allPhotos {
name
created
}
}

接下來的工作只剩下在每張照片被貼出時為它們加上時戳。我們在每張照片加入一個 created 欄位,並且用 JavaScript Date 物件與自訂型態 DateTime 來加上及驗證時戳。

1
2
3
4
5
6
7
8
9
postPhoto(parent, args) {
let newPhoto = {
id: _id++,
...args.input,
created: new Date()
};
photos.push(newPhoto);
return newPhoto;
}

現在當新照片被貼出時,就會被加上它們的建立日期與時間時戳了。

postPhoto
1
2
3
4
5
6
7
8
9
10
11
12
mutation newPhoto {
postPhoto(input: {
name: "範例照片 haha",
description: "這是範例照片 after custom scalar"
}) {
id
name
description
url
created
}
}
results
1
2
3
4
5
6
7
8
9
10
{
"data": {
"postPhoto": {
"id": "5",
"name": "範例照片 haha",
"description": "這是範例照片 after custom scalar",
"url": "https://fakeimg.pl/120x160/?text=5",
"created": "2019-07-30T08:04:57.022Z"
}
}

抽象語法樹 (abstract syntax tree, AST)

GraphQL query 文件是個字串。當我們傳送 query 給 GraphQL API 時,字串會被解析成抽象語法樹,並且在操作執行之前進行驗證。抽象語法樹 (AST) 是一種代表 query 的階層式物件。AST 是個含有內嵌欄位的物件,裡面的欄位代表 GraphQL query 的細節。

解析程序的第一個步驟是將字串解析成一堆較小的片段,這個步驟包括將關鍵字、引數,甚至括號與冒號解析成單獨的標記,這個程序稱為詞法分析 (lexing 或 lexical analysis)。接下來將詞法分析後的 query 解析成 AST。使用 AST 可讓動態修改與驗證 query 的工作輕鬆許多。

GraphQL query 是一種 GraphQL 文件。文件至少要有一個定義(Definition),也可能有一串定義。

  • 定義只有可能是兩種型態: OperationDefinition 或 FragementDefinition。
  • 一個 OperationDefinition 只能含有三種操作型態: mutation、query 或 subscription。
  • 每一個操作定義都有 OperationType 與 SelectionSet。

在每一個操作後面的大括號 { } 內都有該操作的 SelectionSet,它們就是我們查詢的欄位。

GraphQL 可以遍歷這個 AST 並且用 GraphQL 語言與目前的 schema 來驗證它的細節。如果查詢語言的語法是正確的,且 schema 含有我們請求的欄位與型態,該操作就會執行。如果情況不是如此,就會回傳特定的錯誤。

此外,AST 物件比字串容易修改。如果我們想要在 query 附加另外的欄位資料,可直接修改 AST。我們只要為特定的操作加入一個額外的 SelectionSet 就可以了。AST 是 GraphQL 很重要的成分。每一項操作都會被解析成 AST,以便對它進行驗證並最終執行它。

模塊化的 GraphQL 架構

到此我們會發覺 index.js 文件隨著 schema 與 resolvers 而不斷的增長,我們不能繼續都將程式碼定義在一個單一的文件中,我們必須模組化。

模式拼接 (Schema stitching) 是 GraphQL 中的一個強大功能。它是將多個 GraphQL 模式合併到一個模式中。目前,我們應用程序中的模式尚屬單純,但將來可能需要使用多個模式和模式拼接更複雜的操作。其中每個模式都匹配一種類型 (例如 User 使用者類型,Photo 照片類型),該操作需要合併兩個 GraphQL 模式,以便能使用 GraphQL 伺服器的 API 訪問整個 GraphQL 模式。這是架構拼接背後的基本動機之一。

甚至可以更進一步,最終可能會出現微服務或第三方平台,這些平台各有其專用的 GraphQL API,然後可以將它們合併到一個 GraphQL 架構中,併接成為單一的事實來源。最後,客戶端可以使用整個模式,而該模式由多個應用領域驅動的微服務組成。

在我們的例子中,讓我們從 GraphQL schema 和 resolvers 的技術問題開始分離。之後,您將按使用者(User)和照片(Photo)的應用域(domain)分隔。

首先在我們的專案目錄下建立兩個子目錄: schema 與 resolvers,我們要將原先定義在 index.js 的 typeDefs 與 resolvers 的程式碼分離出來,並放入這子目錄中。

schema/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
const { gql } = require("apollo-server-express");

module.exports = gql`
type Query {
sayHello: String!
totalPhotos: Int!
allPhotos: [Photo!]!
}

input PostPhotoInput {
name: String!
category: PhotoCategory = PORTRAIT
description: String
}

type Mutation {
postPhoto(input: PostPhotoInput!): Photo!
}

enum PhotoCategory {
SELFIE
PORTRAIT
ACTION
LANDSCAPE
GRAPHIC
}

scalar DateTime

type Photo {
id: ID!
url: String!
name: String!
description: String
category: PhotoCategory!
postedBy: User!
taggedUsers: [User!]!
created: DateTime!
}

type User {
appLogin: ID!
name: String
avatar: String
postedPhotos: [Photo!]!
inPhotos: [Photo!]!
}
`;

GraphQL schema 的定義是一個字符串,原先我們使用 JavaScript 的模板文字 (template literals) 編寫。這裡改用 gql,gql 是一種 JavaScript 模板文字標記,用於將 GraphQL 查詢字符串解析為標準 GraphQL AST或抽象語法樹。

原先使用字符串編寫並沒甚麼錯, 但是,如果您嘗試添加額外的欄位,或要將多個 GraphQL 文件或查詢合併在一起,則一般的字符串操作起來不怎麼方便。

再來就是 resolvers 子目錄下的 index.js:

resolvers/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
54
55
56
const { users, photos, tags } = require("../models/data");
const { GraphQLScalarType } = require("graphql");
const timestamp = require('monotonic-timestamp');

module.exports = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。",
totalPhotos: () => photos.length,
allPhotos: () => photos
},
Mutation: {
postPhoto(parent, args) {
let newPhoto = {
id: timestamp().toString(),
...args.input,
created: new Date()
};
photos.push(newPhoto);
return newPhoto;
}
},
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: parent => {
return users.find(u => u.appLogin === parent.appUser);
},
taggedUsers: parent =>
tags
// 回傳一個只含當前照片的 tag 陣列
.filter(tag => tag.photoID === parent.id)
// 將 tag 陣列轉換成 userID 陣列
.map(tag => tag.userID)
// 將 userID 陣列轉換成使用者物件陣列
.map(userID => users.find(u => u.appLogin === userID))
},
User: {
postedPhotos: parent => {
return photos.filter(p => p.appUser === parent.appLogin);
},
inPhotos: parent =>
tags
// 回傳一個只含有當前使用者的 tag 陣列
.filter(tag => tag.userID === parent.appLogin)
// 將 tag 陣列轉換成 PhotoID 陣列
.map(tag => tag.photoID)
// 將 photoID 陣列轉換成照片物件陣列
.map(photoID => photos.find(p => p.id === photoID))
},
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
}),
};

注意這裡的 models/data 路徑不一樣了。也改用 monotonic-timestamp 套件來產生照片的 id,可以使用 npm 安裝此套件。

1
npm install monotonic-timestamp --save

最後就要修改專案目錄下的 index.js

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');

const app = express();

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

server.applyMiddleware({ app });

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

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

把 schema 與 resolvers 加進來,現在整個專案的入口點變的清爽多了。但故事還沒完,如果 schema 與 resolvers 越來越多,是不是也可以再以應用域 (domain) 模組化?

回到 schema 目錄下,我們還需要再改造 schema/index.js。建立兩個新檔案 user.js 與 photo.js 我們要將
User 與 Photo 分開定義。

schema/user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
sayHello: String!
}

type User {
appLogin: ID!
name: String
avatar: String
postedPhotos: [Photo!]!
inPhotos: [Photo!]!
}
`;
schema/photo.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
const { gql } = require("apollo-server-express");

module.exports = gql`
extend type Query {
totalPhotos: Int!
allPhotos: [Photo!]!
}

extend type Mutation {
postPhoto(input: PostPhotoInput!): Photo!
}

input PostPhotoInput {
name: String!
category: PhotoCategory = PORTRAIT
description: String
}

enum PhotoCategory {
SELFIE
PORTRAIT
ACTION
LANDSCAPE
GRAPHIC
}

scalar DateTime

type Photo {
id: ID!
url: String!
name: String!
description: String
category: PhotoCategory!
postedBy: User!
taggedUsers: [User!]!
created: DateTime!
}
`;

注意這理 Query 和 Mutation 類型的 extend 語句。每個文件只描述自己的實體,包含類型及其關係。 關係可以是來自不同文件的類型,例如,與 User 使用者類型關係的 Photo 照片類型,即使類型是定義在不同的文件。由於現在有多種類型,因此需要擴展類型 (extend)。

接下來,在 schema/index.js 中為它們定義共享基類型:

schema/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { gql } = require("apollo-server-express");

const userSchema = require('./user');
const photoSchema = require('./photo');

const linkSchema = gql`
type Query {
_: Boolean
}

type Mutation {
_: Boolean
}

type Subscription {
_: Boolean
}
`;

module.exports = [linkSchema, userSchema, photoSchema];

在此我們定義一個 linkSchema 共享基類型,它會使用其他特定於應用域 schema 中的 extend 語句進行擴展,它使用的是一種併接模式(Schema Stitching)。

現在我們也要分離解析器的映射,resolvers/user.js 與 resolvers/photo.js:

resolvers/user.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const { photos, tags } = require("../models/data");

module.exports = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。"
},

User: {
postedPhotos: parent => {
return photos.filter(p => p.appUser === parent.appLogin);
},
inPhotos: parent =>
tags
// 回傳一個只含有當前使用者的 tag 陣列
.filter(tag => tag.userID === parent.appLogin)
// 將 tag 陣列轉換成 PhotoID 陣列
.map(tag => tag.photoID)
// 將 photoID 陣列轉換成照片物件陣列
.map(photoID => photos.find(p => p.id === photoID))
}
};
resolvers/photo.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
const { users, photos, tags } = require("../models/data");
const { GraphQLScalarType } = require("graphql");
const timestamp = require('monotonic-timestamp');

module.exports = {
Query: {
totalPhotos: () => photos.length,
allPhotos: () => photos
},
Mutation: {
postPhoto(parent, args) {
let newPhoto = {
id: timestamp().toString(),
...args.input,
created: new Date()
};
photos.push(newPhoto);
return newPhoto;
}
},
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: parent => {
return users.find(u => u.appLogin === parent.appUser);
},
taggedUsers: parent =>
tags
// 回傳一個只含當前照片的 tag 陣列
.filter(tag => tag.photoID === parent.id)
// 將 tag 陣列轉換成 userID 陣列
.map(tag => tag.userID)
// 將 userID 陣列轉換成使用者物件陣列
.map(userID => users.find(u => u.appLogin === userID))
},
DateTime: new GraphQLScalarType({
name: "DateTime",
description: "有效的日期時間值",
parseValue: value => new Date(value),
serialize: value => new Date(value).toISOString(),
parseLiteral: ast => ast.value
})
};
resolvers/index.js
1
2
3
4
const userResolvers = require('./user');
const photoResolvers = require('./photo');

module.exports = [userResolvers, photoResolvers];

由於 Apollo Server 也接受一個解析器映射陣列,resolvers/index.js 文件中導入所有解析器映射,並將它們 export 為解析器映射列表。現在的模組化好多了。

接下來我們需要把資料移往資料庫了。

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

邊與連結

GraphQL 的威力來自於邊 (edge),也就是資料點之間的連結。當你建構 GraphQL 伺服器時,型態 (type) 通常對應模型 (model)。你可以想像這些型態就像資料被存放在資料庫的資料表 (table) 內,我們可以在那裡用連結 (connections) 來連結型態。接下來我們要來探討可以使用哪種連結來定義型態之間的相互關係。

一對多連結

使用者必須能夠讀取貼過的照片。我們要在一個名為 postedPhotos 的欄位讀取這種資料,它會被解析成使用者貼過的照片清單,而且這些照片會被過濾。

因為一位 User 可貼出多張 Photos,我們將它稱為一對多關係。我們將 User 加入 typeDefs:

typeDefs
1
2
3
4
5
6
type User {
appLogin: ID!
name: String
avatar: String
postedPhotos: [Photo!]!
}

此時,我們已經建立一個有向圖了。我們可以從 User 型態走到 Photo 型態。要產生無向圖,我們必須提供一條從 Photo 型態走回 User 型態的連結。我們在 Photo 型態加入 postedBy 欄位:

typeDefs
1
2
3
4
5
6
7
8
type Photo {
id: ID!
url: String!
name: String!
description: String
category: PhotoCategory!
postedBy: User!
}

藉由加入 postedBy 欄位,我們建立一條可返回貼出 Photo 的 User 的連結,建立一個無向圖。這是一對一連結,因為一張照片只能由一位 User 貼出。

為了測試伺服器,我們需要一些樣本資料,往後我們會將資料移到資料庫。先移除 index.js 中設定的空陣列 photos 變數。在專案的根目錄下建立一個子目錄 models 來放我們的樣本資料 data.js:

models/data.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
exports.users = [
{ appLogin: "scott", name: "老虎 Scott" },
{ appLogin: "blake", name: "葛蘭特 Blake" },
{ appLogin: "james", name: "麥克 James" }
];

exports.photos = [
{
id: "1",
name: "Dropping the Heart Chute",
description: "是我最喜歡的 Chute",
category: "ACTION",
appUser: "blake"
},
{
id: "2",
name: "Enjoying the sunshine",
category: "SELFIE",
appUser: "james"
},
{
id: "3",
name: "Gunbarrel 25",
description: "25 laps on gunbarrel today",
category: "LANDSCAPE",
appUser: "james"
}
];

然後將它加入 index.js 中:

index.js
1
const { users, photos } = require('./models/data');

因為連結是用物件型態的欄位建立的,所以它們可以對應解析函式。在這些函式中,我們可以使用父物件的資料來協助找到有關的資料並回傳。

我們將 postedPhotos 與 postedBy 解析函式加入服務:

resolvers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const resolvers = {
...
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`,
postedBy: parent => {
return users.find(u => u.appLogin === parent.appUser)
}
},
User: {
postedPhotos: parent => {
return photos.filter(p => p.appUser === parent.appLogin)
}
}
};

我們必須在 Photo postedBy 欄位加入一個解析函式。我們可以自行決定如何在這個解析函式裡找到連結的資料。這裡我們使用陣列的 find( ) 方法取得 appLogin 符合每張照片的 appUser 值的使用者。

我們在 User 解析函式裡面使用陣列的 filter( ) 方法來取得該位使用者貼過的照片。這個 filter( ) 方法會回傳一個照片陣列。

接著我們試著傳送 allPhotos query:

query
1
2
3
4
5
6
7
8
9
query photos {
allPhotos {
name
url
postedBy {
name
}
}
}
result
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
{
"data": {
"allPhotos": [
{
"name": "Dropping the Heart Chute",
"url": "https://fakeimg.pl/120x160/?text=1",
"postedBy": {
"name": "葛蘭特 Blake"
}
},
{
"name": "Enjoying the sunshine",
"url": "https://fakeimg.pl/120x160/?text=2",
"postedBy": {
"name": "麥克 James"
}
},
{
"name": "Gunbarrel 25",
"url": "https://fakeimg.pl/120x160/?text=3",
"postedBy": {
"name": "麥克 James"
}
}
]
}
}

我們要自行連接資料與解析函式,但是一旦我們能夠回傳那個連接的資料,用戶端就可以開始編寫功能強大的 query。你可以在 allPhotos query 的 postedBy 下載加入 postedPhotos 看看:

query
1
2
3
4
5
6
7
8
9
10
11
12
query photos {
allPhotos {
name
url
postedBy {
name
postedPhotos {
name
}
}
}
}

多對多

接下來要在服務中加入 “在照片中標記使用者” 的功能。這意味著一位 User 可被標記 (tag) 在許多不同的照片中,而一張照片裡面可以標記許多不同的使用者。使用者與照片透過標記建立的關係稱為多對多,多位使用者對多張照片。

為了建立多對多關係,我們在 Photo 加入 taggedUsers 欄位,在 User 加入 inPhotos 欄位。

typeDefa
1
2
3
4
5
6
7
8
9
type User {
...
inPhotos: [Photo!]!
}

type Photo {
...
taggedUsers: [User!]!
}

taggedUsers 欄位會回傳一串使用者,而 inPhotos 欄位會回傳內含某位使用者的照片串列。

為了實作這個多對多連結,我們要在樣本資料中加入一個標記 (tags) 陣列,這是一個定義多對多關係的資料,它提供了兩組資料之間的交集。在關連式資料庫中稱為 intersection table,它定義兩個多對多資料表之間的關係。

models/data.js
1
2
3
4
5
6
exports.tags = [
{ photoID: "1", userID: "blake" },
{ photoID: "2", userID: "james" },
{ photoID: "2", userID: "scott" },
{ photoID: "2", userID: "blake" }
];

然後 import 到 index.js 中:

index.js
1
const { users, photos, tags } = require('./models/data');

當我們有張照片時,必須搜尋 tags 資料集來找出在照片中被標記的使用者。當我們有一位使用者時,就可以找到內含該使用者的照片串列。因為目前的資料放在 JavaScript 陣列裡面,所以我們在解析函式裡面使用陣列方法來尋找資料

resolvers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Photo: {
...
taggedUsers: parent => tags
// 回傳一個只含當前照片的 tag 陣列
.filter(tag => tag.photoID === parent.id)
// 將 tag 陣列轉換成 userID 陣列,我們只需要 userID 屬性
.map(tag => tag.userID)
// 將 userID 陣列轉換成使用者物件陣列
.map(userID => users.find(u => u.appLogin === userID))
},
User: {
...
inPhotos: parent => tags
// 回傳一個只含有當前使用者的 tag 陣列
.filter(tag => tag.userID === parent.appLogin)
// 將 tag 陣列轉換成 PhotoID 陣列,我們只需要 photoID 屬性
.map(tag => tag.photoID)
// 將 photoID 陣列轉換成照片物件陣列
.map(photoID => photos.find(p => p.id === photoID))
}

taggedUsers 欄位解析函式會濾除所有非當前照片的照片,並將過濾後的串列對應到實際的 User 物件組成的陣列。inPhotos 欄位解析函式會用使用者來過濾標記,並將使用者標記對應到實際的 Photo 物件組成的陣列。

接著我們可以傳送一個 GraphQL query 來查看每一張照片中有哪些使用者被標記:

query
1
2
3
4
5
6
7
8
9
query listPhotos {
allPhotos {
name
url
taggedUsers {
name
}
}
}

你應該會發現,我們有個 tags 的陣列,但沒有稱為 Tag 的 GraphQL 型態。GraphQL 並不要求資料模型完全匹配 schema 內的型態。用戶端可以藉由查詢 User 型態或 Photo 型態在每張照片找到被標記的使用者,以及有某位使用者被標記的照片。他們不需要查詢 Tag 型態,這只會讓事情更複雜。我們已經完成在解析函式中尋找被標記的使用者或照片的工作了,這可以讓用戶端更容易查詢這些資料。

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

瞭解了 query 與 schema 後,我們要開始建立一個 GraphQL 服務。這可以採用各種不同的技術來完成,但是接下來我們要使用 JavaScript。因為它可以同時使用在伺服器端(Node.js)與客戶端(Browser JavaScript)。

當 GraphQL 在 2015 年發表規格時,它把焦點放在 “明確地解釋查詢語言與型態系統”,刻意不講明伺服器的實作細節,允許具備各種語言背景的開發者使用他們最熟悉的語言。Facebook 的團隊則以 JavaScript 寫了一個參考作品,稱為 GraphQL.js,並且與它一起發表了 express-graphql;它是以 Express 建構 GraphQL 伺服器的簡單做法,值得特別強調的是,它是第一個可協助開發者完成工作的程式庫。

介紹了用 JavaScript 製作 GraphQL 伺服器後,我們選擇使用 Apollo Server,它是 Apollo 團隊提供的開放原始碼解決方案。Apollo Server 相當容易設定,且提供一系列的準產品功能,包括訂閱支援、檔案上傳、可快速連接既有服務的資料來源 API ,及立即可用的 Apollo Engine 集成。它也包含 GraphQL Playground,可讓你直接在瀏覽器內編寫 query。

Node.js with GraphQL and Apollo Server

Apollo Server 可以與 Express、Koa、Hapi 等 Node.js 的架構一起使用。 它與類別庫無關,因此可以將其與客戶端和服務器端應用程序中的許多不同的第三方程式庫連接。在此應用程序中,我們將使用 Express,因為它是Node.js 中最流行和最常見的中間件庫套件(Middleware library)。這是我們的 Node.js 教育訓練文件,第 253 頁開始有詳細談到 Express。

設定專案

首先在電腦裡面用一個空的資料夾建立 graphql-sample-api 專案。然後切換到 graphql-sample-api 目錄,在終端視窗或命令提示字元使用 npm init -y 命令在這個資料夾裡面建立一個新的 npm 專案。這個工具程式會產生一個 package.json 檔案,因為我們使用 -y 旗標,所有的選項都會被設成預設值。

1
2
3
cd graphql-sample-api

npm init -y

接著安裝兩個專案套件: apollo-server 與 apollo-server-express

1
npm install apollo-server apollo-server-express --save

正如在程式庫名稱中所看到的,你可以使用任何其他中間件解決方案(例如,Koa 或 Hapi)來補充獨立的 Apollo 服務器。你不一定要使用 Express 與 Apollo Server Express,但它可以整合 Express 與 Apollo Server,這樣你除了可以使用 Apollo Server 的所有功能,也可以使用 Express 中介軟體,設定更客製化的組態。

除了Apollo Server 的這些程式庫之外,還需要 Express 和 GraphQL 的核心程式庫:

1
npm install express graphql --save

你也可以選擇性的加入 CORS 套件。因為同源準則的限制,當你要跨域向服務器執行 HTTP 請求,需要設定同源準則策略,否則,您可能會遇到 GraplQL 伺服器的跨域資源共享錯誤。

1
npm install cors --save

除此之外,我們還可以安裝 nodemon。nodemon 將監視檔案的變更,並且在我們做出更改時重新啟動伺服器。如此一來,我們就不用在每次更改時都要停止並重新啟動伺服器。但這應該只用在開發階段,避免用在正式上線時。

1
npm install nodemon --save-dev

然後我們在 package.json 的 scripts 鍵加入 nodemon 命令:

1
2
3
"scripts": {
"start": "nodemon -e js,json,graphql"
}

現在當我們使用 npm start 啟動專案後,nodemon 會監視副檔名為 js、json 或 graphql 的任何檔案的異動。此外,我們要在專案根目錄建立一個 index.js 檔案。並確定 package.json 裡面的 main 鍵值指向 index.js,這會是這個專案的入口點:

1
"main": "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
const express = require('express');
const { ApolloServer, gql } = require('apollo-server-express');
const cors = require('cors');

const app = express();

app.use(cors());

const typeDefs = gql`
type Query {
sayHello: String!
}
`;

const resolvers = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。"
}
};

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

server.applyMiddleware({ app });

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

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

第 21 行使用 ApolloServer 建構式建立一個新的伺服器實例,傳送含有兩個值 typeDefs 與 resolvers 的物件給建構式。

第 9 行 typeDefs 是定義 GraphQL schema 的地方,這只是個字串。

第 15 行 resolvers 是定義 GraphQL resolver 的地方。截至目前為止,我們則還未談到解析函式 (resolver),稍後我們再談。

第 26 行我們呼叫 applyMiddleWare 將 Express 加進來,這樣我們就可以使用 Express 框架提供的所有中介函式了。

現在使用 npm start 啟動 GraphQL API,然後就可以用瀏覽器造訪我們的 GraphQL API 了。 試試這個 query:

query
1
2
3
{
sayHello
}

瀏覽器的右半邊回傳:

result
1
2
3
4
5
{
"data": {
"sayHello": "哈囉,台南! 就從這裡開始。"
}
}

好了,你已經建立了一個 GraphQL API 伺服器。

繼續往下之前,我們需要來了解甚麼是解析函式 (resolver)。

解析函式 Resolver

截至目前為止,我們在討論 GraphQL 時將重點都放在 query 上。schema 定義了用戶端可執行的查詢操作,以及各種型態之間的關係。 schema 描述了資料需求,但不會執行取得該資料的的工作,這是解析函式的工作。

解析函式 (resolver) 是回傳特定欄位(field)資料的函式。解析函式會以 schema 定義的型態與外形來回傳資料。解析函式可非同步執行,也可以從 REST API、資料庫或任何其它服務抓取或上傳資料。

我們來看一下根 Query (Root Query) 的解析函式長得如何。在上述 index.js 中,typeDefs 變數是定義 schema 的地方,這只是個字串,resolvers 變數則是個物件:

1
2
3
4
5
6
7
8
9
10
11
const typeDefs = `
type Query {
sayHello: String!
}
`;

const resolvers = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。"
}
};

這裡 sayHello 是根 Query 的一個欄位 (field),當我們建立 sayHello 這類的 query 時,必須提供一個名稱相同的解析函式來支援它。我們用型態定義來描述該欄位(sayHello)應回傳那一種型態。解析函式會從某處回傳該型態的值,在此只是個靜態字串值 “哈囉,台南! 就從這裡開始。”。

另外要特別注意的是,你必須在 “typename 與 schema 內物件相同的物件” 底下定義解析函式。sayHello 欄位是 schema 中 Query 型態物件的一部份,這個欄位的解析函式也必須在 Query 物件裡面。

現在可以定義第二個 query,看看如何運作:

1
2
3
4
5
6
7
8
9
10
11
12
13
const typeDefs = `
type Query {
sayHello: String!
totalPhotos: Int!
}
`;

const resolvers = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。",
totalPhotos: () => 42
}
};

現在試試從 GraphQL Playground 查詢:

query
1
2
3
{
totalPhotos
}
result
1
2
3
4
5
{
"data": {
"totalPhotos": 42
}
}

解析函式是製作 GraphQL 的關鍵。每個欄位(field)都要有個對應的解析函式。解析函式必須遵守 schema 的規則。它的名稱必須和在 schema 內定義的欄位名稱一樣,而且它必須回傳在 schema 定義的資料型態。

根解析函式

之前談過,GraphQL API 有 Query、Mutation 與 Subscription 根型態。這些型態位於最頂層,代表 API 的所有入口。到目前為止,我們已經在 Query 型態加入了 sayHello 與 totalPhotos 兩個欄位了,代表 API 可以查詢這些欄位。

我們來為 Mutation 建立根型態。這個 mutation 欄位稱為 postPhoto,它可接收 String 型態的 name 與 description 引數。當 mutation 被送出時,它必須回傳一個 Boolean:

1
2
3
4
5
6
7
8
9
10
const typeDefs = `
type Query {
sayHello: String!
totalPhotos: Int!
}

type Mutation {
postPhoto(name: String! description: String): Boolean!
}
`;

建立 postPhoto mutation 之後,我們要在 resolvers 物件內加入對應的解析函式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const photos = [];

const resolvers = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。",
totalPhotos: () => photos.length
},
Mutation: {
postPhoto(parent, args) {
photos.push(args);
return true;
}
}
};

為了不要失去目前的學習焦點,我們建立一個 photos 變數,用來將照片的細節儲存在陣列中,稍後會將照片資料存放到資料庫內。

接著,我們修改第 6 行的 totalPhotos 解析函式,讓它回傳 photos 陣列的長度。當這個欄位被查詢時,它會回傳目前在陣列中的照片數量。

接著加入 postPhoto 解析函式。我們這一次在 postPhoto 函式中使用引數。第一個引數是父物件 (parent) 的參考。在本例,postPhoto 解析函式的父物件是 Mutation,目前我們不會使用父物件的資料,但它必定是解析函式的第一個引數,因此,我們要加入一個預留的 parent 引數,這樣才可以使用解析函式的第二個引數: mutation 的引數。

傳送給 postPhoto 解析函式的第二個引數是傳給這項操作(Operation) 的 GraphQL 引數: name 以及可選的 description。 args 變數是個含有 { name, description } 這兩個屬性的物件,目前這引數代表一個照片物件,所以我們直接將它們傳給 photos 陣列。

接下來我們要在 GraphQL Playground 中測試 postPhoto mutation,傳送一個字串給 name 引數:

mutation
1
2
3
mutation newPhoto {
postPhoto(name: "sample photo")
}

這個 mutation 會將照片細節加入陣列,並回傳 true。

接著使用查詢變數(Query Variables)來修改這個 mutation:

mutation
1
2
3
mutation newPhoto($name: String!, $description: String) {
postPhoto(name: $name, description: $description)
}

將變數加入 mutation 後( 這裡的變數是 $name 與 $description),我們必須傳送資料來提供字串變數。我們在 Playground 的左下角將 name 與 description 的值加到 Query Variables 視窗:

Query Variables
1
2
3
4
{
"name": "sample photo A",
"description": "我們數據集的示例照片"
}

型態解析函式

當你執行 GraphQL query、mutation 或 subscription 時,它會回傳外形與 query 相同的結果。我們知道解析函式可回傳純量型態(scalar type)值,例如整數、字串與布林,但解析函式也可以回傳物件。

我們要在此應用中建立一個 Photo 型態與一個將會回傳一串 Photo 物件的 allPhotos query 欄位:

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const typeDefs = `
type Query {
sayHello: String!
totalPhotos: Int!
allPhotos: [Photo!]!
}

type Mutation {
postPhoto(name: String! description: String): Photo!
}

type Photo {
id: ID!
url: String!
name: String!
description: String
}
`;

因為我們在第 12 行型態定義中加入 Photo 物件與第 5 行加入了 allPhotos query,所以必須在解析函式中做對應的調整。

postPhoto mutation 原來回傳一個布林值,現在將它改為回傳一個外形為 Photo 型態的資料。 allPhotos query 則回傳一串外形與 Photo 型態的物件陣列:

index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const photos = [];
let _id = 0;

const resolvers = {
Query: {
sayHello: () => "哈囉,台南! 就從這裡開始。",
totalPhotos: () => photos.length,
allPhotos: () => photos
},
Mutation: {
postPhoto(parent, args) {
let newPhoto = {
id: _id++,
...args
}

photos.push(newPhoto);

return newPhoto;
}
}
};

因為 Photo 型態需要一個 ID,所以我們建立一個變數來儲存 ID。我們會在 postPhoto 解析函式裡面遞增這個值來產生 ID。args 變數提供照片的 name 與 description 欄位,但我們也需要 ID。是否建立代碼與時戳之類的變數通常則是由伺服器決定的。

postPhoto mutation 除了會在 photos 加入照片物件外,也會回傳一個外形符合 Photo 型態的物件,而不是回傳布林值。這個物件是用自動產生的 ID 及以 args 傳入的 name 和 description 欄位來建構的。這些物件的外形都符合在 schema 中定義的 Photo 型態的外形,所以我們可以從 allPhotos query 回傳整個 photos 組成的陣列。

我們可以調整 mutation 來確認 postPhoto 可正確的運作。因為回傳的 Photo 是一種型態,我們必須在 mutation 中加入一個選擇組 (第 3、 4、 5 行):

mutation
1
2
3
4
5
6
7
mutation newPhoto($name: String!, $description: String) {
postPhoto(name: $name, description: $description) {
id
name
description
}
}

用 mutation 加入一些照片後,使用 allPhotos query 可回傳一個包含所有新增的 Photo 物件的陣列:

query
1
2
3
4
5
6
7
query listPhotos {
allPhotos {
id
name
description
}
}

我們也曾經在 Photo 型態中加入一個不可為 null 的 url 欄位。當我們現在在選擇組加入 url 時會發生甚麼事情?

query
1
2
3
4
5
6
7
8
query listPhotos {
allPhotos {
id
name
description
url
}
}

當我們在 query 的選擇組加入 url 時,會顯示一個錯誤: Cannot return null for non-nullable field Photo.url。我們並未在 postPhoto mutation 的資料集中加入 url 欄位,我們不需要儲存 url,我們只需要在 query 時產生 url 的資料即可。schema 的每一個欄位都會對應一個解析函式。我們只要在解析函式清單加入一個 Photo 物件,並定義想要對應解析函式的欄位即可。在本例中,我們想要使用一個函式來協助解析 url:

index.js
1
2
3
4
5
6
7
const resolvers = {
Query: { ... },
Mutation: { ... },
Photo: {
url: parent => `https://fakeimg.pl/120x160/?text=${parent.id}`
}
};

因為我們要使用 Photo url 的解析函式,所以在解析函式中加入一個 Photo 物件。這個在根部加入的 Photo 解析函式稱為 trivial 解析函式。我們會在 resolvers 物件的最頂層加入 trivial 解析函式。如果你沒有指定 trivial 解析函式,GraphQL 會使用預設的解析函式,回傳與欄位同名的特性 (例如, Photo.name、Photo.description) 。

在 query 中選擇 Photo 的 url 時會呼叫對應的解析函式。解析函式的第一個引數一定是 parent 物件。在本例中,parent 代表目前被解析的 Photo 物件。假設我們的服務只能處裡 JPEG 圖像,這些圖像是用他們的 Photo ID 來命名的,可以用 http://yoursite.com/img/${parent.id}.jpg 路由來找到(我們這裡沒有實作可以放範例照片的伺服器,所以用一個假圖片的網址)。因為 parent 是 Photo 物件,我們可以透過這個引數來取得照片的 ID,並用它來自動產生當前照片的 URL。

當我們定義 GraphQL schema 時,就是在描述 application 的資料需求。使用解析函式可讓我們有充分的能力與彈性滿足這個需求。函式提供這些能力與彈性。函式可以是非同步的、可以回傳純量型態和物件,也可以從各種來源回傳資料。解析函式只是個函式,GraphQL schema 的每一個欄位 (field) 都可以對應一個解析函式

使用 input 與 enum

接下來我們要在 typeDefs 加入一種 enum 型態: PhotoCategory,以及一種 input 型態: PostPhotoInput:

typeDefs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum PhotoCategory {
SELFIE
PORTRAIT
ACTION
LANDSCAPE
GRAPHIC
}

type Photo {
...
category: PhotoCategory!
}

input PostPhotoInput {
name: String!
category: PhotoCategory=PORTRAIT
description: String
}

type Mutation {
postPhoto(input: PostPhotoInput!): Photo!
}

在解析 Photo 時,我們必須確保照片分類 (它是個字串,應符合在 enum 內定義的值) 是有效的。我們也要在使用者貼出新照片時接收分類。

我們在單一物件下加入一個 PostPhotoInput 型態來整合 postPhoto mutation 的引數。這個 input 型態有個 category 欄位,就算使用者沒有提供引數給 category 欄位,也會使用預設的 PORTRAIT。

我們也必須稍微修改 postPhoto 解析函式。我們將 Photo 的細節,包括 name、description 與 category 放在 input 欄位裡面。我們必須從 args.input 存取這些值,而不是 args:

resolvers
1
2
3
4
5
6
7
8
9
10
postPhoto(parent, args) {
let newPhoto = {
id: _id++,
...args.input
};

photos.push(newPhoto);

return newPhoto;
}

接著我們用新的 input 型態執行 mutation:

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:

Query Variables
1
2
3
4
5
6
{
"input": {
"name": "sample photo A",
"description": "我們數據集的示例照片"
}
}

如果用戶端沒有提供 category,它會使用預設的 PORTRAIT。或者,如果用戶端提供 category 的值,我們就用 enum 型態來驗證它,再將操作送給伺服器。當它是有效的 category 時,我們用引數將它傳給解析函式。

藉由 input 型態,我們更容易重複使用 “由用戶端傳遞引數給 mutation” 的操作,且較不容易出錯。藉由結合 input 型態與 enum,我們可以更了解特定欄位可用的輸入型態有哪些。input 與 enum 是很棒的功能,且同時使用可發揮更好的效果。

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

變動 Mutation

mutation 必須在 schema 中定義。與 query 一樣,我們要在 schema 內,用 mutation 自訂的物件型態定義它。技術上,在 schema 中定義 mutation 與 query 的方式沒有任何差異。有差異的是它們的目的。你只需要在動作或事件會改變有關於 app 的狀態時建立 mutation。

mutation 代表 app 的動詞,它們只應該包含使用者可以用你的服務做的事情。當你設計 GraphQL 服務時,可列出使用者可以用你的 app 做的所有動作,它們可能都是你的 mutation。

在 PhotoShape app 中,使用者可以登入、貼出照片與標記照片。這些動作都會改變一些 app 的狀態。當使用者登入後,用戶端的使用者就會改變。當使用者貼出照片時,系統會多出一張照片。有人標記照片時也會改變狀態,每當有照片被標記時,就會產生新的照片標記資料紀錄。

我們可以將這些 mutation 加入 schema 內的根 mutation 型態,讓用戶端可以使用他們。我們從第一個 mutation postPhoto 開始寫起。

1
2
3
4
5
6
7
8
9
10
11
12
type Mutation {
postPhoto(
name: String!
description: String
category: PhotoCategory=PORTRAIT
): Photo!
}

schema {
query: Query
mutation: Mutation
}

在 Mutation 型態下面加入一個稱為 postPhoto 的欄位可讓使用者貼出照片。目前只是詮釋這個動作,還需要 GraphQL 服務的實作。

當使用者貼出照片時,至少需要提供照片的 name,而 description 與 category 是可選的。如果使用者沒有提供 category 引數,貼出的照片將會使用預設值 PROTRAIT。例如,使用者可以傳送下列的 mutation 來貼出照片:

client side
1
2
3
4
5
6
7
8
9
10
mutation {
postPhoto(name: "Sending the Palisades") {
id
url
created
postedBy {
name
}
}
}

使用者可以在貼出照片後選擇關於剛才貼出的照片的資訊。這是很好的功能,因為有些新照片資料是在伺服器上產生的,例如新照片的 ID 是資料庫建立的,照片的 url 是自動生成的,我們也會幫照片加上它被 created 建立時的日期與時間時戳。照片被貼出之後,query 可以選擇以上所有新欄位。

此外,選擇組也有關於貼出照片的使用者資訊。使用者必須登入才能貼出照片。如果目前沒有登入的使用者,這個 mutation 就要回傳錯誤。如果有使用者登入了,我們可以透過 postedBy 欄位來取得關於誰貼出照片的資料。

輸入型態 Input

你或許已經發現,有一些 query 與 mutation 的引數愈來愈長了。有一種調整這些引數的好方法,使用輸入型態。輸入型態類似 GraphQL 物件型態,但他只供輸入引數使用。

1
2
3
4
5
6
7
8
9
input PostPhotoInput{
name: String!
description: String!
category: PhotoCategory=POSTRAIT
}

type Mutation {
postPhoto(input: PostPhotoInput!): Photo!
}

PostPhotoInput 型態就像物件型態,但是只供輸入引數使用。這裡 name 與 description 是必須的,但 category 欄位仍然是選用的。現在當你傳送 postPhoto mutation 時,要將新照片的相關資料放在一個物件裡面:

mutation
1
2
3
4
5
6
7
mutation newPhoto($input: PostPhotoInput!) {
postPhoto(input: $input) {
id
url
created
}
}

我們建立這個 mutation 時,將 $input 查詢變數的型態設成 PostPhotoInput! 輸入型態。它不可為 null,因為我們至少要用 input.name 欄位來加入新照片。當我們傳送這個 mutation 時,必須用 input 欄位內的查詢變數(Query Variables)來提供新照片的資料:

1
2
3
4
5
6
7
{
"input": {
"name": "Hanging at the Arc",
"description": "Sunny on the deck of the Arc",
"category": "LANDSCAPE"
}
}

我們的輸入被一起放在一個 JSON 物件裡面,並且在 “input” 鍵底下以 query 變數與 mutation 一起送出。因為查詢變數被格式化為 JSON,category 必須是個符合 PhotoCategory 的其中一種分類的字串。

輸入型態是建構與編寫簡明的 GraphQL schema 的關鍵元素。你可以將輸入型態當成任何欄位的引數,也可以在 app 中用它們來改善資料分頁與資料過濾。

回傳型態

現在 schema 的所有欄位都回傳主要的型態,User 與 Photo。但除了實際的承載資料 (payload data) 之外,有時我們也需要回傳關於 query 與 mutation 的詮釋資訊。例如,如果有使用者已經登入並且通過驗證了,除了 User 承載資料之外,我們也需要回傳權杖 (token)。

1
2
3
4
5
6
7
8
9
type AuthPayload {
user: User!
token: String!
}

type Mutation {
...
adAuth(code: String!): AuthPayload!
}

我們藉由傳送有效的碼給 adAuth mutation 來驗證使用者,成功後,回傳一個自訂的物件型態,裡面有成功登入的使用者的資訊,可用來做進一步授權的權杖,以及包括 postPhoto mutation 的多個 mutation 時所需的使用者資訊。

你可以在任何 “需要除了承載資料之外的資料” 的欄位使用自訂回傳型態。或許除了尋承載資料之外,我們也想要知道一個 query 需要多少時間來傳遞回應,或是在某個回應中可找到多少結果。你可以使用自訂的回傳型態來處理這類的事情。

訂閱 Subscription

Subscription 型態與 GraphQL schema 定義語言的任何其他物件型態沒有甚麼不同。我們要在自訂物件型態內將 subscription 定義成欄位。而當建立 GraphQL 服務時,要自行確保 subscription 實作了 PubSub 設計模式以及一種即時傳輸。

例如,我們可以加入 subscription 來讓用戶端監聽 Photo 或 User 型態的建立:

1
2
3
4
5
6
7
8
9
10
type Subscription {
newPhoto: Photo!
newUser: User!
}

schema {
query: Query
mutation: Mutation
subscription: Subscription
}

我們在這裡建立一個自訂的 Subscription 物件,它有兩個欄位: newPhoto 與 newUser。當使用者貼出新照片時,那張照片會被推送到訂閱 newPhoto subscription 的所有用戶端。有新的使用者被建立時,他們的資料會被推送到每一個監聽新使用者的用戶端。

subscription 與 query 或 mutation 一樣可以使用引數。假如我們要在 newPhoto subscription 加入過濾器,讓它只監聽新的 ACTION 照片:

1
2
3
4
type Subscription {
newPhoto(category: PhotoCategory): Photo!
newUser: User!
}

現在當使用者訂閱 newPhoto subscription 時,他們可以過濾被送到這個 subscription 的照片。例如,若要濾出新的 ACTION 照片,用戶端可傳送下列的操作給 GraphQL API:

query
1
2
3
4
5
6
7
8
9
10
subscription {
newPhoto(category: "ACTION") {
id
name
url
postedBy {
name
}
}
}

這個訂閱只會回傳 ACTION 照片的資料。

如果即時處理資料是很重要的功能,訂閱是很好的解決方案。

在上一篇 Vue Oracle 框架 我們談到了 Lost Updates 問題,這裡我們進一步來詳細了解它及看看可以如何解決。

Lost Updates 是經典的關連式資料庫問題,關連式資料庫裡所必須包含的 ACID 特性,就是在解決這個問題。我們重覆上一篇所發生的情境來了解 Lost Updates 發生的時機。

  1. 使用者 A 與使用者 B 幾乎在同一時間從資料庫讀取同筆 (row) 資料。
  1. 使用者 A 與使用者 B 各自修改該筆數據,並相繼提交更新資料庫。
  1. 使用者 A 重新從資料庫讀取資料,卻發現他先前修改的資料不見了。

這被稱為 Lost Updates,因為使用者 A 所做的所有更改都將丟失。

這原本是一個常見與簡單的使用者 UI 介面。 因此,我們編寫的應用程序沒有任何資料鎖定 (Lock),只是簡單的 SELECT 和 UPDATE 命令。不幸的是,它卻很容易發生,也很常會被忽略。

要注意的是,會發生這個問題,使用者 A 和使用者 B 甚至不需要在完全相同的時間點讀取資料。他們只要是在大約的時間點,便有可能就會發生,使用者越多則發生的機率越高。

這是一個關連式資料庫的問題,也是應用程序開發工具的問題,GUI 開發人員如果不了解 Lost Updates 的情況下,被賦予編寫資料庫應用程序的任務,這種錯誤就會一次又一次的出現。 他們掌握了 SELECT、INSERT、UPDATE 和 DELETE 的使用知識,然後著手編寫應用程序;當開發的應用程序以上述方式運行時,錯誤看起來是如此隨機,又很零星,並且在受控環境中是完全不可複制的,這常導致開發人員認為這一定是使用者的錯誤,而發生爭執,最終則會失去使用者的信任。所以如果你要了解一個應用程序開發人員的水準,首先就可以測試它是不是有 Lost Updates 問題。

許多工具,例如 Oracle Forms 通過確保某筆資料在你進行查詢和更改之前沒有被更改、並鎖定數據,背後保護您免受於這種錯誤;但是許多其他工具,例如自行編寫的 C#、Java 或 JavaScript 應用程序卻沒有這樣做,你必須自行防止,類似的這些細節有時是相當繁瑣的。

而保護的方式也因所開發的應用程序類別而有不同,Oracle Forms 與 Oracle APEX 這兩種工具所開發的應用程序就有非常大的區別,也分別使用了不同的防止 Lost Updates 策略。

Oracle Forms 是一種 stateful 開發工具,當使用者登入後,會一直與資料庫保持連線直到使用者登出,使用的是資料庫同一個 session,所以可以使用數據庫的 ACID 特性來防止 Lost Updates 的發生,這種機制就是我們熟悉的 Lock

Oracle APEX 則是一種 stateless 類型應用程序開發工具,它無法直接連線資料庫,通常是透過使用 HTTP 協定的 API 來與資料庫或其他資源互動,當它讀取資料與要提交更改回資料庫時,不會使用資料庫的同一個 session,所以我們無法事先 Lock 該筆資料,這與 Oracle Forms 不同,需要有不同的策略。

從資料庫 Lock 的觀點來看,為防止 Lost Updates 問題,可選擇使用兩種不同的資料鎖定策略:

  • 悲觀鎖定 (Pessimistic Locking)
  • 樂觀鎖定 (Optimistic Locking)

這在 Oracle Forms 則具有較大的優勢,兩種策略都可選擇;Oracle APEX 所開發的 Web Application 則只有一種選擇,即樂觀鎖定。以使用者角度來看,悲觀鎖定則會有比較好的 UX 體驗。

Pessimistic Locking 悲觀鎖定

Oracle Forms 使用的策略是 Pessimistic Locking 悲觀鎖定,我們就從它的 UI 介面來了解甚麼是悲觀鎖定,以及它的優勢。雖然以後不會再使用 Oracle Forms 來開發應用程序,但它可以讓我們更深刻的了解 Lost Updates 問題。

當使用 Oracle Forms 的 Query 查詢如以下畫面的資料:

它在背後其實執行了一段 SQL 指令:

select
1
2
3
SELECT empno, ename, job, mgr, hiredate, sal, comm, deptno
FROM emp
/

這是一段很普通的 SELECT 讀取,資料庫不會發出任何的資料鎖定,因此其它使用者也可以登入並開啟另外一個 Form 畫面讀取相同的資料與畫面。

回到第一個使用者 A 登入的 Form,當使用者 A 選擇特定的資料行(row),將遊標移到一個欄位,並輸入任何的值,例如將 empno 7788 的薪水欄位更改為 45300 (或隨便按一個空白鍵)。

Oracle Forms 馬上會在該時間點(鍵入第一個字符)發出以下的 SQL 指令:

select for update nowait
1
2
3
4
5
6
7
8
9
10
11
12
SELECT empno, ename, job, mgr, hiredate, sal, comm, deptno
FROM emp
WHERE empno = :empno
AND ename = :ename
AND job = :job
AND mgr = :mgr
AND hiredate = :hiredate
AND sal = :sal
AND comm = :comm
AND deptno = :deptno
FOR UPDATE NOWAIT
/

Oracle Forms 應用程序會從螢幕上的數據提供綁定變數 (bind variables),從資料庫中重新查詢同一筆資料,但這次使用 SELECT FOR UPDATE NOWAIT 鎖定該筆資料 (row) 以防止資料庫中的其它 session 對該筆資料進行更新。

從資料庫引擎中可查詢到該 Lock,通常 SELECT FOR UPDATE 會發出一個 Row-X (SX)(row exclusive lock) Lock。

此時如果使用資料庫其它的 session 發出如下的指令:

因為沒有使用 NOWAIT,這個 session 將會停在等待回覆的狀態,資料庫則會對該筆資料再發出一個 Row-X (SX) Lock,而後面的 session 在等待前一個 session 釋放其 Lock (可透過 commit 或 rollback 釋放鎖定)。

如果不想等待,使用 SELECT FOR UPDATE NOWAIT, 則會收到 ORA-00054 資源繁忙錯誤。 我們被封鎖,必須等待其他用戶完成,並釋放出資源。

這就是為什麼這種方法稱為悲觀鎖定的原因。 我們在嘗試更新之前就鎖定了該筆資料,因為我們懷疑該筆資料是否會保持不變。這是資料庫層的 Lost Updates 保護機制。

回到 Oracle Forms,移到使用者 B 的畫面上,選擇同一筆資料,將遊標移到該筆資料的任何欄位,隨便輸入一個值或按下空白鍵。

這回 Oracle Forms 馬上會彈出一個警告視窗,它無法預留該筆資料作修改,這是 Oracle Forms 防止 Lost Updates 的第一道防線。你可以等一下按 Yes 再試試看。

回到使用者 A 的 Form 畫面,從 Action 選項中選擇 Save, 將資料存入資料庫(commit),此同時也會釋放其鎖定的資料。

所以我們可以回到使用者 B 的畫面,從警告視窗中按下 Yes,再度嘗試取得該筆資料的鎖定,這次左下角則出現另一個警告訊息

FRM-40654: Record has been updated by another user. Re-query to see change.

要求你要從新從資料庫 Re-query 資料。

這是 Oracle Forms 防止 Lost Updates 的第二道防線。Oracle Forms 如何知道該筆資料已被更改過了?

你有注意到之前我們發出 SELECT FOR UPDATE NOWAIT 時在 WHERE 的條件嗎? 要 Lock 一筆資料其實只需要使用 Primary Key。但在 WHERE 條件中卻使用了所有的欄位,Oracle Forms 根據目前畫面上的資料嘗試鎖定該筆資料時卻讀取不到資料,因為目前畫面上的資料仍然是舊的資料與數據庫中的資料不同(薪水欄位已被更改),所以它無法鎖定任何的資料,因此要求你必須在修改任何數據之前重新查詢並鎖定。

成功鎖定行後,應用程序就可以發佈一些更新並提交更改。

update
1
2
3
4
5
6
7
8
9
10
UPDATE emp
SET ename = :ename,
job = :job,
mgr = :mgr,
hiredate = :hiredate,
sal = :sal,
comm = :comm,
deptno = :deptno
WHERE empno = :empno
/

悲觀鎖定在 Oracle Forms 與 Oracle Database 應用的非常有效,並且比樂觀鎖定具有很多的優勢。它可以防止使用者在輸入更新前提醒使用者目前的 UI 與資料庫數據間的關連。

但也要了解它可能產生的缺點,使用者可能會無意中鎖定資料,而如果使用者走開並且在一段時間內未真正使用該筆資料,則可能會讓其它的使用者空等,所以應該搭配使用資料庫的超時機制讓應用程序釋放資料的鎖定。

Optimistic Locking 樂觀鎖定

悲觀鎖定無法應用在像用 Oracle APEX 所開發的 Web Application 上,Web Application 使用的 HTTP 協定是一種 stateless 協定,無法像 stateful 協定一樣持續連線在資料庫上,只能使用第二種樂觀鎖定策略。

樂觀鎖定的基本概念是將舊值和新值同時都保留在應用程序中,並在提交更新數據時使用如下指令直接更新資料:

Optimistic update
1
2
3
4
5
6
Update table
Set column1 = :new_column1, column2 = :new_column2, ...
Where column1 = :old_column1
And column2 = :old_column2
...
/

在這裡,我們不會事先鎖定該筆資料,樂觀的希望數據在讀取與提交更改的這段時間內不會被更改。要注意的是 WHERE 條件,必須與可被更改的欄位做映對(Set 的欄位都必須在 Where 條件上)。

在這種情況下,如果我們成功的提交更新了一筆資料,那我們很幸運,在我們讀出數據與提交更新的時間之間,數據沒有發生變化。

如果我們更新零筆資料,那麼表示有其他人更改了數據,我們必須重新查詢該筆資料的新值後,重新鍵入要更改的資料(這可能會是一個無止境的循環,因為該筆資料可能會再次被更改)。

因為我們無法鎖定它,只能稍後嘗試更新它,使用者可能會花時間和精力不斷的重覆進行更改(運氣真差),而只會是被告知 “對不起,數據已更改, 請重試”。

這時你該如何設計你的 UI 讓使用者的抱怨少一些? 是否應該嘗試合併兩個更新的值,並根據業務規則(大量代碼)執行更新衝突解決方案? 與悲觀鎖定比較,這常會造成使用者較差的 UX 體驗。

這種樂觀的鎖定基本概念重點就是在 WHERE 條件上,必須與可被更新的欄位做映對,但可有一些變異的技術。

Oracle APEX 使用的是 checksum 技術,也就是在讀取資料時對該筆資料所有欄位的值做 checksum 計算,並儲存在應用程序中,當要提交更改之前再從數據庫中讀取同一筆資料做 checksum 計算,比對舊的 checksum 與最新的 checksum 值來辨識資料是否被更改過。

當兩個 checksum 不相等時,Oracle APEX 會彈出錯誤訊息:

當遇到這種錯誤,使用者只能放棄他所做得更改,從新更新 UI 介面資料,再重覆輸入更改。

以下是一段簡單的 JavaScript checksum 程式碼範例,它可以在客戶端(流覽器),也可以放在 API 端,這要看應用程序與 API 的規劃而定:

checksum example
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
const crypto = require('crypto');

const employee = {
"empno": 7607,
"ename": "史密斯",
"job": "CLERK",
"mgr": 7788,
"hiredate": "2016-08-29T16:00:00Z",
"sal": 45300,
"comm": null,
"deptno": 10
};


function checksum (obj) {
const string = Object.keys(obj)
.sort()
.map(prop => String(obj[prop]))
.join('');

return crypto
.createHash("sha1")
.update(string)
.digest("hex");
}

console.log(checksum(employee)); // b2004faab97268287bc4a40856d5cb4ec70dba55

程式碼很簡單,但這種 checksum 技術,仍然會有時間差的風險存在,因為它是在應用程序端完成的。在從資料庫讀取當前的資料、計算 checksum、比對 checksum 後再提交更改,資料庫裡的資料有可能會在這段時間間被更改。

因此直接使用樂觀鎖定基本概念的方式似乎比使用 checksum 來的保險,但如果欄位很多,似乎也是非常煩人。

因此我們有另外一種變異方式,如果你有權限或可以更改資料庫資料表的結構,則可在資料表中新增一個版本控制欄位,每次更新資料時同時更改這個欄位的值,這樣就可以簡化樂觀鎖定時的 WHERE 條件,就只需要 Primary Key 與版控制欄位。

Optimistic update
1
2
3
4
5
Update table
Set column1 = :new_column1, column2 = :new_column2, ...
Where primary_key = :primary_key
And version = :old_version
/

在 Oracle Database 中,版本控制欄位的值可以使用 SYS_GUID( ) 以確保值的唯一性。它會產生一個 RAW(16) 型態的值。

sys_guid( )
1
2
3
4
5
select sys_guid() as version from dual;

VERSION
--------------------------------
9606E4C2447143D7E0538B190B0AD862

悲觀的鎖定在進行更改之前鎖定資料,其他使用者將被鎖定在該筆資料之外,應用程序的可伸縮性將降低。但如果我們要避免 Lost Updates,無論您怎麼做,最終只有一個用戶能夠更新該行。如果能事先鎖定該行,然後再對其進行更新,使用者將會有比較好的體驗(UX)。

在此所展示的悲觀鎖定我們所用的是 Oracle 資料庫,Oracle 的鎖定不會像在其他資料庫會阻止其它 session 的讀取。這是由於 Oracle 100% 實作了 ACID 的並發性(concurrency)和鎖定(locking)。

在有些資料庫中,有些情况恰好相反。如果試圖對它們進行悲觀鎖定,那麼任何應用程序可能都將無法正常工作。這些資料庫中的鎖定阻止了查詢。因此這裡所描述的情況,可能不適用在其它的資料庫中。

至於樂觀鎖定則比較不限制在各種資料庫中,但使用者可能會遇到較差的體驗,如果企圖使用 UI 介面,改進使用者的體驗,勢必也要有 API 層的配合,就像樂觀鎖定的名稱一樣,樂觀一點吧。