Koa2從零到腳手架

山頭人漢波發表於2022-04-10

什麼是 Koa2

由 Express 原班人馬打造的新生代 Node.js Web 框架,它的程式碼很簡單,沒有像 Express 那樣,提供路由、靜態服務等等,它是為了解決 Node 問題(簡化了 Node 中操作)並取代之,它本身是一個簡單的中介軟體框架,需要配合各個中介軟體才能使用

文件

中文文件 (野生)

最簡單的 Koa 伺服器

const Koa = require('koa')

const app = new Koa()

app.use((ctx) => {
  ctx.body = 'Hello World'
})

app.listen(3000, () => {
  console.log('3000埠已啟動')
})

洋蔥模型

洋蔥模型

這是 Koa 的洋蔥模型

看看 Express 的中介軟體是什麼樣的:

Express的中介軟體

請求(Request)直接依次貫穿各個中介軟體,最後通過請求處理函式返回響應(Response)。再來看看 Koa 的中介軟體是什麼樣的:

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.boby
ctx.status // 相當於 ctx.response.status
更多 Context 屬性請參考 Context API 文件

中介軟體的第二個引數便是 next 函式:用來把控制權轉交給下一個中介軟體。但它與 Express 的 next 函式本質的區別在於, Koa 的 next 函式返回的是一個 Promise ,在這個 Promise 進入完成狀態(Fulfilled)後,就會去執行中介軟體中第二個階段的程式碼。

有哪些常見的中介軟體

路由中介軟體——koa-router或@koa/router

下載 npm 包

npm install koa-router --save
有些教程使用 @koa/router,現如今這兩個庫由同一個人維護,程式碼也一致。即 koa-router === @koa/router(寫自2021年8月23日)

NPM包地址:koa-router@koa/router

如何使用

在根目錄下建立 controllers 目錄,用來存放控制器有關的程式碼。首先是 HomeController,建立 controllers/home.js,程式碼如下:

class HomeController {
  static home(ctx) {
    ctx.body = 'hello world'
  }
  static async login(ctx) {
    ctx.body = 'Login Controller'
  }
  static async register(ctx) {
    ctx.body = 'Register Controller'
  }
}

module.exports = HomeController;

實現路由

再建立 routes 資料夾,用於把控制器掛載到對應的路由上面,建立 home.js

const Router = require('koa-router')
const { home, login, register } = require('../controllers/home')

const router = new Router()

router.get('/', home)
router.post('/login', login)
router.post('/register', register)

module.exports = router

註冊路由

在 routes 中建立 index.js,以後所有的路由都放入 routes,我們建立 index.js 的目的是為了讓結構更加整齊,index.js 負責所有路由的註冊,它的兄弟檔案負責各自的路由

const fs = require('fs')
module.exports = (app) => {
  fs.readdirSync(__dirname).forEach((file) => {
    if (file === 'index.js') {
      return
    }
    const route = require(`./${file}`)
    app.use(route.routes()).use(route.allowedMethods())
  })
}

注:allowedMethods 的作用

  1. 響應 option 方法,告訴它所支援的請求方法
  2. 相應地返回 405 (不允許)和 501 (沒實現)

注:可以看到 @koa/router 的使用方式基本上與 Express Router 保持一致

引入路由

最後我們需要將 router 註冊為中介軟體,新建 index.js,編寫程式碼如下:

const Koa = require('koa')
const routing = require('./routes')

// 初始化 Koa 應用例項
consr app = new Koa()

// 註冊中介軟體
// 相應使用者請求
routing(app)

// 執行伺服器
app.listen(3000);

使用 postman 測試一下

測試路由

其他中介軟體

  • koa-bodyparser ——請求體解析
  • koa-static —— 提供靜態資源服務
  • @koa/cors —— 跨域
  • koa-json-error —— 處理錯誤
  • koa-parameter —— 引數校驗
cnpm i koa-bodyparser -S 
cnpm i koa-static -S
cnpm i @koa/cors -S
cnpm i koa-json-error -S
cnpm i koa-parameter -S
const path = require('path')
const Koa = require('koa')
const bobyParser = require('koa-bodyparser')
const koaStatic = require('koa-static')
const cors = require('@koa/cors')
const error = require('koa-json-error')
const parameter = require('koa-parameter')
const routing = require('./routes')

