基于koa定制属于自己的企业级框架

时间:2022-06-26
本文章向大家介绍基于koa定制属于自己的企业级框架,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

笔者公司用的是think.js作为后端框架,初次使用,深感业务场景的傻瓜式。它就是一个基于koa二次开发。一个显著的特点就是可以在对应文件夹下直接书写接口。比如api /aaa/对应 aaa文件夹下的index。/bbb/aaa/user对应bbb文件夹下的 aaa.js下等 user方法等。

本文重点阐述的是一个企业级框架的实现过程。基于koa中间件这一强大的机制。可以给自己的node开发搞出很多好玩的东西。

认识egg.js(阿里系框架)

Egg.js 为企业级框架和应用而生,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本。

Express 是 Node.js 社区广泛使用的框架,简单且扩展性强,非常适合做个人项目。但框架本身缺少约定,标准的 MVC 模型会有各种千奇百怪的写法。Egg 按照约定进行开发,奉行『约定优于配置』,团队协作成本低。

官方文档 https://eggjs.org/zh-cn/intro/quickstart.html

安装

npm install egg-init -g
egg-init --type=simple
// showcase && cd showcase
npm install
npm run dev
// open http://localhost:7001

约定优于配置:接口

一般接口

接口逻辑在controller下的文件夹下。

// app/controller/home.js
'use strict';

const Controller = require('egg').Controller;
class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = 'hi, egg';
  }
}

module.exports = HomeController;

路由逻辑:

// app/router.js
'use strict';
/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

怎么写接口:

// router.js
router.get('/user',controller.user.index);

// 新建控制器 user.js
'use strict';
const Controller = require('egg').Controller;

class UserController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = [{
        name:'dangjingtao'
    },{
        name:'djtao'
    }];
  }
}

module.exports = UserController;
复杂接口:分离业务逻辑的服务集成

当controller特别复杂时,需要对controller继续分层,在controller同级别目录下新建service目录。新建一个user.js

// app/service/user.js
const {Service}=require('egg');

class UserService extends Service{
    async getAll(){
        return [{name:'djtao'},{name:'dangjingtao'}]
    }
}

module.exports=UserService;

在使用时不需要做任何引入,直接可通过 ctx.service.user调用 getAll

// controller/user.js
'use strict';

const Controller = require('egg').Controller;

class UserController extends Controller {
  async index() {
    const { ctx } = this;
    // ctx.body = [{
    //     name:'dangjingtao'
    // },{
    //     name:'djtao'
    // }];
    console.log(111111,ctx.service.user)
    ctx.body= await ctx.service.user.getAll();
  }
}

module.exports = UserController;

接入数据库

配置

以接入mysql为例:使用sequelize作为工具:

npm i egg-sequelize mysql2 -S

config/config.default.js中便携serquelize配置

const userConfig = {
    // myAppName: 'egg',
    sequelize:{
      dialect:'mysql',
      host:'127.0.0.1',
      port:'3306',
      username:'root',
      password:'12345678',
      database:'eggjs'
    }
  };

在plugin.js中注册插件:

'use strict';

/** @type Egg.EggPlugin */
module.exports = {
  // had enabled by egg
  // static: {
  //   enable: true,
  // }
  serquelize:{
    enable:true,
    package:'egg-sequelize'
  }
};
建立数据模型

在app目录下新建model文件夹,新建user.js

module.exports=app=>{
    const {STRING}=app,Sequelize;
    const User=app.model.define(
        'user',
        {name:STRING(30)},
        {timestampt:false}
    );

    User.async({force:true});
    return User;
}

使用时也是直接调用 this.ctx.model.User.

async getAll(){
        // return [{name:'djtao'},{name:'dangjingtao'}];
        return await this.ctx.model.User.findAll()
    }

调用如下:

手撸一个MVC(degg.js)

自己手撸mvc,期望实现以下特点:

  • 基于koa,模仿egg(koa-egg.js)
  • 目标是创建约定大于配置、开发效率高、可维护性强的项目架构

路由(router)

规范:
- 所有路由放到一个routes文件夹中
- 若导出路由对象,使用 动词+空格+路径 作为key,值是操作方法
- 到处函数,则函数返回第二条作为约定格式的对象。
路由定义

先看路由需要实现什么:

  • 新建routes/index.js,默认Index.js没有前缀
module.exports = {
    'get /': async ctx => {
        ctx.body = '首页'
    },
    'get /detail': ctx => {
        ctx.body = '详情'
    }
}
  • 新建routes/user.js
module.exports = {
    "get /": async ctx => {
        ctx.body = "用户首页";
    },
    "get /info": ctx => {
    ctx.body = "用户详情页面";
    }
};
路由加载器

然后就是实现路由加载器。能够读文件夹,"智能地"解析路由。

在根目录下新建 degg-loader.js

// 路由加载器
const fs = require("fs");
const path = require("path");
const Router = require("koa-router");

/**
 * 读文件夹
 * @param {文件夹} dir
 * @param {回调,参数是文件名和文件路径} cb
 */
