通用技术 使用 ASM 通过代码反推接口文档

Barry250 · 2023年08月22日 · 最后由 Barry250 回复于 2023年08月30日 · 5149 次阅读

各位不知道有没有遇到过如下情况,在一个 7,8 百个接口的大项目中:
项目早期开发比较混乱,没有留下接口文档。然而开发也不太想去帮你整理 (或者你也不太指挥的动开发)
无法理清前端调用接口与对应的表的关系
无法理清前端调用·接口与下游系统的调用关系

分享一下我的处理方法,抛砖引玉。

先来个简单的项目 demo,项目。

Controller 层:

@RestController
@RequestMapping("user")
public class Manger {
    @Autowired
    TestService testService;
    @RequestMapping("login.do")
    public List test1(@RequestBody MyRequest request, UserModel userModel) {
        return testService.test1(request);
    }
}

Service 层,注意这里是 service 接口的实现,service 接口我就不贴了,没东西

@Service
public class TestServiceImpl implements TestService{
    @Autowired
    private BannerFloorMapper bannerFloorMapper;
    public List test1(MyRequest myRequest){
        List list = bannerFloorMapper.queryEntityByBannerOrder(1, myRequest.getPassword(), null);
        return list;
    }
}

Mapper 层

@Mapper
public interface BannerFloorMapper<T extends FloorBannerModel> {
    public List<T> queryEntityByBannerOrder(@Param("bannerOrder") Integer bannerOrder, @Param("floorCode") String floorCode, @Param("id")Long id);
}

大体的项目 demo 如此,下面说说怎么去解析。

1.什么是 ASM

这里是抄的百度的介绍
ASM 是一个通用的 Java 字节码操作和分析框架。 它可以用于修改现有类或直接以二进制形式动态生成类。 ASM 提供了一些常见的字节码转换和分析算法,可以从中构建自定义复杂转换和代码分析工具。
题外话,这玩意和 JSR269 应该是 java agent 插桩技术的 2 个前置知识点了,能鼓捣出好多有意思的功能。我感觉学习一下还是蛮有意思的。
我认识的一个朋友好像拿这个玩意写 我的世界 的游戏插件😁
推荐一下我在 B 站学习的一个 up 主,以及他的相关教程。https://space.bilibili.com/1321054247/video

2.前置条件

这里说一下需要的前置条件
+ 你能拿到源码,并且编译成字节码文件
因为解析是通过字节码来进行解析的,所以这一步是必须的。idea 插件相关的技术是支持解析 java 源码的,有兴趣的同学也可以看看,这里不做过多表述。
+ 你要对项目代码比较熟悉,有个初步的认知
为什么没有一款这样的插件出来,即使是 swagger 也需要开发去写一堆注解,就是因为不同的开发,不同的公司,代码习惯,水平高低,基础架构包的封装方式等等千差万别,没有办法拿出一套放之四海而皆准的插件出来,所以就需要我们在此基础上进行针对当下项目的定制开发解析。

###3.正式开始。
递归获取项目文件夹下的所有.class 结尾文件

//递归获取文件夹下所有以suffix结尾的文件
public static void findFileList(List results, File dir, String suffix) throws IOException {
    if (!dir.exists() || !dir.isDirectory()) {
        return;
    }
    String[] files = dir.list();
    for (String file : files) {
        File fileName = new File(dir, file);
        if (fileName.isFile() && fileName.toString().endsWith(suffix) && !fileName.toString().endsWith("Test.class")) {
            results.add(fileName);
        } else {
            findFileList(results, fileName, suffix);
        }
    }
}

挨个去遍历 class 文件

