从零构建一个插件系统 3. 并发插件系统

今天我们将探讨如何构建一个支持并发执行的插件系统。在上一篇《从零构建一个插件系统 2. 串行插件系统》中,我们实现了一个功能分离且具备完整 TypeScript 类型推导的串行系统。本篇将在此基础上,通过引入并发机制来显著提升插件的整体执行效率。

要实现带有依赖关系的并发,一个核心的前置知识是 拓扑排序 (Topological Sorting) 。若无依赖关系,简单的 Promise.all 即可满足需求。然而,在真实的插件生态中,插件间往往存在复杂的执行顺序依赖,这正是拓扑排序发挥作用的场景。

拓扑排序

拓扑排序 是一种应用于 有向无环图 (Directed Acyclic Graph, DAG) 的算法,它能将图中的所有顶点排成一个线性序列。该序列满足以下条件:对于图中任意一条从顶点 u 指向顶点 v 的边,u 在序列中都出现在 v 之前。

这个定义可以简化为:分析一个任务集合的依赖关系,并给出一个可行的执行顺序。

以一个插件系统为例,假设存在插件 A、B、C、D,其依赖关系如下:

  • 插件 B 依赖插件 A (A → B)
  • 插件 C 依赖插件 B (B → C)
  • 插件 D 无任何依赖

这个关系可以表示为如下的 DAG:

txt 复制代码
A → B → C

D

根据拓扑排序,一个可行的执行顺序是 [A, D, B, C][D, A, B, C]。这意味着 A 和 D 可以并发执行,A 执行完毕后才能执行 B,B 执行完毕后才能执行 C。

算法实现

根据上述的例子,以下是拓扑排序的一种经典实现,基于 Kahn 算法 ,它利用队列和节点的 入度 (In-degree) 来完成排序。

ts 复制代码
// 定义插件的依赖关系
type PluginDependency = {
  [plugin: string]: string[]; // key: 插件名, value: 依赖的插件名列表
};

/**
 * 对插件依赖进行拓扑排序
 * @param dependencies 插件及其依赖项的映射
 * @returns 按执行顺序列出的插件名称数组
 * @throws 如果检测到循环依赖,则抛出错误
 */
function topologicalSort(dependencies: PluginDependency): string[] {
  // 步骤 1: 构建邻接表 (Adjacency List) 和入度表 (In-degree Map)
  const graph = new Map<string, string[]>(); // 存储依赖关系:A -> [B] (B依赖A)
  const inDegree = new Map<string, number>(); // 存储每个插件的依赖数量
  const plugins = Object.keys(dependencies);

  // 初始化数据结构
  for (const plugin of plugins) {
    graph.set(plugin, []);
    inDegree.set(plugin, 0);
  }

  // 填充邻接表和入度表
  for (const [plugin, prerequisites] of Object.entries(dependencies)) {
    for (const prereq of prerequisites) {
      if (graph.has(prereq)) {
        // 'plugin' 依赖 'prereq', 添加一条从 prereq -> plugin 的边
        graph.get(prereq)!.push(plugin);
        // 'plugin' 的入度加一
        inDegree.set(plugin, inDegree.get(plugin)! + 1);
      }
    }
  }

  // 步骤 2: 将所有入度为 0 的节点加入队列
  const queue: string[] = [];
  for (const [plugin, degree] of inDegree.entries()) {
    if (degree === 0) {
      queue.push(plugin);
    }
  }

  // 步骤 3: 处理队列中的节点
  const sortedOrder: string[] = [];
  while (queue.length > 0) {
    const currentPlugin = queue.shift()!;
    sortedOrder.push(currentPlugin);

    // 遍历当前节点的所有邻接点(即依赖当前节点的插件)
    const dependentPlugins = graph.get(currentPlugin) || [];
    for (const dependent of dependentPlugins) {
      // 将其入度减 1
      const newDegree = inDegree.get(dependent)! - 1;
      inDegree.set(dependent, newDegree);
      // 如果入度变为 0,则加入队列
      if (newDegree === 0) {
        queue.push(dependent);
      }
    }
  }

  // 步骤 4: 检查是否存在循环依赖
  if (sortedOrder.length !== plugins.length) {
    const cyclicPlugins = plugins.filter((p) => !sortedOrder.includes(p));
    throw new Error(
      `检测到循环依赖,无法完成排序。相关插件: ${cyclicPlugins.join(", ")}`
    );
  }

  return sortedOrder;
}