function load(dir,cb){
    //获取绝对路径
    const url=path.resolve(__dirname,dir);
    const files=fs.readdirSync(url);

    files.forEach(filename=>{
        filename=filename.replace('.js','');
        const file=require(url+'/'+filename);
        cb(filename,file);
    })
}

/**
 * 初始化路由
 *
 */
function initRouter(){
    const router=new Router();
    load('router',(filename,routes)=>{
        // 对于index,需要特殊处理:请求后缀为index。指向跟路径
        const prefix =filename==='index'?'':`/${filename}`;
        Object.keys(routes).forEach(key=>{
            // 根据空格解析
            const [method,path]=key.split(' ');
            console.log(`正在映射地址:${method.toLocaleUpperCase()} /${prefix}/${path}`);

            router[method](prefix+path,routes[key])
        })
    })

    return router;
}

module.exports={initRouter}

测试:

// 根目录index.js
const app = new (require('koa'))()
const {initRouter} = require('./kkb-loader')
app.use(initRouter().routes())
app.listen(3000)
封装

现在跑通了,但是明显看出了对koa的依赖,可以根据面向对象的思想稍微封装一下:

// degg.js
const koa = require("koa");
const { initRouter } = require("./degg-loader");

class degg {
    constructor(conf) {
        this.$app = new koa(conf);
        this.$router = initRouter();
        this.$app.use(this.$router.routes());
    }
    start(port) {
        this.$app.listen(port, () => {
            console.log("服务器启动成功,端口" + port);
        });
    }
}
module.exports = degg;

index.js就可以很友好的这么写:

const degg = require("./degg");
const app = new degg();
app.start(3000);

控制器(controller)

以上的实现还是没有体现关注点分离的思想,我希望路由处理方法放在controller,而router导出的对象是这样的:

// router/index.js
module.exports = app =>({
    'get /':app.$ctrl.home.index,
    'get /detail':app.$ctrl.home.detail,
})

通过 请求方法/路由跳转对应的处理逻辑。

所以得做两件事:

  1. 抽取route中业务逻辑至controller
/**
 * 初始化路由
 * 兼容上一代
 */
function initRouter(app){
    const router=new Router();
    load('router',(filename,routes)=>{
        // 对于index,需要特殊处理:请求后缀为index。指向跟路径
        const prefix =filename==='index'?'':`/${filename}`;
        //兼容上一代写法:
        routes=typeof routes==='function'?routes(app):routes;
        Object.keys(routes).forEach(key=>{
            const [method,path]=key.split(' ');
            console.log(`正在映射地址:${method.toLocaleUpperCase()} /${prefix}/${path}`);
            // 解析路由
            router[method](prefix+path,routes[key])
        })
    })
    return router;
}
  1. 根据路径,对controller进行分配
function initController(){
    const controllers={}
    load('controller',(filename,controller)=>{
        // 添加路由控制器
        controllers[filename]=controller;
    })

    return controllers
}

module.exports={initRouter,initController}

打印出的逻辑如下:

服务集成(service)

什么叫"服务集成"?想想之前service文件夹,放的是通过不同方式从数据层获取数据的方法。

比如说,我需要后端提供一个人的名字(getName)和年龄(getAge)就包含了两个方法。

//  service/user.jsss
const delay=(data, tick)=> new Promise(resolve=>{
    setTimeout(()=>{
        resolve(data)
    },tick)
});

// 可复用的服务,一个同步,一个异步
module.exports={
    getName(){
        return delay('djtao',1000);
    },
    getAge(){
        return 30
    }
}

给loader添加service逻辑,和controller高度相似:

/**
 * 服务集成
 */
function initService() {
    const services = {};
    // 读取控制器目录
    load("service", (filename, service) => {
        // 添加路由,和controller一样的逻辑
        services[filename] = service;
    });
      return services;
}

module.exports={initRouter,initController,initService}

在degg,js中:

this.$service=initService();

这时候路由怎么执行呢?需要添加异步逻辑。同时,把ctx挂载带app实例中。

/**
 * 初始化路由
 */
function initRouter(app) {
    const router = new Router();
    load('router', (filename, routes) => {
        // 对于index,需要特殊处理:请求后缀为index。指向跟路径
        const prefix = filename === 'index' ? '' : `/${filename}`;
        //兼容上一代写法:
        routes = typeof routes === 'function' ? routes(app) : routes;
        Object.keys(routes).forEach(key => {
            const [method, path] = key.split(' ');
            console.log(`正在映射地址:${method.toLocaleUpperCase()} /${prefix}/${path}`);

            // 解析路由,处理异步
            router[method](prefix + path, async ctx => { 
                // 挂载ctx至app
                app.ctx = ctx; 
                // 路由处理器现在接收到的是app
                await routes[key](app);                
            });
        })
    })
    return router;
}

这时候,router对应的不再是ctx。而是app

