ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++抽象工厂与单件模式实践:多数据库切换的工程化实现

C++抽象工厂与单件模式实践:多数据库切换的工程化实现

简介:面向软件构造课程学习者与设计模式初学者的实验报告资源,主题为抽象工厂模式与单件模式的编程实现。报告基于 2019 年软件构造课程真实实验场景,以公司数据库操作为例,设计支持 MySQL 和 SQL Server 两种数据库的用户、部门、项目增删查改模块,完整给出 IFactory 抽象工厂、SqlserverFactory、MysqlFactory 具体工厂,以及 SqlserverUser、MysqlUser 等具体实现类的 C++ 代码。报告还详细解释了抽象工厂如何组织多产品族、单件模式如何保证工厂唯一实例,并配有 UML 图与实验结论,便于读者对照理解设计模式的落地方式。资源为单个 PDF 文件,整体大小 301KB,简洁便携;目前已有 122 人下载学习,适合正在复习设计模式、撰写实验报告或设计数据访问层的学习者参考。通过阅读可熟悉抽象工厂与单件模式的关键代码结构,掌握按数据库类型动态切换实现类的思路。

1. 抽象工厂与单件:一次搞定多数据库切换的C++模拟工程

接到公司内部管理系统,数据库要在 MySQL 和 SQL Server 之间来回切换,最直观的做法是在每个数据操作里写 if-else 判断类型。表一多就乱套。这次拆的实验资源来自一份课程实验报告,用抽象工厂模式把用户表、部门表、项目表的操作各自抽象成接口,MySQL 和 SQL Server 分别实现一整族类,调用方只认抽象接口;再配合单件模式保证每个具体工厂在程序里只有一个实例。它纯 C++ 实现,不依赖任何第三方库,能直接编译运行。适合正在学设计模式的人,也适合准备给系统做多数据源支持的人,拿它的骨架改成真实业务代码。

2. 抽象工厂模式:三张表与两种数据库的产品族划分

这个实验的核心需求看起来简单:公司数据库有用户表、部门表、项目表,每张表支持查询和添加,数据库类型有 MySQL 和 SQL Server 两种。如果按常规思路,直接写六个类——SqlserverUser、MysqlUser、SqlserverDepartment、MysqlDepartment、SqlserverProject、MysqlProject——也能跑,但客户端代码里到处是新具体类,切换数据库时一堆地方要改。

抽象工厂模式换了一种组织方式:先定义抽象的"产品接口",再定义抽象的"工厂接口",最后为每个数据库实现一整族产品。客户端只依赖抽象接口,换数据库等同于换一个具体工厂,业务代码一行不用动。下面从产品接口开始拆。

2.1 抽象产品接口:IUser、IDepartment、IProject 只做两件事

需求里每张表只支持查询和添加,所以抽象产品接口只需要两个方法:一个 Insert,一个 Get。这里先定义三个实体类作为数据载体,再定义三个抽象接口。

// 实体类,承载业务数据,实验里只放了主键 id class User { int id; }; class Department { int id; }; class Project { int id; }; // 抽象产品接口:用户表的操作契约 class IUser { public: virtual void Insert(User* user) = 0; virtual User* GetUser(int id) = 0; virtual ~IUser() {} }; // 抽象产品接口:部门表的操作契约 class IDepartment { public: virtual void Insert(Department* department) = 0; virtual Department* GetDepartment(int id) = 0; virtual ~IDepartment() {} }; // 抽象产品接口:项目表的操作契约 class IProject { public: virtual void Insert(Project* project) = 0; virtual Project* GetProject(int id) = 0; virtual ~IProject() {} };

三个接口的语义很明确:Insert 接收实体指针,负责把一条记录写进对应表;Get 接收主键 id,负责按 id 查出记录并返回实体指针。定义为纯虚函数,是为了强制每个具体数据库产品都实现这两件事,调用方拿到接口引用后不需要关心实现细节。

