共用代码处理听起来不像一个很难的技术话题,但真正做过多人协作项目的开发者都知道,它往往是代码腐化最快的地方。同一个用户校验逻辑在三个项目里各写一遍,同一个日期格式化工具类在不同仓库里出现五六个变体,同一个状态枚举在服务端和客户端各维护一份。这些问题发生时,团队很少意识到根因是共用代码的边界和机制没有设计清楚。这一篇作为专题的第一期,会把共用代码处理这件事从概念到落地完整过一遍,以 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.javaUserStatus 用于统一用户状态枚举:
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 发版、回滚与升级节奏
公共组件发版需要有比业务项目更严格的流程:
- 修改代码,并补充或调整测试。
- 本地和 CI 环境跑完整测试,确认不影响已有调用方。
- 更新 CHANGELOG,记录变更点、影响范围、升级建议。
- 发布到制品库,打上版本号。
- 通知调用方,但不强制统一升级。业务项目按自己的迭代节奏引入新版本。
回滚时,由于制品库保留历史版本,调用方只需要把 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 为例,至少覆盖这些场景:
| 测试场景 | 输入 | 预期结果 |
|---|---|---|
| 正常状态 | 0 | ACTIVE |
| 禁用状态 | 1 | DISABLED |
| null 输入 | null | UNKNOWN,不抛异常 |
| 未知数字 | 99 | UNKNOWN,不抛异常 |
| 负数 | -1 | UNKNOWN |
这类测试看起来简单,但它保护的是所有调用方。去掉任何一个分支都可能在某一个项目里引发线上故障。
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 第一次实践:找复制次数最多的工具方法
共用代码处理不是一个“一次性重构”能解决的问题,它更接近一个持续治理的过程。第一步建议选择风险最低、收益最明显的对象:项目里被复制次数最多的那个工具方法。
操作步骤:
- 在整个代码仓库里搜索同一段逻辑,数一下出现了几处。
- 确认这几处当前行为一致,没有各自改造成不同语义。
- 按第三节的流程抽取成独立模块,加上边界测试。
- 把第一个项目切换到公共模块,验证行为一致后,再切换后续项目。
- 在团队规范里写明:新代码禁止再复制这段逻辑,必须引用公共模块。
第一次实践不要追求大面积重构,先跑通一个完整案例,把发布、升级、验证的流程走顺。
7.2 观察一段时间,再决定是否继续抽业务组件
工具类抽取成功后,再观察公共组件的使用情况。重点观察三件事:
- 调用方是否真的在复用,而不是又复制了一份。
- 每次升级公共组件时,调用方升级的阻塞点在哪里。
- 公共组件是否出现“为某一个项目特化”的趋势。
多数时候,阻塞不是技术问题,而是调用方不知道升级后会影响什么。这就是为什么 CHANGELOG 和兼容性说明要写清楚。
共用代码处理的核心判断很简单:任何被多个地方使用的代码,都要考虑“改一处能否自动让所有调用方生效”。复制粘贴做不到,直接改公共模块也需要版本机制的配合。后续的话题可以继续深入公共组件的依赖管理细节、多团队共用时的契约版本策略,或者具体到某个语言生态的共用代码处理实践。先把这一期的三种边界和最小落地案例吃透,再往下讨论才有基础。