作者 | 天猪
排版 | Nodejs技术栈
本文经作者 @天猪 授权分享,由公众号 “Nodejs技术栈” 排版,文末点击阅读原文可跳转原文查看。
回想下,当你需要新起一个 Node.js 应用的时候,会怎么做?
憨厚一点的就从头开始初始化,一个个插件的安装,CTRL +C 一个个的配置。好一点的,就会封装一个骨架,然后一键生成新项目。
那如果在应用中的一些实践,想下沉为基础能力,就需要修改骨架。此时,如何把旧项目升级呢?一两个还好说,如果十几个,甚至上百个呢?
我们的实践是:基于 Egg 封装一个适合特定团队业务场景的上层业务框架。
如果你的团队需要:
统一的技术选型,比如数据库、模板、前端框架及各种中间件设施都需要选型,而框架封装后保证应用使用一套架构。
统一的默认配置,开源社区的配置可能不适用于公司,而又不希望每个应用重复配置。
统一的部署方案,通过框架和平台的双向控制,应用只需要关注自己的代码。
统一的代码风格,框架不仅仅解决代码重用问题,还可以对应用做一定约束,并定制适合团队的目录加载规范。
下面,我们来一起基于Egg 定制一个独属于我们的 鸭蛋框架(yadan),它提供以下能力:
内置 nunjucks
来提供服务端模板渲染能力。
封装一套请求后端接口的协议,并自动加载 app/rpc/**
为ctx.rpc.clz.method()
方法。
// 请求后端接口,查询用户信息
const userInfo = await ctx.rpc.user.getDetail('yadan');
// 渲染首页
await this.ctx.render('home.tpl', { userInfo });
完整的示例代码可以参见 https://github.com/atian25/yadan,下文我们会讲解关键细节。
通过骨架一键初始化 Framework 代码:
$ npm init egg --type=framework yadan
可以看到,Framework 的目录结构,和一个 Egg 应用几乎一模一样,熟悉的 config
、 app/extend
、 app/service
。
yadan
├── app
│ ├── extend
│ └── service
├── config
│ ├── config.default.js
│ └── plugin.js
├── lib
│ └── framework.js
├── test
│ ├── fixtures
│ └── framework.test.js
├── README.md
├── index.js
└── package.json
接下来我们逐个讲解下关键细节。
首先来看下入口文件,其实就是继承了下 Application,然后把当前目录通过EGG_PATH
的约定,加入到 Egg 的LoadUnits
中去。
骨架已经默认生成,基本上不用改,代码如下:
// lib/framework.js
const path = require('path');
const egg = require('egg');
const EGG_PATH = Symbol.for('egg#eggPath');
class Application extends egg.Application {
get [EGG_PATH]( "EGG_PATH") {
return path.dirname(__dirname);
}
}
class Agent extends egg.Agent {
get [EGG_PATH]( "EGG_PATH") {
return path.dirname(__dirname);
}
}
module.exports = Object.assign(egg, {
Application,
Agent,
});
我们要内置模板插件,先安装依赖:
tnpm i --save egg-view-nunjucks
再挂载下插件:
// config/plugin.js
exports.nunjucks = {
enable: true,
package: 'egg-view-nunjucks',
};
可以设置统一的默认配置,如把默认的模板引擎设置为 nunjucks
:
// config/config.default.js
module.exports = () => {
const config = {};
config.view = {
defaultViewEngine: 'nunjucks',
mapping: {
'.nj': 'nunjucks',
'.tpl': 'nunjucks',
},
};
return config;
};
除了常规的扩展外,在实际业务开发中,我们往往需要为团队定制一些新的目录规范。
此处我们来定义一个 RPC
规范:
约定 app/rpc/**
将被挂载为 ctx.rpc.**
提供 egg.RPC
基类,对后端请求进行封装,供应用层继承。
直接 show me the code
,其实就是对 HTTP 协议做了一个上层封装,统一了响应格式。
该 RPC 类在framework.js
里面会被引入到 egg 对象上。
// lib/rpc.js
class RPC {
constructor(ctx) {
this.ctx = ctx;
this.app = ctx.app;
this.logger = ctx.logger;
this.config = ctx.app.config;
}
async api(apiName, data) {
const host = this.config.rpc.host;
try {
const targetUrl = `${host}/api${apiName}`;
this.logger.info(`[RPC] request api: ${targetUrl}`);
const res = await this.ctx.curl(targetUrl, {
dataType: 'json',
contentType: 'json',
timeout: 5000,
data,
});
return this.handlerResult(res, { apiName, data });
} catch (err) {
return this.handlerError(err, { apiName, data });
}
}
handlerResult(res) {
return {
success: true,
data: res.data,
};
}
handlerError(err, meta) {
this.logger.error(`[RPC] request ${meta.apiName} fail: ${err.message}`);
return {
success: false,
error: {
message: err.message,
},
};
}
}
module.exports = RPC;
在 《如何为团队量身定制 Egg 目录挂载规范?》[1]一文中有专门介绍过。
此处我们仅需要简单配置下:
// config/config.default.js
module.exports = () => {
const config = {};
// ...
// 自定义加载规范
config.customLoader = {
rpc: {
directory: 'app/rpc',
inject: 'ctx',
loadunit: true,
},
};
return config;
};
然后我们如果在应用中添加app/rpc/user.js
文件:
// app/rpc/user.js
const { RPC } = require('egg');
module.exports = class TestRPC extends RPC {
async getDetail(id) {
return await this.api('/user/detail', { id });
}
};
在 Controller 那边就可以直接调用 ctx.rpc.user.getDetail()
了。
class HomeController extends Controller {
async detail() {
const { ctx } = this;
const name = ctx.params.name;
const { data: userInfo } = await ctx.rpc.user.getDetail(name);
await ctx.render('home.tpl', userInfo);
}
}
单元测试很重要,尤其是 Framework 必须要求 100% 的测试覆盖率。
首先需要新增 fixtures
,可以看到,就是一个标准的 Egg 应用,用来模拟我们的业务场景。
└── test
├── fixtures
│ └── example
│ ├── app
│ │ ├── rpc
│ │ │ └── user.js
│ │ ├── controller
│ │ │ └── home.js
│ │ └── router.js
│ ├── config
│ │ └── config.default.js
│ └── package.json
└── framework.test.js
然后编写一个个的单测:
跟 Egg 应用的单元测试几乎没区别,只是多了一个framework: true
的声明。
// test/framework.test.js
const mock = require('egg-mock');
describe('test/framework.test.js', () => {
let app;
before(() => {
app = mock.app({
baseDir: 'example',
// 声明是测试 Framework
framework: true,
});
return app.ready();
});
after(() => app && app.close());
afterEach(mock.restore);
it('should GET /', async () => {
return app.httpRequest()
.get('/')
.expect('yadan\n')
.expect(200);
});
});
如果你的 Framework 提供了多个功能,我们建议拆为多个 fixtures,一个特性一个特性的测试,并覆盖完全。
通过 npm run cov
来查看你的单元测试覆盖率,我们内置骨架也帮你自动生成了GitHub Action
的 CI 测试配置。
跟平时发布 npm 没啥区别,此处介绍下我们的一些最佳实践。
如果你想在发布前先测试,首先可以通过npm link
方式来软链到应用中
$ cd /path/to/demo
$ npm link /path/to/framework
详情参见你所不知道的模块调试技巧 - npm link #17[2]
接着就可以发布测试版本了,此时可以先发0.x
:
修改 package.json 为0.0.1
发布指令为 npm publish --tag=beta
在应用引入时为 npm i --save @eggjs/yadan@beta
这样的好处是,在 0.x 升级新版本的时候,应用那边能安装到最新的版本。
因为根据Semver规则, ^0.0.1
是安装不到 0.1.0
等版本的。
当 beta 验证通过后,应该果断的发布 1.x
版本,禁止停留在 0.x
版本,否则你会踩坑。
Chromium 等都版本帝了,你吝啬个啥啊,版本号又不值钱。
修改 package.json 为 1.0.0
发布指令去掉 beta,改为 npm publish
在应用引入时为 npm i --save @eggjs/yadan
后续发版本,要严格遵循 **Semver **规则,不能有 break change,且要求应用不锁版本,通过^1
的方式引入依赖。
如果实在无法兼容,就发大版本,且最好提供 codemod 来帮旧应用自动升级。
在应用中使用你的框架很简单,只需要在 package.json
简单声明下:
{
"name": "egg-showcase",
"egg": {
"framework": "@eggjs/yadan"
},
"dependencies": {
"yadan": "^1"
}
}
然后正常启动即可,会看到以下信息:
[master] yadan started on http://127.0.0.1:7001 (1511ms)
这样,所有依赖这个 Framework 的应用,都可以使用它提供的标准化能力和团队规范。
至此,我们就已经完成了一个基于 Egg 的上层业务框架的开发,是不是觉得很简单?
简单就对了!Egg 本身的定位就是框架的框架,帮助团队的技术负责人,来定制适合特定的业务场景的上层业务框架。
在阿里内部也是这么实践的:
实际上,框架还支持多层继承,在我们内部的继承关系其实是:
特定场景框架: chair-serverless | midway-faas |
↑ ↑
团队业务框架: chair | midway | nut | ...
↑ ↑ ↑ ↑
阿里统一框架: @ali/egg
↑ ↑ ↑ ↑
开源社区框架: egg
从上面可以看到,Egg 的应用、插件、框架的目录结构几乎一模一样。
实际开发过程中,我们也有一套渐进式的演进方式,分享给大家:
实验性的功能,可以先在应用里面实现,作为 inline plugin 通过 path 方式来挂载。
功能稳定后,就抽出来变为独立的插件,应用再通过 npm 依赖方式引入,只需改两行代码即可。
当该功能成熟后,成为团队的统一规范时,直接把这个插件集成到 Framework 中,所有应用只需重新安装下依赖,即可立刻享受到。
这个过程是闭环的,是渐进式,而且升级过程几乎无痛。
详见文档渐进式开发[3]
最后补一张之前的 Slide:
希望通过本文,让大家了解到 Egg 的三个概念,也能一窥我们如此设计架构的原因。
一个人的项目怎么样都无所谓,但当大规模应用的时候,数千个应用分布到数十个团队里面,此时的生态共建、差异化定制、应用治理能力,就变为一个很复杂的工程问题了(可以思考下这种规模下如何推动框架升级和治理)。
这也是我们为什么做 Egg 的初心,它的定位就是框架的框架,专注于提供一套 Loader 规范和插件框架体系,目标用户是团队的架构师。 它本身是不能跟市面上的框架直接对比的,基于它搭建的上层业务框架,才是一个合适的框架对比对象。
但实际上,框架只是整个链路中的很小的一点,Egg 也已经是我们 3 年前的实践了。
如何让前端同学可以在不增加额外学习成本的情况下,无感无痛地使用服务端能力,目前还有非常多急需解决的问题,需要深入到 PaaS、中间件基础设施、研发平台等等层面。我们还在路上,正致力于为蚂蚁提供 轻研发、免运维 的下一代 Node.js 研发方案。
以上,天猪,2020 年,蚂蚁金服体验技术部广州分部。
[1]
《如何为团队量身定制 Egg 目录挂载规范?》: https://zhuanlan.zhihu.com/p/153322661
[2]《你所不知道的模块调试技巧 - npm link #17》: https://github.com/atian25/blog/issues/17
[3]《渐进式开发》: https://pic2.zhimg.com/80/v2-71fa9b958806b9f88506cd7a6e09d69d_720w.jpg
❤️爱心三连击1.看到这里了就点个在看支持下吧,你的「在看」是我创作的动力。
2.关注公众号程序员成长指北,「带你一起学Node」!
3.特殊阶段,带好口罩,做好个人防护。
4.可以添加我微信【ikoala520】,拉你进技术交流群一起学习。
“在看转发”是最大的支持