// 示例
const pluginDependencies: PluginDependency = {
  a: [],
  b: ["a"],
  c: ["b"],
  d: [],
};
console.log("插件执行顺序:", topologicalSort(pluginDependencies));
// 输出: 插件执行顺序: [ 'a', 'd', 'b', 'c' ] (或 'd', 'a', ...)

下面是上述代码的执行流程模拟:

  1. 构建图:

    • graph: Map { 'a' => ['b'], 'b' => ['c'], 'c' => [], 'd' => [] }
    • inDegree: Map { 'a' => 0, 'b' => 1, 'c' => 1, 'd' => 0 }
  2. 初始化队列: 遍历 inDegree,将入度为 0 的 ad 加入队列。

    • queue: ['a', 'd']
    • sortedOrder: []
  3. 处理队列:

    • 第一次循环: queue.shift() 取出 a
      • sortedOrder 变为 ['a']
      • a 的邻接点 b 的入度从 1 减为 0,b 入队。
      • 当前状态: queue: ['d', 'b'], sortedOrder: ['a']
    • 第二次循环: queue.shift() 取出 d
      • sortedOrder 变为 ['a', 'd']
      • d 没有邻接点。
      • 当前状态: queue: ['b'], sortedOrder: ['a', 'd']
    • 第三次循环: queue.shift() 取出 b
      • sortedOrder 变为 ['a', 'd', 'b']
      • b 的邻接点 c 的入度从 1 减为 0,c 入队。
      • 当前状态: queue: ['c'], sortedOrder: ['a', 'd', 'b']
    • 第四次循环: queue.shift() 取出 c
      • sortedOrder 变为 ['a', 'd', 'b', 'c']
      • c 没有邻接点。
      • 当前状态: queue: [], sortedOrder: ['a', 'd', 'b', 'c']
  4. 结束: queue 为空,循环结束。sortedOrder.length 等于插件总数,排序成功。

整个代码的流程其实就是统计相关的依赖,然后初次执行入度为 0 的插件,之后根据邻接表来继续插入入度为 0 的插件继续执行,重复直到结束。

插件系统架构重构

为了在系统中更好地集成依赖管理,我们首先将插件的定义从函数式转向面向对象。

为何选择 Class

在设计插件系统时,使用函数或类都是可行的选择。然而,对于一个需要处理复杂依赖关系和内部状态的系统,基于 Class 有明显优势:

  • 函数式: 依赖关系通常通过字符串 ID 来声明,如 dependencies: ['plugin-a']。这种方式是脆弱的,容易因拼写错误而失效,且缺乏编译时检查。
  • Class: 依赖关系可以直接通过类引用来声明,如 dependencies: [PluginA]。这种方式是类型安全的,IDE 可以提供自动补全和重构支持,极大地提升了开发体验和代码的健壮性。

综上,虽然函数式插件更轻量,但在构建一个需要处理复杂依赖、配置和状态的并发插件系统时,Class 提供了更结构化、更健壮和更易于扩展的解决方案。

types.ts

关于也进行了调整,不过不阅读也不影响理解文章,但是为了完整性,下面还是贴了一下完整的类型实现。
点击查看:types.ts 定义

ts 复制代码
/**
 * @file types.ts
 * @description 在此文件中集中定义所有核心类型,以便于管理和复用。
 */

// --- 基础数据结构 ---

/**
 * 代表从源(如 GitHub Issues)拉取的基础资源。
 */