这里我补了一个细节:原资源里的抽象产品接口没有虚析构函数。如果将来通过 IUser 指针 delete 一个 SqlserverUser 对象,析构不会调用子类的析构函数,可能漏掉资源释放。我一般会加上virtual ~IUser() {}这种空实现,成本为零,但把隐患堵住了。

2.2 抽象工厂 IFactory:一组创建方法组合成家族契约

有了三个抽象产品接口,接下来需要定义"按数据库类型创建整套产品"的工厂接口。这就是抽象工厂模式的关键:它不是一个创建方法,而是三个创建方法组成的一组契约。

class IFactory { public: virtual ~IFactory() {} virtual IUser* CreateUser() = 0; virtual IDepartment* CreateDepartment() = 0; virtual IProject* CreateProject() = 0; };

每个 Create 方法负责创建一个抽象产品接口对象。调用方通过 IFactory 指针调用 CreateUser(),得到的是 IUser 指针,并不关心背后是 SQL Server 实现还是 MySQL 实现。这种方法签名组合就是所谓的"产品族契约"。

和工厂方法模式的区别值得说清楚:工厂方法模式针对的是单一产品,子类决定具体创建哪个产品对象;抽象工厂模式针对的是整族产品,一个具体工厂一次性给出一整套实现。如果只做用户表一个产品,用工厂方法就够了;这里有三张表、两个数据库,抽象工厂是更自然的选择。

2.3 两套具体产品族:Sqlserver 与 Mysql 的双轨实现

每个数据库都需要实现三个抽象产品接口。拿用户表举例,两个具体产品长这样:

// SQL Server 用户表实现 class SqlserverUser : public IUser { public: void Insert(User* user) { cout << "向SQL Server用户表增加一个用户" << endl; } User* GetUser(int id) { cout << "从SQL Server用户表得到一个用户" << endl; return NULL; } }; // MySQL 用户表实现 class MysqlUser : public IUser { public: void Insert(User* user) { cout << "向MySQL用户表增加一个用户" << endl; } User* GetUser(int id) { cout << "从MySQL用户表得到一个用户" << endl; return NULL; } };

这里用 cout 输出模拟真实的 SQL 执行,因为实验环境没有连数据库。真实项目里,把 cout 替换为具体的 SQL 拼装、参数绑定、结果集映射即可,接口结构不用变。SqlserverDepartment、SqlserverProject、MysqlDepartment、MysqlProject 四个类的结构和上面完全一致,只是对象名和输出文字不同,完整清单在资源里。

需要提醒的是,原资源的两个数据库版本输出文案完全一样,都是"增加一个用户",日志里根本看不出当前操作的是哪个库。我在复现时把输出改成了带库名的写法,后面验证输出时会方便很多。新接触这个模式的同学容易漏掉这一步,等日志混在一起时就后悔了。

2.4 为什么不用简单工厂或工厂方法:切换成本和扩展性的权衡

如果只是临时应付两个数据库,简单工厂也能做,比如写一个静态方法,根据传入的字符串 switch 到不同具体类。但问题在于,每加一个数据库类型,这个工厂方法就要加一个 case,每加一张新表,所有工厂方法都要动,违背开闭原则的苗头很明显。

抽象工厂把变化隔离在具体工厂这一层:新增一个数据库,只需要新增一套产品类和一个具体工厂类,已有的 SqlserverFactory、MysqlFactory 和客户端代码全部不用动。这一点在实验里体会不深,因为只有两张表三个产品,但放到真实系统里,产品族越复杂,抽象工厂的价值越明显。第 6 章我会演示新增 Oracle 数据库要改哪些文件,到那时候这个结论会更直观。

3. 单件模式接入工厂:私有构造、静态指针与 GetInstance

抽象工厂解决了"由谁创建、创建哪一族"的问题,但还有一个隐患:客户端可能会随手 new 出多个 MysqlFactory。工厂本身不是重量级对象,但每个实例都持有自己的一份状态,多个实例并存容易埋下不一致的雷。实验给的做法是用单件模式来约束:每个具体工厂在程序里只能存在一个实例。

