一杯茶的時間,上手 Koa2 - MySQL 開發

憑藉精巧的 “洋蔥模型” 和對 Promise 以及 async/await 異步編程的完全支持,Koa 框架自從誕生以來就吸引了無數 Node 愛好者。然而 Koa 本身只是一個簡單的中間件框架,要想實現一個足夠複雜的 Web 應用還需要很多周邊生態支持。這篇教程不僅會帶你梳理 Koa 的基礎知識,還會充分地運用和講解構建 Web 應用必須的組件(路由、數據庫、鑑權等),最終實現一個較爲完善的用戶系統。

起步

Koa 作爲 Express 原班人馬打造的新生代 Node.js Web 框架,自從發佈以來就備受矚目。正如 Koa 作者們在文檔 [3] 中所指出的:

Philosophically, Koa aims to "fix and replace node", whereas Express "augments node".(Express 是 Node 的補強,而 Koa 則是爲了解決 Node 的問題並取代之。)

在這一篇文章中,我們將手把手帶你開發一個簡單的用戶系統 REST API,支持用戶的增刪改查以及 JWT 鑑權,從實戰中感受 Koa2 的精髓,它相比於 Express 做出的突破性的改變。我們將選擇 TypeScript[4] 作爲開發語言,數據庫選用 MySQL,並使用 TypeORM[5] 作爲數據庫橋接層。

「注意」

這篇文章不會涉及 Koa 源碼級別的原理分析,重心會放在讓你完全掌握如何去使用 Koa 及周邊生態去開發 Web 應用,並欣賞 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 服務器

創建 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 服務器幾乎完全一致:

  1. 初始化應用實例 app

  2. 註冊相關的中間件(跨域 cors 和請求體解析中間件 bodyParser

  3. 添加請求處理函數,響應用戶請求

  4. 運行服務器

定睛一看,第 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 這是故意偷工減料的嗎?先不用急,我們馬上在下一節講解中間件時就會了解到 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 所擁有的其他功能,例如路由、模板引擎、發送文件等。接下來,我們就來學習 Koa 最重要的知識點:「中間件」

大名鼎鼎的 “洋蔥模型”

你也許從來沒有用過 Koa 框架,但很有可能聽說過 “洋蔥模型”,而 Koa 正是洋蔥模型的代表框架之一。下面這個圖你也許很熟悉了:

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

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

可以看到,Koa 中間件不像 Express 中間件那樣在請求通過了之後就完成了自己的使命;相反,中間件的執行清晰地分爲**「兩個階段」**。我們馬上來看下 Koa 中間件具體是什麼樣的。

Koa 中間件的定義

Koa 的中間件是這樣一個函數:

async function middleware(ctx, next) {
  // 第一階段
  await next();
  // 第二階段
}

第一個參數就是 Koa Context,也就是上圖中貫穿所有中間件和請求處理函數的綠色箭頭所傳遞的內容,裏面**「封裝了請求體和響應體」**(實際上還有其他屬性,但這裏暫時不講),分別可以通過 ctx.requestctx.response 來獲取,以下是一些常用的屬性:

ctx.url    // 相當於 ctx.request.url
ctx.body   // 相當於 ctx.response.body
ctx.status // 相當於 ctx.response.status

「提示」

關於所有請求和響應上面的屬性及其別稱,請參考 Context API 文檔 [11]。

中間件的第二個參數便是 next 函數,這個熟悉 Express 的同學一定知道它是幹什麼的:用來把控制權轉交給下一個中間件。但是它跟 Express 的 next 函數本質的區別在於,「Koa 的 next」** 函數返回的是一個 Promise」**,在這個 Promise 進入完成狀態(Fulfilled)後,就會去執行中間件中第二階段的代碼。

那麼我們不禁要問:這樣把中間件的執行拆分爲兩個階段,到底有什麼好處嗎?我們來通過一個非常經典的例子來感受一下:日誌記錄中間件(包括響應時間的計算)。

實戰:日誌記錄中間件

讓我們來實現一個簡單的日誌記錄中間件 logger ,用於記錄每次請求的方法、URL、狀態碼和響應時間。創建 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() 讓出執行權,等待下游中間件運行結束後,再在第二階段通過計算 Date.now() 的差值來得出處理請求所用的時間。

思考一下,如果用 Express 來實現這個功能,中間件應該怎麼寫,會有 Koa 這麼簡單優雅嗎?

「提示」

這裏通過兩個 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 服務器來說,我們還需要更多的 “武器裝備” 才能應對日常的業務邏輯。在接下來的部分,我們將通過社區的優秀組件來解決兩個關鍵問題:路由和數據庫,並演示如何結合 Koa 框架進行使用。

實現路由配置

由於 Koa 只是一箇中間件框架,所以路由的實現需要獨立的 npm 包。首先安裝 @koa/router 及其 TypeScript 類型定義:

$ npm install @koa/router
$ npm install @types/koa__router -D

「注意」

有些教程使用 koa-router ,但由於 koa-router 目前處於幾乎無人維護的狀態,所以我們這裏使用維護更積極的 Fork 版本 @koa/router

路由規劃

在這篇教程中,我們將實現以下路由:

實現 Controller

src 中創建 controllers 目錄,用於存放控制器有關的代碼。首先是 AuthController ,創建 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';
  }
}

然後創建 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

實現路由

然後我們創建 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 狀態碼。

我們通過 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 開發,因此這裏使用爲 TS 量身打造的 ORM[12] 庫 TypeORM。

