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

本文由圖雀社區成員 mRc 寫做而成,歡迎加入圖雀社區,一塊兒創做精彩的免費技術教程,予力編程行業發展。html

若是您以爲咱們寫得還不錯,記得 點贊 + 關注 + 評論 三連,鼓勵咱們寫出更好的教程💪node

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

起步

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

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

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

注意算法

這篇文章不會涉及 Koa 源碼級別的原理分析,重心會放在讓你徹底掌握如何去使用 Koa 及周邊生態去開發 Web 應用,並欣賞 Koa 的設計之美。此外,這篇教程比較長,若是一杯茶不夠的話能夠續杯~sql

預備知識

本教程假定你已經具有了如下知識:typescript

  • JavaScript 語言基礎知識(包括一些經常使用的 ES6+ 語法)
  • Node.js 基礎知識,還有 npm 的基本使用,能夠參考這篇教程進行學習
  • TypeScript 基礎知識,只需瞭解簡單的類型註解就能夠了,能夠參考咱們的 TypeScript 系列教程
  • *(非必須)*Express 框架基礎知識,對於體驗 Koa 之美大有幫助,並且在本文中咱們會大量穿插和 Express 的對比,可參考這篇教程進行學習

所用技術

  • Node.js:10.x 及以上
  • npm:6.x 及以上
  • Koa:2.x
  • MySQL:推薦穩定的 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 服務器

建立 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,所以接下來無需關閉服務器,修改代碼保存後會自動加載最新的代碼並運行。

第一個 Koa 中間件

嚴格意義上來講,Koa 只是一箇中間件框架,正如它的介紹所說:

Expressive middleware for node.js using ES2017 async functions.(經過 ES2017 async 函數編寫富有表達力的 Node.js 中間件)

下面這個表格更能說明 Koa 和 Express 的鮮明對比:

能夠看到,Koa 實際上對標的是 Connect(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 文檔

中間件的第二個參數即是 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

路由規劃

在這篇教程中,咱們將實現如下路由:

  • GET /users :查詢全部的用戶
  • GET /users/:id :查詢單個用戶
  • PUT /users/:id :更新單個用戶
  • DELETE /users/:id :刪除單個用戶
  • POST /users/login :登陸(獲取 JWT Token)
  • POST /users/register :註冊用戶

實現 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 庫 TypeORM。

數據庫的準備工做

首先,請安裝和配置好 MySQL 數據庫,能夠經過兩種方式:

  • 官網下載安裝包,這裏是下載地址
  • 使用 MySQL Docker 鏡像

在確保 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"
  }
}
複製代碼

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

  • database 就是咱們剛剛建立的 koa 數據庫
  • synchronize 設爲 true 可以讓咱們每次修改模型定義後都能自動同步到數據庫*(若是你接觸過其餘的 ORM 庫,其實就是自動數據遷移)*
  • entities 字段定義了模型文件的路徑,咱們立刻就來建立

接着修改 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({ select: false })
  password: string;

  @Column()
  email: string;
}
複製代碼

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

  • Entity 用於裝飾整個類,使其變成一個數據庫模型
  • Column 用於裝飾類的某個屬性,使其對應於數據庫表中的一列,可提供一系列選項參數,例如咱們給 password 設置了 select: false ,使得這個字段在查詢時默認不被選中
  • PrimaryGeneratedColumn 則是裝飾主列,它的值將自動生成

提示

關於 TypeORM 全部的裝飾器定義及其詳細使用,請參考其裝飾器文檔

在 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,請參考這裏的文檔

細心的你應該還發現咱們經過 ctx.request.body 獲取到了請求體的數據,這是咱們在第一步就配置好的 bodyParser 中間件在 Context 對象中添加的。

而後咱們修改 AuthController ,實現具體的註冊邏輯。因爲密碼不能明文保存在數據庫中,須要使用非對稱算法進行加密,這裏咱們使用曾經得到過密碼加密大賽冠軍的 Argon2 算法。安裝對應的 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 鑑權,可是不會過多講解其原理(可參考這篇文章進行學習)。

首先安裝相關的 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 的更多使用細節,請參考其文檔

在 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 的設計。出於學習目的,這裏做了簡化,而且只實現了咱們須要用到的錯誤。

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

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

相關文章
相關標籤/搜索