什麼是koa?
koa是Express的下一代基於Node.js的web框架。使用 koa 編寫 web 應用,通過組合不同的 generator,可以免除重複繁瑣的回調函數嵌套,並極大地提升常用錯誤處理效率。Koa 不在內核方法中綁定任何中間件,它僅僅提供了一個輕量優雅的函數庫,使得編寫 Web 應用和API變得得心應手。
Koa能幹什麼?
主要用途
- 網站(比如cnode這樣的論壇)
- api(三端:pc、移動端、h5)
- 與其他模塊搭配,比如和socket.io搭配寫彈幕、im(即時聊天)等
koa是微型web框架,但它也是個Node.js模塊,也就是説我們也可以利用它做一些http相關的事兒。舉例:實現類似於http-server這樣的功能,在vue或react開發裏,在爬蟲裏,利用路由觸發爬蟲任務等。比如在bin模塊裏,集成koa模塊,啓動個static-http-server這樣的功能,都是非常容易的。
搭建項目啓動服務
// 1. 創建項目文件夾後初始化npm
npm init
// 2. 安裝koa環境
npm install koa
// 3. 根目錄下創建app文件夾作為我們源代碼的目錄
// 4. app下新建index.js作為入口文件
生成目錄結構如下:

編寫app/index.js
const Koa = require('koa');
const app = new Koa();
const port = '3333';
const host = '0.0.0.0';
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});
根目錄下運行node app/index.js,啓動成功後控制枱出現API server listening on 0.0.0.0:3333,打開瀏覽器訪問本機ip:3333

路由處理
koa中處理相應的路由返回對應的響應這一開發過程類似java中編寫controller,restful風格的路由可以非常語義化的根據業務場景編寫對應的處理函數,前端利用axios訪問服務端找到對應的函數(路由名字)來獲取對應想要的結果。
編寫app/index.js:
// app/index.js
const Koa = require('koa');
const app = new Koa();
const port = '3333';
const host = '0.0.0.0';
app.use(async ctx => {
const { path } = ctx;
console.log(path)
if (path === '/test1') {
ctx.body = 'response for test1';
} else if (path === '/test2') {
ctx.body = 'response for test2';
} else if (path === '/test3') {
ctx.body = 'response for test3';
} else {
ctx.body = 'Hello World';
}
});
app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});
注意:每次在koa中更新代碼後想要生效必須重啓koa服務
這時,我們再訪問試試:

結果按我們預期返回了,這時我們先解決上訴的問題,如何熱更新代碼來幫助我們提高開發效率
-
安裝
nodemonnpm install nodemon npm i nodemon -g // 建議直接全局安裝
- 修改
package.js
"scripts": {
"start": "nodemon app/index.js"
}
運行npm run start再次啓動服務,這時修改代碼後只需要刷新瀏覽器即可,不用重啓node服務了!
可以預見的是:上面對路由的處理在實戰中是不可行的,api逐漸增加後需要考慮到系統的可維護性,koa-router應運而生。
koa-router: 集中處理URL的中間件,它負責處理URL映射,根據不同的URL調用不同的處理函數,這樣,我們就能能專心為每個URL編寫處理函數
在 app 目錄下新建 router 目錄,如下所示:

安裝koa-router
npm install koa-router
編寫app/router/index.js
const koaRouter = require('koa-router');
const router = new koaRouter();
router.get('/test1', ctx => {
ctx.body = 'response for test1';
});
router.get('/test2', ctx => {
ctx.body = 'response for test2';
});
router.get('/test3', ctx => {
ctx.body = 'response for test3';
});
module.exports = router;
瀏覽器中再次訪問測試,http://192.168.0.197:3333/test3,返回response for test3,返回結果與之前一致。再次細想一下,實際公司的業務場景中,router/index.js中可能一個處理函數就會非常龐大,因此,路由文件我們只需要關心具體的路由,它對應的處理函數可以單獨提出來統一管理。我們可以把業務邏輯處理函數放到controller中,如下:

我們新增了三個文件:
app/router/routes.js路由列表文件app/contronllers/index.js業務處理統一導出app/contronllers/test.js業務處理文件
所有的業務邏輯代碼放到controller中管理,如app/contronllers/test.js所示:
const echo = async ctx => {
ctx.body = '這是一段文字...';
}
module.exports = {
echo
}
app/contronllers/index.js統一入口,管理導出
const test = require('./test');
module.exports = {
test
}
app/router/routes.js路由文件專心管理所有路由,無需維護對應業務邏輯代碼
const { test } = require('../controllers');
const routes = [
{
path: 'test1',
method: 'get',
controller: test.echo
}
];
module.exports = routes;
改造app/router/index.js
const koaRouter = require('koa-router');
const router = new koaRouter();
const routes = require('./routes');
routes.forEach(route => {
const { method, path, controller } = route;
// router 第一個參數是 path, 後面跟上路由級中間件 controller(上面編寫的路由處理函數)
router[method](path, controller);
});
module.exports = router;
打開瀏覽器訪問http://192.168.0.197:3333/test1

結果如逾期正常返回,測試成功。
參數解析
測試完get請求後再請求一個post請求,path為/postTest,參數為name: wangcong,請求如下:

打印出console.log('postTest', ctx)如下,好像並沒有找到我們傳入的參數'name',那如何獲取到post的請求體呢?

koa-bodyparser: 對於POST請求的處理,koa-bodyparser中間件可以把koa2上下文的formData數據解析到ctx.request.body中
安裝中間件之前,我們可以按照改造router的方式改造一下中間件的管理
新建app/midllewares目錄,添加index.js文件統一管理所有中間件
const router = require('../router');
// 路由處理,router.allowedMethods()用在了路由匹配router.routes()之後,所以在當所有路由中間件最後調用.此時根據ctx.status設置response響應頭
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();
// 導出數組是為後面使用koa-compose做準備,koa-compose支持傳入數組,數組裏的中間件一次同步執行
// 洋葱模型, 務必注意中間件的執行順序!!!
module.exports = [
mdRoute,
mdRouterAllowed
];
index.js文件裏集中了所有用到的中間件,接下來改造下啓動文件 app/index.js:
const Koa = require('koa');
const app = new Koa();
const compose = require('koa-compose');
const MD = require('./midllewares'); // 引入所有的中間件
const port = '3333';
const host = '0.0.0.0';
app.use(compose(MD)); // compose接收一箇中間件數組, 按順序同步執行!!!
app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});
compose 是一個工具函數,Koa.js 的中間件通過這個工具函數組合後,按 app.use() 的順序同步執行,也就是形成了 洋葱圈 式的調用。
引入 koa-bodyparser統一處理請求參數,注意:bodyParser 為了處理每個 Request 中的信息,要放到路由前面先讓他處理再進路由
// midllewares/index.js
const router = require('../router');
const koaBody = require('koa-bodyparser'); // bodyParser 就是為了處理每個 Request 中的信息,要放到路由前面先讓他處理再進路由
// 路由處理,router.allowedMethods()用在了路由匹配router.routes()之後,所以在當所有路由中間件最後調用.此時根據ctx.status設置response響應頭
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();
/**
* 參數解析
* https://github.com/koajs/bodyparser
*/
const mdKoaBody = koaBody({
enableTypes: ['json', 'form', 'text', 'xml'],
formLimit: '56kb',
jsonLimit: '1mb',
textLimit: '1mb',
xmlLimit: '1mb',
strict: true
})
// 洋葱模型, 務必注意中間件的執行順序!!!
module.exports = [
mdKoaBody,
mdRoute,
mdRouterAllowed
];
postman請求測試

獲取ctx.request.body成功!
引用koa-bodyparser文檔的一句話,可以看出來它並不支持二進制流來進行上傳,並且希望我們用co-busboy來解析multipart format data
Notice: this module don't support parsing multipart format data, please use co-busboy to parse multipart format data.
替換koa-bodyparser為koa-body,koa-body 主要是下面兩個依賴:
"co-body": "^5.1.1",
"formidable": "^1.1.1"
官方這樣對它做了介紹
A full-featuredkoabody parser middleware. Supportsmultipart,urlencoded, andjsonrequest bodies. Provides the same functionality as Express's bodyParser -multer.
修改app/midllewares/index.js:
const { tempFilePath } = require('../config');
const { checkDirExist } = require('../utils/file');
const router = require('../router');
const koaBody = require('koa-body'); // koa-body 就是為了處理每個 Request 中的信息,要放到路由前面先讓他處理再進路由
// 路由處理,router.allowedMethods()用在了路由匹配router.routes()之後,所以在當所有路由中間件最後調用.此時根據ctx.status設置response響應頭
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();
/**
* 參數解析
* https://github.com/koajs/bodyparser
*/
const mdKoaBody = koaBody({
multipart: true, // 支持文件上傳
// encoding: 'gzip', // 啓用這個會報錯
formidable: {
uploadDir: tempFilePath, // 設置文件上傳目錄
keepExtensions: true, // 保持文件的後綴
maxFieldsSize: 200 * 1024 * 1024, // 設置上傳文件大小最大限制,默認2M
onFileBegin: (name,file) => { // 文件上傳前的設置
// 檢查文件夾是否存在如果不存在則新建文件夾
checkDirExist(tempFilePath);
// 獲取文件名稱
const fileName = file.name;
// 重新覆蓋 file.path 屬性
file.path = `${tempFilePath}/${fileName}`;
},
onError:(err)=>{
console.log(err);
}
}
})
// 洋葱模型, 務必注意中間件的執行順序!!!
module.exports = [
mdKoaBody,
mdRoute,
mdRouterAllowed
];
其中,創建了config和utils兩個文件夾,各自目錄分別為:

