接口设计中的扩展与组合:一次Code Review引发的思考

背景

最近在开发微服务项目时遇到了一个典型的技术决策场景:我们的商品服务通过 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 字段的维护,认知成本也不低)

可能存在的问题

  1. 隐式默认值问题,容易出错

    less 复制代码
    // 问题:source == null 默认是 Web 端,这是一个隐式假设
    if (request.getSource() == null || "web".equals(request.getSource())) {
        // 执行 Web 端逻辑
    }
    
    // 隐患:
    // 1. 订单服务调用时,不知道会走 Web 端逻辑,可能执行了不需要的查询
    // 2. 未来如果有第4个、第5个调用方,默认值假设可能不成立
    // 3. 新人维护代码时,不清楚 null 代表什么含义
  2. 职责不清晰,一个方法服务多个场景

    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行+
  3. 各端逻辑耦合,改一处影响全局

    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 端)也很容易扩展

成功的关键前提:

  1. 两条业务线都已经独立演进到稳定状态
  2. 业务边界已经清晰
  3. 确实发现了可复用的核心逻辑
  4. 这是对内的重构,不影响外部调用方

当前场景:商品列表查询

但这次商品列表查询是否满足组合条件呢?我认为不满足

关键差异对比

维度 退款合并(成功的组合) 商品列表(当前场景)
阶段 两条线都已经稳定运行很久 PC 端是全新需求,还没开始
业务理解 业务边界清晰,知道哪些可以复用 PC 端需求还不明确,可能会变
复用价值 确实有核心逻辑可以提取 只是都叫"商品列表",实际逻辑差异大
方向 对内重构,优化内部结构 对外扩展,增加新能力

扩展 vs 组合的本质区别

概念定义

扩展(Extension)- 对外增加能力

  • 目的:给系统增加新的对外能力
  • 时机:新需求、新业务线
  • 原则:各自独立,给予足够的演进空间
  • 示例:新增 PC 端商品列表接口

组合(Composition)- 对内优化结构

  • 目的:重构内部实现,消除重复
  • 时机:多条业务线稳定后,发现可复用模式
  • 原则:提取共性,保持灵活性
  • 示例:合并 PC 端和 Web 端的退款逻辑

核心原则:先扩展,后组合

退款逻辑的重构本质上也是先进行了扩展,然后再进行组合

为什么退款会先进行扩展?

  1. 时间成本 - 本质上还是两个独立的业务,如果在开发之初就尝试组合复用,会极大增加时间成本
  2. 需求变化 - 新的业务免不了调整,这时业务边界不清晰,很容易造成影响
  3. 兼容成本 - 如果在需求不清晰的时候就考虑兼容,很容易处处受限

为什么商品列表会让人考虑直接组合?

  1. 业务逻辑简单 - 本质就是一个 CRUD,兼容成本看起来很低
  2. 需求变化小 - 感觉不太会有太多的变化

但实际上这两者是一样的,它们都是对外增加了新的能力,而不能因为业务简单就直接考虑进行组合。


总结

这次 Code Review 让我明白了几个道理:

1. 不要混淆"对外扩展"和"对内组合"

  • 对外扩展时:优先考虑独立性和灵活性
  • 对内组合时:可以考虑复用和统一

2. 不要过早组合

  • 退款合并是成功的,因为是"先扩展,后组合"
  • 如果在 PC 端商品列表刚立项时就组合,是"过早组合"

3. 分阶段演进

  1. 扩展期:新增独立接口,给予足够的演进空间
  2. 稳定期:各自独立发展,积累业务理解
  3. 组合期:发现可复用模式,进行内部重构

4. 当前场景的决策

  • 现在 :采用方案 B,新增独立的 queryProductListForPc() 方法
  • 未来:等 PC 端商品列表也稳定运行半年后,再评估是否需要组合
  • 原则:先让子弹飞一会儿,不要急着优化

最重要的是:扩展总不会错,不会影响老逻辑,尤其是在小公司快速迭代的环境下。


关注「9号达人」公众号,获取更多干货

我是一名小厂程序员,专注分享真实的项目实战经验接地气的技术思考

关注公众号「9号达人」

不讲大而全的理论,只聊真实踩过的坑。

相关推荐
百***62852 小时前
oracle 12c查看执行过的sql及当前正在执行的sql
java·sql·oracle
键来大师2 小时前
Android15 源码关闭Selinux
android·java·framework·rk3588
柠石榴2 小时前
GO-1 模型本地部署完整教程
开发语言·后端·golang
合作小小程序员小小店3 小时前
桌面开发,在线%日记本,日历%系统开发,基于eclipse,jdk,java,无数据库
java·数据库·eclipse·jdk
LaoZhangAI3 小时前
Gemini 2.5 Flash Image API尺寸设置完整指南:10种宽高比详解
前端·后端
拾忆,想起3 小时前
Dubbo线程模型全解析:提升微服务性能的底层逻辑
java·数据库·微服务·架构·dubbo·哈希算法
论迹3 小时前
【JavaEE】-- IoC & DI
java·java-ee
lzj20143 小时前
Spring AI使用知识库增强对话功能
java
大头an3 小时前
Spring 6 & Spring Boot 3新特性:事务管理的革新
java