2017-01-11 11:25:27 +08:00

12 KiB
Raw Blame History

title: 插件开发

插件开发

前言

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

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

接下来我们就来逐一讨论

为什么要插件

我们在使用 koa 中间件过程中发现了下面一些问题

  1. 中间件加载其实是有先后顺序的,但是中间件自身却无法管理这种顺序,只能交给使用者。这样其实非常不友好,一旦顺序不对,结果可能有天壤之别。
  2. 中间件的定位是拦截用户请求,并在它前后做一些事情,例如:鉴权、安全检查、访问日志等等。但实际情况是,有些功能是和请求无关的,例如:定时任务、消息订阅、后台逻辑等等。
  3. 有些功能包含非常复杂的初始化逻辑,需要在应用启动的时候完成。这显然也不适合放到中间件中去实现

综上所诉,我们需要一套更加强大的机制,来管理、编排那些相对独立的业务逻辑。

什么是插件

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

. hello-plugin
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
   ├── extend (可选)
   |   ├── helper.js (可选)
   |   ├── request.js (可选)
   |   ├── response.js (可选)
   |   ├── context.js (可选)
   |   ├── application.js (可选)
   |   └── agent.js (可选)
   ├── service (可选)
   └── middleware (可选)
       └── mw.js
├── config
|   ├── config.default.js
   ├── config.prod.js
|   ├── config.test.js (可选)
|   ├── config.local.js (可选)
|   └── config.unittest.js (可选)
└── test
    └── middleware
        └── mw.test.js

那区别在哪儿呢?

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

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

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

插件的依赖管理

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

// plugin a
{
  "name": "egg-plugin-a",
  "eggPlugin": {
    "name": "a",
    "dependencies": [ "b" ]
  }
}

// plugin b
{
  "name": "egg-plugin-b",
  "egg-Plugin": {
    "name": "b",
    "optionalDependencies": [ "c" ]
  }
}

// plugin c
{
  "name": "egg-plugin-c",
  "egg-Plugin": {
    "name": "c"
  }
}

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

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

需要特别强调的是:如果采用 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 - 扩展 app 对象
  • app/extend/agent.js - 扩展 agent 对象

插入自定义中间件

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

const staticCache = require('koa-static-cache');
const assert = require('assert');
const mkdirp = require('mkdirp');

module.exports = (options, app) => {
  assert.strictEqual(typeof options.dir, 'string',
    'Must set `app.config.static.dir` when static plugin enable');

  // ensure directory exists
  mkdirp.sync(options.dir);

  app.loggers.coreLogger.info('[egg-static] starting static serve %s -> %s',
    options.prefix, options.dir);

  return staticCache(options);
};
  1. app.js 中将中间件插入到合适的位置(例如:下面将 static 中间件放到 bodyParser 之前)
const assert = require('assert');

module.exports = app => {
  // 将 static 中间件放到 bodyParser 之前
  const index = app.config.coreMiddleware.indexOf('bodyParser');
  assert(index >= 0, 'bodyParser 中间件必须存在');

  app.config.coreMiddleware.splice(index, 0, 'static');
};

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

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

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

    // ${plugin_root}/app.js
    const MyClient = require('my-client');
    
    module.exports = app => {
      app.myClient = new MyClient();
      app.myClient.on('error', err => {
        app.coreLogger.error(err);
      });
      app.beforeStart(function*() {
        yield app.myClient.ready();
        app.coreLogger.info('my client is ready');
      });
    };
    
  • 也可以添加 agent 启动逻辑,使用 agent.beforeStart API

    // ${plugin_root}/agent.js
    const MyClient = require('my-client');
    
    module.exports = agent => {
      agent.myClient = new MyClient();
      agent.myClient.on('error', err => {
        agent.coreLogger.error(err);
      });
      agent.beforeStart(function*() {
        yield agent.myClient.ready();
        agent.coreLogger.info('my client is ready');
      });
    };
    

设置定时任务

  1. package.json 里设置依赖 schedule 插件
{
  "name": "your-plugin",
  "eggPlugin": {
    "name": "your-plugin",
    "dependencies": [ "schedule" ]
  }
}
  1. ${plugin_root}/app/schedule/ 目录下新建文件,编写你的定时任务
exports.schedule = {
  type: 'worker',
  cron: '0 0 3 * * *',
  // interval: '1h',
  // immediate: true,
};

exports.task = function* (ctx) {
  // your logic code
};

插件使用指南

安装

和安装普通 npm 包一样

$ npm i egg-onerror --save

开启和关闭

在应用的 ${app_root}/config/plugin.js 文件里配置

module.exports = {
  onerror: {
    enable: true,
    package: 'egg-onerror',
  },
};

每个配置项有一下配置参数:

  • {Boolean} enable - 是否开启此插件
  • {String} package - npm 模块名称,允许插件以 npm 模块形式引入
  • {String} path - 插件绝对路径,跟 package 配置互斥
  • {Array} env - 只有在指定运行环境才能开启,会覆盖插件自己的配置

这里稍微讲下 package 和 path 的区别

  • package 是 npm 方式引入,也是最常见的引入方式
  • path 是绝对路径引入,一般是内置插件,比如:应用内部抽了一个插件,但还没来得及发布到 npm,或者是应用自己覆盖了框架的一些插件

说明: 框架内部内置了一些插件,而应用在使用这些插件的时候就不用配置 package 或者 path只需要指定 enable 与否

// 对于内置插件,可以用下面的简洁方式开启或关闭
exports.onerror = false;

框架已内置插件列表:

插件的寻址规则

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

  • 如果配置了 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 等关键字,便于索引
    {
      "name": "egg-view-nunjucks",
      "version": "0.5.0",
      "description": "view plugin for egg",
      "eggPlugin": {
        "name": "view",
        "dep": [
          "security"
        ]
      },
      "keywords": [
        "egg",
        "egg-plugin",
        "eggPlugin",
        "egg-plugin-view",
        "nunjucks"
      ],
    }