30分鐘上手 Koa2 + MySQL 開發(fā)
?如果您覺得我們寫得還不錯(cuò),記得 「點(diǎn)贊 + 關(guān)注 + 評(píng)論」 三連,鼓勵(lì)我們寫出更好的教程??
?
憑借精巧的“洋蔥模型”和對(duì) Promise 以及 async/await 異步編程的完全支持,Koa 框架自從誕生以來就吸引了無數(shù) Node 愛好者。然而 Koa 本身只是一個(gè)簡(jiǎn)單的中間件框架,要想實(shí)現(xiàn)一個(gè)足夠復(fù)雜的 Web 應(yīng)用還需要很多周邊生態(tài)支持。這篇教程不僅會(huì)帶你梳理 Koa 的基礎(chǔ)知識(shí),還會(huì)充分地運(yùn)用和講解構(gòu)建 Web 應(yīng)用必須的組件(路由、數(shù)據(jù)庫(kù)、鑒權(quán)等),最終實(shí)現(xiàn)一個(gè)較為完善的用戶系統(tǒng)。
起步
Koa 作為 Express 原班人馬打造的新生代 Node.js Web 框架,自從發(fā)布以來就備受矚目。正如 Koa 作者們?cè)?span style="color: rgb(89, 89, 89);">文檔[3]中所指出的:
?Philosophically, Koa aims to "fix and replace node", whereas Express "augments node".(Express 是 Node 的補(bǔ)強(qiáng),而 Koa 則是為了解決 Node 的問題并取代之。)
?
在這一篇文章中,我們將手把手帶你開發(fā)一個(gè)簡(jiǎn)單的用戶系統(tǒng) REST API,支持用戶的增刪改查以及 JWT 鑒權(quán),從實(shí)戰(zhàn)中感受 Koa2 的精髓,它相比于 Express 做出的突破性的改變。我們將選擇 TypeScript[4] 作為開發(fā)語(yǔ)言,數(shù)據(jù)庫(kù)選用 MySQL,并使用 TypeORM[5] 作為數(shù)據(jù)庫(kù)橋接層。
?「注意」
這篇文章不會(huì)涉及 Koa 源碼級(jí)別的原理分析,重心會(huì)放在讓你完全掌握如何去使用 Koa 及周邊生態(tài)去開發(fā) Web 應(yīng)用,并欣賞 Koa 的設(shè)計(jì)之美。此外,「這篇教程比較長(zhǎng)」,如果一杯茶不夠的話可以續(xù)杯~
?
預(yù)備知識(shí)
本教程假定你已經(jīng)具備了以下知識(shí):
JavaScript 語(yǔ)言基礎(chǔ)知識(shí)(包括一些常用的 ES6+ 語(yǔ)法) Node.js 基礎(chǔ)知識(shí),還有 npm 的基本使用,可以參考這篇教程[6]進(jìn)行學(xué)習(xí) TypeScript 基礎(chǔ)知識(shí),只需了解簡(jiǎn)單的類型注解就可以了,可以參考我們的 TypeScript 系列教程[7] *(非必須)*Express 框架基礎(chǔ)知識(shí),對(duì)于體驗(yàn) Koa 之美大有幫助,而且在本文中我們會(huì)大量穿插和 Express 的對(duì)比,可參考這篇教程[8]進(jìn)行學(xué)習(xí)
所用技術(shù)
Node.js:10.x 及以上 npm:6.x 及以上 Koa:2.x MySQL:推薦穩(wěn)定的 5.7 版本及以上 TypeORM:0.2.x
學(xué)習(xí)目標(biāo)
學(xué)完這篇教程,你將學(xué)會(huì):
如果編寫 Koa 中間件 通過 @koa/router實(shí)現(xiàn)路由配置通過 TypeORM 連接和讀寫 MySQL 數(shù)據(jù)庫(kù)(其他數(shù)據(jù)庫(kù)都類似) 了解 JWT 鑒權(quán)的原理,并動(dòng)手實(shí)現(xiàn) 掌握 Koa 的錯(cuò)誤處理機(jī)制
準(zhǔn)備初始代碼
我們已經(jīng)為你準(zhǔn)備好了項(xiàng)目的腳手架,運(yùn)行以下命令克隆我們的初始代碼:
git clone -b start-point https://github.com/tuture-dev/koa-quickstart.git
如果你訪問 GitHub 不流暢,可以克隆我們的 Gitee 倉(cāng)庫(kù):
git clone -b start-point https://gitee.com/tuture/koa-quickstart.git
然后進(jìn)入項(xiàng)目,安裝依賴:
cd koa-quickstart && npm install
?「注意」
這里我使用了
?package-lock.json確保所有依賴版本一致,如果你用yarn安裝依賴出現(xiàn)問題,建議刪除node_modules,重新用npm install安裝。
最簡(jiǎn)單的 Koa 服務(wù)器
創(chuàng)建 src/server.ts ,編寫第一個(gè) Koa 服務(wù)器,代碼如下:
// src/server.ts
import Koa from 'koa';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
// 初始化 Koa 應(yīng)用實(shí)例
const app = new Koa();
// 注冊(cè)中間件
app.use(cors());
app.use(bodyParser());
// 響應(yīng)用戶請(qǐng)求
app.use((ctx) => {
ctx.body = 'Hello Koa';
});
// 運(yùn)行服務(wù)器
app.listen(3000);
整個(gè)流程與一個(gè)基本的 Express 服務(wù)器幾乎完全一致:
初始化應(yīng)用實(shí)例 app注冊(cè)相關(guān)的中間件(跨域 cors和請(qǐng)求體解析中間件bodyParser)添加請(qǐng)求處理函數(shù),響應(yīng)用戶請(qǐng)求 運(yùn)行服務(wù)器
定睛一看,第 3 步中的請(qǐng)求處理函數(shù)(Request Handler)好像不太一樣。在 Express 框架中,一個(gè)請(qǐng)求處理函數(shù)一般是這樣的:
function handler(req, res) {
res.send('Hello Express');
}
兩個(gè)參數(shù)分別對(duì)應(yīng)請(qǐng)求對(duì)象(Request)和響應(yīng)對(duì)象(Response),但是在 Koa 中,請(qǐng)求處理函數(shù)卻只有一個(gè)參數(shù) ctx (Context,上下文),然后只需向上下文對(duì)象寫入相關(guān)的屬性即可(例如這里就是寫入到返回?cái)?shù)據(jù) body 中):
function handler(ctx) {
ctx.body = 'Hello Koa';
}
我的天,Koa 這是故意偷工減料的嗎?先不用急,我們馬上在下一節(jié)講解中間件時(shí)就會(huì)了解到 Koa 這樣設(shè)計(jì)的獨(dú)到之處。
運(yùn)行服務(wù)器
我們通過 npm start 就能開啟服務(wù)器了。可以通過 Curl (或者 Postman 等)來測(cè)試我們的 API:
$ curl localhost:3000
Hello Koa
?「提示」
我們的腳手架中配置好了 Nodemon[9],因此接下來無需關(guān)閉服務(wù)器,修改代碼保存后會(huì)自動(dòng)加載最新的代碼并運(yùn)行。
?
第一個(gè) Koa 中間件
嚴(yán)格意義上來說,Koa 只是一個(gè)中間件框架,正如它的介紹所說:
?Expressive middleware for node.js using ES2017 async functions.(通過 ES2017 async 函數(shù)編寫富有表達(dá)力的 Node.js 中間件)
?
下面這個(gè)表格更能說明 Koa 和 Express 的鮮明對(duì)比:

