共用代码治理:从重复代码到公共模块的完整落地指南
2026/9/8 5:02:43 网站建设 项目流程

共用代码处理听起来不像一个很难的技术话题,但真正做过多人协作项目的开发者都知道,它往往是代码腐化最快的地方。同一个用户校验逻辑在三个项目里各写一遍,同一个日期格式化工具类在不同仓库里出现五六个变体,同一个状态枚举在服务端和客户端各维护一份。这些问题发生时,团队很少意识到根因是共用代码的边界和机制没有设计清楚。这一篇作为专题的第一期,会把共用代码处理这件事从概念到落地完整过一遍,以 Java/Maven 生态为主要示例,但思路同样适用于 Python、Go、前端工程等其他技术栈。

这篇内容适合几类读者:刚接手多项目代码库的后端开发,准备抽取公共组件的团队负责人,以及被重复代码折磨过、想系统理清处理思路的人。读完并动手跟做一遍后,你应该能够:说清楚共用代码的三种边界;知道什么时候该抽公共模块、什么时候不该抽;独立完成一次从重复代码到公共模块的抽取落地;理解版本管理、依赖冲突、测试和文档等后续治理动作。

1. 先用真实场景想清楚:共用代码到底指什么

1.1 共用代码的常见形态

在讨论怎么处理之前,先统一一下“共用代码”指什么。实际项目里至少包含这几类:

  • 工具类代码:日期格式化、字符串判空、加密签名、文件读写、JSON 解析的二次封装。
  • 常量与枚举:状态码、错误码、业务类型、配置项名称、Redis key 前缀。
  • 通用基础设施组件:统一异常处理器、日志切面、接口响应包装类、分页参数对象。
  • 跨模块业务逻辑:用户登录态校验、权限判断、幂等校验、金额计算规则。
  • 数据契约:DTO、VO、数据库表的实体映射定义。

这些代码有一个共同特点:会被两个或两个以上的模块、项目甚至团队反复引用。只要被多个地方引用,它就从“某个功能的内部实现”变成了“公共资产”,处理方式也随之改变。

1.2 复制粘贴为什么总是长痛

很多团队最初处理共用代码的方式就是复制粘贴。短期看起来快,但问题会随时间累积:

  • 修 Bug 无法一次改完。同一个校验逻辑在 A 项目修好了,B 项目还在跑有问题的旧版本。
  • 行为漂移。复制出去的代码经过各自项目“顺手”修改,三个月后同一个名字的方法在不同项目里返回结果已经不一样。
  • 排查成本高。线上出问题时,开发者需要先确认当前项目跑的是哪一份代码、从哪里复制来的、改过没有,这个确认过程非常耗时。
  • 新人难以判断。新成员看代码时不知道该改工具类还是该改业务代码,也不知道改完会不会影响其他项目。

复制粘贴不是完全不能碰,而是要明确边界。下面说明什么时候用复制可以接受,什么时候必须抽取。

1.3 什么时候复制,什么时候抽取

判断标准可以套用一条工程界熟悉的“三次法则”:

  • 同一段代码第一次重复出现时,先不要急着抽公共模块。此时对业务的理解可能还不够,强行抽象容易抽出错误边界。
  • 第二次重复出现时,开始观察两处实现之间的差异,评估抽取的可能性和成本。
  • 第三次重复出现时,基本可以确定这是一段需要共享的代码,此时进行抽取。

这个法则的前提是:重复代码已经稳定,短期内不会大改。如果业务本身还在频繁变化,抽公共模块的时机要往后推,否则公共模块会一直跟着业务改,调用方也要频繁升级。

共用代码处理的本质不是“把所有重复都消灭”,而是“把重复的代码放到一个可维护、可变更、可追溯的机制里管理”。复制粘贴是完全没有机制,公共库是多了一层机制,公共服务则是更强的机制。选择哪一种,取决于共用范围有多广、调用方有多依赖、变更频率有多高。

2. 动手之前,先分清共用代码的三个边界