const app = new Koa()

app.use(
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === 'production' ? rest : { stack, ...rest },
  }),
)
app.use(bobyParser())
app.use(koaStatic(path.join(__dirname, 'public')))
app.use(cors())
app.use(parameter(app))
routing(app)

app.listen(3000, () => {
  console.log('3000埠已啟動')
})

實現 JWT 鑑權

JSON Web Token(JWT)是一種流行的 RESTful API 鑑權方案

先安裝相關的 npm 包

cnpm install koa-jwt jsonwebtoken -S

建立 config/index.js ,用來存放 JWT Secret 常量,程式碼如下:

const JWT_SECRET = 'secret'

module.exports = {
  JWT_SECRET,
}

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

加入JWT後的洋蔥模型

可以看出,所有的請求都可以直接訪問未受保護的路由,但是受保護的路由都放在 JWT 中介軟體的後面,我們需要再建立幾個檔案來做 JWT 的實驗

我們知道,所謂的使用者(users)是個最常見的需要鑑權的路由,所以我們現在 controllers 中建立 user.js ,寫下如下程式碼:

class UserController {
  static async create(ctx) {
    ctx.status = 200
    ctx.body = 'create'
  }
  static async find(ctx) {
    ctx.status = 200
    ctx.body = 'find'
  }
  static async findById(ctx) {
    ctx.status = 200
    ctx.body = 'findById'
  }
  static async update(ctx) {
    ctx.status = 200
    ctx.body = 'update'
  }
  static async delete(ctx) {
    ctx.status = 200
    ctx.body = 'delete'
  }
}

module.exports = UserController

註冊 JWT 中介軟體

使用者的增刪改查都安排上了,語義很明顯了,其次我們在 routes 檔案中建立 user.js,這裡展示與 users 路由相關的程式碼:

const Router = require('koa-router')
const jwt = require('koa-jwt')
const {
  create,
  find,
  findById,
  update,
  delete: del,
} = require('../controllers/user')

const router = new Router({ prefix: '/users' })
const { JWT_SECRET } = require('../config/')

const auth = jwt({ JWT_SECRET })

router.post('/', create)
router.get('/', find)
router.get('/:id', findById)
router.put('/:id', auth, update)
router.delete('/:id', auth, del)

module.exports = router

綜上程式碼,routes 檔案下的 home.js 都不需要 JWT 中介軟體的保護,user.js 中的 更新和刪除需要 JWT 的保護

測試一下,能看出 JWT 已經起作用了

測試JWT

我們到目前為止,完成了對 JWT 的驗證,但是驗證的前提是先簽發 JWT,怎麼簽發,你登入的時候我給你一個簽好名的 token,要更新/刪除時在請求頭中帶上 token,我就能校驗...

這裡牽扯到登入,我們先暫停一下,先補充資料庫的知識,讓專案更加完整

Mongoose 加入戰場

如果要做一個完整的專案,資料庫是必不可少的,與 Node 匹配的較好的是 NoSql 資料庫,其中以 Mongodb 為代表,當然如果我們要使用這一資料庫,需要按照相應的庫,而這個庫就是 mongoose

下載 mongoose

cnpm i mongoose -S

連線及配置

config/index.js 中新增 connectionStr 變數,代表 mongoose 連線的資料庫地址

const JWT_SECRET = 'secret'
const connectionStr = 'mongodb://127.0.0.1:27017/basic'

module.exports = {
  JWT_SECRET,
  connectionStr,
}

建立 db/index.js

const mongoose = require('mongoose')
const { connectionStr } = require('../config/')

module.exports = {
  connect: () => {
    mongoose.connect(connectionStr, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    })

    mongoose.connection.on('error', (err) => {
      console.log(err)
    })

    mongoose.connection.on('open', () => {
      console.log('Mongoose連線成功')
    })
  },
}

進入主檔案 index.js,修改配置並啟動

