首发于NodeJS
如何为团队定制自己的 Node.js 框架?(基于 EggJS)

如何为团队定制自己的 Node.js 框架?(基于 EggJS)

## 背景

回想下,当你需要新起一个 Node.js 应用的时候,会怎么做?

憨厚一点的就从头开始初始化,一个个插件的安装,CTRL + C 一个个的配置。好一点的,就会封装一个骨架,然后一键生成新项目。

那如果在 A 应用中的一些实践,想下沉为基础能力,就需要修改骨架。此时,如何把旧项目升级呢?一两个还好说,如果十几个,甚至上百个呢?

我们的实践是:基于 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 });

完整的示例代码可以参见 github.com/atian25/yada,下文我们会讲解关键细节。


## 初始化

通过骨架一键初始化 Framework 代码:

$ npm init egg --type=framework yadan

可以看到,Framework 的目录结构,和一个 Egg 应用几乎一模一样,熟悉的 configapp/extendapp/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]() {
    return path.dirname(__dirname);
  }
}

class Agent extends egg.Agent {
  get [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 规范

除了常规的扩展外,在实际业务开发中,我们往往需要为团队定制一些新的目录规范。

此处我们来定义一个 RPC 规范:

  • 约定 app/rpc/** 将被挂载为 ctx.rpc.**
  • 提供 egg.RPC 基类,对后端请求进行封装,供应用层继承。

### 定义 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;

### RPC 加载逻辑

《如何为团队量身定制 Egg 目录挂载规范?》一文中有专门介绍过。

此处我们仅需要简单配置下:

// 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('<div>yadan</div>\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》

### 发布 beta

接着就可以发布测试版本了,此时可以先发 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 中,所有应用只需重新安装下依赖,即可立刻享受到。

这个过程是闭环的,是渐进式,而且升级过程几乎无痛。详见文档 《渐进式开发》

最后补一张之前的 Slide:




## 写在最后

希望通过本文,让大家了解到 Egg 的三个概念,也能一窥我们如此设计架构的原因。

一个人的项目怎么样都无所谓,但当大规模应用的时候,数千个应用分布到数十个团队里面,此时的生态共建、差异化定制、应用治理能力,就变为一个很复杂的工程问题了(可以思考下这种规模下如何推动框架升级和治理)。

这也是我们为什么做 Egg 的初心,它的定位就是框架的框架,专注于提供一套 Loader 规范和插件框架体系,目标用户是团队的架构师。它本身是不能跟市面上的框架直接对比的,基于它搭建的上层业务框架,才是一个合适的框架对比对象。

但实际上,框架只是整个链路中的很小的一点,Egg 也已经是我们 3 年前的实践了。

如何让前端同学可以在不增加额外学习成本的情况下,无感无痛地使用服务端能力,目前还有非常多急需解决的问题,需要深入到 PaaS、中间件基础设施、研发平台等等层面。我们还在路上,正致力于 为蚂蚁提供 轻研发、免运维 的下一代 Node.js 研发方案。

以上,天猪,2020 年,蚂蚁金服体验技术部广州分部。

编辑于 07-08

文章被以下专栏收录