编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

    • README
    • 面向对象设计思想
    • 面向对象特性思考
    • 接口vs抽象类比较
    • 接口而非实现编程
      • 1.先回答上篇思考题
        • 1.1 上篇遗留三道题
        • 1.2 云迁移6万行代码
        • 1.3 五次反转补锅
        • 1.4 灵魂五连问
      • 2.从一个搬迁切入
        • 2.1 上云搬迁案例
        • 2.2 改造的代价
        • 2.3 问题的根因
      • 3.原则的本质
        • 3.1 一句话理解
        • 3.2 接口的双重含义
        • 3.3 抽象层的价值
      • 4.重构图片存储
        • 4.1 三步重构法
        • 4.2 重构后代码
        • 4.3 演化对比图
      • 5.支付渠道实战
        • 5.1 多支付场景
        • 5.2 抽象统一接口
        • 5.3 调用方零修改
      • 6.设计的边界
        • 6.1 接口的代价
        • 6.2 何时不需要
        • 6.3 反模式警示
      • 7.原则进阶
        • 7.1 与依赖倒置
        • 7.2 与开闭原则
        • 7.3 与可测试性
      • 8.总结与延伸
      • 9.综合实战案例
        • 9.1 货运推送接需求
        • 9.2 依赖实现的崩塌
        • 9.3 接口化三步重构
        • 9.4 类图与可测性
        • 9.5 留下三道思考题
      • 10.认知跃迁总结
    • 多用组合和少继承
    • 设计原则的全景图
    • SOLID原则案例汇
    • 反模式与坏味道
    • 重构十二式的实战
    • 可测试性实战设计
    • DDD与战术的建模
    • 综合实战图片框架
  • 常见设计原则

  • 巧学设计模式

  • 系统架构设计

  • 编程
  • 面向对象设计
杨充
2023-03-24
目录

接口而非实现编程

# 第一卷第4章:接口而非实现编程

# 目录介绍

  • 1.先回答上篇思考题
    • 1.1 上篇遗留三道题
    • 1.2 云迁移6万行代码
    • 1.3 五次反转补锅
    • 1.4 灵魂五连问
  • 2.从一个搬迁切入
    • 2.1 上云搬迁案例
    • 2.2 改造的代价
    • 2.3 问题的根因
  • 3.原则的本质
    • 3.1 一句话理解
    • 3.2 接口的双重含义
    • 3.3 抽象层的价值
  • 4.重构图片存储
    • 4.1 三步重构法
    • 4.2 重构后代码
    • 4.3 演化对比图
  • 5.支付渠道实战
    • 5.1 多支付场景
    • 5.2 抽象统一接口
    • 5.3 调用方零修改
  • 6.设计的边界
    • 6.1 接口的代价
    • 6.2 何时不需要
    • 6.3 反模式警示
  • 7.原则进阶
    • 7.1 与依赖倒置
    • 7.2 与开闭原则
    • 7.3 与可测试性
  • 8.总结与延伸
  • 9.综合实战案例
    • 9.1 货运推送接需求
    • 9.2 依赖实现的崩塌
    • 9.3 接口化三步重构
    • 9.4 类图与可测性
    • 9.5 留下三道思考题
  • 10.认知跃迁总结

# 1.先回答上篇思考题

# 1.1 上篇遗留三道题

上一篇 03.接口vs抽象类比较 末尾留下了三道题:

  • 🟢 AbstractStandardGateway.pay 加 final 的原因?
  • 🟡 多插拔点该怎么设计?
  • 🔴 合规检查等「横切关注」该怎么加进去?
题 本篇答案
🟢 不加 final → 子类可以重写「骨架流程」 → 模板失效,所有“公共动作”可被跳过
🟡 多插拔点 → 多个接口。每个可插拔点提取为接口(RateLimiter/FinChecker/Retryer),抽象类只作为「接口装配者」
🔴 横切关注 = AOP / Decorator。不走接口,也不走抽象类——走装饰器链,这是本篇「依赖接口而非实现」的另一面

