一杯咖啡的時間,上手 Koa2 + MySQL 開發(fā)(文末送書!)

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

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

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

請求(Request)直接依次貫穿各個中間件,最后通過請求處理函數返回響應(Response),非常簡單。然后我們來看看 Koa 的中間件是什么樣的:

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

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

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

你可以繼續(xù)注冊幾個用戶,然后繼續(xù)訪問 /users 相關的路由,應該可以成功地獲取、修改和刪除相應的數據了!
實現 JWT 鑒權
JSON Web Token(JWT)是一種流行的 RESTful API 鑒權方案。這里我們將手把手帶你學會如何在 Koa 框架中使用 JWT 鑒權,但是不會過多講解其原理(可參考這篇文章[18]進行學習)。
首先安裝相關的 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';
在實際開發(fā)中,請?zhí)鎿Q成一個足夠復雜的字符串,并且最好通過環(huán)境變量的方式注入。
重新規(guī)劃路由
有些路由我們希望只有已登錄的用戶才有權查看(受保護的路由),而另一些路由則是所有請求都可以訪問(不受保護的路由)。在 Koa 的洋蔥模型中,我們可以這樣實現:

所有請求都可以直接訪問未受保護的路由,但是受保護的路由就放在 JWT 中間件的后面(或者從洋蔥模型的角度看是“里面”),這樣對于沒有攜帶 JWT Token 的請求就直接返回,而不會繼續(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?相關的路由
unprotectedRouter.post('/auth/login',?AuthController.login);
unprotectedRouter.post('/auth/register',?AuthController.register);
const?protectedRouter?=?new?Router();
//?users?相關的路由
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?};
上面我們分別實現了 protectedRouter 和 unprotectedRouter ,分別對應于需要 JWT 中間件保護的路由和不需要保護的路由。
注冊 JWT 中間件
接著便是注冊 JWT 中間件,并分別在其前后注冊不需要保護的路由 unprotectedRouter 和需要保護的路由 protectedRouter。修改服務器文件 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());
????//?注冊?JWT?中間件
????app.use(jwt({?secret:?JWT_SECRET?}).unless({?method:?'GET'?}));
????//?需要?JWT?Token?才可訪問
????app.use(protectedRouter.routes()).use(protectedRouter.allowedMethods());
????//?...
??})
??//?...
對應剛才“洋蔥模型”的設計圖,是不是感覺很直觀?
?「提示」
在 JWT 中間件注冊完畢后,如果用戶請求攜帶了有效的 Token,后面的
?protectedRouter就可以通過ctx.state.user獲取到 Token 的內容(更精確的說法是 Payload,負載,一般是用戶的關鍵信息,例如 ID)了;反之,如果 Token 缺失或無效,那么 JWT 中間件會直接自動返回 401 錯誤。關于 koa-jwt 的更多使用細節(jié),請參考其文檔[19]。
在 Login 中簽發(fā) JWT Token
我們需要提供一個 API 端口讓用戶可以獲取到 JWT Token,最合適的當然是登錄接口 /auth/login。打開 src/controllers/auth.ts ,在 login 控制器中實現簽發(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:?'密碼錯誤'?};
????}
??}
??//?...
}
在 login 中,我們首先根據用戶名(請求體中的 name 字段)查詢對應的用戶,如果該用戶不存在,則直接返回 401;存在的話再通過 argon2.verify 來驗證請求體中的明文密碼 password 是否和數據庫中存儲的加密密碼是否一致,如果一致則通過 jwt.sign 簽發(fā) Token,如果不一致則還是返回 401。
這里的 Token 負載就是標識用戶 ID 的對象 { id: user.id } ,這樣后面鑒權成功后就可以通過 ctx.user.id 來獲取用戶 ID。
在 User 控制器中添加訪問控制
Token 的中間件和簽發(fā)都搞定之后,最后一步就是在合適的地方校驗用戶的 Token,確認其是否有足夠的權限。最典型的場景便是,在更新或刪除用戶時,我們要「確保是用戶本人在操作」。打開 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:?'無權進行此操作'?};
??????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:?'無權進行此操作'?};
??????return;
????}
????const?userRepository?=?getManager().getRepository(User);
????await?userRepository.delete(userId);
????ctx.status?=?204;
??}
}
兩個 Controller 的鑒權邏輯基本相同,我們通過比較 ctx.params.id 和 ctx.state.user.id 是否相同,如果不相同則返回 403 Forbidden 錯誤,相同則繼續(xù)執(zhí)行相應的數據庫操作。
代碼寫完之后,我們用剛才注冊的一個用戶信息去訪問登錄 API:

成功地獲取到了 JWT Token!然后我們復制獲取到的 Token,在接下來測試受保護的路由時,我們需要添加一個 Authorization 頭部,值為 Bearer ,如下圖所示:

然后就可以測試受保護的路由了!這里由于篇幅限制就省略了。
錯誤處理
最后,我們來簡單地聊一下 Koa 中的錯誤處理。由于 Koa 采用了 async 函數和 Promise 作為異步編程的方案,所以錯誤處理自然也很簡單了——直接用 JavaScript 自帶的 try-catch 語法就可以輕松搞定。
實現自定義錯誤(異常)
首先,讓我們來實現一些自定義的錯誤(或者異常,本文不作區(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?||?'無此內容';
??}
}
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?||?'權限不足';
??}
}
這里的錯誤類型參考了 Nest.js[20] 的設計。出于學習目的,這里作了簡化,并且只實現了我們需要用到的錯誤。
在 Controller 中使用自定義錯誤
接著我們便可以在 Controller 中使用剛才的自定義錯誤了。打開 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('密碼錯誤');
????}
??}
??//?...
}
可以看到,我們將直接手動設置狀態(tài)碼和響應體的代碼改成了簡單的錯誤拋出,代碼清晰了很多。
?「提示」
Koa 的 Context 對象提供了一個便捷方法
?throw,同樣可以拋出異常,例如ctx.throw(400, 'Bad request')。
同樣地,修改 UserController 相關的邏輯。修改 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();
????}
????//?...
??}
}
添加錯誤處理中間件
最后,我們需要添加錯誤處理中間件來捕獲在 Controller 中拋出的錯誤。打開 src/server.ts ,實現錯誤處理中間件,代碼如下:
//?src/server.ts
//?...
createConnection()
??.then(()?=>?{
????//?...
????//?注冊中間件
????app.use(logger());
????app.use(cors());
????app.use(bodyParser());
????app.use(async?(ctx,?next)?=>?{
??????try?{
????????await?next();
??????}?catch?(err)?{
????????//?只返回?JSON?格式的響應
????????ctx.status?=?err.status?||?500;
????????ctx.body?=?{?message:?err.message?};
??????}
????});
????//?...
??})
??//?...
可以看到,在這個錯誤處理中間件中,我們把返回的響應數據轉換成 JSON 格式(而不是之前的 Plain Text),這樣看上去更統一一些。
至此,這篇教程就結束了。內容很多,希望對你有一定的幫助。我們的用戶系統已經能夠處理大部分情形,但是對于一些邊際情況的處理依然很糟糕(能想到有哪些嗎?)。不過話說回來,相信你已經確定 Koa 是一個很棒的框架了吧?
?想要學習更多精彩的實戰(zhàn)技術教程?來圖雀社區(qū)[21]逛逛吧。
?
Reference
mRc: https://github.com/mRcfps
[2]圖雀社區(qū): https://tuture.co?utm_source=juejin_zhuanlan
[3]文檔: https://github.com/koajs/koa/blob/master/docs/koa-vs-express.md
[4]TypeScript: https://www.typescriptlang.org/
[5]TypeORM: https://github.com/typeorm/typeorm
[6]這篇教程: https://juejin.im/post/5df39f94518825122030859c
[7]TypeScript 系列教程: https://juejin.im/post/5ec6377ff265da76f30e45d2
[8]這篇教程: https://juejin.im/post/5df9cf38f265da33b82bf5a2
[9]Nodemon: https://github.com/remy/nodemon
[10]Connect: https://github.com/senchalabs/connect
[11]Context API 文檔: https://github.com/koajs/koa/blob/master/docs/api/context.md
[12]ORM: http://www.ruanyifeng.com/blog/2019/02/orm-tutorial.html
[13]下載地址: https://dev.mysql.com/downloads/mysql/
[14]裝飾器: https://www.tslang.cn/docs/handbook/decorators.html
[15]裝飾器文檔: https://github.com/typeorm/typeorm/blob/master/docs/zh_CN/decorator-reference.md
[16]這里的文檔: https://github.com/typeorm/typeorm/blob/master/docs/zh_CN/repository-api.md
[17]Argon2: https://www.argon2.com/
[18]這篇文章: http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
[19]文檔: https://github.com/koajs/jwt
[20]Nest.js: https://docs.nestjs.com/exception-filters
[21]圖雀社區(qū): https://tuture.co?utm_source=juejin_zhuanlan
??
本公眾號將抽獎送出 5 本? 《Node.js+Express+Vue.js項目開發(fā)實戰(zhàn)》,感謝機械工業(yè)出版社的贊助~