export type Issue = {
  id: number;
  title: string; // 新增标题字段,用于专栏处理
  content: string;
};

// --- 生命周期上下文 API ---

/**
 * `load` 生命周期钩子中可用的上下文 API。
 */
export type LoadContext = {
  emitIssue: (issue: Issue) => void;
};

/**
 * `transform` 生命周期中共享的数据上下文。
 */
export type TransformContext = {
  issues: Issue[];
  summary?: string;
  thumbnail?: string;
  // 新增专栏字段,用于存放处理结果
  // 格式为:{ [专栏名]: issueId[] }
  columns?: Record<string, number[]>;
};

/**
 * `generate` 生命周期钩子中可用的上下文 API。
 */
export type GenerateContext = {
  writeFile: (filePath: string, content: string) => Promise<void>;
};

// --- 插件与钩子注册 ---

/**
 * 定义了插件可以注册的各种生命周期回调函数。
 */
export type PluginHooksRegister = {
  onLoad: (callback: (ctx: LoadContext) => Promise<void> | void) => void;
  onTransform: (
    callback: (ctx: TransformContext) => Promise<void> | void
  ) => void;
  onGenerate: (
    callback: (
      ctx: Readonly<TransformContext>,
      api: GenerateContext
    ) => Promise<void> | void
  ) => void;
};

// --- 插件类定义 ---

/**
 * 代表插件类的构造函数类型。
 */
export type PluginClass = new () => Plugin;

/**
 * 所有插件必须继承的抽象基类。
 */
export abstract class Plugin {
  /**
   * 此插件依赖的其他插件类的列表(可选)。
   */
  dependencies?: PluginClass[];

  /**
   * 插件的名称,自动从其类名派生。
   */
  get name(): string {
    return this.constructor.name;
  }

  /**
   * 插件的核心逻辑入口。
   */
  abstract apply(hooks: PluginHooksRegister): void;
}

插件实现

所有插件继承自一个 Plugin 抽象基类,并通过 dependencies 属性声明依赖,在 apply 方法中注册逻辑。

ts 复制代码
export class ExtractSummary extends Plugin {
  // 通过类引用声明依赖
  dependencies: PluginClass[] = [ImageProxy];

  apply(hooks: PluginHooksRegister) {
    hooks.onTransform(async (ctx) => {
      // ... 插件逻辑
    });
  }
}

点击查看:所有插件的实现示例代码

ts 复制代码
import { Plugin, PluginClass, PluginHooksRegister } from "./types";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/** 1. 拉取 Issues */
export class FetchIssues extends Plugin {
  apply(hooks: PluginHooksRegister) {
    hooks.onLoad(async (ctx) => {
      /* ... */
    });
  }
}

/** 2. 处理图片链接 (无依赖) */
export class ImageProxy extends Plugin {
  apply(hooks: PluginHooksRegister) {
    hooks.onTransform(async (ctx) => {
      /* ... */
    });
  }
}

/** 3. 处理专栏 (无依赖) */
export class ProcessColumns extends Plugin {
  apply(hooks: PluginHooksRegister) {
    hooks.onTransform(async (ctx) => {
      /* ... */
    });
  }
}

/** 4. 提取摘要 (依赖图片处理) */
export class ExtractSummary extends Plugin {
  dependencies: PluginClass[] = [ImageProxy];
  apply(hooks: PluginHooksRegister) {
    hooks.onTransform(async (ctx) => {
      /* ... */
    });
  }
}

/** 5. 提取缩略图 (依赖图片处理) */
export class ExtractThumbnail extends Plugin {
  dependencies: PluginClass[] = [ImageProxy];
  apply(hooks: PluginHooksRegister) {
    hooks.onTransform(async (ctx) => {
      /* ... */
    });
  }
}

/** 6. 生成最终的 JSON 输出 */
export class Output extends Plugin {
  constructor(options: { prefix?: string } = {}) {
    super(); /* ... */
  }
  apply(hooks: PluginHooksRegister) {
    hooks.onGenerate(async (ctx) => {
      /* ... */
    });
  }
}

