Java接口设计与实现:默认方法、动态代理与接口幂等治理
刚入行那会儿我总觉得 Java 接口是个挺虚的东西——定义一堆没有方法体的方法再让别的类去实现绕了一圈不还是调那些实现类的方法吗直到有一次接手支付模块里面散落着三个渠道的逻辑每个调用点都在写 if-else 判断渠道类型。要加第四个渠道的时候我改了十一个文件还漏了一处上线当天就报了错。那次之后我才真正明白Java 接口与实现这套机制解决的不是语法层面的复用问题而是把变化约束在一个可以整体替换的边界上。接口定义的是契约实现交付的是能力调用方只认契约不认人这才是它真正的价值。这篇内容我打算把 Java 接口从定义到实现、从语法细节到工程治理完整讲一遍。不管你是刚学完 Java 基础、被 abstract 和 interface 的区别绕晕的新手还是写了几年业务代码、想补一补动态代理和接口幂等这些工程话题的老手都能在里面找到能直接用的东西。我会尽量少讲空泛概念多给能抄的代码和踩过的坑包括默认方法的冲突处理、JDK 动态代理和 CGLIB 的分工、接口版本怎么演进才不炸线上以及面试里那几道几乎必问的题该怎么答。1. 接口设计的第一性问题到底该固定什么、放开什么1.1 从调用方视角倒推接口很多人写接口的习惯是先把实现想清楚再抽个接口出来这个顺序其实是反的。接口是给调用方看的所以应该从调用方的需求倒推他要完成一件事最少需要知道哪些信息、调用哪些动作其余的全部藏起来。拿 JDK 集合框架举例List接口只承诺了有序、可重复、能用下标访问这几件事至于底层是数组还是链表调用方一概不管。ArrayList和LinkedList的实现差异巨大一个随机访问是 O(1)一个是 O(n)但它们对外的契约是一致的。这就是接口的威力它把能做什么和怎么做到彻底分开了。接口这个词在硬件领域也天天出现。USB 定义了引脚、电压和时序插上就能通信至于对面插的是 U 盘、键盘还是网卡主机完全不关心。软件接口是同一个思路只不过引脚换成了方法签名电压换成了参数和返回值的约定。这里有个容易犯的错误叫大接口。我见过一个UserService接口里面塞了注册、登录、改密码、查积分、发消息、导出报表二十多个方法结果每个实现类都得实现全部方法其中一半直接throw new UnsupportedOperationException()。这就是典型的违反接口隔离原则。正确的做法是按调用方拆前台需要UserQueryService后台管理需要UserAdminService各取所需互不牵连。判断接口粒度是否合适我一般用两个问题自检第一实现这个接口的类是不是每个方法都用得上第二调用这个接口的地方是不是每次都能用上它声明的大部分方法两个问题只要有一个答案是否就该考虑拆了。1.2 接口和抽象类的边界到底在哪这是初学者最容易纠结的地方也是面试的常客。我用一句话概括抽象类描述的是是什么接口描述的是能做什么。狗是动物所以继承Animal抽象类狗会叫所以实现Barkable接口。一只机器狗也会叫但它不是动物照样能实现Barkable。从语法能力上看两者的差异可以整理成下面这张表对比项接口抽象类构造器没有有成员变量只能是 public static final 常量任意类型、任意修饰符方法体Java 8 起支持 default 和 static 方法可以有具体方法多继承一个类可以实现多个接口只能继承一个父类访问修饰符方法隐式 public可 public / protected / 包级设计意图定义能力契约、解耦复用代码、模板方法这张表里最容易被忽略的是最后一行。抽象类的核心价值在于代码复用比如模板方法模式AbstractExportTask把读取、转换、写出的骨架写好只留一个doWrite()让子类实现。接口的核心价值在于解耦和替换它不关心你能不能复用代码。所以选型逻辑很清楚如果你有一批类共享一段稳定的骨架逻辑并且它们本质上是同一类事物用抽象类如果你只是想让一批毫无血缘关系的类具备某种可被统一调用的能力用接口。现实里两者经常搭配使用。比如定义Repository接口约定数据访问能力再提供一个AbstractRepository抽象类实现掉公共的连接管理、日志、异常转换具体实现类继承抽象类即可。这样接口负责契约稳定抽象类负责省事各司其职。2. 接口定义的完整细节语法、修饰符与版本演进2.1 一个标准接口到底长什么样先看一段最基础的定义把它拆开揉碎讲public interface DataSyncService { // 常量隐式 public static final String DEFAULT_CHARSET UTF-8; // 抽象方法隐式 public abstract SyncResult sync(String sourceId, SyncOptions options); // 默认方法Java 8 引入 default boolean supports(String sourceId) { return sourceId ! null !sourceId.isEmpty(); } // 静态方法Java 8 引入属于接口本身 static DataSyncService noop() { return (sourceId, options) - SyncResult.empty(); } // 私有方法Java 9 引入供上面的默认/静态方法复用 private void log(String msg) { System.out.println([sync] msg); } // 嵌套接口隐式 public static interface SyncOptions { int getBatchSize(); } }几个细节值得单独说。第一接口里的成员变量不管你写不写修饰符编译器都会补成public static final所以DEFAULT_CHARSET是个常量而且可以通过DataSyncService.DEFAULT_CHARSET直接访问。很多人第一次看到接口里定义变量会懵其实就是这个规则。第二接口里的方法在 Java 8 之前只能是抽象方法隐式public。注意这里没有abstract也得是abstract而且不能是 protected 或 private——早期版本里私有方法会直接编译报错直到 Java 9 才放开。第三嵌套接口默认是static的不需要也不能显式写static写了反而在某些老版本编译器上报警告。它的常见用途是把紧耦合的类型收拢在同一个命名空间下比如Map.Entry、ApplicationContext里那一堆嵌套接口。还有一点新手常踩的坑接口不能被实例化但可以声明引用。DataSyncService service new DbSyncServiceImpl();这行代码左边是接口类型右边是实现类编译期检查的是接口有没有sync方法运行期执行的是实现类的版本。这个编译看左边、运行看右边的规则是理解多态和动态代理的基础后面第 3 节会重点展开。2.2 默认方法带来的便利与新麻烦Java 8 引入default方法官方说法是为了在不破坏已有实现类的前提下给接口加新方法。这个理由非常实在Collection接口在 Java 8 里加了几十个方法如果全是抽象方法全世界的Collection实现类都得重写一遍那场面没法看。default的实际用法很简单给个默认实现实现类不覆盖就用接口的。但真正麻烦的是菱形继承如果一个类实现了两个接口而这两个接口有签名相同的默认方法编译器会直接报错要求你必须重写interface A { default String hello() { return A; } } interface B { default String hello() { return B; } } class C implements A, B { // 不重写会编译报错继承了两个不相关的 hello() 默认实现 Override public String hello() { return A.super.hello() B.super.hello(); } }A.super.hello()这个语法是调用指定接口默认实现的唯一方式注意它只在类的实例方法里可用静态上下文里写不了。还有一条规则很容易记混类优先于接口。如果一个类继承的父类里有一个具体方法同时又实现了一个带同名默认方法的接口那么父类的方法胜出接口的默认实现被忽略。这条规则保证了向后兼容但也常常让人困惑——明明接口里写了默认实现怎么跑的是父类的遇到这种情况先看继承链多半是类路径上的某个父类把它遮住了。static方法则是另一个维度它属于接口本身不会被子接口继承也不能被实现类调用。DataSyncService.noop()可以调但DbSyncServiceImpl.noop()编译不过。这个设计是有意为之避免了静态方法在继承体系里到处乱窜造成歧义。注意默认方法虽然方便但不要拿它来藏业务逻辑。它对实现类是不可见的隐式行为排查问题时很容易被忽略。我见过一个项目在接口默认方法里做了脏数据过滤结果某个实现类出问题时排查了整整一天最后才发现是这层默认逻辑干的。默认方法只适合放真正稳定的、与业务无关的通用逻辑比如空值判断、默认参数、日志埋点。2.3 函数式接口与 Lambda 的配合方式一旦接口里只剩一个抽象方法它就成了函数式接口可以用 Lambda 或方法引用来写。FunctionalInterface这个注解是给编译器看的加上之后如果你手滑写了两个抽象方法编译期就会报错。注意它只约束抽象方法的数量默认方法和静态方法不算。JDK 内置的四大函数式接口值得背下来日常写代码用得极多接口入参出参典型用途ConsumerTT无遍历消费、回调SupplierT无T延迟创建、对象池FunctionT,RTR类型转换、映射PredicateTTboolean过滤、校验用起来是这样的FunctionString, Integer len String::length; PredicateString notBlank s - s ! null !s.trim().isEmpty(); ConsumerString printer System.out::println; ListString names List.of(a, , bb); names.stream() .filter(notBlank) .map(len) .forEach(System.out::println); // 输出 1 和 2这里有个实用技巧Supplier最大的价值是延迟执行。比如日志场景如果日志级别是 DEBUG 才启动的昂贵计算写成log.debug(() - buildExpensiveMessage())就只在需要时才计算比起先拼接字符串再判断级别性能差别在高频路径上非常明显。自定义函数式接口时命名建议带上语义比如OrderValidator、PriceCalculator比MyFunction好读太多。另外尽量继承自标准接口比如FunctionalInterface interface Validator extends PredicateOrder这样能直接复用and、or、negate这些组合方法。3. 实现层类实现、策略组合与动态代理3.1 类实现与方法调用的完整链路一个类实现接口核心要求是覆盖所有抽象方法否则这个类必须声明为抽象类。方法签名必须完全一致返回类型可以是被返回类型的子类型协变返回抛出的受检异常不能比接口声明的更宽。真正值得关注的是运行期的调用链路。JVM 里有两条方法调用指令容易搞混invokevirtual用于普通实例方法invokeinterface用于接口方法。为什么接口调用要单独一条指令因为类的方法分派可以通过固定偏移量的虚方法表高效完成而接口方法需要额外的接口方法表来查找历史上性能略差一些不过现代 JVM 在这块做了大量优化实际差距已经很小。再说多态。下面这段代码是理解一切的钥匙public interface Notifier { void send(String msg); } public class SmsNotifier implements Notifier { Override public void send(String msg) { System.out.println(sms: msg); } } Notifier n new SmsNotifier(); n.send(hello); // 运行期才决定调用 SmsNotifier.send编译器看到n.send时只知道Notifier有这个方法至于具体执行哪个类的方法要等运行期看n实际指向什么对象。这个机制叫动态分派是策略模式、依赖注入、AOP 全部的基础。理解它之后你就会明白为什么很多框架只需要拿到接口类型就能做事——注入的对象可以是任意实现框架根本不需要知道是哪一个。还有个常见的认知误区接口里的方法默认都是public所以实现类重写时不能降低可见性。写成protected void send(...)会直接编译失败。同理接口里不能定义final方法默认方法也不行否则实现类无法覆盖。3.2 策略模式加工厂把 if-else 干净地干掉回到我开头提到的支付模块那个坑。当时的问题就是调用点直接判断渠道类型代码长这样if (wechat.equals(channel)) { // 微信支付逻辑 } else if (alipay.equals(channel)) { // 支付宝逻辑 } else if (unionpay.equals(channel)) { // 银联逻辑 }加第四个渠道就要动这里而且是所有调用点都要动。用接口重构之后思路变成这样public interface PayChannel { String code(); // 渠道标识 PayResult pay(PayRequest request); // 支付能力 } Component public class WechatPayChannel implements PayChannel { Override public String code() { return wechat; } Override public PayResult pay(PayRequest request) { // 具体调用微信的能力 return PayResult.success(); } } Component public class PayChannelFactory { private final MapString, PayChannel channelMap new HashMap(); // 构造注入所有实现Spring 会自动把 List 塞进来 public PayChannelFactory(ListPayChannel channels) { for (PayChannel c : channels) { channelMap.put(c.code(), c); } } public PayChannel get(String code) { PayChannel channel channelMap.get(code); if (channel null) { throw new IllegalArgumentException(unsupported channel: code); } return channel; } }改动之后调用方只剩一行factory.get(channel).pay(request)加新渠道只需新增一个实现类其他代码一行不动。这正好符合开闭原则对扩展开放对修改关闭。这里有个我踩过的坑值得说。最初我在工厂里用PostConstruct去注册结果测试时手动 new 工厂channelMap是空的排查了半天。改成构造器注入之后依赖关系变成了显式的测试也简单了——直接new PayChannelFactory(List.of(new WechatPayChannel()))就能跑。能构造注入就别用字段注入这是我在接口工厂这套组合里最实在的一条经验。顺带提一个性能方面的考量。这种工厂模式在渠道数量少十几个以内时毫无压力HashMap查找是 O(1)。但如果实现类多到几百个启动时全部初始化可能拖慢启动速度这时候可以考虑懒加载注册的是SupplierPayChannel而不是实例真正用到时才创建。要不要优化用启动耗时数据说话别凭感觉提前优化。3.3 动态代理让接口在运行期被改写动态代理是接口体系里最能体现威力的部分。核心思想是在运行期动态生成一个实现了指定接口的类把接口方法的所有调用转发到你写的处理器里你就能在不改动业务代码的前提下插入日志、事务、重试、权限校验。JDK 自带的实现只需要三样东西类加载器、要实现的接口数组、一个InvocationHandler。public class RetryHandler implements InvocationHandler { private final Object target; private final int maxRetry; public RetryHandler(Object target, int maxRetry) { this.target target; this.maxRetry maxRetry; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Throwable last null; for (int i 0; i maxRetry; i) { try { return method.invoke(target, args); } catch (InvocationTargetException e) { last e.getTargetException(); } } throw last; } } // 生成代理对象 DataSyncService real new DbSyncServiceImpl(); DataSyncService proxy (DataSyncService) Proxy.newProxyInstance( real.getClass().getClassLoader(), new Class?[]{DataSyncService.class}, new RetryHandler(real, 3)); proxy.sync(src-1, options); // 自动带重试几个必须知道的限制。第一JDK 动态代理只能代理接口目标对象必须至少实现一个接口否则Proxy.newProxyInstance生成的类没法被当成目标类型使用。第二代理类继承自Proxy所以它已经没有位置再继承别的类了这是 Java 单继承导致的天然约束。第三method.invoke抛出的异常会被包装成InvocationTargetException如果不拆包直接往外抛调用方看到的异常类型就变了这个坑非常隐蔽。需要代理没有接口的类时就得靠 CGLIB 这类基于继承的方案它生成目标类的子类并覆盖方法。代价是 final 类和 final 方法没法代理。Spring AOP 默认策略在不同版本里有变化早期默认走 JDK 代理后来默认改成 CGLIB具体行为受配置项影响做技术选型时最好在项目里实测确认一下不要凭记忆下结论。我自己总结的选型原则是如果目标本来就是接口调用优先 JDK 代理它不依赖额外库、语义干净如果目标是个没有接口的具体类或者需要代理类自身的方法用 CGLIB。另外要注意自调用问题——在同一个类里this.method()调用不会经过代理事务注解、日志注解都会失效。我见过太多人栽在这个点上解决办法通常是把方法拆到另一个 Bean 里或者通过注入自身代理来调用。4. 接口的工程化治理幂等、兼容与验证4.1 接口幂等性怎么落地幂等的意思是同一个请求执行一次和执行多次对系统状态的影响相同。查询天然幂等但下单、扣款、发券这些写操作不处理就会出问题——用户在弱网环境下点了两次提交或者消息队列重投了一次订单就多了一笔。常见的几种实现方式我按适用场景排一下方案原理适用场景代价唯一索引数据库层拦截重复插入创建类操作需要设计唯一键幂等令牌先申请 token提交时校验并删除表单提交、支付需要额外存储状态机只允许特定状态流转订单、工单需梳理状态图去重表记录已处理的业务标识消息消费表会持续增长分布式锁同一标识串行执行并发抢单锁的性能开销我最常用的组合是唯一索引兜底 业务标识去重。唯一索引是最后一道防线不管上游怎么重试数据库都不会写进两条去重表或缓存则负责提前拦截避免无谓的写入压力。具体到代码用状态机约束流转是最干净的做法public enum OrderStatus { CREATED, PAID, SHIPPED, FINISHED; public boolean canTransferTo(OrderStatus target) { return switch (this) { case CREATED - target PAID; case PAID - target SHIPPED; case SHIPPED - target FINISHED; case FINISHED - false; }; } } public void pay(Long orderId) { Order order orderMapper.selectById(orderId); if (!order.getStatus().canTransferTo(OrderStatus.PAID)) { // 已经是 PAID 或更后面的状态直接返回成功保证幂等 return; } // 用带状态条件的更新避免并发下重复扣款 int rows orderMapper.updateStatus(orderId, OrderStatus.CREATED, OrderStatus.PAID); if (rows 0) { return; // 被其他线程抢先更新了 } // 真正的扣款、发券逻辑 }关键在于那句带旧状态条件的 update它把检查和更新合并成了一个原子操作比先查再改安全得多。这个方法我在高并发场景下用了很多次比加分布式锁简单也比纯靠缓存判断可靠。注意幂等判断要放在业务逻辑最前面而不是写完之后。我见过有同事在方法末尾加了幂等校验结果金额已经扣了才判断出重复只能再写补偿逻辑成本翻倍。4.2 接口版本演进与向后兼容接口一旦发布出去改动它的成本就陡然上升。你永远不知道有多少实现在依赖它尤其是这个接口被当作 SDK 暴露给外部团队时。Java 层面提供了几个工具。最温和的是新增默认方法实现类不覆盖也能正常编译运行这是官方推荐的首选手段。其次是给旧方法加Deprecated并注明替代方案给调用方留出迁移时间。最激烈的是删除或改签名这基本等同于破坏性变更只能靠大版本号隔离。我自己的实践约定有这么几条。第一接口尽量保持窄方法少意味着变更面小把易变的部分单独拆成扩展接口。第二新增能力优先走新接口比如已有DataSyncService需要加异步能力时不要往它里面塞方法而是定义AsyncDataSyncService extends DataSyncService让需要的实现类去实现。第三返回值尽量用对象而不是基本类型这样以后加字段不会破坏签名。第四参数也尽量用对象封装sync(SyncOptions options)就比sync(String a, int b, boolean c)好扩展得多加参数时不用改签名。还有一个细节如果接口返回的是集合务必返回不可变副本或有明确文档说明的可变对象。JDK 的List.of()返回的是不可变列表调用方一旦尝试add就会抛UnsupportedOperationException。这类问题在接口契约里必须写清楚否则就是给使用者埋雷。4.3 接口的测试与压测怎么下手接口写完不等于能上线验证环节同样要围绕接口这个契约来做。单元测试层面接口的最大好处是可以轻松 Mock。因为调用方依赖的是接口测试时换成假实现就行不需要启动数据库和外部服务。我会给每个接口准备一个内存实现比如InMemoryDataSyncService它既能用于测试也能当本地开发的桩。public class InMemoryDataSyncService implements DataSyncService { private final ListSyncResult results new ArrayList(); Override public SyncResult sync(String sourceId, SyncOptions options) { SyncResult r SyncResult.of(sourceId, options.getBatchSize()); results.add(r); return r; } public ListSyncResult getResults() { return results; } }契约测试是另一个值得投入的方向。同一套测试用例跑在接口的多个实现上保证行为一致。这套东西在替换底层实现时价值极大我就靠它把存储层从一种方案换成另一种方案全量用例跑一遍就敢上线。压测方面关注的核心指标是 QPS、平均响应时间、P99 响应时间和错误率。P99 比平均值重要得多平均值好看但 P99 飙高说明有部分请求在排队或触发慢查询用户体验照样差。做压测时要注意几个容易忽略的点连接池大小要跟并发线程数匹配池子太小会先成为瓶颈压测机自身的网络和 CPU 要留足余量别把压测机压成瓶颈数据量要接近生产规模几千条数据的查询和几千万条完全是两回事单接口压测和混合场景压测结果差异很大有条件尽量做后者。指标怎么定没有标准答案但有个经验做法先跑出当前系统的极限再按 60% 到 70% 的水位设告警阈值给突发留出余量。压测发现问题后优化顺序一般是先看慢查询和索引再看锁竞争和线程池配置最后才考虑加机器。5. 常见问题与排查技巧实录5.1 高频报错速查与定位思路接口相关的报错有几类特别典型我把它们整理成了速查表遇到时可以对照着看报错常见成因排查方向AbstractMethodError实现类没实现新增的抽象方法检查依赖版本是否对齐NoSuchMethodError编译期和运行期的接口版本不一致用mvn dependency:tree找冲突ClassCastException强转到未实现的接口确认对象的实际类型UnsupportedOperationException调用了不可变集合的修改方法检查是否返回了List.of()IncompatibleClassChangeError类被改成了接口或反过来排查依赖里的同名类InvocationTargetException动态代理里目标方法抛异常拆包取getTargetException()其中AbstractMethodError和NoSuchMethodError最让人头疼因为它们编译期完全正常只有运行到那一行才炸。根本原因几乎都是同一个类或接口存在多个版本。典型场景是A 模块依赖了core-1.0B 模块依赖了core-2.0Maven 的依赖传递调解选了其中一个导致运行时加载的接口版本和编译时的不一样。我一般按这个顺序排查第一步用dependency:tree把依赖树打出来搜索相关包名看看有几个版本进来第二步用dependencyManagement强制统一版本第三步如果还有问题检查是不是有第三方包把类打进自己的 jar 里了这种 shaded jar 最难发现需要用jar tf看包内容。另一个高频问题是动态代理下注解失效。事务、缓存、自定义注解几乎都依赖代理而代理只对从外部进来的调用生效。类内部this.foo()这种自调用不会经过代理注解自然就不起作用了。定位方法很直接在方法入口打断点看调用栈里有没有代理类那一层。解决办法是把方法挪到另一个 Bean或者注入自身引用再调用。5.2 面试里那几道必问的题怎么答接口相关的面试题翻来覆去就那么几道但答法差别很大。我按自己的理解给个思路。接口能不能有构造器不能。接口不能被实例化也就没有构造器。但要注意接口里的成员变量是隐式的public static final常量在接口初始化时就会被赋值这部分逻辑在编译后会放到一个静态初始化块里和构造器完全是两回事。一个类能实现多个接口吗接口能继承多个接口吗都能。类只能单继承父类但可以实现多个接口接口之间用extends可以同时继承多个父接口。这也解释了为什么 Java 8 之后要专门处理默认方法的菱形继承冲突。抽象类和接口怎么选按我第 1 节的框架答抽象类表达是什么、能复用代码、支持字段和构造器接口表达能做什么、支持多实现、更利于解耦和替换。实际项目中常常组合使用。动态代理的实现原理JDK 代理基于接口运行期生成一个实现了指定接口和Proxy的子类把调用转发给InvocationHandlerCGLIB 基于继承生成目标类的子类并覆盖方法。前者要求有接口后者要求类和方法非 final。接口幂等怎么设计从业务标识入手讲清楚唯一索引兜底、状态机约束、去重表或令牌机制并强调幂等判断要前置。如果能举出自己项目里的具体数字比如加了带状态条件的更新之后重复下单从每天几十笔降到零说服力会强很多。真正拉开差距的不是背答案而是能说出为什么这么选。比如被问到为什么用接口而不是直接调实现类回答为了解耦太空洞加上我们当时有三个渠道实现加新渠道时只新增一个类调用方零改动这样的具体场景分量完全不同。5.3 几个反直觉的踩坑记录最后分享几个我在实际项目中遇到过的、说出来你可能不太信的问题。第一个是默认方法的序列化问题。给一个可序列化的接口加了默认方法后某些序列化框架在反序列化时会尝试调用它来初始化结果因为依赖的对象字段还没赋值而抛异常。这个问题的隐蔽之处在于本地测试完全正常只有在特定框架组合下才复现。教训是接口上的默认方法尽量保持无副作用不要访问实例状态。第二个是接口常量被内联。接口里的static final常量如果是编译期常量比如String字面量、基本类型字面量javac 会把它的值直接内联到调用方的字节码里。这意味着你改了接口里的常量值但调用方没有重新编译它仍然用着旧值。这个坑在跨模块发布时特别容易踩。规避方式是不要在接口里定义会被内联的编译期常量需要常量时用枚举或者工具类的静态方法。第三个是泛型擦除带来的接口冲突。一个类不能同时实现ComparableString和ComparableInteger因为擦除后两个接口的方法签名完全一样编译器会报继承了两个不同参数化的同名接口。这个问题在写通用组件时偶尔会遇到解决办法通常是拆成两个不同的接口用不同的方法名区分。第四个是对象序列化后接口版本不匹配。把对象存进缓存后来给接口加了个方法反序列化时用的是新版本的类但缓存里的数据是旧版本写的字段对不上就出问题。这类问题在缓存和消息队列场景很常见处理方式是给序列化的数据结构加版本号或者干脆用独立的 DTO 而不是直接序列化领域对象。写到这里我在接口这件事上最深的体会是接口设计的好坏在你加第二个实现类的时候才会暴露。只有一个实现类时怎么设计都看不出问题等到要替换、要扩展、要加代理的时候那些当初图省事留下的毛病才会全部冒出来。所以每次定义接口前我都会逼自己想一下半年后如果要在不改调用方的前提下换掉这个实现现在的设计能扛住吗如果答案是犹豫的那就再改一版。