三道题本质指向一个问题:调用方不应该看到具体实现。它不仅是「面向接口编程」,更是「面向能力契约编程」。

# 1.2 云迁移6万行代码

2022 年某互联网公司「去阿里云」:从阿里云 OSS 迁到腾讯云 COS。调研阶段估计「2 人月12 天完成」。实际:

年中启动 → 预计Q3完成
实际进度:
Q3末完成 30%、上线出了 17 个由「调用 SDK 差异」引起的事故
Q4末完成 65%、为了兼容两个 SDK,加了中间适配层
Q1今未全完:还有3 个老服务依然依赖 OSS SDK——谁也不敢动
1
2
3
4
5

事后复盘 git,发现代码里出现了 62873 个 OSSClient 字符串。这意味着是迫在几万处代码背后作修改,而非某个抽象中枢。

flowchart LR
    subgraph 均依赖实现的世界[均依赖实现]
        S1[OrderService] --> O[OSSClient]
        S2[ProductService] --> O
        S3[CdnService] --> O
        S4[还有62个服务] --> O
    end
    subgraph 中间有3年后[迁移 3 年后]
        T1[OrderService] --> AD[迁移适配层] --> COS[COSClient]
        T2[ProductService] --> AD --> OSS_LEGACY[仍未能切走 OSS]
    end
1
2
3
4
5
6
7
8
9
10
11

如果开发人从第一天就让 OrderService 依赖于 ImageStore 接口而不是 OSSClient 实现——迁移只需改 1 个 Bean 配置。但实际付出的是 3 年 + 几十个工程师 + 17 个线上事故 的代价。

这不是「代码不够优雅」的问题——这是企业生死存亡级别的设计选择。

# 1.3 五次反转补锅

手术现场五次反转:

  • 反转 1:动动手把 OSSClient 改名为 ImageStore → 调用方代码看起来凅了,实际还是同一个类。
  • 反转 2:抽出接口 ImageStore,让 OSSImageStore 实现 → 调用方仍然写 new OSSImageStore(),耦合在 new 表达式里。
  • 反转 3:发现调用方调用了以下 8 个 OSS 独有特性方法(bucketAcl/presignUrl/expireRule...)——接口里要不要加?加了就不抽象,不加调用方要重写。
  • 反转 4:抽象 PresignUrl、ExpireRule 为独立接口 → 可能会出现 5个接口,调用方发现「代码里一堆 if instanceof XxxAware」。
  • 反转 5:最终落定——不是「接口越庞大越好」,而是「接口要面向「调用场景」,不面向「实现能力」」。多出来的能力要么不该走接口,要么该举升为独立能力接口。

这五个反转又一次揭示:「面向接口编程」不是难在语法,难在「选哪些能力走接口」。

# 1.4 灵魂五连问

Q1 ── 「依赖接口」与「依赖实现」在字节码层看有什么区别?
       └─→ §02 原则的本质
Q2 ── 不是已经用了抽象类了吗,为什么还要接口?
       └─→ §02.2 接口的双重含义
Q3 ── 哪些东西不该抽象为接口?
       └─→ §05 设计的边界Q4 ── 为什么「依赖接口」几乎等价于「可测性」?
       └─→ §06.3 与可测性
Q5 ── 「依赖抽象」与「依赖倒置」到底一样不一样?
       └─→ §06.1 与依赖倒置
1
2
3
4
5
6
7
8
9

# 2.从一个搬迁切入

# 2.1 上云搬迁案例

承接上一篇电商系统。系统里有图片处理模块,最初图片传阿里云:

public class AliyunImageStore {
    public void createBucketIfNotExisting(String bucketName) { /* … */ }
    public String generateAccessToken() { /* 阿里云鉴权 */ }
    public String uploadToAliyun(Image img, String bucket, String token) { /* … */ }
    public Image downloadFromAliyun(String url, String token) { /* … */ }
}

