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),那業務邏輯我們也給他安排一個service。service的作用其實就是為了封裝一下業務邏輯,以便哪里再次使用,以及方便做單元測試。
很多人不明白,為什么要把事情搞得那么復雜,又分為控制器,又分為業務邏輯。對于還沒有太多業務經驗的同學來說,肯定要罵街,但是思考一下以下的場景:
- 有人訪問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的前提下,迫使開發人員寫出至少是能看的代碼。
- 簡化一些沒必要的操作,讓開發人員專注業務邏輯。
當你了解了eggjs的「約束」的思想,那你學習eggjs就已經“夠深入了”,接下去就應該一層層剝掉egg的外殼,往下學習,egg->koa->nodejs以及整個后端的體系。
來自: