Nodejs:摆脱黑工坊发展出一款基础企业级框架

Nodejs:摆脱黑工坊发展出一款基础企业级框架

方正方正

说着也是奇怪,nodejs发展那么多年了,基础框架依旧横行霸道,当你提到nodejs的时候肯定会有人说koa/express 云云,然后随便搜索一下教程,就是教你如何制作一款博客。


诚然,nodejs强大的能力可不是给大家单单用来制作一款博客的...


无论是express还是koa,都是属于基础框架。我认为基础框架和企业级框架有两点是不同的:

  • 基础框架没有任何的限制和约束,开发者可以以任意风格书写自己的代码,业务逻辑
  • 企业级框架特别繁琐


没有任何约束的框架在一开始的时候会非常的爽快,开发几个demo,手到擒来,但是一旦代码真正上去的时候(而且一定会),你就会发现,大量重复的操作,重复的逻辑,以及无法做单元测试。导致项目的复杂度越来越高,代码越来越丑,非常的难以维护。


为框架添加一些约束,就会增加其难用程度,学习成本变高,很多新手就会觉得:哎哟,我这样写逻辑也是可以的嘛,为什么要搞那么复杂?


编程就是这样,如果你真正接触过一个从零到有的项目,你就会知道,很多东西你刚开始逃避的,到最后你就得全部加回来,一个不少!话虽如此,跑题有甚,今天我们就来看看,如何将基础框架koa变成一款低端的企业级框架


koa基础

koa真的很容易,代码超级简单,稍微有些基础的同学,半天就能读懂其源码和用法:Koa 框架教程 - 阮一峰的网络日志,在这里我就不多说基础了。



如下几个步骤,就能让你在你的目录下搭建一个koa2的项目,极其简单

npm init //一路回车
npm install --save koa
npm install --save koa-router
//在目录下新建一个app.js文件

touch app.js


//app.js
const koa = require('koa');
const router = require('koa-router');

const app = new koa();
const Router = new router();

Router.get('/', (ctx, next) => {
    ctx.body = 'hello';
})


app.use(Router.routes());

app.listen(3000, '127.0.0.1', () => {
    console.log('服务器启动');
})

访问http://127.0.0.1:3000/就能看到我们的hello.

是的,简简单单的几步,我们就能够搭建起一个非常简单的koa服务器了。


koa进阶

诚然,不幸的事情很快就发生了。我们的网站,不可能只有一个路由,而是一大堆路由,那么代码就会变成

//app.js
Router.get('/', (ctx, next) => {
    ctx.body = 'hello';
})

Router.post('/create', (ctx, next) => {
    ctx.body = 'hello';
})

Router.post('/post', (ctx, next) => {
    ctx.body = 'hello';
})

Router.patch('/patch', (ctx, next) => {
    ctx.body = 'hello';
})

...

一大堆的router 列表,一个成熟的大网站,势必有几千几万个路由组成,都写在一个文件中是不可能的,这样会导致程序无法维护。我们第一个要做就是路由拆分


路由拆分1

最简单的路由拆分,也是github里面大量demo代码的做法,就是定义一个Router文件夹,把所有router代码拆分到各个router文件中去,导出,然后再app.js进行引入。



第一步
在router文件中只做路由path和HTTP方法的导出:

//user.js

const mapRouter = require('../routerLoader').mapRouter;


const getUser = async (ctx, next) => {
    ctx.body = 'getUser';
}

const getUserInfo = async (ctx, next) => {
    ctx.body = 'getUserInfo';
}

/**
 * 注意,我们规定HTTP方法放在前面,path放在后面,中间用空格隔开
 */
module.exports = {
    'get /': getUser,
    'get /getUserInfo': getUserInfo
}

第二步

我们先在app.js同级目录下添加一个routerLoader.js,然后添加以下的方法

//routerLoader.js
const router = require('koa-router');
const Router = new router();

const User = require('./router/user');//倒入模块

/**
 * 添加router
 */
const addRouters = (router) => {
    Object.keys(router).forEach((key) => {
        const route = key.split(' ');

        console.log(`正在映射地址:${route[1]}--->HTTP Method:${route[0].toLocaleUpperCase()}--->路由方法:${router[key].name}`)
        Router[route[0]](route[1], router[key])
    })
}