2.1 单项目内模块间共用:控制依赖方向

第一个边界发生在同一个代码仓库里,通常是多模块项目之间。处理相对简单,核心是控制依赖方向。

一个常见的 Maven 多模块项目结构:

project-root/ ├── pom.xml ├── common/ │ ├── src/main/java/com/example/common/ │ │ ├── util/ │ │ ├── constant/ │ │ └── result/ │ └── pom.xml ├── user-service/ └── order-service/

在这里,common 模块被 user-service 和 order-service 依赖。需要注意三条约定:

  • 保持单向依赖。业务模块依赖 common,common 不能反向依赖业务模块。
  • common 里只放无业务状态的工具类、常量、通用结果包装。
  • 如果一段代码需要访问数据库、Redis、外部接口,它通常不应该放在 common 里,而应放在具体业务模块中。

单项目内的共用代码最容易出的问题,是开发者图省事把数据库操作、远程调用、业务判断都塞进 common,最后 common 变成“垃圾场”,谁都不敢随便改。

2.2 跨项目共用:独立仓库与制品管理

第二个边界发生在多个独立项目之间。此时共用代码不能再靠复制粘贴维护,需要抽取成独立组件,并通过制品库管理。

在 Java 生态里,对应做法是:

  • 创建独立组件仓库,例如 example-common。
  • 使用 Maven 构建,发布到内部 Nexus 或 Artifactory。
  • 业务项目在 pom.xml 中通过 groupId、artifactId、version 引入。
<dependency> <groupId>com.example</groupId> <artifactId>example-common</artifactId> <version>1.2.0</version> </dependency>

引入独立组件意味着要承担版本管理的成本,包括发布、升级、回滚、兼容性评估。这比复制粘贴“重”,但它是解决跨项目共用问题的可持续路径。

2.3 跨团队跨系统共用:优先共享契约而非实现

第三个边界是跨团队、跨系统的共用。此时要特别克制。两个团队之间共享的代码越多,发布协同成本越高。

优先共享的是稳定的“契约”,而不是频繁变化的“实现”:

  • 接口协议,例如 REST 接口的请求响应结构。
  • 数据模型,例如通过 protobuf 或 JSON Schema 定义的消息结构。
  • 定义良好的枚举和错误码。

至于工具类、内部实现,不建议跨团队直接依赖。跨团队共用一个 jar 包意味着任何一方的版本升级都要同步协调,团队之间会失去发布自由。除非组织有足够强的平台治理能力和统一发布节奏,否则更推荐通过接口、消息、配置中心这类弱耦合方式共用能力。

三种边界的处理方式可以用一张表收敛:

共用范围推荐方式需要建立的机制主要风险
单项目内模块间Maven/Gradle 模块依赖依赖方向规范、包结构约定common 膨胀、循环依赖
多项目间独立组件仓库 + 制品库版本管理、发版流程、变更说明版本失控、升级滞后
跨团队系统间契约共享、接口或消息协同协议版本管理、兼容策略耦合过重、发布协调成本高

3. 完整走一遍:把重复的用户校验逻辑抽成公共组件

这一节用一个可运行的示例说明共用代码处理的完整流程。以 Java + Maven 为例,场景是三个业务项目里各自维护了一段“用户状态校验”逻辑。

3.1 先看问题代码长什么样

三个项目里都写了一段结构类似的代码,只是细节不同:

public class UserValidator { public boolean canAccess(Long userId, String operation) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException("用户不存在"); } if (user.getStatus() == 1) { throw new BusinessException("用户已被禁用"); } if ("order".equals(operation) && user.getLevel() < 2) { throw new BusinessException("权限不足"); } return true; } }

这段代码的问题在于:

  • userMapper、BusinessException 来自各自项目的不同依赖,导致三份代码无法直接合并。
  • 核心的“用户状态判断”和“业务权限判断”耦合在一起。
  • 各项目对“禁用状态”的定义可能不同,有的用 status==1,有的用 status==2。

