如何重构基于SQLite内存数据库的C++全局可变状态应用?
看起来你正卡在一个典型的「混合业务逻辑与持久化层」的重构困境里——我完全懂这种看着15k行代码里到处飘着硬编码SQL字符串,想加单元测试却被全局可变的内存DB绑住手脚的感觉。下面是一些我在类似场景里用过的、能逐步推进的重构思路,不用一下子彻底重写:
第一步:把所有SQL逻辑集中到数据访问层(DAL)
现在你的SQL查询散落在代码各处,第一步要做的是把它们全部封装到专门的DAL类里。比如:
- 给每张任务表/辅助表对应一个Store类,比如
TaskAStore、ResultStore - 把原来硬编码的
SELECT/UPDATE/INSERT语句,都改成这些Store类的方法,比如TaskAStore::getPendingTasks()、ResultStore::addTaskResult(const TaskResult& result) - 所有业务代码不再直接拼接SQL字符串,而是调用这些Store方法
这么做的好处:
- 所有SQL逻辑集中在少数几个类里,以后改表结构或查询规则时,不用全局搜索修改
- 后续做单元测试时,可以给Store类做Mock,彻底摆脱对SQLite的依赖
第二步:封装全局DB状态为单一上下文
现在内存DB是全局可变的,导致业务逻辑和DB状态紧耦合。你可以把整个DB实例和所有Store类封装成一个TaskExecutionContext类,所有需要访问数据的业务方法都通过这个上下文对象来操作,而不是直接碰全局DB。比如:
class TaskExecutionContext { private: sqlite3* db_; TaskAStore taskA_store_; TaskBStore taskB_store_; ResultStore result_store_; public: TaskExecutionContext() : db_(initInMemoryDB()), taskA_store_(db_), ... {} TaskAStore& getTaskAStore() { return taskA_store_; } // 其他Store的访问方法 };
然后把这个上下文作为参数传递给所有业务逻辑函数。这样状态的流动会变得清晰,也方便在测试时替换成模拟的上下文。
第三步:把业务逻辑从DB操作中剥离出来
现在的核心问题是业务规则和SQL查询混在一起,比如「找到下一个可行任务」的逻辑可能是C++代码加一堆SQL。你可以把这部分拆成三个独立的步骤:
- 数据获取:通过DAL层从DB拿到原始的任务、依赖、结果数据(都是内存对象)
- 业务规则判断:纯C++代码,只处理内存中的数据,判断哪些任务可以执行,处理所有特殊依赖规则
- 数据更新:把业务逻辑的结果(标记任务完成、新增结果)通过DAL层写回DB
举个重构前后的对比:
重构前(混合逻辑):
// 直接拼接SQL并执行,在回调里处理业务规则 char* errMsg; sqlite3_exec(db, "SELECT id FROM task_b WHERE dep_id IN (SELECT id FROM task_a WHERE completed=1) AND status='pending'", [](void* data, int argc, char** argv, char** colNames) -> int { // 在这里处理特殊规则,再执行UPDATE return 0; }, nullptr, &errMsg);
重构后(分层清晰):
// 1. 获取数据 const auto completed_task_a_ids = ctx.getTaskAStore().getCompletedIds(); const auto pending_task_bs = ctx.getTaskBStore().getPendingTasks(); // 2. 纯内存中的业务规则判断 std::vector<TaskB> available_tasks; for (const auto& task : pending_task_bs) { if (std::find(completed_task_a_ids.begin(), completed_task_a_ids.end(), task.dep_id) != completed_task_a_ids.end()) { // 加上所有特殊情况的判断逻辑 available_tasks.push_back(task); } } // 3. 更新DB for (const auto& task : available_tasks) { ctx.getTaskBStore().markCompleted(task.id); }
这样,业务规则层的代码就可以单独写单元测试了——不用启动SQLite,直接构造内存中的任务数据,调用判断逻辑就行。
第四步:用测试驱动逐步覆盖核心逻辑
既然现在完全没有单元测试,重构时可以从最核心、最容易出问题的业务规则开始写测试。比如:
- 先写测试用例:「当TaskA完成时,依赖它的TaskB应该变为可执行」
- 然后重构对应的业务逻辑代码,让它通过测试
- 再逐步覆盖其他特殊情况,比如「执行TaskC后才发现它的依赖TaskD其实不可行」
因为已经把业务逻辑和DB操作剥离了,这些测试可以完全在内存中运行,不用处理任何SQL细节。
第五步:逐步替换内存DB为纯内存数据结构(可选)
如果内存DB的初衷是「简化逻辑和提升扩展性」,但现在反而成了负担,可以考虑逐步把部分表替换成纯C++的内存数据结构(比如std::unordered_map、std::vector)。比如先把TaskA的存储从SQLite表换成std::unordered_map<int, TaskA>,对应的Store方法直接操作这个map,而不是SQL。
这么做的好处:
- 彻底摆脱SQL的束缚,业务逻辑可以更直接地操作数据
- 单元测试更简单,不用处理SQL的解析和执行
- 性能可能也会提升(毕竟内存DB还是有SQL解析的开销)
当然,这一步要小步推进,先替换一张表,测试没问题再换另一张,不要一下子全换掉。
最后:小步快跑,拒绝完美主义
重构这种老代码最忌讳的就是想一下子彻底重写——你已经意识到这不现实,这很好。每次只改一小块:比如今天把一个SQL查询封装到DAL里,明天把一个业务逻辑从DB操作里抽出来,后天写一个单元测试覆盖这个逻辑。每次提交都保证代码能正常运行,这样风险最小,也能逐步看到进展。
内容的提问来源于stack exchange,提问作者Frank Puffer

