还原故障现场,挖掘通用场景,寻求最佳解决方案!!!
1. 问题&分析
在仅有有限集合场景下,枚举真好用,可以通过强类型来对值的范围进行强约束。但,它的副作用也逐渐显露出来。
1.1. 案例
小艾昨晚刚刚上线一个迭代,今天早上便被报警电话吵醒,立即打开电脑查看详细日志,发现 “我的订单” 再报 NPE 错误,随后,便电话通知 QA 通知快速回滚。线上回滚完成后报警逐渐减少,最终系统恢复正常。
详细日志如下:
Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException:
Failed to convert value of type 'java.lang.String' to required type 'com.geekhalo.demo.enums.code.bug.OrderStatus';
nested exception is org.springframework.core.convert.ConversionFailedException:
Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.RequestParam com.geekhalo.demo.enums.code.bug.OrderStatus] for value 'CANCELLED';
nested exception is java.lang.IllegalArgumentException: No enum constant com.geekhalo.demo.enums.code.bug.OrderStatus.CANCELLED]
本次迭代根本就没有对这个接口进行修改,为什么会出问题?小艾面对如下代码陷入思考:
@GetMapping("myOrders")
public List myOrders(@RequestParam("status") OrderStatus status) {
// 忽略具体逻辑
}
当他点开 OrderStatus 之后才发现问题:
public enum OrderStatus{
/**
* 已创建
*/
CREATED,
/**
* 原来定义为 CANCELLED,当用户超时未支付时,系统自动取消该订单
* 下次迭代将增加手工取消订单功能,手工取消订单状态改为 MANUAL_CANCELLED
*/
TIMEOUT_CANCELLED,
/**
* 已支付
*/
PAID,
/**
* 已完成
*/
FINISHED;
}
OrderStatus 枚举中存在一个 CANCELLED 枚举项,当用户超时未支付时,系统自动取消该订单,订单状态为 CANCELLED。在下次迭代将增加手工取消订单功能,手工取消订单状态改为 MANUAL_CANCELLED,为了对两者进行区分,所以将原来的 CANCELLED 重命名为 TIMEOUT_CANCELLED。但,老的 APP 并没有调整,请求参数仍旧为 CANCELLED,导致系统异常。
1.2. 问题分析
随着系统的演进,我们会对枚举类型进行调整,包括:
添加新枚举项。
重命名已有枚举项。
调整枚举项的定义顺序。
删除已有枚举项(只废除不删除)。
但,这些操作都会对枚举的 name 和 ordrial 进行破坏。这种破坏:
在领域层是无害的,每次升级内存对象便会进行更新;
接入层可能会找不到对应的枚举而导致异常;
存储层也可能因为找不到对应的枚举而异常;
当然,你也可以定义规范:
枚举不能进行 Rename 操作;
新增枚举必须放在最后;
请谨记原则:规范是一种“无能”的表现。只会为系统埋下巨大的“雷”,然后迎来后来人的“粉身碎骨”。
2. 解决方案
既然枚举的 name 和 ordrial 会随着定义的变化而变化,那能否为其提供一个不变的 ==code== 呢?
2.1. 枚举知识
虽然编译器为枚举添加了很多功能,但究其本质,枚举终究是一个类。除了必须继承自 Enum 外,我们基本上可以将 enum 看成一个常规类,因此属性、方法、接口等在枚举中仍旧有效。
2.1.1. 枚举中的属性和方法
除了编译器为我们添加的方法外,我们也可以在枚举中添加新的属性和方法,甚至可以有main方法。
@Getter
public enum OrderStatus{
CREATED("已创建"),
TIMEOUT_CANCELLED("超时自动取消"),
MANUAL_CANCELLED("手工取消"),
PAID("已支付"),
FINISHED("已完成");
private final String description;
OrderStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
public static void main(String... args){
for (OrderStatus orderStatus : OrderStatus.values()){
System.out.println(orderStatus.toString() + ":" + orderStatus.getDescription());
}
}
}
main执行输出结果:
CREATED:已创建
TIMEOUT_CANCELLED:超时自动取消
MANUAL_CANCELLED:手工取消
PAID:已支付
FINISHED:已完成
如果准备添加自定义方法,需要在 enum 实例序列的最后添加一个分号。同时 java 要求必须先定义 enum 实例,如果在定义 enum 实例前定义任何属性和方法,那么在编译过程中会得到相应的错误信息。
enum 中的构造函数和普通类没有太多的区别,但由于只能在 enum 中使用构造函数,其默认为 private,如果尝试升级可见范围,编译器会给出相应错误信息。
2.1.2. 重写枚举方法
枚举中的方法与普通类中方法并无差别,可以对其进行重写。其中 Enum 类中的 name 和 ordrial 两个方法为final,无法重写。
@Getter
public enum OrderStatus{
CREATED("已创建"),
TIMEOUT_CANCELLED("超时自动取消"),
MANUAL_CANCELLED("手工取消"),
PAID("已支付"),
FINISHED("已完成");
private final String description;
OrderStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
/**
* @return the description
* @return
*/
@Override
public String toString() {
return description;
}
public static void main(String... args){
for (OrderStatus orderStatus : OrderStatus.values()){
System.out.println(orderStatus.name() + ":" + orderStatus.toString());
}
}
}
main输出结果为
CREATED:已创建
TIMEOUT_CANCELLED:超时自动取消
MANUAL_CANCELLED:手工取消
PAID:已支付
FINISHED:已完成
重写toString方法,返回描述信息。
2.1.3. 实现接口
由于所有的 enum 都继承自 java.lang.Enum 类,而 Java 不支持多继承,所以我们的 enum 不能再继承其他类型,但 enum 可以同时实现一个或多个接口,从而对其进行扩展。
public interface CodeBasedEnum {
int code();
}
@Getter
public enum OrderStatus2 implements CodeBasedEnum{
CREATED(1),
TIMEOUT_CANCELLED(2),
MANUAL_CANCELLED(5),
PAID(3),
FINISHED(4);
private final int code;
OrderStatus2(int code) {
this.code = code;
}
public static void main(String... args){
for (OrderStatus2 orderStatus : OrderStatus2.values()){
System.out.println(orderStatus.name() + ":" + orderStatus.getCode());
}
}
}
main函数输出结果:
CREATED:1
TIMEOUT_CANCELLED:2
MANUAL_CANCELLED:5
PAID:3
FINISHED:4
2.2. 修复方案
枚举是一个特殊的类,可以实现接口,所以可以基于接口为枚举添加统一的行为。
在这,可以为枚举增加一个 getCode 方法,以 Code 作为枚举的唯一标识,在枚举重构时只要保证 code 不变,系统就不会受到影响。
2.2.1. 构建统一接口
接口定义如下:
public interface CodeBasedEnum {
int getCode();
}
2.2.2. 枚举实现接口
新的枚举类如对:
@Getter
public enum OrderStatus implements CodeBasedEnum{
CREATED(1),
TIMEOUT_CANCELLED(2),
MANUAL_CANCELLED(5),
PAID(3),
FINISHED(4);
private final int code;
OrderStatus(int code) {
this.code = code;
}
}
2.2.3. 集成 Spring MVC
完成以上工作后,接下来便是最关键的一个操作:如何让 Spring MVC 能够将 code 转化为对应的 枚举。
Spring MVC 提供两种方式完成请求参数的类型转换。
基于 GenericConverter 的类型转换。主要处理 @RequestParam @PathVariable 等的转换
基于 HttpMessageConverter 的类型转换。主要处理 @RequestBody 注解的对象,完成 JSON 到对象的转换
2.2.3.1. CodeBasedEnumConverter
CodeBasedEnumConverter 类完成 String 到 枚举的转换,核心代码如下:
@Component
public class CodeBasedEnumConverter implements ConditionalGenericConverter {
private final List enumsCls = Lists.newArrayList();
public CodeBasedEnumConverter() {
// 注册转换类型 CodeBasedOrderStatus
this.enumsCls.add(CodeBasedOrderStatus.class);
}
/**
* 匹配实现 CodeBasedEnum 的枚举
* @param sourceType
* @param targetType
* @return
*/
@Override
public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
Class type = targetType.getType();
return this.enumsCls.stream()
.anyMatch(t -> t == type);
}
/**
* 匹配
* @return
*/
@Override
public Set getConvertibleTypes() {
return this.enumsCls.stream()
.map(t -> new ConvertiblePair(String.class, t))
.collect(Collectors.toSet());
}
/**
* 完成枚举转化
* @param source
* @param sourceType
* @param targetType
* @return
*/
@Override
public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
String value = (String) source;
// 空值处理
if (StringUtils.isEmpty(value)) {
return null;
}
Class targetCls = targetType.getType();
// 匹配失败
if (!this.enumsCls.contains(targetCls)){
return null;
}
// 遍历枚举所有 Value
for (Object enumValue : targetCls.getEnumConstants()){
// 判断枚举的 code
String code = String.valueOf(((CodeBasedEnum) enumValue).getCode());
if(value.equals(code)) {
return enumValue;
}
// 判断枚举 Name
String name = ((Enum) enumValue).name();
if (value.equals(name)){
return enumValue;
}
}
return null;
}
}
该类完成 Spring 到 CodeBasedOrderStatus 的转化,同时兼容 code 和 name 两种工作模式。
命令行执行指令:
curl -X GET "http://127.0.0.1:8090/enums/code/fix/myOrders?status=3" -H "accept: */*"
控制台输出:
status is PAID
2.2.3.2. CodeBasedEnumJacksonCustomizer
CodeBasedEnumJacksonCustomizer 完成 JSON 的类型转换,核心代码如下:
@Configuration
public class CodeBasedEnumJacksonCustomizer {
@Bean
public Jackson2ObjectMapperBuilderCustomizer commonEnumBuilderCustomizer(){
return builder ->{
// 注册自定义枚举反序列化器
builder.deserializerByType(CodeBasedOrderStatus.class, new CommonEnumJsonDeserializer(CodeBasedOrderStatus.class));
};
}
/**
* 自定义枚举反序列化器
*/
static class CommonEnumJsonDeserializer extends JsonDeserializer {
private final Class codeBasedEnumCls;
CommonEnumJsonDeserializer(Class codeBasedEnumCls) {
this.codeBasedEnumCls = codeBasedEnumCls;
}
@Override
public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
String value = jsonParser.readValueAs(String.class);
if (StringUtils.isEmpty(value)) {
return null;
}
// 遍历枚举所有 Value
for (Object enumValue : codeBasedEnumCls.getEnumConstants()){
// 判断枚举的 code
String code = String.valueOf(((CodeBasedEnum) enumValue).getCode());
if(value.equals(code)) {
return enumValue;
}
// 判断枚举 Name
String name = ((Enum) enumValue).name();
if (value.equals(name)){
return enumValue;
}
}
return null;
}
}
}
该类完成 JSON 到 CodeBasedOrderStatus 的转化,同时兼容 code 和 name 两种工作模式。
命令行执行指令:
curl -X POST "http://127.0.0.1:8090/enums/code/fix/myOrders" -H "accept: */*" -H "Content-Type: application/json" -d "{\"status\":\"1\"}"
控制台输出:
status is CREATED
2.2.4. 集成存储引擎
同样的道理,如果在存储引擎层存储枚举的 name 或 ordrial,那在重构时也会破坏原来的语义,从而引起线上问题。
由于 code 是枚举的唯一标识,在数据存储时也需要完成 code 与 枚举 间的双向转换。
应用程序与存储引擎间主要由各类 ORM 框架完成通讯,这些 ORM 框架均提供了类型映射的扩展点,通过该扩展点可以完成 code 与 枚举 的双向转换。
2.2.4.1. 集成 MyBastis
MyBatis 作为最流行的 ORM 框架,提供了 TypeHandler 用于处理自定义的类型扩展。
@MappedTypes(CodeBasedOrderStatus.class)
public class CodeBasedOrderStatusHandler extends BaseTypeHandler {
/**
* 将枚举类型转换为数据库存储的code
* @param preparedStatement
* @param i
* @param t
* @param jdbcType
* @throws SQLException
*/
@Override
public void setNonNullParameter(PreparedStatement preparedStatement, int i, CodeBasedOrderStatus t, JdbcType jdbcType) throws SQLException {
preparedStatement.setInt(i, t.getCode());
}
/**
* 数据库存储的code转换为枚举类型
* @param resultSet
* @param columnName
* @return
* @throws SQLException
*/
@Override
public CodeBasedOrderStatus getNullableResult(ResultSet resultSet, String columnName) throws SQLException {
int code = resultSet.getInt(columnName);
for (CodeBasedOrderStatus status : CodeBasedOrderStatus.values()){
if (status.getCode() == code){
return status;
}
}
return null;
}
@Override
public CodeBasedOrderStatus getNullableResult(ResultSet resultSet, int i) throws SQLException {
int code = resultSet.getInt(i);
for (CodeBasedOrderStatus status : CodeBasedOrderStatus.values()){
if (status.getCode() == code){
return status;
}
}
return null;
}
@Override
public CodeBasedOrderStatus getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
int code = callableStatement.getInt(i);
for (CodeBasedOrderStatus status : CodeBasedOrderStatus.values()){
if (status.getCode() == code){
return status;
}
}
return null;
}
}
CodeBasedOrderStatusHandler 通过 @MappedTypes(CodeBasedOrderStatus.class) 对其进行标记,以告知框架该 Handler 是用于 CodeBasedOrderStatus 类型的转换。
逻辑比较简单,直接看代码中的注解即可。
有了类型之后,需要在 spring boot 的配置文件中指定 type-handler 的加载逻辑,具体如下:
完成配置后,使用 Mapper 对数据进行持久化,数据表中存储的便是 code 信息,具体如下:
image2.2.4.2. 集成 JPA
随着 Spring data 越来越流行,JPA 又焕发出新的活力,JPA 提供 AttributeConverter 以对属性转换进行自定义。
首先,构建 CodeBasedOrderStatusConverter,具体如下:
public class CodeBasedOrderStatusConverter implements AttributeConverter {
@Override
public Integer convertToDatabaseColumn(CodeBasedOrderStatus e) {
return e.getCode();
}
@Override
public CodeBasedOrderStatus convertToEntityAttribute(Integer code) {
for (CodeBasedOrderStatus e : CodeBasedOrderStatus.values()) {
if (e.getCode() == code) {
return e;
}
}
return null;
}
}
在有了 CodeBasedOrderStatusConverter 之后,我们需要在 Entity 的属性上增加配置信息,具体如下:
@Data
@Entity
@Table(name = "order_info_jpa")
public class OrderInfoBOForJpa {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 指定枚举的转换器
*/
@Convert(converter = CodeBasedOrderStatusConverter.class)
private CodeBasedOrderStatus status;
}
@Convert(converter = CodeBasedOrderStatusConverter.class) 是对 status 的配置,使用 CodeBasedOrderStatusConverter 进行属性的转换。
运行持久化指令后,数据库如下:
image3. 示例&源码
代码仓库:https://gitee.com/litao851025/learnFromBug
代码地址:https://gitee.com/litao851025/learnFromBug/tree/master/src/main/java/com/geekhalo/demo/enums/code
领取专属 10元无门槛券
私享最新 技术干货