PluginSystem 实现

PluginSystem 类是整个系统的协调器,负责插件的注册、生命周期的管理以及并发调度。下面为 PluginSystem 使用方式。

ts 复制代码
const system = new PluginSystem();

// 注册插件实例
system
  .use(new FetchIssues())
  .use(new ImageProxy())
  .use(new ProcessColumns())
  .use(new ExtractSummary())
  .use(new Output());

// 运行整个系统
await system.run();

core.ts

这里提前贴一下插件系统的实现部分,后续的代码讲解基于这个文件来进行。

ts 复制代码
import fs from "fs/promises";
import path from "path";
import {
  Plugin,
  PluginHooksRegister,
  LoadContext,
  TransformContext,
  GenerateContext,
  Issue,
} from "./types";

type LoadCallback = (ctx: LoadContext) => Promise<void> | void;
type TransformCallback = (ctx: TransformContext) => Promise<void> | void;
type GenerateCallback = (
  ctx: Readonly<TransformContext>,
  api: GenerateContext
) => Promise<void> | void;

type LifecycleMapping = {
  load: { callback: LoadCallback; args: [LoadContext] };
  transform: { callback: TransformCallback; args: [TransformContext] };
  generate: {
    callback: GenerateCallback;
    args: [Readonly<TransformContext>, GenerateContext];
  };
};

export class PluginSystem {
  private plugins: Plugin[] = [];

  private hooks: {
    load: Map<Plugin, LoadCallback>;
    transform: Map<Plugin, TransformCallback>;
    generate: Map<Plugin, GenerateCallback>;
  } = { load: new Map(), transform: new Map(), generate: new Map() };

  use(plugin: Plugin): this {
    this.plugins.push(plugin);
    return this;
  }

  private collectHooks() {
    for (const plugin of this.plugins) {
      const register: PluginHooksRegister = {
        onLoad: (callback) => this.hooks.load.set(plugin, callback),
        onTransform: (callback) => this.hooks.transform.set(plugin, callback),
        onGenerate: (callback) => this.hooks.generate.set(plugin, callback),
      };
      plugin.apply(register);
    }
  }

  async run() {
    this.collectHooks();

    console.log("\n--- [load] 生命周期开始 ---");
    const issues: Issue[] = [];
    const loadContext: LoadContext = {
      emitIssue: (issue) => issues.push(issue),
    };
    await this.runLifecycle("load", loadContext);

    console.log("\n--- [transform] 生命周期开始 ---");
    const transformContext: TransformContext = { issues };
    await this.runLifecycle("transform", transformContext);

    console.log("\n--- [generate] 生命周期开始 ---");
    const generateContext: GenerateContext = {
      writeFile: async (filePath, content) => {
        const dir = path.dirname(filePath);
        await fs.mkdir(dir, { recursive: true });
        await fs.writeFile(filePath, content, "utf-8");
        console.log(`[Generate] 产物已生成: ${filePath}`);
      },
    };
    await this.runLifecycle(
      "generate",
      Object.freeze(transformContext),
      generateContext
    );

    return transformContext;
  }

