動態

詳情 返回 返回

koa實踐總結 - 動態 詳情

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

image-20210627084008790

路由處理

koa中處理相應的路由返回對應的響應這一開發過程類似java中編寫controllerrestful風格的路由可以非常語義化的根據業務場景編寫對應的處理函數,前端利用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服務

這時,我們再訪問試試:

image-20210627085327472

結果按我們預期返回了,這時我們先解決上訴的問題,如何熱更新代碼來幫助我們提高開發效率

  1. 安裝nodemon

    npm install nodemon
    npm i nodemon -g // 建議直接全局安裝
  1. 修改package.js
"scripts": {
  "start": "nodemon app/index.js"
}

運行npm run start再次啓動服務,這時修改代碼後只需要刷新瀏覽器即可,不用重啓node服務了!

可以預見的是:上面對路由的處理在實戰中是不可行的,api逐漸增加後需要考慮到系統的可維護性,koa-router應運而生。

koa-router: 集中處理URL的中間件,它負責處理URL映射,根據不同的URL調用不同的處理函數,這樣,我們就能能專心為每個URL編寫處理函數

app 目錄下新建 router 目錄,如下所示:

image-20210627090757106

安裝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中,如下:

image-20210627092430698

我們新增了三個文件:

  • 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

image-20210627093721213

結果如逾期正常返回,測試成功。

參數解析

測試完get請求後再請求一個post請求,path為/postTest,參數為name: wangcong,請求如下:

image-20210627102933947

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

image-20210627102601854

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請求測試

image-20210627102758998

獲取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-bodyparserkoa-bodykoa-body 主要是下面兩個依賴:

"co-body": "^5.1.1",
"formidable": "^1.1.1"

官方這樣對它做了介紹

A full-featured koa body parser middleware. Supports multipart, urlencoded, and json request 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兩個文件夾,各自目錄分別為:

image-20210627163251606

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

image-20210627163452123

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}`)
});

測試上傳功能:

image-20210627164254743

注意:KoaBody配置中,keepExtensions: true必須開啓,否則上傳不會成功!

查看app目錄下,生成了我們剛剛上傳的文件

image-20210627164506045

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

image-20210627164652759

統一響應體 & 錯誤處理

統一 格式處理返回響應,可以充分利用洋葱模型進行傳遞,我們可以編寫兩個中間件,一個統一返回格式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 = '這是一段文字...';
}

再次請求看看跟之前有什麼不一樣

image-20210627171126461

結果如逾期返回,再進行模擬錯誤的返回,修改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再次請求測試:

image-20210627173550538

結果如預期返回

修改test.js為koa運行時的代碼錯誤:

const echo = async ctx => {
  const data = '';
  data = 'a'; // 模擬語法錯誤
  ctx.body = '這是一段文字...';
}

再次請求,得到結果如下:

image-20210627173801715

至此,錯誤處理搞定了,統一返回格式也搞定。

參數校驗

參數校驗可以極大的避免上訴的程序運行時的錯誤,在這個例子裏,我們也將參數校驗放在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;
}

請求測試,正常傳參如下 :

image-20210628073806022

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

image-20210628073852996

可以預料的是,隨着業務場景複雜度的上升,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.jsprint路由新增一條校驗規則,如下:

{
  path: '/print',
  method: 'get',
  valid: schTest.print,
  controller: test.print
}

koa-router 允許添加多個路由級中間件,我們將參數校驗放在這裏處理。隨後在 app目錄下新建目錄 schema,用來存放參數校驗部分的代碼,添加兩個文件:

  1. app/schema/index.js:

    const schTest = require('./test');
    
    module.exports = {
     schTest
    };
  2. 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;
}

請求接口測試

image-20210628082630673

到這裏,參數校驗就算整合完成,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請求接口測試效果:

image-20210627174730031

打開日誌文件,查看日誌 :

[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實戰型項目到這裏就結束了,企業級開發中,還會有更多的問題需要解決,期待更加貼近企業級的實戰項目。

項目遠程地址

user avatar Leesz 頭像 zaotalk 頭像 aqiongbei 頭像 longlong688 頭像 banana_god 頭像 munergs 頭像 kitty-38 頭像 DingyLand 頭像 tanggoahead 頭像 lin494910940 頭像 it1042290135 頭像 licin 頭像
點贊 120 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.