1.问题背景
界面歌曲列表需要添加 收藏icon。后端需要返回对应歌曲的用户收藏信息。
-
allmusic-api-server/service/favoriatesongservice包下
依赖函数:commonservice.GetRelatedInfos() 用于查询歌曲相关信息与本次需求无关。
-
allmusic-api-server/service/commonservice包下
我们有接口:PlayHistoryV2,用于查询用户的历史播放记录。
需求:取播放历史歌曲后,我们需要根据userid来对歌曲于user的收藏信息返回对应参数。
依赖函数:favoriatesongservice.IsFavoriteAudioSongIds() 用于查询user和songIds的收藏关系
2.问题表现
依赖链路:
package allmusic-api-server/tests
imports allmusic-api-server/route
imports allmusic-api-server/route/api/v1
imports allmusic-api-server/route/api/v1/callback
imports allmusic-api-server/controller/listenbooks
imports allmusic-api-server/service/listenbooks
imports allmusic-api-server/service/commonservice
imports allmusic-api-server/service/favoriatesongservice
imports allmusic-api-server/service/commonservice: import cycle not allowed
发现allmusic-api-server/service/favoriatesongservice和allmusic-api-server/service/commonservice循环引用。
问题1:本次需求目前代码导致的循环引用,为两个包之间的循环引用。
问题2:解决第一个循环引用后,发现的另一处循环引用,为三个包之间的循环引用。
3.问题分析
3.1什么是循环引用?
循环引用是指两个或多个模块(包、类、函数等)相互依赖,形成一个闭环。这种情况会导致编译器或运行时无法确定模块的初始化顺序,从而引发错误或异常。循环引用在软件开发中是一个常见的问题,尤其是在大型项目中,模块之间的依赖关系复杂时更容易出现。
假设有两个模块 A 和 B:
- 模块 A 依赖于模块 B。
- 模块 B 依赖于模块 A。
这种相互依赖的关系就形成了一个循环引用。更复杂的情况可能涉及多个模块,例如:
- 模块 A 依赖于模块 B。
- 模块 B 依赖于模块 C。
- 模块 C 依赖于模块 A。
这种情况下,A、B、C 三个模块之间形成了一个循环引用。
3.2循环引用的影响?
- 编译错误和初始化问题
- 编译错误:在编译时,编译器需要确定模块的初始化顺序。如果存在循环引用,编译器无法确定哪个模块应该先初始化,从而导致编译失败。
- 初始化顺序不确定:即使编译器能够处理循环引用,初始化顺序的不确定性可能导致某些模块在使用时尚未初始化,进而引发运行时错误。
- 运行时错误
- 未初始化的依赖:在运行时,如果某个模块依赖的另一个模块尚未初始化,可能会导致程序崩溃或产生不可预期的行为。
- 内存泄漏:循环引用可能导致内存泄漏,特别是在垃圾回收机制无法正确处理循环引用的情况下。例如,在某些编程语言中,循环引用的对象可能永远不会被回收,从而导致内存泄漏。
- 测试困难
- 单元测试复杂化:循环引用使得单元测试变得更加复杂,因为测试某个模块时需要考虑其所有依赖模块。这增加了编写和维护测试用例的难度。
- 集成测试复杂化:在集成测试中,循环引用可能导致测试环境的初始化顺序问题,从而影响测试结果的可靠性。
- 设计缺陷
- 违反单一职责原则:循环引用通常是设计不良的标志,表明模块之间的职责划分不清晰。模块之间的紧密耦合违反了单一职责原则,使得代码难以扩展和重构。
- 难以扩展:由于模块之间的紧密耦合,添加新功能或修改现有功能时,需要考虑多个模块的相互影响,增加了开发难度。
示例分析
假设有以下模块依赖关系:
A -> B -> C -> A
在这个依赖链中,模块 A、B 和 C 之间形成了一个循环引用。以下是可能的危害:
- 编译错误:编译器无法确定 A、B 和 C 的初始化顺序,导致编译失败。
- 运行时错误:即使编译通过,运行时可能因为模块未初始化而导致程序崩溃。
3.3为什么会出现循环引用?
大概率是代码写的不好/结构设计不好。
研究项目的过程中,我发现项目的层级不仅仅是简单的拆分为controller、service、dao。
其中service层也可以继续拆分。
例如在我们的项目中,有一commonService包。这个包只依赖于dao层,为service层的其他服务提供服务。其实就相当于又多了一层。
而我们的包依赖。在这种更加详细的分层下减少了循环引用的风险。
我们出现问题的原因就是因为我们新加入的服务逻辑的代码侵入到了commonService这一层中,导致下层对上层产生了依赖。
这样会很大的增加耦合。
常见的原因:
-
复杂的依赖关系
当项目变得复杂时,不同模块之间可能需要共享功能或数据,导致它们相互依赖。例如,模块A需要调用模块B的功能,而模块B也需要调用模块A的功能。这种相互调用就会形成循环引用。
-
不良的代码设计
如果在设计阶段没有明确模块的职责和边界,容易导致模块之间的相互依赖。例如模块A和模块B都在处理相似的工作,就有可能过度耦合,造成循环依赖。
-
缺乏抽象
没有通过接口或抽象类来隔离模块之间的依赖,直接依赖具体实现。例如,模块A直接依赖模块B的具体实现,而不是依赖一个抽象接口。应当提取接口/依赖注入的方式来进行抽象
3.4循环引用的解决方案?
从“下层引用了上层”这个情况我们可以进一步优化为 “只有上层引用下层”。
具体的做法是重构代码,把依赖的代码提取到上层的包中。
根据[[设计模式]]
依赖倒置原则
1、上层模块不应该依赖底层模块,它们都应该依赖于抽象。
2、抽象不应该依赖于细节,细节应该依赖于抽象。面向接口编程,依赖于抽象而不依赖于具体。写代码时用到具体类时,不与具体类交互,而与具体类的上层接口交互。
我们可以进一步的进行优化,把依赖的方法都写成接口进行抽象。达到“上层依赖于抽象,细节依赖于抽象”,进一步的减少耦合,我们不需要关注底层的代码实现,只需要关注接口的函数签名表示。
解决循环应用的常用方法如下。
1. 重构代码结构
拆分包:将相互依赖的代码拆分到不同的包中,确保每个包只负责单一的功能或逻辑。通过重新组织代码,可以减少包之间的直接依赖。例如,如果包 A 和包 B 相互依赖,可以考虑将它们的公共逻辑提取到一个新的包 C 中,然后让包 A 和包 B 依赖包 C。
2. 使用接口
定义接口:在一个包中定义接口,在另一个包中实现接口。这样可以通过接口来解耦包之间的依赖关系。例如,包 A 定义一个接口,包 B 实现这个接口,包 A 只依赖接口而不是具体实现。这样,包 A 和包 B 之间的直接依赖关系被打破。
3. 依赖注入
依赖注入:通过构造函数或初始化函数将依赖注入,而不是直接在包中引用。依赖注入可以在运行时动态地提供依赖,从而避免编译时的循环依赖。例如,包 A 和包 B 都需要对方的服务,可以通过构造函数将对方的服务注入,而不是在包内部直接引用对方的服务。
4. 使用全局变量或单例模式
全局变量或单例模式:在某些情况下,可以使用全局变量或单例模式来管理共享的依赖。通过集中管理依赖,可以避免包之间的循环引用。例如,包 A 和包 B 都需要一个共享的服务,可以将这个服务定义为全局变量或单例,然后在包 A 和包 B 中引用这个全局变量或单例。
5. 延迟依赖
延迟依赖:通过延迟初始化或懒加载的方式,避免在包加载时立即引入依赖。延迟依赖可以在需要时才初始化依赖,从而避免循环依赖。例如,包 A 需要包 B 的服务,但包 B 也需要包 A 的服务,可以在包 A 中延迟初始化包 B 的服务,直到真正需要时才进行初始化。
6. 结合使用接口和依赖注入
接口和依赖注入结合:通过接口定义依赖关系,并使用依赖注入来提供具体实现。这种方法结合了接口和依赖注入的优点,可以更灵活地管理依赖关系,避免循环依赖。例如,包 A 定义一个接口,包 B 实现这个接口,然后通过依赖注入将包 B 的实现注入到包 A 中使用。
4.解决方案
目前对项目的代码还不是特别的熟悉,项目中的大多数接口并没有采用依赖抽象的方式,这里就直接重构了一下代码,把相关的代码提取到了上层的包中。 保证了commonService包只依赖于dao层。
问题1:
问题2: