背景
最近在开发微服务项目时遇到了一个典型的技术决策场景:我们的商品服务通过 Dubbo 对外提供了一个商品列表查询的 RPC 接口,目前被网页端系统调用。现在 PC 端管理系统(新的调用方)也需要类似的商品列表功能。在 Code Review 过程中,关于是新建接口还是复用现有接口,组长和我产生了分歧。
这次争论让我意识到:同样是"代码复用",不同人看到的场景也可能是不同的。
💡 本文约 4500 字,预计阅读 10 分钟 。如果你也遇到过类似的技术决策困惑,欢迎关注公众号「9号达人」,一起交流实战经验。
项目架构:
scss
┌─────────────────┐
│ 订单服务 │ ────┐
│ order-service │ │
└─────────────────┘ │
│
┌─────────────────┐ │ ┌──────────────────────────┐
│ PC管理系统 │ ────┼─────→│ 商品服务 │
│ pc-admin │ │ │ product-service │
└─────────────────┘ │ │ │
│ │ ProductFacade (Dubbo) │
┌─────────────────┐ │ │ ├─ queryProductList() │
│ 网页商城 │ ────┘ │ └─ ??? 新增方法 ??? │
│ web-mall │ └──────────────────────────┘
└─────────────────┘
具体场景
- 现有接口:商品服务对外提供的 RPC 接口(被网页商城调用,包含热度计算、推荐数据等额外查询)
- 新需求:PC 端管理系统需要商品列表功能(只需要基本查询,用于后台管理)
- 我的方案 :在 Facade 接口中新增一个方法
queryProductListForPc() - 质疑 :为什么不在现有方法
queryProductList()加个source参数来区分,代码差不多为什么要重复?
两种设计思路的对比
方案 A: 在请求对象中新增可选字段,通过判空兼容
swift
/**
* 商品服务对外 Facade 接口(API 层)
* 调用方:web-mall, pc-admin, order-service
*/
public interface ProductFacade {
/**
* 查询商品列表(保持方法签名不变,向后兼容)
*/
Result<List<ProductDTO>> queryProductList(ProductQueryRequest request);
}
typescript
// 请求对象(在 API jar 中)
@Data
public class ProductQueryRequest implements Serializable {
private Integer pageNum;
private Integer pageSize;
private String source; // 新增字段:来源标识(可选,老调用方不传)
}
less
// product-service-impl 实现
@Service
@DubboService
public class ProductFacadeImpl implements ProductFacade {
@Override
public Result<List<ProductDTO>> queryProductList(ProductQueryRequest request) {
List<Product> products = productService.queryBasicList(
request.getPageNum(),
request.getPageSize()
);
// 判断 source 字段是否为空(向后兼容的关键)
if (request.getSource() == null || "web".equals(request.getSource())) {
// 老调用方不传 source,默认按 Web 端处理
// 或者显式传 "web"
enrichWebData(products); // +50ms
calculateWebMetrics(products); // +80ms
loadWebRecommendations(products); // +70ms
} else if ("pc".equals(request.getSource())) {
// PC端逻辑(或者不做额外处理)
}
return Result.success(ProductConverter.toDTO(products));
}
}
优点
- 只修改请求对象,不修改方法签名,向后兼容
- 老调用方不传
source,代码不用改,也能正常运行 - 只有一个方法,"代码复用率高",后期可以统一维护,认知成本低(实际上依赖对 source 字段的维护,认知成本也不低)
可能存在的问题
-
隐式默认值问题,容易出错
less// 问题:source == null 默认是 Web 端,这是一个隐式假设 if (request.getSource() == null || "web".equals(request.getSource())) { // 执行 Web 端逻辑 } // 隐患: // 1. 订单服务调用时,不知道会走 Web 端逻辑,可能执行了不需要的查询 // 2. 未来如果有第4个、第5个调用方,默认值假设可能不成立 // 3. 新人维护代码时,不清楚 null 代表什么含义 -
职责不清晰,一个方法服务多个场景
csharp// 这个方法既要服务 Web 端,又要服务 PC 端,还可能要服务 App 端 // 随着业务发展,if-else 会越来越多 if (source == null || "web".equals(source)) { // Web端逻辑(50行) } else if ("pc".equals(source)) { // PC端逻辑(80行) } else if ("app".equals(source)) { // App端逻辑(60行) } // 这个方法会膨胀到200行+ -
各端逻辑耦合,改一处影响全局
scss// 场景:Web 端要优化性能,调整了基础查询逻辑 List<Product> products = productService.queryBasicList(pageNum, pageSize); // 问题:这个改动会影响 PC 端、App 端的所有调用 // 需要回归测试所有端的所有场景
方案 B:我的方案 - 新增独立方法
swift
/**
* 商品服务对外 Facade 接口(API 层)
*/
public interface ProductFacade {
/**
* 查询商品列表(保持原有签名不变,向后兼容)
*/
Result<List<ProductDTO>> queryProductList(ProductQueryRequest request);
/**
* 查询商品列表 - PC端(新增方法,专门给PC管理端使用)
*/
Result<List<ProductDTO>> queryProductListForPc(ProductQueryRequest request);
}
java
// 请求对象保持不变
@Data
public class ProductQueryRequest implements Serializable {
private Integer pageNum;
private Integer pageSize;
// 不添加 source 字段
}
less
// product-service-impl 实现
@Service
@DubboService
public class ProductFacadeImpl implements ProductFacade {
/**
* 查询商品列表(保持原有逻辑不变,服务 Web 端和老调用方)
*/
@Override
public Result<List<ProductDTO>> queryProductList(ProductQueryRequest request) {
List<Product> products = productService.queryBasicList(
request.getPageNum(),
request.getPageSize()
);
// Web端特有的数据增强
enrichWebData(products); // +50ms
calculateWebMetrics(products); // +80ms
loadWebRecommendations(products); // +70ms
return Result.success(ProductConverter.toDTO(products));
}
/**
* PC端商品列表(新增,轻量级)
*/
@Override
public Result<List<ProductDTO>> queryProductListForPc(ProductQueryRequest request) {
// 只做基础查询,不做额外增强
List<Product> products = productService.queryBasicList(
request.getPageNum(),
request.getPageSize()
);
return Result.success(ProductConverter.toDTO(products));
}
}
优点
1. 完全向后兼容,老调用方零改动
ini
// 订单服务、Web商城的调用代码完全不需要改
ProductQueryRequest request = new ProductQueryRequest();
request.setPageNum(1);
request.setPageSize(10);
Result<List<ProductDTO>> result = productFacade.queryProductList(request);
// PC管理系统调用新方法
ProductQueryRequest request = new ProductQueryRequest();
request.setPageNum(1);
request.setPageSize(10);
Result<List<ProductDTO>> result = productFacade.queryProductListForPc(request);
2. 独立升级,互不影响
- 商品服务发布新版本 API jar 包(新增了
queryProductListForPc方法) - PC 管理系统升级 jar 包,调用新方法
- 订单服务、Web 商城不用动 ,继续用老方法
queryProductList - 各自按自己的节奏升级
3. 零风险上线
- 新方法只服务新调用方(PC 管理系统)
- 老方法保持不变,所有老调用方不受影响
- 不存在"遗漏某个调用方"的问题
4. 职责清晰,独立演进
scss
queryProductList() // 服务 Web端和老调用方,未来加推荐、短视频
queryProductListForPc() // 服务 PC端,未来加ERP、批量导入
// 两个方法各管各的,互不干扰
5. 性能优化,各取所需
scss
// Web 端调用:执行完整逻辑,耗时 200ms
productFacade.queryProductList(request); // 含推荐、热度等
// PC 端调用:只查基础数据,耗时 10ms
productFacade.queryProductListForPc(request); // 轻量级
// 老调用方(订单服务):继续走原有逻辑
productFacade.queryProductList(request); // 虽然有冗余查询,但向后兼容
6. 便于监控和问题排查
arduino
// 可以分别监控:
// - queryProductList 的调用量、耗时、错误率(Web端 + 老调用方)
// - queryProductListForPc 的调用量、耗时、错误率(PC端)
// 出问题时能快速定位是哪个端的调用
缺点
- Facade 接口方法数量增加(但每个方法职责更清晰)
- 存在一定程度的代码重复(但可以通过 Service 层复用解决)
- 存在认知成本,换个开发看到好几个获取商品列表的方法会有点困惑
为什么我们的想法会不同?
讨论到这里,我突然意识到一个问题:我们实际讨论的是扩展和组合。
一个成功的组合案例:退款逻辑合并
在我们的项目中,曾经进行过一次重构,把两条独立的退款逻辑成功合并到了一起,进行统一维护。
重构前的状态
scss
// PC 端退款逻辑(已稳定运行 1 年+)
public class PcRefundService {
public RefundResult refundForPc(RefundRequest request) {
// 1. PC 端特有的审核流程(多级审批)
pcAuditService.audit(request);
// 2. 调用第三方支付退款
paymentGateway.refund(request);
// 3. PC 端特有的凭证生成
pcVoucherService.generateVoucher(request);
// 4. 同步到 ERP 系统
erpService.syncRefund(request);
}
}
// Web 端退款逻辑(已稳定运行 8 个月)
public class WebRefundService {
public RefundResult refundForWeb(RefundRequest request) {
// 1. Web 端特有的风控检查
riskControlService.check(request);
// 2. 调用第三方支付退款(同样的支付网关)
paymentGateway.refund(request);
// 3. Web 端特有的积分返还
pointService.returnPoints(request);
// 4. 发送通知给用户
notificationService.notify(request);
}
}
重构后的状态
csharp
public class UnifiedRefundService {
public RefundResult refund(RefundRequest request) {
// 1. 根据来源执行不同的前置流程
if ("pc".equals(request.getSource())) {
pcAuditService.audit(request);
} else if ("web".equals(request.getSource())) {
riskControlService.check(request);
}
// 2. 通用的退款核心逻辑(提取出来)
paymentGateway.refund(request);
// 3. 根据来源执行不同的后置流程
if ("pc".equals(request.getSource())) {
pcVoucherService.generateVoucher(request);
erpService.syncRefund(request);
} else if ("web".equals(request.getSource())) {
pointService.returnPoints(request);
notificationService.notify(request);
}
}
}
这次合并为什么成功?
- 两条退款逻辑都已经稳定运行很久,业务边界清晰
- 核心流程(调用支付网关)确实可以复用
- 合并后维护更方便,bug 修复只需改一处
- 新增其他端的退款(如 App 端)也很容易扩展
成功的关键前提:
- 两条业务线都已经独立演进到稳定状态
- 业务边界已经清晰
- 确实发现了可复用的核心逻辑
- 这是对内的重构,不影响外部调用方
当前场景:商品列表查询
但这次商品列表查询是否满足组合条件呢?我认为不满足。
关键差异对比
| 维度 | 退款合并(成功的组合) | 商品列表(当前场景) |
|---|---|---|
| 阶段 | 两条线都已经稳定运行很久 | PC 端是全新需求,还没开始 |
| 业务理解 | 业务边界清晰,知道哪些可以复用 | PC 端需求还不明确,可能会变 |
| 复用价值 | 确实有核心逻辑可以提取 | 只是都叫"商品列表",实际逻辑差异大 |
| 方向 | 对内重构,优化内部结构 | 对外扩展,增加新能力 |
扩展 vs 组合的本质区别
概念定义
扩展(Extension)- 对外增加能力
- 目的:给系统增加新的对外能力
- 时机:新需求、新业务线
- 原则:各自独立,给予足够的演进空间
- 示例:新增 PC 端商品列表接口
组合(Composition)- 对内优化结构
- 目的:重构内部实现,消除重复
- 时机:多条业务线稳定后,发现可复用模式
- 原则:提取共性,保持灵活性
- 示例:合并 PC 端和 Web 端的退款逻辑
核心原则:先扩展,后组合
退款逻辑的重构本质上也是先进行了扩展,然后再进行组合。
为什么退款会先进行扩展?
- 时间成本 - 本质上还是两个独立的业务,如果在开发之初就尝试组合复用,会极大增加时间成本
- 需求变化 - 新的业务免不了调整,这时业务边界不清晰,很容易造成影响
- 兼容成本 - 如果在需求不清晰的时候就考虑兼容,很容易处处受限
为什么商品列表会让人考虑直接组合?
- 业务逻辑简单 - 本质就是一个 CRUD,兼容成本看起来很低
- 需求变化小 - 感觉不太会有太多的变化
但实际上这两者是一样的,它们都是对外增加了新的能力,而不能因为业务简单就直接考虑进行组合。
总结
这次 Code Review 让我明白了几个道理:
1. 不要混淆"对外扩展"和"对内组合"
- 对外扩展时:优先考虑独立性和灵活性
- 对内组合时:可以考虑复用和统一
2. 不要过早组合
- 退款合并是成功的,因为是"先扩展,后组合"
- 如果在 PC 端商品列表刚立项时就组合,是"过早组合"
3. 分阶段演进
- 扩展期:新增独立接口,给予足够的演进空间
- 稳定期:各自独立发展,积累业务理解
- 组合期:发现可复用模式,进行内部重构
4. 当前场景的决策
- 现在 :采用方案 B,新增独立的
queryProductListForPc()方法 - 未来:等 PC 端商品列表也稳定运行半年后,再评估是否需要组合
- 原则:先让子弹飞一会儿,不要急着优化
最重要的是:扩展总不会错,不会影响老逻辑,尤其是在小公司快速迭代的环境下。
关注「9号达人」公众号,获取更多干货
我是一名小厂程序员,专注分享真实的项目实战经验 和接地气的技术思考。
关注公众号「9号达人」
不讲大而全的理论,只聊真实踩过的坑。