3.1 工厂为什么要单件:全局唯一访问点与创建成本

单件模式最常见的应用是数据库连接实例——整个应用程序只允许一个 Connection 实例。工厂类的处境类似:每个产品族只需要一个具体工厂实例,因为工厂本身不保存业务数据,它的职责只是按需创建产品对象,多个实例并存只会增加混淆。

实验里还有一个更实际的理由:抽象工厂的创建过程如果涉及读取配置文件、初始化连接参数,这些开销不应该被重复执行。单件模式把第一次创建的结果缓存到静态指针里,后续调用直接返回已有实例,既保证全局只有一份,也把重复初始化的开销省掉了。

3.2 懒汉式单件的三个关键点:private 构造、static 指针成员、类外初始化

MysqlFactory 的单件化是原资源里最完整的一段,我原样保留并加了注释:

class MysqlFactory : public IFactory { public: ~MysqlFactory() { cout << "MySQL抽象工厂实例删除!" << endl; } IUser* CreateUser() { return new MysqlUser(); } IDepartment* CreateDepartment() { return new MysqlDepartment(); } IProject* CreateProject() { return new MysqlProject(); } // 单件入口:全局唯一获取工厂实例的通道 static IFactory* GetInstance() { if (factory == NULL) { cout << "抽象工厂实例生成!" << endl; factory = new MysqlFactory(); } else { cout << "Error, 抽象工厂实例已有!" << endl; } return factory; } private: // 构造函数私有化:外部无法直接 new MysqlFactory() MysqlFactory() {} static MysqlFactory* factory; }; // 静态成员变量必须在类外定义并初始化 MysqlFactory* MysqlFactory::factory = NULL;

三个关键点逐一说明。第一,构造函数放在 private 区,外部代码调用new MysqlFactory()时编译直接报错,唯一的创建入口被堵在 GetInstance 里。第二,factory 是 static 指针成员,属于类而不属于对象,所有调用方共享同一个判断状态,这是单件能生效的前提。第三,静态成员变量必须在类外初始化,如果漏掉这行MysqlFactory* MysqlFactory::factory = NULL;,链接时会报未定义符号。

关于 GetInstance 的参数说明,这个方法无参,返回 IFactory 指针。第一次调用时 factory 为空,进入 if 分支执行 new,并打印"抽象工厂实例生成!";第二次及之后调用时 factory 非空,走 else 分支打印错误提示,然后返回已有实例。这个提示信息本身就是单件是否生效的调试信号。

3.3 客户端调用流程:先取工厂,再按表建操作对象

客户端代码是验证整套设计的关键。引用原资源的 main 逻辑:

int main() { User* user = new User(); Department* dept = new Department(); Project* pro = new Project(); cout << "MySQL" << endl; // 第一次获取:应该输出“抽象工厂实例生成!” IFactory* factory1 = MysqlFactory::GetInstance(); // 第二次获取:应该输出“Error, 抽象工厂实例已有!” IFactory* factory2 = MysqlFactory::GetInstance(); IUser* iu = factory1->CreateUser(); iu->Insert(user); iu->GetUser(1); IDepartment* idept = factory1->CreateDepartment(); idept->Insert(dept); idept->GetDepartment(1); IProject* ipro = factory1->CreateProject(); ipro->Insert(pro); ipro->GetProject(1); return 0; }

逻辑顺序很清晰:先获取工厂,再用工厂创建用户表操作对象,插入一条用户记录、按 id 查一条记录;部门表和项目表的操作完全对称。由于 factory1 和 factory2 指向的是同一个实例,后面无论用哪个变量创建产品,行为完全一致。这也证明了单件模式的价值——外部不用管工厂怎么实例化的,拿到手的一定是同一份。

3.4 单件只挡同族:MySQL 工厂唯一,不影响 SQL Server 工厂

原资源实验总结里有一条容易被忽略的结论:MysqlFactory 的单件只对 MysqlFactory 自身生效,并不阻止创建 SqlserverFactory 实例。这一点从代码结构上就能看出来,factory 是 MysqlFactory 的静态成员,它管不到 SqlserverFactory 的静态成员。

换句话说,程序里可以同时存在一个 MySQL 工厂实例和一个 SQL Server 工厂实例,这是期望行为;但不能存在两个 MySQL 工厂实例。理解这一点,后面接双数据库同时访问的场景时就不会踩坑——单件是"同类的单件",不是"全局的单件"。

4. 运行与结果验证:编译命令、预期输出与一行切库

设计模式学得再好,跑不起来也是白搭。这一章把实验工程从代码变成可执行程序,同时验证两件事:编译链接是否通过,单件是否真的拦截了第二次实例创建。

4.1 工程文件组织与编译命令

原资源把所有类都放在一个文件里,适合快速演示。实际工程项目里我一般会拆成头文件和源文件,按角色分文件,这样每个文件职责单一,也方便复用。建议的文件结构:

abstract_factory/ ├── products.h # User/Department/Project 实体类 ├── abstract_products.h # IUser/IDepartment/IProject 抽象接口 ├── concrete_products.h # SqlserverUser/MysqlUser 等六个具体产品类 ├── factory.h # IFactory、SqlserverFactory、MysqlFactory └── main.cpp # 客户端入口

编译命令很简单,因为整个工程没有任何第三方依赖:

g++ -std=c++11 main.cpp -o factory_demo ./factory_demo

如果还是想用单文件验证,把所有类和 main 写进一个 cpp 文件,直接g++ main.cpp -o factory_demo一样能过。头文件里注意加 include guard,避免重复包含时报重定义错误。Windows 上直接用 Visual Studio 建一个空控制台项目,把文件加进去编译也可以,不需要额外配置。

4.2 main 函数执行流程与预期输出

按改进后的带库名输出文案,main 的完整执行流程预期如下表:

执行步骤控制台输出
factory1 = MysqlFactory::GetInstance()抽象工厂实例生成!
factory2 = MysqlFactory::GetInstance()Error, 抽象工厂实例已有!
iu->Insert(user)向MySQL用户表增加一个用户
iu->GetUser(1)从MySQL用户表得到一个用户
idept->Insert(dept)向MySQL部门表增加一个部门
idept->GetDepartment(1)从MySQL部门表得到一个部门
ipro->Insert(pro)向MySQL项目表增加一个项目
ipro->GetProject(1)从MySQL项目表得到一个项目

这里有一个容易疑惑的点:程序结束前没有任何 delete,所以析构函数的 cout 根本不会执行。如果你在输出里看不到"MySQL抽象工厂实例删除!",不是代码写错了,而是实例根本没被销毁。想验证析构输出,可以在 main 末尾手动delete factory1;但这会引出第 5 章讲的坑。

4.3 一行切换 SQL Server:抽象工厂的价值验证

抽象工厂最爽的地方在这一步。把 main 里的两行替换成 SQL Server 工厂:

IFactory* factory1 = SqlserverFactory::GetInstance(); IFactory* factory2 = SqlserverFactory::GetInstance();

其余代码一行都不用改。编译运行后,"抽象工厂实例生成!"这行由 SqlserverFactory 触发,三张表的操作全部落到 SQL Server 产品族。客户端从头到尾只依赖 IFactory、IUser、IDepartment、IProject 四个抽象类型,具体数据库类型被隔离在工厂这一层,这就是抽象工厂模式解耦能力的直接体现。

4.4 验证单件唯一性的两个方法:地址比较与计数输出

单件是否真的唯一,光看日志不够严谨。我一般会在测试代码里打印 GetInstance 返回的指针地址:

IFactory* f1 = MysqlFactory::GetInstance(); IFactory* f2 = MysqlFactory::GetInstance(); cout << "f1 address: " << f1 << endl; cout << "f2 address: " << f2 << endl;

如果两次输出地址相同,单件生效;地址不同,说明 GetInstance 里 new 的结果没被缓存,每次调用都新建了实例。第二个方法是给 GetInstance 加一个静态计数变量,每次进入方法自增一次,最后打印总数,正常情况下总数应该等于 1。这两个方法都能在第一时间暴露单件失效问题。

5. 避坑与排查:单件失效、实例泄漏与复制粘贴事故

再好的设计模式,落地时也会被细节坑。这一章全部是我在复现这个实验时遇到或观察到的真实问题。每条按"现象→原因→解决"整理,能直接用在排查现场。

5.1 坑一:return new 没赋值,单件形同虚设

现象:连续两次调用 SqlserverFactory::GetInstance(),每次都打印"抽象工厂实例生成!",且两次返回的指针地址不同。单件完全失效。

原因:GetInstance 里写的是return new SqlserverFactory();,注意这里直接把 new 的结果返回了,并没有赋值给静态指针 factory。下一次调用时 factory 仍然是 NULL,继续进入 if 分支再 new 一次。而且 return 后面的 cout 语句永远不会执行,属于不可达代码。

解决:先赋值给 factory,再统一从尾部 return 这个指针。

// 错误写法:new 了直接返回,factory 永远为空 static IFactory* GetInstance() { if (factory == NULL) { return new SqlserverFactory(); cout << "抽象工厂实例生成!" << endl; // 永远执行不到 } else { cout << "Error, 抽象工厂实例已有!" << endl; } return factory; } // 正确写法:先执行 factory = new,再 return factory static IFactory* GetInstance() { if (factory == NULL) { cout << "抽象工厂实例生成!" << endl; factory = new SqlserverFactory(); } else { cout << "Error, 抽象工厂实例已有!" << endl; } return factory; }

这个坑极具隐蔽性,因为单从语法上看完全合法,编译器不会报任何警告。我复现时差点把锅甩给编译器。从那以后我检查单件代码,第一眼一定看 new 出来的对象有没有被静态指针接住。

5.2 坑二:析构输出张冠李戴,MysqlFactory 打印 SQL Server 字样

现象:销毁 MysqlFactory 实例时,控制台输出的是"SQL Server 抽象工厂实例删除!",而不是 MySQL 的提示。

原因:原资源里 MysqlFactory 的析构函数是从 SqlserverFactory 复制粘贴过来的,改类名时漏掉了析构函数里的输出文字。类名和日志文案不一致,排查时极具迷惑性——程序行为是 MySQL 的,日志却总提示 SQL Server。

解决:复制粘贴生成具体类后,逐文件核对类名、方法名、输出文案三处。批量生成六个具体产品类和两个工厂类时,这种错误几乎必现一次,测试时重点看日志里的数据库名和类名的匹配关系。

5.3 坑三:new 出来没人 delete,析构函数成了摆设

现象:程序退出时没有任何析构输出,用内存检测工具能查出多处泄漏。User、Department、Project 三个实体是 new 的,MysqlUser 等产品对象是工厂 new 的,全部没有对应 delete。

原因:原资源为了演示设计模式,把内存管理完全省略了。真实工程里这种写法会持续泄漏,服务跑几天内存就飘起来了。

解决:小实验无所谓,真实项目建议用智能指针兜底。以 C++11 为例:

std::unique_ptr<IFactory> factory(MysqlFactory::GetInstance()); std::unique_ptr<IUser> iu(factory->CreateUser()); std::unique_ptr<User> user(new User()); iu->Insert(user.get());

到这里你会发现问题:工厂指针被 unique_ptr 接管后,程序退出时会调用 MysqlFactory 的析构函数,那行"MySQL抽象工厂实例删除!"终于能看到了。但要注意,GetInstance 返回的是 IFactory 指针,unique_ptr 析构时通过 IFactory 的虚析构调用具体的析构,这也是第 2 章我坚持让抽象产品接口加虚析构的原因。

5.4 坑四:私有构造堵住了 new,也堵住了优雅销毁

现象:单件工厂实例没有提供任何销毁入口。进程不退,实例就永远驻留;想测试"重建工厂"的场景,只能重启进程。

原因:构造函数私有化之后,外部无法 delete 并重新 new 一个实例,而 GetInstance 里也没有提供对应的 DestroyInstance 方法。

解决:需要可靠生命周期管理时,补一个静态销毁方法,并把静态指针复位为 NULL:

static void DestroyInstance() { if (factory != NULL) { delete factory; factory = NULL; } }

销毁后再次调用 GetInstance 会重新走创建流程,这正好可以复现"实例生成 → 错误提示 → 销毁 → 再次生成"的完整状态切换。调用时机放在程序收尾处,或者单元测试的 teardown 阶段。

5.5 坑五:多线程并发首访可能创建两个工厂

现象:两个线程同时第一次调用 GetInstance,线程 A 判断 factory 为 NULL,还没执行 new,线程 B 也判断为 NULL,随后两个线程各 new 一个实例。单件在并发下彻底失效。

原因:懒汉式单件的if (factory == NULL)判断和后续的factory = new不是原子操作,存在时间窗口。这是懒汉式的天生缺陷。

解决:最直接的办法是给 GetInstance 加锁:

static IFactory* GetInstance() { std::lock_guard<std::mutex> lock(mutex_); if (factory == NULL) { factory = new MysqlFactory(); } return factory; }

注意锁必须放在 if 判断外面,否则两个线程会串行进入,第二次仍会重新 new。代价是每次调用都有锁开销,所以生产环境常用双重检查锁定来优化,但 C++ 里内存序问题比想象中复杂,新手不建议自行实现。下一章会给出一个更省心的替代方案。

6. 扩展:第三种数据库接入、Meyer 单件与智能指针收尾

抽象工厂的价值只有在扩展时才真正显现。假设现在要接 Oracle 数据库,改动点非常清晰:新增 OracleUser、OracleDepartment、OracleProject 三个产品类,分别继承 IUser、IDepartment、IProject,再新增 OracleFactory 继承 IFactory 并实现三个 Create 方法。客户端只换一行,把MysqlFactory::GetInstance()换成OracleFactory::GetInstance(),三张表操作自然切到 Oracle 产品族。已有的 Sqlserver 和 Mysql 代码一行不用动。

单件工厂的写法我推荐换成 Meyer 单件,这是 C++11 标准里线程安全且最省心的一种实现。同样是 MysqlFactory,改写后长这样:

class MysqlFactory : public IFactory { public: static MysqlFactory& GetInstance() { static MysqlFactory instance; // 首次调用时构造,标准保证线程安全 return instance; } private: MysqlFactory() {} MysqlFactory(const MysqlFactory&); // 禁止拷贝 MysqlFactory& operator=(const MysqlFactory&); // 禁止赋值 };

函数局部静态变量的初始化由编译器保证线程安全,而且返回的是引用,不需要 delete,不存在泄漏问题。唯一要适应的是调用方式变成MysqlFactory::GetInstance().CreateUser(),语义上更干净。如果项目里必须统一用 IFactory 指针,可以写IFactory* p = &MysqlFactory::GetInstance();中转一下。

我把这份代码按"接口 → 产品 → 工厂 → 客户端"四个层次重排时,最大的教训就在第 5 章的坑一:一个 return new 的疏漏会让整个单件静默失效,日志还打印着"实例生成",让人误以为一切正常。从那以后我每次写完单件,都强制走一遍自查清单:静态指针有没有类外初始化、new 出来的对象有没有赋值给指针、返回值是不是同一个指针、析构文案有没有张冠李戴。这套检查花了不到两分钟,但帮我躲过了至少三次莫名其妙的单件失效排查。完整工程已经整理进实验资源,直接下载后编译运行,就能复现上面所有输出和两个工厂的行为对比。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进