从一次代码重构说起:我是如何用C# virtual方法,让老项目支持新插件机制的
从一次代码重构说起:我是如何用C# virtual方法,让老项目支持新插件机制的
那天下午,产品经理又带着新需求来了:"咱们的报告导出功能能不能加个Excel格式?客户说PDF不方便做二次处理。"我看了看代码库里那个将近2000行的ReportGenerator类,里面密密麻麻全是PDF生成的逻辑,心里顿时凉了半截。这就是典型的技术债——三年前为了赶工期,把所有输出逻辑都写死在了一个类里。现在要加新功能,难道要再复制粘贴2000行代码?
1. 老系统的扩展性困境
我们那个报告生成模块最初设计时,只考虑了PDF这一种输出格式。核心类ReportGenerator的结构大概是这样的:
public class ReportGenerator { public void GenerateReport(ReportData data) { // 2000行PDF生成逻辑 SetupPdfDocument(); RenderHeader(data); RenderBody(data); RenderFooter(data); SaveToPdfFile(); } private void SetupPdfDocument() { /*...*/ } private void RenderHeader(ReportData data) { /*...*/ } // 更多私有方法... }这种设计带来了几个明显问题:
- 修改成本高:每次新增格式都要修改核心类
- 测试风险大:改动PDF逻辑可能影响其他格式
- 代码重复:不同格式间无法复用公共逻辑
更糟的是,系统里已经有十几个地方直接调用了GenerateReport方法。如果大刀阔斧地重构,很可能会引发连锁反应。
2. 寻找合适的扩展方案
面对这种情况,我考虑了三种主流扩展方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 接口(Interface) | 强制实现规范,多继承 | 所有逻辑都要重新实现 | 全新系统,规范明确 |
| 抽象类 | 可提供部分实现 | 单继承限制 | 有明确继承层次的结构 |
| 虚方法(Virtual) | 保留基类逻辑,选择性重写 | 修改基类影响所有子类 | 渐进式改造老系统 |
考虑到我们系统的特殊情况:
- 大部分基础逻辑(如数据准备、样式计算)可以复用
- 需要最小化对现有调用代码的修改
- 未来可能支持更多格式
最终我选择了虚方法方案,它能在保持原有架构的基础上,通过方法重写实现扩展点。
3. 重构实战:引入虚方法
重构的第一步是识别出可变的部分。通过分析,我发现报告生成可以分为几个固定阶段:
- 文档初始化
- 页眉渲染
- 正文渲染
- 页脚渲染
- 文件保存
于是我将这些阶段提取为虚方法:
public class ReportGenerator { public void GenerateReport(ReportData data) { SetupDocument(); RenderHeader(data); RenderBody(data); RenderFooter(data); SaveToFile(); } protected virtual void SetupDocument() { /* PDF初始化 */ } protected virtual void RenderHeader(ReportData data) { /* PDF页眉 */ } protected virtual void RenderBody(ReportData data) { /* PDF正文 */ } protected virtual void RenderFooter(ReportData data) { /* PDF页脚 */ } protected virtual void SaveToFile() { /* 保存PDF */ } }关键点:将原本私有方法改为
protected virtual,保持原有公有接口不变,这样所有现有调用代码都不需要修改。
4. 实现Excel导出插件
现在可以轻松创建Excel导出类了:
public class ExcelReportGenerator : ReportGenerator { protected override void SetupDocument() { // 创建Excel工作簿 base.SetupDocument(); // 仍可调用基类逻辑 } protected override void SaveToFile() { // 保存为.xlsx文件 // 不需要调用base,完全重写逻辑 } }这个方案的美妙之处在于:
- 只需重写需要改变的方法(如
SaveToFile) - 可以复用基类逻辑(如数据校验)
- 新增格式不会影响现有功能
5. 踩坑记录与解决方案
在实际操作中,我遇到了几个典型问题:
5.1 构造函数调用顺序
public class ExcelReportGenerator : ReportGenerator { private ExcelPackage _excel; public ExcelReportGenerator() { _excel = new ExcelPackage(); // 错误!基类构造函数先执行 } protected override void SetupDocument() { _excel.AddWorksheet(); // NullReferenceException! } }解决方案:将初始化逻辑移到虚方法中:
protected override void SetupDocument() { _excel = new ExcelPackage(); base.SetupDocument(); }5.2 误用new关键字
有次我错误地用new代替了override:
public class WordReportGenerator : ReportGenerator { protected new void RenderHeader(ReportData data) { // 不会被多态调用! } }教训:永远检查方法修饰符,确保要重写的方法确实是virtual或abstract的。
5.3 基类逻辑依赖
有时候子类需要访问基类的某些状态:
protected override void RenderBody(ReportData data) { if (base._currentPage == null) // 错误!不能访问基类私有字段 throw new InvalidOperationException(); }改进:将需要共享的状态改为protected:
protected PageInfo _currentPage; // 基类中6. 最终效果与扩展思考
重构后的系统架构清晰多了:
ReportGenerator ├── PdfReportGenerator ├── ExcelReportGenerator └── WordReportGenerator调用方代码完全不用修改:
// 以前 var generator = new ReportGenerator(); generator.GenerateReport(data); // 现在 var generator = new ExcelReportGenerator(); // 仅这行需要改 generator.GenerateReport(data);这个方案特别适合以下场景:
- 老系统渐进式改造
- 需要保留大部分基类逻辑
- 希望最小化调用方改动
当然,虚方法也有其局限性。如果后续需要更灵活的插件机制,可以考虑结合:
- 策略模式(Strategy Pattern)
- 依赖注入(DI Container)
- 动态代理(DynamicProxy)
但就我们当前的需求而言,虚方法提供了最平滑的演进路径。整个重构过程只用了两天,就实现了原本以为要重写整个模块的功能。最重要的是,这个改动让系统真正具备了应对变化的弹性——当产品经理第三次来要CSV导出时,我只需要新增一个CsvReportGenerator类就行了。