/**
 * 返回router中间件
 */
const setRouters = () => {
    addRouters(User);
    return Router.routes()
}

module.exports = setRouters;

第三步
修改app.js

//app.js
const koa = require('koa');

const setRouters = require('./routerLoader');//引入router中间件

const app = new koa();


app.use(setRouters());//引入router中间件

app.listen(3000, '127.0.0.1', () => {
    console.log('服务器启动');
})

到这里,我们完成了简单的路由拆分,由此我们引入了第一个规范

  • 所有路由不得随便乱写,必须写在router文件夹中
  • 导出router方法的时候,必须用http+空格+路径的方式进行导出


路由拆分2

上述的方法很好,将一大堆的路由,都拆成了小路由,并且每一个文件控制一个路由模块,每一个模块又有了自己的功能,非常的爽!我们维护项目的力度再次变得可控起来。

好景不长,当我们增加到100个路由模块的时候,我们就想哭了,routerLoader.js文件就会变成..

const User = require('./router/user');//倒入模块
const model2 = require('./router/model2');//倒入模块
.....//省略一大堆模块
const model100 = require('./router/model100');//倒入模块

/**
 * 返回router中间件
 */
const setRouters = () => {
    addRouters(User);
    addRouters(model2);
    ...//省略一大堆模块
    addRouters(model100);
    return Router.routes()
}

module.exports = setRouters;

这个routerLoader.js又会变成非常长的一个文件,这显然不符合我们的要求,而且,我们每添加一个路由模块,就要跑到routerLoader.js中去添加两行代码,不仅容易犯错,还很烦人。

我们是否可以自动扫描router目录下的文件,让其自动帮我们加载呢?答案是:肯定的。

.........

/**
 * 扫描目录
 */
const Scan = () => {
    const url = './router';
    const dir = fs.readdirSync(url)//同步方法无所谓的,因为是在服务器跑起来之前就完成映射,不会有任何性能影响

    dir.forEach((filename) => {
        const routerModel = require(url + '/' + filename);
        addRouters(routerModel);
    })
}


/**
 * 返回router中间件
 */
const setRouters = () => {
    Scan();
    return Router.routes()
}

我们添加一个Scan()函数,帮助我们去扫描硬编码目录router下的所有文件,然后循环自动倒入router中,最后返回这个router。那么我们现在无论是增加,删除路由模块,都不需要再动routerLoader.js模块了。

我们只需要疯狂的飙一下业务代码在router之下就可以了,专注于router中的东西


引入控制器Controller


很高兴,我们的router模块变成了

//user.js

const mapRouter = require('../routerLoader').mapRouter;


const getUser = async (ctx, next) => {
    ctx.body = 'getUser';
}

const getUserInfo = async (ctx, next) => {
    ctx.body = 'getUserInfo';
}

/**
 * 注意,我们规定HTTP方法放在前面,path放在后面,中间用空格隔开
 */
module.exports = {
    'get /': getUser,
    'get /getUserInfo': getUserInfo
}

这样的一种形式,当我们想要增加一个模块的时候只需要添加一个文件,并做好映射导出就可以了,极大的增加了我们的开发效率,更加的规范化,bug就意味着更少


但是,这样的一种形式仍然有问题没解决,从宏观上来看我们处理业务的流程是:

用户请求->到达router->处理业务->返回请求给用户


业务处理阶段

在业务处理阶段,我们最好,也是最推荐的,把业务逻辑与控制流程分开,这是什么意思呢?比如我们早起刷牙吃早餐去上班这件事用为代码表示:

const gotoWork = () => {
    起床();//隐藏了如何起床的细节,比如被闹钟吵醒,自然醒
    刷牙();//隐藏了如何刷牙的细节,风骚或者不风骚的刷牙手法
    完成早餐();//隐藏了如何做早餐的各种细节
    去工作();//隐藏了如何去工作的细节,比如坐什么车
}

然后我们分别把起床()刷牙()完成早餐()去工作,这几个函数的内部细节完善,这样我们就拥有了一个gotoWork controller。这么做的好处很明显:

  • 控制器主要用于控制流程,不出现任何业务具体实现代码
  • 分散的业务代码,可以被很容易的复用,单元测试