//这里是遍历的入口,具体的逻辑在ControllerClassVisitor内
    for (File s : results) {
            FileInputStream fileInputStream = new FileInputStream(s);
            ClassReader classReader = new ClassReader(fileInputStream);
            ControllerClassVisitor controllerClassVisitor = new ControllerClassVisitor(ASM9);
            classReader.accept(controllerClassVisitor, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
    }

controllerClassVisitor 类,用于解析类

    //我们的切入口是controller层,所以我们先去遍历class文件,只解析controller层类,其他类跳过,等方法中调用到的时候再去解析
public class ControllerClassVisitor extends ClassVisitor {
    boolean isController = false;  //是否为controller类的标记
    String[] secondUrlPath = new String[1];  //class上面的requestmapping内的第二段url地址,用于最后拼接出最终的url地址

    public ControllerClassVisitor(int api) {
        super(api);
    }
    @Override
    //visitAnnotation,访问类的注解,如果存在多注解就会多访问几次,我们需要访问类注解,来判断当前类是什么类。
    //descriptor为注解的类型
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        if("Lorg/springframework/web/bind/annotation/RestController;".equals(descriptor)){
            //如果使用了RestController注解,则我们判断他是一个controller类,并打上controller类标签
            //这里注解不止RestController一种,常用的还有Lorg/springframework/stereotype/Controller;,注意最后;不能丢
            //我还遇到过一个项目里面的注解为自己基础jar包内自定义的,这里说明了熟悉自家代码的重要性
            isController = true;
            return null;
        }
        if("Lorg/springframework/web/bind/annotation/RequestMapping;".equals(descriptor)){
            //如果是类上有RequestMapping注解,那就说明,类上也有url的地址需要拼接,就需要我们进入这个注解的visitor里面去获取url的部分地址
            return new ControllerClassAnnotationVisitor(api, secondUrlPath);
        }
        //啥事没发生,直接return父类的相同方法
        return super.visitAnnotation(descriptor, visible);
    }

    @Override
        //visitMethod方法,访问类中方法的方法,acess为访问修饰符,name为方法名,descriptor为传参,用来判断方法的重载,signature为泛型,exceptions为抛出的异常
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        if(isController && !"<init>".equals(name) && !"<clinit>".equals(name)){
         //如果isController 为true,即当前类是controller类,且方法名字不为"<init>(构造方法)",和"<clinit>(静态代码初始化)"
        //那么就进入controller层方法的visitor
           return new ControllerMethodVisitor(ASM9, secondUrlPath, constant, esbList);
        }
        return super.visitMethod(access, name, descriptor, signature, exceptions);
    }
}

ControllerClassAnnotationVisitor 类,用于解析类上的注解
如果进到这里,我们是已经确定了当前的注解为 RequestMapping,所以我们需要去取 url 拼接的地址
具体的地址值是在 ControllerClassAnnotationTypeVisitor 内(这里有点绕)

public class ControllerClassAnnotationVisitor extends AnnotationVisitor {
    String[] secondUrlPath;
    public ControllerClassAnnotationVisitor(int api, String secondUrlPath[]) {
        super(api);
        this.secondUrlPath = secondUrlPath;
    }
    @Override
    //访问RequestMapping注解内的值
    public AnnotationVisitor visitArray(String name) {
        return new ControllerClassAnnotationTypeVisitor(api, secondUrlPath);
    }
}

ControllerClassAnnotationTypeVisitor 类 ,解析 RequestMapping 注解内的值

public class ControllerClassAnnotationTypeVisitor extends AnnotationVisitor {
    String[] secondUrlPath;
    public ControllerClassAnnotationTypeVisitor(int i, String secondUrlPath[], Constant constant) {
        super(i);
        this.secondUrlPath = secondUrlPath;
    }
    @Override
    //o就是注解内value的具体对象了,我们这边是url地址的一部分,所以直接toString获取
    public void visit(String s, Object o) {
        secondUrlPath[0] = o.toString();
        super.visit(s, o);
    }
}

ControllerMethodVisitor 类,用于解析 controller 类中的方法

public class ControllerMethodVisitor extends MethodVisitor {
    String[] secondUrlPath;
    boolean isUrlMethod = false;    //是否为指向外部接口的方法
    String interfaceUrl = null;          //最终的对外接口的url

