0%

這個框架範本,是建立在前一篇 Vue Fullstack 框架範本 的基礎上。

除了基本的認證外,實際加入了 Oracle 資料庫的資料運作。因此除了客戶端 Vue 的框架,也實作了伺服端與 Oracle 資料庫的 API, 實做了 Oracle 資料表的資料讀取、新增、更改與刪除,因此在此專案中需要安裝 Node.js 的 Oracle Database driver node_oracledb。安裝這個 node_oracledb 你必須先在你的伺服器端安裝 Oracle Client。

如果你使用的是 Microsoft Windows 64 bit,可將 XXX111:\DBA\Oracle Client\12.2\instantclient_12_2_basic 整個目錄複製到你的電腦上,並將路徑加入你的 PATH 環境變數上。

原始碼

  • 如果你有安裝 git,可以用 git clone 取得程式碼,我的 Z: 虛擬硬碟是指向 XXX111。
1
2
3
4
5
6
7
git clone Z:\DBA\Git\Depot\vue-oracle-sample.git

cd vue-oracle-sample

npm install

npm start

如果要用在正試環境時,記得將 package.json 的屬性 start 改為用 Node.js 啟動:

package.json
1
2
3
"scripts": {
"start": "node ./bin/www"
},

你可以使用 admin 管理員帳號管理使用者,記得更改 admin 的密碼。

範例帳號,預設密碼都是 changeonlogin:

  • admin
  • user
  • scott
  • smith
  • allen

網址 http://localhost:3000/

Oracle Database 使用的則是 Lxxx, Schema: xxxx/xxxxxxx

Used TNSNAMES adapter:

(DESCRIPTION = (ADDRESS = (PROTOCOL = TCP)(HOST = xxxx.xxx.com.tw)(PORT = 1522)) (CONNECT_DATA = (SERVER = DEDICATED) (SERVICE_NAME = xxxx.xxx.com.tw)))

頁面簡介

這個是 Oracle Table dept 的 UI 介面:

這個是 Oracle Table emp 的 UI 介面:

Lost Update 問題

Web Application 是一種 stateless 架構,我們這裡的範本仍有一個重要的問題需要解決,就是 Lost Update Problem。甚麼是 Lost Update,我們直接來看例子:

使用者 A 與使用者 B 幾乎在同一時間從資料庫讀取同筆資料:

然後各自修改了資料:

然後使用者 A 與 B 相繼送出更新,資料庫的資料會如何?

這種狀況在單人系統發生的機率很少,多人使用的系統,這是必須解決的問題,在 stateful 的架構中,關連性資料庫的 ACID 特性會防止這個狀況,你們可以嘗試了解一下 Oracle Forms 是如何解決的。

至於 APEX 又是如何解決的?

你如果是使用 APEX 的精靈產生的 Form

當使用者 B 嘗試送出更新時,則會出現如下的錯誤訊息

使用者 B 必須取消更新(Cancel)重新讀取資料,再重做更新。

APEX 使用的是 checksum 技術,你則可以另行使用其它的版本控制技術。

我們一直使用的是 Oracle 關連式資料庫,ACID 的特性幫我們解決了單塊架構的資料一至性問題,微服務背後的問題則相當的複雜,如果雲端與微服務是未來的策略,這些基礎的問題與架構必須加緊了解與規劃。

這個框架範本,是建立在Fullstack Vue 實作範例 - Authorization 介紹的基礎上。

這個範本則把使用者的資料移到資料庫中,使用的是 LevelDB。客戶端也有介面可以維護使用者的資料,包括新增、修改與刪除。使用者如果有 AD 帳號,可以選擇使用 AD 認證,也可使用自設的密碼認證,可設定角色(Role)作授權的管理,授權包含客戶端與伺服端 API 的授權。

這個範本可以當成以 Vue.js 為基礎的實作架構,複製後就可以直接在架構上新增功能。認證與授權都已在架構中。認證應不需要做甚麼修改,授權只需依照使用者授權的範本,複製修改即可。

現在大家都熱中在客戶端技術的學習,但我認為如果要走雲端及微服務,伺服端的 API 與資料的一致性才是重點與複雜所在,這可以事先漸進的把這些概念與機制建立起來。而這些概念與架構都在今年的 Node.js 教育訓練中。

原始碼

  • 如果你有安裝 git,可以用 git clone 取得程式碼,我的 Z: 虛擬硬碟是指向 XXX111。
1
2
3
4
5
6
7
git clone Z:\DBA\Git\Depot\vue-fullstack-sample.git

cd vue-fullstack-sample

npm install

npm start
  • 如果沒有安裝 git,可從 XXX111:\DBA\Oracle Training\Xyang\Source\vue-fullstack-sample.zip 取得壓縮檔,解壓縮後:
1
2
3
npm install

npm start

如果要用在正試環境時,記得將 package.json 的屬性 start 改為用 Node.js 啟動:

package.json
1
2
3
"scripts": {
"start": "node ./bin/www"
},

你可以使用 admin 管理員帳號管理使用者,記得更改 admin 的密碼。

範例帳號,預設密碼都是 changeonlogin:

  • admin
  • user
  • scott
  • smith
  • allen

網址 http://localhost:3000/

Ready to go! Go!

頁面簡介

整體架構與Fullstack Vue 實作範例 - Authorization 是一樣的,伺服器端新增了使用者的新增、更改、刪除與更改密碼的 API,可以把這個部分改使用 GraphQL,或者使用 C# .NET,這不會影響客戶端的程式碼。

客戶端則新增了使用者管理的操作頁面,有些頁面只有管理者才能操作,有些頁面則限制只有登入的使用者能存取自己的資訊。這不僅在客戶端要作管控,伺服端的 API 也需同時管控這些資料的授權。

在這個架構上,你只需加入自己的頁面與 REST API,就像使用 APEX 一樣。

此範例,將介紹如何在 Vue.js 中實現基於角色的授權/訪問控制(role based authorization/access control)的示例。

將從伺服器端開始,單頁應用程式 SPA 的伺服器端在客戶端下載 index.html 檔案後,似乎就沒甚麼事情了,如果不是很複雜的系統,我們可以用它兼作 API 伺服器。這個範例我們就將它兼作為伺服器端認證的 API,我們將在此伺服器上實作身份驗證服務 API。正式應用上可以使用 GraphQL 將認證與一般的 API 整合,只需將此範例認證 API 改為指向 GraphQL 即可,除此,客戶端不需甚麼變動。

原始碼

這個範例可以當成 Web Application 的起始架構,程式碼可以使用 git 或複製壓縮檔取得:

  • 如果你有安裝 git,可以用 git clone 取得程式碼,我的 Z: 虛擬硬碟是指向 XXX111。
1
2
3
4
5
6
7
git clone Z:\DBA\Git\Depot\vue-demo-sample-auth.git

cd vue-demo-sample-auth

npm install

npm start
  • 如果沒有安裝 git,可從 XXX111:\DBA\Oracle Training\Xyang\Source\vue-demo-sample-auth.zip 取得壓縮檔,解壓縮後:
1
2
3
npm install

npm start

如果要用在正試環境時,記得將 package.json 的屬性 start 改為用 Node.js 啟動:

package.json
1
2
3
"scripts": {
"start": "node ./bin/www"
},

如果你想跟著稍後的實作細節自己建立程式碼,可以複製Fullstack Vue 實作範例 (1)作為起始架構,跟著實作。

實作細節

以下則是詳細的過程及說明。

伺服器端

HTML5 歷史模式

以 Vue.js 建立的 SPA App 通常只使用一個瀏覽器可以訪問的索引檔案,例如 index.html,而網址導航(網頁切換),通常透過客戶端的 Vue Router 來達成。預設的情況下, Vue Router 使用 URL 雜湊來儲存路徑,要在你的網站中存取 /about,你要輸入 http://<你的網站>.com/#/about。不過,幾乎所有現行的瀏覽器都支援 HTML5 history API 。有了它,開發者無須重載新網頁就可以更新 URL,網址也會更好看。

public/router.js
1
2
3
4
5
6
7
const router = new VueRouter({
mode: history,
routes: [
{ path: '/', component: PageHome},
{ path: '/about', component: PageAbout }
]
});

在第 2 行我們可以把 Vue Router 設為 HTML5 history API。 現在,若你直接用瀏覽器連到 http://<你的網站>.com/about,你會看到 …404 網頁:

在 SPA 中使用 HTML 5 history API 除了讓客戶端的程式碼去抓完整的路徑之外,你還需要告訴伺服器在回應每一次它無法識別的要求時,要回傳那一個 HTML 網頁,在 express 上,我們需要安裝一個套件。

1
npm install --save connect-history-api-fallback
app.js
1
2
3
4
5
...
const history = require("connect-history-api-fallback");
...
app.use(history());
...

但是因為我們想讓此 express 兼作 API 伺服器,這樣的設定將會把 express 的伺服器端路由全部攔截,因此必須加入一些條件,讓它跳過一些路由:

app.js
1
2
3
4
5
6
7
8
app.use(history({
rewrites: [
{
from: /^\/api\/.*$/,
to: (context) => context.parsedUrl.pathname
}
]
}));