可以看到,Koa 實(shí)際上對(duì)標(biāo)的是 Connect[10](Express 底層的中間件層),而不包含 Express 所擁有的其他功能,例如路由、模板引擎、發(fā)送文件等。接下來,我們就來學(xué)習(xí) Koa 最重要的知識(shí)點(diǎn):「中間件」。
大名鼎鼎的“洋蔥模型”
你也許從來沒有用過 Koa 框架,但很有可能聽說過“洋蔥模型”,而 Koa 正是洋蔥模型的代表框架之一。下面這個(gè)圖你也許很熟悉了:

不過以個(gè)人觀點(diǎn),這個(gè)圖實(shí)在是太像“洋蔥”了,反而不太好理解。接下來我們將以更清晰直觀的方式來感受 Koa 中間件的設(shè)計(jì)之美。首先我們來看一下 Express 的中間件是什么樣的:

請(qǐng)求(Request)直接依次貫穿各個(gè)中間件,最后通過請(qǐng)求處理函數(shù)返回響應(yīng)(Response),非常簡(jiǎn)單。然后我們來看看 Koa 的中間件是什么樣的:

可以看到,Koa 中間件不像 Express 中間件那樣在請(qǐng)求通過了之后就完成了自己的使命;相反,中間件的執(zhí)行清晰地分為「兩個(gè)階段」。我們馬上來看下 Koa 中間件具體是什么樣的。
Koa 中間件的定義
Koa 的中間件是這樣一個(gè)函數(shù):
async function middleware(ctx, next) {
// 第一階段
await next();
// 第二階段
}
第一個(gè)參數(shù)就是 Koa Context,也就是上圖中貫穿所有中間件和請(qǐng)求處理函數(shù)的綠色箭頭所傳遞的內(nèi)容,里面「封裝了請(qǐng)求體和響應(yīng)體」(實(shí)際上還有其他屬性,但這里暫時(shí)不講),分別可以通過 ctx.request 和 ctx.response 來獲取,以下是一些常用的屬性:
ctx.url // 相當(dāng)于 ctx.request.url
ctx.body // 相當(dāng)于 ctx.response.body
ctx.status // 相當(dāng)于 ctx.response.status
?「提示」
關(guān)于所有請(qǐng)求和響應(yīng)上面的屬性及其別稱,請(qǐng)參考 Context API 文檔[11]。
?
中間件的第二個(gè)參數(shù)便是 next 函數(shù),這個(gè)熟悉 Express 的同學(xué)一定知道它是干什么的:用來把控制權(quán)轉(zhuǎn)交給下一個(gè)中間件。但是它跟 Express 的 next 函數(shù)本質(zhì)的區(qū)別在于,「Koa 的 「「next」」 函數(shù)返回的是一個(gè) Promise」,在這個(gè) Promise 進(jìn)入完成狀態(tài)(Fulfilled)后,就會(huì)去執(zhí)行中間件中第二階段的代碼。
那么我們不禁要問:這樣把中間件的執(zhí)行拆分為兩個(gè)階段,到底有什么好處嗎?我們來通過一個(gè)非常經(jīng)典的例子來感受一下:日志記錄中間件(包括響應(yīng)時(shí)間的計(jì)算)。
實(shí)戰(zhàn):日志記錄中間件
讓我們來實(shí)現(xiàn)一個(gè)簡(jiǎn)單的日志記錄中間件 logger ,用于記錄每次請(qǐng)求的方法、URL、狀態(tài)碼和響應(yīng)時(shí)間。創(chuàng)建 src/logger.ts ,代碼如下:
// src/logger.ts
import { Context } from 'koa';
export function logger() {
return async (ctx: Context, next: () => Promise<void>) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} ${ctx.status} - ${ms}ms`);
};
}
嚴(yán)格意義上講,這里的 logger 是一個(gè)「中間件工廠函數(shù)」(Factory),調(diào)用這個(gè)工廠函數(shù)后返回的結(jié)果才是真正的 Koa 中間件。之所以寫成一個(gè)工廠函數(shù),是因?yàn)槲覀兛梢酝ㄟ^給工廠函數(shù)傳參的方式來更好地控制中間件的行為(當(dāng)然這里的 logger 比較簡(jiǎn)單,就沒有任何參數(shù))。
在這個(gè)中間件的第一階段,我們通過 Date.now() 先獲取請(qǐng)求進(jìn)入的時(shí)間,然后通過 await next() 讓出執(zhí)行權(quán),等待下游中間件運(yùn)行結(jié)束后,再在第二階段通過計(jì)算 Date.now() 的差值來得出處理請(qǐng)求所用的時(shí)間。
思考一下,如果用 Express 來實(shí)現(xiàn)這個(gè)功能,中間件應(yīng)該怎么寫,會(huì)有 Koa 這么簡(jiǎn)單優(yōu)雅嗎?
?「提示」
這里通過兩個(gè)
?Date.now()之間的差值來計(jì)算運(yùn)行時(shí)間其實(shí)是不精確的,為了獲取更準(zhǔn)確的時(shí)間,建議使用process.hrtime()。
然后我們?cè)?src/server.ts 中把剛才的 logger 中間件通過 app.use 注冊(cè)進(jìn)去,代碼如下:
// src/server.ts
// ...
import { logger } from './logger';
// 初始化 Koa 應(yīng)用實(shí)例
const app = new Koa();
// 注冊(cè)中間件
app.use(logger());
app.use(cors());
app.use(bodyParser());
// ...
這時(shí)候再訪問我們的服務(wù)器(通過 Curl 或者其他請(qǐng)求工具),應(yīng)該可以看到輸出日志:

關(guān)于 Koa 框架本身的內(nèi)容基本講完了,但是對(duì)于一個(gè)比較完整的 Web 服務(wù)器來說,我們還需要更多的“武器裝備”才能應(yīng)對(duì)日常的業(yè)務(wù)邏輯。在接下來的部分,我們將通過社區(qū)的優(yōu)秀組件來解決兩個(gè)關(guān)鍵問題:路由和數(shù)據(jù)庫(kù),并演示如何結(jié)合 Koa 框架進(jìn)行使用。
實(shí)現(xiàn)路由配置
由于 Koa 只是一個(gè)中間件框架,所以路由的實(shí)現(xiàn)需要獨(dú)立的 npm 包。首先安裝 @koa/router 及其 TypeScript 類型定義:
$ npm install @koa/router
$ npm install @types/koa__router -D
?「注意」
有些教程使用
?koa-router,但由于koa-router目前處于幾乎無人維護(hù)的狀態(tài),所以我們這里使用維護(hù)更積極的 Fork 版本@koa/router。
路由規(guī)劃
在這篇教程中,我們將實(shí)現(xiàn)以下路由:
GET /users:查詢所有的用戶GET /users/:id:查詢單個(gè)用戶PUT /users/:id:更新單個(gè)用戶DELETE /users/:id:刪除單個(gè)用戶POST /users/login:登錄(獲取 JWT Token)POST /users/register:注冊(cè)用戶
實(shí)現(xiàn) Controller
在 src 中創(chuàng)建 controllers 目錄,用于存放控制器有關(guān)的代碼。首先是 AuthController ,創(chuàng)建 src/controllers/auth.ts ,代碼如下:
// src/controllers/auth.ts
import { Context } from 'koa';
export default class AuthController {
public static async login(ctx: Context) {
ctx.body = 'Login controller';
}
public static async register(ctx: Context) {
ctx.body = 'Register controller';
}
}
然后創(chuàng)建 src/controllers/user.ts,代碼如下:
// src/controllers/user.ts
import { Context } from 'koa';
export default class UserController {
public static async listUsers(ctx: Context) {
ctx.body = 'ListUsers controller';
}
public static async showUserDetail(ctx: Context) {
ctx.body = `ShowUserDetail controller with ID = ${ctx.params.id}`;
}
public static async updateUser(ctx: Context) {
ctx.body = `UpdateUser controller with ID = ${ctx.params.id}`;
}
public static async deleteUser(ctx: Context) {
ctx.body = `DeleteUser controller with ID = ${ctx.params.id}`;
}
}
注意到在后面三個(gè) Controller 中,我們通過 ctx.params 獲取到路由參數(shù) id 。
實(shí)現(xiàn)路由
然后我們創(chuàng)建 src/routes.ts,用于把控制器掛載到對(duì)應(yīng)的路由上面:
// src/routes.ts
import Router from '@koa/router';
import AuthController from './controllers/auth';
import UserController from './controllers/user';
const router = new Router();
// auth 相關(guān)的路由
router.post('/auth/login', AuthController.login);
router.post('/auth/register', AuthController.register);
// users 相關(guān)的路由
router.get('/users', UserController.listUsers);
router.get('/users/:id', UserController.showUserDetail);
router.put('/users/:id', UserController.updateUser);
router.delete('/users/:id', UserController.deleteUser);
export default router;
可以看到 @koa/router 的使用方式基本上與 Express Router 保持一致。
注冊(cè)路由
最后,我們需要將 router 注冊(cè)為中間件。打開 src/server.ts,修改代碼如下:
// src/server.ts
// ...
import router from './routes';
import { logger } from './logger';
// 初始化 Koa 應(yīng)用實(shí)例
const app = new Koa();
// 注冊(cè)中間件
app.use(logger());
app.use(cors());
app.use(bodyParser());
// 響應(yīng)用戶請(qǐng)求
app.use(router.routes()).use(router.allowedMethods());
// 運(yùn)行服務(wù)器
app.listen(3000);
可以看到,這里我們調(diào)用 router 對(duì)象的 routes 方法獲取到對(duì)應(yīng)的 Koa 中間件,還調(diào)用了 allowedMethods 方法注冊(cè)了 HTTP 方法檢測(cè)的中間件,這樣當(dāng)用戶通過不正確的 HTTP 方法訪問 API 時(shí),就會(huì)自動(dòng)返回 405 Method Not Allowed 狀態(tài)碼。
我們通過 Curl 來測(cè)試路由(也可以自行使用 Postman):
$ curl localhost:3000/hello
Not Found
$ curl localhost:3000/auth/register
Method Not Allowed
$ curl -X POST localhost:3000/auth/register
Register controller
$ curl -X POST localhost:3000/auth/login
Login controller
$ curl localhost:3000/users
ListUsers controller
$ curl localhost:3000/users/123
ShowUserDetail controller with ID = 123
$ curl -X PUT localhost:3000/users/123
UpdateUser controller with ID = 123
$ curl -X DELETE localhost:3000/users/123
DeleteUser controller with ID = 123
同時(shí)可以看到服務(wù)器的輸出日志如下:

路由已經(jīng)接通,接下來就讓我們來接入真實(shí)的數(shù)據(jù)吧!
接入 MySQL 數(shù)據(jù)庫(kù)
從這一步開始,我們將正式接入數(shù)據(jù)庫(kù)。Koa 本身是一個(gè)中間件框架,理論上可以接入任何類型的數(shù)據(jù)庫(kù),這里我們選擇流行的關(guān)系型數(shù)據(jù)庫(kù) MySQL。并且,由于我們使用了 TypeScript 開發(fā),因此這里使用為 TS 量身打造的 ORM[12] 庫(kù) TypeORM。
數(shù)據(jù)庫(kù)的準(zhǔn)備工作
首先,請(qǐng)安裝和配置好 MySQL 數(shù)據(jù)庫(kù),可以通過兩種方式:
官網(wǎng)下載安裝包,這里是下載地址[13] 使用 MySQL Docker 鏡像
在確保 MySQL 實(shí)例運(yùn)行之后,我們打開終端,通過命令行連接數(shù)據(jù)庫(kù):
$ mysql -u root -p
輸入預(yù)先設(shè)置好的根帳戶密碼之后,就進(jìn)入了 MySQL 的交互式執(zhí)行客戶端,然后運(yùn)行以下命令:
--- 創(chuàng)建數(shù)據(jù)庫(kù)
CREATE DATABASE koa;
--- 創(chuàng)建用戶并授予權(quán)限
CREATE USER 'user'@'localhost' IDENTIFIED BY 'pass';
GRANT ALL PRIVILEGES ON koa.* TO 'user'@'localhost';
--- 處理 MySQL 8.0 版本的認(rèn)證協(xié)議問題
ALTER USER 'user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'pass';
flush privileges;
TypeORM 的配置和連接
首先安裝相關(guān)的 npm 包,分別是 MySQL 驅(qū)動(dòng)、TypeORM 及 reflect-metadata(反射 API 庫(kù),用于 TypeORM 推斷模型的元數(shù)據(jù)):
$ npm install mysql typeorm reflect-metadata
然后在項(xiàng)目根目錄創(chuàng)建 ormconfig.json ,TypeORM 會(huì)讀取這個(gè)數(shù)據(jù)庫(kù)配置進(jìn)行連接,代碼如下:
// ormconfig.json
{
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "user",
"password": "pass",
"database": "koa",
"synchronize": true,
"entities": ["src/entity/*.ts"],
"cli": {
"entitiesDir": "src/entity"
}
}
這里有一些需要解釋的字段:
database就是我們剛剛創(chuàng)建的koa數(shù)據(jù)庫(kù)synchronize設(shè)為true能夠讓我們每次修改模型定義后都能自動(dòng)同步到數(shù)據(jù)庫(kù)*(如果你接觸過其他的 ORM 庫(kù),其實(shí)就是自動(dòng)數(shù)據(jù)遷移)*entities字段定義了模型文件的路徑,我們馬上就來創(chuàng)建
接著修改 src/server.ts,在其中連接數(shù)據(jù)庫(kù),代碼如下:
// src/server.ts
import Koa from 'koa';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
import { createConnection } from 'typeorm';
import 'reflect-metadata';
import router from './routes';
import { logger } from './logger';
createConnection()
.then(() => {
// 初始化 Koa 應(yīng)用實(shí)例
const app = new Koa();
// 注冊(cè)中間件
app.use(logger());
app.use(cors());
app.use(bodyParser());
// 響應(yīng)用戶請(qǐng)求
app.use(router.routes()).use(router.allowedMethods());
// 運(yùn)行服務(wù)器
app.listen(3000);
})
.catch((err: string) => console.log('TypeORM connection error:', err));
創(chuàng)建數(shù)據(jù)模型定義
在 src 目錄下創(chuàng)建 entity 目錄,用于存放數(shù)據(jù)模型定義文件。在其中創(chuàng)建 user.ts ,代表用戶模型,代碼如下:
// src/entity/user.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ select: false })
password: string;
@Column()
email: string;
}
可以看到,用戶模型有四個(gè)字段,其含義很容易理解。而 TypeORM 則是通過裝飾器[14]這種優(yōu)雅的方式來將我們的 User 類映射到數(shù)據(jù)庫(kù)中的表。這里我們使用了三個(gè)裝飾器:
Entity用于裝飾整個(gè)類,使其變成一個(gè)數(shù)據(jù)庫(kù)模型Column用于裝飾類的某個(gè)屬性,使其對(duì)應(yīng)于數(shù)據(jù)庫(kù)表中的一列,可提供一系列選項(xiàng)參數(shù),例如我們給password設(shè)置了select: false,使得這個(gè)字段在查詢時(shí)默認(rèn)不被選中PrimaryGeneratedColumn則是裝飾主列,它的值將自動(dòng)生成
?「提示」
關(guān)于 TypeORM 所有的裝飾器定義及其詳細(xì)使用,請(qǐng)參考其裝飾器文檔[15]。
?
在 Controller 中操作數(shù)據(jù)庫(kù)
然后就可以在 Controller 中進(jìn)行數(shù)據(jù)的增刪改查操作了。首先我們打開 src/controllers/user.ts ,實(shí)現(xiàn)所有 Controller 的邏輯,代碼如下:
// src/controllers/user.ts
import { Context } from 'koa';
import { getManager } from 'typeorm';
import { User } from '../entity/user';
export default class UserController {
public static async listUsers(ctx: Context) {
const userRepository = getManager().getRepository(User);
const users = await userRepository.find();
ctx.status = 200;
ctx.body = users;
}
public static async showUserDetail(ctx: Context) {
const userRepository = getManager().getRepository(User);
const user = await userRepository.findOne(+ctx.params.id);
if (user) {
ctx.status = 200;
ctx.body = user;
} else {
ctx.status = 404;
}
}
public static async updateUser(ctx: Context) {
const userRepository = getManager().getRepository(User);
await userRepository.update(+ctx.params.id, ctx.request.body);
const updatedUser = await userRepository.findOne(+ctx.params.id);
if (updatedUser) {
ctx.status = 200;
ctx.body = updatedUser;
} else {
ctx.status = 404;
}
}
public static async deleteUser(ctx: Context) {
const userRepository = getManager().getRepository(User);
await userRepository.delete(+ctx.params.id);
ctx.status = 204;
}
}
TypeORM 中操作數(shù)據(jù)模型主要是通過 Repository 實(shí)現(xiàn)的,在 Controller 中,可以通過 getManager().getRepository(Model) 來獲取到,之后 Repository 的查詢 API 就與其他的庫(kù)很類似了。
?「提示」
關(guān)于 Repository 所有的查詢 API,請(qǐng)參考這里的文檔[16]。
?
細(xì)心的你應(yīng)該還發(fā)現(xiàn)我們通過 ctx.request.body 獲取到了請(qǐng)求體的數(shù)據(jù),這是我們?cè)诘谝徊骄团渲煤玫?bodyParser 中間件在 Context 對(duì)象中添加的。
然后我們修改 AuthController ,實(shí)現(xiàn)具體的注冊(cè)邏輯。由于密碼不能明文保存在數(shù)據(jù)庫(kù)中,需要使用非對(duì)稱算法進(jìn)行加密,這里我們使用曾經(jīng)獲得過密碼加密大賽冠軍的 Argon2[17] 算法。安裝對(duì)應(yīng)的 npm 包:
npm install argon2
然后實(shí)現(xiàn)具體的 register Controller,修改 src/controllers/auth.ts,代碼如下:
// src/controllers/auth.ts
import { Context } from 'koa';
import * as argon2 from 'argon2';
import { getManager } from 'typeorm';
import { User } from '../entity/user';
export default class AuthController {
// ...
public static async register(ctx: Context) {
const userRepository = getManager().getRepository(User);
const newUser = new User();
newUser.name = ctx.request.body.name;
newUser.email = ctx.request.body.email;
newUser.password = await argon2.hash(ctx.request.body.password);
// 保存到數(shù)據(jù)庫(kù)
const user = await userRepository.save(newUser);
ctx.status = 201;
ctx.body = user;
}
}
確保服務(wù)器在運(yùn)行之后,我們就可以開始測(cè)試一波了。首先是注冊(cè)用戶(這里我用 Postman 演示,直觀一些):

你可以繼續(xù)注冊(cè)幾個(gè)用戶,然后繼續(xù)訪問 /users 相關(guān)的路由,應(yīng)該可以成功地獲取、修改和刪除相應(yīng)的數(shù)據(jù)了!
實(shí)現(xiàn) JWT 鑒權(quán)
JSON Web Token(JWT)是一種流行的 RESTful API 鑒權(quán)方案。這里我們將手把手帶你學(xué)會(huì)如何在 Koa 框架中使用 JWT 鑒權(quán),但是不會(huì)過多講解其原理(可參考這篇文章[18]進(jìn)行學(xué)習(xí))。
首先安裝相關(guān)的 npm 包:
npm install koa-jwt jsonwebtoken
npm install @types/jsonwebtoken -D
創(chuàng)建 src/constants.ts ,用于存放 JWT Secret 常量,代碼如下:
// src/constants.ts
export const JWT_SECRET = 'secret';
在實(shí)際開發(fā)中,請(qǐng)?zhí)鎿Q成一個(gè)足夠復(fù)雜的字符串,并且最好通過環(huán)境變量的方式注入。
重新規(guī)劃路由
有些路由我們希望只有已登錄的用戶才有權(quán)查看(受保護(hù)的路由),而另一些路由則是所有請(qǐng)求都可以訪問(不受保護(hù)的路由)。在 Koa 的洋蔥模型中,我們可以這樣實(shí)現(xiàn):

所有請(qǐng)求都可以直接訪問未受保護(hù)的路由,但是受保護(hù)的路由就放在 JWT 中間件的后面(或者從洋蔥模型的角度看是“里面”),這樣對(duì)于沒有攜帶 JWT Token 的請(qǐng)求就直接返回,而不會(huì)繼續(xù)傳遞下去。
想法明確之后,打開 src/routes.ts 路由文件,修改代碼如下:
// src/routes.ts
import Router from '@koa/router';
import AuthController from './controllers/auth';
import UserController from './controllers/user';
const unprotectedRouter = new Router();
// auth 相關(guān)的路由
unprotectedRouter.post('/auth/login', AuthController.login);
unprotectedRouter.post('/auth/register', AuthController.register);
const protectedRouter = new Router();
// users 相關(guān)的路由
protectedRouter.get('/users', UserController.listUsers);
protectedRouter.get('/users/:id', UserController.showUserDetail);
protectedRouter.put('/users/:id', UserController.updateUser);
protectedRouter.delete('/users/:id', UserController.deleteUser);
export { protectedRouter, unprotectedRouter };
上面我們分別實(shí)現(xiàn)了 protectedRouter 和 unprotectedRouter ,分別對(duì)應(yīng)于需要 JWT 中間件保護(hù)的路由和不需要保護(hù)的路由。
注冊(cè) JWT 中間件
接著便是注冊(cè) JWT 中間件,并分別在其前后注冊(cè)不需要保護(hù)的路由 unprotectedRouter 和需要保護(hù)的路由 protectedRouter。修改服務(wù)器文件 src/server.ts ,代碼如下:
// src/server.ts
// ...
import jwt from 'koa-jwt';
import 'reflect-metadata';
import { protectedRouter, unprotectedRouter } from './routes';
import { logger } from './logger';
import { JWT_SECRET } from './constants';
createConnection()
.then(() => {
// ...
// 無需 JWT Token 即可訪問
app.use(unprotectedRouter.routes()).use(unprotectedRouter.allowedMethods());
// 注冊(cè) JWT 中間件
app.use(jwt({ secret: JWT_SECRET }).unless({ method: 'GET' }));
// 需要 JWT Token 才可訪問
app.use(protectedRouter.routes()).use(protectedRouter.allowedMethods());
// ...
})
// ...
對(duì)應(yīng)剛才“洋蔥模型”的設(shè)計(jì)圖,是不是感覺很直觀?
?「提示」
在 JWT 中間件注冊(cè)完畢后,如果用戶請(qǐng)求攜帶了有效的 Token,后面的
?protectedRouter就可以通過ctx.state.user獲取到 Token 的內(nèi)容(更精確的說法是 Payload,負(fù)載,一般是用戶的關(guān)鍵信息,例如 ID)了;反之,如果 Token 缺失或無效,那么 JWT 中間件會(huì)直接自動(dòng)返回 401 錯(cuò)誤。關(guān)于 koa-jwt 的更多使用細(xì)節(jié),請(qǐng)參考其文檔[19]。
在 Login 中簽發(fā) JWT Token
我們需要提供一個(gè) API 端口讓用戶可以獲取到 JWT Token,最合適的當(dāng)然是登錄接口 /auth/login。打開 src/controllers/auth.ts ,在 login 控制器中實(shí)現(xiàn)簽發(fā) JWT Token 的邏輯,代碼如下:
// src/controllers/auth.ts
// ...
import jwt from 'jsonwebtoken';
// ...
import { JWT_SECRET } from '../constants';
export default class AuthController {
public static async login(ctx: Context) {
const userRepository = getManager().getRepository(User);
const user = await userRepository
.createQueryBuilder()
.where({ name: ctx.request.body.name })
.addSelect('User.password')
.getOne();
if (!user) {
ctx.status = 401;
ctx.body = { message: '用戶名不存在' };
} else if (await argon2.verify(user.password, ctx.request.body.password)) {
ctx.status = 200;
ctx.body = { token: jwt.sign({ id: user.id }, JWT_SECRET) };
} else {
ctx.status = 401;
ctx.body = { message: '密碼錯(cuò)誤' };
}
}
// ...
}
在 login 中,我們首先根據(jù)用戶名(請(qǐng)求體中的 name 字段)查詢對(duì)應(yīng)的用戶,如果該用戶不存在,則直接返回 401;存在的話再通過 argon2.verify 來驗(yàn)證請(qǐng)求體中的明文密碼 password 是否和數(shù)據(jù)庫(kù)中存儲(chǔ)的加密密碼是否一致,如果一致則通過 jwt.sign 簽發(fā) Token,如果不一致則還是返回 401。
這里的 Token 負(fù)載就是標(biāo)識(shí)用戶 ID 的對(duì)象 { id: user.id } ,這樣后面鑒權(quán)成功后就可以通過 ctx.user.id 來獲取用戶 ID。
在 User 控制器中添加訪問控制
Token 的中間件和簽發(fā)都搞定之后,最后一步就是在合適的地方校驗(yàn)用戶的 Token,確認(rèn)其是否有足夠的權(quán)限。最典型的場(chǎng)景便是,在更新或刪除用戶時(shí),我們要「確保是用戶本人在操作」。打開 src/controllers/user.ts ,代碼如下:
// src/controllers/user.ts
// ...
export default class UserController {
// ...
public static async updateUser(ctx: Context) {
const userId = +ctx.params.id;
if (userId !== +ctx.state.user.id) {
ctx.status = 403;
ctx.body = { message: '無權(quán)進(jìn)行此操作' };
return;
}
const userRepository = getManager().getRepository(User);
await userRepository.update(userId, ctx.request.body);
const updatedUser = await userRepository.findOne(userId);
// ...
}
public static async deleteUser(ctx: Context) {
const userId = +ctx.params.id;
if (userId !== +ctx.state.user.id) {
ctx.status = 403;
ctx.body = { message: '無權(quán)進(jìn)行此操作' };
return;
}
const userRepository = getManager().getRepository(User);
await userRepository.delete(userId);
ctx.status = 204;
}
}
兩個(gè) Controller 的鑒權(quán)邏輯基本相同,我們通過比較 ctx.params.id 和 ctx.state.user.id 是否相同,如果不相同則返回 403 Forbidden 錯(cuò)誤,相同則繼續(xù)執(zhí)行相應(yīng)的數(shù)據(jù)庫(kù)操作。
代碼寫完之后,我們用剛才注冊(cè)的一個(gè)用戶信息去訪問登錄 API:

成功地獲取到了 JWT Token!然后我們復(fù)制獲取到的 Token,在接下來測(cè)試受保護(hù)的路由時(shí),我們需要添加一個(gè) Authorization 頭部,值為 Bearer <JWT_TOKEN> ,如下圖所示:

然后就可以測(cè)試受保護(hù)的路由了!這里由于篇幅限制就省略了。
錯(cuò)誤處理
最后,我們來簡(jiǎn)單地聊一下 Koa 中的錯(cuò)誤處理。由于 Koa 采用了 async 函數(shù)和 Promise 作為異步編程的方案,所以錯(cuò)誤處理自然也很簡(jiǎn)單了——直接用 JavaScript 自帶的 try-catch 語(yǔ)法就可以輕松搞定。
實(shí)現(xiàn)自定義錯(cuò)誤(異常)
首先,讓我們來實(shí)現(xiàn)一些自定義的錯(cuò)誤(或者異常,本文不作區(qū)分)類。創(chuàng)建 src/exceptions.ts ,代碼如下:
// src/exceptions.ts
export class BaseException extends Error {
// 狀態(tài)碼
status: number;
// 提示信息
message: string;
}
export class NotFoundException extends BaseException {
status = 404;
constructor(msg?: string) {
super();
this.message = msg || '無此內(nèi)容';
}
}
export class UnauthorizedException extends BaseException {
status = 401;
constructor(msg?: string) {
super();
this.message = msg || '尚未登錄';
}
}
export class ForbiddenException extends BaseException {
status = 403;
constructor(msg?: string) {
super();
this.message = msg || '權(quán)限不足';
}
}
這里的錯(cuò)誤類型參考了 Nest.js[20] 的設(shè)計(jì)。出于學(xué)習(xí)目的,這里作了簡(jiǎn)化,并且只實(shí)現(xiàn)了我們需要用到的錯(cuò)誤。
在 Controller 中使用自定義錯(cuò)誤
接著我們便可以在 Controller 中使用剛才的自定義錯(cuò)誤了。打開 src/controllers/auth.ts,修改代碼如下:
// src/controllers/auth.ts
// ...
import { UnauthorizedException } from '../exceptions';
export default class AuthController {
public static async login(ctx: Context) {
// ...
if (!user) {
throw new UnauthorizedException('用戶名不存在');
} else if (await argon2.verify(user.password, ctx.request.body.password)) {
ctx.status = 200;
ctx.body = { token: jwt.sign({ id: user.id }, JWT_SECRET) };
} else {
throw new UnauthorizedException('密碼錯(cuò)誤');
}
}
// ...
}
可以看到,我們將直接手動(dòng)設(shè)置狀態(tài)碼和響應(yīng)體的代碼改成了簡(jiǎn)單的錯(cuò)誤拋出,代碼清晰了很多。
?「提示」
Koa 的 Context 對(duì)象提供了一個(gè)便捷方法
?throw,同樣可以拋出異常,例如ctx.throw(400, 'Bad request')。
同樣地,修改 UserController 相關(guān)的邏輯。修改 src/controllers/user.ts,代碼如下:
// src/controllers/user.ts
// ...
import { NotFoundException, ForbiddenException } from '../exceptions';
export default class UserController {
// ...
public static async showUserDetail(ctx: Context) {
const userRepository = getManager().getRepository(User);
const user = await userRepository.findOne(+ctx.params.id);
if (user) {
ctx.status = 200;
ctx.body = user;
} else {
throw new NotFoundException();
}
}
public static async updateUser(ctx: Context) {
const userId = +ctx.params.id;
if (userId !== +ctx.state.user.id) {
throw new ForbiddenException();
}
// ...
}
// ...
public static async deleteUser(ctx: Context) {
const userId = +ctx.params.id;
if (userId !== +ctx.state.user.id) {
throw new ForbiddenException();
}
// ...
}
}
添加錯(cuò)誤處理中間件
最后,我們需要添加錯(cuò)誤處理中間件來捕獲在 Controller 中拋出的錯(cuò)誤。打開 src/server.ts ,實(shí)現(xiàn)錯(cuò)誤處理中間件,代碼如下:
// src/server.ts
// ...
createConnection()
.then(() => {
// ...
// 注冊(cè)中間件
app.use(logger());
app.use(cors());
app.use(bodyParser());
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// 只返回 JSON 格式的響應(yīng)
ctx.status = err.status || 500;
ctx.body = { message: err.message };
}
});
// ...
})
// ...
可以看到,在這個(gè)錯(cuò)誤處理中間件中,我們把返回的響應(yīng)數(shù)據(jù)轉(zhuǎn)換成 JSON 格式(而不是之前的 Plain Text),這樣看上去更統(tǒng)一一些。
至此,這篇教程就結(jié)束了。內(nèi)容很多,希望對(duì)你有一定的幫助。我們的用戶系統(tǒng)已經(jīng)能夠處理大部分情形,但是對(duì)于一些邊際情況的處理依然很糟糕(能想到有哪些嗎?)。不過話說回來,相信你已經(jīng)確定 Koa 是一個(gè)很棒的框架了吧?
?想要學(xué)習(xí)更多精彩的實(shí)戰(zhàn)技術(shù)教程?來趣談前端逛逛吧。
?

??愛心三連擊
1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的「點(diǎn)贊,在看」是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)趣談前端,進(jìn)程序員優(yōu)質(zhì)學(xué)習(xí)交流群, 字節(jié), 阿里大佬和你一起學(xué)習(xí)成長(zhǎng)!
3.也可添加微信【Mr_xuxiaoxi】獲取大廠內(nèi)推機(jī)會(huì)。

