什麼是 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 的中間件是什麼樣的:
請求(Request)直接依次貫穿各個中間件,最後通過請求處理函數返回響應(Response)。再來看看 Koa 的中間件是什麼樣的:
可以看出,Koa 中間件不像 Express 中間件那樣在請求通過了之後就完成自己的使命;相反,中間件的執行清晰地分為兩個階段。我們看看 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.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 的作用
- 響應 option 方法,告訴它所支持的請求方法
- 相應地返回 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 的實驗
我們知道,所謂的用户(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,怎麼簽發,你登錄的時候我給你一個簽好名的 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 已經連接成功了
創建數據模型定義
在根目錄下創建 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 開發