还原故障现场,挖掘通用场景,寻求最佳解决方案!!!
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元无门槛券
私享最新 技术干货