這個設定可以讓它通過以 /api/* 開頭的所有路由,我們就可設定如下的認證 API。

1
app.use('/api/v1/users', usersRouter);

以下會是伺服器端完成後的專案目錄架構,客戶端的程式碼都放在 public 目錄下,public 目錄也是 express 伺服器的靜態檔案服務的根目錄:

伺服器端身份驗證 API

我們將建立 3 使用者服務 API,並對這些 API 實作權限控管:

  • /api/v1/users 需要有管理者權限,才可以取得所有使用使用者的資料。
  • /api/v1/users/:id 只有目前登入者有權限取得自己的資料。
  • /api/v1/authenticate 登入認證用,不須有權限的控管。

為了將焦點放在架構上,我們將使用者資料直接放在一個 JavaScript 陣列上,正式上線的系統應該將它移到資料庫上。

services/users.data.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = [
{
id: 1,
username: "admin",
password: "$2b$10$JSxCrnRLV1Ypc6P.thx29O4M1hePOyBX4V8hGn0yXOl2FRq5996oi", // admin
displayName: "Administrator",
roles: ["admin"]
},
{
id: 2,
username: "user",
password: "$2b$10$KRW4hJpiDt3BIusPY2bQNOrKh7QuxWT8m2YSt9Q9b.aixSPv.tCDG", // user
displayName: "User One",
roles: ["user"]
},
...
];
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
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const jwtSecretKey = "gJ3Lck9rb5yZ6T8i66dcsuFeoYJ6x";
const users = require("./users.data");

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

const user = users.find(
u => u.username === username && validPassword(password, u.password)
);

if (!user) {
reject(new Error("Username or password is incorrect"));
}

const token = jwt.sign(
{
id: user.id,
username: user.username,
roles: user.roles
},
jwtSecretKey,
{ expiresIn: "12h" }
);

resolve({
id: user.id,
displayName: user.displayName,
roles: user.roles,
token: token
});
});
};

exports.jwtVerify = (token) => {
if (!token) {
throw new Error("Authentication failed");
}
let payload;

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;
};

exports.allUsers = () => {
return new Promise((resolve, reject) => {
let rows = users.map(u => ({
id: u.id,
username: u.username,
displayName: u.displayName,
roles: u.roles
}));

resolve(rows);
});
};

exports.findById = (id) => {
return new Promise((resolve, reject) => {
if (!id) {
reject(new Error("User not found"));
}

const user = users.find(x => x.id === id);

if (!user) {
reject(new Error("User not found"));
}

resolve({
id: user.id,
username: user.username,
displayName: user.displayName,
roles: user.roles
});
});
};

exports.findRole = (role, roles) => {
if (!roles || roles.indexOf(role) == -1) {
return false;
}
return true;
};

exports.setPassword = password => {
return bcrypt.hashSync(password, 10);
};

const validPassword = (password, hash) => {
return bcrypt.compareSync(password, hash);
};

這裡使用兩個套件,bcryptjs 與 jsonwebtoken。bcryptjs 用來作密碼的加密與密碼的比對,jsonwebtoken 用來產生加密的 Token 與解密。

其中最重要的兩個函式,authenticate( ) 與 jwtVerify( )。authenticate( ) 用來作登入認證,在第 20 行,當認證成功,它會產生 token,token 會包含使用者的 id、username 與 roles。token 會加密,並下傳到客戶端,當客戶端要訪問伺服器端的 API 時,必須都要附帶此 token 作權限控管。這裡的 token 有效性設定為 12 小時,可視需要修改。

第 30 行是當認證成功時傳給客戶端的使用者物件,除了 token 還另外含有 id、displayName 與 roles,你應該有注意到,token 裡也含有 roles 的資訊,這要區分兩者有不同的用途,token 裡的 roles 是用來作伺服器端 API 的權限控管,另外下傳使用者物件中的 roles 則可提供 SPA 客戶端作基本的客戶端路由(或頁面)控管,你不能只控管客戶端的權限,伺服器端的 API 安全控管更加重要。

jwtVerify( ) 函式則是用來解開 token 加密的資料,客戶端在訪問伺服器端 API 時都必須附帶此 token,依 token 中的 id 與 roles 作權限的控管。

其它的服務 allUsers、findById 直接抓取陣列,雖然都是同步的,但我還是用 Promise 使用非同步的方式,方便以後改為用資料庫存儲使用者資料,也讓所有的服務都一致使用非同步存取,以免混淆。

Express 中間件 (Middleware)

接下來我們來看如何使用 express 的中間件 (Middleware)來保護資料。

如果沒有中間件,你必須在一個函式中處理一個請求 (Request) 的所有事情,使用中間件則可將一個複雜的處理函式模組化,不僅較好維護、較為彈性,可用性也較佳。

當 Express 接收到一個 Request 要求時,Request 物件將會經過一個中間件堆疊,然後透過 Response 物件回應給客戶端,堆疊中的每個中間件隨時有可能會結束這個流程直接回應給客戶端。所以我們可以在堆疊中插入我們的認證機制中間件,成功則繼續堆疊的運作,失敗就直接回應給客戶端,不繼續往下流程。這是一個強大的功能,也是 Express 運作的基石。

Authorize middleware

首先來處理權限控管中間件:

middlewares/authorize.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
const { jwtVerify, findRole } = require("../services/user.service");

exports.authorize = (role) => {
return [ validateToken(), isRole(role) ];
}

exports.isCurrentUser = () => {
return [ validateToken(), currentUser() ];
};

function validateToken() {
return (req, res, next) => {
let token;
let payload;

if (!req.headers.authorization) {
return res.status(401).json({ message: "You are not authorized" });
}

token = req.headers.authorization.split(" ")[1];

if (!token) {
return res.status(401).json({ message: "You are not authorized" });
}

try {
payload = jwtVerify(token);
req.currentUser = payload;
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ message: err.message });
} else {
return res.status(401).json({ message: err.message });
}
}
};
}

function isRole(role) {
return (req, res, next) => {
if (!req.currentUser) {
return res.status(401).json({ message: "You are not authorized" });
}

if (!role || findRole(role, req.currentUser.roles)) {
next();
} else {
res.status(401).json({ message: "You are not authorized" });
}
}
}

function currentUser() {
return (req, res, next) => {
if (!req.currentUser) {
return res.status(401).json({ message: "You are not authorized" });
}
let id = !isNaN(req.params.id) ? parseInt(req.params.id) : undefined;

if (id === req.currentUser.id || findRole("admin", req.currentUser.roles)) {
next();
} else {
res.status(401).json({ message: "You are not authorized" });
}
};
}

Express 中間件函式有一定的標準格式:

middleware standard
1
2
3
4
function myMiddleware(request, response, next) {
...
next();
}

中間件可接受 3 個參數,當你的處理函式處理完畢後,呼叫 next( ) 來呼叫堆疊中的下一個中間件。

  • 第 11 行 validateToken 中間件專門處理 token 的權限,包括每個 Request 有沒有附帶 token、token 是不是我們發出的合法 token 與 token 是否過期。
  • 第 16 行判斷 Request 物件的 headers 有沒有 authorization 屬性,客戶端的每個 Request headers 都必須將 token 附在這個屬性上。
  • 第 26 行如果是合法的 token,會將它解密,並將資料附加到 Request 物件上的屬性 currentUser 上,這是一個自建的新屬性,將會隨著 Request 物件傳給下個中間件,下遊的中間件都可從 Request 物件參考到這個自建的屬性。
  • 第 29 行呼叫 next( ) 函式,才會往下執行中間件堆疊的下一個中間件。
  • 第 31 行判斷 token 是否已過期。

isRole 與 currentUser 也都是標準的中間件函式格式,你需要甚麼權限控管就依此範例加上去。

最後則看我們 export 出去的中間件 authorize 與 isCurrentUser,它們返回的都是一個中間件陣列,它們是有順序的,可依需求組合你需要的權限控管,這太棒了。

Users service

可以開始建立實際的 Users services API 了,API 裡的函式實際上也是使用中間件的格式,但我們把這種中間件放在 controllers 目錄中,可以與其它有共用且有專们目的的中間件區分。

controllers/user.controller.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 { authenticate, allUsers, findById } = require("../services/user.service");

exports.allUsers = (req, res, next) => {
allUsers()
.then( users => res.status(200).json( { items: users }))
.catch(err => res.status(404).json({ message: err.message }));
};

exports.getUser = (req, res, next) => {
const id = !isNaN(req.params.id) ? parseInt(req.params.id) : undefined;

findById(id)
.then(user => res.status(200).json(user))
.catch(err => res.status(404).json({ message: err.message }));
};

exports.authenticate = (req, res, next) => {
const username = req.body.username;
const password = req.body.password;

authenticate(username, password)
.then(user => res.status(200).json(user))
.catch(err => res.status(401).json({ message: err.message }));
};

allUsers 與 getUser 將會使用在 HTTP GET 方法,authenticate 用於登入認證,會使用 HTTP POST 方法。
第 10 行用 req.params.id 可以取得路由參數,例如, /api/v1/users/:id。
第 18 行可以從 HTTP POST 的 Request 物件 body 屬性取得客戶端送來的資料。

Users 的路由設定現在看起來就很簡潔了。

routes/users.js
1
2
3
4
5
6
7
8
9
10
11
12
const express = require('express');
const router = express.Router();
const { allUsers, getUser, authenticate } = require('../controllers/user.controller');
const { authorize, isCurrentUser } = require('../middlewares/authorize');

router.get('/', authorize('admin'), allUsers);

router.get("/:id", isCurrentUser(), getUser);

router.post("/authenticate", authenticate);

module.exports = router;

這裡的路由就是一連串的中間件堆疊組合了,前面兩個路由 / 與 /:id 分別有用 authorize( ) 與 isCurrentUser( ) 保護,登入用的 /authenticate 則不需要權限控管。注意這裡的中間件是有順序性的。

現在當你拜訪 /api/v1/users API 時,必須要有管理者身份。拜訪 /api/v1/users/:id 則只能是登入者本身。

以下則是伺服器端最終 app.js 的程式碼,這裡我多加了一個 cors 套件,雖然在此範例不需要,但如果是一般的 REST API 伺服器都會需要。

重新啟動伺服器,你的 SPA 伺服器就 OK 了,Go!

app.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
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');
const cors = require('cors');
const history = require("connect-history-api-fallback");

const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');

const app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

app.use(logger('dev'));
app.use(express.json());
app.use(history({
rewrites: [
{
from: /^\/api\/.*$/,
to: (context) => context.parsedUrl.pathname
}
]
}));
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(cors());

app.use('/', indexRouter);
app.use('/api/v1/users', usersRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

// render the error page
res.status(err.status || 500);
res.render('error');
});

module.exports = app;

客戶端 Vue SPA

客戶端的資源都放在伺服器的 public 目錄下,如果你用的是 Vue Cli 打包後的目錄 dist,可將整個 dist 目錄複製到伺服器端,然後取代掉 public 目錄下的所有資源。你可以有另外一種選擇,就是將 express 的靜態檔案服務的根目錄改為 dist 目錄,取代原先的 public 目錄。

以下會是客戶端完成後的目錄架構:

大部分的程式碼都在 public/javascripts 目錄下,components 與 views 目錄放的是 Vue 組件,services 目錄則是與後端 REST API 的連結,helpers 目錄擺放一些不好歸屬但共用的一些模組,Vue 的一些起始程式碼,index.html、main.js、router.js 與 App.vue.js 根組件直接就放在 public 目錄下。如果你使用 Vue Cli 則模組的寫法與 import 的方式會不一樣,請自行修改。

客戶端的 REST API Services

首先從 helpers.js 程式碼開始:

public/javascripts/helpers/helpers.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
const _helpers = (() => {
const config = {
apiUrl: `http://${location.host}/api/v1`
};

function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if ([401, 403].indexOf(response.status) !== -1) {
authenticationService.logout();
//location.reload(true);
}
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
});
}

const requestOptions = {
get() {
return {
method: "GET",
...headers()
};
},
post(body) {
return {
method: "POST",
...headers(),
body: JSON.stringify(body)
};
},
patch(body) {
return {
method: "PATCH",
...headers(),
body: JSON.stringify(body)
};
},
put(body) {
return {
method: "PUT",
...headers(),
body: JSON.stringify(body)
};
},
delete() {
return {
method: "DELETE",
...headers()
};
}
};

function headers() {
const currentUser = authenticationService.currentUserValue || {};
const authHeader = currentUser.token
? { Authorization: "Bearer " + currentUser.token }
: {};
return {
headers: {
...authHeader,
"Content-Type": "application/json"
}
};
}

return {
config,
requestOptions,
handleResponse,
};
})();

_helpers 模組擺放的是一些使用 REST API 服務時會用到的共用程式碼,把它包裝起來讓連結 REST API 時方便一點。

handleResponse( ) 函式處理從 API 返回的資料,第 11 行有個 authenticationService 物件,這稍後我們會討論到。authenticationService 負責登入、登出及登入者狀態的維護,是這個範例的核心。這裡當 API 認證失敗時(Response 返回狀態是 401 或 403)會呼叫 authenticationService.logout( ),強迫使用者登出,這意味著,任何從伺服器端 API 只要返回 401 或 403 狀態,都會強迫客戶端登出,這包括不合法的 API 攫取。稍後我們會看到 logout( ) 函式作了些甚麼。

requestOptions 物件則會依據 HTTP 方法設定 HTTP Request 所需的一些可選的引數 (Arguments),每個方法都會再呼叫 headers() 函式。

headers( ) 函式每次都會從 authenticationService.currentUserValue 取得目前的登入者(如果尚未登入則會是 null 值),取得他們的 token,將這個 token 附在每個 HTTP Request 的 HTTP Headers 上,伺服器端才能依這個 token 給予授權的資料。這就是基本的 SPA 與 API 的認證、授權機制。

Authentication Service

authenticationService 是這個範例的重點,大部分的 Vue SPA 都會使用 Vuex 來實作認證機制與認證使用者的狀態管理,但在這個範例我要借用 Angular 的認證及狀態管理機制。使用的則是 RxJS 程式庫。

RxJS 是一個使用可觀察序列 (Observable sequences)組成異步和基於事件的程式庫。它是所有 SPA 架構的核心,不熟悉沒關係,這裡只會用到它其中的一項功能 BehaviorSubject,它很適合用來管理使用者登入狀態。

BehaviorSubject 是一種觀察者模式 (Observer Pattern),基本的觀察者模式定義是:

定義物件(稱為主體 subject)於狀態改變時,能夠通知多個觀察者(或稱監聽者)

但 BehaviorSubject 除了在狀態改變時可以發送與監聽信息之外,它還能夠存儲當下的狀態,而不是單純的只是事件發送,也就是說如果有一個新的訂閱,我們希望主體 (Subject) 能立即給出最新狀態,而不是要等到下次的狀態改變。也因為它有存儲目前的狀態,所以也可直接取得它當下的狀態,而無須透過訂閱。

public/javascripts/services/authentication.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
const authenticationService = (() => {
const { BehaviorSubject } = rxjs;
const { config, requestOptions, handleResponse } = _helpers;

const currentUserSubject = new BehaviorSubject(
JSON.parse(localStorage.getItem("currentUser"))
);

function login(username, password) {
return fetch(`${config.apiUrl}/users/authenticate`,
requestOptions.post({ username, password })
).then(handleResponse)
.then(user => {
if (user.roles && Array.isArray(user.roles)) {
user.isAdmin = user.roles.indexOf("admin") !== -1 ? true : false;
}
localStorage.setItem("currentUser", JSON.stringify(user));
currentUserSubject.next(user);

return user;
});
}

function logout() {
localStorage.removeItem("currentUser");
currentUserSubject.next(null);
}

return {
login,
logout,
currentUser: currentUserSubject.asObservable(),
get currentUserValue() { return currentUserSubject.value; }
};
})();

因為 BehaviorSubject 會存儲當下的狀態,所已使用 BehaviorSubject 建構式創建一個 currentUserSubject 實例時,必須要給它一個初始值,這裡會從 localStorage 讀取目前登入者的資料,如果沒有登入者則會是 null 值。

login( ) 函式會到伺服器端 API 執行認證,如果認證失敗,先前的 handleResponse( ) 函式會攔截,並呼叫這裡的 logout( ) 函式 ( authenticationService.logout( ) ); 如果成功,則會將資料存儲到 localStorage,並呼叫 currentUserSubject.next(user) 發送( emit )使用者資料,這個使用者資料也就是 currentUserSubject 實例目前的狀態,可以使用 currentUserSubject.value 取得目前的狀態。另外則用 currentUserSubject.asObservable( ) 可以訂閱信息。

logout( ) 函式則很簡單,清除 localStorage currentUser 的資料,並發射一個 null 值。

最後將這些功能包裝成一個比較簡潔的物件,注意 currentUserValue 屬性是惟讀的。這就是這個 authenticationService 的 API。 authenticationService.currentUser 可以訂閱信息,authenticationService.currentUserValue 可以取得當下的狀態。

在 Vue 網頁還沒完成前,我們可以在瀏覽器 console 測試。但要記得將相關 JavaScript 檔案加入 index.html。

你也可同時觀察 localStorage 的變化。你也可以訂閱它:

當你開始訂閱時,它馬上會返回當前的狀態; 當我們呼叫 logout( ) 函式,它也馬上有了回應。稍後我們就可看到如何應用在 Vue 中。

User Service

使用者的 Service API 則很單純,這不用多解釋:

public/javascripts/services/user.service.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const userService = (() => {
const { config, requestOptions, handleResponse } = _helpers;

function getAll() {
return fetch(`${config.apiUrl}/users`, requestOptions.get()).then(
handleResponse
);
}

function getById(id) {
return fetch(`${config.apiUrl}/users/${id}`, requestOptions.get()).then(
handleResponse
);
}

return {
getAll,
getById
}
})();

Vue Router Navigation Guards 客戶端路由保護

現在先來看看如何在 Vue Router 中加入客戶端路由保護。

Vue Router 提供了幾個路由防護函式可以讓你在路由器上加上防護(gurad),有些是全域性的、有些是可以加在單獨路由上、有些則可以加在在組件上的。這裡我們用一個全域性的防護 router.beforeEach( ) 函式。

public/router.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 router = new VueRouter({
mode: "history",
routes: [
{
path: "/",
name: "home",
component: HomePage
},
{
path: "/account",
name: "account",
component: AccountPage,
meta: {
requiresAuth: true
}
},
{
path: "/user",
name: "user",
component: UserPage,
meta: {
requiresAuth: true
}
},
{
path: "/login",
name: "login",
component: LoginPage
},
{
path: "/about",
name: "about",
component: AboutPage
},
{
path: "*",
component: PageNotFound
}
]
});

router.beforeEach((to, from, next) => {
const currentUser = authenticationService.currentUserValue;
const requiresAuth = to.matched.some((record) => {
return record.meta.requiresAuth;
});

if (requiresAuth && !currentUser) {
next({ path: "/login", query: { returnUrl: to.path } });
} else {
next();
}
});
  • 第 13、21 行我們在要受保護的路由上加入一個 meta 物件,你可以在 meta 物件上加入任何的資料。這裡我們只設定一個 requiresAuth 屬性,值是 true 時表示這個路由需要使用者登入。沒有設定 meta 物件或 false 則無須登入就可以拜訪此路由。

  • 第 42 行 router.beforeEach( ) 是全域性的路由防護函式,authenticationService.currentUserValue 可以取得目前的登入者。這裡不需要使用 authenticationService.currentUser 訂閱的方式,訂閱未來的狀態變化,只需要取得目前的狀態。

  • 第 44 行,檢查每個路由 meta 物件的 requiresAuth 屬性,看起來較複雜,主要考慮到如果使用巢狀路由,to.mata 所代表的是子組件,若在 /account 上加進 meta 物件,則使用者存取 /account/email 時,受檢查的 meta 物件所代表的是子物件,而不是父物件。要解決這個問題,可以在 to.matched 上進行迭代,其中也會包含父路由。

  • 第 49 行,如果路由需要防護,但尚未登入,則將路由導向 /login 頁面,returnUrl 則可以讓認證成功後,重新導航回原頁面。

App 組件

現在來看 App 組件的設定。

public/App.vue.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
const App = {
name: "App",
components: {},
template: `
<v-app>
<v-app-bar :clipped-left="$vuetify.breakpoint.lgAndUp" app >
<v-toolbar-title class="headline ml-0 pl-4" >
<v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<span class="hidden-sm-and-down">Uni-President</span>
</v-toolbar-title>
<div class="flex-grow-1"></div>
<v-toolbar-items>
<v-btn color="indigo" tile text to="/">
<v-icon>mdi-home</v-icon>
<span class="mr-2 green--text text--accent-4 hidden-sm-and-down">Home</span>
</v-btn>
<v-btn color="amber" tile text to="/about">
<v-icon>mdi-information</v-icon>
<span class="mr-2 hidden-sm-and-down">About</span>
</v-btn>
<v-menu bottom left>
<template v-slot:activator="{ on }">
<v-btn icon color="teal" v-on="on">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="item in items" :key="item.title" link :to="item.to">
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar-items>
</v-app-bar>
<v-content>
<v-container>
<router-view />
</v-container>
</v-content>
<v-footer app>
<small class="grey--text">&copy; 2019</small>
</v-footer>
<v-navigation-drawer v-model="drawer" :clipped="$vuetify.breakpoint.lgAndUp" app>
<v-list-item>
<v-list-item-avatar>
<v-img src="images/pec_logo.png"></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title class="red--text text--darken-2">
Uni-President
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
<v-list dense>
<v-list-item v-for="item in selectedItems" :key="item.title" link :to="item.to">
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider class="my-3"></v-divider>
<v-list-item v-if="!currentUser" link to="/login">
<v-list-item-icon>
<v-icon>mdi-login</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Login</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item v-else @click="logout">
<v-list-item-icon>
<v-icon>mdi-logout</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Logout <span class="blue--text text--darken-2">({{ currentUser && currentUser.displayName }})</span></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
</v-app>`,

data: () => ({
currentUser: null,
drawer: null,
items: [
{ title: "Home", icon: "mdi-view-dashboard", to: "/" },
{ title: "About", icon: "mdi-forum", to: "/about" },
{ title: "Account", icon: "mdi-account-card-details", to: "/account" },
{ title: "Not Exists", icon: "mdi-forum", to: "/not/exists" }
],
adminItems: [
{ title: "User", icon: "mdi-account-card-details", to: "/user" }
]
}),
computed: {
selectedItems() {
if (!this.currentUser || !this.currentUser.isAdmin) {
return this.items;
}
return [...this.items, ...this.adminItems];
}
},
methods: {
logout() {
authenticationService.logout();
router.push("/login");
}
},
created() {
authenticationService.currentUser.subscribe(x => (this.currentUser = x));
}
};
  • 第 117 行當組件創建時,使用身份驗證服務中的 currentUser observable 訂閱信息,這可以馬上取得目前的狀態,也可以訂閱未來的狀態變化。這裡不能使用身份驗證服務中的 currentUserValue,因為 App 組件是我們的根組件,它只會創建一次,直到流覽器關掉這個 App。如果只在創建時取得現值,將無法偵測到身份驗證服務的狀態變化。基本上,當我們不使用訂閱時,應該取消它,但這裡不用擔心取消訂閱的問題,因為這個組件是應用程序的根組件,該組件被銷毀的唯一時間是 App 關閉時,這也會銷毀任何的訂閱。

  • logout( ) 方法,則會呼叫身份驗證服務中的 logout( ),並重新導向登錄頁面。

  • 第 104 行的 selectedItems ,會判斷如果登入的使用者有管理者身份,會合併導航抽屜一般使用者與管理者的顯示項目。所以只有管理者身份者才會有 /user 的路由連結。

  • 第 70、78 行有 v-if 與 v-else 可以切換不同的 Login 與 Logout 功能。

LoginPage 組件

public/javascripts/views/LoginPage.vue.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
const LoginPage = {
name: "LoginPage",
template: `
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sx="12" sm="8" md="4">
<v-card class="elevation-12">
<v-toolbar flat >
<v-img src="images/pec_logo.png" max-width="45"></v-img>
<v-toolbar-title class="red--text text--darken-2 ml-5">
Uni-President
</v-toolbar-title>
</v-toolbar>
<v-card-text>
<v-form v-model="valid">
<v-text-field
v-model="username"
:rules="[rules.required, rules.min]"
label="Username"
name="username"
prepend-icon="mdi-account"
hint="Username is required"
></v-text-field>
<v-text-field
v-model="password"
prepend-icon="mdi-lock"
:append-icon="show ? 'mdi-eye' : 'mdi-eye-off'"
:rules="[rules.required, rules.min]"
:type="show ? 'text' : 'password'"
name="password"
label="Password"
hint="Password is required"
@click:append="show = !show"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<div class="flex-grow-1"></div>
<v-btn :disabled="!valid || loading"
color="blue" block large
@click="handleSubmit"
>Login</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" sx="12" sm="12" md="12">
<v-alert v-if="error" type="error"> {{ error }} </v-alert>
</v-col>
</v-row>
</v-container>
`,
props: {
source: String
},
data: () => ({
valid: true,
show: false,
username: "",
password: "",
rules: {
required: value => !!value || "Required.",
min: v => v.length >= 4 || "Min 4 characters"
},
loading: false,
returnUrl: "",
error: ""
}),
created() {
if (authenticationService.currentUserValue) {
return router.push("/");
}

this.returnUrl = this.$route.query.returnUrl || "/";
},
methods: {
handleSubmit(e) {
this.loading = true;
authenticationService.login(this.username, this.password).then(
user => router.push(this.returnUrl),
error => {
this.error = error;
this.loading = false;
}
);
}
}
};

HTML 部分主要的就是 Vuetify v-form 組件建立的,需要注意的就是它的驗正規則也可以是陣列堆疊式的。

  • 第 69 行當組件創建時直接抓取身份驗證服務的 currentUserValue 當下的狀態。如果已經登入了,就直接將頁面導向 Home Page。

  • 第 73 行如果是由別的受防護的路由轉過來的,則可以在驗證成功後依據 this.$route.query.returnUrl,重新導回那個路由。

  • 第 76 行 handleSubmit( ) 呼叫身份驗證服務的 login( ) ,認證成功則將頁面導向原頁面或 Home Page。

UserPage 組件

這個組件只有登入的使用者有管理者身份才會顯示在 App 組件的導航抽屜(navigation-drawer),但是我們在路由的防護設定只有針對有沒有登入作防護,並沒有針對何種身分作防護,所以如果你已經登入,不管是甚麼身份,仍然可以直接從流覽器上輸入網址 http://localhost:3000/user 重載拜訪這個組件,所以我們也應該在此組件上再多一層防護。

另外一種選擇則可以在路由器上加上身份的防護,例如,在 meta 物件加上防護資訊:

public/router.js
1
2
3
4
5
6
7
8
{
path: "/user",
name: "user",
component: UserPage,
meta: {
requiresRole: 'admin'
}
}

你可以在身份驗證服務的 currentUserValue 取到相關角色 (roles) 的資訊,然後修改 router.beforeEach( ) 路由防護規則。

這個組件有一個子組件 ListUsers.vue.js 組件,只有有管理者身份者才能看到所有使用者的資料。

public/javascripts/components/ListUsers.vue.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
Vue.component("ListUsers", {
template: `
<div>
<div if="currentUser && currentUser.isAdmin">
<v-card class="mx-auto" max-width="344" tile >
<v-list dense>
<v-subheader class="title teal--text text--darken-1">Users</v-subheader>
<em v-if="users.loading">Loading users...</em>
<span v-if="users.error" class="red--text">ERROR: {{ users.error }}</span>
<v-list-item-group color="primary">
<v-list-item v-for="item in users.items" :key="item.displayName" >
<v-list-item-icon>
<v-icon color="blue" v-text="'mdi-account'"></v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title v-text="item.displayName"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card>
</div>
</div>`,

data: () => ({
currentUser: authenticationService.currentUserValue,
users: [],
}),
created() {
if (this.currentUser && this.currentUser.isAdmin) {
userService.getAll().then(users => this.users = users);
}
}
});

除了第 4 行對 HTML 保護外,也在第 30 行對資料作了保護。這裡要提醒一下,看看我在 if 中的測試規則 if ( this.currentUser && this.currentUser.isAdmin ) {…} 要對一個物件的屬性作測試,先要測試這個物件是否存在,否則你的臭蟲會抓不完。

以下則是 UserPage 父組件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const UserPage = {
name: "UserPage",
template: `
<div>
<v-row>
<v-col>
<v-card max-width="344" class="mx-auto">
<v-card-title>I'm {{ currentUser && currentUser.displayName }}</v-card-title>
<v-card-text>Some text</v-card-text>
</v-card>
</v-col>
<v-col v-if="currentUser && currentUser.isAdmin">
<ListUsers />
</v-col>
</v-row>
</div>`,
data: () => ({
currentUser: authenticationService.currentUserValue
})
};

這個組件中有兩個 v-col 元素,一個有身份防護,一個沒有。但是你應該會發覺,在客戶端的這些防護其實都是很脆弱的,客戶端很容易就可被竄改,所以伺服器端的資料防護才是重要。在我們的範例,即使你竄改了客戶端的身份,你仍然無法取得伺服器端 API 的資料,伺服器端的資料保護就依賴在我們的 Token,所以要好好保護你的 Token,至少不要發出一個沒有期限的 Token。資料安全是一個大主題,目前的範例只是一個基本機制。

AccountPage 組件與 UserPage 組件一模一樣。只是在展示不同的身份會有不同的內容。

最後的 index.html 則如下:

public/index.html
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
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link href="stylesheets/@mdi/font/css/materialdesignicons.min.css" rel="stylesheet" />
<link href="stylesheets/vuetify.min.css" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="app" v-cloak></div>

<script src="libs/vue.js"></script>
<script src="libs/vuetify.js"></script>
<script src="libs/vue-router.js"></script>
<script src="libs/rxjs.umd.min.js"></script>
<script src="javascripts/views/HomePage.vue.js"></script>
<script src="javascripts/views/LoginPage.vue.js"></script>
<script src="javascripts/views/AboutPage.vue.js"></script>
<script src="javascripts/components/ListUsers.vue.js"></script>
<script src="javascripts/views/AccountPage.vue.js"></script>
<script src="javascripts/views/UserPage.vue.js"></script>
<script src="javascripts/views/PageNotFound.vue.js"></script>
<script src="javascripts/helpers/helpers.js"></script>
<script src="javascripts/services/authentication.service.js"></script>
<script src="javascripts/services/user.service.js"></script>
<script src="App.vue.js"></script>
<script src="router.js"></script>
<script src="main.js"></script>
</body>
</html>

祝 身體健康、快樂!

活在使用 Oracle APEX 開發環境實在幸福,只要會 SQL 與 PL/SQL 就可以搞定一個 Web Application。我們可以不用了解開發一個 Web Application 的架構與技術,也不用管認證,如果可以,我寧願留在 APEX 中,不用管資料的一致性、資料的交易、伺服器端的管理、Message Broker 等等。

但環境的變化,往往逼迫我們要去適應一些新的應用,走出 APEX,要了解與學習就非常非常的多了。首先,要決定使用何種語言就是一種挑戰。 選擇了語言,要使用何種開發架構,又是一番的掙扎。如今 SPA 架構是主流,也是走向 PWA 的必要過程。SPA 目前有 3 大主流架構,React、Angular 與 Vue,雖然 Angular 是我的最愛,因為它使用 TypeScript 可以彌補 JavaScript 弱型態的缺點,但門檻比較高,所以我們就從 Vue.js 開始。

在這個範例中我們會從 Server 端開始,然後使用 Vue.js 開發前端。這個範例沒有使用 Vue.js 的開發工具 Vue Cli,也沒有使用 Webpack 打包工具及 Webpack Dev Server,主要的目的是讓大家了解整個 Web Application 所需要的架構及運作方式。真正的開發還是要使用 Vue CLI 會方便一些。

原始碼

這個範例可以當成 Web Application 的起始架構,程式碼可以使用 git 或複製壓縮檔取得:

  • 如果你有安裝 git,可以用 git clone 取得程式碼,我的 Z: 虛擬硬碟是指向 XXX111。
1
2
3
4
5
6
7
git clone Z:\DBA\Git\Depot\vue-demo-sample.git

cd vue-demo-sample

npm install

npm start
  • 如果沒有安裝 git,可從 XXX111:\DBA\Oracle Training\Xyang\Source\vue-demo-sample.zip 取得壓縮檔,解壓縮後:
1
2
3
npm install

npm start

如果要用在正試環境時,記得將 package.json 的屬性 start 改為用 Node.js 啟動:

package.json
1
2
3
"scripts": {
"start": "node ./bin/www"
},

實作細節

以下則是詳細的過程及說明。

當使用者透過流覽器上的 URL 開啟 Web Application,總是要有一個伺服器讓它可以連結,我們就從建立一個 Web Server 開始,雖然公司決定使用 .NET 架構,但這理我還是使用 Node.js Express,這可以讓我們前、後端都使用 JavaScript,可以不用在語言中切換。使用 .NET 架構將不會影響前端的 Vue.js 程式碼。

伺服器端 Node.js Express

如果已經上過 Node.js 課程,應該已經有裝好 Express,首先切換到你要建立專案的目錄,我們需要建立一個 Express 專案,這裡的專案名稱是 vue-demo-sample。

1
2
3
express vue-demo-sample --view ejs --git
cd vue-demo-sample
npm install

這樣我們的 Web Server 就可以啟動了,使用 VSCode 直接開啟 vue-demo-sample 目錄,然後開啟一個終端視窗,啟動 Web Server。

1
npm start

從流覽器開啟 http://localhost:3000/ 你就可以看到 Express Server 的起始畫面,這個 Web Server 就將是我們 Web Application 的伺服器,我們可以開始使用 Vue.js 開發前端程式了。

開始 Vue.js 之前我們先來了解一下伺服器端的架構與運作方式。

public 目錄是 Express 靜態檔案服務的根目錄,所有需要下載到流覽器前端的程式碼,都會放在這個目錄下,當你從流覽器開啟 http://localhost:3000/ 時,它會對應到 Express Server 端的這個 public 目錄,然後尋找 index.html 並下載到流覽器渲染成我們所看到的畫面。在 SPA 架構中,這幾乎是我們會從伺服器下載的唯一一個 HTML 檔案,我們也將從這裡開始 Vue.js 的旅程。

但在 Express 的 public 目錄中並沒有發現 index.html,這是因為 Express 使用伺服器端的模版(這理使用 EJS,EJS 是一種簡單的模板語言,可讓您使用純 JavaScript 生成 HTML 標記)動態產生一個 index.html,你可以在 views 目錄中發現這個模版 index.ejs

views/index.ejs
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
</body>
</html>

這是傳統 Web Application 使用的方式,我們稱為伺服器端渲染,動態的在伺服器端使用模版將資料與畫面綁成一個 HTML 檔案然後下載到流覽器,PHP、JSP、ASP 等等都是使用這種架構,缺點很明顯,當前端瀏覽器畫面有任何的異動都必須經過伺服器端從新渲染,然後下載。

AJAX 的出現,改變了此種生態,它可以動態的在前端只更動畫面的某區塊,而不用重新渲染整個頁面,這就是 SPA 架構的基礎。所以如果使用 SPA 架構,我們只需要從伺服器端下載一個起始的 HTML 檔案。所以我們可以從 index.ejs 開始,但我通常不會去動此檔案。所以就在 public 目錄下建立一個 index.html 檔案,將 index.ejs 內容複製到 index.html 中,並將動態渲染的部分改成靜態內容。

public/index.html
1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
<head>
<title>Demo Sample</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1>Demo Sample</h1>
<p>Welcome to Tainan! 就從這裡開始!</p>
</body>
</html>

修改了 index.html 後需要從新啟動 Express Server,從流覽器中我們就可以看到結果了,這是因為 Express 在 public 目錄下發現有 index.html 靜態檔案,就直接下載執行,並停止原先往下執行 index.ejs 的動態渲染,現在可以從 index.html 開始 Vue.js 的開發了。

開始之前我們要修改一下,此專案目錄下的啟動設定,我們用 nodemon 幫我們監控,當我們有更動檔案時幫我們從新啟動 Express,免得每次都要手動,但記得上線時將此設定回覆。

開啟 package.json 將 node 改為 nodemon。

package.json
1
2
3
4
5
...
"scripts": {
"start": "nodemon ./bin/www"
},
...

從新啟動後,你將會看到 nodemon 會開始監看你的專案目錄,當有任何異動就會重新啟動 Express Server。 但是 nodemon 沒有辦法幫你前端的瀏覽器重新刷新,這還是得手動重新刷新流覽器。Vue CLI 則可以幫你自動刷新流覽器,可讓我們有比較好的開發體驗。

前端開發

Vue.js 與 Vuetify.js

我們要從 public/index.html 檔案開始,先讓我們快速看到一些樣子,既然要使用 Vuetify.js,就讓它有 Vuetify.js 的樣子。前往 Vuetify 網站,它有個 index.html 的範例,將它複製到我們的 index.html。

public/index.html
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
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@3.x/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
</head>
<body>
<div id="app">
<v-app>
<v-content>
<v-container>Hello world</v-container>
</v-content>
</v-app>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script>
new Vue({
el: '#app',
vuetify: new Vuetify(),
})
</script>
</body>
</html>

重新刷新你的瀏覽器網頁,你將會看到結果。

我們來更改一些程式碼,看看是不是有正常運作。加入 Vue 實例屬性 data:

public/index.html
1
2
3
4
5
6
7
8
9
<script>
new Vue({
el: '#app',
vuetify: new Vuetify(),
data: {
message: "Hello Tainan! 就從這裡開始!"
}
})
</script>

然後修改 html 標籤 <v-container> 讓它作單向資料綁定,同時加入一個 Vuetify 的 class “display-1”。

public/index.html
1
<v-container class="display-1">{{ message }}</v-container>

刷新網頁,將會看到如下畫面,表示 Vue.js 與 Vuetify.js 都正常運作了。

Vue-router

一般傳統的 Web App 如果要改變頁面顯示的內容,都是透過更改流覽器的 URL 來達成。改變流覽器的 URL,流覽器會送出一個 Request 到伺服器端要求重新送回一個樣板動態渲染的 HTML 內容,這就是 Server-side Routing。在 SPA 改變頁面顯示內容,也都是透過更改 URL 來達成,但必須防止流覽器將此 Request 送到伺服器端,替代的執行我們所要求的行為,這就有賴 Vue Router 程式庫來幫我們達成,這就是所謂的前端路由 Client-side Routing。

前網 Vue Router 網站看看如何開始。

public/index.html
1
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>

從 CDN 將 vue-router.js 下載,並將以下的 HTML 加到 index.html 原先的 message 下面:

public/index.html
1
2
3
4
5
<p>
<router-link to="/">Go to Home</router-link>
<router-link to="/about">Go to About</router-link>
</p>
<router-view></router-view>

再來須要更改 javascript,將 router 加入運作。

public/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
const HomePage = { template: '<div>This is Home Page</div>' };
const AboutPage = { template: '<div>This is About Page</div>' };

const routes = [
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage }
];

const router = new VueRouter({
routes
});

new Vue({
el: "#app",
vuetify: new Vuetify(),
router,
data: {
message: "Hello Tainan! 就從這裡開始!"
}
});
</script>

HomePage 與 AboutPage 是兩個簡單的 Vue.js 組件,這裡也將 router 加入 Vue 實例中。刷新流覽器頁面,你將可以看到兩個連結,點擊這些連結,看看頁面的內容會不會跟著變動,也順便觀察一下流覽器 URL 的變動。

到這裡你會發覺,隨著系統的功能增加, index.html 會越來越複雜,總不能把所有程式碼都放在一個檔案裡吧,我們需要把它拆開,就是所謂的模組化。模組化首先想到得就是怎麼按種類區分到不同的目錄下,所以就從 Express Server 的 public 目錄開始規劃,以下會是我的目錄結構。

現在就要來開始拆解 index.html,首先要將 index.html 中的 script 獨立出來,依功能分類放在不同的目錄與檔案中。

首先是 Vue.js 的組件,通常會把組件再依其功能分為顯示頁面的組件及功能性的組件,顯示頁面組件會放在 public/javascripts/views 目錄中; 功能性組件則會放在 public/javascripts/components 目錄:

public/javascripts/views/HomePage.vue.js
1
2
3
4
5
6
7
const HomePage = {
name: "HomePage",
template: `
<div>
<h1 class="display-1">This is Home Page</h1>
</div>`
};
public/javascripts/views/AboutPage.vue.js
1
2
3
4
5
6
7
const AboutPage = {
name: "AboutPage",
template: `
<div>
<h1 class="display-1">This is About Page</h1>
</div>`
};

新增一個 PageNotFound 組件:

public/javascripts/views/PageNotFound.vue.js
1
2
3
4
5
6
7
const PageNotFound = {
name: "PageNotFound",
template: `
<div>
<h1>404 Page Not Found</h1>
</div>`
};

最好也將 Vue Router 獨立在一個 JavaScript 檔案中,直接放在 public 目錄下,將 router mode 改為使用 HTML5 history API :

public/router.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const router = new VueRouter({
mode: "history",
routes: [
{
path: "/",
name: "home",
component: HomePage
},
{
path: "/about",
name: "about",
component: AboutPage
},
{
path: "*",
component: PageNotFound
}
]
});

將 Vue.js Application 的入口點獨立放在 public/main.js,這裡的 data 屬性將來也不再需要。

public/main.js
1
2
3
4
5
6
7
8
new Vue({
el: "#app",
vuetify: new Vuetify(),
router,
data: {
message: "Hello Tainan! 就從這裡開始!"
}
});

更改 index.html 的 <script> 部分,記得去除原來寫在這裡的程式碼,現在都已拆散在各獨立的程式檔中。你應該也能想像的到,如果不用 Vue Cli 開發工具來幫我們打包這些程式碼,index.html 將需要 import 多少獨立的 JavaScript 檔,而且必須注意它 import 的順序。

index.html
1
2
3
4
5
6
7
8
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<script src="javascripts/views/HomePage.vue.js"></script>
<script src="javascripts/views/AboutPage.vue.js"></script>
<script src="javascripts/views/PageNotFound.vue.js"></script>
<script src="router.js"></script>
<script src="main.js"></script>

這裡的順序很重要,通常 main.js 會擺在最後面,子組件要擺在父組件之前。

同時修改一下 CSS,防止在渲染畫面時顯示未渲染前的畫面,例如, { { message } }。將此 CSS 放在 HTML head 的最後面。

public/index.html
1
2
3
4
5
<style>
[v-cloak] {
display: none;
}
</style>
public/index.html
1
<div id="app" v-cloak>

在此行加上 v-cloak。 刷新流覽器頁面看看是否正常。並觀察一下流覽器上 URL 的變化與之前有甚麼不同。現在使用的是 HTML5 history API,先前則是使用 hash mode。

再下來要修改從 CDN 下載檔案的方式,改為從自己的 Express Server 下載。首先當然得先下載這些檔案到我們的 Express Server,將它存在本地端上,我們可用 npm 下載。npm 除了用在管理 Node.js 端的組件外,現在也有很多的前端組件也可以用 npm 來下載安裝,我們就使用 npm install。

1
2
3
4
npm install vue --save-dev
npm install vuetify --save-dev
npm install @mdi/font --save-dev
npm install vue-router --save-dev

其中 @mdi/font 是 Meterial Design Icons,我們也會用到。

順便我們也將 Vuex 也先安裝起來。

1
npm install vuex --save-dev

Vuex 是 Vue.js 應用程序的狀態管理模式庫。它可集中存儲應用程序中所有組件的共享資源,並確保狀態只能以可預測的方式進行變更。這我們目前尚未用到。

npm 會將這些模組安裝在 node_modules 目錄下,這是伺服器端的目錄架構,我們不希望將此目錄暴露在網路下,所以我們只要將幾個需要的檔案複製到 public 目錄下。從 CSS 開始

  • 複製 node_modules/@mdi 整個目錄到 public/stylesheets 目錄下。
  • 複製 node_modules/vuetify/dist/vuetify.min.css 檔案到 public/stylesheets 目錄下。

接下來是 JavaScript

  • 複製 node_modules/vue/dist/vue.js 到 public/libs 目錄下。
  • 複製 node_modules/vuetify/dist/vuetify.js 到 public/libs 目錄下。
  • 複製 node_modules/vue-router/dist/vue-router.js 到 public/libs 目錄下。

接下來修改 index.html

public/index.html
1
2
<link href="stylesheets/@mdi/font/css/materialdesignicons.min.css" rel="stylesheet" />
<link href="stylesheets/vuetify.min.css" rel="stylesheet" />
public/index.html
1
2
3
<script src="libs/vue.js"></script>
<script src="libs/vuetify.js"></script>
<script src="libs/vue-router.js"></script>

測試一下看看是否正常,隨便找一個組件加入 icon 看看是否也正常運作

public/javascripts/views/HomePage.vue.js
1
2
3
4
5
6
7
8
const HomePage = {
name: "HomePage",
template: `
<div>
<h1 class="display-1">This is Home Page</h1>
<v-icon large color="red">mdi-home</v-icon>
</div>`
};

現在看起來就像

看起來很醜,但基本架構都已成型,就來裝扮一下 Application 的外表,我們可以到 Vuetify 中找一個預製的佈局,我使用的是 Google Contacts,將整個內容複製到一個新的檔案 public/App.vue.js,然後修剪一下內容。這將會是我們 Application 的外殼,不管網頁如何變化,這個外殼都會存在畫面上,例如網頁的導覽。這個 App.vue.js 就直接放在 public 目錄下。

public/App.vue.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
const App = {
name: "App",
components: {},
template: `
<v-app>
<v-app-bar :clipped-left="$vuetify.breakpoint.lgAndUp" app >
<v-toolbar-title class="headline ml-0 pl-4" >
<v-app-bar-nav-icon @click.stop="drawer = !drawer"></v-app-bar-nav-icon>
<span class="hidden-sm-and-down">Uni-President</span>
</v-toolbar-title>
<div class="flex-grow-1"></div>
<v-toolbar-items>
<v-btn color="indigo" tile text to="/">
<v-icon>mdi-home</v-icon>
<span class="mr-2 green--text text--accent-4 hidden-sm-and-down">Home</span>
</v-btn>
<v-btn color="amber" tile text to="/about">
<v-icon>mdi-information</v-icon>
<span class="mr-2 hidden-sm-and-down">About</span>
</v-btn>
<v-menu bottom left>
<template v-slot:activator="{ on }">
<v-btn icon color="teal" v-on="on">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="item in items" :key="item.title" link :to="item.to">
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar-items>
</v-app-bar>
<v-content>
<v-container>
<router-view />
</v-container>
</v-content>
<v-footer app>
<small class="grey--text">&copy; 2019</small>
</v-footer>
<v-navigation-drawer v-model="drawer" :clipped="$vuetify.breakpoint.lgAndUp" app>
<v-list-item>
<v-list-item-avatar>
<v-img src="images/pec_logo.png"></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title class="red--text text--darken-2">
Uni-President
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
<v-list dense>
<v-list-item v-for="item in items" :key="item.title" link :to="item.to">
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
</v-app>`,

data: () => ({
drawer: null,
items: [
{ title: "Home", icon: "mdi-view-dashboard", to: "/" },
{ title: "About", icon: "mdi-forum", to: "/about" },
{ title: "Not Exists", icon: "mdi-forum", to: "/not-exists" }
]
})
};

將 App.vue.js 加入 index.html,這得放在 main.js 之前。我也擺了一個 pec_logo.png 在 public/images 中。

同時需要修改一下 main.js,讓 Vue 創建實例時,就直接渲染 App 組件。

public/main.js
1
2
3
4
5
new Vue({
vuetify: new Vuetify(),
router,
render: h => h(App)
}).$mount("#app");

現在看起來漂亮多了

這可以當作基本的 Web Application 架構,複製整個專案,就可以開始加入你的 Application 組件了。

GraphQL 即將改變你的設計程序,你會開始將 API 視為型態的集合,而不是 REST 端點的集合。在開始製作 API 之前,你必須思考、討論與正式定義這個 API 將要公開的資料型態。這個型態的集合稱為 schema。這與關聯式資料庫的 schema 是同義的,Table 就是一種資料型態。

Schema 優先(Schema First) 是可讓你的團隊對於組成 app 的資料型態達成共識的設計方法。後端團隊可藉由它來明確瞭解他們需要儲存與傳遞的資料。前端團隊可以知道他們開始建構使用者介面時需要的定義。每個人都有個明確的詞彙表可用來溝通如何建構系統。

為了便於定義資料型態,關連式資料庫有資料定義語言(Data Definition Language,DDL)是 SQL 語言集中負責資料結構定義與資料庫物件定義的語言;GraphQL 也附有一種定義 schema 的語言,稱為 Schema Definition Language,或稱 SDL。如同 SQL 在不同的關連式資料庫中,GraphQL Query Language,無論你使用哪種語言或框架來建構 app,GraphQL SDL 都是一樣的。GraphQL schema 文件是定義可在 app 中使用的型態之文件,用戶端與伺服器端可以用它來驗證 GraphQL 請求。

定義型態

實際建構 GraphQL 型態與 schema 是瞭解它們的最佳方式。這個範例將建立一個照片分享 app, 可讓使用者登入他們的帳號來張貼照片,並且在那些照片中標記使用者。

PhotoShare app 有兩種主要的型態: User 與 Photo。我們從設計 schema 開始。

型態 (Type)

GraphQL Schema 的核心單位是型態。在 GraphQL 中,型態代表一種自訂的物件,這些物件描述了 app 的核心功能。例如,社群 app 有 Users 與 Posts。部落格或許有 Categories 與 Articles。這些型態代表 app 的資料。當你定義 schema 時,就是在定義團隊討論領域物件(domain object)時使用的共同語言。

型態有一些欄位(field),它們代表與每個物件有關的資料。每個欄位會回傳特定型態的資料,它可能是整數或字串,也可能是自訂的物件型態或型態串列。

schema 是型態定義的集合。你可以在 JavaScript 檔案中用字串來編寫 schema,或在任何文字檔中編寫它。這些檔案通常使用 .graphql 副檔名。

我們在此範例 schema 檔案中定義第一個 GraphQL 物件型態 – Photo:

1
2
3
4
5
6
type Photo {
id: ID!
name: String!
url: String!
description: String
}

我們在大括號之間定義了 Photo 的欄位。Photo 的 url 是圖像檔案位置的參考。這個描述式也含有一些關於 Photo 的描述資料: name 與 description。最後,每個 Photo 都有個 ID,它是可當成鍵來讀取照片的專屬代碼。

每個欄位的資料都有特定型態。我們現在只在 schema 定義一種自訂型態,Photo,但 GraphQL 有一些內建的型態可讓欄位使用。這些內建型態稱為純量型態 (scalar types)。description、name 與 url 欄位都使用 String 純量型態。當我們查詢這些欄位時,回傳的資料將會是 JSON 字串。驚嘆號 ! 代表該欄位不能為 null,也就是說在每個查詢中,name 與 url 欄位都必須回傳一些資料。descripton 可為 null,代表照片的說明是可選的,當這個欄位被查詢時可回傳 null。

Photo id 欄位是每張照片的專屬代碼。在 GraphQL 中,當你要回傳專屬的代碼時,就要使用 ID 純量型態。這個代碼的 JSON 是個字串,但是這個字串將會被驗證是否為唯一值。

純量型態 (Scalar Type)

GraphQL 的內建純量型態 ( Int、Float、String、Boolean、ID )相當實用,但有時你可能想要定義自己的純量型態。純量型態不是物件型態,它沒有欄位。但是當你實作 GraphQL 服務時,可以指定自訂的純量型態,並定義應該如何驗證,例如:

1
2
3
4
5
6
7
8
9
scalar DateTime

type Photo {
id: ID!
name: String!
url: String!
description: String
created: DateTime!
}

我們建立了一個自訂的純量型態: DateTime。現在我們可以找出每張照片是何時建立(created)的。任何被標記為 DateTime 的欄位都會回傳一個 JSON 字串,但我們可以使用自訂純量來確保該字串可被序列化、驗證與格式化為官方的日期與時間。

你可以為任何需要驗證的型態宣告自訂純量。 graphql-custom-types npm 套件有一些常用的自訂純量型態可讓你快速的加入你的 GraphQL 服務。

列舉 (enum)

enumeration (列舉)型態,或 enum ,是可讓欄位回傳有限的字串值集合的純量型態。當你想要確保某個欄位只回傳一群特定值之中的一個值時,可使用 enum 型態。

例如,我們來建立一個稱為 PhotoCategory 的 enum 型態,以及五種可能的選項來定義被貼出的照片的類型: SELFIE、PORTRAIT、ACTION、LANDSCAPE 或 GRAPHIC:

1
2
3
4
5
6
7
enum PhotoCategory {
SELFIE
PORTRAIT
ACTION
LANDSCAPE
GRAPHIC
}

你可以在定義欄位時使用 enum 型態。我們在 Photo 物件型態中加入一個 category 欄位:

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

加入 category 可確保我們實作這個服務之後,它可以回傳五個有效的值之一。

連結與串列

當你建立 GraphQL schema 時,可以定義可回傳 “任何 GraphQL 型態組成的串列” 的欄位。建立串列的方式是將 GraphQL 型態放在方刮號內,[String] 定義一個字串串列,而 [PhotoCategory] 定義一個照片種類的串列。串列也可以包含多種型態。

當你定義串列時,驚嘆號的使用有點複雜。當驚嘆號在結束的方括號後面時,代表該欄位本身是不可為 null 的。當驚嘆號在結束的方括號之前時,代表串列內的值是不可為 null 的。當你看到驚嘆號時,它就必須有值,且不能回傳 null。

串列可否為 null 的規則:

串列宣告 定義
[Int] 可為 null 的整數值串列
[Int!] 不可為 null 的整數值組成的串列
[Int]! 可為 null 的整數值組成的不可為 null 的串列
[Int!]! 不可為 null 的整數值組成的不可為 null 的串列

大部分的串列定義都是不可為 null 的值組成的不可為 null 的串列,也就是 [Int!]!, 因為我們通常不希望串列裡面的值是 null。我們應事先濾除任何 null 值。如果串列不包含任何值,我們可直接回傳一個空的 JSON 陣列,例如 [ ]。空陣列在技術上不是 null,它是不包含任何值的陣列

連接資料與查詢多種相關資料的型態的能力都是非常重要的功能。當我們用自訂物件型態來建立串列時,就是在使用這個強大的功能,並將物件相互連接。

一對一連結

當我們使用自訂物件型態來建立欄位時,就是在連結兩個物件。在圖論中,兩個物件之間的連結稱為邊 (edge)。第一種連結類型是一對一連結,代表將一種物件型態連結另一種物件型態。

照片是使用者貼出的,所以我們的系統裡面的每張照片都應該有一條邊連接貼出它的使用者。下圖是兩種型態 (Photo 與 User) 之間的單向連結。連接兩個節點的邊稱為 postedBy。

我們來看一下如何在 schema 中定義它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type User {
adLogin: ID!
name: String
avatar: String
}

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

我們先在 schema 中加入一個新型態,User。PhotoShare app 的使用者會先用 AD 登入 ( 假設這個 app 的使用者使用 Windows AD 認證 )。當使用者登入時,我們可以取得他們的 id,並用它來作為使用者記錄的專屬代碼,這裡的欄位就是 adLogin。另外也可將他們的名子與大頭照儲存在 name 與 avatar 欄位。

接著在 Photo 加入 postedBy 欄位來建立連結。因為每張照片都必須被使用者貼出,所以我們將這個欄位設為 User! 型態,加入驚嘆號是為了讓這個欄位不可為 null。

一對多連結

讓 GraphQL 服務成為一張無向圖有很大的好處,因為這個做法可讓用戶端有極大的 query 建立彈性,原因是無向圖可讓他們從任何的節點(node 或稱 vertice)開始遍歷圖。我們只要提供一個從 User 型態返回 Photo 型態的路徑就可以產生無向圖。這代表當我們查詢一位 User 時,可以看到那位使用者貼出的所有照片:

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

藉由在 User 型態加入 postedPhotos 欄位,我們提供了一個從使用者回到 Photo 的路徑(藉由 postedBy 的邊)。postedPhotos 欄位會回傳一個 Photo 型態的串列,它們是使用者貼出的照片,我們也可稱此時的 User 型態與 postedPhotos 欄位中的 Photo 型態有父子關係。因為使用者可以張貼多張照片,所以我們建立了一對多連結。一對多連結是一種常見的連結,可藉由在父物件裡建立一個 “可以列出多個其它物件” 的欄位來產生,如圖所示。

我們經常在根型態中加入一對多連結,而這些連結可以是階層式的。

為了讓 query 可以使用我們已經定義的資料型,Photo 與 User,我們必須定義 Query 根型態的欄位。我們來看一下如何將新的自訂型態加入 Query 根型態:

1
2
3
4
5
6
7
8
9
10
type Query {
totalPhotos: Int!
allPhotos: [Photo!]!
totalUsers: Int!
allUsers: [User!]!
}

schema {
query: Query
}

加入 Query 型態就定義了可在 API 使用的查詢,此例中為 Photo 與 User 各個型態各加入兩個 query: 一個用來傳遞各個型態的紀錄總數,另一個用來傳遞這些紀錄的完整串列。我們也將 Query 型態加入 schema 檔案,這可讓我們在 GraphQL API 中使用 query。

現在我們可以用下面的 query 字串來查詢照片與使用者:

1
2
3
4
5
6
7
query {
totalPhotos
allPhotos {
name
url
}
}

多對多連結

有時我們想要將一個節點串列與另一個節點串列關連起來。PhotoShare app 可讓使用者在他們貼出的每張照片中標示其他使用者,這個程序稱為標記 (tagging)。一張照片可能有許多使用者,且同一位使用者可能會被標記在許多照片中。

為了建立這種類型的連結,我們必須在 User 與 Photo 型態裡加入串列欄位。

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

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

你可以看到,多對多連結是兩個一對多連結組成的。在這個例子中,一張 Photo 可以有多個被標記的使用者,且一位 User 可被標記在多張照片內。

引數 Arguments

在 GraphQL 中,你可以將引數加到任何欄位,用引數來傳送可以影響 GraphQL 操作結果的資料。接下來說明如何在 schema 中定義引數(Arguments)。

Query 型態有列出 allUsers 或 allPhotos 的欄位,但當你只想要選擇一位 User 或一張 Photo 時該怎麼做? 此時要提供一些關於想要選擇的使用者或照片的資訊。你可以使用引數連同 query 一併傳送那項資訊:

1
2
3
4
5
type Query {
...
User(adLogin: ID!): User!
Photo(id: ID!): Photo!
}

引數與欄位一樣,必須有個型態。在 schema 內可以使用的任何一種純量型態或物件型態都可以用來定義那個型態。為了選擇特定的使用者,我們必須以引數傳送那位使用者的專屬 adLogin。

query
1
2
3
4
5
6
query {
User(adLogin: "Scott") {
name
avatar
}
}
query
1
2
3
4
5
6
7
query {
Photo(id: "12HK456H6P") {
name
description
url
}
}

在這兩個例子中,我們都用引數來查詢一筆特定記錄的細節。因為這些引數是必須的,所以它們被定義成不可為 null 的欄位。如果你在使用這些 query 時沒有提供 id 或 adLogin,GraphQL 就會回傳錯誤。

過濾資料

引數並非必須 “不可為 null”。我們可以使用 “可為 null” 的欄位來加入選用的引數。這意味著我們可以在執行查詢時提供引數作為選用的參數。例如,我們可以用照片種類來過濾 allPhotos query 回傳的照片串列:

query
1
2
3
4
type Query {
...
allPhotos(category: PhotoCategory): [Photo!]!
}

我們在 allPhotos query 中加入一個選用的 category 欄位。這個 category 必須匹配 enum 型態 PhotoCategory 的值。如果使用者傳送 query 時沒有提供種類值,我們假設將會回傳每一張照片。但是如果有提供種類,就可以取得一個過濾後的、屬於同一個種類的照片串列:

query
1
2
3
4
5
6
7
query {
allPhotos(category: "SELFIE") {
name
description
url
}
}

這個 query 會回傳 SELFIE 種類的每張照片的 name 、description 與 url。

這裡我們則再將我們熟悉的關連式資料庫表 EMP 與 DEPT 對應到 GraphQL schema 資料型態。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Employee {
empno: ID!
ename: String
job: String
mgr: Int
hiredate: String
sal: Float
comm: Float
income: Float
department: Department
}

type Department {
deptno: ID!
dname: String
loc: String
employees: [Employee!]!
}

其中 empno 在資料庫是 Number(4),這裡可以設定為 Int!,但我們為了確保回傳唯一值,將它設為 ID!,但要記得純量型態 ID 是一種字串型態,相對的 mgr 欄位就必須有所取捨,Int 或 String? department 欄位則不存在 EMP 資料表中,這裡我們則設定了一個一對一連結,返回 Department 資料型態。 income 則會是 sal 與 comm 相加的值,我們可以在此朔造資料庫端與使用者介面端的橋樑。

Department 型態的欄位 employees 回傳一個 Employee 型態的串列,這是個一對多連結,它可以回傳空陣列 [ ],要記得,空陣列在技術上不是 null,它是不包含任何值的陣列。

現在我們可以 query:

query
1
2
3
4
5
6
7
8
9
10
11
12
query {
employee(empno: "7788") {
empno,
ename,
income
department {
deptno,
dname,
loc
}
}
}
result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"data": {
"employee": {
"empno": "7788",
"ename": "SCOTT",
"income": 3502,
"department": {
"deptno": "20",
"dname": "RESEARCH",
"loc": "DALLAS"
}
}
}
}
query
1
2
3
4
5
6
7
8
9
10
query {
department(deptno: "10") {
dname,
employees {
empno,
ename,
income
}
}
}
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
28
29
{
"data": {
"department": {
"dname": "ACCOUNTING",
"employees": [
{
"empno": "7608",
"ename": "แมว",
"income": 1123
},
{
"empno": "7839",
"ename": "KING",
"income": 5000
},
{
"empno": "7782",
"ename": "CLARK",
"income": 2450
},
{
"empno": "7934",
"ename": "楊喆",
"income": 5678
}
]
}
}
}

接續下一篇 GraphQL Schema 設計 (2)

Fragment

你可以將操作的定義及 fragment 放入 GraphQL 查詢文件。fragment 是可在多個操作中重複使用的選擇組(SelectionSet)。我們回到 Snowtooth 的 GraphQL Playground 介面,看一下這個 query:

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 {
Lift(id: "jazz-cat") {
name
status
capacity
night
elevationGain
trailAccess {
name
difficulty
}
}
Trail(id: "river-run") {
name
difficulty
accessedByLifts {
name
status
capacity
night
elevationGain
}
}
}

這個查詢請求的是 Jazz Cat 纜車與 River Run 雪道的資訊。Lift 的選擇組裡有 name、status、capacity、night 與 elevationGain。我們想要取得的 River Run 雪道資訊有一些欄位與 Lift 型態的欄位相同。我們可以建立一個 fragment 來協助減少 query 重複的地方:

1
2
3
4
5
6
7
fragment liftInfo on Lift {
name
status
capacity
night
elevationGain
}

我們用 fragment 關鍵字來建立 fragment。fragment 是屬於特定型態的選擇組,所以你必須在 fragment 的定義中指定它所屬的型態。這個範例的 fragment 稱為 liftInfo,它是 Lift 型態的選擇組。

當我們要在另一個選擇組中加入 liftInfo fragment 欄位時,可在 fragment 名詞前面加上三個句點:

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 {
Lift(id: "jazz-cat") {
...liftInfo
trailAccess {
name
difficulty
}
}
Trail(id: "river-run") {
name
difficulty
accessedByLifts {
...liftInfo
}
}
}

fragment liftInfo on Lift {
name
status
capacity
night
elevationGain
}

這種語法類似 JavaScript 的 spread 運算子。這三個句點可讓 GraphQL 將 fragment 的欄位指派給當前的選擇組。在這個範例中,我們在 query 的兩個地方使用同一個 fragment 來選擇 name、status、capacity、night 與 elevationGain。

我們無法將 liftInfo fragment 加入 Trail 選擇組,因為 liftInfo 只定義了 Lift 型態的欄位。我們可以加入另一個雪道 Trail 型態欄位的 fragment trailInfo:

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
query {
Lift(id: "jazz-cat") {
...liftInfo
trailAccess {
...trailInfo
}
}
Trail(id: "river-run") {
...trailInfo
groomed
trees
night
}
}

fragment trailInfo on Trail {
name
difficulty
accessedByLifts {
...liftInfo
}
}

fragment liftInfo on Lift {
name
status
capacity
night
elevationGain
}

在這個範例中,我們建立了一個稱為 trailInfo 的 fragment,並在 query 的兩個地方使用它。我們也在 trailInfo fragment 中使用 liftInfo fragment 來選擇與它連接的纜椅資料。你可以視需求建立任意數量的 fragment,並交換使用它們。在 River Run Trail query 使用的選擇組中,我們將 fragment 與想要選擇的、關於 River Run 雪道的其他資料結合。你可以一併使用 fragment 與選擇組的其他欄位,也可以在單個選擇組中結合多個屬於同樣型態的 fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
query {
allTrails {
...trailStatus
...trailDetails
}
}

fragment trailStatus on Trail {
name
status
}

fragment trailDetails on Trail {
groomed
trees
night
}

fragment 有個很棒的優點在於,你只需要修改一個 fragment,就可以修改在許多不同的 query 裡面的選擇組:

1
2
3
4
fragment liftInfo on Lift {
name
status
}

這樣修改 liftInfo fragment 的選擇組會讓使用這個 fragment 的每一個 query 選擇較少的資料。

變動 Mutation

到目前為止,我們已討論許多關於讀取資料的事情了。我們用 query 來描述在 GraphQL 中發生的所有讀取。若要寫入新資料,就要使用 mutation (變動)。mutation 的定義類似 query ,它們都有名稱,也可以擁有 “可回傳物件型態或純量型態的選擇組” ,不同之處在於 mutation 可執行一些影響後端資料狀態的修改。

例如,這是個很危險的 mutation:

1
2
3
mutation burnItDown {
deleteAllData
}

Mutation 是一種根物件型態。API 的 schema 定義了這個型態可用的欄位 (field)。上述的 Mutation API 擁有強大的威力,可清除所有的資料,它實作了一個 deleteAllData 的欄位,當所有資料都被成功的刪除,這個欄位會回傳一個純量型態: true,或者如果出錯了,會回傳 false。資料究竟會不會被刪除是藉由實作這個 API 來決定。

我們來看另外一個範例,這次我們要創造一些東西,而不是摧毀東西:

1
2
3
4
5
6
7
mutation createSong {
addSong(title: "No Scrubs", numberOne: true, performerName: "TLC") {
id
title
numberOne
}
}

我們可以使用這個範例來創建新歌曲,利用引數將 title、numberOne 狀態與 performerName 傳給這個 mutation 之後,它會將這首新歌加入資料庫。如果這個 mutation 欄位 addSong 會回傳物件,你就要在這個 mutation 後面加入一個選擇組。在本例中,mutation 完成後會回傳 Song 型態的物件,裡面有剛才創建的歌曲的資料。我們可以在 mutation 後面選擇新歌的 id、title 與 numberOne 狀態的選擇組:

1
2
3
4
5
6
7
8
9
{
"data": {
"addSong": {
"id": "Saca534f4bb1de07cb6d73ae",
"title": "No Scrubs",
"numberOne": true
}
}
}

上面是這個 mutation 可能的回應。如果出錯,這個 mutation 會在 JSON 回應裡面回傳錯誤,而不是新建的 Song 物件。

我們也可以使用 mutation 來更改既有的資料。當我們想要更改 Snowtooth 的纜椅狀態時,也可以使用 mutation:

1
2
3
4
5
6
mutation closeLift {
setLiftStatus(id: "jazz-cat" status: CLOSED) {
name
status
}
}

這個 mutation 將 Jazz Cat 纜椅的狀態從 open 改成 closed。我們可以在 mutation 後面的選擇組裡面選擇被更改的 Lift 的欄位。在此,我們取得被改變的纜椅的 name,以及更新後的 status。

使用查詢變數

現在我們已經會使用 mutation 引數傳送新字串值來更改資料了,另一種做法是使用輸入變數,以變數取代 query 內的靜態值可讓我們可以傳入動態值。在 addSong mutation,我們要用變數名稱來取代字串,在 GraphQL 中,變數必定以 $ 字元開頭:

1
2
3
4
5
6
7
mutation createSong($title:String! $numberOne:Boolean $by:String!) {
addSong(title:$title, numberOne:$numberOne, performerName:$by) {
id
title
numberOne
}
}

我們將靜態值換成 $variable,接著宣告的 mutation 可接收 $variable。GraphiQL 與 Playground 都有一個 Query Variables 視窗 (左下角,可把空間往上拉大),我們在這裡用 JSON 物件來傳送輸入資料,務必用正確的變數名稱作為 JSON 鍵:

1
2
3
4
5
{
"title": "No Scrubs",
"numberOne": true,
"by": "TLC"
}

當你傳送引數資料時,變數的功能很強大,它不但可讓你的 mutation 在測試的過程中更有條理,當你連接用戶端介面時,使用動態輸入也有很大的幫助。

訂閱 Subscription

GraphQL 的第三種操作類型是訂閱 (subscription)。有時用戶端想要取得伺服器傳送的即時更新。訂閱可讓我們監聽 GraphQL API 的即時資料變更。

GraphQL 的訂閱功能來自 Facebook 的實際使用案例。這個團隊想要在不重新整理網頁的情況下,顯示關於貼文獲得的贊 (Live Likes)數量的即時資訊。Live Likes 是以訂閱來製作的及時使用案例。每一個用戶端都會訂閱 like 事件,並即時看到 like 的更新。

如同 mutation 與 query,subscription 是一種根型態。你必須在 API schema 的 subscription 型態下的欄位中定義用戶端可以監聽的資料變更。編寫 GraphQL query 來監聽 subscription 的做法類似定義其它操作的方式。

例如,在 Snowtooth 中,我們可以用 subscription 監聽任何纜椅狀態的變動:

1
2
3
4
5
6
7
subscription {
liftStatusChange {
name
capacity
status
}
}

當我們執行這個 subscription 時,會用 WebSocket 來監聽纜椅狀態的改變。請留意,在 GraphQL Playground 按下播放按鈕後不會立刻收到回傳的資料。當 subscription 被送到伺服器時,這個 subscription 會監聽資料的任何改變。

如果你想要看到 subscription 收到資料,就必須做出改變。你必須打開一個新視窗或標籤,用 mutation 來傳送改變。 如果使用的是 GraphQL Playground,視窗上端可以直接打開一個新標簽來加入 mutation。 我們在新視窗或標籤送一個改變纜椅狀態的 mutation:

1
2
3
4
5
6
mutation closeLift {
setLiftStatus(id: "astra-express" status: HOLD) {
name
status
}
}

回到 subscription 的視窗,將會收到被更新的資料,以及資料被送到 subscription 的時間。

與 query 和 mutation 不同的是,subscription 會保持開啟。接下來每當有纜椅的狀態改變時,新的資料就會被推送到這個 subscription。若要停止監聽狀態的變動,你必須取消 subscription。當你使用 GraphQL Playground 時,只要按下停止按鈕即可。不幸的是,用 GraphiQL 來取消 subscription 唯一的做法是關閉運行該 subscription 的瀏覽器標籤。

自我查詢 Introspection

自我查詢 (introspection)是 GraphQL 最強大的功能之一。自我查詢是指查詢目前的 API 的 schema。自我查詢是將這些精心建構的 GraphQL 文件加入 GraphiQL 與 Playground 介面的方式。

你可以傳送 query 給每一個 “可回傳特定 API 的 schema 資料” 的 GraphQL API。例如,如果你要知道可在 Snowtooth 中使用哪種 GraphQL 型態,可以執行 __schema query 來查看該資訊:

1
2
3
4
5
6
7
8
query {
__schema {
types {
name
description
}
}
}

當你執行這個 query 時,可以看到這個 API 可用的每一個型態,包括根型態、自訂型態,甚至純量型態。如果你想要查看特定型態的資料,可執行 __type query,並用引數來傳送想要查詢的型態名稱:

1
2
3
4
5
6
7
8
9
10
11
12
query liftDetails {
__type(name:"Lift") {
name
fields {
name
description
type {
name
}
}
}
}

這種自我查詢功能可讓你看到 Lift 型態可供查詢的所有欄位。當你想要瞭解新的 GraphQL API 時,找出根型態提供哪些欄位是很好的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
query roots {
__schema {
queryType {
...typeFields
}
mutationType {
...typeFields
}
subscriptionType {
...typeFields
}
}
}

fragment typeFields on __Type {
name
fields {
name
}
}

自我查詢會遵循 GraphQL 查詢語言的規則。我們用 fragment 來簡化上述的 query,並查詢每個根型態的名稱與它們提供的欄位。自我查詢可讓用戶端知道目前的 API schema 如何運作。

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

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

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

例如,你的 query 一開始是 GraphQL 文件。文件至少有一個定義,也可能有一串定義。定義只有可能是兩種型態之一: OperationDefinitionFragmentDefinition。下面的文件範例有三個定義: 兩項操作 (Operation) 與一個 fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
query jazzCatStatus {
Lift(id: "jazz-cat") {
name
night
elevationGain
trailAccess {
name
difficulty
}
}
}

mutation closeLift($lift: ID!) {
setLiftStatus(id: $lift, status: CLOSED) {
...liftStatus
}
}

fragment liftStatus on Lift {
name
status
}

一個 OperationDefinition 只能含有三種操作型態之一: mutationquerysubscription。每一個操作定義都有 OperationTypeSelectionSet

在每一個操作後面的大括號內都有該操作的 SelectionSet,它們就是我們用引數來查詢的欄位。例如,Lift 欄位是 jazzCatStatus query 的 SelectionSet,而 setLiftStatus 欄位是 closeLift mutation 的選擇組。

選擇組可嵌套在另一個選擇組裡。jazzCatStatus query 有三個嵌套的選擇組。第一個 SelectionSet 含有 Lift 欄位。在它裡面有個 SelectionSet 含有 name、night、elevationGain 與 trailAccess 欄位。在 trailAccess 欄位內有另一個 SelectionSet,含有每個雪道的 name 與 difficulty 欄位。

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

AST 是 GraphQL 很重要的成分。每一項操作都會被解析成 AST,以便對它進行驗證並最終執行它。

SQL,或結構化查詢語言 (Structured Query Language),是一種用來存取、管理與操作資料庫內資料的領域專用語言 (domain-specific language)。 SQL 導入 “以單一指令來存取多筆紀錄” 的概念,也可以讓你用任何鍵來存取任何紀錄,而不只是只能使用 ID。

SQL 可執行的指令相當簡單(喔?): SELECT、INSERT、UPDATE 與 DELETE。你只能對資料做這些事情。使用 SQL 時,我們可以編寫一條查詢指令來取得在資料庫的資料表內互相連接(join)的資料。

“資料只能讀取、建立、更新或刪除” 這個概念確實成就了 REST,這個概念要求我們基於這四種基本的資料操作法來使用不同的 HTTP 方法: GET、POST、PUT 與 DELETE。但是,如果你想要指定用 REST 來讀取或改變那一種類型的資料,唯一的方式就是透過端點 URL,不能用實際的查詢語言。

GraphQL 將原本用來查詢資料庫的概念應用在網際網路上。你只要用一種 GraphQL query 就可以回傳彼此相連的資料。你可以像 SQL 那樣使用 GraphQL query 來改變或移除資料。畢竟,SQL 與 GraphQL 代表同一個東西: Query Language (查詢語言)。

雖然 GraphQL 與 SQL 都是查詢語言,但它們完全不同,它們適用於完全不同的環境。 SQL query 是傳給資料庫的,而 GraphQL query 是傳給 API 的。SQL 資料存放在資料庫的資料表內,GraphQL 資料可存放在任何地方: 資料庫、多個資料庫、檔案系統、REST API、WebSocket,甚至其他的 GraphQL API。 SQL 是資料庫的查詢語言, GraphQL 是網際網路的查詢語言

GraphQL 與 SQL 的語法也完全不同。GraphQL 使用 Query 來請求資料,而不是 SELECT,這項操作是 GraphQL 完成的每項工作的核心。 GraphQL 將所有的資料的異動都包成一種資料型態: Mutation,而不是使用 INSERT、UPDATE 或 DELETE。因為 GraphQL 是讓網際網路使用的,它有一種訂閱(Subscription)型態可用來監聽通訊端連結上的資料變動。SQL 沒有類似訂閱的東西。

GraphQL 是按照規格來標準化的。無論你使用哪一種程式語言都可以: GraphQL 查詢單純是個 GraphQL 查詢。查詢語法是個字串,無論你的專案使用 JavaScript、Java、C# 或任何其他東西,它看起來都一樣。

GraphQL 的 query 只是放在 POST 請求的內文(body)中送給 GraphQL 端點的字串。下面是一個 GraphQL query,它是用 GraphQL 查詢語言寫成的字串:

query
1
2
3
4
5
{
allLifts {
name
}
}

你可以使用 curl 將這個 query 送給一個 GraphQL 端點:

1
2
3
curl 'http://snowtooth.herokuapp.com/'
-H 'Content-Type: application/json'
--data '{"query":"{allLifts {name}}"}'

或從瀏覽器使用 JavaScript:

1
2
3
4
5
6
7
fetch("http://snowtooth.herokuapp.com/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "{allLifts {name}}"})
})
.then(res => res.json())
.then(result => console.log(result));

如果 GraphQL schema 支援這種外形的 query,你就可以直接收到一個 JSON 回應。那個 JSON 回應有你請求的 data 欄位中的資料,或者,在發生錯誤時有個 errors 欄位。我們發出一個請求,收到一個回應。

若要修改資料,你可以傳送 mutation (變動)。mutation 長得很像 query,但它的目的是改變關於 app 整體狀態的事務。你可以使用 mutation 直接傳送執行改變所需的資料,例如:

mutation
1
2
3
4
5
6
mutation {
setLiftStatus(id: "panorama" status: OPEN) {
name
status
}
}

上面的 mutation 是用 GraphQL 查詢語言寫成的,我們希望用它將 id 為 panorama 的纜椅改為 OPEN 狀態。我們一樣可用 cURL 將這項操作送給 GraphQL 伺服器:

1
2
3
curl 'http://snowtooth.herokuapp.com/'
-H 'Content-Type: application/json'
--data '{"query":"mutation {setLiftStatus(id: \"panorama\" status:OPEN) {name status}}"}'

稍後我們將會看到如何使用更好的方法將變數對應到 query 或 mutation。

GraphQL API 工具

GraphQL 社群建立了一些可用來和 GraphQL API 互動的開放原始碼工具。這些工具可讓你用 GraphQL 查詢語言來編寫 query,並將這些 query 送到 GraphQL 端點,以及查看 JSON 回應。目前有兩種熱門工具: GraphiQL 與 GraphQL Playground。

GraphiQL

GrahpiQL 是 Facebook 建造的瀏覽器內部整合開發環境 (IDE),可用來查詢與瀏覽 GraphQL API。GraphiQL 提供語法突顯、程式自動完成及錯誤警告等功能,可以讓你直接在瀏覽器中執行查詢並查看查詢結果。許多公用的 API 都提供 GraphiQL 介面以供查詢即時資料。

GraphQL Playground

另一種可用來流覽 GraphQL API 的工具是 GraphQL Playground,它很像 GraphiQL,但它還有一些方便的功能,其中最重要的功能是它可以讓你連同 GraphQL 請求一起傳送自訂的 HTTP 標頭 (HTTP headers)。

GraphQL 查詢

Snowtooth Mountain 是虛構的滑雪勝地。我們將使用它來瞭解一些 GraphQL Query 範例。我們將要觀察 Snowtooth Mountain 網路團隊如何使用 GraphQL 來提供即時、最新的纜椅和雪道狀態資訊。Snowtooth 滑雪巡邏員可以直接用它們的手機打開與關閉纜椅和雪道。你可以參考 Snowtooth 的 GraphQL Playground 介面(請用 Chrome 瀏覽器)來了解往後的範例。

你可以使用 query (查詢)從 GraphQL API 請求資料。query 描述你想要從 GraphQL 伺服器取得的資料。當你傳送 query 時,就是以 field (欄位)為單位索取資料。這些欄位對應伺服器回傳的 JSON 資料回應內的同一組欄位。例如,如果你傳送一個索取 allLifts 的 query,並請求 name 與 status 欄位,就會收到一個含有 allLifts 陣列與每一個纜椅的 name 與 status 的字串。

query
1
2
3
4
5
6
query {
allLifts {
name
status
}
}
results
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"data": {
"allLifts": [
{
"name": "Astra Express",
"status": "CLOSED"
},
{
"name": "Jazz Cat",
"status": "CLOSED"
},
...
]
}
}
錯誤處裡

當你成功發出查詢後,會收到一個包含有 “data” 鍵的 JSON 文件。不成功的查詢會收到一個含有 “error” 鍵的 JSON 文件。這個鍵底下的 JSON 資料就是錯誤的詳細訊息。JSON 回應也可能同時含有 “data” 與 “error”。

你可以在查詢文件裡加入多個 query,但每次只能執行一項操作。

query
1
2
3
4
5
6
7
8
9
10
11
12
13
query lifts {
allLifts {
name
status
}
}

query trails {
allTrails {
name
difficulty
}
}

當你按下撥放按鈕時,GraphQL Playground 會要求你選擇這兩項操作之一。如果你想要藉由一個請求來取得所有資料,就必須將它們全都放在ㄧ個 query 裡面:

query
1
2
3
4
5
6
7
8
9
10
11
query liftsAndTrails {
liftCount(status: OPEN)
allLifts {
name
status
}
allTrails {
name
difficulty
}
}

從這裡開始,你可以慢慢看到 GraphQL 的優點。我們可以用一個 query 來索取各種不同的資料點。我們索取目前處於特定狀態的 liftCount、取得目前處於哪種狀態的纜椅量,也索取每一個纜椅的 name 與 status。最後,我們在同一個 query 中要求取得每一條滑雪道的 name 與 difficulty。

Query 是一種 GraphQL 型態。我們稱它為根型態,因為這種型態對應一項操作 (Operation),而操作是查詢文件的根。query 可在 GraphQL API 中使用的欄位已被定義在該 API 的 schema 裡面了,這個文件告訴我們哪個 Query 型態有哪些欄位可以選擇。

GraphQL Schema 告訴我們,當我們查詢這個 API 時,可以選擇欄位 liftCount、allLifts 與 allTrails。它也定義了其他可供選擇的欄位,但與查詢有關的重點在於,我們能夠選擇需要的欄位,以及省略不想要的欄位。

我們會在編寫 query 時,將需要的欄位放在大括號裡面來選擇它們,這些段落稱為選擇組 (selection set)。我們在選擇組中定義的欄位與 GraphQL 型態有直接的關係。liftCount、allLifts 與 allTrails 欄位都被定義在 Query 型態內。

你可以在ㄧ個選擇組裡面放入另一個選擇組。因為 allLifts 欄位會回傳一串 Lift 型態,我們必須使用大括號來建立一個這種型態的新選擇組。我們可以請求各種關於 lift 的資料,但在這個範例中,我們只想要選擇 lift 的 name 與 status。類似的情況,allTrails query 會回傳 Trail 型態。

JSON 回應含有我們在 query 中請求的所有資料,那些資料都會被格式化為 JSON,並且使用與 query 一樣的外形來傳遞。它發出的每一個 JSON 欄位的名稱都與選擇組的欄位名稱一樣。我們可以在 query 中指定別名(chairlifts, liftName)來改變回應物件的欄位名稱,例如:

query
1
2
3
4
5
6
7
8
9
10
11
query liftAndTrails {
open: liftCount(status: OPEN)
chairlifts: allLifts {
liftName: name
status
}
skiSlopes: allTrails {
name
difficulty
}
}

其回應為:

result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"data": {
"open": 6,
"chairlifts": [
{
"liftName": "Astra Express",
"status": "CLOSED"
}
],
"skiSlopes": [
{
"name": "Blue Bird",
"difficulty": "intermediate"
}
]
}
}

我們取回具備同樣外形的資料了,但也改變回應中的一些欄位名稱。要過濾 GraphQL 查詢結果,其中一種方式就是傳入查詢引數。引數是與一個查詢欄位有關的一對鍵/值(或好幾對)。如果我們只想要取得被關閉纜椅的名稱,可以傳入一個過濾回應的引數:

query
1
2
3
4
5
6
query closedLifts {
allLifts(status: CLOSED) {
name
status
}
}
query
1
2
3
4
5
6
7
8
query jazzCatStatus {
Lift(id: "jazz-cat") {
name
status
night
elevationGain
}
}

邊 (edge) 與連結

在 GraphQL 查詢語言中,欄位可為純量型態物件型態。純量型態 (scalar type)類似其他語言中的基本型態。他們是選擇組的葉(leaf)節點。GraphQL 內建五種純量型態: 整數 (Int)、浮點數 (Float)、字串 (String)、布林 (Boolean)、及專屬代碼 (ID)。

使用整數與浮點數都會得到 JSON 數字,使用字串與 ID 則會得到 JSON 字串。使用布林只會得到布林值。雖然使用 ID 與 String 字串會得到同一種 JSON 資料型態,但 GraphQL 會確保 ID 回傳唯一的字串。

GraphQL 物件型態是在 schema 內定義的一或多個欄位群組,它們定義了應回傳的 JSON 物件的外形。JSON 可以在欄位底下無止盡地嵌套物件,GraphQL 也是如此。我們可以藉由查詢某個物件來取得與它有關的物件的細節來將物件連(join)在一起。

例如,假如我們想要取得可以用特定的纜椅到達雪道的清單:

1
2
3
4
5
6
7
8
9
10
11
query trailsAccessedByJazzCat {
Lift(id: "jazz-cat") {
name
status
capacity
trailAccess {
name
difficulty
}
}
}

在上述的 query 中,我們索取關於 “Jazz Cat” 纜椅的一些資料,name、status 與 capacity 欄位都是純量型態。trailAccess 欄位是屬於 Trail 型態(物件型態)。在這個範例中,trailAccess 會回傳一個過濾過的、可用 Jazz Cat 抵達的雪道清單。因為 trailAccess 是 Lift 型態的欄位,API 可使用父物件(也就是 Jazz Cat Lift)的資料來過濾回傳的雪道清單。

這個操作範例查詢兩種資料型態(纜椅與雪道)之間的一對多連結。一台纜椅與許多與它有關的雪道相連。如果我們從 Lift 節點開始遍歷圖,可透過命名為 trailAccess 的邊前往與該纜椅連接的一或多個 Trail 節點。如果你要從 Trail 節點走回 Lift 節點,因為這張圖是無向的,所以可以做到:

1
2
3
4
5
6
7
8
9
query liftToAccessTrail {
Trail(id: "dance-fight") {
groomed
accessedByLifts {
name
capacity
}
}
}

在 liftToAccessTrail query 中,我們選擇一個名為 “Dance Fight” 的 Trail。groomed 欄位回傳一個布林純量型態,可讓我們知道 Dance Fight 雪道是否被整理過了。accessedByLifts 欄位回傳可帶著滑雪客前往 Dance Fight 雪道的纜椅。

接續下一篇 GraphQL 查詢語言 (2)

這裡我們會用 NW.js 與 Vue.js 開發一套可離線應用的桌面應用系統,資料將會存在客戶端的 localStorege 中,因此可不需要其它的資料庫,最主要還是在 Vue.js 的運用。你可將此移植到 Web 上,完全不需要修改。要在 NW.js 上運作我們只需要改變一些 NW.js 的啟動設定。

下面圖示將會是完成後的樣子,程式碼都很短,但幾乎包括了 Vue.js 的主要功能,也包括了資料的查詢、新增、修改與刪除。

最上面的輸入區可輸入 “待辦事項”, 按下 Enter 鍵後會將資料存儲到本機端,然後顯示在下方的待辦事項條列區。條列區上方有一個切換複選框(checkbox),可以將所有的待辦事項標記為已完成,或取消所有待辦事項已完成標記。

每一待辦事項也有專屬的切換複選框標記為已完成,或取消已完成標記。雙擊待辦事項的字串,則可修改待辦事項內容,按下 Enter 鍵或離開修改區域會自動存儲,也可以按 Esc 鍵取消修改。最右邊則有刪除鍵,可刪除該筆待辦事項。

到此,新增、修改與刪除都包含了,這些資料的異動存儲與 UI 介面的互動都是使用 Vue.js,其實跟 NW.js 一點關係都沒有。

條列區下方,左邊有尚未完成的筆數訊息,右邊則有三個查詢選項,可選擇查詢所有的待辦事項、尚未完成與已經完成的待辦事項。我們將會用到客戶端路由(Client-Side Routing)的技術。最右邊的 Clear completed 選項則只有在有標記已完成的事項時才會顯示出來,這可以刪除所有已完成的待辦事項。雖然這裡也可以用幾個按鍵(button)解決,但我想傳達的是,甚麼是客戶端路由(Client-Side Routing)的概念,這在 SPA Web 上是很重要的基礎關鍵。Vue.js 有它自己的路由管理器 Vue Router,但在這個範例,我們不需要那麼複雜的功能。

我們要建立一個可離線操作的桌面應用,使用較簡單的 NW.js,因此要確定你已安裝 NW.js。這個範例也可直接移植到 Web Server 上,例如, Node.js Express, 不用修改程式碼。

建立專案

建立一個專案目錄 todo-nwjs,然後使用 npm 初始化專案。

init
1
2
cd todo-nwjs
npm init -y

這個專案除了 NW.js 外,另外會用到 3 個套件,可以使用 npm 安裝。NW.js 則在之前我們已安裝為 global 套件,因此所有的專案都可以共享。

install
1
2
3
npm install vue --save
npm install bootstrap --save
npm install director --save

Vue.js 是 SPA 的框架,Bootstrap 則是 CSS 框架,director 我們會用到它的客戶端路由(Client-Side Routing)。Vue.js 本身也有路由管理器 Vue Router,但我們這裡的路由較簡單,不必用到那麼複雜的路由管理器。

首先我們從修改專案的設定 package.json 開始,這是這個專案唯一與 NW.js 有關的地方,其它的程是碼等都不會看到有 NW.js 的影子。

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"name": "todo-nwjs",
"version": "1.0.0",
"description": "",
"main": "app/index.html",
"window": {
"width": 600,
"height": 450
},
"scripts": {
"start": "nw .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bootstrap": "^4.3.1",
"director": "^1.2.8",
"vue": "^2.6.10"
}
}

main 是專案的入口點,我們會放在專案根目錄下的子目錄 app,index.html 是一般的 HTML 文件,這是此範例的起始文件。window 屬性則設定 NW.js 起始的視窗大小,start 屬性可讓我們在開發專案時使用 npm start 啟動此應用系統,這是唯一與 NW.js 有關的地方,編寫程式碼時,你可以把它忘了。

我們先看 index.html 初始的樣子。

index.html
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Todo List</title>
<link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="stylesheets/app.css" />
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="todoapp" class="container">
<h1 class="text-center">Todos</h1>
</div>
<script src="../node_modules/director/build/director.min.js"></script>
<script src="../node_modules/vue/dist/vue.min.js"></script>
<script src="javascripts/store.js"></script>
<script src="javascripts/app.js"></script>
<script src="javascripts/routes.js"></script>
</body>
</html>

在 NW.js 中可以直接從 node_modules 目錄中引用這些套件,但如果用的是 Web Server 通常都會有限制,你必須把用戶端所需要的資源放在靜態檔案(static files)服務區,而且如果最後使用 nwbuild 打包程式碼時會將整個 node_modules 目錄打包進來,這不是我們要的,我們只需要幾個模組,最好將它們複製到 app 目錄下分類規劃的子目錄。

index.html
1
2
3
4
<link rel="stylesheet" href="stylesheets/bootstrap.min.css" />

<script src="javascripts/director.min.js"></script>
<script src="javascripts/vue.min.js"></script>

index.html 中我們還定義了 [v-cloak] { display: none },這個指令可以隱藏未編譯的 Mustache 標籤直到 Vue.js 的實例準備完畢,這可避免 HTML 繫結 Vue 例項,在頁面載入時會閃爍的狀況。

除了這 3 個套件的程式庫外,我們的程式碼將分別放在 app 目錄下的子目錄 javascripts 中。

  • store.js 專門處理 localStorage 的存儲。
  • app.js 是 Vue.js 的主程式。
  • routes.js 則負責設定客戶端路由(Client-Side Routing)。

這些程式碼是有順序的,在 HTML 檔輸入 javascripts 有些都需注意其前後順序關係。

stylesheets 目錄下的 app.css 在補足 bootstrap CSS 框架不足的地方,最主要的在第三行的 text-decoration 的 line-through,這可讓已完成的待辦事項上有一條刪除橫槓。

app.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
li.completed label {
color: #d9d9d9;
text-decoration: line-through;
}
li a.active {
font-weight: bold;
color: #006699;
}
.info {
margin: 65px auto 0;
color: #bfbfbf;
font-size: 10px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
text-align: center;
}
.info p {
line-height: 1;
}

其它的 javascripts 文件,我們先從 store.js 看起。

Mobile-first 與 offline-first 的 Web 應用最主要的考量應該是網路。在現今多樣化的移動應用裝置,並不像我們在公司內部的應用,網路都是穩定的。所以現今的應用系統首先必須考慮的應該是,當沒有網路的時候,我的應用系統該如何運作? 除了原生的應用與桌面應用,Web 首先當然必須是 PWA 架構,再來就必須考慮資料的存儲問題。沒有網路你無法將資料存儲到遠端的資料庫,所以本機端的存儲是必要的。所有的存儲都必須先存入本機端,再透過訊息佇列(Message Queue)的概念送到遠端的資料庫,這樣應用系統才不會因網路的因素而影響數據的存儲。

近些年來流覽器端的數據存儲可選方案有長足的進展,之前我們在 JavaScript 教育訓練中曾經使用過 PouchDB,它可以在應用程序離線時將數據存儲在本地端,然後在應用程序重新連線時與伺服器端的 CouchDB 或可兼容的服務伺服器同步,無論使用者下次登錄何處,都能保持用戶數據同步。其它可選的本地端存儲數據方案還有很多,例如, IndexedDBLovefieldSQLiteNeDBLevelDBMinimongo,可選的方案很多,致於要使用哪一種? 這取決於你要存儲的數據類型、數據大小以及查詢數據的方式。

這個範例的數據不多,資料類型也不複雜,直接使用瀏覽器的原生功能 localStorage 來實做 API。使用了 JSON.stringify( ) 和 JSON.parse( ) 方法來序列化數據的存儲和讀取數據。

store.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(function(exports) {
"use strict";

const STORAGE_KEY = "todos-vuejs";

exports.todoStorage = {
fetch: function() {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
},
save: function(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
};
})(window);

這裡使用 IIFE 來模組化 JavaScript 程式碼,這段程式碼會產生一個 window.todoStorage 物件,裡面就只包含兩個方法 fetch( ) 與 save( ),因為 localStorage 只能存儲字符串,所以這裡必須使用 JSON.stringify 與 JSON.parse 作序列化。

接下來看 route.js,這是一個簡單的客戶端路由(Client-Side Routing)應用,使用 director 套件。

route.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(function(app, Router) {
"use strict";

const router = new Router();

["all", "active", "completed"].forEach(function(visibility) {
router.on(visibility, function() {
app.visibility = visibility;
});
});

router.configure({
notfound: function() {
window.location.hash = "";
app.visibility = "all";
}
});

router.init();
})(app, Router);

IIFE 中加入兩個引數,app 是 Vue 的實例,Router 則是 director 的建構式函數,所以這段程式碼必須運作在導入 director.js 及產生 Vue 的實例之後,這裡 Vue 實例的變數名稱是 app。所以把它放在 index.html 最後加入的 JavaScripts 文件。

這裡只使用 director.js 簡單的客戶端路由功能,forEach( ) 註冊了 3 個路由監聽器,分別監聽 3 個客戶端路由 all、active 與 completed,客戶端路由與伺服器端路由不同,使用的是 window.location.hash。當在 index.html 中的 <a href=”#/all”>All</a> 錨點與超連結被按下時就會觸發 “all” 監聽器。這 3 個路由的監聽器處理器函數會改變 Vue 實例的資料 visibility,visibility 的變動則會觸發 Vue 實例的 computed 的方法 filteredTodos,這我們馬上會在接下來的 app.js 中看到。

最後就是我們的重點 app.js 程式碼,最主要的是 Vue.js 的操作。重點著重在 Vue.js 的應用與時機,不會講解太多的 Vue.js 基本操作,目前有專業的 Vue.js 教育訓練課程進行中,請踴躍參與。

app.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
(function(exports) {
"use strict";

const filters = {
all: function(todos) {
return todos;
},
active: function(todos) {
return todos.filter(function(todo) {
return !todo.completed;
});
},
completed: function(todos) {
return todos.filter(function(todo) {
return todo.completed;
});
}
};

exports.app = new Vue({
data: {
todos: [],
newTodo: "",
editedTodo: null,
visibility: "all"
},
watch: {
todos: {
deep: true,
handler: todoStorage.save
}
},
computed: {
filteredTodos: function() {
return filters[this.visibility](this.todos);
},
remaining: function() {
return filters.active(this.todos).length;
},
allDone: {
get: function() {
return this.remaining === 0;
},
set: function(value) {
this.todos.forEach(function(todo) {
todo.completed = value;
});
}
}
},
methods: {
pluralize: function(word, count) {
return word + (count === 1 ? "" : "s");
},
addTodo: function() {
var value = this.newTodo && this.newTodo.trim();
if (!value) {
return;
}
this.todos.push({
id: this.todos.length + 1,
title: value,
completed: false
});
this.newTodo = "";
},
removeTodo: function(todo) {
var index = this.todos.indexOf(todo);
this.todos.splice(index, 1);
},
editTodo: function(todo) {
this.beforeEditCache = todo.title;
this.editedTodo = todo;
},
doneEdit: function(todo) {
if (!this.editedTodo) {
return;
}
this.editedTodo = null;
todo.title = todo.title.trim();
if (!todo.title) {
this.removeTodo(todo);
}
},
cancelEdit: function(todo) {
this.editedTodo = null;
todo.title = this.beforeEditCache;
},
removeCompleted: function() {
this.todos = filters.active(this.todos);
}
},
directives: {
"todo-focus": function(el, binding) {
if (binding.value) {
el.focus();
}
}
},
created() {
this.todos = todoStorage.fetch();
}
}).$mount("#todoapp");
})(window);

開頭定義了三個客戶端路由對映的處理器函數,會依據不同的路由選擇返回不同條件的數據。

接著是 Vue.js 實例,watch 屬性監聽 Vue 實例的資料屬性(data)的變化,這理監聽的是 todos 屬性,當 todos 有異動就會觸發 watch 處理器(handler) todoStorage.save, 將資料存儲到 localStorage。這裡有一個 deep 屬性,預設值是 false,雖然會監聽 data 中屬性的變化, 但如果是一個物件,它只會監聽物件引用值的變化,當屬性為 true 時它才會監聽物件的內容變動,在此範例,這才是我們所要的。

接下來是幾個 computed 屬性,在 Vue 中,computed 的屬性可以被視為像是 data 一樣,可以讀取和設值,因此在 computed 中可以分成 getter(讀取)和 setter(設值),在沒有寫 setter 的情況下,computed 預設只有 getter ,也就是只能讀取,不能改變設值。filteredTodos 與 remaining 就是只有預設的 getter; allDone 則有 getter 與 setter,當在 HTML Template 中單向綁定 { { allDone } } 時會觸發 getter,使用 v-model=”allDone” 雙向綁定時則會觸發 getter 與 setter。這裡的 getter 會返回一個布爾值,setter 會依據傳入的引數改變所有待辦項目的 todo.completed 屬性。它會用在待辦項目條列區上端的 checkbox 元素上,它的值不是 true 就是 false,當值改變時則會觸發 setter。

methods 屬性則都是 todos 的新增、修改與刪除函數,其中要注意的是資料的修改(Update)分為開始修改 editTodo( ) 與結束修改 doneEdit( )。在 editTodo 函數中我們新增了一個屬性 beforeEditCache 用來存儲修改之前的待辦事項字符串(todo.title),這可以在當使用者捨棄修改時(cancelEdit)時回覆修改前的字符串(rollback),在這裡也就是當使用者在修改中途時按下 Esc 鍵。 而 editedTodo 屬性則可以判斷目前正在修改的待辦項目。

directives 是 Vue.js 提供的一種機制,可以將數據的變化映射為 DOM 行為,這理就是當我們對待辦事項雙擊修改時,將游標聚焦在該要被修改的項目上。指令(Directive)所定義的函數稱為鉤子函數(Hook Function),這裡所傳入的參數 el 是指令所綁定的元素,可用來直接操作 DOM,binding 是一個物件,包含一些屬性,其中 value 屬性绑定指令(Directive)的值,例如在此範例中,v-todo-focus=”todo == editedTodo” 中,binding.value 绑定值將會是 true 或者是 false,所以會將游標聚焦在被修改的項目(editedTodo)上。

created( ) 則是一個 Vue.js 的生命週期鉤子,可以用來在一個 Vue.js 實例被創建之後執行程式碼。這裡只是從存儲中抓取資料。

以下則是 index.html 文件:

index.html
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Todo List</title>
<link rel="stylesheet" href="stylesheets/bootstrap.min.css" />
<link rel="stylesheet" href="stylesheets/app.css" />
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="todoapp" class="container">
<h1 class="text-center">Todos</h1>
<!-- What needs to be done? -->
<input
type="text"
class="form-control"
style="font-size: 1.5em;"
autofocus
autocomplete="off"
placeholder="What needs to be done?"
v-model="newTodo"
@keyup.enter="addTodo"
/>
<div v-show="todos.length">
<!-- Mark all as completed -->
<ul class="list-group list-group-flush">
<li class="list-group-item mb-0 pb-0 border-bottom border-warning">
<input
id="toggle-all"
type="checkbox"
class="form-check-input"
v-model="allDone"
/>
<label for="toggle-all" class="text-primary mb-0"></label>
</li>
<!-- todos list -->
<li
class="list-group-item pb-0"
v-for="todo in filteredTodos"
:key="todo.id"
:class="{completed: todo.completed}"
>
<div v-show="todo !== editedTodo">
<input
type="checkbox"
class="form-check-input"
v-model="todo.completed"
/>
<label @dblclick="editTodo(todo)">{{ todo.title }}</label>
<button
class="btn text-danger float-right"
@click="removeTodo(todo)"
>
X
</button>
</div>
<!-- edit todo -->
<input
v-show="todo == editedTodo"
type="text"
class="form-control"
v-model="todo.title"
v-todo-focus="todo == editedTodo"
@blur="doneEdit(todo)"
@keyup.enter="doneEdit(todo)"
@keyup.esc="cancelEdit(todo)"
/>
</li>
</ul>
</div>
<!-- routes -->
<div v-show="todos.length" class="bg-light mt-2 pt-2 pb-2">
<div class="d-flex flex-row">
<h6 class="pt-2 mr-3">
<strong v-text="remaining"></strong>
{{ pluralize("item", remaining) }} left
</h6>
<ul class="nav justify-content-center mr-auto">
<li class="nav-item">
<a
href="#/all"
class="nav-link"
:class="{active: visibility == 'all'}"
>All</a
>
</li>
<li class="nav-item">
<a
href="#/active"
class="nav-link"
:class="{active: visibility == 'active'}"
>Active</a
>
</li>
<li class="nav-item">
<a
href="#/completed"
class="nav-link"
:class="{active: visibility == 'completed'}"
>Completed</a
>
</li>
</ul>
<button
class="btn btn-light btn-sm text-primary"
@click="removeCompleted"
v-show="todos.length > remaining"
>
Clear completed
</button>
</div>
</div>
</div>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
<script src="javascripts/director.min.js"></script>
<script src="javascripts/vue.min.js"></script>
<script src="javascripts/store.js"></script>
<script src="javascripts/app.js"></script>
<script src="javascripts/routes.js"></script>
</body>
</html>

這裡的 index.html 就不詳細解說了,都是一些 HTML 與 Vue.js 的基本,可以對照 app.js 中的功能。

最後就用 NW.js 啟動應用程式:

1
npm start

試著輸入一些資料,關閉應用程式,再啟動,資料是否是關閉前的資料,這裡沒有使用任何資料庫,只用純 Web 技術建立了一個桌面應用系統。

最後可以將它打包成實際的桌面應用程式,如果我們直接在專案目錄下執行 nwbuild 打包工具,這將會連 node_modules 目錄都打包進去,我們所需要的程式碼與資源都在 app 目錄下,我們只需要打包 app 目錄就可以了,但是 app 目錄下還少一個 package.json 文件,這是 NW.js 啟動時必須的設定檔,得將它一起打包進來,否則無法啟動 NW.js,所以得從專案根目錄複製一份 package.json 到 app 目錄下,我們還需稍做修改:

"app/package.json"
1
2
3
4
5
6
7
8
9
10
{
"name": "todo-nwjs",
"version": "1.0.0",
"description": "",
"main": "index.html",
"window": {
"width": 600,
"height": 450
}
}

屬性 main 的路徑需要修改,因 index.html 目前與 package.json 同目錄,其它則只留下 window 屬性設定一些 NW.js 起始的設定。

現在可以打包程式碼了,從專案目錄下執行 nwbuild。

1
nwbuild app/ -o ./build -p win64

打包完成後你可以在專案目錄下找到 build/todo-nwjs/win64/todo-nwjs.exe,現在就可以直接執行此執行檔了。

Web 應用程式的跨平台運作特性,減低了一些應用開發的負擔。但是隨著多樣化的移動設備應用,Web 應用程式存在一些限制和挑戰,例如,

  • 網路不是一直可用的。當你在火車上或者在隧道裡的時候,就可能沒有網路,你的 Web 應用也會因此中斷,這對於使用者來說可能造成非常多的困擾。
  • Web 流覽器本身也都有一些安全策略,因此對於本機的軟硬體資源也都有限制。

基於這些理由,原生的桌面應用(Desktop)需求也有其存在的優勢,但是開發這些原生應用需要精通不同的技術,進入的門檻複雜。因此過去有類似 Apache Cordova 這類的開發平台,應用現有的 Web 開發技術來開發移動設備的原生應用。最近 的 PWA 架構,也在試圖使用 Web 技術,來取得像原生應用的優點。

JavaScript 生態圈則有 NW.js 與 Electron,試圖將現有的 Web 技術應用在桌面應用系統(Desktop Application)上,寫一份程式碼就可以在 Mac、Windows 及 Linux 上建置及執行。

NW.js 和 Electron

NW.js 是 Node.js 第一個桌面應用開發框架,但是近年來 Electron 的發展比較快速,風頭蓋過了 NW.js。兩者都是來自於同一個創造者,但是兩者在內部架構上採用不一樣的策略。以流行度與發展趨勢來看,Electron 似乎領先 NW.js。不過也有人更喜歡 NW.js,因為相對而言,NW.js 在代碼運行和應用加載方面比較簡單,而且它還支援像 Google 的 Chromebook 平台。

NW.js

簡單來說,NW.js 是一個框架,它支持用 HTML、CSS 和 JavaScript 來構建桌面應用(Desktop Application)。它整合 Node.js 與瀏覽器中的 WebKit 瀏覽器引擎,因此開始的命名為 node-webkit。

通過整合 Node.js 與 WebKit,不僅可以在應用視窗內載入 HTML、CSS 與 JavaScript 文件,還可以通過 JavaScript API 和作業系統進行互動。通過這個 JavaScript API 可以控制視窗的視覺元素,例如,視窗大小
、工具條以及菜單項目,而且還可以訪問本機文件系統與其它資源,這些都是 Web 應用無法做到的。

我們還是由 Hello Tainan 開始。

NW.js Hello Tainan

首先先要安裝 NW.js,開啟一個終端視窗,使用本機管理者身份將 nw 安裝成全局(global)套件,這樣每個專案就可以共享。

install NW.js
1
npm install -g nw

這個安裝會花一些時間!我大該花了 10 分鐘。

新建一個資料夾 hello-tainan-nwjs,我們要在此建立一個 NW.js 專案。開啟 VSCode 選擇專案資料夾,然後開啟一個終端視窗,初始化專案:

init
1
npm init -y

在專案目錄下新建一個子目錄 app,我們會將程式碼放在這個目錄中,在這個 app 目錄下新建一個 index.html 文件。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Hello Tainan</title>
</head>
<body>
<h1>Hello Tainan!</h1>
<button onclick="sayHello()">Say Hello</button>
<script src="javascripts/main.js"></script>
</body>
</html>

在 app 目錄下再開一個子目錄 javascripts 來擺放 JavaScript 程式檔,在 javascripts 目錄下建立 main.js 程式檔,我們會將主要的程式碼放在這個文件中,目前裡面只是一個簡單的 sayHello( ) 函數。

app/javascripts/main.js
1
2
3
function sayHello () {
alert("Hello Tainan, 台南!");
}

修改專案目錄下的 package.json 的 main 屬性,將其值指向 “app/index.html”,這會是我們專案的入口點:

package.json
1
2
3
4
5
6
7
8
{
"name": "hello-tainan-nwjs",
"version": "1.0.0",
"description": "",
"main": "app/index.html",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

從專案目錄下開啟終端視窗啟動 NW.js

1
nw .

你將會看到如下的樣子。

如果單擊螢幕上的 Say Hello 按鈕,會彈出一個寫著 “Hello Tainan, 台南!” 的警示視窗。

如果從 Chrome 流覽器直接打開 index.html 文件檔,也會看到同樣的界面,單擊按鈕也會看到同樣的結果。

這就是關鍵,代碼不需要修改,你就可以直接將網站的 HTML 頁面轉為 NW.js 開發的桌面應用。

我們來上一點妝扮,在專案目錄下開啟一個終端視窗安裝 bootstrap。

1
npm install bootstrap --save

從 node_modules/bootstrap/dist/css/ 將 bootstrap.min.css 複製到 app/stylesheets 目錄下。並將 bootstrap.min.css 加入 index.html,並在一些 DOM 元素加上一些 bootstrap 的妝扮。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
...
<link rel="stylesheet" href="stylesheets/bootstrap.min.css" />
</head>
<body>
<div class="container text-center">
<h1>Hello Tainan!</h1>
<button class="btn btn-primary" onclick="sayHello()">Say Hello</button>
</div>
<script src="javascripts/main.js"></script>
</body>
</html>

將 Vue.js 也加進來:

1
npm install vue --save

將 node_modules/vue/dist 下的 vue.min.js 複製到 app/javascripts 目錄下, 將 vue.min.js 加入 index.html,再來就需要修改 main.js 產生 Vue 實例,我們也會透過 REST API 從遠端抓取一些資料。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<body>
<div id="app" class="container text-center">
<h1>{{ hello }}</h1>
<button class="btn btn-primary" onclick="sayHello()">Say Hello</button>
<table class="table table-dark mt-3">
<tr v-for="e in employees">
<td>{{ e.empno }}</td>
<td>{{ e.ename }}</td>
<td>{{ formatDate(e.hiredate) }}</td>
<td>{{ e.sal }}</td>
<td>{{ e.comm }}</td>
<td>{{ e.deptno }}</td>
</tr>
</table>
</div>
<script src="javascripts/vue.min.js"></script>
<script src="javascripts/main.js"></script>
</body>

修改 main.js 讓它多作點事情:

app/javascripts/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
function sayHello () {
alert("Hello Tainan, 台南!");
}

const vm = new Vue({
data: {
hello: "Hello Tainan, 台南!",
employees: []
},
methods: {
formatDate(date) {
let newDate = new Date(date);
return newDate.toISOString().slice(0,10);
}
},
created() {
fetch("http://xxxxx.xxx.com.tw:8080/lxxx/xxxxdemo/employees/")
.then(r => r.json())
.then(result => {
this.employees = result.items
});
}
}).$mount("#app");

啟動 NW.js,另外也從瀏覽器直接打開 index.html 檔。

背景是從流覽器執行的畫面,前景則是 NW.js。兩者的程式碼都來自同一份,完全沒甚麼不同!

Electron

Electron 和 NW.js 的區別之一就是整合 Chromium 和 Node.js 的方式不同。NW.js 維護一個共享的 JavaScript 上下文,而 Electron 有多個獨立的 JavaScript 上下文,一個負責啟動運行的視窗 (main),另外一個負責具體的應用視窗 (renderer)。所以在啟動時也有很大的區別,NW.js 通常使用 HTML 作為入口文件,而 Electron 使用的是 JavaScript 文件。

Electron Hello Tainan

一樣要先安裝 Electron,開啟一個終端視窗,使用本機管理者身份:

install Electron
1
npm install -g electron

這也會花一些時間。

新建一個資料夾 hello-tainan-electron,我們要在此建立一個 Electron 專案。開啟 VSCode 選擇專案資料夾,然後開啟一個終端機視窗,初始化專案,除此之外,專案本身也需安裝 electron,這也會花一些時間:

init project
1
2
npm init -y
npm install electron --save

在專案目錄下新建一個子目錄 app,我們會將程式碼放在這個目錄中,在這個 app 目錄下新建一個 index.html 檔與 main.js 程式碼文件。在 app 目錄下另外開兩個子目錄 javascripts 與 stylesheets。

以下則是 app 目錄下的 index.html 與 main.js 文件檔。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Hello Tainan</title>
</head>
<body>
<h1>Hello Tainan!</h1>
<button onclick="sayHello()">Say Hello</button>
<script src="javascripts/app.js"></script>
</body>
</html>
app/main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
const { app, BrowserWindow } = require('electron');

let mainWindow = null;

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

app.on('ready', () => {
mainWindow = new BrowserWindow();
mainWindow.loadURL(`file://${__dirname}/index.html`);
mainWindow.on('closed', () => mainWindow = null);
});

main.js 是 Electron 的入口點,使用 mainWindow.loadURL( ) 將 index.html 加載進來,這個 index.html 與 NW.js 的 index.html 內容是一樣的。

再來要加入我們自己的程式碼,新建一個 JavaScript 文件 app/javascripts/app.js 放我們的應用程式碼,目前只有一個函數 sayHello( )。

app/javascripts/app.js
1
2
3
function sayHello() {
alert("Hello Tainan, 台南!");
}

接下來一樣要修改專案目錄下的 package.json 的 main 屬性,更改專案的入口點,這與 NW.js 完全不同,是 JavaScript 文件:

package.json
1
2
3
4
5
6
7
8
{
"name": "hello-tainan-electron",
"version": "1.0.0",
"description": "",
"main": "app/main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},

從專案目錄下開啟終端視窗啟動 Electron

1
electron .

你將會看到如下的樣子。

如果單擊螢幕上的 Say Hello 按鈕,會彈出一個寫著 “Hello Tainan, 台南!” 的警示視窗。

如果從 Chrome 流覽器直接打開 index.html 文件檔,也會看到同樣的界面,單擊按鈕也會看到同樣的結果。

同樣,把 bootstrap 與 Vue.js 加進來。

1
2
npm install bootstrap --save
npm install vue --save

分別將 node_modules/bootstrap/dist/css/bootstrap.min.css 與 node_modules/vue/dist/vue.min.js 複製到 app/stylesheets 與 app/javascripts 目錄

修改 app/index.html 與 app/javascripts/app.js:

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<body>
<div id="app" class="container text-center">
<h1>{{ hello }}</h1>
<button class="btn btn-primary" onclick="sayHello()">Say Hello</button>
<table class="table table-dark mt-3">
<tr v-for="e in employees">
<td>{{ e.empno }}</td>
<td>{{ e.ename }}</td>
<td>{{ formatDate(e.hiredate) }}</td>
<td>{{ e.sal }}</td>
<td>{{ e.comm }}</td>
<td>{{ e.deptno }}</td>
</tr>
</table>
</div>
<script src="javascripts/vue.min.js"></script>
<script src="javascripts/app.js"></script>
</body>
app/javascripts/app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function sayHello() {
alert("Hello Tainan, 台南!");
}

const vm = new Vue({
data: {
hello: "Hello Tainan, 台南!",
employees: []
},
methods: {
formatDate(date) {
let newDate = new Date(date);
return newDate.toISOString().slice(0, 10);
}
},
created() {
fetch("http://xxxxx.xxx.com.tw:8080/lxxx/xxxxdemo/employees/")
.then(r => r.json())
.then(result => {
this.employees = result.items;
});
}
}).$mount("#app");

啟動 Electron

1
electron .

另外也從瀏覽器直接打開 index.html 檔。

背景是從流覽器執行的畫面,前景則是 Electron。兩者的程式碼也都來自同一份,完全沒甚麼不同!

我們常用的 Visual Studio Code 程式碼編輯器就是使用 Electron 開發的。

微軟 Metro UI

隨著 Window 8 和 Surface 平板的發佈, 微軟對其 UI 做了很大的改變,引入了一套新設計風格的 UI,名為 Metro。帶來的改變不僅是重新設計了元素的樣式,還包括了應用佈局和結構的改變。有人基於這種 Metro 風格的規範,做了一個名為 Metro UI CSS 的 CSS 框架,它可以讓開發者建出符合 Metro 規範的基於 HTML 的應用樣式。

將前面範例使用的 bootstrap 用 Metro 取代,可做出像微軟桌面應用系統風格的畫面:

Offline First 本機存儲

最後我們要建立一個極簡單的備忘便條應用,就像可以完全離線操作的桌面應用系統。我們這裡使用較簡單的 NW.js 框架。

首先建立專案資料夾 let-me-remember,然後切換到專案目錄下,初始化一個 npm 專案。

1
2
cd let-me-remember
npm init -y

我們需要修改 package.json 來啟動應用程式,並定義起始視窗的大小。

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "let-me-remember",
"version": "1.0.0",
"description": "",
"main": "app/index.html",
"window": {
"width": 480,
"height": 320,
"frame": false,
"toolbar": false
},
"scripts": {
"start": "nw .",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

這裡我們將應用程式的入口點 main 屬性改為 app/index.html,並新增了 window 屬性,定義了起始視窗的大小,也去除了視窗的邊框,讓它看起來就像螢幕上的便條簽。scripts 新增了 start 屬性,這樣我們可以使用 npm 啟動專案。

在專案目錄下建一子目錄 app 來放我們的程式碼,並在 app 目錄下再建兩個子目錄 stylesheets 與 javascripts 來擺放 CSS 與 JavaScript 文件。首先是 app 目錄下的 index.html 文件。

app/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="stylesheets/app.css">
<title>備忘便條</title>
</head>
<body>
<div id="close" onclick="process.exit(0)">x</div>
<textarea rows="10" onKeyUp="saveNotes();"></textarea>
<script src="javascripts/app.js"></script>
</body>
</html>

HTML 文件很簡單,使用 textarea 元素來存放用戶輸入的便條內容。還用了一個 id 為 close 的 div 元素,當它被單擊的時候,會觸發退出應用的流程。注意這裡,我們可以從 HTML 中的 JavaScript 上下文中直接調用 Node.js 的 process 全局變數

另外我們要新增 app.css 與 app.js 兩個文件,這分別位於 stylesheets 與 javascripts 目錄下。

"stylesheets/app.css"
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
body {
background: #ffe15f;
color: #694921;
padding: 1em;
}

textarea {
font-family: "Hannotate SC", "Hanzipen SC", "Comic Sans", "Comic Sans MS";
outline: none;
font-size: 14pt;
border: none;
width: 100%;
height: 100%;
background: none;
}

#close {
cursor: pointer;
position: absolute;
top: 8px;
right: 10px;
text-align: center;
font-family: "Helvetica Neue", "Arial";
font-weight: 400;
}
"javascripts/app.js"
1
2
3
4
5
6
7
8
9
10
11
12
function initialize() {
let notes = window.localStorage.getItem('notes');
if (!notes) notes = "不要忘了...";
window.document.querySelector("textarea").value = notes;
}

function saveNotes() {
let notes = window.document.querySelector("textarea").value;
window.localStorage.setItem("notes", notes);
}

window.onload = initialize;

我們使用 HTML5 的 localStorage API,可以在應用中實現數據持久化,並且可以讓保存數據發生在後台,用戶完全感覺不到。

現在可以啟動應用程式了。

1
npm start

畫面看起來就像直接貼在螢幕上的便條籤。隨意打入一些備忘內容,內容就會被保存。當重新打開應用時,此前保存的內容會顯示在便條中。

最後我們需要將此應用直接打包成桌面應用的可執行檔。我們要使用一個 nw-builder 的套件,首先安裝它,如果要安裝成全局套件需要系統管理者權限。

"nw-builder install"
1
npm install nw-builder -g

目前 nw-builder 可以打包的平台有 win32, win64, osx32, osx64, linux32, linux64,這裡我們要打包成
win64 的應用。

1
nwbuild . -o ./build -p win64

第一次執行 nwbuild 會花一些時間,它需要從網路分別下載不同平台的打包工具 nwjs sdk 來執行打包的工作。執行完後會在專案目錄下產生一個 build 目錄,目錄下會有一個專案名稱的子目錄,裡面又會分不同的作業系統目錄。我們可以在 win64 目錄下找到 Windows 版本的 let-me-remember.ext 文件,現在只要直接打開此執行檔,就可以啟動我們的應用程式了。

Electron 版本的備忘便條應用就自己試試了。

從 RabbitMQ 接收資料,只顯示在終端視窗上似乎沒甚麼意思,接下來我們要把它直接寫入我們熟悉的 Oracle 資料庫。因此我們先要了解 C# 與關連式資料庫的運作方式。

首先要學習如何連接(connection)資料庫,以及關閉與資料庫的連接。如何使用查詢,如何添加和更新紀錄。如果你已了解 Oracle Data Provider for .NET (ODP.NET)的操作,則可以跳過 Oracle 範例,直接到後面的 “整合 RabbitMQ 消費訊息與 Oracle”。

ADO.NET 之前使用 OLEDB 與 ODBC 附帶不同的資料庫提供程式(Data Provider),一個提供程式用於 SQL Server;另一個提供程式用於 Oracle。OLEDB 技術已不再獲得支援,所以這個提供程式不應該再用於新的應用程式。對於 Oracle 資料庫,微軟提供的程式也不再使用,因為來自 Oracle 所提供的程式能更好的滿足需求。我們這裡要使用的 Oracle Data Provider for .NET (ODP.NET) Core 是 Oracle.ManagedDataAccess.Core

Oracle 範例

我們要在原先的 amqp-example 解決方案之下再開一個 OracleSample 專案來學習 Oracle 資料庫的操作,切換到你的 amqp-example 解決方案目錄:

1
dotnet new console --name OracleSample

然後在 OracleSample 專案目錄下安裝 NuGet Oracle 套件。

Oracle Data Provider for .NET Core
1
2
cd OracleSample
dotnet add package Oracle.ManagedDataAccess.Core

安裝成功後,你可在 OracleSample 專案目錄下的檔案 OracleSample.csproj 發現新的資料紀錄。

1
2
3
<ItemGroup>
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="2.19.31"/>
</ItemGroup>

連線 Connection

我們就可以開始了。首先是資料庫的連線。在 OracleSample 目錄下開一個目錄 src 來放我們的範例,然後在 src 目錄下建立一個新的 C# 程式檔 ConnectionSample.cs。

src/ConnectionSample.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using Oracle.ManagedDataAccess.Client;

namespace OracleSample.src
{
public class ConnectionSample
{
public static void OpenConnection()
{
string connectionString = "User Id=xxxx;Password=xxxxxxx;Data Source=10.11.xx.xxx:1522/xxxx.xxx.com.tw;";

var connection = new OracleConnection(connectionString);
connection.Open();
Console.WriteLine("Connection opened");
Console.WriteLine("Press [enter] to continue");
Console.ReadLine();
connection.Close();
}
}
}

在第 16 行我們加一個 Console.ReadLine() 讓程式暫停,我們才能從資料庫觀察連線狀況。

回到 OracleSample 專案的根目錄,修改一下 Program.cs 以便測試,記得要將 OracleSample.src 命名空間加進來:

Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
using OracleSample.src;

namespace OracleSample
{
class Program
{
static void Main(string[] args)
{
ConnectionSample.OpenConnection();
}
}
}

在專案目錄下開啟一個終端視窗來啟動程式:

1
dotnet run

讓它暫停在終端機上,到資料庫觀察一下連線:

1
2
3
USERNA  PID  SID MACHINE         SPID     STATUS   SERIAL# MODULE               PROGRAM                      CLIENT_INFO
------ ---- ---- --------------- -------- -------- ------- -------------------- ---------------------------- ------------------
DEMO 53 90 XXX\7x0x0x4xP1 428 INACTIVE 3289 OracleSample.dll OracleSample.dll

OracleConnection 類實現了 IDisposable 介面,其中包含 Dispose() 方法和 Close() 方法。這兩個方法的功能相同,都是釋放連線。這樣,就可以使用 using 語句來自動關閉資料庫連線。

這個 ConnectionSample 範例使用定義好的連接字符串打開資料庫連接,一旦打開連線就可以對資料庫執行命令,完成後關閉連線。

直接在程式碼設定連接字符串,不是一個很適合的方式,最好是將它設定在一個配置檔案中。在 .NET Core 中,配置文件可以是 JSON 或 XML 格式,或從環境變數中讀取。這範例我們使用 JSON 格式。

首先安裝要兩個 NuGet 套件:

1
2
3
cd OracleSample
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json

在 OracleSample 專案根目錄下建立 config.json 配置檔:

config.json
1
2
3
4
5
6
7
{
"Data": {
"DefaultConnection": {
"ConnectionString": "User Id=demo;Password=demo426;Data Source=10.11.xx.xxx: 1522/lxxx.xxx.com.tw;"
}
}
}

然後我們要修改 ConnectionSample.cs 從 config.json 配置檔讀取資料庫連線資訊:

src/ConnectionSample.cs
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
using System;
using Oracle.ManagedDataAccess.Client;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace OracleSample.src
{
public class ConnectionSample
{
public static void OpenConnection()
{
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("config.json");
IConfiguration config = configurationBuilder.Build();
string connectionString = config["Data:DefaultConnection:ConnectionString"];

var connection = new OracleConnection(connectionString);
connection.Open();
Console.WriteLine("Connection opened");
Console.WriteLine("Press [enter] to continue");
Console.ReadLine();
connection.Close();
}
}
}

程式碼似乎變的複雜一些,但是這是必要的,這可避免為往後增加一些技術債。

這裡使用 ConfigurationBuilder 創建一個實例,AddJsonFile 擴展方法添加 JSON 文件 config.json,從這個文件讀取配置信息。調用 ConfigurationBuilder 的 Build() 方法,從所添加的配置文件中建構配置,返回一個實現了 IConfiguration 介面的物件。這樣就可以檢索配置值。

Pooling

我們可以在設定連接字符串中直接設定連接池(connection pool)。選項 Pooling 設置為 false,會禁用連接池; 預設是啟用的: Pooling=true。 Min Pool Size 和 Max Pool Size 允許配置池中的連接數。預設情況下,Min Pool Size 的值為 1,Max Pool Size 的值為 100。Connection Lifetime 定義了連線在釋放前在池中保持不活躍狀態的時間。

命令 Command

連線到資料庫後就可以針對資料庫執行命令,就是要在資料庫上執行包含 SQL 語句的文本字符串。命令也可以是一個存儲過程 (Stored Procedure)。

把 SQL 子句作為一個參數傳遞給 Command 類的建構函式,就可以建構一條命令。在 OracleSample/src 目錄下建立一個新的程式檔 CommandSample.cs:

src/CommandSample.cs
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
using System;
using Oracle.ManagedDataAccess.Client;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace OracleSample.src
{
public class CommandSample
{
public static void CreateCommand()
{
using (var connection = new OracleConnection(GetConnectionString()))
{
string sql = "SELECT empno, ename, hiredate, sal, deptno From emp";
var command = new OracleCommand(sql, connection);
connection.Open();
//...
}
}
private static string GetConnectionString()
{
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("config.json");
IConfiguration config = configurationBuilder.Build();
string connectionString = config["Data:DefaultConnection:ConnectionString"];
return connectionString;
}
}
}

這裡我們將讀取配置檔獨立在一個靜態方法 GetConnectionString() 中,讓程式模組化一些。OracleCommand 命令的建構,也可以透過 OracleConnection 的 CreateCommand( ) 方法,把 SQL 語句賦予 CommandText 屬性:

src/CommandSample.cs
1
2
OracleCommand command = connection.CreateCommand();
command.CommandText = sql;

命令通常需要參數。例如,下面的 SQL 語句需要一個 deptno 參數。不要試圖使用字符串連接來建立參數,它經常被用於 SQL 注入(SQL injection)攻擊。相反的,應使用 ODP.NET 的參數特性,OracleParameter 物件會抑制這種攻擊:

src/CommandSample.cs
1
2
3
4
5
string sql = "SELECT empno, ename, hiredate, sal, deptno From emp where deptno = :deptno";
var command = new OracleCommand(sql, connection);
command.BindByName = true;
OracleParameter deptno = new OracleParameter("deptno", 10);
command.Parameters.Add(deptno);

定義好命令後,就需要執行它。執行語句有許多方式,這取決於要從命令中返回甚麼數據。OracleCommand 類提供了下述可執行的命令:

  • ExecuteReader() : 執行命令,返回一個類型化的 IDataReader。
  • ExecuteNonQuery() : 執行命令,但不返回任何結果。
  • ExecuteScalar(): 執行命令,返回結果集中第一行第一列的值。

ExecuteReader( ) 方法

我們分別來看看這三個方法,在 src 目錄下產生 ExecuteSample.cs,首先看 ExecuteReader(),ExecuteReader( ) 方法執行命令,並返回一個 DataReader 物件,返回的物件可以用於遍歷返回的紀錄。

src/ExecuteSample.cs
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
using System;
using System.IO;
using Oracle.ManagedDataAccess.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace OracleSample.src
{
public class ExecuteSample
{
public static void ExecuteReader(int deptnoParameter)
{
string sql = "SELECT empno, ename, hiredate, sal, comm, deptno From emp where deptno = :deptno order by empno";

try
{
using (var connection = new OracleConnection(GetConnectionString()))
using (var command = new OracleCommand(sql, connection))
{
command.BindByName = true;

OracleParameter deptnoBind = new OracleParameter("deptno", OracleDbType.Int32);
deptnoBind.Value = deptnoParameter;
command.Parameters.Add(deptnoBind);

connection.Open();
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
short empno = reader.GetInt16(0);
string ename = reader.GetString(1);
DateTime hiredate = reader.GetDateTime(2);
float sal = reader.GetFloat(3);
float? comm = reader.IsDBNull(4) ? (float?)null : reader.GetFloat(4);
short deptno = reader.GetInt16(5);

Console.WriteLine($"{empno,4} {ename,-10} {hiredate:yyyy-MM-dd} {sal,7:F2} {comm,7:F2} {deptno,2}");
}
}
}
}
catch (OracleException ex)
{
Console.WriteLine(ex.Message);
}
}
private static string GetConnectionString()
{
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("config.json");
IConfiguration config = configurationBuilder.Build();
string connectionString = config["Data:DefaultConnection:ConnectionString"];
return connectionString;
}
}
}

這裡建構 OracleParameter 的方式與上個範例有些微的不同,我們定義了 deptno 參數的型態 OracleDbType.Int32。

當調用 OracleCommand 物件的 ExecuteReader() 方法時,返回 OracleDataReader 物件。這裡要注意,OracleDataReader 使用後需要銷毀。

從數據讀取器 reader 中讀取記錄時,在 while 循環中調用 Read() 方法。Read() 方法的第一次調用時會將游標 (cursor) 移動到返回的第一筆記錄上,再次調用時游標定位到下一筆記錄,如果沒有記錄了,Read() 方法就返回 false。

訪問列(column)的值時,調用不同的 GetXXX() 方法讀取欄位的值,傳遞給這些方法的索引對應於用 SQL SELECT 語句檢索的列(column),因此即使資料庫的結構有變化,該索引也保持不變 (不要用 SELECT *)。在強類型化的 GetXXX() 方法中,需要注意從資料庫返回的 null 值,GetXXX () 方法會拋出一個異常。為了避免異常,使用 C# 條件語句 ?: 和 OracleDataReader.IsDBNull() 方法,檢查值是否為 null,如果是,就把 null 分配給可空的 (Nullable Type) decimal? 變數 comm。

可空類型 Nullable Types

引用類型的變數可以為空 (null),而值類型的變數不能。把 C# 類型映射到資料庫類型時,這是一個特殊的問題。資料庫中的值可以為 null。C# 的解決方案就是: 可空類型 Nullable Types。可空類型是可以為 null 的值類型。可空類型只需要在類型的後面添加”?”。

在下面的程式碼片段中,x1 是一個普通的 int,x2 是一個可以為空的 int,所以可以把 null 分配給 x2:

1
2
int x1 = 1;
int? x2 = null;

修改 OracleSample 專案下的 Program.cs 測試:

Program.cs
1
2
3
4
static void Main(string[] args)
{
ExecuteSample.ExecuteReader(10);
}

對於 OracleDataReader,可以不使用 GetXXX 方法,而可以使用無類型的索引器返回一個物件,因此,需要轉換為相應的類型:

src/ExecuteSample.cs
1
2
3
4
5
6
short empno = (short)reader[0];
string ename = (string)reader[1];
DateTime hiredate = (DateTime)reader[2];
float sal = (float)reader[3];
float? comm = reader.IsDBNull(4) ? (float?) null : (float?)reader[4];
short deptno = (short)reader[5];

OracleDataReader 的索引器還允許使用 string 而不是 int 傳遞列名 (column name)。在這些不同的選項中,這是最慢的方法,但它的可讀性最佳,與發出服務調用所需的時間相比,訪問索引器所需的額外時間其實可以忽略不計。

src/ExecuteSample.cs
1
2
3
4
5
6
short empno = (short)reader["empno"];
string ename = (string)reader["ename"];
DateTime hiredate = (DateTime)reader["hiredate"];
float sal = (float)reader["sal"];
float? comm = reader.IsDBNull(4) ? (float?)null : (float?)reader["comm"];
short deptno = (short)reader["deptno"];

強烈型的語言有時在映對資料庫的資料型態實在是很困擾,既然是從資料庫返回的資料,不管它是否為 null,總是會有初始值,我們就直接使用 var 類型推斷。 要記得使用 var 類型推斷,一定要賦予初始值,否則它無法推斷:

src/ExecuteSample.cs
1
2
3
4
5
6
var empno = reader["empno"];
var ename = reader["ename"];
var hiredate = reader["hiredate"];
var sal = reader["sal"];
var comm = reader["comm"];
var deptno = reader["deptno"];

你會不會好奇,當 comm 欄位是 null 時,程式是不是會當掉? 或者變數 hiredate 的資料型態是甚麼? 可以使用 GetType() 方法查看它的類別型態。

src/ExecuteSample.cs
1
Console.WriteLine($"{empno.GetType()} {ename.GetType()} {hiredate.GetType()} {sal.GetType()} {comm.GetType()} {deptno.GetType()}");

結果應該是:

1
System.Int16 System.String System.DateTime System.Single System.DBNull System.Int16

ExecuteNonQuery( ) 方法

ExecuteNonQuery() 方法一般用於 UPDATE、INSERT 或 DELETE 語句,其中維一返回值是受影響的紀錄筆數。但如果調用帶輸出參數的存儲過程 (Stored Procedure),該方法就有返回值。

在 ExecuteSample 類中新增一個靜態方法 ExecuteNonQuery( ):

src/ExecuteSample.cs
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
public static void ExecuteNonQuery()
{
var newEmployee = new
{
empno = 9588,
ename = "C#範例",
job = "ANALYST",
mgr = 7566,
hiredate = DateTime.Now,
sal = 23000,
comm = 111,
deptno = 40
};

string sql = "INSERT INTO EMP (empno, ename, job, mgr, hiredate, sal, comm, deptno) " +
"VALUES (:empno, :ename, :job, :mgr, :hiredate, :sal, :comm, :deptno)";

try
{
using (var connection = new OracleConnection(GetConnectionString()))
using (var command = new OracleCommand(sql, connection))
{
command.BindByName = true;

OracleParameter empnoBind = new OracleParameter("empno", OracleDbType.Int32);
empnoBind.Value = newEmployee.empno;
command.Parameters.Add(empnoBind);

OracleParameter enameBind = new OracleParameter("ename", OracleDbType.Varchar2);
enameBind.Value = newEmployee.ename;
command.Parameters.Add(enameBind);

OracleParameter jobBind = new OracleParameter("job", OracleDbType.Varchar2);
jobBind.Value = newEmployee.job;
command.Parameters.Add(jobBind);

OracleParameter mgrBind = new OracleParameter("mgr", OracleDbType.Int32);
mgrBind.Value = newEmployee.mgr;
command.Parameters.Add(mgrBind);

OracleParameter hiredateBind = new OracleParameter("hiredate", OracleDbType.Date);
hiredateBind.Value = newEmployee.hiredate;
command.Parameters.Add(hiredateBind);

OracleParameter salBind = new OracleParameter("sal", OracleDbType.Decimal);
salBind.Value = newEmployee.sal;
command.Parameters.Add(salBind);

OracleParameter commBind = new OracleParameter("comm", OracleDbType.Decimal);
commBind.Value = newEmployee.comm;
command.Parameters.Add(commBind);

OracleParameter deptnoBind = new OracleParameter("deptno", OracleDbType.Int32);
deptnoBind.Value = newEmployee.deptno;
command.Parameters.Add(deptnoBind);

connection.Open();
int rows = command.ExecuteNonQuery();
Console.WriteLine($"{rows} row(s) inserted");
}
}
catch (OracleException ex)
{
Console.WriteLine(ex.Message);
}
}

這裡我們使用匿名類型(Anonymous Types)產生一個 newEmployee 物件。var 與 new 關鍵字一起使用時,可以創建匿名類型。匿名類型只是一個繼承自 Object 且沒有名稱的類。該類的定義從物件初始化器中推斷,類似於隱式類型化的變數。

這個程式碼大部份都是在宣告 OracleParameter 參數,注意它們對應的資料庫類型型態。ExecuteNonQuery() 方法會返回命令所影響的筆數。

可以把 SQL 改成 Upsert,讓生活可以優雅一些:

src/ExecuteSample.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
string sql = "MERGE INTO emp e " +
"USING(select :empno as empno, " +
" :ename as ename, " +
" :job as job, " +
" :mgr as mgr, " +
" :hiredate as hiredate, " +
" :sal as sal, " +
" :comm as comm, " +
" :deptno as deptno " +
" from dual) p " +
"ON(e.empno = p.empno) " +
"WHEN MATCHED THEN " +
"UPDATE SET e.ename = p.ename, " +
" e.job = p.job, " +
" e.mgr = p.mgr, " +
" e.hiredate = p.hiredate, " +
" e.sal = p.sal, " +
" e.comm = p.comm, " +
" e.deptno = p.deptno " +
"WHEN NOT MATCHED THEN " +
" INSERT(e.empno, e.ename, e.job, e.mgr, e.hiredate, e.sal, e.comm, e.deptno) " +
" VALUES(p.empno, p.ename, p.job, p.mgr, p.hiredate, p.sal, p.comm, p.deptno)";

ExecuteScalar( ) 方法

在許多情況下,需要從 SQL 語句返回一個結果,例如要查詢資料表的筆數,或者資料庫伺服器當前的日期時間。ExecuteScalar()方法就可用於這些場合。同樣在 ExecuteSample.cs 中新增一個靜態方法 ExecuteScalar():

src/ExecuteSample.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void ExecuteScalar()
{
string sql = "SELECT count(*) as count From emp";

try
{
using (var connection = new OracleConnection(GetConnectionString()))
using (var command = new OracleCommand(sql, connection))
{
connection.Open();
object count = command.ExecuteScalar();
Console.WriteLine($"counted {count} emp records");
}
}
catch (OracleException ex)
{
Console.WriteLine(ex.Message);
}
}

該方法返回一個物件,根據需要,可以把該物件強制轉換為合適的類型。如果所調用的 SQL 只返回一列(column),則最好使用 ExecuteScalar( ) 方法來檢索這一列。這也適合用於只返回一個值的存儲過程(Stored Procedure)。

整合 RabbitMQ 消費訊息與 Oracle

終於來到最終的目標,將 RabbitMQ 的消費訊息寫入 Oracle 資料庫。

回到 Receive 專案目錄下,首先需要安裝 3 個 NuGet 套件:

1
2
3
4
cd Receive
dotnet add package Oracle.ManagedDataAccess.Core
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json

將 OracleSample 專案目錄下的 config.json 檔案複製到 Receive 專案目錄下。

在 Receive 專案目錄下新建一個子目錄 Services,我們要將一些後端資料庫的服務放在這個目錄下。在 Services 目錄下新建一個 C# 程式檔 OracleDemoMessage.cs:

Services/OracleDemoMessage.cs
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
using System;
using System.IO;
using Oracle.ManagedDataAccess.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;

namespace Receive.Services
{
public class OraDemoMessage
{
public static void Save(string messageParameter, string createdbyParameter)
{
string sql = "INSERT INTO DEMO_MESSAGES (message, createdby) " +
"VALUES (:message, :createdby)";
try
{
using (var connection = new OracleConnection(GetConnectionString()))
using (var command = new OracleCommand(sql, connection))
{
command.BindByName = true;

OracleParameter messageBind = new OracleParameter("message", OracleDbType.Varchar2);
messageBind.Value = messageParameter;
command.Parameters.Add(messageBind);

OracleParameter createdbyBind = new OracleParameter("createdby", OracleDbType.Varchar2);
createdbyBind.Value = createdbyParameter;
command.Parameters.Add(createdbyBind);

connection.Open();
int rows = command.ExecuteNonQuery();
Console.WriteLine($"rowsAffected: {rows} row(s)");
}
}
catch (OracleException ex)
{
Console.WriteLine(ex.Message);
}
}

private static string GetConnectionString()
{
var configurationBuilder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("config.json");
IConfiguration config = configurationBuilder.Build();
string connectionString = config["Data:DefaultConnection:ConnectionString"];
return connectionString;
}
}
}

這裡有一個接受兩個參數的 Save() 靜態方法,這會將資料寫入 Oracle 資料庫的 DEMO_MESSAGES 資料表。

回到 Receive 專案目錄,我們需要修改專案的入口點 Receive.cs 程式,首先要加入 Receive.Services 命名空間:

Receive.cs
1
2
3
4
5
using System;
using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Receive.Services;

再來修改消費者事件監聽器,將消費消息寫入 Oracle 資料庫:

Receive.cs
1
2
3
4
5
6
7
8
9
consumer.Received += (model, e) =>
{
var body = e.Body;
var message = Encoding.UTF8.GetString(body);

Console.WriteLine($" [x] Received {message}");

OraDemoMessage.Save(message, "RabbitMQ dotnetDemoQueue");
};

現在就可以啟動接收器 Receive:

1
2
3
dotnet run

Press [enter] to exit.

開啟另一個終端視窗,切換到 Send 專案目錄下送出一個訊息:

1
2
3
4
5
cd Send
dotnet run

[x] Send 哈囉 from C# send 消息 2019/7/17 下午 03:53:06
Press [enter] to exit.

到 Oracle 資料庫驗證:

SQL
1
2
3
4
5
6
SQL> select * from demo_messages;

ID MESSAGE CREATED CREATEDBY
----- ------------------------------------------------ ---------- ------------------------------

23 哈囉 from C# send 消息 2019/7/17 下午 03:53:06 17-JUL-19 RabbitMQ dotnetDemoQueue

就這樣了,祝好運!