// router/user.js
module.exports = {
    "get /": async app => {
        const name=await app.$service.user.getName()
        app.ctx.body = `hello, ${name}`;
    },
    "get /info":async app => {
        const age=await app.$service.user.getAge()
       app.ctx.body = `i am ${age} years old`;
    }
};

对于使用controller的home.js:

module.exports={
    index:async app=>{
        app.ctx.body='首页ctrl'
    },

    detail:async app=>{
        app.ctx.body='详情ctrl'
    }
}

更新之后的controller层就可以通过app拿到service的方法。

这样,逻辑就出来了。

数据层(model)

数据库还是使用sequelize和msql2。

约定
- config/config.js存放配置项
- key表示对应配置目标
- model存放数据库模型
配置及其加载

配置sequelize连接配置项,config.js

// 这里就是数据库的配置

module.exports = {
    db: {
        dialect:'mysql',
        host:'127.0.0.1',
        port:'3306',
        username:'root',
        password:'12345678',
        database:'eggjs'
    }
}

然后在loader中添加内容:

const Sequelize = require("sequelize");
function loadConfig(app) {
    load("config", (filename, conf) => {
        if (conf.db) {
            app.$db = new Sequelize(conf.db);
            // 加载模型
            app.$model = {};
            // 期望:从model下拿到对应数据表比如user
            load("model", (filename, { schema, options }) => {
                app.$model[filename] = app.$db.define(filename, schema, options);
            });
            app.$db.sync();
        }
    });
}
module.exports = { loadConfig };

// degg.js
//先加载配置项
loadConfig(this);
数据模型

在model文件夹下新建user.js,存放配置:

const { STRING } = require("sequelize");
module.exports = {
  schema: {
    name: STRING(30)
}, options: {
    timestamps: false
  }
};
测试

在controller层添加逻辑

js detail:asyncapp=>{// app.ctx.body='详情ctrl'app.ctx.body=awaitapp.$model.user.findAll()}

中间件(middleware)

中间件,其实就是一个插槽。

规定koa中间件放入middleware文件夹 
编写一个请求记录中间件,./middleware/logger.js

新建一个自己写的logger中间件, middleware/logger.js

module.exports = async (ctx, next) => {
  console.log(ctx.method + " " + ctx.path);
  const start = new Date();
  await next();
  const duration = new Date() - start;
  console.log(
    ctx.method + " " + ctx.path + " " + ctx.status + " " + duration + "ms"
  );
};

配置中间件(插槽):

// config.js
module.exports = {
    db:{...},
        middleware: ['logger'] // 以数组形式,保证执行顺序
}
// 如果有middleware选项,则按其规定循序应用中间件 if (conf.middleware) {
      conf.middleware.forEach(mid => {
        const midPath = path.resolve(__dirname, "middleware", mid);
        app.$app.use(require(midPath));
}); }

在loader中:

function loadConfig(app) {
    load("config", (filename, conf) => {
                // 。。。
        //中间件
        if (conf.middleware) {
            conf.middleware.forEach(mid => {
                const midPath = path.resolve(__dirname, "middleware", mid);
                app.$app.use(require(midPath));
            });
        }
    });
}

定时任务

有时候需要打日志。

我们使用Node-schedule来管理定时任务。

约定:schedule目录,存放定时任务,使用crontab格式来启动定时

所以,新建 module目录,写以下两个配置和处理逻辑:

//log.js
module.exports = {
    interval: '*/3 * * * * *',
    handler() {
        console.log('定时任务:嘿嘿嘿 三秒执行一次' + new Date())
    }
}
// user.js
module.exports = {
    interval: '30 * * * * *',
    handler() {
        console.log('定时任务 嘿嘿 每分钟第30秒执行一次' + new Date())
    }
}

然后在loader添加相应处理逻辑(注意导入和导出):

function initSchedule() {
    // 读取控制器目录
    load("schedule", (filename, scheduleConfig) => {
        schedule.scheduleJob(scheduleConfig.interval, scheduleConfig.handler);
    });
}

运行结果如下

视图层(views)与错误捕捉

这实际上是一个健壮功能,也是一个内置功能。

我希望通过如下方法,很轻易地渲染一个html:

ctx.render('/html',配置)

那我们就用ejs作为degg.js的模板引擎。

修改loader:

function initViews(app){
    return views(__dirname + '/views', {
        extension: 'ejs'
    })
}

// degg.js使用这个配置
this.$views = initViews(this);
this.$app.use(this.$views);

新建view文件夹。写一个404的页面(404.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>404</title>
</head>
<body>
    你来到了没有知识的荒原...
</body>
</html>

写一个错误内容的中间件,

// middleware/err.js
// 错误捕捉
module.exports = async (ctx, next) => {
    try {
        await next();

        if(ctx.status!==200){
            ctx.throw(ctx.status);
        }
    } catch (err) {
        const status = err.status || 500;
        ctx.status = status;
        if (status === 404) {
            await ctx.render("./404.html");
        } else if (status === 500) {
            await ctx.render("./500.ejs",{message:err,stack:err.stack});
        }
    }
};

注册中间件

middleware: ['logger','err']

测试结果如下