这两点在开发中至关重要,如何控制项目的复杂度,以及不要重复写代码,就靠的把业务逻辑与控制流程分开的约定。


业务逻辑分离,引入service

我们已经有了两组概念,控制流程放在控制器(controller),那业务逻辑我们也给他安排一个serviceservice的作用其实就是为了封装一下业务逻辑,以便哪里再次使用,以及方便做单元测试。


很多人不明白,为什么要把事情搞得那么复杂,又分为控制器,又分为业务逻辑。对于还没有太多业务经验的同学来说,肯定要骂街,但是思考一下以下的场景:

  • 有人访问A地址,调用了A的控制器,控制器中调用了service中的获取用户信息逻辑
  • 有人访问b地址,调用了b的控制器,控制器中调用了service中的获取用户信息逻辑
  • 有人访问c地址,调用了c的控制器,控制器中调用了service中的获取用户信息逻辑

这就很明显了,当我们把业务逻辑和控制流程分开以后,我们的代码可以做到最大程度的复用。


实现Controller


创建一个controller目录,我们规定所有的xxxcontroller.js一定要写在controller目录下,这是我们引入的第二条规范。

controller目录下创建user.js

//  controller/user.js

module.exports = {
    async getUser(ctx) {
        ctx.body = 'getUser';
    },
    async getUserInfo() {
        ctx.body = 'getUserInfo';
    }
};

对我们的方法进行导出,这里很简单就不多做解释。

新增controllerLoader.js在根目录下,其实很简单就是为了扫描controller目录中的文件,并以一个数组返回

const fs = require('fs');

function loadController() {
    const url = './controller';
    const dir = fs.readdirSync(url)//同步方法无所谓的,因为是在服务器跑起来之前就完成映射,不会有任何性能影响

    return dir.map((filename) => {
        const controller = require(url + '/' + filename);
        return { name: filename.split('.')[0], controller };
    })
}

module.exports = loadController;

这里其实没做什么复杂性操作,就是把目录扫描以后,导出一个数组对象,这个对象里存的就是controller对应的文件名字,以及controller方法.

修改app.js

我们将获得的controller,绑定在koa的原型链上,新增下面的代码:

......
//app.js
const koa = require('koa');
const setRouters = require('./routerLoader');//引入router中间件

//新增的代码
const controllerLoader = require('./controllerLoader');
const controllers = controllerLoader();
koa.prototype['controller'] = {};
controllers.forEach((crl) => {
    koa.prototype.controller[crl.name] = crl.controller;
})

const app = new koa();

app.use(setRouters(app));//引入router中间件,注意把app的实例传进去

app.listen(3000, '127.0.0.1', () => {
    console.log('服务器启动');
})


我们新增了一坨代码,其实可以封装到controllerLoader中去,不过无所谓啦,先这么搞着。新增的这一坨代码目的就是把我们刚刚导出的数组,全部都映射到koa这个类的原型下,当new一个koa对象的时候,就会拥有这些方法了。


注意app.use(setRouters(app));这里我们将app传入到setRouters我们自己写的中间件中去,具体要干嘛,往下看。

修改routerLoader.js文件

//routerLoader.js
const router = require('koa-router');
const Router = new router();
const fs = require('fs');

/**
 * 返回router中间件
 */
const setRouters = (app) => {
    const routers = require('./routers')(app);//在这里使用app
    Object.keys(routers).forEach((key) => {
        const [method, path] = key.split(' ');
        Router[method](path, routers[key])
    })
    return Router.routes()
}

module.exports = setRouters;

