• 框架开发
  • 框架与多进程
  • 如何定制一个框架
    • 框架 API
      • egg.startCluster
      • egg.Applicationegg.Agent
      • egg.AppWorkerLoaderegg.AgentWorkerLoader
    • 框架继承
    • 框架继承原理
    • 自定义 Agent
    • 自定义 Loader
  • 框架启动原理
  • 框架测试
    • 初始化
    • 缓存
    • 多进程测试

    框架开发

    如果你的团队遇到过:

    • 维护很多个项目,每个项目都需要复制拷贝诸如 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。

    1. Koa Application
    2. ^
    3. EggCore
    4. ^
    5. ┌──────┴───────┐
    6. Egg Agent Egg Application
    7. ^ ^
    8. agent worker app worker

    如何定制一个框架

    你可以直接通过 egg-boilerplate-framework 脚手架来快速上手。

    1. $ mkdir yadan && cd yadan
    2. $ npm init egg --type=framework
    3. $ npm i
    4. $ npm test

    但同样,为了让大家了解细节,接下来我们还是手把手来定制一个框架,具体代码可以查看示例

    框架 API

    Egg 框架提供了一些 API,所有继承的框架都需要提供,只增不减。这些 API 基本都有 Agent 和 Application 两份。

    egg.startCluster

    Egg 的多进程启动器,由这个方法来启动 Master,主要的功能实现在 egg-cluster 上。所以直接使用 EggCore 还是单进程的方式,而 Egg 实现了多进程。

    1. const startCluster = require('egg').startCluster;
    2. startCluster({
    3. // 应用的代码目录
    4. baseDir: '/path/to/app',
    5. // 需要通过这个参数来指定框架目录
    6. framework: '/path/to/framework',
    7. }, () => {
    8. console.log('app started');
    9. });

    所有参数可以查看 egg-cluster

    egg.Applicationegg.Agent

    进程中的唯一单例,但 Application 和 Agent 存在一定差异。如果框架继承于 Egg,会定制这两个类,那 framework 应该 export 这两个类。

    egg.AppWorkerLoaderegg.AgentWorkerLoader

    框架也存在定制 Loader 的场景,覆盖原方法或者新加载目录都需要提供自己的 Loader,而且必须要继承 Egg 的 Loader。

    框架继承

    框架支持继承关系,可以把框架比作一个类,那么基类就是 Egg 框架,如果想对 Egg 做扩展就继承。

    首先定义一个框架需要实现 Egg 所有的 API

    1. // package.json
    2. {
    3. "name": "yadan",
    4. "dependencies": {
    5. "egg": "^2.0.0"
    6. }
    7. }
    8. // index.js
    9. module.exports = require('./lib/framework.js');
    10. // lib/framework.js
    11. const path = require('path');
    12. const egg = require('egg');
    13. const EGG_PATH = Symbol.for('egg#eggPath');
    14. class Application extends egg.Application {
    15. get [EGG_PATH]() {
    16. // 返回 framework 路径
    17. return path.dirname(__dirname);
    18. }
    19. }
    20. // 覆盖了 Egg 的 Application
    21. module.exports = Object.assign(egg, {
    22. Application,
    23. });

    应用启动时需要指定框架名(在 package.json 指定 egg.framework,默认为 egg),Loader 将从 node_modules 找指定模块作为框架,并加载其 export 的 Application。

    1. {
    2. "scripts": {
    3. "dev": "egg-bin dev"
    4. },
    5. "egg": {
    6. "framework": "yadan"
    7. }
    8. }

    现在 yadan 框架目录已经是一个 loadUnit,那么相应目录和文件(如 appconfig)都会被加载,查看框架被加载的文件。

    框架继承原理

    使用 Symbol.for('egg#eggPath') 来指定当前框架的路径,目的是让 Loader 能探测到框架的路径。为什么这样实现呢?其实最简单的方式是将框架的路径传递给 Loader,但我们需要实现多级框架继承,每一层框架都要提供自己的当前路径,并且需要继承存在先后顺序。

    现在的实现方案是基于类继承的,每一层框架都必须继承上一层框架并且指定 eggPath,然后遍历原型链就能获取每一层的框架路径了。

    比如有三层框架:部门框架(department)> 企业框架(enterprise)> Egg

    1. // enterprise
    2. const Application = require('egg').Application;
    3. class Enterprise extends Application {
    4. get [EGG_PATH]() {
    5. return '/path/to/enterprise';
    6. }
    7. }
    8. // 自定义模块 Application
    9. exports.Application = Enterprise;
    10. // department
    11. const Application = require('enterprise').Application;
    12. // 继承 enterprise 的 Application
    13. class department extends Application {
    14. get [EGG_PATH]() {
    15. return '/path/to/department';
    16. }
    17. }
    18. // 启动需要传入 department 的框架路径才能获取 Application
    19. const Application = require('department').Application;
    20. const app = new Application();
    21. app.ready();

    以上均是伪代码,为了详细说明框架路径的加载过程,不过 Egg 已经在本地开发和应用部署提供了很好的工具,不需要自己实现。

    自定义 Agent

    上面的例子自定义了 Application,因为 Egg 是多进程模型,所以还需要定义 Agent,原理是一样的。

    1. // lib/framework.js
    2. const path = require('path');
    3. const egg = require('egg');
    4. const EGG_PATH = Symbol.for('egg#eggPath');
    5. class Application extends egg.Application {
    6. get [EGG_PATH]() {
    7. // 返回 framework 路径
    8. return path.dirname(__dirname);
    9. }
    10. }
    11. class Agent extends egg.Agent {
    12. get [EGG_PATH]() {
    13. return path.dirname(__dirname);
    14. }
    15. }
    16. // 覆盖了 Egg 的 Application
    17. module.exports = Object.assign(egg, {
    18. Application,
    19. Agent,
    20. });

    但因为 Agent 和 Application 是两个实例,所以 API 有可能不一致。

    自定义 Loader

    Loader 应用启动的核心,使用它还能规范应用代码,我们可以基于这个类扩展更多功能,比如加载数据代码。扩展 Loader 还能覆盖默认的实现,或调整现有的加载顺序等。

    自定义 Loader 也是用 Symbol.for('egg#loader') 的方式,主要的原因还是使用原型链,上层框架可覆盖底层 Loader,在上面例子的基础上

    1. // lib/framework.js
    2. const path = require('path');
    3. const egg = require('egg');
    4. const EGG_PATH = Symbol.for('egg#eggPath');
    5. class YadanAppWorkerLoader extends egg.AppWorkerLoader {
    6. load() {
    7. super.load();
    8. // 自己扩展
    9. }
    10. }
    11. class Application extends egg.Application {
    12. get [EGG_PATH]() {
    13. // 返回 framework 路径
    14. return path.dirname(__dirname);
    15. }
    16. // 覆盖 Egg 的 Loader,启动时使用这个 Loader
    17. get [EGG_LOADER]() {
    18. return YadanAppWorkerLoader;
    19. }
    20. }
    21. // 覆盖了 Egg 的 Application
    22. module.exports = Object.assign(egg, {
    23. Application,
    24. // 自定义的 Loader 也需要 export,上层框架需要基于这个扩展
    25. AppWorkerLoader: YadanAppWorkerLoader,
    26. });

    AgentWorkerLoader 扩展也类似,这里不再举例。AgentWorkerLoader 加载的文件可以于 AppWorkerLoader 不同,比如:默认加载时,Egg 的 AppWorkerLoader 会加载 app.js 而 AgentWorkerLoader 加载的是 agent.js

    框架启动原理

    框架启动在多进程模型、Loader、插件中或多或少都提过,这里系统的梳理下启动顺序。

    • startCluster 启动传入 baseDirframework,Master 进程启动
    • Master 先 fork Agent Worker
      • 根据 framework 找到框架目录,实例化该框架的 Agent 类
      • Agent 找到定义的 AgentWorkerLoader,开始进行加载
      • AgentWorkerLoader,开始进行加载 整个加载过程是同步的,按 plugin > config > extend > agent.js > 其他文件顺序加载
      • agent.js 可自定义初始化,支持异步启动,如果定义了 beforeStart 会等待执行完成之后通知 Master 启动完成。
    • Master 得到 Agent Worker 启动成功的消息,使用 cluster fork App Worker
      • App Worker 有多个进程,所以这几个进程是并行启动的,但执行逻辑是一致的
      • 单个 App Worker 和 Agent 类似,通过 framework 找到框架目录,实例化该框架的 Application 类
      • Application 找到 AppWorkerLoader,开始进行加载,顺序也是类似的,会异步等待,完成后通知 Master 启动完成
    • Master 等待多个 App Worker 的成功消息后启动完成,能对外提供服务。

    框架测试

    在看下文之前请先查看单元测试章节,框架测试的大部分使用场景和应用类似。

    初始化

    框架的初始化方式有一定差异

    1. const mock = require('egg-mock');
    2. describe('test/index.test.js', () => {
    3. let app;
    4. before(() => {
    5. app = mock.app({
    6. // 转换成 test/fixtures/apps/example
    7. baseDir: 'apps/example',
    8. // 重要:配置 framework
    9. framework: true,
    10. });
    11. return app.ready();
    12. });
    13. after(() => app.close());
    14. afterEach(mock.restore);
    15. it('should success', () => {
    16. return app.httpRequest()
    17. .get('/')
    18. .expect(200);
    19. });
    20. });
    • 框架和应用不同,应用测试当前代码,而框架是测试框架代码,所以会频繁更换 baseDir 达到测试各种应用的目的。
    • baseDir 有潜规则,我们一般会把测试的应用代码放到 test/fixtures 下,所以自动补全,也可以传入绝对路径。
    • 必须指定 framework: true,告知当前路径为框架路径,也可以传入绝对路径。
    • app 应用需要在 before 等待 ready,不然在 testcase 里无法获取部分 API
    • 框架在测试完毕后需要使用 app.close() 关闭,不然会有遗留问题,比如日志写文件未关闭导致 fd 不够。

    缓存

    在测试多环境场景需要使用到 cache 参数,因为 mm.app 默认有缓存,当第一次加载过后再次加载会直接读取缓存,那么设置的环境也不会生效。

    1. const mock = require('egg-mock');
    2. describe('/test/index.test.js', () => {
    3. let app;
    4. afterEach(() => app.close());
    5. it('should test on local', () => {
    6. mock.env('local');
    7. app = mock.app({
    8. baseDir: 'apps/example',
    9. framework: true,
    10. cache: false,
    11. });
    12. return app.ready();
    13. });
    14. it('should test on prod', () => {
    15. mock.env('prod');
    16. app = mock.app({
    17. baseDir: 'apps/example',
    18. framework: true,
    19. cache: false,
    20. });
    21. return app.ready();
    22. });
    23. });

    多进程测试

    很少场景会使用多进程测试,因为多进程无法进行 API 级别的 mock 导致测试成本很高,而进程在有覆盖率的场景启动很慢,测试会超时。但多进程测试是验证多进程模型最好的方式,还可以测试 stdout 和 stderr。

    多进程测试和 mm.app 参数一致,但 app 的 API 完全不同,不过 SuperTest 依然可用。

    1. const mock = require('egg-mock');
    2. describe('/test/index.test.js', () => {
    3. let app;
    4. before(() => {
    5. app = mock.cluster({
    6. baseDir: 'apps/example',
    7. framework: true,
    8. });
    9. return app.ready();
    10. });
    11. after(() => app.close());
    12. afterEach(mock.restore);
    13. it('should success', () => {
    14. return app.httpRequest()
    15. .get('/')
    16. .expect(200);
    17. });
    18. });

    多进程测试还可以测试 stdout/stderr,因为 mm.cluster 是基于 coffee 扩展的,可进行进程测试。

    1. const mock = require('egg-mock');
    2. describe('/test/index.test.js', () => {
    3. let app;
    4. before(() => {
    5. app = mock.cluster({
    6. baseDir: 'apps/example',
    7. framework: true,
    8. });
    9. return app.ready();
    10. });
    11. after(() => app.close());
    12. it('should get `started`', () => {
    13. // 判断终端输出
    14. app.expect('stdout', /started/);
    15. });
    16. });