抽取时不能把整个类搬进公共模块,而是要把“稳定的公共逻辑”和“各项目自己的业务逻辑”分开。

3.2 抽取公共模块,而不是照搬整个类

第一步,在独立组件仓库 example-common 中建立公共模块,只放不依赖具体 ORM 和业务框架的代码。

推荐的包结构:

com.example.common.user/ ├── UserStatus.java ├── UserStatusValidator.java └── exception/ └── UserStatusException.java

UserStatus 用于统一用户状态枚举:

package com.example.common.user; public enum UserStatus { ACTIVE(0, "正常"), DISABLED(1, "禁用"), UNKNOWN(-1, "未知"); private final int code; private final String desc; UserStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static UserStatus fromCode(Integer code) { if (code == null) { return UNKNOWN; } for (UserStatus status : values()) { if (status.code == code) { return status; } } return UNKNOWN; } }

UserStatusValidator 只负责状态判断,不直接操作数据库,数据由调用方传入:

package com.example.common.user; import com.example.common.user.exception.UserStatusException; public class UserStatusValidator { public void validate(UserStatus status) { if (status == null || status == UserStatus.UNKNOWN) { throw new UserStatusException("用户状态未知"); } if (status == UserStatus.DISABLED) { throw new UserStatusException("用户已被禁用"); } } }

这里的关键设计是:公共模块不依赖 userMapper,调用方负责把 user.status 转为 UserStatus 枚举后再传给校验器。这样公共模块就与具体项目的数据访问层解耦了。

3.3 业务项目内做适配,把公共组件接进来

每个业务项目需要做一层很薄的适配,把本项目的用户实体翻译成公共枚举:

@Service public class UserAccessService { private final UserMapper userMapper; private final UserStatusValidator statusValidator; public UserAccessService(UserMapper userMapper, UserStatusValidator statusValidator) { this.userMapper = userMapper; this.statusValidator = statusValidator; } public boolean canAccess(Long userId, String operation) { User user = userMapper.selectById(userId); if (user == null) { throw new BusinessException("用户不存在"); } statusValidator.validate(UserStatus.fromCode(user.getStatus())); // 业务项目自身的权限判断仍然保留在本地 if ("order".equals(operation) && user.getLevel() < 2) { throw new BusinessException("权限不足"); } return true; } }

这样处理后,状态判断的规则收敛到公共组件,业务项目保留与自身业务强相关的权限判断。以后状态规则变更,只需要改公共组件并升级版本,不需要每个项目复制一遍。

3.4 落地时一定要做好的三个适配点

第一,状态码映射不是一次性完成的。旧项目可能有脏数据,例如 status 存了 99。枚举的 fromCode 方法应该返回 UNKNOWN 而不是直接抛异常,否则历史数据会导致大面积报错。

第二,公共组件的异常类型与业务项目的异常体系不一致。示例中 UserStatusException 是公共组件自定义的运行时异常,业务项目要在全局异常处理器里为它增加一个映射,否则用户会看到 500 而不是业务提示。

第三,不要为了“复用”而强行统一。如果两个项目的状态判断规则本质上不同,例如一个把 status=2 视为正常,另一个把 status=2 视为禁用,那说明这不是同一段逻辑,不应该抽到一个组件里。此时需要先统一状态语义,再谈抽取。

4. 共用代码最容易被忽略的是版本与兼容性治理

代码抽出来只是第一步,真正决定共用代码质量的是它被发布、升级、回滚的过程。

4.1 用语义化版本号表达兼容性变化

公共组件必须有明确版本号,并遵循语义化版本规则:

版本段位何时递增示例
MAJOR不兼容的接口变更删除方法、变更方法签名、修改异常类型
MINOR向后兼容的新功能新增枚举值、新增方法、新增配置项
PATCH向后兼容的缺陷修复修复某个边界条件、优化实现逻辑

注意两个容易混淆的点:

  • 新增枚举值虽然是向后兼容的,但如果调用方使用 switch 并带 default 分支,default 行为可能被影响。所以 MINOR 升级也要提醒调用方关注行为变化。
  • 修改方法内部实现,只要入参出参不变,属于 PATCH,但要格外注意性能和行为语义的变化。修复一个 Bug 可能改变某个调用方的预期结果。

4.2 发版、回滚与升级节奏

公共组件发版需要有比业务项目更严格的流程:

  1. 修改代码,并补充或调整测试。
  2. 本地和 CI 环境跑完整测试,确认不影响已有调用方。
  3. 更新 CHANGELOG,记录变更点、影响范围、升级建议。
  4. 发布到制品库,打上版本号。
  5. 通知调用方,但不强制统一升级。业务项目按自己的迭代节奏引入新版本。

回滚时,由于制品库保留历史版本,调用方只需要把 pom 里的 version 改回旧版本并重新构建即可。前提是公共组件不做跨版本的数据结构变更,这要求所有变更都尽量保持兼容。

4.3 依赖冲突怎么定位

公共组件越来越多时,会出现典型的依赖冲突。常见现象:

  • 启动时 NoSuchMethodError、NoClassDefFoundError。
  • 运行时行为与本地测试不一致。
  • Maven 构建时提示依赖冲突警告。

检查命令,在项目根目录执行:

mvn dependency:tree -Dverbose

或者只看某个依赖的来源:

mvn dependency:tree -Dincludes=com.example:example-common

如果发现同一个类来自多个版本,优先使用 dependencyManagement 统一版本:

<dependencyManagement> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>example-common</artifactId> <version>${example-common.version}</version> </dependency> </dependencies> </dependencyManagement>

排查顺序建议:先看运行时异常栈,确认是哪个类和哪个方法;再用 dependency:tree 找到这个类来自哪些依赖;再确认是传递依赖版本冲突,还是公共组件内部引用了错误的第三方库版本;最后决定升级公共组件版本还是排除传递依赖。

注意:不要一看到 NoSuchMethodError 就猜测是代码写错。优先执行依赖树命令,确认类加载路径,再动手修改。

5. 测试、文档与迭代节奏决定共用代码能走多远

5.1 公共模块的测试重点放在边界条件

公共模块被多个项目依赖,因此它的测试要在边界条件上下足功夫。以 UserStatus 为例,至少覆盖这些场景:

测试场景输入预期结果
正常状态0ACTIVE
禁用状态1DISABLED
null 输入nullUNKNOWN,不抛异常
未知数字99UNKNOWN,不抛异常
负数-1UNKNOWN

这类测试看起来简单,但它保护的是所有调用方。去掉任何一个分支都可能在某一个项目里引发线上故障。

5.2 文档写使用场景,不写重复的注释

公共模块的文档要做到以下几点:

  • README 写清楚这个模块解决什么问题、什么情况下用、什么情况下不要用。
  • 每个公共方法用 Javadoc 写清入参、出参、异常、使用示例。
  • CHANGELOG 按版本记录变更,标注是否兼容。

不需要写的是“这个方法用于校验用户状态”这种跟代码一样重复的注释。注释应该解释“为什么 status==1 表示禁用”,而不是复述代码逻辑。

5.3 迭代节奏要可预期

公共组件不适合频繁大改。推荐节奏:

  • PATCH 版本可以相对频繁,例如修复明确 Bug。
  • MINOR 版本按需发布,但每一次都要提醒调用方检查行为变化。
  • MAJOR 版本要提前冻结变更清单,给出迁移方案和过渡期。在过渡期内,旧版本继续保留,调用方逐步迁移。

一个容易犯的错是“公共组件跟着最早提出需求的项目走”。如果公共组件的变更只为了服务一个调用方,而其他调用方被强制升级,这就是典型的边界失控。正确做法是把公共组件当作一个独立的“产品”来维护,而不是某个项目的附属代码。

6. 共用代码处理最容易踩的坑与排查清单

6.1 三个高频坑位

坑位一:公共模块把业务依赖带进来了。

现象:业务项目引入 example-common 后,报出 ClassNotFoundException,或者项目里多了一堆不需要的依赖。

原因:公共模块的 pom 没有区分 compile 和 provided,把数据库驱动、Redis 客户端、Spring 全家桶都声明成了 compile 依赖。

解决:公共模块里尽量只依赖 JDK 和必要的 API。如果必须依赖 Spring,应使用 provided 或 optional 范围,避免把依赖传染给所有调用方。

坑位二:公共方法静默吞异常。

现象:某个调用方一直拿到 null 或 false,定位很久才发现公共方法里 catch 住所有异常并返回默认值。

原因:公共方法为了“方便调用方”,把异常处理掉了。

解决:公共方法对外不要吞异常。不确定怎么处理的异常应该抛出,由调用方决定如何处理。吞异常会让调用方完全失去排查线索。

坑位三:没有版本概念,调用方直接依赖最新代码。

现象:业务项目引入公共模块时使用 SNAPSHOT 或 LATEST,每次构建结果都可能不同,线上和本地代码不一致。

原因:公共模块发布机制缺失,开发者图省事直接依赖动态版本。

解决:业务项目固定使用 release 版本,版本号写入 pom。公共模块每次变更都走发布流程,生成不可变版本。

6.2 公共模块上线前检查清单

无论你是抽取新的公共组件,还是给别人维护的公共组件升级,发布前都建议过一遍这个清单:

检查项检查方式通过标准
依赖范围查看 pom.xml第三方框架用 provided/optional,不传染调用方
包结构检查包名按业务域分包,不把所有类塞进 util
版本号对比上次发布按语义化版本规则递增
兼容性阅读变更 diff删除、重命名必须走 MAJOR 版本
测试运行模块全部单元测试边界条件均有覆盖,全部通过
文档查看 README 和 CHANGELOG变更点、升级建议已记录
异常处理检查公共方法不吞异常,异常类型明确
回滚方案确认制品库历史版本旧版本仍然可用,调用方能回滚

检查清单不是走形式。共用代码一旦发布,影响面是所有调用方。投入在检查上的时间,远小于线上故障后的排查时间。

7. 从这周开始,可以这样上手共用代码治理

7.1 第一次实践:找复制次数最多的工具方法

共用代码处理不是一个“一次性重构”能解决的问题,它更接近一个持续治理的过程。第一步建议选择风险最低、收益最明显的对象:项目里被复制次数最多的那个工具方法。

操作步骤:

  1. 在整个代码仓库里搜索同一段逻辑,数一下出现了几处。
  2. 确认这几处当前行为一致,没有各自改造成不同语义。
  3. 按第三节的流程抽取成独立模块,加上边界测试。
  4. 把第一个项目切换到公共模块,验证行为一致后,再切换后续项目。
  5. 在团队规范里写明:新代码禁止再复制这段逻辑,必须引用公共模块。

第一次实践不要追求大面积重构,先跑通一个完整案例,把发布、升级、验证的流程走顺。

7.2 观察一段时间,再决定是否继续抽业务组件

工具类抽取成功后,再观察公共组件的使用情况。重点观察三件事:

  • 调用方是否真的在复用,而不是又复制了一份。
  • 每次升级公共组件时,调用方升级的阻塞点在哪里。
  • 公共组件是否出现“为某一个项目特化”的趋势。

多数时候,阻塞不是技术问题,而是调用方不知道升级后会影响什么。这就是为什么 CHANGELOG 和兼容性说明要写清楚。

共用代码处理的核心判断很简单:任何被多个地方使用的代码,都要考虑“改一处能否自动让所有调用方生效”。复制粘贴做不到,直接改公共模块也需要版本机制的配合。后续的话题可以继续深入公共组件的依赖管理细节、多团队共用时的契约版本策略,或者具体到某个语言生态的共用代码处理实践。先把这一期的三种边界和最小落地案例吃透,再往下讨论才有基础。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询