没错,我们app实际上就是用来传递参数的....具体传递去哪里,你可以在routers.js中看到(等一下创建


和之前一样,我们规定导出路由的方式是http method + 空格 + path,这样比较傻瓜,也方便我们写方法。


最后曙光,根目录下新增一个routers.js文件

module.exports = (app) => {
    return {
        'get /': app.controller.user.getUser
    }
}

可以看到,我们的app,用在这里,用于获取controller中的各种方法.

删除之前的router文件夹。到此,我们的controller实现了,并且把他挂载到了koa这个类的原型上,我们将router和控制器分离,把路径都集中在一个文件里管理,controller只负责控制流程。


实现Service

引入Service的概念就是为了让控制器和业务逻辑完全分离,方便做测试和逻辑的复用,极大的提高我们的维护效率。


同样的,我们引入一个规范,我们的service必须写在service文件夹中,里面的每一个文件,就是一个或者一组相关的service.


在根目录下,新建一个service文件夹:
新增一个service文件就叫它userService吧!

//  service/userService.js

module.exports = {
    async storeInfo() {
        //doing something
    }
};

好了,我们可以在任意时候使用这个函数了,非常简单。


有一些小问题

我们在写controller业务控制的时候,我们不得不在使用的时候,就去引入一下这个文件
const serviceA = require('./service/serviceA'),这种代码是重复的,不必要的,非常影响我们的开发速度


我们来回顾一下controller中的一些逻辑

//  controller/user.js

module.exports = {
    async getUser(ctx) {
        ctx.body = 'getUser';
    },
    async getUserInfo(ctx) {
        ctx.body = 'getUserInfo';
    }
};

我们可以看到,在每一个函数中,我们基本上都会使用到ctx这个变量,那为什么我不能学koa一样,把这个service也像参数一样传递进来呢?


修改我们的controllerLoader

const fs = require('fs');

function loader(path) {
    const url = __dirname + '/controller';
    const dir = fs.readdirSync(url)//同步方法无所谓的,因为是在服务器跑起来之前就完成映射,不会有任何性能影响

    return dir.map((filename) => {
        const module = require(url + '/' + filename);
        return { name: filename.split('.')[0], module };
    })
}


function loadController() {
    const url = __dirname + '/controller';
    return loader(url);
}

function loadService() {
    const url = __dirname + '/service';
    return loader(url);
}

module.exports = {
    loadController,
    loadService
};

代码也非常傻瓜,其实就是去扫描一下service下面的文件夹,并且返回一下,然后把controllerLoader.js改名叫Loader.js,表示这个文件里都是loader.


然后修改一下我们routerLoader.js

//routerLoader.js
const router = require('koa-router');
const Router = new router();
const fs = require('fs');

const services = require('./controllerLoader').loadService();//这里引入service


/**
 * 返回router中间件
 */
const setRouters = (app) => {
    const routers = require('./routers')(app);
    const svs = {};
    services.forEach((service) => {
        svs[service.name] = service.module;
    })
    Object.keys(routers).forEach((key) => {
        const [method, path] = key.split(' ');
        Router[method](path, (ctx) => {
            const handler = routers[key];//注意这里的变化
            handler(ctx, svs);//注意这里的变化
        })
    })
    return Router.routes()
}

module.exports = setRouters;

这一段的代码变化其实就是把作用域拉长了一点,使得在调用路由方法的时候,给所有的方法添加一个svs参数,也就是我们的service.


于是我们愉快的到处使用我们的service

//  controller/user.js

module.exports = {
    async getUser(ctx, service) {
        await service.userService.storeInfo();//开心的使用service
        ctx.body = 'getUser';
    },
    async getUserInfo(ctx) {
        ctx.body = 'getUserInfo';
    }
};



使用面向对象封装我们的框架

我们的工作目录还比较乱,接下来我们对目录进行一些简单的调整:
我们给我们的框架叫做kluy,新建一个kluy目录,新建一个core.js

const koa = require('koa');
const fs = require('fs');
const koaRoute = require('koa-router');

class KluyLoader {
    removeString(source) {
        const string = 'kluy';
        const index = source.indexOf(string);
        const len = string.length;
        return source.substring(0, index);
    }

    loader(path) {
        const dir = fs.readdirSync(path)//同步方法无所谓的,因为是在服务器跑起来之前就完成映射,不会有任何性能影响
        return dir.map((filename) => {
            const module = require(path + '/' + filename);
            return { name: filename.split('.')[0], module };
        })
    }

    loadController() {
        const url = this.removeString(__dirname) + '/controller';
        return this.loader(url);
    }

    loadService() {
        const url = this.removeString(__dirname) + '/service';
        return this.loader(url);
    }

}

class Kluy extends koa {
    constructor(props) {
        super(props);
        this.router = new koaRoute();

        this.loader = new KluyLoader();
        const controllers = this.loader.loadController();
        this.controller = {};
        controllers.forEach((crl) => {
            this.controller[crl.name] = crl.module;
        })
    }

    setRouters() {
        const _setRouters = (app) => {
            const routers = require('../routers')(app);
            const svs = {};
            app.loader.loadService().forEach((service) => {
                svs[service.name] = service.module;
            })
            Object.keys(routers).forEach((key) => {
                const [method, path] = key.split(' ');
                app.router[method](path, (ctx) => {
                    const handler = routers[key];
                    handler(ctx, svs);
                })
            })
            return app.router.routes()
        }
        this.use(_setRouters(this));
    }
}

module.exports = Kluy;

上述的代码其实做了一件非常简单的事情。就是把之前的所有启动前初始化的代码,全部封装到了我们的框架类kluy中,然后导出。这么做的好处就是:

  • 当我们调用的时候不需要知道任何初始化细节(各种loader之间的麻烦事)
  • 方便我们发布npm

我们在一开始的app.js中就可以这么写了

//app.js
const kluy = require('./core');
const app = new kluy();
app.setRouters();
app.listen(3000, '127.0.0.1', () => {
    console.log('服务器启动');
})

目录结构

.
├── package-lock.json
├── package.json
└── src               ──>项目代码
    ├── controller     ──>控制器代码目录
    │   └── user.js
    ├── kluy     ──>框架代码目录
    │   ├── app.js
    │   └── core.js
    ├── routers.js    ──>路由器的导出
    └── service      ──>业务逻辑代码目录
        └── userService.js

由此,我们的目录就变成了这么一个清爽的结构,构建一个应用也因为我们封装得体,只需要几行代码就可以实现


稍微总结一下之前的工作

到目前为止,我们对我们的项目引入了三个规范

  • controller,专门处理业务的控制流程,尽量不出现任何的业务逻辑,而且controller必须放在controller文件夹中,否则无法读取到
  • router,路由的设置,我们全部放在了routers.js中,集中化管理,使得我们的路由、Http方法不会因为散落各地而难以查找
  • service ,业务逻辑与控制器完全分离,不依赖于控制器,能够方便你的逻辑复用和单元测试
  • 全自动按目录加载:所有的代码,类,都按照规范写好后,就能够全自动的导入到项目中,无需人力再进行对这种无用但是又容易出错的操作进行乱七八糟的维护,极大的提升了我们开发业务代码的效率。

或许聪明的你已经发现了这么做的好处:超出控制范围的代码框架连启动都无法启动,比如有人不爽,想到处写业务逻辑,boom,爆炸。


又比如,有人想到处乱写router,boom爆炸。


由此,我们得出了一个深刻的道理:

一定的限制和约束,是企业级(包括个人)大项目所必须的


优雅的处理硬编码

在我们的项目中,有很多东西是需要我们使用硬编码去书写的,例如,启动ip地址,端口,数据链接端口,数据库名字,密码,跨域的一些http请求等等。

我曾经看过一些非常不规范的开发,把各种硬编码写入逻辑中,有时候,线上和线下的配置是完全不一样的,维护起来那叫一个要命。

按照之前我们的思路,我们可以将配置,写入一个config文件夹中,用名字的限制来区分我们线下,线上的配置

同样我们可以使用前面类似的方法进行对config文件夹中的东西进行扫描,然后自动加载到项目中去。

在考虑如何实现config自动挂载之前,我们得思考一下一个问题:挂去哪里?

  • router :很小几率会用上
  • controller:业务控制流程,但是还有是有一定几率会用上.
  • service: 业务逻辑上,用到的是最多的,也是最频繁的


我们决定,将config绑定在kluy的实例上:

kluyLoader类中添加一个方法:

class KluyLoader {
....
  loadConfig() {
        const url = this.removeString(__dirname) + '/config';
        return this.loader(url);
    }
.....
}
class Kluy extends koa {
    constructor(props) {
        super(props);
        this.router = new koaRoute();

        this.loader = new KluyLoader();
        const controllers = this.loader.loadController();
        this.controller = {};
        controllers.forEach((crl) => {
            this.controller[crl.name] = crl.module;
        })

        this.config = {};//加载config
        this.loader.loadConfig().forEach((config) => {
            this.config = { ...this.config, ...config.module }
        })
    }

    setRouters() {
        const _setRouters = (app) => {
            const routers = require('../routers')(app);
            const svs = {};
            app.loader.loadService().forEach((service) => {
                svs[service.name] = service.module;
            })
            Object.keys(routers).forEach((key) => {
                const [method, path] = key.split(' ');
                app.router[method](path, (ctx) => {
                    const handler = routers[key];
                    handler(ctx, svs, app);//将app,传递给controller
                })
            })
            return app.router.routes()
        }
        this.use(_setRouters(this));
    }
}

上述代码在loader类中加上一个loadconfig方法。然后便可以在我们的kluy类中加载config,最后,传递给controller。


愉快的使用service

//  controller/user.js

module.exports = {
    async getUser(ctx, service, app) {
        app.config.name...//这里使用
        await service.userService.storeInfo();//开心的使用service
        ctx.body = 'getUser';
    },
    async getUserInfo(ctx) {
        ctx.body = 'getUserInfo';
    }
};

一个企业级骨架

通过上述几个步骤和规范,我们就定制了一套初级低能儿版的企业级框架。虽然是低能儿版本,但是相比于基础koa框架来说,已经强大得很多了。


规范预览

.
├── package-lock.json
├── package.json
└── src
    ├── config          ──────>项目配置
    │   └── dev.js
    ├── controller     ──────>控制器逻辑
    │   └── user.js
    ├── kluy               ──────>框架底层
    │   ├── app.js
    │   └── core.js
    ├── routers.js       ──────>router模块
    └── service           ──────>业务逻辑层
        └── userService.js

然而,企业级框架还是比较复杂的,我们仍然需要考虑更多的情况;

  • orm

orm的引入能够极大的提升我们的开发效率,在项目越早的时期引入,优势越明显。然而引入orm也是需要一套规范的。

  • 安全

koa这种基础框架,是没有安全防范的,需要开发者自己去做。

  • 自动化启动

分别为开发自动重启以及线上部署自动重启

  • 日志系统

web应用中需要有一套完整的机制来完成日志记录

  • 等等...

单元测试....


最后,推荐一款企业框架:Egg

最近也一直在使用eggjs来构建自己的东西,除了给项目添加大量的约束之外,我还使用了Typescript进行编码。


我在知乎上看到不少人问如何学习egg,老实说,eggjs我并没有花太多时间就已经能上手了,当你看完我上面的教程以后,你就会发现,eggjs就是如我上述所说的一样,把各种规范已经封装好了:一款企业应用就是应该这样的


其实Eggjs并没有太多需要你「深入」的地方,把问题看得简单点,无非就是给你的开发添加一点「制约」。


Eggjs是koa2的一层上层封装,我们就从koa2说起(以下简称k),k是一个基础性框架,基础性框架有一个特点就是:上手极其容易,但是代码就会杂乱无章,而且是你想干嘛就干嘛。


想干嘛就干嘛是基础框架最大的痛点,简单来说就是:1000个人会写出1000种风格完全不同的代码,所以企业在使用koa的时候喜欢往上封装一层,添加一堆的「约束」,简单来说就是:你不按照规则写,就会爆炸


添加约束的好处就是保证在「尽可能小的粒度下代码风格一致」,又简单来说就是:套路一样


Nodejs有一个毛病就是,缺少一种能够让新手也写出至少能看的代码的方式,这一块这么多年了都做得不是很好,一定程度上限制了Nodejs的发展。


然后Eggjs出现了:

  1. 尽量让代码统一套路,在规则之外的代码全部爆炸,跑都跑不起来
  2. 在1的前提下,迫使开发人员写出至少是能看的代码。
  3. 简化一些没必要的操作,让开发人员专注业务逻辑。


当你了解了eggjs的「约束」的思想,那你学习eggjs就已经“够深入了”,接下去就应该一层层剥掉egg的外壳,往下学习,egg->koa->nodejs以及整个后端的体系


最后,传送门:egg - 为企业级框架和应用而生

文章被以下专栏收录
48 条评论