如果你的团队遇到过:
- 维护很多个项目,每个项目都需要复制拷贝诸如
gulpfile.js
、webpack.config.js
之类的文件。 - 每个项目都需要使用一些相同的类库,相同的配置。
- 在新项目中对上面的配置做了一个优化后,如何同步到其他项目?
如果你的团队需要:
- 统一的技术选型,比如数据库、模板、前端框架及各种中间件设施都需要选型,而框架封装后保证应用使用一套架构。
- 统一的默认配置,开源社区的配置可能不适用于公司,而又不希望应用去配置。
- 统一的部署方案,通过框架和平台的双向控制,应用只需要关注自己的代码,具体查看应用部署。
- 统一的代码风格,框架不仅仅解决代码重用问题,还可以对应用做一定约束,作为企业框架是很必要的。Egg 在 Koa 的基础上做了很多约定,框架可以使用 Loader 自己定义代码规则。
为此,Egg 为团队架构师和技术负责人提供 框架定制
的能力,框架是一层抽象,可以基于 Egg 去封装上层框架,并且 Egg 支持多层继承。
这样,整个团队就可以遵循统一的方案,并且在项目中可以根据业务场景自行使用插件做差异化;当后者验证为最佳实践后,就能下沉到框架中,其他项目仅需简单的升级框架版本即可享受到。
具体可以参见渐进式开发。
框架与多进程
框架的扩展与多进程模型有关,我们已经了解了多进程模型,也知道 Agent Worker
和 App Worker
的区别。因此,我们需要扩展的类也有两个:Agent
和 Application
,这两个类的 API 不一定相同。
在 Agent Worker
启动的时候会实例化 Agent
,而在 App Worker
启动时会实例化 Application
。这两个类又同时继承自 EggCore。
EggCore 可以看做是 Koa Application
的升级版,默认内置了 Loader、Router 及应用异步启动等功能,可以看作是支持 Loader
的 Koa。
Koa Application
^
EggCore
^
┌──────┴───────┐
│ │
Egg Agent Egg Application
^ ^
agent worker app worker
如何定制一个框架
你可以直接通过 egg-boilerplate-framework 脚手架快速上手。
$ mkdir yadan && cd yadan
$ npm init egg --type=framework
$ npm i
$ npm test
但同样,为了让大家了解细节,接下来我们会手把手地来定制一个框架,具体代码可以查看示例。
框架 API
Egg 框架提供了一些 API,所有继承的框架都需要提供,只增不减。这些 API 基本都存在于 Agent 和 Application 两份实现中。
egg.startCluster
Egg 的多进程启动器,通过这个方法来启动 Master,主要的功能实现在 egg-cluster 上。因此,直接使用 EggCore 是单进程方式启动的,而 Egg 实现了多进程模式。
const startCluster = require('egg').startCluster;
startCluster(
{
// 应用的代码目录
baseDir: '/path/to/app',
// 需要通过这个参数来指定框架目录
framework: '/path/to/framework',
},
() => {
console.log('app started');
},
);
所有参数可以查看 egg-cluster。
egg.Application
和 egg.Agent
它们是进程中的唯一实例,但 Application 和 Agent 存在一定差异。如果框架继承自 Egg,会定制这两个类,那么框架应该导出(export)这两个类。
egg.AppWorkerLoader
和 egg.AgentWorkerLoader
框架也可能会有定制 Loader 的场景,如覆盖原方法或新加载目录,都需要提供自己的 Loader。而且必须继承自 Egg 的 Loader。
框架继承
框架支持继承关系,可以把框架类比于一个类,那么基类就是 Egg 框架。如果你想对 Egg 进行扩展,那么可以继承它。
首先,定义一个框架需要实现 Egg 所有的 API:
// package.json
{
"name": "yadan",
"dependencies": {
"egg": "^2.0.0"
}
}
// index.js
module.exports = require('./lib/framework.js');
// lib/framework.js
const path = require('path');
const egg = require('egg');
class Application extends egg.Application {
protected override customEggPaths() {
// 返回框架路径
return [path.dirname(__dirname), ...super.customEggPaths()];
}
}
module.exports = Object.assign(egg, {
Application,
// 可能还会有其他扩展
});
应用启动时需要指定框架名(在 package.json
中指定 egg.framework
,默认值是 egg
),Loader 会从 node_modules
中寻找指定模块作为框架,并载入其导出的 Application。
{
"scripts": {
"dev": "egg-bin dev"
},
"egg": {
"framework": "yadan"
}
}
现在,yadan 框架的目录已经成为了一个加载单元(loadUnit),因此相应的目录和文件(如 app
和 config
)都会被加载。详情可以查看框架被加载的文件。
框架继承原理
使用 customEggPaths()
来指定当前框架的路径,目的是让 Loader 能探测到框架路径。为什么采取这种实现方式?本可以将框架路径直接传给 Loader,但为了实现多级框架继承,每一层框架都要提供自己的路径,且继承有其顺序。
现在的实现方案是基于类继承的。每一层框架都必须继承上一层框架,并且指定 eggPath,之后遍历原型链,就可以获取到每一层框架的路径了。
比如有三层框架:部门框架(department)> 企业框架(enterprise)> Egg
// enterprise
const Application = require('egg').Application;
class EnterpriseApplication extends Application {
protected override customEggPaths() {
return ['/path/to/enterprise', ...super.customEggPaths()];
}
}
// 自定义模块的 Application
exports.Application = EnterpriseApplication;
// department
const EnterpriseApplication = require('enterprise').Application;
// 继承自 enterprise 的 Application
class DepartmentApplication extends EnterpriseApplication {
protected override customEggPaths() {
return ['/path/to/department', ...super.customEggPaths()];
}
}
// 启动时需要传入 department 的框架路径
const DepartmentApplication = require('department').Application;
const app = new DepartmentApplication();
app.ready();
以上都是示例代码,用于解释框架路径加载过程。实际上,Egg 已经提供了本地开发和应用部署的优秀工具,不需要我们自行实现。 下面是根据《优秀技术文档的写作标准》修改后的全文内容:
自定义 Agent
上面的例子自定义了 Application。由于 Egg 是多进程模型,因此还需要定义 Agent。原理是一样的。
// lib/framework.js
const path = require('path');
const egg = require('egg');
class Application extends egg.Application {
protected override customEggPaths() {
// 返回 framework 路径
return [path.dirname(__dirname), ...super.customEggPaths()];
}
}
class Agent extends egg.Agent {
protected override customEggPaths() {
return [path.dirname(__dirname), ...super.customEggPaths()];
}
}
// 覆盖了 Egg 的 Application
module.exports = Object.assign(egg, {
Application,
Agent,
});
但因为 Agent 和 Application 是两个实例,API 有可能不一致。
自定义 Loader
Loader 是应用启动的核心。利用它,我们不仅能规范应用代码,还能基于这个类扩展更多功能,比如加载数据模型。扩展 Loader 还可以覆盖默认的实现,或调整现有的加载顺序等。
我们使用 customEggLoader()
来自定义 Loader,主要原因还是为了使用原型链。这样,上层框架可以覆盖底层 Loader。在上面的例子基础上:
// lib/framework.js
const path = require('path');
const egg = require('egg');
class YadanAppWorkerLoader extends egg.AppWorkerLoader {
load() {
super.load();
// 进行自己的扩展
}
}
class Application extends egg.Application {
protected override customEggPaths() {
// 返回 framework 路径
return [path.dirname(__dirname), ...super.customEggPaths()];
}
// 覆盖 Egg 的 Loader,启动时将使用这个 Loader
protected override customEggLoader() {
return YadanAppWorkerLoader;
}
}
// 覆盖了 Egg 的 Application
module.exports = Object.assign(egg, {
Application,
// 自定义的 Loader 也需要导出,以便上层框架进行扩展
AppWorkerLoader: YadanAppWorkerLoader,
});
AgentWorkerLoader 的扩展也类似,这里不再赘述。AgentWorkerLoader 加载的文件可以与 AppWorkerLoader 不同。比如,默认加载时,Egg 的 AppWorkerLoader 会加载 app.js
,而 AgentWorkerLoader 加载的是 agent.js
。
框架启动原理
框架启动过程在多进程模型、Loader、插件中或多或少都有提及。这里我们系统地梳理一下启动顺序:
startCluster
启动时传入baseDir
和framework
,从而启动 Master 进程。- Master 首先 fork Agent Worker:
- 根据 framework 找到框架目录,实例化该框架的 Agent 类。
- Agent 根据定义的 AgentWorkerLoader 开始加载。
- 整个 AgentWorkerLoader 的加载过程是同步的,按照 plugin > config > extend >
agent.js
> 其他文件的顺序进行。 - 如果
agent.js
中定义了自定义初始化并支持异步启动,当执行完成后,会告知 Master 启动已完成。
- Master 在接到 Agent Worker 启动成功的消息后,会 fork App Worker:
- App Worker 由多个进程组成,这些进程会并行启动,但执行逻辑是一致的。
- 单个 App Worker 通过 framework 找到框架目录,实例化该框架的 Application 类。
- Application 根据 AppWorkerLoader 开始加载,加载顺序类似,会异步等待完成后通知 Master 启动完成。
- Master 在等到所有 App Worker 发来的启动成功消息后,完成启动,开始对外提供服务。
框架测试
在看下文之前,请先查看单元测试章节。框架测试的大部分使用场景和应用类似。
初始化
框架的初始化方式有一定差异。
const mock = require('@eggjs/mock');
describe('test/index.test.js', () => {
let app;
before(() => {
app = mock.app({
// 转换成 test/fixtures/apps/example
baseDir: 'apps/example',
// 重要:配置 framework
framework: true,
});
return app.ready();
});
after(() => app.close());
afterEach(mock.restore);
it('should success', () => {
return app.httpRequest().get('/').expect(200);
});
});
- 框架和应用不同,应用测试当前代码,而框架是测试框架代码,所以会频繁更换 baseDir 达到测试各种应用的目的。
- baseDir 有潜规则,我们一般会把测试的应用代码放到
test/fixtures
下,所以自动补全,也可以传入绝对路径。 - 必须指定
framework: true
,告知当前路径为框架路径;也可以传入绝对路径。 - app 应用需要在 before 等待 ready,否则在 testcase 里无法获取部分 API。
- 框架在测试完毕后,需要使用
app.close()
关闭,否则会有遗留问题,例如日志写文件未关闭导致 fd 不够。
缓存
在测试多环境场景需要使用到 cache 参数,因为 mock.app
默认有缓存,当第一次加载后再次加载会直接读取缓存,那么设置的环境也不会生效。
const mock = require('@eggjs/mock');
describe('test/index.test.js', () => {
let app;
afterEach(() => app.close());
it('should test on local', () => {
mock.env('local');
app = mock.app({
baseDir: 'apps/example',
framework: true,
cache: false,
});
return app.ready();
});
it('should test on prod', () => {
mock.env('prod');
app = mock.app({
baseDir: 'apps/example',
framework: true,
cache: false,
});
return app.ready();
});
});
多进程测试
很少场景会使用多进程测试,因为多进程无法进行 API 级别的 mock,导致测试成本很高。而进程在有覆盖率的场景启动很慢,测试会超时。但多进程测试是验证多进程模型最好的方式。还可以测试 stdout 和 stderr。
多进程测试和 mock.app
参数一致,但 app 的 API 完全不同。不过,SuperTest 依然可用。
const mock = require('@eggjs/mock');
describe('test/index.test.js', () => {
let app;
before(() => {
app = mock.cluster({
baseDir: 'apps/example',
framework: true,
});
return app.ready();
});
after(() => app.close());
afterEach(mock.restore);
it('should success', () => {
return app.httpRequest().get('/').expect(200);
});
});
多进程测试还可以测试 stdout/stderr,因为 mock.cluster
是基于 coffee 扩展的,可进行进程测试。
const mock = require('@eggjs/mock');
describe('test/index.test.js', () => {
let app;
before(() => {
app = mock.cluster({
baseDir: 'apps/example',
framework: true,
});
return app.ready();
});
after(() => app.close());
it('should get `started`', () => {
// 判断终端输出
app.expect('stdout', /started/);
});
});