config中文件目前只配置了上傳文件的臨時路徑,後面還可以配置一些不同環境下的配置相關:

utils文件夾下創建了一個file.js工具文件和`index.js統一導出文件,主要處理對文件相關(路徑、文件名等)的邏輯:
// utils/file.js
const fs = require('fs');
const path = require('path');
function getUploadDirName(){
const date = new Date();
let month = Number.parseInt(date.getMonth()) + 1;
month = month.toString().length > 1 ? month : `0${month}`;
const dir = `${date.getFullYear()}${month}${date.getDate()}`;
return dir;
}
// 創建目錄必須一層一層創建
function mkdir(dirname) {
if(fs.existsSync(dirname)){
return true;
} else {
if (mkdir(path.dirname(dirname))) {
fs.mkdirSync(dirname);
return true;
}
}
}
function checkDirExist(p) {
if (!fs.existsSync(p)) {
mkdir(p)
}
}
function getUploadFileExt(name) {
let idx = name.lastIndexOf('.');
return name.substring(idx);
}
function getUploadFileName(name) {
let idx = name.lastIndexOf('.');
return name.substring(0, idx);
}
module.exports = {
getUploadDirName,
checkDirExist,
getUploadFileExt,
getUploadFileName
}
// utils/index.js
const file = require('./file')
module.exports = {
file
}
在app/index.js引入全局公共部分,掛載到app.context上下文中:
// app/index.js
const Koa = require('koa');
const app = new Koa();
const compose = require('koa-compose');
const MD = require('./midllewares');
const config = require('./config');
const utils = require('./utils');
const port = '3333';
const host = '0.0.0.0';
app.context.config = config;
app.context.utils = utils;
app.use(compose(MD));
app.listen(port, host, () => {
console.log(`API server listening on ${host}:${port}`)
});
測試上傳功能:

注意:KoaBody配置中,keepExtensions: true必須開啓,否則上傳不會成功!
查看app目錄下,生成了我們剛剛上傳的文件

在koa-body @4中,控制枱打印文件相關信息用ctx.request.files,低版本使用ctx.request.body.files

統一響應體 & 錯誤處理
統一 格式處理返回響應,可以充分利用洋葱模型進行傳遞,我們可以編寫兩個中間件,一個統一返回格式middleware,一個錯誤處理middleware,分別如下:
文件app/midllewares/response.js
const response = () => {
return async (ctx, next) => {
ctx.res.fail = ({ code, data, msg }) => {
ctx.body = {
code,
data,
msg,
};
};
ctx.res.success = msg => {
ctx.body = {
code: 0,
data: ctx.body,
msg: msg || 'success',
};
};
await next();
};
};
module.exports = response;
文件 app/middlewares/error.js
const error = () => {
return async (ctx, next) => {
try {
await next();
if (ctx.status === 200) {
ctx.res.success();
}
} catch (err) {
if (err.code) {
// 自己主動拋出的錯誤
ctx.res.fail({ code: err.code, msg: err.message });
} else {
// 程序運行時的錯誤
ctx.app.emit('error', err, ctx);
}
}
};
};
module.exports = error;
app/middlewares/index.js引用它們:
const { tempFilePath } = require('../config');
const { checkDirExist } = require('../utils/file');
const router = require('../router');
const koaBody = require('koa-body'); // koa-body 就是為了處理每個 Request 中的信息,要放到路由前面先讓他處理再進路由
const response = require('./response');
const error = require('./error');
// 路由處理,router.allowedMethods()用在了路由匹配router.routes()之後,所以在當所有路由中間件最後調用.此時根據ctx.status設置response響應頭
const mdRoute = router.routes();
const mdRouterAllowed = router.allowedMethods();
/**
* 參數解析
* https://github.com/koajs/bodyparser
*/
const mdKoaBody = koaBody({
multipart: true, // 支持文件上傳, 必須設置為true!!!
// encoding: 'gzip', // 啓用這個會報錯
formidable: {
uploadDir: tempFilePath, // 設置文件上傳目錄
keepExtensions: true, // 保持文件的後綴
maxFieldsSize: 200 * 1024 * 1024, // 設置上傳文件大小最大限制,默認2M
onFileBegin: (name,file) => { // 文件上傳前的設置
// 檢查文件夾是否存在如果不存在則新建文件夾
checkDirExist(tempFilePath);
// 獲取文件名稱
const fileName = file.name;
// 重新覆蓋 file.path 屬性
file.path = `${tempFilePath}/${fileName}`;
},
onError:(err)=>{
console.log(err);
}
}
})
// 統一返回格式
const mdResHandler = response();
// 錯誤處理
const mdErrorHandler = error();
// 洋葱模型, 務必注意中間件的執行順序!!!
module.exports = [
mdKoaBody,
mdResHandler,
mdErrorHandler,
mdRoute,
mdRouterAllowed
];
再次強調一遍,app.use()中,中間件執行是按續同步執行,mdResHandler定義了兩種處理通道(成功和失敗),真正判斷邏輯在error.js中間件中,一種是業務型錯誤碼,需要返回給前端進行處理,另一種是服務端代碼運行時報錯,這種錯誤類型我們需要出發koa的錯誤處理事件去處理。error.js中判斷處理後都是調用mdResHandler統一返回格式返回請求響應。針對服務端運行時代碼錯誤,我們還需要做出修改,在app/index.js中修改代碼如下:
app.on('error', (err, ctx) => {
if (ctx) {
ctx.body = {
code: 9999,
message: `程序運行時報錯:${err.message}`
};
}
});
完成後,我們還是利用之前的controller/ap/test.js中echo的代碼:
const echo = async ctx => {
ctx.body = '這是一段文字...';
}
再次請求看看跟之前有什麼不一樣

結果如逾期返回,再進行模擬錯誤的返回,修改test.js下的echo函數如下:
// test.js
const { throwError } = require('../utils/handle');
const echo = async ctx => {
const data = '';
ctx.assert(data, throwError(50002, 'token失效!'));
// 不會往下執行了
ctx.body = '這是一段文字...';
}
// utils/handle.js
const assert = require('assert');
const throwError = (code, message) => {
const err = new Error(message);
err.code = code;
throw err;
};
module.exports = {
assert,
throwError
};
postman再次請求測試:

結果如預期返回
修改test.js為koa運行時的代碼錯誤:
const echo = async ctx => {
const data = '';
data = 'a'; // 模擬語法錯誤
ctx.body = '這是一段文字...';
}
再次請求,得到結果如下:

至此,錯誤處理搞定了,統一返回格式也搞定。
參數校驗
參數校驗可以極大的避免上訴的程序運行時的錯誤,在這個例子裏,我們也將參數校驗放在controller裏面去完成,test.js新增一個業務處理函數print用於返回前端姓名,打印在頁面上:
const print = async ctx => {
const { name } = ctx.request.query;
if (!name) {
ctx.utils.handle.assert(false, ctx.utils.handle.throwError(10001, '參數錯誤'));
}
ctx.body = '打印姓名: ' + name;
}
請求測試,正常傳參如下 :

不傳參數,返回錯誤狀態碼10001:

可以預料的是,隨着業務場景複雜度的上升,controller層後面對於參數校驗的部分代碼會變得越來越龐大,所以這部分一定是可以優化的,第三方插件 joi 就是應對這種場景,我們可以藉助此中間件幫助我們完成參數校驗。在 app/middlewares/ 下添加 validator.js 文件:
module.exports = paramSchema => {
return async function (ctx, next) {
let body = ctx.request.body;
try {
if (typeof body === 'string' && body.length) body = JSON.parse(body);
} catch (error) {}
const paramMap = {
router: ctx.request.params,
query: ctx.request.query,
body
};
if (!paramSchema) return next();
const schemaKeys = Object.getOwnPropertyNames(paramSchema);
if (!schemaKeys.length) return next();
schemaKeys.some(item => {
const validObj = paramMap[item];
const validResult = paramSchema[item].validate(validObj, {
allowUnknown: true
});
if (validResult.error) {
ctx.assert(false, ctx.utils.handle.throwError(9998, validResult.error.message));
}
});
await next();
};
};
修改app/router/index.js:
const koaRouter = require('koa-router');
const router = new koaRouter();
const routes = require('./routes');
const validator = require('../midllewares/validator');
routes.forEach(route => {
const { method, path, controller, valid } = route;
router[method](path, validator(valid), controller);
});
module.exports = router;
可以看到,route中多解構了一個valid來作為validator的參數,app/router/routes.js中print路由新增一條校驗規則,如下:
{
path: '/print',
method: 'get',
valid: schTest.print,
controller: test.print
}
koa-router 允許添加多個路由級中間件,我們將參數校驗放在這裏處理。隨後在 app目錄下新建目錄 schema,用來存放參數校驗部分的代碼,添加兩個文件:
-
app/schema/index.js:const schTest = require('./test'); module.exports = { schTest }; -
app/schema/test.js:const Joi = require('@hapi/joi'); const print = { query: Joi.object({ name: Joi.string().required(), age: Joi.number().required() }) }; module.exports = { list };
把之前app/controller/test.js手動校驗部分刪掉 ,測試joi中間件是否生效:
const print = async ctx => {
const { name } = ctx.request.query;
ctx.body = '打印姓名: ' + name;
}
請求接口測試

到這裏,參數校驗就算整合完成,joi 更多的使用方法請查看文檔
配置跨域
使用@koa/cors插件來進行跨域配置,app/middlewares/index.js添加配置,如下:
// ...省略其他配置
const cors = require('@koa/cors'); // 跨域配置
// 跨域處理
const mdCors = cors({
origin: '*',
credentials: true,
allowMethods: [ 'GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH' ]
});
module.exports = [
mdKoaBody,
mdCors,
mdResHandler,
mdErrorHandler,
mdRoute,
mdRouterAllowed
];
日誌
採用 log4js 來記錄請求日誌,添加文件 app/middlewares/log.js :
const log4js = require('log4js');
const { outDir, flag, level } = require('../config').logConfig;
log4js.configure({
appenders: { cheese: { type: 'file', filename: `${outDir}/receive.log` } },
categories: { default: { appenders: [ 'cheese' ], level: 'info' } },
pm2: true
});
const logger = log4js.getLogger();
logger.level = level;
module.exports = () => {
return async (ctx, next) => {
const { method, path, origin, query, body, headers, ip } = ctx.request;
const data = {
method,
path,
origin,
query,
body,
ip,
headers
};
await next();
if (flag) {
const { status, params } = ctx;
data.status = status;
data.params = params;
data.result = ctx.body || 'no content';
if (ctx.body.code !== 0) {
logger.error(JSON.stringify(data));
} else {
logger.info(JSON.stringify(data));
}
}
};
};
在 app/middlewares/index.js 中引入上面寫的日誌中間件:
const log = require('./log'); // 添加日誌
// ...省略其他代碼
// 記錄請求日誌
const mdLogger = log();
module.exports = [
mdKoaBody,
mdCors,
mdLogger,
mdResHandler,
mdErrorHandler,
mdRoute,
mdRouterAllowed
];
利用postman請求接口測試效果:

打開日誌文件,查看日誌 :
[2021-06-27T17:45:53.803] [INFO] default - {"method":"GET","path":"/test1","origin":"http://192.168.0.197:3333","query":{},"body":{},"ip":"192.168.0.197","headers":{"host":"192.168.0.197:3333","connection":"keep-alive","cache-control":"no-cache","user-agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36","postman-token":"ae200806-a92b-00c4-5f2d-d5afdb7d717c","accept":"*/*","accept-encoding":"gzip, deflate","accept-language":"zh-CN,zh;q=0.9,en;q=0.8"},"status":200,"params":{},"result":{"code":0,"data":"這是一段文字...","msg":"success"}}
到這裏,日誌模塊引用成功!
數據庫操作
在 app 下再新增一個 service 目錄, 之後的數據庫操作放在 service 目錄下,controller專注業務處理,service專注數據庫的增刪改查等事務操作。還可以添加一個 model 目錄,用來定義數據庫表結構,具體的操作將在之後的koa應用實戰中具體展示。
總結
基本的koa實戰型項目到這裏就結束了,企業級開發中,還會有更多的問題需要解決,期待更加貼近企業級的實戰項目。
項目遠程地址