架构模式 依赖倒置 运行期发现 QPluginLoader ABI 契约
一句话定义:插件架构模式(Plugin Architecture)是指宿主程序(Host)在编译期只依赖一个稳定的抽象接口(契约/SDK),把具体功能实现放进独立的动态库(Plugin)里,由加载器(Loader)在运行期扫描、按元数据识别、实例化并接入宿主——宿主发布之后一行代码不用改、一次不用重编,用户装上一个 .so / .dll 功能就出现,删掉那个文件功能就干净消失。
它不是一个「类级」的 GoF 模式,而是一种架构级组合:依赖倒置原则(DIP)+ 运行期发现 + 元数据契约 + 生命周期隔离。你昨天学的抽象工厂、工厂方法、策略、中介者,在插件架构里全都会以「零件」的形式再次出现;插件架构是它们的装配方式。
本篇定位:Day 24/25/26 解决的是「已有的模块之间怎么分工、怎么说话」。今天要回答一个更激进的问题:模块能不能在编译期之后才存在?如果宿主编译时完全不知道未来会有哪些功能,它靠什么调用一个还不存在的类?答案是把「知道具体类型」这件事推到运行期,用接口 + 元数据 + 加载器三件套换回来。这篇先给 4 个文件、约 90 行纯 C++17 的手写插件骨架(接口 / 注册表 / 两个插件 / 宿主,可真实编译运行并给出输出),再用 Qt6 官方三件套 Q_DECLARE_INTERFACE + Q_PLUGIN_METADATA + QPluginLoader 搭一个完整最小工程(含 CMakeLists.txt),最后讲清插件最难的两件事:ABI 稳定与加载失败隔离。
Web 架构师的类比入口:插件 ≈ Chrome 扩展 / VS Code Extension / Webpack 的 plugin 与 loader / Node 的 require('./x.js') / jQuery 插件。但 Web 生态有两大便利我们这里没有:JS 是动态语言且有 GC,模块可以随便 require,对象不用谁删;C++ 里既没有运行时反射(拿不到「这个类有哪些方法」),也没有 GC(dlclose 之后残留指针就是悬空指针崩溃)。所以我们必须显式定义 ABI 契约:一个纯虚基类 + 一个接口标识字符串(IID)+ 一个稳定的导出符号,并且亲手管理加载/卸载的生命周期——这正是 Qt 的 Q_DECLARE_INTERFACE / Q_PLUGIN_METADATA / QPluginLoader 三件套替你标准化的东西。
先看一段非常典型的「没有插件的桌面客户端」代码。假设我们在写一个图像编辑器,滤镜功能是核心卖点:
// ❌ 无模式的图像编辑器(节选):所有滤镜硬编码在宿主里
#include <QImage>
#include <QString>
enum class FilterType { None, Invert, Blur, Sepia }; // 每加一个滤镜,这里改一次
QImage applyFilter(const QImage& src, FilterType type)
{
switch (type) { // 宿主必须知道每个滤镜的实现细节
case FilterType::Invert: /* 30 行反相代码,散落在宿主里 */ break;
case FilterType::Blur: /* 60 行高斯模糊代码 */ break;
case FilterType::Sepia: /* 25 行怀旧色调代码 */ break;
case FilterType::None: return src;
}
return src;
}
// 菜单构造:字符串列表也要跟着手改
QStringList filterNames() { return { "反相", "模糊", "怀旧" }; }
这段代码在「只有三个滤镜、只有一个团队、只发一个版本」时完全能用,甚至是最省事的选择。但产品一旦长大,它同时踩中五个坑:
enum、switch、filterNames() 三处已测试过的代码。每次扩展都在「修改」而不是「新增」。#include 了每一个滤镜的头文件。.so。还有两个不那么显眼、但在 C++ 桌面开发里非常致命的点:故障隔离与启动成本。硬编码时,任何一个滤镜里的段错误都会拖垮整个进程;而所有滤镜代码都在启动时被加载进内存,功能越多启动越慢。插件架构让「加载」变成一个可以被跳过、被延后、被隔离的独立阶段——加载失败只打印一行警告,宿主照常启动。
判断信号:当你的项目同时满足「功能集合会持续增长」+「不同客户/不同版本功能集合不同」+「希望第三方或非核心团队扩展」这三条时,插件架构是正解。反之,如果你的应用只有固定 5 个功能、只发一个内部版本,那么本节开头那段 switch 就是最优解——插件架构是一种为变化付出的架构成本,没有变化就不该付这个钱。
通俗讲:宿主定「卡口」,插件做「镜头」。机身只需要知道卡口的直径、法兰距和触点协议,不需要知道面前装的是 35mm 定焦还是 70-200 长焦;镜头坏了换一支,机身不动。
四个生活类比,分别对应插件架构的四个关键性质:
把类比翻译成工程语言,插件架构由三个不可省略的要素组成:
*_api.h / SDK 头文件,由宿主团队维护。它只允许追加方法,不允许修改已有方法签名、不允许改虚函数顺序——否则所有已编译的插件立即 ABI 不兼容,必须全部重编。QPluginLoader::metaData() 就是干这个的)。一句话记住它和普通「接口编程」的区别:普通接口编程把依赖推迟到「运行时多态」,插件架构把依赖推迟到「加载时发现」——宿主编译时,插件类根本不存在于它的翻译单元里。
结构上,插件架构是「宿主 + 抽象接口 + N 个运行期实体」的星型关系。注意图中最重要的那条线:宿主编译期只依赖接口,插件也是,两者之间没有任何编译期依赖;唯一的联系是运行期由加载器建立的对象指针。
┌─────────────────────────────────────────────────────────┐
│ 宿主 Host App │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ PluginMgr │─────▶│ PluginRegistry │ │
│ │ (加载器) │ 注册 │ id → 工厂函数 / 实例 │ │
│ └──────┬───────┘ └───────────────┬──────────────┘ │
└─────────┼──────────────────────────────┼────────────────┘
│ QPluginLoader │ 依赖(编译期,仅接口)
│ 运行期 dlopen / 读元数据 ▼
┌───────────────────────┐ ┌────────────────────────────────┐
│ plugin_invert.so │ │ ImageFilterInterface │
│ └ InvertFilterPlugin│───────▶│ + name() / id() │
└───────────────────────┘ 实现 │ + apply(QImage) │
┌───────────────────────┐ (编译期) │ + 虚析构 + IID 元数据 │
│ plugin_tint.so │────────▶└────────────────────────────────┘
│ └ TintFilterPlugin │
└───────────────────────┘
| 角色 | 职责 | 变化频率 | 实现要点 |
|---|---|---|---|
| 抽象接口 / 契约 | 定义「插件能做什么」,是宿主与插件之间唯一的编译期依赖 | 极低(改它就是改 ABI,所有插件重编) | 纯虚方法 + virtual ~;参数只用稳定类型(Qt 类型或 POD/C 类型) |
| 具体插件类 | 实现接口;内部实现可以随便改,只要不改接口 | 高(每个插件独立迭代、独立发布) | Qt:QObject + Q_INTERFACES + Q_PLUGIN_METADATA |
| 元数据 | 插件身份证:IID、名称、版本、依赖;供宿主在不实例化时筛选校验 | 随插件版本变化 | JSON 文件(Q_PLUGIN_METADATA FILE)或 QJsonObject 字面量 |
| 加载器 Loader | 扫描目录、加载动态库、校验接口身份、实例化、注册、隔离失败、卸载 | 低(属于框架基础设施) | QPluginLoader + QLibrary::isLibrary() + qobject_cast |
| 注册表 / 宿主业务 | 按 id 或能力取用插件,只面向接口编程,不认识任何具体类 | 中 | 宿主代码里不出现任何插件头文件,符号层面完全解耦 |
一个反直觉但很重要的推论:接口是「宿主 SDK」的一部分,不是插件的一部分。它必须独立成文件、独立成版本号,因为它有两个使用者(宿主与所有插件)。如果你把接口头文件放在宿主的业务目录里,插件开发者就不得不拉取整个宿主源码树——那一刻插件架构的解耦价值就消失了一半。
下面 4 个文件构成最小可运行骨架:接口、注册表、两个插件、宿主。为了在单个终端里一条命令就能编译运行,我把两个插件放进了同一个可执行文件(真实场景它们是独立的 .so);但请注意代码里那条关键分界线——main.cpp 没有 include 任何插件头文件,它只声明了两个 extern "C" 入口符号,这就是「编译期解耦」在源码层面的样子。
// ============================================================
// filter_api.hpp —— 宿主与插件共享的「契约」(等价于插件的 SDK 头文件)
// ============================================================
#pragma once
#include <memory>
#include <string>
#include <vector>
class IImageFilter {
public:
virtual ~IImageFilter() = default; // 必须:通过基类指针销毁派生对象才安全
[[nodiscard]] virtual std::string name() const = 0; // 显示名(给用户看)
[[nodiscard]] virtual std::string id() const = 0; // 稳定 id(给注册表/配置文件用)
// 处理一批像素:返回新容器,不修改入参 —— 值语义,避免跨模块共享可变状态
[[nodiscard]] virtual std::vector<int> apply(const std::vector<int>& pixels) const = 0;
};
using FilterPtr = std::unique_ptr<IImageFilter>; // 所有权明确:谁创建谁销毁
// ============================================================
// filter_registry.hpp —— 宿主侧注册表:id → 工厂函数
// ============================================================
#pragma once
#include "filter_api.hpp"
#include <algorithm>
#include <functional>
#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
class FilterRegistry {
public:
using Creator = std::function<FilterPtr()>; // 工厂:延迟创建,宿主决定何时实例化
// 注册失败(id 重名)只告警不抛异常:一个坏插件不能阻断宿主启动
bool add(const std::string& id, Creator creator)
{
const auto inserted = creators_.emplace(id, std::move(creator)).second;
if (!inserted) {
std::cerr << "[warn] 插件 id 重复,忽略: " << id << '\n';
}
return inserted;
}
[[nodiscard]] FilterPtr create(const std::string& id) const
{
const auto it = creators_.find(id);
return (it == creators_.end()) ? nullptr : it->second(); // 缺席=返回 nullptr,不是异常
}
[[nodiscard]] std::vector<std::string> ids() const
{
std::vector<std::string> out;
out.reserve(creators_.size());
for (const auto& entry : creators_) {
out.push_back(entry.first);
}
std::sort(out.begin(), out.end()); // 稳定有序输出,便于测试和 UI 展示
return out;
}
[[nodiscard]] std::size_t size() const noexcept { return creators_.size(); }
private:
std::unordered_map<std::string, Creator> creators_;
};
// ============================================================
// invert_filter.cpp —— 插件 1:反相(真实场景中它是独立的 plugin_invert.so)
// 此文件不 include 任何宿主业务头文件,只依赖契约 filter_api.hpp
// ============================================================
#include "filter_api.hpp"
#include "filter_registry.hpp"
namespace {
// 具体类放进匿名命名空间:符号不外泄,杜绝与其他插件重名冲突
class InvertFilter final : public IImageFilter {
public:
[[nodiscard]] std::string name() const override { return "反相"; }
[[nodiscard]] std::string id() const override { return "invert"; }
[[nodiscard]] std::vector<int> apply(const std::vector<int>& pixels) const override
{
std::vector<int> out;
out.reserve(pixels.size()); // 一次分配到位,避免反复扩容
for (const int p : pixels) {
out.push_back(255 - p); // 8bit 通道反相
}
return out; // 返回局部变量,靠 NRVO 零拷贝
}
};
} // namespace
// 插件入口:extern "C" 关闭 C++ 名字修饰,宿主 dlsym 时才能按固定名字找到它
extern "C" void register_invert_plugin(FilterRegistry& registry)
{
registry.add("invert", []() -> FilterPtr { return std::make_unique<InvertFilter>(); });
}
// ============================================================
// tint_filter.cpp —— 插件 2:调色
// ============================================================
#include "filter_api.hpp"
#include "filter_registry.hpp"
namespace {
class TintFilter final : public IImageFilter {
public:
[[nodiscard]] std::string name() const override { return "调色"; }
[[nodiscard]] std::string id() const override { return "tint"; }
[[nodiscard]] std::vector<int> apply(const std::vector<int>& pixels) const override
{
std::vector<int> out;
out.reserve(pixels.size());
for (const int p : pixels) {
out.push_back((p * 3) / 4 + 20); // 简化色调映射:0.75p + 20(整数运算)
}
return out;
}
};
} // namespace
extern "C" void register_tint_plugin(FilterRegistry& registry)
{
registry.add("tint", []() -> FilterPtr { return std::make_unique<TintFilter>(); });
}
// ============================================================
// main.cpp —— 宿主:只认识 IImageFilter 与 FilterRegistry
// 编译:g++ -std=c++17 -O2 -Wall main.cpp invert_filter.cpp tint_filter.cpp -o filter_demo
// ============================================================
#include "filter_api.hpp"
#include "filter_registry.hpp"
#include <iostream>
#include <string>
#include <vector>
// 宿主只「声明」插件入口,不 include 插件实现 —— 这就是编译期解耦。
// 真实场景中这两行等价于 QPluginLoader::instance() 或 dlsym 拿到的函数指针;
// 每个插件是独立 .so,符号自然互不冲突。
extern "C" void register_invert_plugin(FilterRegistry& registry);
extern "C" void register_tint_plugin(FilterRegistry& registry);
int main()
{
FilterRegistry registry;
// ① 「加载」阶段:等价于扫描 plugins/ 目录,把每个 .so 的入口都调一遍
register_invert_plugin(registry);
register_tint_plugin(registry);
std::cout << "已加载插件 (" << registry.size() << "):";
for (const std::string& id : registry.ids()) {
std::cout << ' ' << id;
}
std::cout << '\n';
const std::vector<int> src{0, 100, 200, 255};
// ② 「使用」阶段:业务代码只面向接口编程,不在乎插件是谁写的、什么时候装的
for (const char* const id : {"invert", "tint", "unknown"}) { // 用 const char* 避免临时 string
FilterPtr filter = registry.create(id);
if (!filter) { // 插件缺失是正常分支,不是异常
std::cout << "未找到插件: " << id << '\n';
continue;
}
const std::vector<int> out = filter->apply(src);
std::cout << filter->name() << "(" << id << ") -> ";
for (const int v : out) {
std::cout << v << ' ';
}
std::cout << '\n';
}
return 0; // 四个 unique_ptr 已全部随作用域析构
}
程序真实输出:
已加载插件 (2): invert tint
反相(invert) -> 255 155 55 0
调色(tint) -> 20 95 170 211
未找到插件: unknown
几个刻意的设计选择,值得逐条看懂:
std::function<FilterPtr()>。这带来三个好处——插件加载时零实例化成本;只有被真正使用的插件才占内存;宿主可以按需创建多个独立实例,避免插件内部状态被跨场景共享。unique_ptr 而不是裸指针:所有权通过类型系统表达,宿主 create() 完就不再操心释放;插件内部实现也永远不会出现「谁 delete」的争论。extern "C":这是插件技术的地基。C++ 函数名会被编译器按参数类型修饰(mangling),而宿主在 dlsym / QLibrary::resolve 时只知道一个字符串,因此跨库边界寻找的符号必须是 C 链接的、稳定的名字。const + [[nodiscard]] + 值语义:接口方法不修改自身状态,跨模块调用最安全;返回新容器避免插件与宿主共享可变缓冲。手写 dlopen / extern "C" 能跑,但真正做产品要自己解决:跨平台文件扩展名(.so/.dll/.dylib)、库类型校验、接口身份识别、元数据读取、QObject 父子关系与信号槽、卸载时机。Qt 用三个宏 + 一个类把这一整套标准化了——这也是为什么「Qt 桌面客户端的插件架构」几乎是 QPluginLoader 的同义词。
// filter_plugin_api.h —— 独立成 SDK,宿主与所有插件共同 include
#pragma once
#include <QImage>
#include <QString>
#include <QtPlugin>
class ImageFilterInterface
{
public:
virtual ~ImageFilterInterface() = default;
virtual QString name() const = 0; // 显示名
virtual QString id() const = 0; // 稳定 id,用于配置持久化
virtual QImage apply(const QImage& input) const = 0;
};
// IID:接口的唯一身份标识(惯例是 "域名/版本" 格式)。
// QPluginLoader 加载后会比对插件记录的 IID 与这里是否一致,
// 从而实现「类型安全的跨模块接口查询」—— 这是 C++ 里没有反射时最实用的替代方案。
#define ImageFilterInterface_iid "com.jankerli.demo.ImageFilterInterface/1.0"
Q_DECLARE_INTERFACE(ImageFilterInterface, ImageFilterInterface_iid)
// plugins/invertfilterplugin.h
#pragma once
#include "filter_plugin_api.h"
#include <QObject>
class InvertFilterPlugin final : public QObject, public ImageFilterInterface
{
Q_OBJECT
// Q_PLUGIN_METADATA:插件的「身份证」,编译进库的元数据段
// - IID : 声明实现了哪个接口(与 Q_DECLARE_INTERFACE 的宏名一致)
// - FILE : 附带一个 JSON 文件作为可读元信息(名称/版本/作者/依赖)
Q_PLUGIN_METADATA(IID ImageFilterInterface_iid FILE "invertfilter.json")
Q_INTERFACES(ImageFilterInterface)
public:
explicit InvertFilterPlugin(QObject* parent = nullptr) : QObject(parent) {}
QString name() const override;
QString id() const override;
QImage apply(const QImage& input) const override;
};
// plugins/invertfilterplugin.cpp
#include "invertfilterplugin.h"
QString InvertFilterPlugin::name() const { return QStringLiteral("反相"); }
QString InvertFilterPlugin::id() const { return QStringLiteral("invert"); }
QImage InvertFilterPlugin::apply(const QImage& input) const
{
if (input.isNull()) {
return {}; // 输入非法直接返回空图,不崩
}
QImage out = input.convertToFormat(QImage::Format_ARGB32); // 统一格式再逐像素处理
for (int y = 0; y < out.height(); ++y) {
QRgb* line = reinterpret_cast<QRgb*>(out.scanLine(y)); // 每行只取一次 scanLine(性能关键)
for (int x = 0; x < out.width(); ++x) {
const QRgb c = line[x];
line[x] = qRgb(255 - qRed(c), 255 - qGreen(c), 255 - qBlue(c));
}
}
return out;
}
// plugins/invertfilter.json —— Q_PLUGIN_METADATA 的元数据文件
{
"name": "invert",
"version": "1.0.0",
"author": "jankerli",
"description": "逐像素 255-v 反相滤镜"
}
// src/pluginmanager.h
#pragma once
#include <QList>
#include <QPluginLoader>
#include <QString>
#include <memory>
#include <vector>
class ImageFilterInterface;
class PluginManager
{
public:
// 扫描 dir 下所有动态库并尝试加载,返回成功加载的插件数
int loadAll(const QString& dir);
QList<ImageFilterInterface*> filters() const { return filters_; }
private:
QList<ImageFilterInterface*> filters_; // 借用指针:所有者是 loader_
std::vector<std::unique_ptr<QPluginLoader>> loaders_; // RAII:析构自动 unload
};
// src/pluginmanager.cpp
#include "pluginmanager.h"
#include "filter_plugin_api.h"
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QLibrary>
int PluginManager::loadAll(const QString& dir)
{
int loaded = 0;
const QDir pluginDir(dir);
// QDir::Files 只取文件;QLibrary::isLibrary() 跨平台判断是否为合法库
// (Linux .so / Windows .dll / macOS .dylib,Qt 内部维护这份规则)
const QFileInfoList entries = pluginDir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
for (const QFileInfo& info : entries) {
if (!QLibrary::isLibrary(info.absoluteFilePath())) {
continue; // 不是动态库(可能是 json/说明文档)直接跳过
}
auto loader = std::make_unique<QPluginLoader>(info.absoluteFilePath());
QObject* root = loader->instance(); // 此刻才真正 dlopen + 读元数据
if (!root) {
// 加载失败(缺少依赖、架构不匹配、ABI 不符…):记录并继续,绝不中断宿主启动
qWarning() << "[plugin] 加载失败:" << info.fileName() << loader->errorString();
continue;
}
// qobject_cast 会校验 Q_PLUGIN_METADATA 里的 IID 是否与本接口一致:
// 别人的插件(实现了别的接口)在这里会被安全地拒之门外,而不是踩内存。
auto* filter = qobject_cast<ImageFilterInterface*>(root);
if (!filter) {
qWarning() << "[plugin] 接口不匹配,跳过:" << info.fileName();
loader->unload();
continue;
}
qInfo() << "[plugin] 已加载:" << filter->id() << filter->name() << info.fileName();
filters_.append(filter);
loaders_.push_back(std::move(loader)); // 必须保留 loader:工厂卸载=插件对象全失效
++loaded;
}
return loaded;
}
// src/main.cpp
#include "filter_plugin_api.h"
#include "pluginmanager.h"
#include <QColor>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
int main(int argc, char* argv[])
{
QCoreApplication app(argc, argv);
// 插件目录约定:可执行文件同级 plugins/,也允许命令行覆盖(便于调试与安装器定制)
const QString pluginDir = (argc > 1)
? QString::fromLocal8Bit(argv[1])
: QCoreApplication::applicationDirPath() + QStringLiteral("/plugins");
PluginManager manager;
const int count = manager.loadAll(pluginDir);
qInfo() << "共加载插件:" << count << "个,目录:" << QDir::toNativeSeparators(pluginDir);
// 造一张 2x1 的图当测试输入
QImage src(2, 1, QImage::Format_ARGB32);
src.setPixel(0, 0, qRgb(10, 20, 30));
src.setPixel(1, 0, qRgb(200, 210, 220));
// 宿主业务:遍历所有插件,统统只通过接口调用 —— 一个插件头文件都没 include
for (ImageFilterInterface* filter : manager.filters()) {
const QImage out = filter->apply(src);
qInfo() << filter->name()
<< QColor(out.pixel(0, 0)).name()
<< QColor(out.pixel(1, 0)).name();
}
return 0;
}
cmake_minimum_required(VERSION 3.16)
project(ImageFilterDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) # 让 CMake 自动为 Q_OBJECT / Q_PLUGIN_METADATA 跑 moc
find_package(Qt6 REQUIRED COMPONENTS Core Gui)
# ---------- 宿主程序 ----------
add_executable(imagefilter_host
src/main.cpp
src/pluginmanager.cpp
src/filter_plugin_api.h
)
target_include_directories(imagefilter_host PRIVATE src)
target_link_libraries(imagefilter_host PRIVATE Qt6::Core Qt6::Gui)
# ---------- 插件动态库(MODULE = 只被加载、不被链接的动态库) ----------
add_library(invertfilterplugin MODULE
plugins/invertfilterplugin.cpp
plugins/invertfilterplugin.h
src/filter_plugin_api.h
)
target_include_directories(invertfilterplugin PRIVATE src)
target_link_libraries(invertfilterplugin PRIVATE Qt6::Core Qt6::Gui)
# 插件统一输出到 build/plugins/,与宿主约定的插件目录一致
set_target_properties(invertfilterplugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins"
)
# 更贴近 Qt 生态的写法(可选,等价于上面两段):
# qt_add_plugin(invertfilterplugin CLASS_NAME InvertFilterPlugin
# SOURCES plugins/invertfilterplugin.cpp)
构建与运行:
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/imagefilter_host # 或 ./build/imagefilter_host /path/to/plugins
真实输出(qInfo 打印到 stderr):
[plugin] 已加载: "invert" "反相" "libinvertfilterplugin.so"
共加载插件: 1 个,目录: /path/to/build/plugins
"反相" "#f5ebe1" "#372d23" # 10,20,30 -> 245,235,225 (#f5ebe1);200,210,220 -> 55,45,35 (#372d23)
三个宏各自解决了什么(面试与实战都会问):
① Q_DECLARE_INTERFACE(I, IID):告诉 Qt「这个纯虚类是一个可查询的接口,它的身份是 IID」,底层通过 qobject_interface_iid<I>() 特化承载 IID。
② Q_PLUGIN_METADATA(IID ... FILE ...):把 IID 与元数据 JSON 写进插件的元对象数据,并导出 qt_plugin_query_metadata / qt_plugin_instance 两个 C 链接符号——宿主就是靠它们在不链接插件的前提下取到实例。
③ Q_INTERFACES(I):把接口登记进 moc 生成的元对象,使 qobject_cast<I*> 能对插件对象做接口级转换(校验 IID),而不是只能转到具体类。
最小 C++ 版(4 个文件)的执行时序:
main() 构造 FilterRegistry,此时注册表是空表——宿主对「有哪些滤镜」一无所知。register_invert_plugin(registry) / register_tint_plugin(registry)。这一步在真实插件里等价于遍历 plugins/、dlopen 每个 .so、dlsym 到入口函数。执行的是插件自己的代码,但它只拿到一个注册表引用——它仍然不知道宿主是干什么的。"invert" → lambda 塞进注册表。此刻没有任何滤镜对象被创建,内存里只有几个函数对象。这是插件架构「加载轻、启动快」的关键。registry.create("invert"),注册表的工厂被调用,std::make_unique<InvertFilter>() 构造具体对象并以 unique_ptr<IImageFilter> 返回;随后 filter->apply(src) 通过虚表分发到插件的实现。unknown 返回 nullptr,宿主走「插件缺失」分支继续执行。unique_ptr 逆序销毁插件对象。真实插件里这一步必须先于 dlclose 完成——对象还活着而库已经卸载,就是悬空虚表指针崩溃。Qt 版(QPluginLoader)的执行时序,比上面多出「元数据校验」与「接口身份识别」两步:
applicationDirPath() + "/plugins" 得到插件目录(安装器只需要往这个目录丢文件)。QDir::entryInfoList(QDir::Files) 列出候选文件,QLibrary::isLibrary() 过滤出真正的动态库(跨平台扩展名由 Qt 维护)。QPluginLoader,调用 instance():内部完成 dlopen → 读取元数据段 → 校验 Qt 版本与 IID → 调用 qt_plugin_instance 构造插件 QObject。失败则返回 nullptr 且 errorString() 给出原因,循环 continue 继续下一个——坏插件不影响宿主。qobject_cast<ImageFilterInterface*>(root) 做接口级转换,IID 不匹配就 unload() 并跳过,保证不会把「别人的插件」当自己的用。filters_,同时 unique_ptr<QPluginLoader> 进入 loaders_ 保活。loader 早于插件对象释放 = 插件代码在对象还活着时被卸载,所以必须把 loader 的生命周期顶到最长。filters(),只用 ImageFilterInterface* 调用 apply();PluginManager 析构时先销毁 filters_ 中的引用关系、再逐个销毁 loader,自动完成卸载。把两条时序叠在一起,就能看出插件架构的本质:「编译期写死的调用关系」被拆成了两个可以独立演进、独立失败、独立发布的阶段——「加载/发现」属于框架,写得极其保守;「实现/算法」属于插件,可以随便迭代。
插件架构看起来"只是把类拆到不同的 .so 里",但它实际同时改善了六个工程维度,这也是它值得付出复杂度代价的原因:
在坏代码里,宿主 #include 了每一个滤镜实现,依赖图是「宿主 → 具体类 × N」。在插件架构里,依赖图变成「宿主 → 接口 ← 插件」,宿主与插件之间连锁链接关系都不存在:宿主可执行文件里找不到 InvertFilter 这个符号,插件的 .so 里也不需要链到宿主的任何符号(除了那个接口头文件提供的纯虚定义)。这种「零 include、零链接」的解耦程度,是任何类级模式都达不到的。
新增功能 = 新增一个动态库。宿主代码、宿主构建脚本、宿主已发布的二进制——全都不用改。这正是 OCP 最强形式的落地:"对扩展开放"发生在宿主编译之后,"对修改关闭"精确到了二进制文件。对比坏代码:加一个滤镜要改 enum、switch、字符串表三处已测试代码,属于典型的"以修改换扩展"。
DIP 说「高层模块不应依赖低层模块,二者都应依赖抽象」。插件架构还多做了一步:抽象由高层(宿主)定义并随 SDK 发布,插件的实现必须服从这个由宿主规定的契约。这就是控制反转——插件把"我这块能力长什么样"的决定权交给了宿主框架,而不是相反。回顾 Day 3 抽象工厂的直觉:"谁定义接口,谁掌握主动权"。
宿主扩展功能的方式不是继承(class MyEditorWithBlur : public ImageEditor——那会改宿主类型体系),而是运行期把一批 IImageFilter* 组合进注册表/管道。宿主类型体系封闭不变,功能集合在运行期自由组合。
一个插件加载失败(缺依赖、架构不匹配、接口版本不符)只产生一行警告日志,宿主照常启动可用;而在单体里,这类问题会变成"应用打不开"。虽然插件内部崩溃仍会带走整个进程(除非另开进程做进程外插件),但加载阶段与发现阶段的失败已经被彻底隔离——这是桌面软件交付质量的关键差异。
宿主测试时不需要真实的插件:FilterRegistry registry; registry.add("fake", ...) 塞一个哑实现即可,完全不碰文件系统和动态库加载。插件自己也能被纯单元测试覆盖——它只依赖一个接口头文件,不需要拉起整个应用。同时,契约冻结后,宿主团队与插件团队(甚至第三方的插件作者)可以各按自己的发布节奏并行演进。
一句话概括设计动机:把「谁会扩展我、怎么扩展我」这两件宿主编译期无法预知的事,转译成一份运行期可读的契约(接口 + 元数据),从而让宿主从"一个程序"变成"一个平台"。代价是:编译器的静态检查能力被削弱,一致性保障要由你自己建立的版本校验与诊断体系补回来。
不做插件化,短期内不会有任何可见问题——这是它最危险的地方。真正的成本在功能数量、客户数量、团队人数各自翻倍之后集中爆发:
| 症状 | 具体表现 | 后期代价 |
|---|---|---|
| enum / if-else 持续膨胀 | 每加一个功能就要改 enum、switch、显示名字符串表、可能的权限判断表;一个滤镜的改动出现在 4~6 个文件里 |
合并冲突高频;漏改一处就是线上 bug(比如新滤镜没出现在菜单里) |
| 类与编译单元膨胀 | 所有算法代码住在同一棵源码树、同一个链接目标里,任何头文件改动触发全量重编 | 构建时间从 30 秒涨到 20 分钟;"改一行、等半小时",迭代速度被物理限制 |
| 耦合与复用归零 | 算法代码顺手就调了 QMessageBox、日志单例、配置单例、宿主 UI 类型 |
算法无法独立测试、无法换界面复用、无法给 CLI 版本使用 |
| 发布地狱 | 改一个系数 → 重编 → 重打包 120MB 安装包 → 全量用户重新下载升级 | 热修不可能;客户现场问题只能等下一个大版本 |
| 故障扩散 | 任意一个算法越界/空指针直接终止整个进程;所有功能在启动时全部加载 | 可用性差;启动时间随功能数线性增长,功能越多越"重" |
| 生态为零 | 第三方、客户私有功能、公司内部实验特性只能塞进主干分支 | 定制版退化成 N 个长期维护分支,回归成本随客户数线性增长 |
注意最后一行:插件的真正价值往往不是"代码更整洁",而是"商业模式的可组合性"——基础版 + 专业插件 + 客户定制插件,可以是同一个宿主二进制、同一个安装器、同一套更新通道。
switch 比一整套加载器便宜得多,也更容易静态检查和调试。过度设计提醒:插件架构的本质是用「把编译期错误变成运行期错误」换取「运行期可装配」。编译器从此不再帮你看住跨模块边界,你必须自己补上:① 接口版本号与 IID 校验;② 加载失败的可诊断日志(loader->errorString() 必须落到用户可见的日志/关于对话框里);③ 插件目录的安装与权限约定;④ 卸载路径的测试覆盖。这四件事没规划好之前上插件,用户看到的现象只会是"功能莫名其妙消失了"。
插件架构不是"又一个模式",而是若干模式的架构级装配。面试里最容易被追问的就是它跟抽象工厂、策略、桥接的分界。
| 模式 | 它解决的核心问题 | 与插件架构的分界 |
|---|---|---|
| 工厂方法 / 抽象工厂 | 把「创建哪个具体产品」从使用者代码里搬走,创建一族相关对象 | 它们的调用方编译期就知道工厂接口与产品接口(通常在同一个工程里)。插件架构在它之上加了"运行期发现 + 元数据 + ABI 稳定"三件事;插件内部极其常用抽象工厂(返回接口指针的工厂)。 |
| 策略模式 | 一族算法可互相替换,替换粒度是"对象" | 策略的替换通常仍由宿主在编译期决定可选集合(DI 或工厂给出)。插件架构的替换粒度是"二进制文件",可选集合在宿主编译后仍可变。 |
| 桥接模式 | 让抽象与实现两个维度独立变化,避免类爆炸 | 桥接把两个维度都编译在一起,是"设计内的维度分解";插件是"发布边界外的装配",两个维度可以来自不同团队、不同发布周期。 |
| 服务定位器 / 依赖注入 | 解决「对象怎么拿到依赖」,而不是「依赖从哪来」 | 它们常被用作插件架构的取用手段:注册表就是简化版服务定位器。插件架构回答的是依赖的"来源与装配时机"。 |
| 观察者 / 事件总线(Day 18 / 26) | 模块间松耦合通信 | 两者是天然的搭档:插件加载完成后,第一步往往是向宿主的事件总线注册自己的能力或订阅事件(Day 26 的结尾留的就是这个伏笔)。 |
一句话记住分界:抽象工厂、策略、桥接是"编译期多态"(编译器认识所有类型);插件架构是"编译期未知 + 运行期装配"(编译器一个插件类型都不认识)。
这不是"硬套",而是 Qt 自身最重要的架构手法之一:Qt 框架主体就是一个宿主程序,几乎所有平台相关和格式相关的能力都是插件。
qxcb、qwindows、qcocoa、qoffscreen、qwasm 等。QtGui 核心只依赖 QPlatformIntegration 抽象,具体平台实现由 QPlatformIntegrationFactory 在运行时通过插件路径发现——这正是"宿主 = Qt 库,插件 = 平台实现"的教科书案例。Keys 字段建立"key → 插件"索引,避免每次使用都全目录扫描。Qt 中所有"按名字找插件"的能力都建在它上面:QPlatformThemeFactory、QPlatformInputContextFactory(输入法)、QGenericPluginFactory(鼠标/键盘/触摸等输入设备插件)、QIconEnginePlugin(QIcon 引擎,比如 SVG 图标引擎)、QStyleFactory(QStyleFactory::keys() 里出现的第三方样式就来自插件)、QSqlDriverFactory(QSqlDatabase::drivers() 的来源)、QImageIOPlugin(QImageReader/QImageWriter 支持 JPEG/GIF/SVG 等格式的插件)。QPluginLoader::staticInstances() 让"编译进可执行文件的静态插件"与"运行期加载的动态插件"在使用方看来完全一致。这是一个非常值得学的设计技巧——把"发现方式"抽象掉,使用方代码对链接方式无感,测试环境用静态插件、生产环境用动态插件也不会出现两套代码路径。QObject(拥有信号槽、属性、事件循环亲和性),又可以同时实现任意多个声明过的接口。这意味着插件天然能接入宿主的事件系统:加载完 qobject_cast 拿到接口后,顺手 connect() 到宿主总线即可(Day 26 讲过的跨线程排队也照样生效)。一处需要说清、避免记混的细节:QStyleFactory 内置的 Fusion / Windows 样式是编译进 Qt 的,只有额外样式走插件路径;同理 JPEG 支持在官方二进制发行里常以插件形式存在(imageformats/libqjpeg.so)。也就是说,同一套插件机制,Qt 既用它做"可选能力",也用它做"静态替代品"——判断某个能力是不是插件,看它的名字出现在 plugins/ 子目录里,而不是看它属于哪个模块。
Q1:插件架构和"面向接口编程"到底差在哪?
面向接口编程解决的是「调用方不知道实现类是谁」(类级解耦,靠多态);插件架构额外解决三件事:「调用方编译时不知道存在哪些实现」「实现从哪来(动态库)」「实现什么时候来、什么时候走(生命周期)」。因此它必须补上 ABI 稳定契约、运行期发现(扫描 + 元数据)、加载/卸载生命周期管理——这三样是面向接口编程不需要考虑的。
Q2:为什么插件接口只能追加虚函数,不能改顺序或签名?
C++ 的多态靠虚函数表(vtable)的固定偏移实现:调用点编译时就把"第几个槽位"写死了。修改虚函数顺序、在中途插入或删除虚函数、改变参数类型或返回值类型,都会让已编译插件对象的 vtable 布局与宿主预期错位——轻则调用到错误函数,重则崩溃。所以插件接口必须遵循两条纪律:只追加(追加到末尾)、版本化(Qt 的惯例是 IID 里带版本,如 .../1.0,必要时让 1.0 与 1.1 接口并存,加载时分别匹配)。
Q3:QPluginLoader::instance() 和 QLibrary::resolve() 有什么区别?
QLibrary::resolve() 是机制:按符号名字取一个函数指针,本质是跨平台的 dlsym,协议、版本校验、实例管理都要你自己写(我们第 ⑤ 节的 extern "C" 入口就是这一层的对手戏)。QPluginLoader 是框架:建立在 QLibrary 之上,额外提供元数据读取(metaData())、Qt 版本与 IID 校验、约定的 qt_plugin_instance 实例入口、静态插件统一处理(staticInstances()),以及与元对象系统集成后的接口级安全转换(qobject_cast)。选哪个取决于你要不要 Qt 替你管协议与兼容性。
Q4:插件卸载为什么容易崩溃?正确姿势是什么?
根本原因是对象生命周期与代码生命周期绑错了:插件对象还活着(它的 vtable 指针还指向插件代码段),而库已经被卸载,任何一次虚调用都会跳到已释放的内存。安全做法:① 保证「先析构所有插件对象,再销毁/卸载 loader」——本示例用 filters_(引用)与 loaders_(RAII 所有者)明确这个顺序;② 卸载后立即把接口指针置空,绝不复用;③ 插件内部避免跨模块单例与长期持有的宿主引用;④ 卸载前断开所有跨模块信号连接。实践中最省事的策略是"启动即加载、退出即进程结束",直接不走卸载路径——Qt Creator 这类大型应用也是按需选择,不必为了"卸载干净"把复杂度堆到不可维护。
Q5:插件之间需要互相通信,应该怎么设计?
不要让插件互相 include(那会重新制造网状依赖,并让签名/版本问题指数级放大)。正确做法是让宿主当唯一的中介:插件只向宿主的注册表声明能力、向宿主的总线发布/订阅事件(Day 26),由宿主的业务代码决定编排顺序(比如"滤镜管道"里谁先谁后)。这样插件之间保持"互不相识",宿主是唯一了解全局的协调者——这正是中介者模式(Day 16)在架构层的复用。
需求:复制第 ⑤ 节的最小示例到 /tmp/plugin_priority_demo/,然后完成两件事:
① 新增第三个插件「自动对比度」(id 用 autocontrast,算法自定,比如把像素线性拉伸到 0~255 全区间);
② 让宿主在"应用全部滤镜"时,按插件自己声明的顺序执行——「自动对比度」必须先跑,「反相」必须最后跑,并且要求宿主业务代码里不允许出现任何硬编码的 id 顺序数组。
提示一:顺序信息属于"插件自己的知识",所以不要放在宿主里。给契约接口加一个 virtual int priority() const = 0;(数值小的先执行),让每个插件在自己类里回答"我排第几";宿主只做 std::stable_sort 后依次 apply()。做完后请体会:以后新增插件不需要动宿主一行代码——这就是 OCP 在插件场景下的样子(对比"改宿主里的顺序数组",你就明白为什么顺序该由插件声明)。
提示二:注意 priority() 是改契约的行为——这意味着已编译的旧插件与新宿主 ABI 不兼容。真实项目里这一步应该做成"新接口版本"(IImageFilter2 或 IID 加版本号),并在加载时校验;练习里可以先用一个 qWarning/std::cerr 打印"发现旧版插件,跳过"来模拟这个过程。做完这一小步,你对"接口只增不改"的理解会比读十篇文章更实在。
(练习只给需求与提示,不给完整答案——请自己动手写完并用 g++ -std=c++17 -Wall 编译通过,观察输出顺序是否与 priority 一致。)
Q_DECLARE_INTERFACE(I, "xxx/1.0") + Q_PLUGIN_METADATA(IID ... FILE ...) + Q_INTERFACES(I) 三个宏成组出现,并配一个 *.json 元数据文件。MODULE 类型的库目标或 qt_add_plugin(),且输出目录被固定到宿主可执行文件同级的 plugins/。QLibrary::isLibrary 过滤 → QPluginLoader::instance() → qobject_cast<IXxx*>」这一固定套路,并伴随"失败只记录日志不中断"的容错分支。unique_ptr 管对象、RAII 管 loader,让"先死后卸"的顺序成为编译期就成立的约束,而不是注释里的一句提醒。今天我们让宿主能接入"编译期还不存在的功能"。但插件装进来之后,用户就会开始点它们——于是立刻撞上桌面客户端的第二道必答题:用户点错了,怎么撤销?而且撤销必须能回滚到任意一步、能重做、能感知"文档已被修改"、能在撤销过程中同步多条 UI 状态。明天我们把这个需求拆到架构层:命令模式(Day 14)的"可撤销命令" + 栈式历史 + 事务合并(mergeWith 合并连续拖动),并在 Qt 里用官方三件套 QUndoCommand / QUndoStack / QUndoView 实现一个带撤销栈的编辑器骨架,含「脏标记与保存点」「宏命令一次撤销一组操作」「撤销栈与插件注册表的配合」三个实战点。