...
const db = require('./db/')
...

db.connect()

啟動服務 npm run serve,即 nodemon index.js,能看出 mongoose 已經連線成功了

nodemon

建立資料模型定義

在根目錄下建立 models 目錄,用來存放資料模型定義檔案,在其中建立 User.js,代表使用者模型,程式碼如下:

const mongoose = require('mongoose')

const schema = new mongoose.Schema({
  username: { type: String },
  password: { type: String },
})

module.exports = mongoose.model('User', schema)

具體可以看看 Mongoose 這篇文章,這裡我們就看行為,以上程式碼表示建立了一個資料物件,供操作器來運算元據庫

在 Controller 中運算元據庫

然後就可以在 Controller 中進行資料的增刪改查操作。首先我們開啟 constrollers/user.js

const User = require('../models/User')

class UserController {
  static async create(ctx) {
    const { username, password } = ctx.request.body
    const model = await User.create({ username, password })
    ctx.status = 200
    ctx.body = model
  }
  static async find(ctx) {
    const model = await User.find()
    ctx.status = 200
    ctx.body = model
  }
  static async findById(ctx) {
    const model = await User.findById(ctx.params.id)
    ctx.status = 200
    ctx.body = model
  }
  static async update(ctx) {
    const model = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body)
    ctx.status = 200
    ctx.body = model
  }
  static async delete(ctx) {
    await User.findByIdAndDelete(ctx.params.id)
    ctx.status = 204
  }
}

module.exports = UserController

以上程式碼中,

  • User.create({xxx}):在 User 表中建立一個資料
  • User.find():檢視所有的 User 表中的資料
  • User.findById(id):檢視 User 表中的其中一個
  • User.findByIdAndUpdate(id, body):更新 User 表中的其中一個資料
  • User.findByIdAndDelete(id):刪除 User 表中的其中一個資料

以上就是對資料庫的增刪改查

加鹽

這個我們需要對密碼進行一下加密,無它,安全。

進資料庫一查,就能看到密碼,這說明資料對開發人員是公開的,開發人員可以拿著使用者的賬號密碼做任何事,這是不被允許的

資料庫中的使用者表

下載 npm 包——bcrypt

cnpm i bcrypt --save

我們前往 models/User.js 中,對其進行改造

...
const schema = new mongoose.Schema({
  username: { type: String },
  password: {
    type: String,
    select: false,
    set(val) {
      return require('bcrypt').hashSync(val, 10)
    },
  },
})
...

新增 select:false 不可見,set(val) 對值進行加密,我們來測試一下

建立李四

能看到 password 被加密了,即使在資料庫裡,也看不出使用者的密碼,那使用者輸入的密碼難道輸入這麼一串密碼,顯然不是,使用者要是輸入的話,我們要對其進行驗證,例如我們的登入

我們進入 constrollers/home 檔案中,對其進行改造,

...
class HomeController {
  static async login(ctx) {
    const { username, password } = ctx.request.body
    const user = await User.findOne({ username }).select('+password')
    const isValid = require('bcrypt').compareSync(password, user.password)
    ctx.status = 200
    ctx.body = isValid
  }
  ...
}
  • User.findOne({ username }) 能查到到沒有 password 的資料,因為我們人為的把 select 設為 false,如果要看,加上 select('+password') 即可
  • require('bcrypt').compareSync(password, user.password) 將使用者輸入的明文密碼和資料庫中的加密密碼進行驗證,為 true 是正確,false 為密碼不正確

回到 JWT

在 Login 中籤發 JWT Token

我們需要提供一個 API 埠讓使用者可以獲取到 JWT Token,最合適的當然是登入介面 /login ,開啟 controllers/home.js,在 login 控制器中實現簽發 JWT Token 的邏輯,程式碼如下:

const jwt = require('jsonwebtoken')
const User = require('../models/User')

const { JWT_SECRET } = require('../config/')