數據庫的準備工作

首先,請安裝和配置好 MySQL 數據庫,可以通過兩種方式:

在確保 MySQL 實例運行之後,我們打開終端,通過命令行連接數據庫:

$ mysql -u root -p

輸入預先設置好的根帳戶密碼之後,就進入了 MySQL 的交互式執行客戶端,然後運行以下命令:

--- 創建數據庫
CREATE DATABASE koa;

--- 創建用戶並授予權限
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

然後在項目根目錄創建 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"
  }
}

這裏有一些需要解釋的字段:

接着修改 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));

創建數據模型定義

src 目錄下創建 entity 目錄,用於存放數據模型定義文件。在其中創建 user.ts ,代表用戶模型,代碼如下:

// src/entity/user.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ selectfalse })
  password: string;

  @Column()
  email: string;
}

可以看到,用戶模型有四個字段,其含義很容易理解。而 TypeORM 則是通過裝飾器 [14] 這種優雅的方式來將我們的 User 類映射到數據庫中的表。這裏我們使用了三個裝飾器:

「提示」

關於 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]。

細心的你應該還發現我們通過 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 演示,直觀一些):

你可以繼續註冊幾個用戶,然後繼續訪問 /users 相關的路由,應該可以成功地獲取、修改和刪除相應的數據了!

實現 JWT 鑑權

JSON Web Token(JWT)是一種流行的 RESTful API 鑑權方案。這裏我們將手把手帶你學會如何在 Koa 框架中使用 JWT 鑑權,但是不會過多講解其原理(可參考這篇文章 [18] 進行學習)。

首先安裝相關的 npm 包:

npm install koa-jwt jsonwebtoken
npm install @types/jsonwebtoken -D

創建 src/constants.ts ,用於存放 JWT Secret 常量,代碼如下:

// src/constants.ts
export const JWT_SECRET = 'secret';

在實際開發中,請替換成一個足夠複雜的字符串,並且最好通過環境變量的方式注入。

重新規劃路由

有些路由我們希望只有已登錄的用戶纔有權查看(受保護的路由),而另一些路由則是所有請求都可以訪問(不受保護的路由)。在 Koa 的洋蔥模型中,我們可以這樣實現:

所有請求都可以直接訪問未受保護的路由,但是受保護的路由就放在 JWT 中間件的後面(或者從洋蔥模型的角度看是 “裏面”),這樣對於沒有攜帶 JWT Token 的請求就直接返回,而不會繼續傳遞下去。

想法明確之後,打開 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 };

上面我們分別實現了 protectedRouterunprotectedRouter ,分別對應於需要 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 的更多使用細節,請參考其文檔 [19]。

在 Login 中籤發 JWT Token

我們需要提供一個 API 端口讓用戶可以獲取到 JWT Token,最合適的當然是登錄接口 /auth/login。打開 src/controllers/auth.ts ,在 login 控制器中實現簽發 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 簽發 Token,如果不一致則還是返回 401。

這裏的 Token 負載就是標識用戶 ID 的對象 { id: user.id } ,這樣後面鑑權成功後就可以通過 ctx.user.id 來獲取用戶 ID。

在 User 控制器中添加訪問控制

Token 的中間件和簽發都搞定之後,最後一步就是在合適的地方校驗用戶的 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.idctx.state.user.id 是否相同,如果不相同則返回 403 Forbidden 錯誤,相同則繼續執行相應的數據庫操作。

代碼寫完之後,我們用剛纔註冊的一個用戶信息去訪問登錄 API:

成功地獲取到了 JWT Token!然後我們複製獲取到的 Token,在接下來測試受保護的路由時,我們需要添加一個 Authorization 頭部,值爲 Bearer <JWT_TOKEN> ,如下圖所示:

然後就可以測試受保護的路由了!這裏由於篇幅限制就省略了。

錯誤處理

最後,我們來簡單地聊一下 Koa 中的錯誤處理。由於 Koa 採用了 async 函數和 Promise 作爲異步編程的方案,所以錯誤處理自然也很簡單了——直接用 JavaScript 自帶的 try-catch 語法就可以輕鬆搞定。

實現自定義錯誤(異常)

首先,讓我們來實現一些自定義的錯誤(或者異常,本文不作區分)類。創建 src/exceptions.ts ,代碼如下:

// src/exceptions.ts
export class BaseException extends Error {
  // 狀態碼
  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('密碼錯誤');
    }
  }

  // ...
}

可以看到,我們將直接手動設置狀態碼和響應體的代碼改成了簡單的錯誤拋出,代碼清晰了很多。

「提示」

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 是一個很棒的框架了吧?

想要學習更多精彩的實戰技術教程?來圖雀社區 [21] 逛逛吧。

Reference

[1]

mRc: https://github.com/mRcfps

[2]

圖雀社區: 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]

圖雀社區: https://tuture.co?utm_source=juejin_zhuanlan

公衆號

公衆號:前端食堂

知乎:童歐巴

掘金:童歐巴

這是一個終身學習的男人,他在堅持自己熱愛的事情,歡迎你加入前端食堂,和這個男人一起開心的變胖~

如果你覺得讀了本文有收穫的話可以點個在看讓我看到。閱讀過程中有任何問題、想法或者感觸也歡迎你在下方留言,也可以在後臺回覆加羣進入食堂的交流羣。

溝通創造價值,分享帶來快樂。也歡迎你分享給身邊有需要的同學,利他就是最好的利己。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-89DJuqLwyjQt1HjW6ED3g