动态

详情 返回 返回

Koa2從零到腳手架 - 动态 详情

什麼是 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 開發

user avatar pengxiaohei 头像 shuirong1997 头像 wmbuke 头像 munergs 头像 xiao2 头像 wnhyang 头像 immerse 头像 hu_qi 头像 changlina 头像 aigoto 头像 compose_hub 头像 fkcaikengren 头像
点赞 47 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.