class HomeController {
  static async login(ctx) {
    const { username, password } = ctx.request.body

    // 1.根據使用者名稱找使用者
    const user = await User.findOne({ username }).select('+password')
    if (!user) {
      ctx.status = 422
      ctx.body = { message: '使用者名稱不存在' }
    }
    // 2.校驗密碼
    const isValid = require('bcrypt').compareSync(password, user.password)
    if (isValid) {
      const token = jwt.sign({ id: user._id }, JWT_SECRET)
      ctx.status = 200
      ctx.body = token
    } else {
      ctx.status = 401
      ctx.body = { message: '密碼錯誤' }
    }
  }
  ...
}

login 中,我們首先根據使用者名稱(請求體中的 name 欄位)查詢對應的使用者,如果該使用者不存在,則直接返回 401;存在的話再通過 (bcrypt').compareSync 來驗證請求體中的明文密碼 password 是否和資料庫中儲存的加密密碼是否一致,如果一致則通過 jwt.sign 簽發 Token,如果不一致則還是返回 401。

在 User 控制器中新增訪問控制

Token 的中介軟體和簽發都搞定之後,最後一步就是在合適的地方校驗使用者的 Token,確認其是否有足夠的許可權。最典型的場景便是,在更新或刪除使用者時,我們要確保是使用者本人在操作。開啟 controllers/user.js

const User = require('../models/User')

class UserController {
  ...
  static async update(ctx) {
    const userId = ctx.params.id
    if (userId !== ctx.state.user.id) {
      ctx.status = 403
      ctx.body = {
        message: '無權進行此操作',
      }
      return
    }
    const model = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body)
    ctx.status = 200
    ctx.body = model
  }
  static async delete(ctx) {
    const userId = ctx.params.id

    if (userId !== ctx.state.user.id) {
      ctx.status = 403
      ctx.body = { message: '無權進行此操作' }
      return
    }

    await User.findByIdAndDelete(ctx.params.id)
    ctx.status = 204
  }
}

module.exports = UserController

新增了一些使用者並登入,將 Token 新增到請求頭中,使用 DELETE 刪除使用者,能看到 狀態碼變成 204,刪除成功

刪除使用者操作

斷言處理

在做登入時、更新使用者資訊、刪除使用者時,我們需要if else 來判斷,這看起來很蠢,如果我們能用斷言來處理,程式碼在看上去會優雅很多,這個時候 http-assert 就出來了

// constrollers/home.js
...
const assert = require('http-assert')


class HomeController {
  static async login(ctx) {
    const { username, password } = ctx.request.body
    // 1.根據使用者名稱找使用者
    const user = await User.findOne({ username }).select('+password')
    // if (!user) {
    //   ctx.status = 401
    //   ctx.body = { message: '使用者名稱不存在' }
    // }
    assert(user, 422, '使用者不存在')
    // 2.校驗密碼
    const isValid = require('bcrypt').compareSync(password, user.password)
    assert(isValid, 422, '密碼錯誤')
    const token = jwt.sign({ id: user._id }, JWT_SECRET)
    ctx.body = { token }
  }
   ...
}

同理,處理 controllers/user

...
  static async update(ctx) {
    const userId = ctx.params.id
    assert(userId === ctx.state.user.id, 403, '無權進行此操作')
    const model = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body)
    ctx.status = 200
    ctx.body = model
  }
  static async delete(ctx) {
    const userId = ctx.params.id
    assert(userId === ctx.state.user.id, 403, '無權進行此操作')
    await User.findByIdAndDelete(ctx.params.id)
    ctx.status = 204
  }
...

程式碼看起來就是整潔清爽

引數校驗

之前我們加了一箇中介軟體——koa-parameter,我們當初只是註冊了這個中介軟體,但是未使用,我們在建立使用者時需要判斷使用者名稱和密碼的資料型別為 String 型別且必填,進入 controllers/user.js 新增程式碼如下:

...
class UserController {
  static async createUser(ctx) {
    ctx.verifyParams({
      username: { type: 'string', required: true },
      password: { type: 'string', required: true },
    })
    const { username, password } = ctx.request.body
    const model = await User.create({ username, password })
    ctx.status = 200
    ctx.body = model
  }
  ...
}

Github地址:koa-basic

參考資料

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

相關文章