基于生成式注解为类添加toString方法

在组内讨论时,有同事提建议在把对象写到日志中时最好直接输出对象不要做任何加工,也就是尽量调用对象自己的toString()方法,不要用JsonKit.toJson(obj)这样先把对象转为json字符串再输出的写法。

这个建议不是没有道理的,jackson和fastjson这些json工具将对象序列化为字符串时会有一个自动检查推测的过程,在这个过程中做了如下事情:

  1. 所有public方法,带返回值,符合“getXxx”(或“isXxx”,如果返回boolean会被称为“isgetter”)命名约定的成员方法被推测存在名字为“xxx”的属性(属性名按照bean命名约定推测,即开头大写字母转成小写)。
  2. 所有public成员字段被推测为要显示的属性,使用字段名字来序列化。

也就是说,一个“getXxx()”方法中如果做了业务性的处理,在被调用的过程中也会被执行。如下面的伪代码:

@Component
public class FetchUserAction {

    @Value("$cus.actId.fu")
    public int actionId;

    public User getCurrentUser(){
        // 从应用上下文获得当前用户ID
        ....

        // 从数据库查询用户信息
        ....

        return currentUser;
    }
}
在对`FetchUserAction`的实例进行序列化时,会得到下面的json:
{
    "actionId": "act001",
    "currentUser": {
        "userId": 123456,
        "userName": "张三"
    }
}

得到这个json的时候意味着至少已经做了 “从应用上下文获得当前用户ID” 和 “从数据库查询用户信息” 两个动作。在输出日志的时候静默的执行了一次涉及到资源的操作,这不是一个合理的事情。

当然也可以为getCurrentUser()这个方法添加类似@JsonIgnore这样的注解以避免出现上面的情况。但问题不在这里,问题的关键在于我们应该只需要对model类的实例或其对应的集合做toJson的处理;其它的执行业务处理的对象不应该被输出到日志中,即使因为种种原因不得不将之输出到日志中也不应该做toJson的处理,以避免出现类似前面的例子中的情况。

如果model类的toString()方法的返回值就已经是经过json序列化的就好了,这样我们在输出日志时就不需要显式地再做这个toJson的操作了,也就不会误将不需要json序列化的对象给序列化了。我一开始想的是通过lombok来解决这个问题,因为model类一般是依赖lombok来生成toString方法的。不幸的是,经过调研,我发现虽然已经有人在lombok的相关issue里提过类似的问题,但是lombok现阶段还不支持这么做。要想解决就只能自己实现了。

期望实现的效果是能够根据类上的一个注解如@ToJson来在编译期自动生成类的toString方法。方法内容是下面这样的:

public String toString() {
    return JsonStringSerializer.toJson(this);
}

在toString方法中调用json序列化工具类实现了将当前对象转为json字符串的操作。

最开始我是想用bytebuddy或asm来做这个事情的,但是使用这些字节码工具的时候需要加上javaagent相关的配置,运维肯定是不允许的。后来我又接触到了生成式注解,感觉这应该是解决这个问题的一个出路。

先来看下什么是生成式注解:

生成式注解处理器是JSR-269中定义的API,该API可以在编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程,通过生成式注解处理器可以读取、修改、添加抽象语法树中的任意元素。

“可以修改添加抽象语法树中的任意元素”,这看起来很酷,正是我想要的。

关于如何修改语法树可以参考这篇文档:《Java 中的屠龙之术:如何修改语法树?》。

看下具体是如何实现的吧,在类中添加toString方法的代码如下:

/**
 * 为给定的类型元素生成toString方法
 * 
 * 此方法负责创建一个公共的toString方法,该方法适用于大多数对象的字符串表示
 * 它通过调用makeToStringBody方法来构建方法体,确保生成的方法能够正确执行
 * 
 * @param typeElement 类型元素,用于获取类的相关信息
 */
private void makeToStringMethod(TypeElement typeElement) {

    // 导入必要的类,以支持JSON字符串序列化功能
    makeImport(typeElement, JsonStringSerializer.class);

    // 创建公共方法的修饰符
    JCTree.JCModifiers modifiers = getTreeMaker().Modifiers(Flags.PUBLIC, List.nil());
    // 定义返回类型为String
    JCTree.JCExpression returnType = getClassExpression(String.class.getName());

    // 初始化参数列表和泛型列表为空
    List<JCTree.JCVariableDecl> parameters = List.nil();
    List<JCTree.JCTypeParameter> generics = List.nil();
    // 获取方法名"toString"
    Name methodName = getName("toString");
    // 初始化异常抛出列表为空
    List<JCTree.JCExpression> exceptThrows = List.nil();

    // 构建toString方法的方法体
    JCBlock methodBody = makeToStringBody();

    // 创建toString方法声明
    JCTree.JCMethodDecl methodDecl =
            getTreeMaker().MethodDef(modifiers, methodName, returnType, generics, parameters, exceptThrows,
                    methodBody, null);

    // 获取类声明,以便将新方法添加到类中
    JCTree.JCClassDecl classDecl = (JCTree.JCClassDecl) getTrees().getTree(typeElement);
    // 将新方法添加到类的定义中
    classDecl.defs = classDecl.defs.append(methodDecl);
}

上面这个方法实现了向类中添加 toString 方法的逻辑,注释是我用通义灵码生成的,还是挺精准的。 如通义生成的注释说明, makeToStringBody 负责生成toString 方法的具体逻辑,这个方法的逻辑如下:

/**
 * 生成toString方法的主体
 * 
 * 此方法用于创建一个代码块,该代码块实现了toString方法的功能
 * 它使用JsonStringSerializer类的toJson方法来序列化当前对象
 * 
 * @return JCBlock 一个代码块,包含实现toString方法逻辑的代码
 */
private JCBlock makeToStringBody() {
    // 获取JsonStringSerializer类的toJson方法的表达式
    JCTree.JCExpression serializerIdent = getMethodExpression(JsonStringSerializer.class.getName(), "toJson");
    // 创建一个列表,包含传递给toJson方法的参数,在这里只是"this",表示当前对象实例
    List<JCTree.JCExpression> toJsonArgs = List.from(List.of(getTreeMaker().Ident(getName("this"))));

    // 创建一个返回语句,用于返回toJson方法的调用结果
    JCTree.JCReturn returnStatement = getTreeMaker().Return(
            getTreeMaker().Apply(List.nil(), serializerIdent, toJsonArgs)
    );

    // 创建并返回一个代码块,包含上述返回语句
    return getTreeMaker().Block(0, List.of(returnStatement));
}

核心代码就是这些。其他的代码可以看我这个项目 zhyea / lombok-ext 。

本来还可以展开说说lombok和mapstruct的,但就这样吧!

END!!!

发表评论

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理