Repository: artusjs/core
Pull request #20 (2022-04-08 11:53:03)

feat(plugin): 插件基类实现与依赖链排序逻辑
related #5 

noahziheng: 插件加载部分涉及 Manifest 改动，需要再思考一波
cc @JerrysShan 

Patch:
diff --git a/src/index.ts b/src/index.ts
index fd6e1b2..28af0dc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,7 @@ export {
 export * from './loader';
 export * from './lifecycle';
 export * from './exception';
+export * from './plugin';
 export * from './application';
 
 import Trigger from './trigger';
diff --git a/src/plugin/base.ts b/src/plugin/base.ts
new file mode 100644
index 0000000..d5bdc45
--- /dev/null
+++ b/src/plugin/base.ts
@@ -0,0 +1,36 @@
+import { Plugin, PluginConfigItem, PluginMetadata } from "./types";
+
+export class BasePlugin implements Plugin {
+  public name: string;
+  public enable: boolean;
+  public importPath: string;
+  public metadata: Partial<PluginMetadata> = {};
+
+  constructor(name: string, configItem: PluginConfigItem) {
+    this.name = name;
+    const importPath = configItem.path ?? configItem.package;
+    if (!importPath) {
+      throw new Error(`Plugin ${name} need have path or package field`);
+    }
+    this.importPath = importPath;
+    this.enable = configItem.enable ?? false;
+  }
+
+  async init() {}
+
+  checkDepExisted(map: Map<string, BasePlugin>): void {
+    const depPluginNames = [
+      ...(this.metadata.dependencies ?? []),
+      ...(this.metadata.optionalDependencies ?? []),
+    ];
+    for (const pluginName of depPluginNames) {
+      if (!map.has(pluginName)) {
+        throw new Error(`Plugin ${this.name} need have plugin ${pluginName} dependencies.`);
+      }
+    }
+  }
+
+  getDepEdgeList(): [string, string][] {
+    return this.metadata.dependencies?.map((depPluginName) => [this.name, depPluginName]) ?? [];
+  }
+}
diff --git a/src/plugin/common.ts b/src/plugin/common.ts
new file mode 100644
index 0000000..fe3236f
--- /dev/null
+++ b/src/plugin/common.ts
@@ -0,0 +1,38 @@
+import { Plugin } from './types';
+
+// A utils function that toplogical sort plugins
+export function topologicalSort(pluginInstanceMap: Map<string, Plugin>, pluginDepEdgeList: [string, string][]): string[] {
+  const res: string[] = [];
+  const indegree: Map<string, number> = new Map();
+
+  pluginDepEdgeList.forEach(([to]) => {
+    indegree.set(to, (indegree.get(to) ?? 0) + 1);
+  });
+
+  const queue: string[] = [];
+
+  for (const [name] of pluginInstanceMap) {
+    if (!indegree.has(name)) {
+      queue.push(name);
+    }
+  }
+
+  while(queue.length) {
+    const cur = queue.shift()!;
+    res.push(cur);
+    for (const [to, from] of pluginDepEdgeList) {
+      if (from === cur) {
+        indegree.set(to, (indegree.get(to) ?? 0) - 1);
+        if (indegree.get(to) === 0) {
+          queue.push(to);
+        }
+      }
+    }
+  }
+
+  if (res.length !== pluginInstanceMap.size) {
+    const diffPlugin = [...pluginInstanceMap.keys()].filter((name) => !res.includes(name));
+    throw new Error(`There is a cycle in the dependencies, wrong plugin is ${diffPlugin.join(',')}.`);
+  }
+  return res;
+}
diff --git a/src/plugin/factory.ts b/src/plugin/factory.ts
new file mode 100644
index 0000000..c1240ce
--- /dev/null
+++ b/src/plugin/factory.ts
@@ -0,0 +1,28 @@
+import { BasePlugin } from './base';
+import { topologicalSort } from './common';
+import { ArtusPlugin } from './impl';
+import { PluginConfigItem } from './types';
+
+export class PluginFactory {
+  static async create(name: string, item: PluginConfigItem): Promise<BasePlugin> {
+    const pluginInstance = new ArtusPlugin(name, item);
+    await pluginInstance.init();
+    return pluginInstance;
+  }
+
+  static async createFromConfig(config: Record<string, PluginConfigItem>): Promise<BasePlugin[]> {
+    const pluginInstanceMap: Map<string, BasePlugin> = new Map();
+    for (const [name, item] of Object.entries(config)) {
+      const pluginInstance = await PluginFactory.create(name, item);
+      pluginInstanceMap.set(name, pluginInstance);
+    }
+    let pluginDepEdgeList: [string, string][] = [];
+    // Topological sort plugins
+    for (const [_name, pluginInstance] of pluginInstanceMap) {
+      pluginInstance.checkDepExisted(pluginInstanceMap);
+      pluginDepEdgeList = pluginDepEdgeList.concat(pluginInstance.getDepEdgeList());
+    }
+    const pluginSortResult: string[] = topologicalSort(pluginInstanceMap, pluginDepEdgeList);
+    return pluginSortResult.map((name) => pluginInstanceMap.get(name)!);
+  }
+}
diff --git a/src/plugin/impl.ts b/src/plugin/impl.ts
new file mode 100644
index 0000000..3d53621
--- /dev/null
+++ b/src/plugin/impl.ts
@@ -0,0 +1,22 @@
+import { BasePlugin } from './base';
+
+export class ArtusPlugin extends BasePlugin {
+  async init() {
+    if (!this.enable) {
+      return;
+    }
+    let pkgJson: Record<string, any>;
+    try {
+      pkgJson = await import(this.importPath + '/package.json');
+    } catch (error) {
+      throw new Error(`${this.name} is not have a package.json file`);
+    }
+    if (!pkgJson?.artusjsPlugin) {
+      throw new Error(`${this.name} is not an Artus plugin`);
+    }
+    this.metadata = pkgJson.artusjsPlugin;
+    if (this.metadata.name !== this.name) {
+      throw new Error(`${this.name} metadata invalid, name is ${this.metadata.name}`);
+    }
+  }
+}
diff --git a/src/plugin/index.ts b/src/plugin/index.ts
new file mode 100644
index 0000000..a2aec1e
--- /dev/null
+++ b/src/plugin/index.ts
@@ -0,0 +1,5 @@
+import {} from './types';
+
+export * from './base';
+export * from './impl';
+export * from './factory';
diff --git a/src/plugin/types.ts b/src/plugin/types.ts
new file mode 100644
index 0000000..28172e8
--- /dev/null
+++ b/src/plugin/types.ts
@@ -0,0 +1,28 @@
+export const enum PluginType {
+  simple = 'simple',
+  module = 'module',
+}
+
+export interface PluginMetadata {
+  name: string;
+  dependencies?: string[];
+  optionalDependencies?: string[];
+  type?: PluginType;
+}
+
+export interface PluginConfigItem {
+  enable: boolean;
+  path?: string;
+  package?: string;
+}
+
+export interface Plugin {
+  name: string;
+  enable: boolean;
+  importPath: string;
+  metadata: Partial<PluginMetadata>;
+
+  init(): Promise<void>;
+  checkDepExisted(map: Map<string, Plugin>): void;
+  getDepEdgeList(): [string, string][];
+}


Test patch:
diff --git a/test/fixtures/plugin-a/package.json b/test/fixtures/plugin-a/package.json
new file mode 100644
index 0000000..012c236
--- /dev/null
+++ b/test/fixtures/plugin-a/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "@artus/test-plugin-a",
+  "artusjsPlugin": {
+    "name": "plugin-a",
+    "dependencies": [ "plugin-b" ],
+    "optionalDependencies": [ "plugin-c" ]
+  }
+}
\ No newline at end of file
diff --git a/test/fixtures/plugin-b/package.json b/test/fixtures/plugin-b/package.json
new file mode 100644
index 0000000..4d2940d
--- /dev/null
+++ b/test/fixtures/plugin-b/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "@artus/test-plugin-b",
+  "artusjsPlugin": {
+    "name": "plugin-b",
+    "dependencies": [ "plugin-c" ]
+  }
+}
\ No newline at end of file
diff --git a/test/fixtures/plugin-c/package.json b/test/fixtures/plugin-c/package.json
new file mode 100644
index 0000000..eef8fd4
--- /dev/null
+++ b/test/fixtures/plugin-c/package.json
@@ -0,0 +1,6 @@
+{
+  "name": "@artus/test-plugin-c",
+  "artusjsPlugin": {
+    "name": "plugin-c"
+  }
+}
\ No newline at end of file
diff --git a/test/fixtures/plugin-wrong-a/package.json b/test/fixtures/plugin-wrong-a/package.json
new file mode 100644
index 0000000..6fe0357
--- /dev/null
+++ b/test/fixtures/plugin-wrong-a/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "@artus/test-plugin-wrong-a",
+  "artusjsPlugin": {
+    "name": "plugin-wrong-a",
+    "dependencies": [ "plugin-wrong-b" ]
+  }
+}
\ No newline at end of file
diff --git a/test/fixtures/plugin-wrong-b/package.json b/test/fixtures/plugin-wrong-b/package.json
new file mode 100644
index 0000000..e76b335
--- /dev/null
+++ b/test/fixtures/plugin-wrong-b/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "@artus/test-plugin-wrong-b",
+  "artusjsPlugin": {
+    "name": "plugin-wrong-b",
+    "dependencies": [ "plugin-wrong-a" ]
+  }
+}
\ No newline at end of file
diff --git a/test/plugin.test.ts b/test/plugin.test.ts
new file mode 100644
index 0000000..b6292c4
--- /dev/null
+++ b/test/plugin.test.ts
@@ -0,0 +1,59 @@
+import 'reflect-metadata';
+import path from 'path';
+import { ArtusPlugin, PluginFactory } from '../src';
+
+describe('test/app.test.ts', () => {
+  describe('app with config', () => {
+    it('should load plugin with dep order', async () => {
+      const mockPluginConfig = {
+        'plugin-a': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-a'),
+        },
+        'plugin-b': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-b'),
+        },
+        'plugin-c': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-c'),
+        }
+      }
+      const pluginList = await PluginFactory.createFromConfig(mockPluginConfig);
+      expect(pluginList.length).toEqual(3);
+      pluginList.forEach(plugin => {
+        expect(plugin).toBeInstanceOf(ArtusPlugin);
+        expect(plugin.enable).toBeTruthy();
+      });
+      expect(pluginList.map((plugin) => plugin.name)).toStrictEqual(['plugin-c', 'plugin-b', 'plugin-a']);
+    });
+  
+    it('should not load plugin with wrong order', async () => {
+      const mockPluginConfig = {
+        'plugin-a': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-a'),
+        },
+        'plugin-b': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-b'),
+        },
+        'plugin-c': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-c'),
+        },
+        'plugin-wrong-a': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-wrong-a'),
+        },
+        'plugin-wrong-b': {
+          enable: true,
+          path: path.resolve(__dirname, './fixtures/plugin-wrong-b'),
+        }
+      }
+      expect(async () => {
+        await PluginFactory.createFromConfig(mockPluginConfig)
+      }).rejects.toThrowError(new Error(`There is a cycle in the dependencies, wrong plugin is plugin-wrong-a,plugin-wrong-b.`));
+    });
+  });
+});