Foveluy / Rluy

16 stars 4 forks source link

Nodejs&koa:摆脱黑工坊发展出一款企业级框架 #1

Open Foveluy opened 6 years ago

Foveluy commented 6 years ago

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

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

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

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

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

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

编程就是这样,如果你真正接触过一个从零到有的项目,你就会知道,很多东西你刚开始逃避的,到最后你就得全部加回来,一个不少!话虽如此,跑题有甚,今天我们就来看看,如何将基础框架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服务器了。

Foveluy commented 6 years ago

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进行引入。

2018-01-20 12 16 04

第一步 在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('服务器启动');
})

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

Foveluy commented 6 years ago

路由拆分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中的东西 2018-01-20 12 16 04

Foveluy commented 6 years ago

引入控制器Controller

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

//user.js

const mapRouter = require('../mapRouter').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),那业务逻辑我们也给他安排一个service。service的作用其实就是为了封装一下业务逻辑,以便哪里再次使用,以及方便做单元测试。

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

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

Foveluy commented 6 years ago

实现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只负责控制流程。

Foveluy commented 6 years ago

实现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';
    }
};
Foveluy commented 6 years ago

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

我们的工作目录还比较乱,接下来我们对目录进行一些简单的调整: 我们给我们的框架叫做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中,然后导出。这么做的好处就是:

我们在一开始的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

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

稍微总结一下之前的工作

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

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

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

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

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

Foveluy commented 6 years ago

优雅的处理硬编码

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

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

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

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

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

我们决定,将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.service.name...//这里使用
        await service.userService.storeInfo();//开心的使用service
        ctx.body = 'getUser';
    },
    async getUserInfo(ctx) {
        ctx.body = 'getUserInfo';
    }
};
Foveluy commented 6 years ago

一个企业级骨架

通过上述几个步骤和规范,我们就定制了一套初级低能儿版的企业级框架。虽然是低能儿版本,但是相比于基础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应用中需要有一套完整的机制来完成日志记录

最后的最后

对于nodejs开发,我正在使用的一套框架是eggjs,基于koa开发,有完整的生态系统,插件机制,规范公约,阿里内部也在使用。

全文完