    public ControllerMethodVisitor(int api, String[] secondUrlPath) {
        super(api);
        this.secondUrlPath = secondUrlPath;
    }
    @Override
    //访问controller类中的方法的注解,controller类中的方法,并一定都是指向对外接口的方法,所以这里也要加一层判断
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        if ("Lorg/springframework/web/bind/annotation/RequestMapping;".equals(descriptor)) {
            //和controller类的判断一样,如果有RequestMapping注解,则认为是指向对外接口的方法,打上标识
            isUrlMethod = true;
            //这里的逻辑与controller类上的注解解析方法相同,
            //注意这里会遇到一个方法对应多个对外接口的写法,如 @RequestMapping(value = {"login.do", "haha.do"}
            return new ControllerMethodAnnotationVisitor(api, secondUrlPath);
        }
        return null;
    }
    @Override
    //这里是访问controller类中的方法内,会调用的方法
    //opcode目前先不管,owner为调用的方法的所在类的名字,name为方法名,descriptor为传参,用来判断方法的重载,isInterface为是否为接口
    public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {
        if (owner.startsWith(com.barry) && isUrlMethod) {
            //可以预见到,代码内会引用一些第三方的依赖的方法,那么我们这边做一下过滤
            //只处理类名为以com.barry开头的类,即我们自己的业务代码,第三方和的代码不管

            //这里我们能确定,我们所在的方法必然是controller类中对外接口的方法,那么,这里的传参descriptor即为我们需要的
            //在demo中我们这里的传参为MyRequest 和UsewrModel2个类,则对应的descriptor如下
            //"(Lcom/barry/MyRequest;Lcom/barry/UserModel;)Ljava/util/List"
            //括号内的2个为传参全限定名,括号外的为方法的返回值全限定名
            //这里我们可以知道,第一个就是request本体,拿到全限定名后就可以拼接出类.class文件地址,从而拿到接口与请求对象的对应关系
            try {
                FileInputStream fileInputStream = new FileInputStream(owner.replace(".", "/"));
                //文件地址不为null,则开始递归挖掘controller类中的方法内的方法的调用
                //这里往下挖就可以挖到service层,然后再到mapper层,mapper层与xml存在对应关系,只需要解析好xml,就能推导到接口与表的关系
                //我们公司所有下游系统调用都是走的统一的企业服务总线,有个通用的sendmsg方法,也可以通过此方法来进行对外接口与内联接口的关系梳理
                //上面我们拿到了接口的请求对象的全限定名了,那么我们只要在接下来的所有方法调用中,找到get了哪些字段,就能得到哪些字段是必要字段了
                //以上本文不做过多梳理了,大家有兴趣可以自己试试
                ClassReader classReader = new ClassReader(fileInputStream);
                FunctionClassVisitor myClassVisitor = new FunctionClassVisitor(ASM9, name, descriptor);
                classReader.accept(myClassVisitor, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
            } catch (NullPointerException e) {
                //有时候引入的是公司的基础架构的依赖内的方法,但是因为是jar包形式的,所以没办法解析到,就会走到这里,需要人工确认了。
                System.out.println(owner + "找不到,咋回事呢?");
            }
        }
    }
}

到这里为止,controller 层的类的解析就结束了。我们来整理一下顺序。
1.通过注解来确定 controller 类以及对外接口的第一段地址
2.通过注解确认对外接口对应的 controller 类内方法以及第二段地址,进行拼接从而得到接口地址
3.通过 controller 类内的对外接口的关联方法的传参,确认接口的请求对象

最重要的是理清楚层级关系,即
controller 的 visitor
annotation 的 visitor
method 的 visitor
service 的 visitor

那么,到这里总结下可能出现的问题和我踩过的一些坑
1.如果我们的服务直接是个 servlet 怎么办?
那就盯具体的方法,比如 servlet 里面 request 的 getParameter 方法

2.如果我们的接口方法的传参是个 json 字符串怎么办
这种一般也会用 JSONObject 之类的把 json 字符串转为对象,你就盯对应的转换方法即可。

3.继续往下解析,会遇到调用为 testService.test1(),实际的逻辑在 testServiceImpl.test1() 内的情况,怎么办
当获取到调用的方法的 owner 为 interface 时,如果代码比较规范,可以直接通过名字拼接的方式来确认具体的类
如果代码不规范,则需要去确认这个接口被哪些类实现了,来确认。

一些坑
1.类的全限定名不一定等于类文件的路径名!!!
遇到过一个类的全限定名为 a.b.c.TEST 的,但是他的 class 文件的实际路径确在 x/y/z 下,当时搞得我都想报个 bug 了。。。最后忍住了。。。好像也没规定不能这么写。

2.注意递归,小心陷入死循环
解析是一层层往下解析的,而当遇到方法内调用自己的时候,极易发生递归,需要设定跳出条件

3.注意方法的重载
结合上调,有时候遇到方法的重载 + 递归一起来的问题,这时候就不能只靠 name 名字来判断方法了,还要加上 descriptor,注意调用链路上的递归与重载,不要搞混。

4.通过反射来进行方法调用
这个没什么好的预防方法,我遇到的不多,但是确实有。
真遇到了你只能根据他采用的反射工具来确定他反射后的调用方法以及涉及到的类

以上就是全部内容了,我尽量把相关的用法思路写在代码注释里面了,欢迎大家讨论。

共收到 17 条回复 时间 点赞

没看出来怎么反推,反推了干嘛啊

别白费心机了,你的做法有点不切实际

如果是 springboot 项目:

  1. 项目添加 swagger 的 jar 包就可以
  2. 用扫描 jar 包,反射读取我感觉也比用 ASM 方便
  3. RestController 只是一种 Controller,老代码可能直接就用 Controller
  4. 既然都能看到代码了,不如直接看下代码方便
simonpatrick 回复

你好,是这样的。
1.swagger 无法解决如下情况

public object test2(String jsonData){
Request request = JsonUtils.toBean(jsonData, LoginRequest.class)
} 

这种我试了下,swagger 只显示了一个 String,根本没有意义
2.这个我倒是没接触过,愿闻其详,主要是 ASM 后面可以将非必要和必要字段也能区分开来。
3.这里我去开个数组,判断注解有没有出现在数组里就可以了。
4.主要是为了方便整理成文档给功能测试做接口测试使用,如果只是自己来自然不用这么麻烦

有两个问题想请教下。

  1. 能不能直接解析源码,如果可以的话,还省却了编译 class 文件这一步
  2. 如果方法的请求参数定义在依赖的其他工程里面,能不能解析出来。
ghost 回复

1.直接解析源码我试过的路子是走的 idea 的插件,那块也能提供针对 java 源码的 api,但是不同 idea 版本的适配还不同,而且教程比较少。。我最后就放弃了。。
2.理论上能拿到那个.class 就可以,因为你依赖的 jar 包里面都是 class 文件了,所以这里要读取 class 文件了,和你第一条反而冲突了?
可以这么搞,把所有依赖的 jar 包都解压出来,然后遍历一遍,维护一个 class 地址和类全限定名的映射表出来,然后解析业务代码的时候,调用到哪个全限定名,就去 class 的地址去加载解析对应的 class 文件。

恒温 回复

方便其他功能测试测试哇,提效,赋能😀

chris 回复

为啥不切实际啊,我已经有几个项目这么做了,其实还可以。
当然,如果是管理流程完善的新项目,压根没这么多问题。

主要是,我这里很多老项目,企业文化又比较死板,你求别人帮忙,人家不帮你也没折。。。

Barry250 回复