public class ImageJob {
    public void process() {
        AliyunImageStore store = new AliyunImageStore();
        store.createBucketIfNotExisting("ai_images");
        String token = store.generateAccessToken();
        store.uploadToAliyun(image, "ai_images", token);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

代码看起来"很正常"。

# 2.2 改造的代价

公司决定切换到自建私有云。只是换个存储,结果改造量惊人:

flowchart LR
    A[切换到私有云] --> B[新写 PrivateImageStore]
    B --> C[方法名 uploadToAliyun 该叫啥]
    B --> D[私有云没有 accessToken]
    B --> E[所有调用点都要改]
    C --> F[全局重命名]
    D --> G[流程不一样]
    E --> H[一处遗漏=线上 bug]
1
2
3
4
5
6
7
8
  • uploadToAliyun() 这个名字写死了"阿里云" → 调用点全要重命名;
  • 私有云不需要 accessToken → 调用流程也要改;
  • 散落在几十个文件里的 new AliyunImageStore() 一处改漏就翻车。

# 2.3 问题的根因

调用方依赖了具体实现,而具体实现把"阿里云"这个易变细节渗透进了:

  • 类名(AliyunImageStore)
  • 方法名(uploadToAliyun)
  • 流程结构(必须 generateAccessToken)

调用方与一个"会变的东西"绑死了,变化的代价就以乘法形式爆炸。


# 3.原则的本质

# 3.1 一句话理解

Program to an interface, not an implementation.

依赖抽象的契约,不依赖具体的实现。

flowchart TB
    subgraph 错误依赖
        Caller1[调用方] --> Concrete1[阿里云实现类]
    end
    subgraph 正确依赖
        Caller2[调用方] --> Iface[ImageStore 接口]
        Iface --> Concrete2[阿里云实现]
        Iface --> Concrete3[私有云实现]
    end
1
2
3
4
5
6
7
8
9

# 3.2 接口的双重含义

这里的"接口"有两层:

  1. 语法层:Java interface、C++ 抽象类——具体的语法机制;
  2. 设计层:一组协议/契约,描述"做什么"而非"怎么做"。

设计层的含义比语法层更广:只要你定义了稳定的对外能力清单,哪怕用普通类承载,本质也是"面向接口编程"。

# 3.3 抽象层的价值

不稳定的实现       稳定的接口          调用方
   ↓                   ↓                ↓
易变细节  ─封装─→  统一契约  ←─依赖─  关心 what 不关心 how
1
2
3

接口是"防火墙":把不稳定的底层与稳定的上层隔开,变化被堵在防火墙之内。


# 4.重构图片存储

# 4.1 三步重构法

flowchart LR
    Step1[步骤1<br/>方法名去掉实现细节] --> Step2[步骤2<br/>封装内部流程]
    Step2 --> Step3[步骤3<br/>抽出统一接口]
1
2
3
步骤 反例 正例
1.命名抽象 uploadToAliyun upload
2.流程封装 调用方拼装 token+upload 接口内部一次完成
3.接口提取 直接 new AliyunImageStore new ImageStore 接口

# 4.2 重构后代码

// 1. 接口:只描述"做什么"
public interface ImageStore {
    String upload(Image image, String bucketName);
    Image download(String url);
}

// 2. 阿里云实现:把 token、bucket 等内部流程封进 upload
public class AliyunImageStore implements ImageStore {
    @Override
    public String upload(Image image, String bucketName) {
        ensureBucket(bucketName);
        String token = generateToken();    // 内部细节
        return doUpload(image, bucketName, token);
    }
    @Override
    public Image download(String url) {
        String token = generateToken();
        return doDownload(url, token);
    }
    private void ensureBucket(String n) { /* … */ }
    private String generateToken() { /* … */ }
    private String doUpload(Image i, String b, String t) { /* … */ }
    private Image  doDownload(String u, String t) { /* … */ }
}

// 3. 私有云实现:流程不同,但接口一致
public class PrivateImageStore implements ImageStore {
    @Override
    public String upload(Image image, String bucketName) {
        ensureBucket(bucketName);
        return doUpload(image, bucketName);   // 不需要 token
    }
    @Override
    public Image download(String url) { return doDownload(url); }
    private void ensureBucket(String n) { /* … */ }
    private String doUpload(Image i, String b) { /* … */ }
    private Image  doDownload(String u) { /* … */ }
}

// 4. 调用方:永远只看到 ImageStore
public class ImageJob {
    private final ImageStore store;
    public ImageJob(ImageStore store) { this.store = store; }
    public void process(Image image) {
        store.upload(image, "ai_images");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

# 4.3 演化对比图

flowchart LR
    subgraph 重构前
        J1[ImageJob] -->|new + 调用| A1[AliyunImageStore]
        A1 -.切换.-> P1[PrivateImageStore]
        J1 -.随之改.-> J2[改名+改流程]
    end
    subgraph 重构后
        J3[ImageJob] --> I[ImageStore 接口]
        I --> A2[AliyunImageStore]
        I --> P2[PrivateImageStore]
        I --> S3[S3ImageStore<br/>未来扩展]
    end
1
2
3
4
5
6
7
8
9
10
11
12

切换实现 → 构造时换一行,调用方零改动。


# 5.支付渠道实战

# 5.1 多支付场景

电商系统支付:支付宝、微信、信用卡、PayPal……且未来还要加。

// ❌ 反例:开关变成调用方的负担
public class CheckoutService {
    public void pay(Order order, String channel) {
        if ("alipay".equals(channel)) new Alipay().pay(...);
        else if ("wechat".equals(channel)) new Wechat().pay(...);
        else if ("paypal".equals(channel)) new PayPal().pay(...);
    }
}
1
2
3
4
5
6
7
8

加一种渠道 → 改 CheckoutService → 改测试 → 改调用方……坏味道全套齐了。

# 5.2 抽象统一接口

public interface PaymentGateway {
    PayResult pay(Order order);
    Refund    refund(String txId, Money amount);
}

public class AlipayGateway implements PaymentGateway { /* … */ }
public class WechatGateway implements PaymentGateway { /* … */ }
public class PaypalGateway implements PaymentGateway { /* … */ }
1
2
3
4
5
6
7
8
classDiagram
    class PaymentGateway {
        <<interface>>
        +pay(Order) PayResult
        +refund(txId, amount) Refund
    }
    PaymentGateway <|.. AlipayGateway
    PaymentGateway <|.. WechatGateway
    PaymentGateway <|.. PaypalGateway
    CheckoutService --> PaymentGateway
1
2
3
4
5
6
7
8
9
10

# 5.3 调用方零修改

public class CheckoutService {
    private final Map<String, PaymentGateway> gateways;  // 注入

    public CheckoutService(Map<String, PaymentGateway> gateways) {
        this.gateways = gateways;
    }

    public void pay(Order order, String channel) {
        PaymentGateway g = gateways.get(channel);
        if (g == null) throw new UnsupportedChannelException(channel);
        g.pay(order);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

新增 PayPal → 只加一个 PaypalGateway + 注入 Map,业务层完全不动。


# 6.设计的边界

# 6.1 接口的代价

抽象不是免费的:

代价 说明
类爆炸 每个实现一个接口 → 类数量翻倍
间接调用 多一层调度,调试栈更深
阅读负担 看到接口要再找实现
过度设计 永远只会有一种实现的也加接口,纯负担

# 6.2 何时不需要

不是所有地方都要"基于接口"。判断标准:这个能力未来可能被替换吗?

情况 是否抽接口
一次性脚本 否
内部工具类(StringUtil) 否
业务实体(Order) 否
三方依赖(支付/消息/存储) 是
跨模块协议 是
易变策略(折扣/风控) 是

# 6.3 反模式警示

反推接口陷阱——先写实现类,再把它的方法照搬成接口:

// ❌ 把 generateAccessToken 也放进接口 → 接口被实现污染
public interface ImageStore {
    String upload(...);
    String generateAccessToken();   // 这是阿里云的细节,泄漏了
}
1
2
3
4
5

接口要"自上而下"设计——先想清楚调用方需要什么能力,才决定接口长什么样,绝不能由实现反推。


# 7.原则进阶

# 7.1 与依赖倒置

依赖倒置原则(DIP):高层不依赖低层,二者都依赖抽象。

"接口而非实现编程"是 DIP 的具体落地手段:

flowchart LR
    H[高层模块<br/>OrderService] --> I[抽象<br/>PaymentGateway]
    L[低层模块<br/>AlipayGateway] --> I
1
2
3

依赖关系从"高层 → 低层"反转为"两侧 → 抽象",故得名"倒置"。

# 7.2 与开闭原则

开闭原则(OCP):对扩展开放,对修改关闭。

新增 PayPal → 加新类(扩展开放)
不改 CheckoutService(修改关闭)
1
2

接口编程是 OCP 的入场券——没有抽象层,扩展就只能靠改旧码。

# 7.3 与可测试性

// 单测时注入假实现
class FakeGateway implements PaymentGateway {
    public PayResult pay(Order o) { return PayResult.success("fake_tx"); }
    public Refund refund(String t, Money m) { return Refund.ok(); }
}

@Test
void test_checkout() {
    CheckoutService svc = new CheckoutService(Map.of("fake", new FakeGateway()));
    svc.pay(order, "fake");
    // 验证业务逻辑,不需要真支付宝
}
1
2
3
4
5
6
7
8
9
10
11
12

没有接口就没有 Mock,没有 Mock 就没有可靠单测。可测试性是接口编程的副产品,也是衡量设计质量的硬指标。


# 8.总结与延伸

关键点 内容
一句话 依赖抽象,不依赖实现
解决的痛 实现切换的连锁修改
三步重构 命名抽象 → 流程封装 → 接口提取
边界 仅一种实现且不会变 → 不必抽
配套原则 DIP、OCP、可测性

到这里,你已经能写出"调用方依赖接口"的代码。但接口/抽象类只是组装代码的方式之一,"继承"也常被用来做扩展点。问题是:继承用多了会带来什么?

下一篇 05.多用组合和少继承 将揭示继承的暗坑,并展示组合如何成为更优解。


# 9.综合实战案例

主线接力——03 篇为支付网关选了抽象,本篇要让调用方发生根本性转变。

# 9.1 货运推送接需求

PM 给出需求:

1. 订单发货后需发货运信息给用户
2. 支持三家快递:顺丰 / 中通 / 京东物流
3. 只主要调用他们接口查物流状态
4. 并将状态变化推送给用户(短信/公众号/站内信)
5. 后期要接亲邮、万国邮政、DHL
1
2
3
4
5

# 9.2 依赖实现的崩塌

工程师第一版:

public class ShippingService {
    private final SfClient   sf   = new SfClient();    // 顺丰 SDK
    private final ZtoClient  zto  = new ZtoClient();   // 中通 SDK
    private final JdLogClient jd  = new JdLogClient(); // 京东物流 SDK

    public void notify(Order order) {
        TrackInfo info;
        switch (order.carrier()) {
            case SF:  info = sf.queryTrack(order.waybill()); break;
            case ZTO: info = zto.track(order.waybill());     break;
            case JD:  info = jd.getStatus(order.waybill());  break;
            default: throw new IllegalStateException();
        }
        // 推送逻辑同样是 switch
        switch (order.user().pushPref()) {
            case SMS: smsClient.send(...); break;
            ...
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

三个问题一起出现:

现象 本质
接亲邮 → 多一个 case、多一次 if OCP 违反
单测时发现 SDK 依赖真实网络 → 无法 mock 可测性崩塌
ShippingService 同时抱住 3 个 SDK、3 个推送渠道、业务三个职责 SRP 违反

# 9.3 接口化三步重构

第 1 步:提炼能力接口——调用方只表达「我要查物流」,不表达「我使用顺丰」:

public interface CarrierTracker {
    TrackInfo track(String waybill);
}
public interface UserNotifier {
    void notify(User u, Notice n);
}
1
2
3
4
5
6

第 2 步:调用方只认识接口:

public class ShippingService {
    private final Map<Carrier, CarrierTracker> trackers;  // 类型 → 实现
    private final List<UserNotifier> notifiers;            // 多推送渠道同时

    public ShippingService(Map<Carrier, CarrierTracker> t, List<UserNotifier> n) {
        this.trackers = t; this.notifiers = n;
    }

    public void notifyTrackChange(Order order) {
        TrackInfo info = trackers.get(order.carrier()).track(order.waybill());
        Notice n = NoticeBuilder.from(info);
        notifiers.forEach(x -> x.notify(order.user(), n));   // 多推送渠道
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

第 3 步:实现接入——每个 SDK 一个实现类、Spring 自动装配:

@Component class SfTracker  implements CarrierTracker { @Override public TrackInfo track(String w) { return ... ; } }
@Component class ZtoTracker implements CarrierTracker { ... }
@Component class JdTracker  implements CarrierTracker { ... }
@Component class SmsNotifier   implements UserNotifier { ... }
@Component class WechatNotifier implements UserNotifier { ... }
1
2
3
4
5

接一家亲邮?@Component class YouzhengTracker 加 1 个 @Bean 注册,ShippingService 一行不改。这才是原则价值的最直接体现。

# 9.4 类图与可测性

classDiagram
    class ShippingService {
        -trackers Map
        -notifiers List
        +notifyTrackChange(order)
    }
    class CarrierTracker { <<interface>> +track(w) TrackInfo }
    class UserNotifier   { <<interface>> +notify(u, n) }
    ShippingService --> CarrierTracker
    ShippingService --> UserNotifier
    CarrierTracker <|.. SfTracker
    CarrierTracker <|.. ZtoTracker
    CarrierTracker <|.. JdTracker
    UserNotifier   <|.. SmsNotifier
    UserNotifier   <|.. WechatNotifier
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

现在写单测变得近乎微笑:

@Test void notifyShouldFanOutToAllChannels() {
    var fakeSf  = mock(CarrierTracker.class);
    when(fakeSf.track("w1")).thenReturn(new TrackInfo("已发货"));
    var fakeSms = mock(UserNotifier.class);
    var fakeWx  = mock(UserNotifier.class);

    var svc = new ShippingService(Map.of(Carrier.SF, fakeSf), List.of(fakeSms, fakeWx));
    svc.notifyTrackChange(orderWithSf("w1"));

    verify(fakeSms).notify(any(), any());
    verify(fakeWx).notify(any(), any());
}
1
2
3
4
5
6
7
8
9
10
11
12

这就是接口编程的隐藏复利:单测不需要任何真实 SDK / 真实网络 / 真实手机。它预告了第 10 篇《可测性设计》。

# 9.5 留下三道思考题

答案在第 05 篇开头揭晓。

  • 🟢 易:为什么我们把 trackers 写成 Map<Carrier, CarrierTracker> 而不是 List<CarrierTracker>?二者语义有什么本质差别?
  • 🟡 中:SfTracker 里要调用顺丰 SDK,而 SDK 包含一个 SfApiClient 该怎么设计?是「SfTracker 继承 SfApiClient」还是「SfTracker 含有一个 SfApiClient 字段」?
  • 🔴 难:如果「顺丰」、「中通」、「京东物流」他们 80% 共同逻辑(例如身份鉴权、重试、限流)你会怎么复用?是抽公共抽象类还是包装一个 RestApiBase 字段?提示:这道题就是下一篇《多用组合少用继承》的核心争论。

# 10.认知跃迁总结

回到云迁移 6 万行代码那件事。如果从代码的第一天,调用方看到的是 ImageStore 接口而不是 OSSClient——迁移可能只需要一个 @Bean 配置、三个工作日、一个工程师。

一句话:

不是「有了接口才能切换实现」,是「所有调用都锁定在接口,实现才脱离了环境锁定」。

接口不是为了抽象而抽象,是为了「年后的你」能走。

下一篇 05.多用组合和少继承——你会发现:接口能解决「调用方锁死」,但解不了「多个能力拼装」。后者需要 OOP 最被滥用也最被误解的机制:继承 vs 组合。

上次更新: 2026/06/17, 11:43:57
接口vs抽象类比较
多用组合和少继承

← 接口vs抽象类比较 多用组合和少继承→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式