枚举类的final修饰

今天开发跟我反馈了一个很奇怪的问题,说有个对象的状态属性是枚举类,设置了该对象的状态后,插入数据库,这个状态没了,凭空消失了,变成了空白字符串。这让人感觉非常奇怪,整个问题排查后得到的结论和枚举类的规范有关系

问题代码

让我们先看看出问题的部分代码是什么样子的:

java 复制代码
@Override
public String insert(PayRequest payRequest) {
    // 省略部分无关代码
    PayRequestDO payRequestDO = convertor.toDO(payRequest);
    payMapper.insert(payRequestDO);
    return payRequest.getPayNo();
}

这个方法很简单,就是把传过来的 PayRequest 对象转成 PayRequestDO 对象,然后插入数据库。

PayRequest 和 PayRequestDO 都是普通的 pojo 对象,没什么复杂的,只是 PayRequestDO 的 status 换成了 String:

java 复制代码
public class PayRequest {
    private String payId;
    private String payNo;
    private Status status;
    // 省略其它属性和 getter setter
}
java 复制代码
public class PayRequestDO {
    private String payId;
    private String payNo;
    private String status;
    // 省略其它属性和 getter setter
}

至于 PayConvertor#toDO 方法,也很简单,就是属性拷贝:

java 复制代码
public PayRequestDO toDO(PayRequest payRequest) {
    PayRequestDO payRequestDO = new PayRequestDO();
    payRequestDO.setPayId(payRequest.getPayId());
    payRequestDO.setPayNo(payRequest.getPayNo());
    payRequestDO.setStatus(payRequest.getStatus().getCode());
    
    // 省略其它代码

    return payRequestDO;
}

开发一再强调,入参的 PayRequest 里面的 status 一定是有值的,而且是写死的,根本不可能是空,然后这些代码也多次检查过了,mybatis 的 mapper xml 写的也绝对没有问题,但是插入数据库就是没值,WHY ?

排查问题

先简单花些时间,排除掉一些写了代码没发布、或是部署错了版本等等类似的低级问题,确保服务器上面跑的代码就是上面贴出来的代码,这一点非常非常重要,永远是查问题时第一件要做的事情(其实大部分的问题在这一步就可以得到解决)。

这里提供两种快速确定线上的代码版本的方案:

方案一:使用 git-commit-id-plugin maven 插件

开启 spring boot 的 info actuator:

java 复制代码
 配置开放的 Actuator 端点,开放 endpoint 需要注意数据安全,可以配置不同的 management port 或脱敏敏感内容
management.endpoints.web.exposure.include=info

mvn packge 构建并以 java -jar 启动后,接下来就可以访问 localhost:8080/actuator/info 来获得当前的 git 提交信息:

java 复制代码
{
  "git":{
    "commit":{
      "time":{
        "epochSecond":17011234567,
        "nano":0
      },
      "id":"1234567"
    },
    "branch":"master"
  }
}

通过这个 commit id 就可以找到代码具体是哪个版本。

方案二:

如果提前没有集成过 git maven 插件,或者没有打开 info endpoint。你还可以把 fat jar 包 down 下来,通过反编译来确定代码版本。当然也有一些在线就可以 dump 代码的方案,例如下面即将出场的 Arthas 有个 jad 命令,还有 JDK 自带的 HSDB,也可以直接 dump 内存中的 class 到本地磁盘,感兴趣可以自行搜索。

------------------------------ 分割线 ------------------------------

排除掉了低级问题,接下来我们分析问题出在哪里。因为问题的表现就是插到数据库里面值丢了,我们可以先看下 db 的 digest 日志分析下:

java 复制代码
2023-12-04 21:06:04.221|PayCenter|00|8||N|trace8423002774916857900o38o50|payCente

通过上面的 digest 日志的 sql 可以看出来,insert sql 里面的 status 字段,传的就已经是 '' 空白字符了,这说明问题不是发生在 orm 框架里面,这里排除掉了 xml 中 sql 的语句写的不对的问题。所以问题一定发生在插入数据库之前的业务方法中。

接下来,我怀疑入参传过来的 PayRequest 里面 status 字段是没值的,我需要看下正在运行中的线程栈中 PayRequest 对象的属性值,这个场景非常适合使用 Arthas 的 watch 方法,我的 idea 里面装了一个 Arthas 的插件(名字叫 Arthas Idea by 汪小哥),我们只需要在 insert 上面右键选择 Arthas Command,选择 Watch 子菜单,就可以拿到 watch 命令了,非常方便。

接下来我们登录到服务器上,切换到 admin 用户(Arthas 启动要求你用和 java 启动同一个用户),输入刚刚拷贝下来的命令:

java 复制代码
watch com.xxxxx.paycenter.service.repository.impl.PayRequestRepositoryImpl insert '{params,returnObj,throwExp}'  -n 1  -x 3

接着我们等待方法执行到 insert,就可以观察 Arthas watch 输出的内容:

java 复制代码
method=com.xxxxx.paycenter.service.repository.impl.PayRequestRepositoryImpl$$EnhancerBySpringCGLIB$$4f979dec.insert location=AtExit
ts=2023-11-15 14:54:49; [cost=6.001557ms] result=@ArrayList[
    @Object[][
        @PayRequest[
            serialVersionUID=@Long[1],
            payId=@String[pay2023001],
            payNo=@String[20231114000000001],
            status=@Status[INIT],
            // ...
        ],
    ],
    @String[20231114000000001],
    null,
]

可以清楚地看到,这里入参的时候 status 属性还是有值的:Status.INIT

接下来,我们去看 mapper 的写数据的时候,status 属性还在不在,同样用 Arthas watch 命令:

java 复制代码
watch com.xxxxx.paycenter.infrastructure.dal.mapper.PayMapper insert '{params,returnObj,throwExp}'  -n 1  -x 3

接着等待方法执行到 mapper 的 insert,观察 Arthas watch 到的内容:

java 复制代码
Affect(class count: 2 , method count: 1) cost in 289 ms, listenerId: 10
method=com.sun.proxy.$Proxy153.insert location=AtExit
ts=2023-11-15 14:51:36; [cost=3.933553ms] result=@ArrayList[
    @Object[][
        @PayRequestDO[
            id=null,
            payId=@String[pay2023002],
            payNo=@String[20231114000000002],
            status=@String[],
            // ...
        ],
    ],
    @Integer[1],
    null,
]

可以看到,很明显,到插入数据库时候,status 已经变成空了!!!

入参的时候有,写入数据库的时候没了,那说明唯一的问题,就在中间的对象转换方法 PayConvertor#toDO 了。

用 Arthas watch 一下 toDO 的入参和出参:

java 复制代码
watch com.xxxxx.paycenter.core.convertor.PayConvertor toDO '{params,returnObj,throwExp}'  -n 1  -x 3 

输出:

java 复制代码
method=com.xxxxx.paycenter.core.convertor.PayConvertor.toDO location=AtExit
ts=2023-11-15 15:01:35; [cost=0.432887ms] result=@ArrayList[
    @Object[][
        @PayRequest[
            serialVersionUID=@Long[1],
            payId=@String[pay2023003],
            payNo=@String[20231114000000003],
            status=@Status[INIT],
            // ...
        ],
    ],
    @PayRequestDO[
        id=null,
        payId=@String[pay2023003],
        payNo=@String[20231114000000003],
        status=@String[],
        // ...
    ],
    null,
]

看输出结果,问题确实发生在 toDO 的内部,数据转换后 status 的属性没了

确切来说,下面这行代码,丢了属性:

java 复制代码
payRequestDO.setStatus(payRequest.getStatus().getCode());

找到原因

至此我们发现了原因,大概是 status 属性背后的枚举类 Status,在 getCode 的时候返回了空。

Status 的代码如下:

java 复制代码
public enum Status {

    INIT("INIT", "初始态"),
    
    SUCCESS("SUCCESS", "成功"),

    FAILED("FAILED", "失败"),
    ;

    private String code;

    private String desc;

    Status(String code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }
}

看着枚举类属性的 setter 方法,我不由得陷入了沉思:为什么一个枚举类的属性,要提供 setter 方法?

通常来说枚举类的属性,一定要设置为 final 关键字修饰,不能提供 setter 方法。试想下如果我按照下面的方式通过 setter 把 FAILED 和 SUCCESS 的 code 换过来,那这代码还能不能继续愉快的玩耍下去了?

Status.FAILED.setCode("SUCCESS");

Status.SUCCESS.setCode("FAILED");

很显然这里提供的 setter 调用直接破坏了枚举类,所以,最好的办法就是为枚举类属性加上 final。

接下来通过 watch Status 的 '{target}' 参数,'{target}' 可以打印对象内部的状态,结果输出也进一步验证了我的猜想,Status 枚举类部分枚举的 code 属性已经成了空白字符串了:

java 复制代码
watch com.xxxxx.paycenter.core.enums.Status getCode '{target}'  -n 1  -x 3

method=com.xxxxx.paycenter.core.enums.Status.getCode location=AtExit
ts=2023-11-15 15:05:36; [cost=0.005638ms] result=@ArrayList[
    @Status[
        INIT=@Status[
            INIT=@Status[INIT],
            SUCCESS=@Status[SUCCESS],
            FAILED=@Status[FAILED],
            code=@String[],
            desc=@String[],
            name=@String[INIT],
            ordinal=@Integer[0],
        ],

根本原因

直接原因基本上已经找到了,接下来我们还需要知道到底是在哪里、出于什么需求调用了枚举类的 setCode 方法,因为我在整个项目里面没有搜到显示的调用,所以修改下枚举的 setCode,增加一些代码以便能在 setCode 被调用的时候打印一下调用栈出来:

java 复制代码
public void setCode(String code) {
    try {
        throw new RuntimeException();
    } catch (Exception e) {
      log.error("code before: {}, after: {}", this.code, code, e);
    }
    this.code = code;
}

有的小伙伴反馈抛异常来看堆栈太丑了,这也提供一个不用抛异常的方案:

java 复制代码
public void setCode(String code) {
    StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
    log.error("code before: {}, after: {}", this.code, code, formatStackTrace(stackTrace));
    this.code = code;
}

public static String formatStackTrace(StackTraceElement[] stackTrace) {
    StringBuilder stringBuilder = new StringBuilder();
    for (StackTraceElement element : stackTrace) {
        stringBuilder.append(element.getClassName())
            .append(".")
            .append(element.getMethodName())
            .append("(")
            .append(element.getFileName())
            .append(":")
            .append(element.getLineNumber())
            .append(")")
            .append(System.lineSeparator());
    }
    return stringBuilder.toString();
}

增加了代码后发布上去,很快打印出来了堆栈:

这是 podam 这个第三方的库引起的问题(始作俑者还是我引入的这个库),这个库的作用是可以通过传一个 class 对象,解析出来它的属性,进行赋值,简单来说就是根据 class 生成随机对象随机属性,测试的工具会用到这个功能,这个库在解析枚举类的时候可能没实现好,导致了通过反射调用了枚举的 setter 方法,最终导致了问题。

改进措施

我们从一个数据库插入属性丢失的问题排查,最终发现问题的原因是枚举类写的不规范导致的问题。

首要的是写代码还是要注意规范,最好本地装一些扫描工具,例如 sonar,发现风险一定要尽快按照建议修复。

其次是 podam 这个第三方的库对枚举的实现方式还是有问题的,需要尽快修复掉这个 bug。

如果提交代码的时候就扫描问题,这样可以把问题扼杀在摇篮里面。

相关推荐
BillKu12 分钟前
Java + Spring Boot + Mybatis 插入数据后,获取自增 id 的方法
java·tomcat·mybatis
全栈凯哥13 分钟前
Java详解LeetCode 热题 100(26):LeetCode 142. 环形链表 II(Linked List Cycle II)详解
java·算法·leetcode·链表
chxii14 分钟前
12.7Swing控件6 JList
java
全栈凯哥16 分钟前
Java详解LeetCode 热题 100(27):LeetCode 21. 合并两个有序链表(Merge Two Sorted Lists)详解
java·算法·leetcode·链表
YuTaoShao16 分钟前
Java八股文——集合「List篇」
java·开发语言·list
PypYCCcccCc21 分钟前
支付系统架构图
java·网络·金融·系统架构
华科云商xiao徐42 分钟前
Java HttpClient实现简单网络爬虫
java·爬虫
扎瓦1 小时前
ThreadLocal 线程变量
java·后端
BillKu1 小时前
Java后端检查空条件查询
java·开发语言
jackson凌1 小时前
【Java学习笔记】String类(重点)
java·笔记·学习