• 插件开发
  • 插件开发
    • 使用脚手架快速开发
  • 插件的目录结构
  • 插件的依赖管理
  • 插件能做什么?
    • 扩展内置对象的接口
    • 插入自定义中间件
    • 在应用启动时做一些初始化工作
    • 设置定时任务
    • 全局实例插件的最佳实践
      • 插件写法
      • 应用层使用方案
        • 单实例
        • 多实例
        • 动态创建实例
    • 插件的寻址规则
    • 插件规范
  • 为何不使用 npm 包名来做插件名?

    插件开发

    插件机制是我们框架的一大特色。它不但可以保证框架核心的足够精简、稳定、高效,还可以促进业务逻辑的复用,生态圈的形成。有人可能会问了:

    • Koa 已经有了中间件的机制,为啥还要插件呢?
    • 中间件、插件、应用它们之间是什么关系,有什么区别?
    • 我该怎么使用一个插件?
    • 如何编写一个插件?

    在使用插件章节我们已经讨论过前几点,接下来我们来看看如何开发一个插件。

    插件开发

    使用脚手架快速开发

    你可以直接使用 egg-boilerplate-plugin 脚手架来快速上手。

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

    插件的目录结构

    一个插件其实就是一个『迷你的应用』,下面展示的是一个插件的目录结构,和应用(app)几乎一样。

    1. . egg-hello
    2. ├── package.json
    3. ├── app.js (可选)
    4. ├── agent.js (可选)
    5. ├── app
    6. ├── extend (可选)
    7. | ├── helper.js (可选)
    8. | ├── request.js (可选)
    9. | ├── response.js (可选)
    10. | ├── context.js (可选)
    11. | ├── application.js (可选)
    12. | └── agent.js (可选)
    13. ├── service (可选)
    14. └── middleware (可选)
    15. └── mw.js
    16. ├── config
    17. | ├── config.default.js
    18. ├── config.prod.js
    19. | ├── config.test.js (可选)
    20. | ├── config.local.js (可选)
    21. | └── config.unittest.js (可选)
    22. └── test
    23. └── middleware
    24. └── mw.test.js

    那区别在哪儿呢?

    1. 插件没有独立的 router 和 controller。这主要出于几点考虑:

      • 路由一般和应用强绑定的,不具备通用性。
      • 一个应用可能依赖很多个插件,如果插件支持路由可能导致路由冲突。
      • 如果确实有统一路由的需求,可以考虑在插件里通过中间件来实现。
    2. 插件需要在 package.json 中的 eggPlugin 节点指定插件特有的信息:

      • {String} name - 插件名(必须配置),具有唯一性,配置依赖关系时会指定依赖插件的 name。
      • {Array} dependencies - 当前插件强依赖的插件列表(如果依赖的插件没找到,应用启动失败)。
      • {Array} optionalDependencies - 当前插件的可选依赖插件列表(如果依赖的插件未开启,只会 warning,不会影响应用启动)。
      • {Array} env - 只有在指定运行环境才能开启,具体有哪些环境可以参考运行环境。此配置是可选的,一般情况下都不需要配置。

        1. {
        2. "name": "egg-rpc",
        3. "eggPlugin": {
        4. "name": "rpc",
        5. "dependencies": [ "registry" ],
        6. "optionalDependencies": [ "vip" ],
        7. "env": [ "local", "test", "unittest", "prod" ]
        8. }
        9. }
      1. 插件没有 plugin.js

        • eggPlugin.dependencies 只是用于声明依赖关系,而不是引入插件或开启插件。
        • 如果期望统一管理多个插件的开启和配置,可以在上层框架处理。

    插件的依赖管理

    和中间件不同,插件是自己管理依赖的。应用在加载所有插件前会预先从它们的 package.json 中读取 eggPlugin > dependencieseggPlugin > optionalDependencies 节点,然后根据依赖关系计算出加载顺序,举个例子,下面三个插件的加载顺序就应该是 c => b => a

    1. // plugin a
    2. {
    3. "name": "egg-plugin-a",
    4. "eggPlugin": {
    5. "name": "a",
    6. "dependencies": [ "b" ]
    7. }
    8. }
    9. // plugin b
    10. {
    11. "name": "egg-plugin-b",
    12. "eggPlugin": {
    13. "name": "b",
    14. "optionalDependencies": [ "c" ]
    15. }
    16. }
    17. // plugin c
    18. {
    19. "name": "egg-plugin-c",
    20. "eggPlugin": {
    21. "name": "c"
    22. }
    23. }

    注意:dependenciesoptionalDependencies 的取值是另一个插件的 eggPlugin.name,而不是 package name

    dependenciesoptionalDependencies 是从 npm 借鉴来的概念,大多数情况下我们都使用 dependencies,这也是我们最推荐的依赖方式。那什么时候可以用 optionalDependencies 呢?大致就两种:

    • 只在某些环境下才依赖,比如:一个鉴权插件,只在开发环境依赖一个 mock 数据的插件
    • 弱依赖,比如:A 依赖 B,但是如果没有 B,A 有相应的降级方案

    需要特别强调的是:如果采用 optionalDependencies 那么框架不会校验依赖的插件是否开启,它的作用仅仅是计算加载顺序。所以,这时候依赖方需要通过『接口探测』等方式来决定相应的处理逻辑。

    插件能做什么?

    上面给出了插件的定义,那插件到底能做什么?

    扩展内置对象的接口

    在插件相应的文件内对框架内置对象进行扩展,和应用一样

    • app/extend/request.js - 扩展 Koa#Request 类
    • app/extend/response.js - 扩展 Koa#Response 类
    • app/extend/context.js - 扩展 Koa#Context 类
    • app/extend/helper.js - 扩展 Helper 类
    • app/extend/application.js - 扩展 Application 类
    • app/extend/agent.js - 扩展 Agent 类

    插入自定义中间件

    1. 首先在 app/middleware 目录下定义好中间件实现

      1. 'use strict';
      2. const staticCache = require('koa-static-cache');
      3. const assert = require('assert');
      4. const mkdirp = require('mkdirp');
      5. module.exports = (options, app) => {
      6. assert.strictEqual(typeof options.dir, 'string', 'Must set `app.config.static.dir` when static plugin enable');
      7. // ensure directory exists
      8. mkdirp.sync(options.dir);
      9. app.loggers.coreLogger.info('[egg-static] starting static serve %s -> %s', options.prefix, options.dir);
      10. return staticCache(options);
      11. };
    2. app.js 中将中间件插入到合适的位置(例如:下面将 static 中间件放到 bodyParser 之前)

      1. const assert = require('assert');
      2. module.exports = app => {
      3. // 将 static 中间件放到 bodyParser 之前
      4. const index = app.config.coreMiddleware.indexOf('bodyParser');
      5. assert(index >= 0, 'bodyParser 中间件必须存在');
      6. app.config.coreMiddleware.splice(index, 0, 'static');
      7. };

    在应用启动时做一些初始化工作

    • 我在启动前想读取一些本地配置

      1. // ${plugin_root}/app.js
      2. const fs = require('fs');
      3. const path = require('path');
      4. module.exports = app => {
      5. app.customData = fs.readFileSync(path.join(app.config.baseDir, 'data.bin'));
      6. app.coreLogger.info('read data ok');
      7. };
    • 如果有异步启动逻辑,可以使用 app.beforeStart API

      1. // ${plugin_root}/app.js
      2. const MyClient = require('my-client');
      3. module.exports = app => {
      4. app.myClient = new MyClient();
      5. app.myClient.on('error', err => {
      6. app.coreLogger.error(err);
      7. });
      8. app.beforeStart(async () => {
      9. await app.myClient.ready();
      10. app.coreLogger.info('my client is ready');
      11. });
      12. };
    • 也可以添加 agent 启动逻辑,使用 agent.beforeStart API

      1. // ${plugin_root}/agent.js
      2. const MyClient = require('my-client');
      3. module.exports = agent => {
      4. agent.myClient = new MyClient();
      5. agent.myClient.on('error', err => {
      6. agent.coreLogger.error(err);
      7. });
      8. agent.beforeStart(async () => {
      9. await agent.myClient.ready();
      10. agent.coreLogger.info('my client is ready');
      11. });
      12. };

    设置定时任务

    1. package.json 里设置依赖 schedule 插件

      1. {
      2. "name": "your-plugin",
      3. "eggPlugin": {
      4. "name": "your-plugin",
      5. "dependencies": [ "schedule" ]
      6. }
      7. }
    2. ${plugin_root}/app/schedule/ 目录下新建文件,编写你的定时任务

      1. exports.schedule = {
      2. type: 'worker',
      3. cron: '0 0 3 * * *',
      4. // interval: '1h',
      5. // immediate: true,
      6. };
      7. exports.task = async ctx => {
      8. // your logic code
      9. };

    全局实例插件的最佳实践

    许多插件的目的都是将一些已有的服务引入到框架中,如 egg-mysql, egg-oss。他们都需要在 app 上创建对应的实例。而在开发这一类的插件时,我们发现存在一些普遍性的问题:

    • 在一个应用中同时使用同一个服务的不同实例(连接到两个不同的 MySQL 数据库)。
    • 从其他服务获取配置后动态初始化连接(从配置中心获取到 MySQL 服务地址后再建立连接)。

    如果让插件各自实现,可能会出现各种奇怪的配置方式和初始化方式,所以框架提供了 app.addSingleton(name, creator) 方法来统一这一类服务的创建。需要注意的是在使用 app.addSingleton(name, creator) 方法时,配置文件中一定要有 client 或者 clients 为 key 的配置作为传入 creator 函数 的 config

    插件写法

    我们将 egg-mysql 的实现简化之后来看看如何编写此类插件:

    1. // egg-mysql/app.js
    2. module.exports = app => {
    3. // 第一个参数 mysql 指定了挂载到 app 上的字段,我们可以通过 `app.mysql` 访问到 MySQL singleton 实例
    4. // 第二个参数 createMysql 接受两个参数(config, app),并返回一个 MySQL 的实例
    5. app.addSingleton('mysql', createMysql);
    6. }
    7. /**
    8. * @param {Object} config 框架处理之后的配置项,如果应用配置了多个 MySQL 实例,会将每一个配置项分别传入并调用多次 createMysql
    9. * @param {Application} app 当前的应用
    10. * @return {Object} 返回创建的 MySQL 实例
    11. */
    12. function createMysql(config, app) {
    13. assert(config.host && config.port && config.user && config.database);
    14. // 创建实例
    15. const client = new Mysql(config);
    16. // 做启动应用前的检查
    17. app.beforeStart(async () => {
    18. const rows = await client.query('select now() as currentTime;');
    19. app.coreLogger.info(`[egg-mysql] init instance success, rds currentTime: ${rows[0].currentTime}`);
    20. });
    21. return client;
    22. }

    初始化方法也支持 Async function,便于有些特殊的插件需要异步化获取一些配置文件:

    1. async function createMysql(config, app) {
    2. // 异步获取 mysql 配置
    3. const mysqlConfig = await app.configManager.getMysqlConfig(config.mysql);
    4. assert(mysqlConfig.host && mysqlConfig.port && mysqlConfig.user && mysqlConfig.database);
    5. // 创建实例
    6. const client = new Mysql(mysqlConfig);
    7. // 做启动应用前的检查
    8. const rows = await client.query('select now() as currentTime;');
    9. app.coreLogger.info(`[egg-mysql] init instance success, rds currentTime: ${rows[0].currentTime}`);
    10. return client;
    11. }

    可以看到,插件中我们只需要提供要挂载的字段以及对应服务的初始化方法,所有的配置管理、实例获取方式都由框架封装并统一提供了。

    应用层使用方案

    单实例
    1. 在配置文件中声明 MySQL 的配置。

      1. // config/config.default.js
      2. module.exports = {
      3. mysql: {
      4. client: {
      5. host: 'mysql.com',
      6. port: '3306',
      7. user: 'test_user',
      8. password: 'test_password',
      9. database: 'test',
      10. },
      11. },
      12. };
    2. 直接通过 app.mysql 访问数据库。

      1. // app/controller/post.js
      2. class PostController extends Controller {
      3. async list() {
      4. const posts = await this.app.mysql.query(sql, values);
      5. },
      6. }
    多实例
    1. 同样需要在配置文件中声明 MySQL 的配置,不过和单实例时不同,配置项中需要有一个 clients 字段,分别申明不同实例的配置,同时可以通过 default 字段来配置多个实例中共享的配置(如 host 和 port)。需要注意的是在这种情况下要用 get 方法指定相应的实例。(例如:使用 app.mysql.get('db1').query(),而不是直接使用 app.mysql.query() 得到一个 undefined)。

      1. // config/config.default.js
      2. exports.mysql = {
      3. clients: {
      4. // clientId, access the client instance by app.mysql.get('clientId')
      5. db1: {
      6. user: 'user1',
      7. password: 'upassword1',
      8. database: 'db1',
      9. },
      10. db2: {
      11. user: 'user2',
      12. password: 'upassword2',
      13. database: 'db2',
      14. },
      15. },
      16. // default configuration for all databases
      17. default: {
      18. host: 'mysql.com',
      19. port: '3306',
      20. },
      21. };
    2. 通过 app.mysql.get('db1') 来获取对应的实例并使用。

      1. // app/controller/post.js
      2. class PostController extends Controller {
      3. async list() {
      4. const posts = await this.app.mysql.get('db1').query(sql, values);
      5. },
      6. }
    动态创建实例

    我们可以不需要将配置提前申明在配置文件中,而是在应用运行时动态的初始化一个实例。

    1. // app.js
    2. module.exports = app => {
    3. app.beforeStart(async () => {
    4. // 从配置中心获取 MySQL 的配置 { host, post, password, ... }
    5. const mysqlConfig = await app.configCenter.fetch('mysql');
    6. // 动态创建 MySQL 实例
    7. app.database = await app.mysql.createInstanceAsync(mysqlConfig);
    8. });
    9. };

    通过 app.database 来使用这个实例。

    1. // app/controller/post.js
    2. class PostController extends Controller {
    3. async list() {
    4. const posts = await this.app.database.query(sql, values);
    5. },
    6. }

    注意,在动态创建实例的时候,框架也会读取配置中 default 字段内的配置项作为默认配置。

    插件的寻址规则

    框架在加载插件的时候,遵循下面的寻址规则:

    • 如果配置了 path,直接按照 path 加载。
    • 没有 path 根据 package 名去查找,查找的顺序依次是:

      1. 应用根目录下的 node_modules
      2. 应用依赖框架路径下的 node_modules
      3. 当前路径下的 node_modules (主要是兼容单元测试场景)

    插件规范

    我们非常欢迎您贡献新的插件,同时也希望您遵守下面一些规范:

    • 命名规范
      • npm 包名以 egg- 开头,且为全小写,例如:egg-xx。比较长的词组用中划线:egg-foo-bar
      • 对应的插件名使用小驼峰,小驼峰转换规则以 npm 包名的中划线为准 egg-foo-bar => fooBar
      • 对于可以中划线也可以不用的情况,不做强制约定,例如:userservice(egg-userservice) 还是 user-service(egg-user-service) 都可以
    • package.json 书写规范

      • 按照上面的文档添加 eggPlugin 节点
      • keywords 里加上 eggegg-plugineggPlugin 等关键字,便于索引

        1. {
        2. "name": "egg-view-nunjucks",
        3. "version": "1.0.0",
        4. "description": "view plugin for egg",
        5. "eggPlugin": {
        6. "name": "nunjucks",
        7. "dep": [
        8. "security"
        9. ]
        10. },
        11. "keywords": [
        12. "egg",
        13. "egg-plugin",
        14. "eggPlugin",
        15. "egg-plugin-view",
        16. "egg-view",
        17. "nunjucks"
        18. ],
        19. }

    为何不使用 npm 包名来做插件名?

    Egg 是通过 eggPlugin.name 来定义插件名的,只在应用或框架具备唯一性,也就是说多个 npm 包可能有相同的插件名,为什么这么设计呢?

    首先 Egg 插件不仅仅支持 npm 包,还支持通过目录来找插件。在渐进式开发章节提到如何使用这两个配置来进行代码演进。目录对单元测试也比较友好。所以 Egg 无法通过 npm 的包名来做唯一性。

    更重要的是 Egg 可以使用这种特性来做适配器。比如模板开发规范定义的插件名为 view,而存在 egg-view-nunjucksegg-view-react 等插件,使用者只需要更换插件和修改模板,不需要动 Controller, 因为所有的模板插件都实现了相同的 API。

    将相同功能的插件赋予相同的插件名,具备相同的 API,可以快速切换。这在模板、数据库等领域非常适用。