  private async runLifecycle<K extends keyof LifecycleMapping>(
    lifecycle: K,
    ...args: LifecycleMapping[K]["args"]
  ) {
    const lifecycleHooks = this.hooks[lifecycle];
    const activePlugins = this.plugins.filter((p) => lifecycleHooks.has(p));
    if (activePlugins.length === 0) return;

    const adj = new Map<string, string[]>();
    const inDegree = new Map<string, number>();
    const classToInstances = new Map<string, Plugin[]>();

    for (const plugin of activePlugins) {
      const className = plugin.constructor.name;
      if (!classToInstances.has(className)) {
        classToInstances.set(className, []);
        inDegree.set(className, 0);
        adj.set(className, []);
      }
      classToInstances.get(className)!.push(plugin);
    }

    for (const [className, instances] of classToInstances.entries()) {
      const dependencies = instances[0].dependencies || [];
      for (const depClass of dependencies) {
        const dependencyClassName = depClass.name;
        if (inDegree.has(dependencyClassName)) {
          adj.get(dependencyClassName)!.push(className);
          inDegree.set(className, (inDegree.get(className) || 0) + 1);
        }
      }
    }

    const queue: string[] = [];
    for (const [name, degree] of inDegree.entries()) {
      if (degree === 0) queue.push(name);
    }

    let executedCount = 0;
    while (queue.length > 0) {
      const currentClassNamesToRun = [...queue];
      queue.length = 0;
      executedCount += currentClassNamesToRun.length;

      const promises = currentClassNamesToRun.flatMap((className) =>
        classToInstances.get(className)!.map((instance) => {
          const hook = lifecycleHooks.get(instance)! as Function;
          console.log(`  -> [${lifecycle}] 开始执行: ${instance.name}`);
          // @ts-expect-error
          return Promise.resolve(hook(...args));
        })
      );
      await Promise.all(promises);

      for (const className of currentClassNamesToRun) {
        for (const dependent of adj.get(className) || []) {
          inDegree.set(dependent, inDegree.get(dependent)! - 1);
          if (inDegree.get(dependent) === 0) queue.push(dependent);
        }
      }
    }

    if (executedCount < classToInstances.size) {
      const circular = Array.from(inDegree.entries())
        .filter(([, d]) => d > 0)
        .map(([n]) => n);
      throw new Error(`[${lifecycle}] 检测到循环依赖: ${circular.join(", ")}`);
    }
  }
}

PluginSystem 流程讲解

从使用方式可以看到,我们的插件系统依然只是对外暴露 userun 这两个方法。因为我们也从这两个方法作为入口来进行讲解,关于拓扑排序的核心实现放到下面来重点讲解。

  1. 注册阶段 (use): use(plugin) 方法的职责单一,仅将插件实例存入内部的 this.plugins 数组。
  2. 执行阶段 (run): run() 方法是系统的主入口,按以下顺序执行:
    • collectHooks(): 此私有方法是准备步骤。它遍历所有已注册的插件,调用其 apply 方法。apply 方法会接收到一个 register 对象,通过此对象将插件的回调函数注册到系统对应生命周期的钩子集合中(如 hooks.load, hooks.transform)。
    • 执行 load 生命周期: 创建 loadContext,然后调用 await this.runLifecycle('load', ...)
    • 执行 transform 生命周期: 创建 transformContext,然后调用 await this.runLifecycle('transform', ...)
    • 执行 generate 生命周期: 创建 generateContext,然后调用 await this.runLifecycle('generate', ...)

此流程确保了生命周期之间的串行执行,而每个生命周期内部的并发逻辑则完全委托给 runLifecycle 方法。

并发调度核心 (runLifecycle)

runLifecycle 方法是实现并发调度的关键。它在单个生命周期内,运用拓扑排序的思想,实现了一种 分波次并发 (Wave-based Concurrency) 的执行模式。

transform 生命周期为例,其依赖关系图如下:

txt 复制代码
[ transform 生命周期依赖图 ]

                 +------------------+
               / |  ExtractSummary  | (依赖 ImageProxy)
              /  +------------------+
             /
+----------------+       +--------------------+
|   ImageProxy   | ----> | ExtractThumbnail | (依赖 ImageProxy)
+----------------+       +--------------------+

+------------------+
| ProcessColumns   |  (无依赖)
+------------------+

该方法的执行逻辑如下:

  1. 构建依赖图: 分析当前生命周期内所有活动插件的 dependencies 属性,构建出 入度表 (inDegree) 和 邻接表 (adj)。
  2. 初始化队列: 将所有入度为 0 的插件类名放入执行队列 queue
  3. 循环执行:
    • 执行: Promise.all 并发执行当前队列中的所有插件。
    • 等待: await 等待这一波次所有插件执行完毕。
    • 解锁: 遍历刚刚执行完的插件,将其邻接点(依赖者)的入度减 1。
    • 入队: 如果一个插件的入度在此过程中变为 0,则将其加入队列,准备在下一波次中执行。
  4. 终止: 当队列为空时,循环终止,表示当前生命周期的所有任务已完成。

