契约思维驱动开发:OpenAPI的最佳实践
文章目录
- 引言
- 软件设计中的规范
- 命名规范
- 异常处理规范
- 服务提供者的异常处理
- 服务调用者的异常处理
- 架构规范
- 软件设计中的标准
- Java规范
- API设计标准
- 依赖契约的扩展机制
- 基于接口的扩展
- 1. Spring ApplicationListener 扩展示例
- 接口定义 (框架提供)
- 用户自定义实现 (扩展点)
- 框架调用逻辑 (Spring内部实现)
- 2. Logback AppenderBase 扩展示例
- 抽象类定义 (框架提供)
- 用户自定义 Appender (扩展点)
- 配置文件 (约定契约)
- 扩展机制核心特点
- 基于配置数据的扩展
- 示例 1:数据库连接池配置(通过属性文件约定)
- 数据契约定义
- 框架解析逻辑
- 用户使用方式
- 示例 2:动态策略加载(通过类名约定)
- 数据契约定义
- 框架解析逻辑
- 用户扩展方式
- 示例 3:SLF4J 日志绑定(通过类名和静态方法约定)
- 数据契约定义
- 用户扩展实现
- 框架调用逻辑
- 关键设计对比
- 核心特点
引言
开发过程中大概率会碰到这样的场景: 代码评估要两天,而修改代码只需要几分钟。这种局面主要是由系统的混乱无序造成的,因为缺少规范和约束,代码的复杂度随意滋长,导致维护困难。面对这样的代码,往往要花费很长时间去厘清代码结构和业务逻辑,而真正需要修改的点也许只是“一行代码”而已。
为了保证软件编程风格的一致性,减少随心所欲带来的复杂度,我们有必要使用契约思维制定一定程度上的编程规范,去约束团队的行为。规范的价值,就在于它能保证代码的一致性,而一致性在很大程度上可以降低认知成本和复杂度
软件设计中的规范
软件需要规范,然而规范并不是越多越好。每个团队都应该根据自己的实际情况,制定能够帮助团队降低系统复杂度、避免混乱的规范。
一般至少会在团队中落实命名规范、异常处理规范、架构规范。 这3个规范可以有效地帮助团队治理代码复杂度。
命名规范
每个开发团队都应该有自己的命名规范,确保命名的一致性。对于核心的领域概念,应该有一个核心领域词汇表,确保这些领域词汇在代码中的表达是一致的.
在一个商品项目中一个和库存相关的逻辑,在短短的一段代码中,就有3种不同的对库存的描述方式,分别是Stock、Inventory、Sellable Amout。这意味着对同一个领域概念,需要理解3遍,这极大地增加了我的记忆负担和认知成本
如果团队有一个命名规范——核心领域词汇表,加上团队成员的共同遵守和维护,其实上述问题是可以避免的
在项目之初就整理出核心的领域概念,然后用中英文的形式,把这些概念放到设计文档中。要求英文的原因是,需要保持领域概念从文档到代码的一致性。 这种方式一方面规范了项目代码的命名,另一方面也让其他的技术人员更深入地理解了该业务。
异常处理规范
在异常处理方面,我们可以分别从服务提供者和服务调用者两个视角去看
服务提供者的异常处理
很多系统都没有异常规范,主要体现在没有统一的异常处理,以及对异常码没有规定。这种规范的缺失会导致系统中到处充斥着try-catch-throw的代码片段。这种代码缺少美感,会影响我们阅读代码的体验,割裂思考的连续性。
-
对于异常,建议最好采用failfast(快速失败)的策略,即一旦发生异常,则终止当前的处理流程,抛出异常即可,然后在最外层进行统一的异常处理。不要在内部进行过多的try-catch,也不要把异常包装成返回值,这些都会额外增加关于异常的处理成本。
-
对于业务系统而言,基本上只需要定义两类异常(Exception),一个是BizException(业务异常),另一个是SysException(系统异常),而且这两个异常都应该是Unchecked Exception。
-
不建议使用Checked Exception,是因为它破坏了开闭原则。如果你在一个方法中抛出了Checked Exception,而catch语句在3个层级之上,那么你需要在catch语句和抛出异常处理之间的每个方法签名中声明该异常。这意味着对软件中较低层级的修改都将波及较高层级,修改好的模块必须重新构建、发布,即便它们自身所关注的任何东西都没被改动过。这也是C#、Python和Ruby都不支持Checked Exception的原因,因为其依赖成本要高于显式声明带来的收益。
-
最后,针对业务异常和系统异常要做统一的异常处理,类似于AOP,在应用处理请求的切面上进行异常处理收敛
因为我们只有一个异常类型BizException,所以针对不同的异常,需要通过不同的异常码(ErrorCode)或者不同的异常消息(ErrorMessage)来做区分。ErrorCode也需要有规范做约束,但要不要使用ErrorCode,使用什么样的ErrorCode,要视具体场景而定。
对于平台、底层系统或软件产品,可以采用编号式的编码规范,这种编码的好处是编码风格固定,给人一种正式感;缺点是必须要配合文档,才能理解错误码代表的意思.
ORA-00001:违反唯一约束条件。
• ORA-00017:请求会话以设置跟踪事件。
• ORA-00018:超出最大会话数。
• ORA-00019:超出最大会话许可数。
• ORA-00023:会话引用进程私用内存;无法分离会话。
• ORA-00024:单一进程模式下不允许从多个进程注册。
然而对于大部分的业务系统而言,完全没有必要采用这种正式的ErrorCode编码风格。因为你不是一个通用系统,使用者只是你的上游系统,因此简单高效处理就好,可以选择不要ErrorCode,仅用ErrorMessage来传达错误信息;或者使用一个基于字符串的、自明的ErrorCode编码,譬如业务系统中有需要表示为“客户姓名不能为空”的ErrorCode,将其定义为CustomerNameIsNull即可。
服务调用者的异常处理
在分布式环境下,一个功能往往需要多个服务的协作才能完成。在使用远程服务的过程中,难免会出现各种异常,比如网络异常、服务自身的异常等。对于那些对可用性要求非常高的场景,我们有必要制定一个服务重试、服务降级的策略,以便当其中一个服务不可用的时候,我们仍然能够对外提供服务。
对于服务重试(Retry),我们需要查看异常种类,如果是BizException,则说明是服务调用者自己的问题(缺少参数、参数不正确,或者不满足业务规则),这时不需要重试;如果是SysException或者其他的系统Error,则可以进行重试。
如果重试仍然不能解决问题,可以考虑服务降级。
架构规范
COLA , Clean Object-oriented and Layered Architecture “整洁的面向对象分层架构”
软件设计中的标准
Java规范
对于从事Java后端开发的人员来说,一定对JCP(Java Community Process)不陌生,JCP是一个开放的国际组织,维护的规范包括J2ME、J2SE、J2EE、XML、OSS、JAIN等。组织成员可以提交Java规范请求(Java Specification Requests, JSR),JSR是由JCP成员向委员会提交的Java发展议案,经过一系列流程后,如果通过,最终会体现在未来版本的Java中。
比如: JSR-315是一份详细的规范说明,其中对Servlet的概念、相关Interface(接口),以及如何实现Servlet都给出了明确的定义和规定,但是并没有限制要如何实现
得益于这样的标准规范设计,不管是Tomcat、Jetty、JBoss,所有基于Servlet的应用程序都可以无缝地运行在任何一个Servlet容器中。只有这样,SpringMVC才是一个通用的MVC框架,它的实现不依赖于具体的Web容器
API设计标准
好的系统架构离不开好的API设计,一个设计不够完善的API注定会导致系统的后续发展和维护非常困难。良好的API设计要至少遵循3个标准,分别是可理解性、封装性和可扩展性
-
可理解性: 代码首先是写给人读的,其次才是被机器运行的 。 API是系统的门面,是使用者了解系统的窗口。一个清晰可理解的API可以显著降低认知成本,提升团队之间的协作效率
-
封装性: API应该是对实现细节的封装,也就是说,好的API设计和好的类设计是类似的,即不应该过多地暴露实现细节,只对外暴露可见的公共(public)部分即可。 封装性做得不好的原因大致有两个,一个是技术实现细节的暴露,另一个是内部业务语义的外泄。
-
可扩展性: API作为一个重要契约,一旦发布出去,想要修改就会变得比较困难。被外部使用越多的API,其变动越困难。因此,我们在设计API的时候,最好预留一定的扩展性,以备不时之需
依赖契约的扩展机制
软件中的扩展机制基本也是依赖契约完成的。扩展的实现方式一般有两种,
- 一种是基于接口的扩展,这是依赖接口的契约;
- 另一种是基于配置数据的扩展,这是依赖数据的契约。
基于接口的扩展
基于接口的扩展主要利用面向对象的多态机制,需要先在组件中定义一个接口(或抽象方法)和处理该接口的模板,然后由用户实现自己的定制。
这种扩展方式在框架设计中被广泛使用。例如Spring中的ApplicationListener
,用户可以用Listener实现容器初始化之后的特殊处理。再比如logback中的AppenderBase
,用户可以通过继承AppenderBase
实现定制的Appender
诉求(向消息队列发送日志)
Spring ApplicationListener 和 Logback AppenderBase 代码示例,展示基于接口的扩展机制
1. Spring ApplicationListener 扩展示例
接口定义 (框架提供)
Spring 框架中定义的监听器接口:
// Spring框架定义的接口,用户需要实现它
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener {
void onApplicationEvent(E event);
}
用户自定义实现 (扩展点)
用户实现 ApplicationListener
监听容器刷新事件:
// 用户自定义监听器,监听容器初始化完成事件
@Component
public class CustomApplicationListener implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// 容器初始化完成后执行的定制逻辑
System.out.println("Spring容器初始化完成!执行自定义初始化操作...");
// 例如:加载缓存、启动定时任务等
}
}
框架调用逻辑 (Spring内部实现)
Spring 容器在初始化完成后,会自动发布 ContextRefreshedEvent
事件,并调用所有注册的监听器:
// 伪代码:Spring框架的事件广播逻辑
public void publishEvent(ApplicationEvent event) {
for (ApplicationListener<?> listener : listeners) {
invokeListener(listener, event); // 调用监听器的 onApplicationEvent 方法
}
}
2. Logback AppenderBase 扩展示例
抽象类定义 (框架提供)
Logback 框架中定义的 AppenderBase
抽象类:
// Logback框架提供的抽象类,用户需要继承它
public abstract class AppenderBase<E> implements Appender<E> {
// 基础方法(框架已实现)
public void start() { /* 初始化资源 */ }
public void stop() { /* 释放资源 */ }
// 抽象方法需要用户实现
protected abstract void append(E event);
}
用户自定义 Appender (扩展点)
用户继承 AppenderBase
实现将日志发送到消息队列:
// 用户自定义Appender,将日志发送到消息队列
public class MQAppender extends AppenderBase<ILoggingEvent> {
private String queueName; // 可扩展配置参数
@Override
protected void append(ILoggingEvent event) {
// 将日志事件发送到消息队列
String logMessage = event.getFormattedMessage();
sendToMQ(queueName, logMessage); // 伪代码:实际需调用MQ客户端API
}
// 支持配置注入
public void setQueueName(String queueName) {
this.queueName = queueName;
}
}
配置文件 (约定契约)
在 logback.xml
中配置自定义 Appender:
<configuration>
<appender name="MQ_APPENDER" class="com.example.MQAppender">
<queueName>log_queue</queueName>
</appender>
<root level="INFO">
<appender-ref ref="MQ_APPENDER" />
</root>
</configuration>
扩展机制核心特点
特点 | 说明 |
---|---|
契约定义 | 框架定义接口/抽象类,明确扩展点的方法签名和调用规则 |
实现隔离 | 扩展实现与框架逻辑解耦,通过多态动态绑定 |
配置化 | 可通过配置文件(如XML、注解)声明扩展实现,无需修改框架代码 |
开放闭合原则 | 框架对扩展开放(允许新增Appender/Listener),对修改闭合(无需改动框架核心逻辑) |
这种模式通过 接口契约 + 配置化 实现了灵活的扩展能力,是框架设计中常用的扩展点设计方法。
基于配置数据的扩展
基于配置数据的扩展,首先要约定一个数据格式,然后利用用户提供的数据组装成实例对象,用户提供的数据是对象中的属性(有时也可能是类,比如SLF4J中的StaticLoggerBinder
)
我们一般在应用中使用键值对(key-value pair)的方式来配置需要修改的属性。这里契约发挥的作用是,配置文件的格式及数据值必须遵循设计的约定,否则配置不能按照预期发挥作用。
比如要使用logback,就必须按照logback的约定去设置logback.xml。如果破坏了这个契约,那么logback就不能正确地从配置文件中解析用户的配置。
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- 定义变量 -->
<property name="LOG_PATH" value="logs" />
<property name="APP_NAME" value="myapp" />
<property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" />
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{36}) - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<!-- 仅当在开发环境时启用(示例条件) -->
<!-- <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>DEBUG</level>
</filter> -->
</appender>
<!-- 文件滚动记录 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 每日滚动 + 文件大小限制 -->
<fileNamePattern>${LOG_PATH}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory> <!-- 保留30天 -->
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- 错误日志单独记录 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>90</maxHistory>
</rollingPolicy>
</appender>
<!-- 日志级别配置 -->
<logger name="com.example" level="DEBUG" /> <!-- 指定包日志级别 -->
<logger name="org.springframework" level="WARN" />
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</configuration>
当然,扩展点自身是一种接口扩展,而获取扩展点的实现方式是数据扩展。
以下是 基于配置数据的扩展 的代码示例,通过约定数据格式(如键值对、类名等)实现扩展能力:
示例 1:数据库连接池配置(通过属性文件约定)
数据契约定义
框架约定配置文件格式(如 database.properties
),要求必须包含以下键值对:
# 必须包含以下 key,且值类型合法
jdbc.url=jdbc:mysql://localhost:3306/test
jdbc.username=root
jdbc.password=123456
jdbc.pool.maxSize=20
框架解析逻辑
框架读取配置文件并封装为配置对象:
public class DatabaseConfig {
private String url;
private String username;
private String password;
private int maxPoolSize;
// 通过反射或手动解析 properties 填充属性
public void load(Properties props) {
this.url = props.getProperty("jdbc.url");
this.username = props.getProperty("jdbc.username");
this.password = props.getProperty("jdbc.password");
this.maxPoolSize = Integer.parseInt(props.getProperty("jdbc.pool.maxSize"));
}
// 根据配置创建数据源
public DataSource createDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setMaximumPoolSize(maxPoolSize);
return new HikariDataSource(config);
}
}
用户使用方式
用户只需修改配置文件即可扩展连接池行为,无需修改代码:
# 用户修改配置文件调整行为
jdbc.pool.maxSize=50 # 扩展连接池容量
示例 2:动态策略加载(通过类名约定)
数据契约定义
框架约定在配置文件中指定策略实现类的全限定名(如 strategy-config.xml
):
<strategies>
<payment-strategy class="com.example.AlipayStrategy"/> <!-- 必须实现 PaymentStrategy 接口 -->
</strategies>
框架解析逻辑
框架读取配置并动态加载类:
public interface PaymentStrategy {
void pay(double amount);
}
public class StrategyFactory {
public static PaymentStrategy createStrategy(String className) {
try {
Class<?> clazz = Class.forName(className);
return (PaymentStrategy) clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Failed to load strategy: " + className, e);
}
}
}
// 使用示例
PaymentStrategy strategy = StrategyFactory.createStrategy("com.example.AlipayStrategy");
strategy.pay(100.0);
用户扩展方式
用户新增策略实现类并修改配置文件:
// 用户新增微信支付策略
public class WechatPayStrategy implements PaymentStrategy {
@Override
public void pay(double amount) {
System.out.println("微信支付: " + amount);
}
}
修改配置文件即可切换策略:
<strategies>
<payment-strategy class="com.example.WechatPayStrategy"/> <!-- 切换实现类 -->
</strategies>
示例 3:SLF4J 日志绑定(通过类名和静态方法约定)
数据契约定义
SLF4J 要求用户必须在类路径中提供 org.slf4j.impl.StaticLoggerBinder
类,且该类必须实现以下方法:
public class StaticLoggerBinder {
public static StaticLoggerBinder getSingleton() { /* ... */ }
public ILoggerFactory getLoggerFactory() { /* ... */ }
}
用户扩展实现
日志框架(如 Logback)遵守契约提供实现:
// Logback 提供的 StaticLoggerBinder(位于 logback-classic 库中)
package org.slf4j.impl;
public class StaticLoggerBinder {
private static final StaticLoggerBinder SINGLETON = new StaticLoggerBinder();
public static StaticLoggerBinder getSingleton() {
return SINGLETON;
}
public ILoggerFactory getLoggerFactory() {
return new LoggerContext(); // Logback 的上下文实现
}
}
框架调用逻辑
SLF4J 通过静态绑定加载用户提供的实现:
// SLF4J 的 LoggerFactory 类(框架侧)
public final class LoggerFactory {
private static ILoggerFactory getILoggerFactory() {
return StaticLoggerBinder.getSingleton().getLoggerFactory();
}
}
关键设计对比
设计要素 | 基于接口的扩展 | 基于配置的扩展 |
---|---|---|
扩展方式 | 实现接口/继承抽象类 | 修改配置文件 |
契约形式 | 接口方法签名 | 配置格式(键名、类名、方法名) |
运行时变化 | 需重新编译 | 动态生效(如热更新) |
典型场景 | 框架插件、事件监听 | 参数调整、策略切换、实现绑定 |
核心特点
- 数据即扩展:通过修改外部化配置(而非代码)改变系统行为
- 约定大于配置:必须严格遵循键名、类名、方法名等约定
- 动态性:许多框架支持配置热加载(如 Spring Cloud Config)
通过这种模式,用户只需遵守数据格式契约,即可灵活扩展系统能力,典型应用包括数据库连接池参数调整、日志实现切换、支付策略动态配置等。