尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

从单例到依赖注入:一次代码重构的完整思路拆解

发布时间:2026/9/26 23:50:05

资讯中心
01
ARTICLE

从单例到依赖注入:一次代码重构的完整思路拆解

从单例到依赖注入:一次代码重构的完整思路拆解
1. 从单例到依赖注入一次代码重构的完整思路拆解单例模式和依赖注入这两个词但凡写过几年业务代码的人都不陌生。但真正让我下定决心把项目里用了三年的单例全面替换成依赖注入是因为一次线上事故——某个下午订单模块突然大面积超时排查了四个小时才发现问题出在一个单例的静态初始化块里它持有了一个数据库连接池的引用而这个连接池在某个边缘场景下被提前关闭了。单例的全局状态让整个调用链的依赖关系变成了一团乱麻你根本不知道谁在什么时候动了什么。这篇文章不是教科书式的模式对比而是我实际重构一个中型Java项目大约六万行代码的完整记录。我会讲清楚为什么单例在项目初期看起来很美为什么随着业务膨胀它会变成技术债以及我是怎么一步步把它替换成依赖注入的。如果你正在维护一个用了大量单例的老项目或者你正在设计一个新项目但不确定该不该用单例这篇文章应该能帮你少走一些弯路。核心关键词会自然融入全文单例、依赖注入、懒汉式单例、静态初始化、构造函数注入、生命周期管理。适合有Java或C基础、对设计模式有初步了解、正在面临代码可维护性问题的开发者阅读。小白也能看懂因为我会用生活化的类比来解释每个概念。2. 单例模式到底解决了什么问题又制造了什么问题2.1 单例的初衷全局唯一访问点单例模式的核心诉求很简单某个类在整個应用生命周期中只应该有一个实例并且提供一个全局访问点。最典型的场景就是配置管理器、日志记录器、线程池、缓存管理器。这些东西如果每次用都new一个要么浪费资源要么导致状态不一致。Java里最经典的懒汉式单例长这样public class ConfigManager { private static ConfigManager instance; private ConfigManager() { // 加载配置文件 } public static synchronized ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } }这段代码的逻辑是第一次调用getInstance时才创建实例之后都返回同一个。加synchronized是为了保证多线程环境下不会创建出两个实例。看起来很完美对吧但它有几个隐藏的问题。第一每次调用getInstance都要抢锁哪怕实例已经创建好了。虽然JVM对偏向锁做了优化但在高并发场景下这仍然是一个不必要的开销。第二如果构造函数里有耗时操作比如读配置文件、建连接池第一个线程在初始化时其他线程全部阻塞等待可能导致启动阶段响应变慢。第三也是最要命的这个类把自己的生命周期和全局状态绑死了你没法在测试时替换成一个mock对象。C的简单单例类也有类似的问题。很多教程会教你这么写class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } private: Singleton() default; Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; };C11之后局部静态变量的初始化是线程安全的这比Java的懒汉式要简洁。但本质上它仍然是一个全局可访问的、生命周期贯穿整个进程的对象。你在任何地方调用Singleton::getInstance()都能拿到它这意味着依赖关系被隐藏了。2.2 单例在项目膨胀后的真实痛点我在重构前统计了一下项目里一共有23个单例类。它们之间的依赖关系是这样的OrderService单例依赖CacheManager单例CacheManager单例依赖ConfigManager单例ConfigManager单例又依赖Logger单例。当你需要测试OrderService时你不得不把整条链路上的单例都初始化一遍。更糟糕的是初始化顺序问题。假设ConfigManager的静态初始化块里需要读一个环境变量而Logger的初始化又依赖ConfigManager提供的日志级别配置。如果JVM先加载了Logger类就会触发ConfigManager的初始化但此时环境变量可能还没设置好。这种隐式的初始化顺序依赖在代码里完全看不出来只有运行时才会暴露。还有一个典型问题单例让单元测试变得极其困难。你想测试一个方法在不同配置下的行为但单例的配置是全局的你改了之后会影响其他测试用例。你只能通过反射强行修改私有静态字段或者写一堆setUp和tearDown方法来重置状态。这些代码本身就是bug的温床。LabVIEW里的单例模式也有类似的困境。LabVIEW虽然不像Java那样有显式的类继承体系但它通过功能全局变量Functional Global Variable来实现单例。一个FGV内部用一个未初始化的移位寄存器来保存状态外部只能通过这个FGV来读写。当项目里有几十个FGV互相调用时数据流的依赖关系会变得非常复杂调试时你很难追踪某个状态是在哪个FGV里被修改的。2.3 为什么我们当初选择了单例说实话项目初期用单例并不是一个错误决定。那时候团队只有三个人业务逻辑简单单例确实能快速解决“全局唯一实例”的问题。而且单例的写法大家都很熟悉新人进来一看就懂不需要额外的学习成本。但问题在于项目在两年内从三个模块膨胀到了十七个模块开发人员从三个变成了十二个。单例的隐式依赖开始成为沟通成本的一部分。A同学修改了ConfigManager的某个字段B同学在另一个模块里依赖这个字段的旧行为结果B的代码挂了。这种问题在代码审查时很难发现因为单例的调用点分散在几十个文件里。注意单例本身不是反模式滥用单例才是。如果一个类确实需要全局唯一实例而且它的依赖关系非常简单、稳定那么单例是可以接受的。问题出在当单例之间形成复杂的依赖网络时整个系统的可维护性会急剧下降。3. 依赖注入的核心原理与选型考量3.1 依赖注入到底在做什么依赖注入的核心思想一句话就能说清楚一个类不应该自己创建它依赖的对象而应该由外部把依赖传进来。这个“外部”通常是一个容器负责创建对象、管理生命周期、组装依赖关系。用生活化的类比单例模式就像你自己去超市买食材、自己做饭。你知道冰箱里有什么、调料放在哪、锅在哪里。但如果你要同时做十道菜你会手忙脚乱。依赖注入就像你请了一个厨房助理你只需要告诉它“我要做一道番茄炒蛋”它会把番茄、鸡蛋、盐、锅都准备好递到你手里。你不需要知道食材是从哪个超市买的也不需要知道锅放在哪个柜子里。在Java里依赖注入最直观的形式是构造函数注入public class OrderService { private final CacheManager cacheManager; private final ConfigManager configManager; public OrderService(CacheManager cacheManager, ConfigManager configManager) { this.cacheManager cacheManager; this.configManager configManager; } public void processOrder(Order order) { String cacheKey configManager.getCachePrefix() order.getId(); cacheManager.put(cacheKey, order); } }OrderService不再自己去找CacheManager和ConfigManager而是由外部在构造时传入。这样做的好处是依赖关系显式化了你一眼就能看出OrderService需要什么。测试时你可以传入mock对象不需要启动整个容器。生命周期管理交给了容器你不需要关心CacheManager什么时候创建、什么时候销毁。3.2 为什么选择构造函数注入而不是字段注入依赖注入有三种常见方式构造函数注入、setter注入、字段注入。我在重构时选择了构造函数注入原因有三个。第一构造函数注入能保证依赖不可变。所有依赖字段声明为final对象一旦创建就不能再修改依赖。这避免了运行过程中依赖被意外替换的问题。字段注入在字段上加Autowired做不到这一点因为字段不是final的。第二构造函数注入能暴露设计问题。如果一个类的构造函数有八个参数那说明这个类承担了太多职责应该拆分。字段注入会掩盖这个问题因为依赖是隐式注入的你写代码时感觉不到参数膨胀。第三构造函数注入对测试最友好。你不需要启动Spring容器直接new一个对象传入mock依赖就行。字段注入在测试时需要用反射工具比如ReflectionTestUtils来设置字段代码更繁琐。实操心得如果你在用Spring框架从Spring 4.3开始如果一个类只有一个构造函数Autowired注解可以省略。这让构造函数注入的代码看起来非常干净。我建议所有新写的类都用构造函数注入老代码逐步迁移。3.3 容器选型Spring、Guice还是手写Java生态里主流的依赖注入容器有Spring、Guice、Dagger等。我的项目本来就在用Spring Boot所以直接用了Spring的IoC容器。如果你的项目没有用SpringGuice是一个更轻量的选择它的API更简洁启动速度更快。Dagger主要用于Android开发通过编译期生成代码来实现注入性能最好但学习曲线较陡。对于C项目依赖注入没有像Java那样成熟的容器框架。常见的做法是手写一个简单的工厂类或者使用Boost.DI这样的库。但C的依赖注入更多是编译期完成的通过模板和策略模式来实现。如果你的C项目规模不大手写工厂类可能比引入一个库更简单。LabVIEW里的依赖注入比较特殊。LabVIEW没有类的构造函数注入概念但你可以通过“按引用传递”的对象来实现类似效果。比如你创建一个CacheManager对象然后在OrderService的初始化方法里把它作为输入参数传进去。LabVIEW的面向对象编程支持聚合关系你可以把依赖对象作为类的私有数据成员在初始化时赋值。4. 从单例迁移到依赖注入的完整实操过程4.1 第一步梳理单例依赖图谱动手改代码之前我先做了一件事把所有单例类列出来然后画出它们之间的依赖关系。具体做法是对每个单例类搜索代码里所有调用getInstance()的地方记录下调用方和被调用方。我用的工具很简单IDE的全局搜索加上一个Excel表格。表格有三列单例类名、被哪些类依赖、依赖了哪些单例。整理完之后我发现23个单例分成了三个明显的层次基础层Logger、ConfigManager、中间层CacheManager、ThreadPoolManager、业务层OrderService、UserService等。这个依赖图谱让我明确了迁移顺序先改基础层再改中间层最后改业务层。因为业务层依赖中间层中间层依赖基础层自底向上迁移可以保证每一步都是可编译、可测试的。注意事项不要试图一次性把所有单例都改完。我见过有团队花了两周时间做全面重构结果合并代码时冲突一大堆测试也跑不过。正确的做法是每次只改一个单例改完立刻跑测试确保没有回归问题再继续下一个。4.2 第二步把单例类改造成普通类以ConfigManager为例改造前的代码是public class ConfigManager { private static ConfigManager instance; private Properties props; private ConfigManager() { props new Properties(); // 加载配置文件的逻辑 } public static synchronized ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } public String get(String key) { return props.getProperty(key); } }改造后Component public class ConfigManager { private final Properties props; public ConfigManager() { props new Properties(); // 加载配置文件的逻辑 } public String get(String key) { return props.getProperty(key); } }改动很小去掉了静态实例字段和getInstance方法把构造函数改成public加上Component注解让Spring管理。但这一步的意义很大ConfigManager不再控制自己的生命周期它变成了一个普通的Java对象谁需要它就在构造函数里声明。对于C项目改造思路类似。把静态成员变量去掉把构造函数改成public然后在需要的地方通过构造函数参数传入。如果你在用工厂模式可以在工厂类里创建ConfigManager的实例并缓存起来。LabVIEW的改造稍微不同。你需要把FGV改成普通的类方法然后在主VI的初始化阶段创建对象通过连线传递给需要的子VI。LabVIEW的数据流编程天然支持这种显式依赖传递只是需要改变原来“哪里需要哪里调FGV”的习惯。4.3 第三步在业务类中声明依赖OrderService改造前是这样的public class OrderService { public void processOrder(Order order) { ConfigManager config ConfigManager.getInstance(); CacheManager cache CacheManager.getInstance(); String prefix config.get(cache.prefix); cache.put(prefix order.getId(), order); } }改造后Service public class OrderService { private final ConfigManager configManager; private final CacheManager cacheManager; public OrderService(ConfigManager configManager, CacheManager cacheManager) { this.configManager configManager; this.cacheManager cacheManager; } public void processOrder(Order order) { String prefix configManager.get(cache.prefix); cacheManager.put(prefix order.getId(), order); } }这里有一个关键点OrderService不再需要知道ConfigManager和CacheManager是怎么创建的。它只声明“我需要这两个东西”Spring容器会在创建OrderService时自动注入。如果ConfigManager的构造函数需要参数Spring也会递归地解析和注入。实操心得在迁移过程中你可能会遇到循环依赖的问题。比如A依赖BB又依赖A。单例模式下这个问题被隐藏了因为getInstance是延迟调用的。改成构造函数注入后Spring启动时会直接报错。这是一个好事因为循环依赖本身就是设计问题。解决办法通常是提取一个新的类来打破循环或者用setter注入作为临时方案。4.4 第四步处理静态工具类的特殊情况项目里有一些类严格来说不算单例但它们是纯静态工具类比如StringUtils、DateUtils。这些类不需要实例化也不持有状态所以不需要改成依赖注入。它们的方法都是无状态的纯函数直接静态调用即可。但有一类“伪工具类”需要特别注意它们看起来是工具类但实际上持有状态或依赖外部资源。比如一个FileHelper类它内部缓存了文件路径的映射关系。这种类应该改造成普通的Spring Bean通过依赖注入来使用。判断标准很简单如果这个类有成员变量非final的静态变量也算并且这些变量的值会影响方法的行为那它就不应该是一个静态工具类。把它改成Bean让容器管理它的生命周期。4.5 第五步测试验证与回滚预案每改造完一个单例我都会做三件事跑单元测试、跑集成测试、在预发环境验证核心流程。单元测试主要验证改造后的类能否正常注入依赖集成测试验证整个调用链是否正常。回滚预案也很重要。我是在一个独立的分支上做重构的每完成一个单例的迁移就提交一次。如果发现某个单例改造后问题太多可以单独回滚那一次提交不影响其他已经改好的部分。这里有一个小技巧在迁移期间可以保留单例的getInstance方法作为过渡但把它标记为Deprecated并在方法内部从Spring容器获取Bean。这样老的调用方不用改新的调用方用依赖注入逐步替换。Deprecated public static ConfigManager getInstance() { return SpringContextHolder.getBean(ConfigManager.class); }SpringContextHolder是一个实现了ApplicationContextAware接口的工具类用来在非Spring管理的类中获取Bean。这个方案只是过渡用的最终目标是把所有getInstance调用都去掉。5. 常见问题与排查技巧实录5.1 循环依赖报错怎么破这是迁移过程中最常见的问题。Spring启动时会抛出BeanCurrentlyInCreationException提示某个Bean正在创建中又被依赖了。比如UserService依赖OrderServiceOrderService又依赖UserService。解决办法有三种。第一种是提取一个新的Service把两个类共同依赖的逻辑抽出来打破循环。第二种是使用Lazy注解让其中一个依赖延迟初始化。第三种是改用setter注入但这不是推荐做法因为它掩盖了设计问题。我遇到的一个具体案例是OrderService需要调用UserService来获取用户信息UserService需要调用OrderService来统计用户订单数。后来我提取了一个UserOrderFacade类把这两个交互逻辑放在一起两个Service都依赖Facade循环就打破了。5.2 单例的静态初始化块怎么迁移有些单例在静态初始化块里做了很多事比如注册监听器、启动定时任务。改成Spring Bean后这些逻辑应该放在PostConstruct注解的方法里。Component public class CacheManager { PostConstruct public void init() { // 原来在静态初始化块里的逻辑 startEvictionTimer(); } PreDestroy public void cleanup() { // 原来没有的清理逻辑现在可以优雅关闭 stopEvictionTimer(); } }PostConstruct在依赖注入完成后执行PreDestroy在容器关闭时执行。这比静态初始化块更可控因为你可以保证所有依赖都已经注入完毕。5.3 多线程环境下的单例行为变化单例模式下多个线程访问的是同一个实例状态共享是显式的。改成依赖注入后默认情况下Spring Bean也是单例的scopesingleton所以行为基本一致。但如果你把Bean的scope改成了prototype每次注入都会创建一个新实例这可能导致状态不一致。我在迁移时犯过一个错误把ThreadPoolManager的scope改成了prototype结果每个Service都创建了自己的线程池导致线程数暴涨。后来改回singleton才恢复正常。所以迁移时一定要确认Bean的scope默认用singleton除非有明确理由用其他scope。5.4 常见问题速查表问题现象可能原因排查方法解决方案启动时报BeanCurrentlyInCreationException循环依赖查看异常堆栈中的Bean名称提取新类打破循环或使用Lazy注入的Bean为null类没有被Spring扫描到检查是否有Component/Service注解添加注解或调整包扫描路径测试时依赖无法注入测试类没有使用Spring上下文检查是否加了SpringBootTest使用MockBean或手动new对象静态方法中调用Bean报NPE静态上下文没有初始化检查SpringContextHolder是否配置实现ApplicationContextAware接口多线程下状态不一致Bean scope配置错误检查Scope注解改回singleton或使用ThreadLocal避坑技巧在迁移期间建议开启Spring的debug日志把Bean的创建和注入过程打印出来。这样当出现问题时你能清楚地看到是哪个Bean的创建失败了。日志配置logging.level.org.springframework.beansDEBUG。6. 迁移后的实际收益与遗留问题6.1 可测试性的提升最明显迁移完成后最直观的变化是单元测试好写了。以前测试OrderService需要先初始化ConfigManager、CacheManager等一堆单例现在只需要在测试类里mock这两个依赖就行。ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private ConfigManager configManager; Mock private CacheManager cacheManager; InjectMocks private OrderService orderService; Test void testProcessOrder() { when(configManager.get(cache.prefix)).thenReturn(order:); orderService.processOrder(new Order(123)); verify(cacheManager).put(order:123, any()); } }这段测试代码不依赖Spring容器运行速度极快。以前跑一个集成测试要十几秒现在单元测试只要几十毫秒。测试覆盖率从原来的45%提升到了78%因为写测试的成本降低了。6.2 依赖关系可视化改成依赖注入后每个类的依赖都写在构造函数里IDE可以自动生成依赖图。我用IntelliJ的Diagrams功能生成了整个项目的Bean依赖图一眼就能看出哪些模块耦合过重。比如发现OrderService依赖了十二个其他Bean这说明它承担了太多职责后来我把它拆分成了OrderCreateService、OrderQueryService、OrderCancelService三个类。6.3 遗留的静态工具类项目里还有大约八个纯静态工具类没有迁移比如StringUtils、JsonUtils、DateUtils。这些类没有状态方法都是纯函数保留静态调用是合理的。但我给它们加了一个约束不允许在静态工具类里调用Spring Bean。如果某个工具方法需要依赖外部资源那就应该改造成Bean。6.4 启动速度的变化迁移后Spring启动时间从原来的8秒增加到了11秒因为多了Bean的创建和依赖注入过程。但这个代价是可以接受的因为启动只发生在部署时而运行时的可维护性提升是持续的。如果你对启动速度有极致要求可以考虑使用Spring的懒加载配置把不常用的Bean标记为Lazy。7. 一些个人体会和后续扩展方向整个迁移过程持续了大约三周改了六万行代码里的四千多行。中间踩过循环依赖的坑也遇到过测试环境Bean找不到的问题但整体收益远大于成本。现在新来的同事看代码从构造函数就能知道一个类依赖了什么不需要去翻getInstance的调用链。如果你也在考虑做类似的迁移我的建议是先从最底层的单例开始改每次只改一个改完立刻跑测试。不要追求一次性完美允许过渡期的存在。另外迁移之前一定要和团队沟通好确保所有人都理解为什么要做这件事否则你改了一半别人又在新代码里写了新的单例那就白费功夫了。后续我打算把项目里的静态配置读取也改成依赖注入的方式目前还有一部分代码直接读System.getProperty这部分和单例的问题类似都是隐式依赖。另外我在研究如何用Spring的ConfigurationProperties来替代手写的ConfigManager让配置管理更类型安全。如果你对这部分也感兴趣可以自己先试试踩坑了欢迎交流。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。