我觉得你用 ASM 也不能达到你的要求,而且非常复杂,为什么:

  1. 入参: string 本来就不好,你要解析出来就是读代码出来,可能有很多范型,所以投入非常大
  2. 你知道了那个入参类型又怎么样?这个入参的类型的字段有注释吗,你知道这些字段做什么样吗?只从字面来看的话,我觉得你也不知道是什么?所以和问题 1 这种情况有多大的区别?
  3. 读 jar 用发射,扫描 class 类,本质和 springboot 启动一样的,所以这个没啥,和 ASM 搞也就那回事吧,只不过看着一堆类型用字符串来表示看着不太爽而已,扫描 annotation 这种 reflection 可以做到的,你目前用 ASM 做的所有事情都可以做到,并且我觉得更简单一些
  4. 这种扫描,好多年前已经做过了,扫描出来,直接连测试代码都生成了,只要改改数据就可以的,但是做自动化的还是比较少?为什么?原因可能太多了
  5. 不是 JAVA 的项目怎么办呢?
  6. 要想知道那些接口办法也不少,要知道全部也没有太多意义

以上我自己实践下来的感受

simonpatrick 回复

另外依靠命名规范也不是很稳定,还不如可能在请求种打日志,或者全局日志拦截,或者录制一下方便。

simonpatrick 回复

大佬教育的是
只不过我这初衷并不是去想搞个通用的玩意出来,而是深耕需要我的几个大项目上。
最开始的切入点其实就是上面那个,后面通过这个解决了遍历的问题之后,扩展很大,比如我上面提到的

//这里往下挖就可以挖到 service 层,然后再到 mapper 层,mapper 层与 xml 存在对应关系,只需要解析好 xml,就能推导到接口与表的关系
//我们公司所有下游系统调用都是走的统一的企业服务总线,有个通用的 sendMsg 方法,也可以通过此方法来进行对外接口与内联接口的关系梳理
//上面我们拿到了接口的请求对象的全限定名了,那么我们只要在接下来的所有方法调用中,找到 get 了哪些字段,就能得到哪些字段是必要字段了
只是都是在上面的基础上,直接添加一个 methodVisitor 就能解决了。

用技术手段去弥补管理流程规范与企业文化的漏洞,有点吃力且效果不佳呀。个人觉得还是主推流程规范吧,没人愿意梳理那就不梳理,暴雷了该分锅分锅,问题大到一定程度才会有领导关注并授权 + 给予资源支持

jinglebell 回复

我司质控和开发是平级的,所以没办法要求人家做什么。人家一句没时间就顶回去了。你也不能说,啥事都要给大老板写邮件吧。。。就很难受。。。

torna 不是挺好吗?

好像可以不用写侵入式注解的?哦哦哦哦,我看看,多谢大佬

这个是精准测试的范畴吧(通过 ASM 理清接口、SQL、方法之间的联系), 而且也不能反推接口文档,编译后是没注释的,哈哈哈,开发写的注释是看不到的,最多能反推到接口的方法和关联的 SQL,而且如果用的是 ORM 类框架,而不是纯 Mybatis 这种 XML 映射 SQL 的框架,就更复杂了,可能纯静态分析就不足够了,例如 Spring JPA、MBP、MBF?不过也许 GPT 能帮上忙。

我个人觉得,如果你是想通过 ASM 反推接口文档,应该是开发一个类似的平台,将 ASM 推出来的入参和端点录到数据库里,比如说每次测试结束以后,让开发抽出 1~2 小时,在你的平台上进行编辑和保存,然后生成 Swagger,感觉这个会比较靠谱些。——虽然应用效率上是低于 SpringFox 此类注解 Swagger 文档框架的,但是积极性还是可以调动起来的,这个开发做的工作比较能量化出来。

我个人思考过这个问题,我觉得,让团队中的其他测试看懂代码可能更重要一些。。。

徐嘉泽 回复

主要就是我现在的团队
因为金融行业的要求,自以及企业风气的原因。很多东西,不能使用那种直来直去的解决方式。

不过相应的,我也折腾了很多很有意思的玩意,当然很多时候别人看起来会多此一举。但是我也只是想起到一个抛砖引玉的效果就足够啦

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册