CLI子命令擴展-插件機制實現

開發CLI工具過程當中,爲了便於擴展,將CLI的實現分爲基礎功能和擴展功能。基礎功能包括init、build、lint、publish等伴隨工程從初始化到最終發佈到生產環境,也即爲CLI 的core。擴展功能包括規範檢測、代碼生成、圖片上傳等和多個平臺集成的開發配套服務設施。本篇文章將會敘述如何優雅的實現插件機制,經過插件擴展子命令和開放CLI的生態。javascript

CLI初始化流程

運行某個CLI命令時,CLI的初始化加載以下圖:java

第一步,判斷當前用戶信息,獲取用戶的rtx名字。而後讀取cli的配置信息,包括cli的根目錄,插件目錄路徑。以後加載內部核心插件和安裝的外部插件,最後整個初始化過程完成。node

外部插件加載

先讀取cli根目錄(通常設在user目錄下,好比.feflow)下的package.json裏的dependencies和devDependencies內容,過濾掉不是以feflow-plugin開頭的npm包。而後經過module.require的方式加載各個插件。git

...
    init() {
        return this.loadModuleList(ctx).map(function(name) {
            const pluginDir = ctx.plugin_dir;
            const path = require.resolve(pathFn.join(pluginDir, name));

            // Load plugins
            return ctx.loadPlugin(path).then(function() {
                ctx.log.debug('Plugin loaded: %s', chalk.magenta(name));
            }).catch(function(err) {
                ctx.log.error({err: err}, 'Plugin load failed: %s', chalk.magenta(name));
            });
        });
    }

    /**
      * Read external plugins.
      */
    loadModuleList(ctx) {
        const packagePath = pathFn.join(ctx.base_dir, 'package.json');
        const pluginDir = ctx.plugin_dir;

        // Make sure package.json exists
        return fs.exists(packagePath).then(function(exist) {
            if (!exist) return [];

            // Read package.json and find dependencies
            return fs.readFile(packagePath).then(function(content) {
                const json = JSON.parse(content);
                const deps = json.dependencies || json.devDependencies || {};

                return Object.keys(deps);
            });
        }).filter(function(name) {
            // Ignore plugins whose name is not started with "feflow-plugin-"
            if (!/^feflow-plugin-|^@[^/]+\/feflow-plugin-/.test(name)) return false;

            // Make sure the plugin exists
            const path = pathFn.join(pluginDir, name);
            return fs.exists(path);
        });
    }

外部插件執行

外部插件包從本地的plugin目錄讀取以後,接下來就須要執行插件代碼了。那麼插件包裏如何獲取cli的上下文環境呢?github

這裏有一個很是巧妙的設計,須要使用node提供的module和vm模塊,這樣經過cli require的文件,均可以經過feflow變量(注入到插件裏的全局變量)訪問到cli的實例,從而可以訪問cli上的各類屬性,好比config, log和一些helper等。npm

const vm = require('vm');
const Module = require('module');

...
    loadPlugin(path, callback) {
        const self = this;

        return fs.readFile(path).then(function(script) {
            // Based on: https://github.com/joyent/node/blob/v0.10.33/src/node.js#L516
            var module = new Module(path);
            module.filename = path;
            module.paths = Module._nodeModulePaths(path);

            function require(path) {
                return module.require(path);
            }

            require.resolve = function(request) {
                return Module._resolveFilename(request, module);
            };

            require.main = process.mainModule;
            require.extensions = Module._extensions;
            require.cache = Module._cache;

            script = '(function(exports, require, module, __filename, __dirname, feflow){' +
                script + '});';

            var fn = vm.runInThisContext(script, path);

            return fn(module.exports, require, module, path, pathFn.dirname(path), self);
        }).asCallback(callback);
    }

插件的runtime

插件代碼執行過程當中,須要獲取某個命令是否有註冊過,及註冊新的子命令及子命令的處理方法。json

class Plugin {

    constructor() {
        this.store = {};
        this.alias = {};
    }

    get(name) {
        name = name.toLowerCase();
        return this.store[this.alias[name]];
    }

    list() {
        return this.store;
    }

    register(name, desc, options, fn) {
        if (!name) throw new TypeError('name is required');

        if (!fn) {
            if (options) {
                if (typeof options === 'function') {
                    fn = options;

                    if (typeof desc === 'object') { // name, options, fn
                        options = desc;
                        desc = '';
                    } else { // name, desc, fn
                        options = {};
                    }
                } else {
                    throw new TypeError('fn must be a function');
                }
            } else {
                // name, fn
                if (typeof desc === 'function') {
                    fn = desc;
                    options = {};
                    desc = '';
                } else {
                    throw new TypeError('fn must be a function');
                }
            }
        }

        if (fn.length > 1) {
            fn = Promise.promisify(fn);
        } else {
            fn = Promise.method(fn);
        }

        const c = this.store[name.toLowerCase()] = fn;
        c.options = options;
        c.desc = desc;

        this.alias = abbrev(Object.keys(this.store));
    }
}

經過register方法來註冊的命令會將子命令及其處理函數存儲在上下文的store裏面。函數

好比:工具

feflow.plugin.register('upload', function() {
  // Do upload picture here
});

以後就能夠經過運行feflow upload來運行插件擴展的命令了。ui

$ feflow upload

子命令調用

初始化完成後,用戶輸入命令都會從上下文的store來查找是否有註冊過該命令。

function call = function(name, args, callback) {
  if (!callback && typeof args === 'function') {
    callback = args;
    args = {};
  }

  var self = this;

  return new Promise(function(resolve, reject) {
    var c = self.plugin.get(name);

    if (c) {
      c.call(self, args).then(resolve, reject);
    } else {
      reject(new Error('Command `' + name + '` has not been registered yet!'));
    }
  }).asCallback(callback);
};

存在的問題

上述實現方式存在一個問題,每次運行一個命令都須要重現初始化一次。後續考慮編寫一個daemon來守護CLI進程。

相關文章
相關標籤/搜索