runLifecycle 模拟执行

让我们以 transform 阶段为例,详细模拟其执行过程:

  • 初始状态:

    • inDegree: Map { 'ImageProxy' => 0, 'ProcessColumns' => 0, 'ExtractSummary' => 1, 'ExtractThumbnail' => 1 }
    • queue: []
  • 步骤 1: 初始化队列 (Wave 1)

    • 遍历 inDegree,将所有入度为 0 的插件类名推入 queue
    • queue 变为 ['ImageProxy', 'ProcessColumns']
  • 步骤 2: 执行第一波 (Wave 1)

    • currentClassNamesToRun = ['ImageProxy', 'ProcessColumns']
    • queue 清空。
    • Promise.all 并发执行 ImageProxyProcessColumns 插件。
    • await 等待它们全部完成。
  • 步骤 3: 解锁下一波

    • 遍历 currentClassNamesToRun
      • 处理 ImageProxy:其依赖者 ExtractSummaryExtractThumbnail 的入度均减 1,变为 0。
      • ExtractSummaryExtractThumbnail 推入 queue
      • 处理 ProcessColumns:无依赖者,无操作。
    • 此时,queue 变为 ['ExtractSummary', 'ExtractThumbnail']
  • 步骤 4: 执行第二波 (Wave 2)

    • currentClassNamesToRun = ['ExtractSummary', 'ExtractThumbnail']
    • queue 清空。
    • Promise.all 并发执行 ExtractSummaryExtractThumbnail 插件。
    • await 等待它们全部完成。
  • 步骤 5: 解锁

    • ExtractSummaryExtractThumbnail 均无依赖者,无操作。queue 保持为空。
  • 步骤 6: 结束

    • queue 为空,while 循环终止。transform 生命周期执行完毕。

最后

通过将 拓扑排序 算法与面向对象的插件设计相结合,我们成功地将一个串行插件系统升级为一个支持并发执行的高性能系统。该系统不仅通过类和依赖引用保证了代码的健壮性和可维护性,还通过"分波次并发"的调度策略,在保证执行顺序正确性的前提下实现了效率的最大化。

在下一篇文章中,我们将进一步探讨如何为该系统引入缓存机制,以优化那些耗时且结果稳定的操作,从而进一步提升构建性能。

相关推荐
大佐不会说日语~3 小时前
Redis高可用架构演进面试笔记
redis·面试·架构
IT小白架构师之路3 小时前
常用设计模式系列(九)—桥接模式
设计模式·桥接模式
德育处主任Pro5 小时前
亚马逊云科技实战架构:构建可扩展、高效率、无服务器应用
科技·架构·serverless
Bar_artist7 小时前
云渲染的算力困局与架构重构:一场正在发生的生产力革命
重构·架构
CHEN5_027 小时前
设计模式——责任链模式
java·设计模式·责任链模式
三桥君7 小时前
AI应用爆发式增长,如何设计一个真正支撑业务的AI系统架构?——解析AI系统架构设计核心要点
人工智能·架构
一休哥助手8 小时前
ChatGPT Agent架构深度解析:OpenAI如何构建统一智能体系统
人工智能·chatgpt·架构
前端拿破轮9 小时前
平衡二叉树的判断——怎么在O(n)的时间复杂度内实现?
前端·算法·设计模式
牛奶咖啡139 小时前
学习设计模式《十九》——享元模式
学习·设计模式·享元模式·认识享元模式·享元模式的优缺点·何时选用享元模式·享元模式的使用示例
讨厌吃蛋黄酥9 小时前
🌟 弹窗单例模式:防止手抖党毁灭用户体验的终极方案!
前端·javascript·设计模式