koa打造项目过程记录

前段时间公司有几个前端lead的项目,全由前端自主完成。开发过程中在node端重新搭建了一套通用的基础框架,主要基于koa+koa-joi-router+mysql+ioredis搭建,在此简单记录一下框架的整体架构。

模块

  • koa
  • koa-body // 帮助解析 http 中 body 部分的中间件
  • koa-joi-router // 帮助结合joi验证功能的路由中间件
  • nodemon //热更新node
  • mysql2 // mysql数据库
  • ioredis // redis数据库

业务分层

Router(路由层)

定义应用的路由规则,将不同的URL路由映射到相应的Controller上。

Controller层(控制器层)

负责接收客户端的请求,处理输入参数,并用相应的Service方法来完成业务逻辑。

Service层(服务层)

核心的业务逻辑、对数据的处理、计算、增删改查,与数据库的交互

Middleware层(中间件层)

处理通用的、跨请求的功能,日志、鉴权、错误处理、跨域等。

Config层(配置层)

mysql、redis数据库连接配置、端口配置、集群配置等。

server
  -routers
    -api1Router.js
    -api2Router.js
    -index.js
  -controllers
    -controller1.js
    -controller2.js
  -service
    -service1.js
    -service2.js
    -index.js
  -middleware
    -cors.js
    -auth.js
    -index.js
  -config
    -dev-config.js
    -rpod-config.js
utils
    -mysqlUtil.js
    -redisUtil.js
    -cookies.js
    -log.js
app.js // 入口文件

各层业务介绍(伪代码)

入口文件app.js
const Koa = require('koa');
const routers = require('./server/routers');
const middleware = require('./server/middleware');
const app = new Koa();
middleware(app);
routers.apiRouters(app);
app.listen(3000);
Middleware层(中间件层)
const cors = require('./cors');
const auth = require('./auth');
const { koaBody } = require('koa-body');
module.export=function(app){
  cors(app) // 处理跨域
  app.use(koaBody({ // 格式化响应体
    multipart: true,
    formLimit: '300mb',
  }))
 auth(app) // 处理鉴权
 。。。
}

 // cors.js (也可以使用 koa-cors插件实现)
module.exports = function(app) { // 这块还有待优化,当时post请求被OPTIONS阻塞了
  app.use(async (ctx, next)=>{
    if(ctx.method==='OPTIONS'){ // 避免post请求被卡住
      ctx.status = 204;
      ctx.set('Access-Control-Allow-Origin', '*');
      ctx.set('Access-Control-Allow-Headers', 'X-Requested-With,content-type');
      ctx.set('Access-Control-Allow-Credentials', true);
      ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
      ctx.body = '';
      await next()
    }else{
      await next()
    }
  })
  app.use(async (ctx, next)=>{
    const whiteOrigin = ['http://aaa.com'];
    const origin = ctx.header.origin;
    if(whiteOrigin.includes(origin)){ // 白名单内允许跨域
      ctx.set('Access-Control-Allow-Origin', origin);
    }  
    ctx.set('Access-Control-Allow-Headers', 'X-Requested-With,content-type');
    ctx.set('Access-Control-Allow-Credentials', true);
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    await next();
  })
}
Router(路由层)
const apiRouter = require('koa-router')();
const api1Router = require('./api1Router');
const api2Router = require('./api2Router');

