高级主题:插件依赖图与拓扑排序加载

插件多了,问题就来了。

我记得刚接手一个中型项目时,插件数量从十几个膨胀到了五十多个。启动时随机崩溃,有时候A插件先加载就报错,有时候B插件先加载又正常。排查了一整天,最后发现是插件之间有依赖关系——说白了,就是有些插件得等别的插件准备好了才能干活。

嗯,这就是我们今天要聊的:插件依赖图与拓扑排序加载

依赖问题的本质

插件之间为什么会有依赖?我遇到过几种典型场景:

  • 服务依赖:日志插件必须先加载,其他插件才能写日志
  • 数据依赖:配置管理插件要先启动,业务插件才能读取配置
  • 接口依赖:插件A提供了回调函数,插件B要注册进去

你想想看,如果B依赖A,但系统先加载了B,B一初始化就去调用A的函数——这时候A还没加载呢,不崩才怪。

核心思路:把插件之间的依赖关系建模成有向图,然后用拓扑排序确定加载顺序。

依赖图的数据结构

我习惯用邻接表来表示依赖图。每个插件是一个节点,依赖关系是一条有向边。

// 插件依赖图节点
typedef struct PluginNode {
    char name[64];           // 插件名
    int id;                  // 节点编号
    int in_degree;           // 入度(有多少插件依赖它)
    struct PluginNode **deps; // 依赖列表(指向它的前驱)
    int dep_count;
} PluginNode;

// 依赖图
typedef struct DepGraph {
    PluginNode **nodes;
    int node_count;
    int capacity;
} DepGraph;

举个例子,假设我们有5个插件:

插件依赖说明
core核心服务,谁都不依赖
logcore日志插件,依赖core
configcore配置插件,依赖core
networklog, config网络插件,依赖日志和配置
appnetwork业务插件,依赖网络

这个依赖图画出来就是:

core log config network app

拓扑排序算法

拓扑排序说白了就是:找出一个顺序,让所有依赖都满足。我常用的是Kahn算法,基于入度来实现。

// Kahn算法实现拓扑排序
int topological_sort(DepGraph *graph, int *result) {
    int queue[MAX_PLUGINS];
    int front = 0, rear = 0;
    int count = 0;

    // 1. 把所有入度为0的节点入队
    for (int i = 0; i < graph->node_count; i++) {
        if (graph->nodes[i]->in_degree == 0) {
            queue[rear++] = i;
        }
    }

    // 2. 循环处理队列
    while (front < rear) {
        int idx = queue[front++];
        result[count++] = idx;

        PluginNode *node = graph->nodes[idx];
        // 遍历所有依赖这个节点的插件
        for (int j = 0; j < node->dep_count; j++) {
            PluginNode *dep = node->deps[j];
            dep->in_degree--;
            if (dep->in_degree == 0) {
                queue[rear++] = dep->id;
            }
        }
    }

    // 3. 检查是否有环
    if (count != graph->node_count) {
        return -1; // 存在循环依赖
    }
    return 0;
}

算法流程其实很简单:

  1. 找到所有不依赖任何人的插件(入度为0),先入队
  2. 从队列取出一个插件,把它加入加载顺序
  3. 这个插件加载完了,那些依赖它的插件就少了一个依赖——入度减1
  4. 如果某个插件的入度变成0了,说明它所有的依赖都加载完了,可以入队了
  5. 重复直到队列为空

小技巧:如果最后发现还有插件没排进去,说明存在循环依赖。我曾经在一个项目里遇到A依赖B、B依赖C、C又依赖A的情况,拓扑排序直接报错。排查了半天才发现是设计问题,最后重构了接口才解决。

循环依赖检测

循环依赖是插件架构的噩梦。我见过最离谱的一次,三个插件互相依赖,谁先加载都报错,谁后加载也报错——死锁了。

检测循环依赖其实很简单:拓扑排序返回-1就说明有环。但更优雅的做法是在注册依赖时就做检测。

