首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

【故障现场】重命名引起的线上问题

还原故障现场,挖掘通用场景,寻求最佳解决方案!!!

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

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OoOC_HzBTwKxkORrM2F3q6rw0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券