0%

建立 GraphQL API (4)

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)