ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java接口与实现:多实现装配、动态代理与幂等设计

Java接口与实现:多实现装配、动态代理与幂等设计 Java 圈子里有个挺有意思的现象面试时问“接口和抽象类有什么区别”几乎人人都能背上几句可真到了项目里能把接口边界划清楚、把多实现管明白、把接口幂等性兜住的人比例就掉得很快。我这些年带过的项目里返工最多的不是算法写错了而是接口定义得太“贪”——一个接口里塞十几个方法三个实现类里有两个是空实现或者直接抛异常。这种代码编译能过跑起来也能跑但半年后再回来看基本没人敢动。这篇东西围绕Java 接口与实现展开从语法细节一路聊到工程落地接口定义的坑、默认方法与函数式接口的取舍、多实现的装配逻辑、动态代理为什么必须依赖接口、接口幂等性怎么设计、JWT 续签里接口该怎么切。中间会有一段完整的可跑代码一个基于策略工厂的支付通道示例附带单元测试和压力测试的思路。适合刚学完 Java 基础想往上走一步的同学也适合工作两三年、面试题能背但工程手感还差一点的开发者。我不打算把 API 文档抄一遍重点写那些文档里不写、但实际会踩的东西。1. 接口到底解决什么问题从一次重构事故说起我最初对接口的理解说白了就是“老师要求这么写”。后来接手一个支付模块才明白接口真正管的是变化的方向。当时那个模块里微信、支付宝、银联三个通道的代码是复制粘贴出来的三份每份八百多行字段名叫法都不统一一个叫orderNo一个叫out_trade_no还有一个叫tradeId。上线新通道时要改四个地方漏一个就出线上问题。那次重构的核心动作只有一件事抽出一个PaymentChannel接口把“下单、查询、退款、回调验签”这四个稳定动作固定下来把“参数怎么拼、签名怎么算、返回怎么解析”这些易变的部分留给实现类。抽完之后新增一个通道只需要写一个实现类加一行注册配置其他代码一行不动。这就是接口的第一层价值它把调用方从具体是谁里解放出来。1.1 抽象与解耦的真实收益很多人把“解耦”当成一个虚词其实它非常具体。解耦的意思是上层代码在编译期只认识接口不认识实现类。这意味着你换实现上层的 class 文件压根不需要重新编译源码也不用动。举个我常用的例子public interface SmsSender { SendResult send(String phone, String content); }业务代码里只依赖SmsSender。今天接的是云厂商 A明天因为计费策略换成厂商 B业务代码零改动改的是 Spring 容器里注入了哪个实现。这里有个很容易忽略的细节接口方法的返回值必须是抽象类型如果SendResult是个具体的厂商 SDK 类那上层就被迫依赖了厂商的 jar接口白抽了。所以返回值要么是自己定义的 DTO要么是 JDK 里的类型比如List、Optional、Map。这也是为什么热词里“list 接口”出现频率那么高——集合框架本身就是接口设计最经典的样板List是接口ArrayList、LinkedList是两种不同取舍的实现。再往深一层说接口还承担了契约测试的职责。同一个接口的多个实现必须满足同一套行为约定。我在项目里会给关键接口写一套契约测试基类用抽象类实现每个实现类继承它跑一遍同样的断言。这样新同事加实现时只要继承基类行为一致性自动被验证比口头约定靠谱得多。1.2 接口与抽象类选型判断的实际标准面试八股文里标准答案是“接口是行为的抽象抽象类是模板的复用”这话没错但不够用来做决策。我自己的判断标准是三条按顺序问第一调用方关心的是“你能做什么”还是“你是什么”只关心能力用接口。第二多个实现之间有没有共享的代码或状态如果有大量共享逻辑和字段抽象类更合适。第三未来是否需要多继承行为Java 类是单继承接口可以多实现如果一个类需要同时具备两组能力只能靠接口。对比维度接口抽象类关键字interface/implementsabstract class/extends继承数量可多实现只能单继承实例字段不允许只能是public static final常量可定义任意字段构造器无有供子类调用方法体Java 8 起支持default/staticJava 9 支持private支持普通方法访问修饰符方法默认public不能是protected/private私有方法除外任意典型场景能力契约、SPI、回调、AOP 切面目标模板方法、共享状态、部分实现复用表里有几行值得展开。接口的方法默认就是public abstract你写void send()和写public abstract void send()完全等价但很多新人不知道接口里不能写protected方法。抽象类适合“模板方法模式”父类把骨架写好把变化的步骤定义成抽象方法交给子类像AbstractList就是这么干的。而接口适合“能力声明”一个类可以同时Serializable、Comparable、Runnable这在类继承体系里做不到。1.3 我在项目里定接口的三条土规矩规矩一接口方法数控制在 5 个以内。超过 5 个大概率是职责没拆开比如把“查询”和“写入”混在一个接口里。读多写少的场景读写接口分开实现类可以实现两个但调用方只依赖它需要的那个这叫接口隔离原则不是什么高深理论。规矩二接口只暴露业务语义不暴露技术细节。见过有人在接口里写了Result execute(MapString, Object params)参数是一个大 Map。这种设计看着灵活实际上把参数校验、类型安全全丢了调用方传错 key 编译期发现不了。宁愿定义一个RefundCommand对象字段类型明确改起来也有据可查。规矩三不要为了“以后可能扩展”提前加方法。接口加方法是破坏性变更所有实现类都得跟着改。我见过一个接口里留了default void preCheck() {}空实现说是“以后可能用”三年了没人实现过反而误导后来的人以为这里有什么钩子。接口的扩展点应该用默认方法配事件机制而不是挖坑不填。2. 接口定义的语法细节与易错点语法这东西看着简单但接口这一块的细节比类多因为它的默认修饰符规则跟类不一样。我统计过我们组代码评审里被打回的接口相关代码八成集中在三类问题修饰符写错、默认方法冲突、泛型擦除导致的类型不安全。这一章就把这些摊开讲。2.1 方法修饰符的默认规则接口里的成员编译器会自动补上修饰符这个自动补全规则必须记牢否则会遇到“为什么我什么修饰符都没写它却是 public static final”这种困惑。字段隐式public static final等价于常量。所以接口里不能定义实例字段也不能定义可变量。抽象方法隐式public abstract。default方法隐式public必须写default关键字可以有方法体能被子类或实现类覆盖。static方法隐式public属于接口本身不被实现类继承只能通过接口名.方法名()调用。private方法Java 9只能被接口内部的default或static方法调用用于抽取公共逻辑不对外暴露。这里有个非常容易踩的坑接口的静态方法不能被实现类“继承”。我见过同事写了channel.of(...)想调用接口静态方法编译不过因为of属于接口不属于实现类正确写法是PaymentChannel.of(...)。同理接口里也不能有final方法因为final和abstract互斥。再看一个反直觉的点接口里其实是可以声明equals、hashCode、toString这些方法签名的编译器不会报错。但这么写通常没意义因为Object已经提供了实现任何实现类都已经满足该契约。真正需要注意的是这些Object的 public 方法不计入函数式接口的抽象方法计数这一点后面讲FunctionalInterface时会用到。public interface Configurable { String DEFAULT_ENV prod; // 隐式 public static final void reload(); // 隐式 public abstract default boolean isProd() { // 默认方法 return DEFAULT_ENV.equals(currentEnv()); } static Configurable noop() { // 静态方法只能 Configurable.noop() return () - { }; } private String currentEnv() { // Java 9内部复用 return System.getProperty(env, DEFAULT_ENV); } }2.2 默认方法的冲突规则菱形继承怎么办Java 8 引入默认方法是为了在给老接口加方法时不破坏已有实现比如Collection加stream()几百万个实现类不可能全部改一遍。但默认方法带来了一个必须处理的问题如果一个类实现了两个接口而这两个接口有同名同参数的默认方法会怎样答案是编译报错。这是好事编译器强迫你显式表态而不是偷偷选一个。处理规则有三条按优先级类中的具体方法优先级最高类里重写了就用类里的。如果类没重写但两个接口的默认方法冲突编译期直接报class inherits unrelated defaults for xxx。子接口的默认方法优先于父接口接口之间的继承。冲突时的解法是显式覆盖并用接口名.super.方法名()指定调用哪个public interface A { default String tag() { return A; } } public interface B { default String tag() { return B; } } public class AB implements A, B { Override public String tag() { return A.super.tag() B.super.tag(); } }这套规则看起来是语法题实际项目里真会遇到。我们有个埋点接口Taggable和缓存接口Cacheable各有一个key()默认方法某个实现类同时实现了两者编译直接红。当时新同事的第一反应是“把其中一个接口的默认方法删掉”这就是错误解法——那会破坏其他实现类的行为。正确做法是在冲突类里显式覆盖明确表达业务上到底该用哪个 key。注意接口名.super.方法名()只能在直接实现该接口的类里使用不能在孙类里跨层调用。如果你在继承链的第三层才发现冲突说明层的设计可能本身就有问题。2.3 函数式接口一个抽象方法的精确含义FunctionalInterface这个注解本身是可选的它的作用是让编译器帮你检查“这个接口到底是不是函数式接口”不符合就编译报错。判断标准只有一个有且仅有一个抽象方法。默认方法、静态方法、Object的 public 方法都不算。这里有个很多人答错的面试点如果一个接口声明了boolean equals(Object o)它是函数式接口吗答案是是。因为equals的签名跟Object的 public 方法一致不计入抽象方法数。同理toString、hashCode也一样但finalize()不行因为它是protected的。FunctionalInterface public interface ValidatorT { boolean validate(T target); // 唯一的抽象方法 default ValidatorT and(ValidatorT other) { return t - this.validate(t) other.validate(t); } default ValidatorT negate() { return t - !this.validate(t); } boolean equals(Object o); // 不算抽象方法 String toString(); // 不算抽象方法 }抽方法时有个经验只要一个接口的实现类里出现了大量只写一行的匿名类就该考虑是不是能改成函数式接口。比如各种Handler、Callback、Converter用 lambda 写可读性会好很多。但要注意 lambda 的this指向的是外层对象不是 lambda 自身如果你在实现里需要引用自己比如递归调用或者需要多方法还是老老实实写实现类。2.4 编译期报错速查接口相关的报错信息有时候挺绕我把常见的几个列一下方便对着报错定位报错信息真实原因处理办法cannot reduce the visibility of the inherited method实现类把接口方法写成了包级或protected实现方法必须加publicclass inherits unrelated defaults for xxx多个接口默认方法签名冲突在类中显式覆盖并指定接口.superis not abstract and does not override abstract method少实现了接口方法补实现或把类声明为abstractillegal combination of modifiers: abstract and private接口里写了矛盾的修饰符检查方法修饰符组合incompatible types: xxx cannot be converted to泛型擦除或返回类型不匹配检查泛型声明与返回类型有个细节值得单独说接口方法是public的实现类里写Override时必须保持public。新手最常犯的错是在实现类里顺手写了不带修饰符的方法编译器提示“不能降低可见性”这时候不是编译器刁难你而是接口契约要求实现必须对外可见否则调用方通过接口引用就调不到了。3. 接口在工程中的落地装配、代理与幂等语法过了接下来是最容易出生产事故的部分。接口在工程里从来不是孤立存在的它要跟 Spring 容器、代理框架、数据库、外部系统打交道。这一章讲四件事同一接口多实现怎么按需装配、动态代理为什么离不开接口、接口幂等性怎么设计、JWT 续签里接口怎么切。3.1 同一接口多实现怎么装配与选择一个接口有多个实现时Spring 按类型注入会直接报NoUniqueBeanDefinitionException因为容器不知道你要哪个。三种解法第一种是Primary标记默认实现简单直接但只能有一个适合“主通道 备用通道”的场景。第二种是Qualifier(beanName)在注入点指定缺点是调用方得知道 bean 名字硬编码了实现细节。第三种是策略工厂模式接口里定义supports(type)工厂启动时把所有实现收集成 Map调用时按类型查。第三种最灵活也是我在支付、短信、物流这类多通道场景的标准做法。public interface PaymentChannel { boolean supports(ChannelType type); PayResult pay(PayCommand command); RefundResult refund(RefundCommand command); } Component public class PaymentChannelFactory { private final MapChannelType, PaymentChannel registry new EnumMap(ChannelType.class); public PaymentChannelFactory(ListPaymentChannel channels) { for (PaymentChannel channel : channels) { for (ChannelType type : ChannelType.values()) { if (channel.supports(type)) { registry.put(type, channel); } } } } public PaymentChannel get(ChannelType type) { PaymentChannel channel registry.get(type); if (channel null) { throw new IllegalStateException(未注册的支付通道: type); } return channel; } }这里有个坑要提醒supports我故意设计成接收枚举而不是字符串就是为了让新增通道时编译器能帮忙检查用字符串的话拼错一个字母要到运行时才发现。另外构造注入ListPaymentChannel是 Spring 的特性它会自动把所有实现类装进列表比手动维护注册表省事但要注意如果一个实现类没加Component它不会进列表这种问题排查起来比较隐蔽日志里不会有任何提示。3.2 JDK 动态代理与 CGLIB为什么接口是前置条件JDK 动态代理的原理是运行时用Proxy.newProxyInstance生成一个实现了指定接口的代理类方法调用被转发到InvocationHandler.invoke。注意关键词是实现了指定接口也就是说被代理的目标必须有接口否则 JDK 代理根本没法生成。这就是为什么 Spring AOP 在目标类没有接口时会退化成 CGLIB——不是它想换是被逼的。两者的差异用一张表说比较清楚对比项JDK 动态代理CGLIB依赖条件必须实现接口无需接口通过继承生成子类生成方式实现接口继承目标类并覆盖方法无法代理的情况目标没有接口final类、final方法、private方法性能调用开销略高创建快创建慢调用快Spring AOP 默认有接口时使用无接口或强制proxyTargetClasstrueInvocationHandler的写法本身不难难的是别在 invoke 里漏掉method.invoke(target, args)的异常处理。InvocationTargetException会把真实业务异常包一层如果不 unwrap上层拿到的异常类型就不对了日志里也看不到原始堆栈。我的习惯是统一 catch 后抛出causeOverride public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start System.nanoTime(); try { return method.invoke(target, args); } catch (InvocationTargetException e) { throw e.getTargetException(); } finally { metrics.record(method.getName(), System.nanoTime() - start); } }注意Transactional、Cacheable这类注解失效九成是因为自调用——同一个类里 A 方法直接调用 B 方法不走代理对象注解自然不生效。这不是注解的 bug是代理机制的必然结果。想避免就把方法拆到另一个 bean 里或者注入自身代理。3.3 接口幂等性三种实现路线怎么选接口幂等性是热词里出现频率很高的一个也是线上面试必问。定义很朴素同一个请求执行一次和执行多次对系统状态的影响相同。查询天然幂等新增和扣款不是所以要专门设计。路线一数据库唯一索引。最省事插入时靠唯一约束拦重复捕获DuplicateKeyException返回已有结果。适合“创建订单”这种天然有唯一业务号的场景缺点是依赖数据库分库分表后唯一索引只在单库生效。路线二幂等表 / token 机制。客户端先申请一个一次性 token提交时带上服务端用INSERT ...抢占抢到才执行业务执行完更新状态。这条路通用性最强代价是多一次请求和一张表。路线三状态机 乐观锁。适合有明确状态流转的业务比如订单从“待支付”到“已支付”更新语句带上原状态条件WHERE status PENDING影响行数为 0 就说明已被处理过。我一般组合使用入口用 token 拦重复提交核心扣款用状态机兜底。因为 token 有可能因为网络重试被绕过状态机是最后一道防线。这里有个实操细节幂等键的生成不要用System.currentTimeMillis()同一毫秒内的并发请求会撞key我踩过这个坑后来统一改成业务号加用户 ID 加时间戳的组合。3.4 JWT 续签中的接口职责划分JWT 的痛点是过期时间和安全性天然矛盾有效期长泄露风险大有效期短用户频繁掉线。常见解法是双 tokenaccessToken短比如 30 分钟refreshToken长比如 7 天配一个专门的刷新接口。接口设计上我会把认证能力拆成独立的接口而不是塞进用户服务public interface TokenService { TokenPair issue(Long userId); TokenPair refresh(String refreshToken); void revoke(String refreshToken); } public interface TokenValidator { TokenPayload validate(String accessToken); }拆开的理由是签发、校验、吊销三件事的变化频率完全不同。签发要对接密钥管理校验要放进网关做高频调用吊销要对接黑名单存储。放在一个接口里网关为了校验得依赖整个签发逻辑包依赖就重了。校验接口要设计得足够轻最好不依赖数据库用本地缓存的公钥验签这样网关的 QPS 压力不会传导到数据库。具体到续签流程刷新接口要处理三件事验证refreshToken签名和有效期、检查是否在黑名单用户主动登出或被踢下线、签发新的一对 token 并让旧的refreshToken失效。第三点很多人漏掉导致一个refreshToken能被无限次使用等于没有过期时间。实现上可以用 Redis 记录已使用的refreshToken并设置与原有效期一致的 TTL或者用版本号机制用户表上存一个tokenVersion签发时写进载荷刷新时比对不一致就拒绝。4. 源码级实操手写一个可复用的接口分层示例前面讲的都是判断和原则这一章直接上一套能跑的代码。场景设定一个通知发送模块支持短信、邮件两个渠道要求支持多渠道切换、支持发送前后的切面统计、支持失败重试并且提供一套契约测试保证两个渠道行为一致。4.1 包结构设计与分层思路先定包结构这个顺序很重要接口放哪个包决定了谁会依赖谁com.example.notify ├── api 对外暴露的接口与 DTO │ ├── NotifyChannel.java │ ├── NotifyCommand.java │ ├── NotifyResult.java │ └── ChannelType.java ├── support 公共支撑 │ ├── NotifyException.java │ └── AbstractNotifyChannel.java ├── channel 具体实现 │ ├── SmsNotifyChannel.java │ └── EmailNotifyChannel.java ├── factory │ └── NotifyChannelFactory.java └── aspect └── NotifyMetricsAspect.java分层的原则是api包不依赖任何实现包channel包依赖apifactory依赖两者但不被实现依赖。这样任何实现类的改动都不会影响接口反过来接口加方法会影响所有实现——所以接口要设计得稳。AbstractNotifyChannel这个抽象类是给实现类复用的把参数校验、日志、异常包装这些重复逻辑放进去实现类只写“怎么发”。这就是接口加抽象类的组合拳接口管契约抽象类管复用。4.2 核心接口与抽象基类实现先看接口和数据结构。注意NotifyCommand我用Builder而不是构造器因为通知参数多七个八个参数用构造器调用方根本记不住顺序。public enum ChannelType { SMS, EMAIL } public final class NotifyCommand { private final String target; // 手机号或邮箱 private final String templateId; private final MapString, String variables; private final String bizId; // 业务幂等键 private NotifyCommand(Builder b) { this.target b.target; this.templateId b.templateId; this.variables Collections.unmodifiableMap(new HashMap(b.variables)); this.bizId b.bizId; } public String target() { return target; } public String templateId() { return templateId; } public MapString, String variables() { return variables; } public String bizId() { return bizId; } public static Builder builder() { return new Builder(); } public static final class Builder { private String target; private String templateId; private MapString, String variables new HashMap(); private String bizId; public Builder target(String target) { this.target target; return this; } public Builder templateId(String id) { this.templateId id; return this; } public Builder put(String key, String value) { this.variables.put(key, value); return this; } public Builder bizId(String bizId) { this.bizId bizId; return this; } public NotifyCommand build() { return new NotifyCommand(this); } } } public interface NotifyChannel { boolean supports(ChannelType type); NotifyResult send(NotifyCommand command); default String name() { return getClass().getSimpleName(); } }NotifyResult我建议设计成带状态和错误码的对象而不是直接返回boolean。返回boolean的话失败原因就丢了排查线上问题时只能靠翻日志而且调用方也没法针对不同失败码做不同处理比如“号码格式错误”应该直接失败“通道限流”应该进重试队列。抽象基类负责统一入口处理用模板方法模式把流程固定下来public abstract class AbstractNotifyChannel implements NotifyChannel { protected final Logger log LoggerFactory.getLogger(getClass()); Override public final NotifyResult send(NotifyCommand command) { validate(command); String requestId UUID.randomUUID().toString().replace(-, ); long start System.currentTimeMillis(); try { NotifyResult result doSend(command, requestId); log.info(notify success channel{} bizId{} requestId{} cost{}ms, name(), command.bizId(), requestId, System.currentTimeMillis() - start); return result; } catch (NotifyException e) { log.warn(notify failed channel{} bizId{} code{}, name(), command.bizId(), e.getCode(), e); return NotifyResult.fail(e.getCode(), e.getMessage()); } catch (Exception e) { log.error(notify error channel{} bizId{}, name(), command.bizId(), e); return NotifyResult.fail(UNKNOWN, 通道异常); } } protected abstract NotifyResult doSend(NotifyCommand command, String requestId); protected void validate(NotifyCommand command) { if (command null || command.target() null || command.target().isBlank()) { throw new NotifyException(INVALID_TARGET, 接收方不能为空); } if (command.templateId() null || command.templateId().isBlank()) { throw new NotifyException(INVALID_TEMPLATE, 模板不能为空); } } }这里send方法加了final是为了防止实现类覆盖掉统一逻辑。这是个很实用的技巧抽象类里“不变的部分”就锁死“变化的部分”用抽象方法留口子。如果哪天有人想在子类里加个不写日志的快捷路径final会直接拦住他。validate做成protected而非private是给子类扩展校验的机会比如邮箱通道想额外校验邮箱格式覆盖后调用super.validate(command)即可。4.3 两个实现类与工厂装配短信通道实现里真实的 SDK 调用我用注释标出来了你可以替换成任意云厂商的客户端Component public class SmsNotifyChannel extends AbstractNotifyChannel { private final SmsClient client; public SmsNotifyChannel(SmsClient client) { this.client client; } Override public boolean supports(ChannelType type) { return type ChannelType.SMS; } Override protected void validate(NotifyCommand command) { super.validate(command); if (!command.target().matches(^1[3-9]\\d{9}$)) { throw new NotifyException(INVALID_PHONE, 手机号格式不正确); } } Override protected NotifyResult doSend(NotifyCommand command, String requestId) { SmsRequest request new SmsRequest(); request.setPhone(command.target()); request.setTemplate(command.templateId()); request.setParams(command.variables()); request.setOutId(requestId); SmsResponse response client.send(request); if (response.isSuccess()) { return NotifyResult.ok(response.getMessageId()); } if (response.isRateLimited()) { throw new NotifyException(RATE_LIMIT, 通道限流); } throw new NotifyException(response.getCode(), response.getMessage()); } }注意requestId的用法它既是日志追踪号也传给第三方做对账用的outId。很多通道的接口都支持传入自定义外部单号用它来做幂等或者问题追溯非常方便这比事后拿时间戳去对账高效得多。另外手机号校验我用的是正则实际项目里建议把校验规则抽成独立的Validator组件复用别每个通道各写一份。邮箱通道结构一样只是校验规则和doSend不同不再重复贴。重点看工厂Component public class NotifyChannelFactory { private final MapChannelType, NotifyChannel registry new EnumMap(ChannelType.class); public NotifyChannelFactory(ListNotifyChannel channels) { for (NotifyChannel channel : channels) { for (ChannelType type : ChannelType.values()) { if (channel.supports(type)) { NotifyChannel exists registry.putIfAbsent(type, channel); if (exists ! null) { throw new IllegalStateException( 通道重复注册: type - exists.name() / channel.name()); } } } } for (ChannelType type : ChannelType.values()) { if (!registry.containsKey(type)) { throw new IllegalStateException(通道未实现: type); } } } public NotifyChannel route(ChannelType type) { return registry.get(type); } }工厂里我加了两道启动检查重复注册直接抛异常有枚举没有对应实现也抛异常。这两个检查的价值在启动期就暴露问题而不是等线上某个通道被调用时才报空指针。我见过因为漏了实现导致的线上事故某类通知一直静默失败因为调用方拿了 null 不做检查异常被上层吞了用户三天没收到验证码才有人反馈。4.4 契约测试与压力测试契约测试用一个抽象基类所有通道实现共享同一套断言public abstract class NotifyChannelContractTest { protected abstract NotifyChannel channel(); protected abstract ChannelType type(); protected abstract void mockSendSuccess(String requestId); Test void 支持声明的通道类型() { assertTrue(channel().supports(type())); } Test void 目标为空时返回失败结果() { NotifyResult result channel().send( NotifyCommand.builder().templateId(T1).build()); assertEquals(INVALID_TARGET, result.getCode()); } Test void 通道异常时不抛异常而是返回失败码() { mockSendSuccess(null); // 模拟第三方抛异常 NotifyResult result channel().send( NotifyCommand.builder().target(validTarget()).templateId(T1).build()); assertFalse(result.isSuccess()); assertNotNull(result.getCode()); } protected abstract String validTarget(); }契约测试的关键是断言行为而不只是断言结果。比如“通道异常时返回失败码而不是抛异常”这条它约束的是错误处理方式。如果某个新实现直接抛出去上层没 catch整条链路就断了。把这种约定写进测试基类比写在文档里有效一万倍因为文档没人看测试挂了必须修。压力测试这块接口层面的压测主要看三件事吞吐量、P99 耗时、错误率。工具上 JMeter 适合带业务链路的场景命令行工具适合纯接口的快速验证。压测时要注意两点先做数据隔离别拿生产数据练手分清瓶颈位置接口本身很快但依赖的数据库慢压测只会把数据库压垮指标里看到的耗时其实是等待时间。我一般会在压测时同步看线程池队列长度和数据库连接池活跃数这两个指标比接口耗时更能说明问题。5. 常见问题与排查技巧实录这一章是我这些年踩过的坑的合订本都是能在别人的代码或自己的代码里直接对上的问题。5.1 接口多实现相关的问题问题一NoUniqueBeanDefinitionException。现象是启动直接失败报“expected single matching bean but found 2”。原因是有两个实现类都标注了ComponentSpring 按类型注入时无法决定。解决办法是加Primary或者改用工厂模式。我推荐工厂因为Primary只是把矛盾藏起来了哪天有人加了第三个实现同样的报错会再来一次。问题二注入了实现类导致切面失效。如果你用Resource或按类型注入具体实现类Spring 可能给你一个原始对象而不是代理对象Transactional就不生效了。判断方法是打印obj.getClass().getName()看到$$EnhancerBySpringCGLIB或$Proxy说明是代理看到原始类名就要警惕。问题三接口方法返回值改类型导致下游编译失败。接口的返回类型变更属于破坏性变更比如把ListString改成CollectionString虽然运行时通常兼容但下游有List特有操作的代码会编译不过。更好的做法是新增一个方法老方法标Deprecated并保留一段时间给使用方迁移窗口。5.2 常用问题速查表现象可能原因定位手段处理方案启动报 bean 冲突同接口多实现无默认看完整异常栈里的 bean 名加工厂路由或Qualifier注解不生效自调用绕过代理打印对象类名看是否有代理标记拆到独立 bean 或注入自身AbstractMethodError运行期接口与编译期不一致检查依赖版本是否冲突统一 jar 版本清理本地缓存重编NoSuchMethodError接口新增方法但实现类未更新看堆栈里的类加载器重新编译发布确认依赖树默认方法冲突多接口同名默认方法编译器已直接报错类中显式覆盖并指定接口.super静态方法调不到通过实现类调用接口静态方法编译器提示找不到符号改成接口名.方法名()表里AbstractMethodError和NoSuchMethodError这两个特别值得说它们都属于编译期和运行期不一致导致的问题典型场景是有人把接口 jar 升级了但实现类的 jar 还是旧的。这类问题排查时不要只看源码一定要用mvn dependency:tree把依赖树打出来经常能发现同一个 artifact 存在两个版本被传递依赖带进来了。5.3 我的几条排查心得第一条异常堆栈从最下面的 Caused by 往上看。框架包装过的异常最外层往往是“调用失败”这种没信息量的话真正的根因在Caused by里而且要看最深的那个。第二条接口相关的问题先确认到底调的是哪个实现。日志里带上getClass().getSimpleName()或者实现类自己提供的name()方法一行日志能省两小时排查。第三条不要靠猜靠可观测性。接口调用量、耗时、错误码分布这三个指标是判断问题范围的最快方式。如果错误码集中在一个通道那问题在通道实现如果所有通道都在涨那问题在上游或依赖。说完排查还有一段关于接口设计节奏的感受。我现在的习惯是新模块先写接口再写实现写接口的时候不去想怎么实现只想“调用方需要什么、返回什么、失败怎么表达”。这个习惯养成之后代码返工率明显下降。反倒是那些一上来就写实现类的模块往往写到一半发现接口不匹配只能推倒重来。接口定义本质上是把需求翻译成契约契约想清楚了实现只是填空。接口这个主题往下还能挖很多比如applicationContext这类框架层的接口设计思路、List接口背后集合框架的取舍、SPI 机制怎么做插件化扩展每一个都能单独写一篇。我个人的体会是Java 基础里最值得反复琢磨的就是接口因为它同时连接着语法、设计原则和工程实践学一遍用不上用一遍才真懂。等你哪天在代码评审里能一眼看出某个接口该拆、某个默认方法不该加这关就算过了。
返回列表