const routers = {
   apiRouters: (app) => {
    apiRouter.use('/base1', api1Router.middleware()); // 子路由1
    apiRouter.use('/base2', api2Router.middleware()); // 子路由2
    app.use(apiRouter.routes());
  },
};
-----------------------------------------------------
api1Router.js / api2Router.js
-----------------------------------------------------
const controller1 = require('../controller/controller1');
// 主要使用koa-joi-router
const router = require('koa-joi-router');
const Joi = router.Joi;
const router1 = router();
router1.get(
  'xxx.json',
  validate: {
        continueOnError: true,
        query: {
           name: Joi.required(),
           id: Joi.required(),
           optional:  Joi.optional(), // 可选
        },
    },(ctx)=>{
      if (ctx.invalid) {
            logger.error(ctx.invalid.msg || '参数错误'); //日志
            throw new Error(APIError.Type.ERR_JOI_VALIDATE(ctx));
        }
        await controller1.getData(ctx);
    },
router1.post(
  'xxx.json',
  validate: {
    type: 'json',
    body: Joi.object({
      id: Joi.string().required(),
      name: Joi.string().required(),
    }).options({
      allowUnknown: true,
    }),
    continueOnError: true,
  },(ctx)=>{
      if (ctx.invalid) {
          ctx.type = 'application/json';
          ctx.body = {
          data: '参数错误',
          error_code: 400,
          error_description: '',
      };
        logger.error(ctx.invalid.msg || '参数错误');
        return;
      // throw new APIError(APIError.Type.ERR_JOI_VALIDATE(ctx)); 之前直接报错,被爬虫把sre报错日志打爆表,不能再这么处理
    }
        await controller1.setData(ctx);
    }
)
module.exports = router;
Controller层(控制器层)
const service1 = require('../server/server1');
class Controller1 {
    async getData(ctx) {
        const {
            id,
            optional
        } = ctx.request.query;
        const result = await service1.getData(id,optional);
        ctx.type = 'application/json';
        ctx.body = {
          data: result,
          error_code: 0,
          error_description: '',
        };
    }
}
module.exports = new Controller1();
-------------------------------------
class Controller2 {
    async setData(ctx) {
      const {
        id, name, optional,
      } = ctx.request.body;
        const result = await service1.setData(id, name, optional);
        ctx.type = 'application/json';
        ctx.body = {
          data: result,
          error_code: 0,
          error_description: '',
        };
    }
}
module.exports = new Controller2();
Service层(服务层)
// service1.js
const db = require('../utils/mysqlUtil');
const TABLE_NAME = 'TABLE_NAME'; // 表名
const SQL = {
  insert: `INSERT INTO ${TABLE_NAME}(id,name,optional) VALUES(?,?,?)`,
  queryByID:(id)=> `SELECT * FROM ${TABLE_NAME} WHERE id=${id}`,
};
class Service1 {
  constructor(){
    
  }
  async getData(id){
    try {
      const [info] = await db.query(SQL.queryByID(id));
      if (info.length) {
        return info[0];
      }
      throw new Error(APIError.Type.ERR_NOT_FOUND);
    } catch (error) {      
      if (error.code) {
        throw new Error(error);
      } else {
        throw new Error({
            status:500,
            code: 1000,
            message: '未知错误',
        });
      }
    }
  }
  async setData(id,name, optional){
    try {
     const [info] = await db.query(SQL.insert, [id,name,optional]);
     const [ret] = await db.query(SQL.queryByID(info.id));
     return ret[0];
    } catch (error) {      
      switch (error.code) {
        case 'ER_DUP_ENTRY':
          throw new Error({
             status: 400,
            code: 3000,
            message: '数据查询失败,未找到元素',
          });
        default:
          throw new Error({
            status:500,
            code: 1000,
            message: '未知错误',
          });
      }
    }
  }
}
mysql2的链接及使用
const mysql = require('mysql2/promise');
const config = require('../../config');
const poolCluster = mysql.createPoolCluster();
poolCluster.add('MASTER', config.mysql);
poolCluster.add('SLAVE', config.mysqlSlave);
connection.escape = mysql.escape;
module.exports = connection;
redis的链接
const Redis = require('ioredis');
const config = require('../../config');

let redis;

if (process.env.NODE_ENV === 'development') {
    redis = new Redis(config.redis);
} else {
    redis = new Redis.Cluster(config.redisCluster);
}

module.exports = redis;
Config层(配置层)
// 根据rocess.env.ENV_TAG 读取不同环境的不同配置
switch(rocess.env.ENV_TAG){
   case 'dev':
     config =dev-config;
   case 'prod':
     config = prod-config;
}
// - dev-config.js
mysql:{
  host:'',
  user:'',
  password:'',
  database:''
},
mysqlSlave:{
},
redis:{
  host: '127.0.0.1',
  port: 6379,
},
redisCluster:{
}
其他补充
  • 因为不需要打包,直接起服务即可,所以用不到webpack\vite打包工具
    启动命令:
      "dev": NODE_ENV=development nodemon app.js,
      "start": "NODE_ENV=production node app.js",
    
  • 需要一些babel、lint工具来兼容代码及规范格式
  • 需要监控、log工具来记录日志及报错信息
  • 错误处理等可以根据实际情况来根据不同的错误类型封装成类
    以上及一些其他细节自行根据需求配置即可。

ps:对koa2的实现原理感兴趣的请导航到https://www.jianshu.com/p/56d572042839


致敬那些曾经一起奋斗伙伴们

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,816评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,729评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,300评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,780评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,890评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,084评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,151评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,912评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,355评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,666评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,809评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,504评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,150评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,121评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,628评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,724评论 2 351