ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

软件模块依赖关系全解析:从失控到可控的架构治理实践

2026/10/1 12:15:32 拓冰建站 浏览量
软件模块依赖关系全解析:从失控到可控的架构治理实践 上个月帮朋友排查一个老系统的问题一个在底层公共模块里躺了五年的工具类只是加了一个字段结果当天下午就有三个业务服务接连告警线上接口超时、启动直接报错折腾到凌晨才勉强恢复。群里瞬间炸锅负责的同事第一反应是“谁动了我的代码”翻了一圈才发现问题根本不出在“谁改了代码”而在于整个系统从没人真正搞清楚过——模块之间到底谁依赖谁、依赖到什么程度、改动一个模块会波及多少个下游。这个场景几乎每个做后端开发的人都经历过。日常写代码的时候模块间依赖关系就像房间里的空气平时看不见摸不着一旦出问题就是牵一发动全身的灾难。跑完构建的时候没人担心IoC容器里哪个Bean循环引用了发版的时候也没人会去数一个配置项被多少个模块读取但只要架构腐化到一定程度每一次改动都像在雷区里散步。所以这篇东西我想把“软件的模块间依赖关系”这件事掰开揉碎地聊一聊。不讲虚的架构理论就说清楚依赖关系到底有哪几种形态、它们从设计到落地是怎么一步步影响你的代码库的、以及我们在实际项目中怎么梳理、控制、可视化这些依赖最后把循环依赖和版本冲突这类常年踩坑的问题一次性讲透。无论你是刚入行的CRUD选手还是已经在带团队做架构决策的负责人这篇都值得花十分钟看完。1. 依赖关系的本质模块间的“契约”与“耦合”1.1 从一次线上事故看依赖失控的代价刚才提到的那个故障其实根因特别简单。公共模块里有个UserUtils类其中一个静态方法内部调用了另一个模块的配置类而那个配置类又被初始化逻辑提前加载结果我朋友那个系统里因为新增字段导致配置加载顺序变化间接引发了一大串Bean初始化失败。你单独看任何一环代码写得都没问题但把依赖链条拉出来你会发现一条从UserUtils到配置中心、再到数据库连接池、再到某个定时任务的隐式调用链没有任何一个文档记录过它也没有任何一层架构约束拦截过它。这就是依赖失控的典型表现。模块间依赖一旦混乱最直接的代价有三个编译通过但运行期行为不可预期、代码复用变成“连带枪毙”、团队协作时改动成本指数上升。你可能觉得“加个字段”不至于但真实生产环境里一个简单的改动引发雪崩的案例比比皆是而且越是老系统、越是复杂的依赖网越容易出现这种问题。1.2 为什么说依赖关系是软件的“隐性地基”你可以把模块间依赖关系理解成建筑物的承重结构。墙体和装修可以随时换但承重墙不能随便拆拆了整栋楼都会出问题。软件也一样业务代码是实现功能的“装修”而模块之间的依赖关系就是“承重墙”——它决定了系统的可维护性、可测试性、可扩展性。依赖关系直接决定了改动的影响范围。A模块依赖B模块那么B模块的任何一个公开接口变化都需要评估A模块是否需要跟着改如果B模块底层又依赖C模块那么C的变化会沿着依赖链向上传导。依赖链越长传导范围越广出问题的概率越高。这也是为什么经验丰富的架构师会严格控制模块间的依赖方向——往一个方向依赖让依赖链保持清晰本质上是在控制风险的传播路径。1.3 谁最需要关注依赖关系我见过不少开发者的误区觉得“依赖管理”是架构师的事跟写业务的没关系。但实际上只要你写代码你就在制造依赖——引入一个工具包、调用另一个类的静态方法、实现一个接口、订阅一个事件全部都是依赖关系。真正需要关注意识的恰恰是一线开发者和技术负责人一线开发者你每次import、每次new、每次Autowired都是在给系统添砖加瓦方向对不对直接影响未来维护成本。技术负责人模块边界怎么划分、依赖方向怎么控制、怎么在CI里卡住不合规的依赖这些是持续要做的事。架构师这是基本盘不用多说。2. 模块间依赖关系的类型与影响范围拆解2.1 编译期依赖与运行期依赖区分“连得上”和“跑得动”很多人一谈到依赖首先想到的是Maven的dependency或者npm的package.json但实际上一段代码里可能同时存在两种性质完全不同的依赖编译期依赖和运行期依赖。编译期依赖是指代码编译时必须存在的依赖比如你直接import了另一个模块的类编译器需要找到这个类才能编译通过。运行期依赖则是指代码运行时才需要的依赖比如通过反射加载的类、通过配置指定的实现类、通过SPI机制发现的组件——编译时它们甚至不需要出现在classpath里。这个区分非常重要。一个典型的坑是本地编译通过、测试环境也没问题一上生产就报ClassNotFoundException。原因往往是某段代码用反射或SPI方式引用了一个运行期才需要的模块而部署包没把这个模块打进去。反过来有些依赖是编译期需要但运行期完全用不到的如果把这类依赖打包发到生产不仅浪费资源还可能引入版本冲突。处理运行期依赖最关键的手段是依赖仲裁和包管理策略。以Java生态为例Maven和Gradle有完整的依赖作用域scope体系compile、provided、runtime、test分别对应不同的依赖生命周期正确设置scope能有效避免“编译期有运行期没有”的尴尬。我在实际项目里见过很多人图省事所有依赖一律默认compile结果就是部署包越来越臃肿模块间的隐式依赖越来越多架构腐化就是从这种不经意的“偷懒”开始的。2.2 直接依赖、间接依赖与传递依赖从调用关系上看依赖还分直接依赖和间接依赖。直接依赖就是你的代码里直接引用了某个模块的类或接口间接依赖则是你依赖的模块又依赖了别的模块你并不直接调用那些底层模块的代码但它们的运行结果会影响你的功能。间接依赖会带来一个著名的痛点传递依赖。在Maven/Gradle体系中你引入了模块AA又依赖了B和CB和C会自动进入你的工程这就是传递依赖。传递依赖是个双刃剑——它让依赖配置变得简洁但同时也让你在不知情的情况下引入了大量外部代码而这些代码的版本往往不受你控制。举个例子你引入了一个HTTP客户端库这个库内部依赖了某个JSON解析库的1.2版本而你的工程其他地方已经用了同一个JSON库的2.0版本。此时构建工具通常会自动选择版本更高的2.0Maven的“就近优先版本仲裁”策略结果HTTP客户端库调用了1.2版本才有的API直接运行期报错。这类问题排查起来极其痛苦因为错误信息往往指向的是第三方库内部而不是你的代码。2.3 循环依赖架构中最危险的“死结”循环依赖是指模块A依赖模块B而模块B又直接或间接依赖模块A。这是所有依赖关系中问题最严重的一种必须被当作红线对待。为什么说它危险因为循环依赖让模块之间的边界彻底失效。原本单向依赖意味着“被依赖方可以独立变更、独立测试、独立发布”一旦形成循环两个模块就变成了一个无法拆分的整体编译、测试、部署全都绑定在一起。更糟糕的是在支持延迟初始化的容器里循环依赖还容易引发初始化死锁或Bean创建失败Spring的构造器注入遇到循环依赖时直接报BeanCurrentlyInCreationException就是典型例子。循环依赖的形成往往不是一次写坏的而是渐进腐化的结果。最开始你可能只是想在一个工具类里调用一下配置类后来配置类为了某些逻辑又引用了工具类的方法等到发现的时候两个模块已经“你中有我、我中有你”了。我后面会专门讲循环依赖怎么识别、怎么解除这里先记住一个结论架构评审时看到循环依赖任何解释都是多余的第一时间拆掉才是唯一正确动作。2.4 可选依赖与动态依赖灵活背后的隐藏成本除了上述主流依赖类型还有两类容易被人忽略的依赖可选依赖和动态依赖。可选依赖是在运行期按需加载的依赖比如你的框架同时支持MySQL和PostgreSQL但具体用哪个由配置文件决定。这类依赖给用户带来了灵活性但代价是模块的行为不再唯一确定同一份代码在不同环境下可能表现出完全不同的能力边界。维护可选依赖的关键是把变更点收敛到配置层不要在业务代码里散落大量if (dbType mysql)之类的分支。动态依赖则是更隐蔽的运行时依赖典型场景是通过反射、代理、字节码增强等方式加载的类。Spring的EnableAsync、MyBatis的动态代理、JDK的SPI机制都属于动态依赖。动态依赖最大的问题是静态分析工具很难发现它们以至于你无法通过扫描代码来建立完整的依赖全景图。我见过一个项目架构图里画得干干净净结果线上环境加载了一个谁都不知道的扩展点实现类出问题时找半天都找不到谁下的订单。动态依赖必须靠文档和约定来管理否则就是架构上的定时炸弹。为了更直观地理解我把这几类依赖整理成了对比表依赖类型发生时机是否容易识别主要风险典型场景编译期依赖编译阶段比较容易部署包缺失、类找不到import、泛型、接口继承运行期依赖运行阶段较难ClassNotFoundException、版本冲突反射、SPI、动态加载直接依赖代码编写时容易改动影响面扩散调用具体类/接口方法间接依赖传递构建阶段自动引入难以察觉版本冲突、依赖膨胀Maven/Gradle传递依赖循环依赖设计/演进过程需要重点检查初始化失败、模块耦合固化双向调用、事件回环可选依赖运行期按需加载取决于配置管理行为不确定、测试不充分多数据库适配、多实现切换动态依赖运行期动态发现工具难发现全景图缺失、故障难定位SPI机制、反射代理3. 依赖设计与落地控制方向的4个关键手段3.1 依赖倒置让高层模块不再依赖底层细节讨论依赖设计绕不开SOLID原则里的依赖倒置原则DIP。它强调两个点高层模块不应该依赖低层模块二者都应该依赖抽象抽象不应该依赖细节细节应该依赖抽象。光看概念可能有点绕我举个例子。假设你有一个订单服务直接调用了一个MysqlOrderRepository类的查询方法。按照DIP你应该先定义一个OrderRepository接口让MysqlOrderRepository实现它订单服务只依赖接口。这样做的核心价值在于切断模块间直接的实现耦合——将来你要换成RedisOrderRepository或者你想在单元测试里用一个Mock实现订单服务代码一行都不用改。实际项目里依赖倒置最常见的落地手段是依赖注入DI。把“模块内部主动去创建/查找依赖”变成“由外部容器把依赖传递进来”也就是从“找依赖”变成“收依赖”。Spring的IoC容器是Java生态里最典型的例子——类不负责管理自己依赖的生命周期只通过构造器或Setter接收外部注入的对象。从依赖关系的角度看依赖注入带来的最大改变是依赖的装配点被集中管理了。你在applicationContext.xml或者Configuration配置类里能看到整个系统的依赖装配关系而不是在各个类的new语句里东拼西凑。这对梳理依赖链、控制系统启动顺序、替换实现类都有巨大帮助。3.2 防腐层与中间抽象隔离外部变化对核心业务的影响还有一种非常实用但经常被忽视的手段防腐层Anti-Corruption LayerACL。这个概念来自领域驱动设计DDD核心思路是在一个模块与外部系统或旧系统之间加一层翻译/隔离逻辑让外部系统的“脏乱差”不会渗透到你的核心领域里。举个真实场景。你的支付模块接入了微信支付、支付宝、银联三套SDK每个SDK的接口签名、回调报文都各不相同。如果你在业务代码里直接散落调这三套SDK那么任何一套SDK升级、接口调整都会波及你的核心下单流程。更麻烦的是三套SDK自身还依赖不同的HTTP库、JSON库这些传递依赖会在模块间形成混乱的依赖网络。解决办法就是做一层PaymentGateway接口把所有第三方支付SDK的调用统一封装在WechatPayAdapter、AlipayAdapter等适配器里。业务模块只依赖PaymentGateway接口不知道也不关心底层是哪个支付渠道。哪个SDK要升级影响范围被限制在适配器内部核心业务模块毫发无伤。防腐层的本质是主动引入一个“受控的间接层”用一次受控的复杂换掉全局的不可控。3.3 按业务边界划分模块而不是按技术分层很多团队在划分模块时习惯按技术分层来比如建一个controller模块、一个service模块、一个dao模块。这种划分方式在小项目里没毛病但项目一旦变大问题就开始出现了明明是同一个业务域的代码却被拆散在三个模块里业务层想要修改一个订单状态得同时改controller、service、dao三个模块模块间的依赖关系变得又碎又缺乏语义。我推荐的模块划分方式是按业务能力/领域边界划分。比如说一个电商系统可以划分成order、product、user、payment、inventory等模块每个模块内部包含自己的controller、service、dao、DTO。这样做的好处非常明显依赖关系有了明确的业务语义看到order依赖product就能猜到是“下单时需要读取商品信息”。每个模块的改动影响范围被限制在自身边界内跨模块的改动需要通过明确的接口协商。模块之间天然形成了“按领域划分的异步边界”为将来微服务拆分打下了基础。当然按业务域划分也不是一劳永逸的。跨领域调用不可避免比如order可能需要调用user的会员等级信息。这时候正确的做法是把跨模块调用收敛到模块对外暴露的接口上不要跨模块直接访问对方的内部类或数据库表。模块之间的数据访问只能通过对外接口这是保证模块独立演化的重要前提。3.4 依赖方向的控制依赖图必须是单向无环的关于模块间依赖关系架构上有一条黄金法则依赖图必须是单向无环的。也就是说如果你把模块画成节点、依赖画成箭头最终形成的应该是一个有向无环图DAG而不应该出现任何环。在实际项目中控制依赖方向通常靠两件事。第一是架构约束工具比如Java生态里的ArchUnit可以在测试阶段检查包依赖规则违反规则直接让测试失败。第二是代码评审规范在评审阶段就盯着依赖方向看到不合理的跨层依赖、反向依赖、循环依赖一律打回去重新设计。我见过不少团队在这上面栽跟头。他们觉得“架构约束工具太大动干戈”觉得“代码评审走个过场就行”结果就是依赖关系在不知不觉中腐化等到架构图上出现十几个互相交叉的箭头时再想清理已经要付出沉重的重构代价。依赖方向的治理必须是“事前约束事中拦截”不能指望事后补救。4. 工具实操用静态分析看清依赖全貌4.1 Maven/Gradle依赖树分析与版本仲裁要梳理模块间依赖关系第一步往往是借助构建工具看清楚“当前模块到底依赖了谁”。Maven的依赖树命令是最基础的工具# 查看整个工程的依赖树 mvn dependency:tree # 查看指定依赖的引入路径 mvn dependency:tree -Dincludescom.fasterxml.jackson.core:jackson-databind # 分析依赖冲突 mvn dependency:analyzedependency:analyze非常有用它可以找出使用了但未声明的依赖和声明了但未使用的依赖。前者是隐患——你本地编译能过是因为传递依赖里碰巧包含了这个库别人一旦调整了依赖版本你的代码就编不过了。后者是负担——没用的依赖占空间不说还容易让版本冲突的爆炸半径变大。Gradle对应的命令是gradle dependencies输出格式类似。我这边强烈建议在CI里跑一个dependency:analyze的检查把“使用了但未声明”的依赖直接当成构建失败来处理这个习惯能省掉无数个深夜排查ClassNotFoundException的烦恼。4.2 JDK自带jdeps分析Jar包层级的依赖如果你拿到一个第三方jar包想知道它依赖了哪些类、用了哪些JDK内部API可以使用JDK自带的jdeps工具# 分析jar包的依赖摘要 jdeps --print-module-deps my-service.jar # 详细输出类的依赖关系 jdeps --verbose:class my-service.jar # 递归分析所有依赖 jdeps --recursive my-service.jarjdeps在模块化改造和依赖冲突排查时特别好用。有一次我在排查一个NoClassDefFoundError用jdeps扫描了出问题的jar包发现它引用了sun.misc.Unsafe而目标JDK版本里这个类被移到了jdk.unsupported模块里通过add-modules或者换掉这个依赖才解决。这类问题光靠看代码很难发现工具扫一下立刻明明白白。4.3 ArchUnit把依赖规则变成可执行的测试在Java生态中ArchUnit是控制模块间依赖关系最得力的工具。它可以把架构规则写成单元测试每次构建时自动执行一旦有人违反了预先定义的依赖规则测试直接失败。一个简单的例子AnalyzeClasses(packages com.example) public class ArchitectureTest { Test void serviceLayerMustNotDependOnControllerLayer() { JavaClasses classes new ClassFileImporter().importPackages(com.example); ArchRule rule noClasses() .that().resideInAPackage(..service..) .should().dependOnClassesThat() .resideInAPackage(..controller..); rule.check(classes); } Test void noCycleBetweenModules() { JavaClasses classes new ClassFileImporter().importPackages(com.example); ArchRule rule slices().matching(com.example.(*)..).should().beFreeOfCycles(); rule.check(classes); } }看到那个beFreeOfCycles()了吗这就是把关卡设在CI里的关键操作。每次提交代码、跑测试系统都会自动检查所有模块之间是否存在循环依赖一旦发现就亮红灯。这种“代码即文档、测试即检查”的方式比任何架构文档都可靠——代码会骗人文档会过期只有CI上的测试永远在执行。4.4 从依赖矩阵到架构图可视化对于老项目而言当你面临一堆整理不清的依赖关系先别急着重构第一步应该是把依赖关系画出来。工具有很多选择Java生态Structure101、Sonargraph、IntelliJ IDEA自带的Dependency Analysis功能。通用工具ArchUnit结合jQAssistant或Neo4j可以生成依赖图并查询依赖路径。简单方案用dependency:tree导出文本再用脚本解析成Graphviz的dot文件渲染成架构图。当然工具生成的依赖图往往非常复杂初次跑出来可能会让你怀疑人生——几百个节点、上千条边密密麻麻全是线。这不代表你的项目没救了反而说明依赖治理确实到了刻不容缓的地步。我的经验是先聚焦在最核心的十几个模块上把大方向理清楚再逐步细化和治理不要指望一次把几千个依赖关系全部厘清那既不现实也没必要。5. 循环依赖的检测、修复与长期防控5.1 循环依赖是怎么一点一点“长”出来的虽然没有团队会主动设计循环依赖但它几乎会自然演化出来。常见的产生路径有三条第一业务功能耦合。比如下单时需要调用库存模块扣库存而库存模块在生成库存流水时又需要回查订单信息。两个功能天然存在交叉需求如果没人统一规划接口边界很快就写成互相调用了。第二公共类归属不清。比如一个DateUtils时间工具类和配置管理模块互相引用时间工具类里为了处理时区配置调了配置模块的接口配置模块里初始化时又用时间工具类做时间转换。这类因为“工具类归属混乱”导致的循环依赖在中小项目里特别普遍。第三事件/回调机制设计不当。A模块发送“订单已创建”事件B模块监听事件后回写订单状态如果A的代码里同步等待B的处理结果或者B的处理逻辑反向调用了A的接口就形成了隐式的循环依赖链路。5.2 检测方法从构建报错到静态扫描检测循环依赖有几个层次的手段最简单的层次是构建报错。Spring Boot在构造器注入遇到循环依赖时会直接报BeanCurrentlyInCreationException。Gradle和Maven本身不会因为循环依赖报错但是一些第三方插件比如com.github.ferstl:depgraph-maven-plugin可以生成依赖图并帮助分析循环。更强大的层次是静态架构扫描。前面提到的ArchUnit的beFreeOfCycles()规则以及JDepend这类工具都能自动检测包与包之间的循环依赖。特别是JDepend它会分析每一层包之间的依赖关系输出依赖环的详细路径方便你从哪个节点下手拆解。还有一个“土办法”在依赖关系相对混乱但工具链还没建立起来的时候很管用从核心模块开始沿着依赖箭头走一遍。如果从一个模块出发沿着依赖方向能走回原点那这个环就算当场逮住了。把每个环记录成一个待办事项优先级从环的直径大小排序——环越大牵涉模块越多影响范围越广优先处理。5.3 修复循环依赖的三种实操手段找到循环依赖以后真正的难题是安全地拆环。这里分享三种我在项目里用过的有效手段第一种重构提层。提炼一个更底层的公共模块把A和B互相依赖的那部分共用逻辑下沉到这个新模块里让A和B都依赖它不再互相依赖。比如刚才提到的DateUtils和配置模块的循环可以把时间格式化和时区配置相关的代码抽出一个TimeConfigurer配置模块负责提供时区源时间工具类只依赖配置接口循环就断了。第二种事件驱动解耦。把同步调用改成异步事件通知。A模块只负责发布事件不关心谁来消费B模块订阅事件处理自己的逻辑。适用于业务时序上不需要立即获取调用结果的场景比如“订单创建后发送通知”这类需求用事件机制天然避免了订单服务和通知服务的静态依赖。第三种中介者模式。引入一个“协调模块”来居中调解A和B的交互。A和B不再直接通信而是都跟中间协调模块交互。适用于A和B都无法完全解耦、但又确实需要交互数据的场景。缺点是会多出一个模块但比起循环依赖带来的隐患这点代价是值得的。任何拆环手段都要配合充分的测试。循环依赖往往伴随着隐式的初始化顺序拆环后最常出现的问题就是某个Bean创建时机变了。所以拆环后第一件事就是跑全量单元测试和集成测试确认启动顺序和运行行为没有异常。5.4 长期防控把“禁止循环”写成代码循环依赖的修复是治标长期防控才是治本。我在团队里推行的做法是把循环依赖检查直接嵌进CI流水线规则包括模块之间禁止循环依赖用ArchUnit扫描。核心业务模块不允许依赖外部不稳定的库用依赖规则扫描。每次PR合入前依赖变化必须通过架构测试。这么做最初会遇到阻力因为总会有“业务紧急下次再改”的声音。但只要坚持住两个月后你会看到明显变化模块边界清晰了联调整体变快线上出问题的概率也降下来了。架构治理就是这样——短期看不出收益但长期一定不会辜负你。6. 依赖管理中的常见问题与经验速查6.1 版本冲突同一个依赖多个版本引发的NoSuchMethodError这是Java生态里最常见、也最难查的问题之一。排查思路我建议这样走先看完整的报错堆栈找到抛NoSuchMethodError的类和方法。用mvn dependency:tree -DincludesgroupId:artifactId查一下这个依赖到底被引入了几次、每次是什么版本。找出版本冲突的源头再决定用dependencyManagement强制指定版本还是排除某个传递依赖。经验表明解决版本冲突的最佳时机是第一次发现的时候不要拖。因为依赖树是层层叠加的今天引入一个库可能带进来3个传递依赖半年后又会带进来更多越拖越难理清。6.2 启动失败依赖顺序导致的初始化错乱常见的表现是Spring容器启动时报BeanCurrentlyInCreationException。这个异常并不一定是编译期的循环引用更多是运行期Bean创建顺序与依赖关系不匹配。排查方向是看启动日志找到最先报错的Bean是在哪个模块、哪个配置里创建的然后往前倒推它依赖了哪些Bean。解决方案通常有两种一是调整Bean的作用域或延迟初始化把非必要的Bean改成Lazy二是消除隐式的初始化依赖把初始化逻辑移到ApplicationRunner或PostConstruct里统一管理。注意用Lazy解决问题是权宜之计它会掩盖依赖设计上的问题长期看还是要梳理清楚依赖关系。6.3 过度设计为了解耦而引入的不必要抽象聊依赖管理很多人容易走另一个极端为了追求“低耦合”每个类都搞一个接口每次调用都套一层代理结果模块间的调用链路变得极长代码读起来像俄罗斯套娃维护成本反而更高。抽象不是免费的。接口需要维护、适配层需要测试、动态分发带来调试困难这些都是成本。我提倡的原则是按需抽象如果一个依赖目前只有一个实现、预期几年内也不会出现第二个实现那就没必要为了“规范”硬抽接口。抽象的价值在于“有多个变更方向的时候”而不是为了架构图好看。6.4 依赖膨胀一个工具库拖入几十个传递依赖很多时候你只是想引入一个简单的HTTP工具结果Maven告诉你它带入了30多个传递依赖里面好几个还跟现有jar包版本冲突。这就是依赖膨胀。应对策略有两条。一是用Maven的exclusion机制排除掉不需要的传递依赖但这需要你清楚到底哪些传递依赖是真正需要的操作前先用dependency:tree看清全貌。二是从源头上选择“轻依赖”的库比如能用JDK自带的HttpClient时就不一定非要引入重量级的包。每引入一个依赖都是给系统的长期健康增加一份风险做决定时要像给自己的代码仓库加依赖一样审慎。7. 依赖关系治理一项需要持续投入的工程模块间依赖关系这件事说复杂也复杂说简单也简单。复杂的是它渗透在代码的每一个角落需要从设计、编码、测试、CI全链路去控制简单的是核心原则就那几条——依赖方向要清晰、依赖图要无环、跨模块要收敛、版本要可控。我在实际项目中摸索出来的最佳路径是先让工具的扫描结果成为团队的技术债清单再按优先级逐项清理最后把架构规则固化到CI里。这个过程通常需要一两个迭代的持续投入但值得。因为模块间依赖关系一旦健康整个系统的可维护性、可测试性、可扩展性都会上一个台阶后续开发效率的提升远超当初的治理成本。我也见过一些团队过于激进一口气把所有技术债全部列进重构计划结果业务迭代被拖垮反而不如那些一步一个脚印、稳扎稳打的团队。所以最后我的建议是别指望一次搞定但一定要开始。从你项目里最核心的三五个模块开始梳理依赖关系画出来循环依赖拆掉版本冲突理清剩下的交给时间。每维护好一层依赖关系你都是在为未来的自己和团队减少一次深夜救火的可能——这件事长期看稳赚不赔。