过度设计的典型

一次调用穿了 5 层间接——记一段插件代码的「考古式」追踪

我最近在维护一套 C++ 插件框架,追一个看似简单的「授权变更后服务有没有起来」的问题。
入口只有一行函数调用,但要看清它最终到底做了什么,得在 4 个工程、6 个文件之间来回跳。
这篇文章把这段经历抽象出来,谈谈这类「插件 / 跨 DLL / 动态加载」的代码为什么这么难懂,
以及如果我从零设计应该怎么避免。

0. 起因

业务需求很朴素:用户的某项授权打开后,对应的后台服务应当自动安装并启动

打开代码,入口长这样:

1
2
3
4
5
6
7
8
9
void ModuleHandler::StartModule(Context& ctx)
{
// ...一些前置检查...

DI_LOG_INFO("notify module on authorization changed...");
PluginManager::Instance()->NotifyAuthChanged(true); // ← 就这一行

// ...
}

日志写的是 "install and start it""then start it"。我合理推测 NotifyAuthChanged(true) 里就是安装 + 启动服务的逻辑。

跳进去一看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool PluginManager::NotifyAuthChanged(bool isAuthorized)
{
std::lock_guard<std::mutex> lock(m_mtx);
for (const auto & item : m_plugins_data_map)
{
auto cb_it = item.second->m_ntp_callback.find(FUNC_NTP_AUTH_CHANGED);
if (cb_it != item.second->m_ntp_callback.end())
{
auto fn = cb_it->second;
if (fn) fn((void*)(isAuthorized ? 1 : 0)); // ← ???
}
}
return true;
}

fn 是个从 map 里取出来的裸函数指针。它指向哪儿?编译期完全看不出来。

这只是 5 跳里的第一跳

1. 第一跳:函数指针 + map 的反向回调

fn 的值是运行时塞进去的。要追下去,不能搜「这里调了谁」,要搜「谁往这张 map 里写入了 FUNC_NTP_AUTH_CHANGED

grep m_ntp_callback[ 只有一处赋值:

1
2
3
4
5
6
7
static void SET_NTP_FUNC_CB(void* handle, int func_index, PFuncNtpCall ntp_cb)
{
auto& map = PluginManager::Instance()->m_plugins_data_map;
auto it = map.find((int)handle);
if (it != map.end())
it->second->m_ntp_callback[func_index] = ntp_cb;
}

这是个主程序里的注册函数。它本身不会被主程序内部调用——它是打包到 callback 表里、传给插件 DLL,让插件 DLL 反过来调用它

1
2
3
4
5
// PluginManager 构造时
m_plugin_callbacks->SET_NTP_FUNC_CB = SET_NTP_FUNC_CB;
// ...
// load_plugin 加载插件 DLL 后
m_pCreate_Plugin(&plugin_data->callbacks); // 把整张表传给插件

插件 DLL 那一侧,导出的 CreatePlugin 收到这张表,回过头来调 SET_NTP_FUNC_CB 把自己的回调登记进去:

1
2
3
4
5
6
7
8
// 在插件 DLL 里
int PluginIntegrateMgr::install(const void* cb_funcs)
{
m_pCallFuncs = (PCALLBACK_FOR_PLUGIN)cb_funcs;
// 反向注册回调
m_pCallFuncs->SET_NTP_FUNC_CB(m_handle, FUNC_NTP_AUTH_CHANGED, AuthChanged);
// ...
}

这就是经典的反向回调注册

  • 主程序导出「注册器」函数
  • 把它打包进 callback 表,传给插件 DLL
  • 插件在初始化时回头调用注册器,把自己的处理函数挂上去

控制流方向是反的,IDE 的 Go-to-Definition 在这一步就废了。

终于追到了具体的回调实现 PluginIntegrateMgr::AuthChanged。点进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int PluginIntegrateMgr::AuthChanged(void* pAuthorized)
{
const bool isAuth = (0 != (int)pAuthorized);
if (isAuth)
{
if (BusinessHandler::Instance()->Init(plugin_dir, device_id))
{
BusinessHandler::Instance()->AddProcessToProtected(...);
}
}
else
{
BusinessHandler::Instance()->UnInit();
}
return 0;
}

Init 里又调了 atomic_wrapper_helper::InitPlugin(...)

2. 第二跳:又一个函数指针,这次是动态加载的 DLL

1
2
3
4
5
6
int atomic_wrapper_helper::InitPlugin(void* init_param)
{
if (m_pInitPlugin)
return m_pInitPlugin(init_param); // ← 又一个函数指针!
return -1;
}

m_pInitPlugin 在哪里赋值?

1
2
m_hDll = LoadLibrary(sWrapperDllPath.c_str());
m_pInitPlugin = (PFunc_InitPlugin)GetProcAddress(m_hDll, TO_STR(Wrap_InitPlugin));

是从一个运行时加载的 DLL 里拿到的导出符号 Wrap_InitPlugin。要追下去:

  • 得知道 m_hDll 加载的是哪个 DLL(看调用方拼装的路径——VendorWrapper.dll);
  • 然后去那个 DLL 的源码里找 Wrap_InitPlugin 的定义。

打开 VendorWrapper.cpp,搜到了:

1
2
3
4
5
extern "C" int WRAPPER_API Wrap_InitPlugin(void* init_param)
{
VendorInterfaceMgr::CreateInstance();
return VendorInterfaceMgr::GetInstance()->InitPlugin(init_param);
}

跟着进 VendorInterfaceMgr::InitPlugin,里面才是真正的安装/启动服务的代码。

3. 还有最阴的一刀:宏改名

如果我在 VendorWrapper.dll 的导出表(dumpbin / DLL Export Viewer)里查,找不到 Wrap_InitPlugin 这个符号——只能看到 InitPlugin

为什么?因为公共头文件里有:

1
2
3
4
5
6
7
8
9
// wrapper_common.h
#define TO_STR_(x) #x
#define TO_STR(x) TO_STR_(x)

// 「Function name to export」
#define Wrap_InitPlugin InitPlugin
#define Wrap_UninitPlugin UninitPlugin
#define Wrap_MessageNotify MessageNotify
// ...

预处理之后:

  • 源码 int Wrap_InitPlugin(...) → 实际编译的是 int InitPlugin(...)
  • 源码 GetProcAddress(m_hDll, TO_STR(Wrap_InitPlugin)) → 展开是 GetProcAddress(m_hDll, "InitPlugin")

所以 grep 源码 "InitPlugin"(带引号)永远找不到,grep InitPlugin(不带引号)则会命中一堆同名 C++ 成员函数:atomic_wrapper_helper::InitPluginVendorWrapper.cpp::InitPlugin(宏展开后)、VendorInterfaceMgr::InitPlugin……名字一样,但分别是薄壳、C 导出薄壳、真正实现

4. 完整链路

把两段合起来:

1
2
3
4
5
6
7
8
9
10
11
12
ModuleHandler::StartModule
└── PluginManager::NotifyAuthChanged(true)
└── fn(...) ← 间接 1: map 里的函数指针
↓ 反向回调注册 ← 间接 2: 控制流方向反了
└── PluginIntegrateMgr::AuthChanged (在插件 DLL 里)
└── BusinessHandler::Init
└── atomic_wrapper_helper::InitPlugin
└── m_pInitPlugin(...) ← 间接 3: 函数指针
↓ GetProcAddress ← 间接 4: 动态加载 DLL
↓ 宏改名 ← 间接 5: 符号名和源码名不一致
└── Wrap_InitPlugin (导出名实际叫 InitPlugin)
└── VendorInterfaceMgr::InitPlugin ★ 真正实现

一次业务调用穿了 5 层「编译期看不到目标」的边界。

5. 为什么这么难懂——5 类间接的叠加

间接类型 长什么样 静态搜索能否定位
虚函数 / pImpl 转发 m_pImpl->Foo() ✅ 直接跳定义
运行时函数指针 + map fn = map[key]; fn(...) ❌ 必须先找 map 的写入点
动态加载 DLL GetProcAddress(hDll, "Foo") ❌ 还得知道 hDll 是哪个 DLL
宏改名导出符号 #define Wrap_Foo Foo ❌ 源码符号和导出符号错位
反向回调注册 主程序导出注册器,插件回头调它注册自己 ❌ 控制流方向反了

每种间接单独看都不算夸张,叠在同一条调用路径上就是阅读成本的指数爆炸。雪上加霜的还有两点:

  • 同名函数泛滥:链路上至少 4 个不同地方都叫 InitPlugin,没有任何命名信息区分谁是 thin wrapper、谁是真正实现。
  • 日志和代码语义错位:日志写「install and start」,但本地代码只调了 NotifyAuthChanged,真正的安装动作在 4 层之外。读日志的人在 StartModule 里找安装代码,永远找不到。

6. 公平地说,这套设计「不是无理由的烂」

平心而论,这种架构有它的诉求:

  • 插件二进制独立升级:第三方插件要能不动主程序就替换 → 必须动态加载;
  • 进程隔离:插件用独立服务 + RPC,避免插件 crash 拖垮主程序;
  • 多插件统一接入:通用 callback 表 + NTP 注册机制,任何插件都能挂进来;
  • C ABI 边界:跨 DLL 必须用 C 风格函数指针,没法直接传 C++ 对象。

问题不在于「这些复杂度本不该存在」——它们是真实的工程约束。问题在于:为了承担这些复杂度所付出的「间接成本」,没有任何机制去对冲。没有文档、没有命名约定、没有日志能让读者快速重建调用图。插件架构的全部复杂度,被无损耗地泄漏给了读代码的人。

7. 如果重新设计:成本递增的几档改进

档位 1(零成本):命名和注释纪律

  • 不要让宏改名擦掉可搜索的标识符#define Wrap_InitPlugin InitPlugin 是整套代码里最致命的一处——它让源码符号和二进制符号不一致,整个 grep 工作流崩塌。直接定义 C 函数名就用 Wrap_InitPlugin,宁可所有调用方都带前缀,也不要为了「干净」拿宏改名。
  • 同名函数加歧义后缀InitPlugin_CWrapper / InitPlugin_DllStub / InitPlugin_Impl,看名字就知道在哪一层、是不是薄壳。
  • 跨边界的 thin wrapper,头上必须有一行 anchor 注释
    1
    2
    // 转发到: VendorInterfaceMgr::InitPlugin (VendorWrapper.dll, vendor_interface_mgr.cpp)
    // 调用链: PluginIntegrateMgr::AuthChanged -> BusinessHandler::Init -> 本函数
    这是这套代码里最便宜、回报最高的改善。

档位 2(低成本):分发表带元数据,不要裸函数指针

1
2
3
4
5
6
7
8
9
10
11
// 原版: 运行时塞东西, 编译期啥都看不见
std::map<int, PFuncNtpCall> m_ntp_callback;

// 改进: 注册时记录来源
struct NtpRegistration {
PFuncNtpCall func;
const char* plugin_name; // "vendor-X"
const char* func_name; // "AuthChanged"
const char* source_location; // __FILE__ ":" __LINE__
};
std::map<int, std::vector<NtpRegistration>> m_ntp_callback;

注册时打一行日志:"NTP[AUTH_CHANGED] registered by vendor-X::AuthChanged (integrate_mgr.cpp:110)"。分发时再打一行:"NTP[AUTH_CHANGED] dispatched to vendor-X::AuthChanged"抓 log 就知道指针指向谁,省掉所有静态追踪。

档位 3(低成本):主程序内部用 C++ 接口,不用函数指针表

跨 DLL 边界本来就只能用 C ABI,但主程序内部完全没必要再用 callback map:

1
2
3
4
5
6
7
8
9
10
11
12
13
class IPlugin {
public:
virtual void OnAuthChanged(bool authorized) = 0;
virtual void OnHeartbeat(...) = 0;
virtual void OnCmdArrived(...) = 0;
};

class VendorPluginAdapter : public IPlugin {
void OnAuthChanged(bool authorized) override {
m_dll_fn_table.auth_changed(authorized); // 唯一一处函数指针调用
}
// ...
};

读者在主程序看到 plugin->OnAuthChanged(true),IDE 跳定义就能进 VendorPluginAdapter,再跳一次进 DLL 调用。间接层数从 5 减到 2

档位 4(中等成本):日志即架构图

在每一个「跨边界跳板」上记一行结构化日志:

1
2
[plugin-bridge] vendor-X.OnAuthChanged(true) → VendorWrapper.dll::InitPlugin
[plugin-bridge] VendorWrapper::InitPlugin → InstallOrUpgradeService (service=svc-X, action=create+start)

让运行时日志能完整画出调用图。新人接手时跑一遍场景看 log,比读 5 个文件还快。

档位 5(中等成本):架构图作为代码文档

在插件管理器的头文件、wrapper DLL 的入口文件各放一张 ASCII 流程图,标清楚「callback 表怎么传过去、NTP 怎么注册回来、动态调用怎么走」。这套代码最大的问题不是技术烂,是隐式知识太多,没落到文本里

档位 6(高成本):拆掉冗余的 wrapper 层

atomic_wrapper_helper 这一层做的事情只有「LoadLibrary + 一堆 GetProcAddress + 同名转发」。它完全可以模板化成一个 DllFunctionTable<T> 类,省掉一个文件、一组成员变量、一组同名转发函数。但这是真正的重构,工作量和风险都大。

8. 一句话总结

这类代码难懂的根本原因是 「插件化、跨进程、跨 DLL、动态分发」这些复杂度全部以最原始的形式(裸函数指针 + 宏改名 + 反向注册)暴露在调用路径上,并且没有任何对冲机制(无文档、无命名区分、无日志线索)

避免它的核心原则只有一条:间接层不能省,但「间接层的元数据」必须留下——可以是命名约定、anchor 注释、运行时日志、或一层 C++ 接口封装,任选其一都比裸函数指针 + 同名 wrapper 强 10 倍。