// 注册依赖时检测是否有环
int register_dependency(DepGraph *graph, const char *from, const char *to) {
    // from 依赖 to
    int from_id = find_node(graph, from);
    int to_id = find_node(graph, to);
    if (from_id == -1 || to_id == -1) return -1;

    // 先添加依赖
    add_dependency(graph, from_id, to_id);

    // 然后做拓扑排序,检查是否有环
    int result[MAX_PLUGINS];
    if (topological_sort(graph, result) != 0) {
        // 有环!回滚刚才添加的依赖
        remove_dependency(graph, from_id, to_id);
        return -1; // 注册失败
    }
    return 0;
}

警告:千万不要在运行时才发现循环依赖。我曾经见过一个项目,插件加载到第8个时突然崩溃,就是因为循环依赖导致死锁。最好在插件注册阶段就完成所有依赖检查。

实际加载流程

有了拓扑排序的结果,加载就变得很直接了:

int load_plugins_in_order(PluginManager *pm, DepGraph *graph) {
    int order[MAX_PLUGINS];
    if (topological_sort(graph, order) != 0) {
        fprintf(stderr, "存在循环依赖,无法加载\n");
        return -1;
    }

    for (int i = 0; i < graph->node_count; i++) {
        int idx = order[i];
        PluginNode *node = graph->nodes[idx];
        printf("加载插件: %s\n", node->name);

        // 加载插件(dlopen等操作)
        if (load_single_plugin(pm, node->name) != 0) {
            fprintf(stderr, "插件 %s 加载失败\n", node->name);
            // 这里要考虑回滚策略
            return -1;
        }
    }
    return 0;
}

按照前面的例子,拓扑排序的结果应该是:core → log → config → network → app。这个顺序保证了每个插件加载时,它依赖的插件都已经就绪。

依赖图的动态更新

实际项目中,插件可能热加载热卸载。这时候依赖图需要动态更新。

我遇到过的情况是:一个插件运行到一半,用户说要卸载它。但其他插件还在用它的接口。怎么办?

我的做法是:

  • 卸载前:检查是否有其他插件依赖它。如果有,拒绝卸载或先卸载依赖它的插件
  • 加载后:重新计算拓扑排序,确保新插件不会破坏现有顺序
int unload_plugin(PluginManager *pm, DepGraph *graph, const char *name) {
    int id = find_node(graph, name);
    if (id == -1) return -1;

    // 检查是否有插件依赖它
    PluginNode *node = graph->nodes[id];
    if (node->dep_count > 0) {
        fprintf(stderr, "有 %d 个插件依赖 %s,无法卸载\n",
                node->dep_count, name);
        return -1;
    }

    // 安全卸载
    unload_single_plugin(pm, name);
    remove_node(graph, id);
    return 0;
}

经验之谈:动态更新依赖图时,一定要加读写锁。我早期没注意这个问题,结果在卸载插件的同时,另一个线程正在加载新插件,两个操作同时修改依赖图,直接导致内存损坏。嗯,那次debug花了我整整两天。

性能考量

拓扑排序的时间复杂度是O(V+E),V是插件数,E是依赖关系数。对于几百个插件的规模,完全不用担心性能。

但如果你有上千个插件,或者依赖关系极其复杂,可以考虑:

  • 增量排序:只重新计算受影响的部分,而不是全量排序
  • 缓存排序结果:如果依赖图没变,直接用上次的结果
  • 分层加载:把插件分成几个层级,同层之间没有依赖,可以并行加载

我个人习惯是:在开发阶段每次启动都做全量排序,方便发现问题。到了生产环境,如果依赖图稳定了,就缓存排序结果,启动速度能快不少。

好了,关于插件依赖图和拓扑排序,核心就是这些。说白了就是:把依赖关系画成图,用算法找出正确的加载顺序。这个思路不仅适用于C语言的插件系统,在其他语言里也是一样的道理。

最后提醒一句:设计插件架构时,尽量让依赖关系单向且简单。依赖越少,系统越稳定。我见过最优雅的设计,所有插件只依赖一个核心接口层,彼此之间完全不依赖——那才是真正的松耦合。


公众号:蓝海资料掘金营,微信deep3321