测试覆盖率 Java 覆盖率 Jacoco 插桩的不同形式总结和踩坑记录

风雨夜阑听 · 2019年09月23日 · 最后由 开开心心 回复于 2022年03月24日 · 21880 次阅读
本帖已被设为精华帖!

关于 Jacoco 的小结和踩坑记录

一、概述

测试覆盖率,老生常谈的话题。因为我测试理论基础不是很好,就不提什么需求覆盖率啦这样那样的主题了,直奔主题,咱主要指 Java 后端的测试覆盖率。

由于历史原因,公司基本不做 UT,所以对测试来说,咱最关心的还是手工执行、接口执行 (人工 Postman 之类的)、接口自动化、WebUI 自动化对一个应用系统的覆盖度。

此文也是对另一篇文章关于 Java 端覆盖率探索的一个细化,当时主要是为了描述 App 端的踩坑,所以关于服务端一笔带过了,现得空详细补充一下。
小记 Java 服务端和 Android 端手工测试覆盖率统计的实现

二、站在巨人的肩膀上

本文提到的内容,多数还是得力与站在巨人的肩膀上。

国际惯例,感谢以下文章带来的灵感:
有赞测试 浅谈代码覆盖率
地址:https://testerhome.com/articles/16981

腾讯移动品质中心 TMQ [腾讯 TMQ] JAVA 代码覆盖率工具 JaCoCo-踩坑篇
地址:https://testerhome.com/topics/5876

腾讯移动品质中心 TMQ [腾讯 TMQ] JAVA 代码覆盖率工具 JaCoCo-实践篇
地址: https://testerhome.com/topics/5823

测试覆盖率 代码变更覆盖率平台 - 针对手工测试的代码变更覆盖率实现之路
地址: https://testerhome.com/topics/19077

以上等等,都是在覆盖率这个坑里蹲着的时候,给我递了绳子的,也让我顺利从坑里爬了出来!谢谢

三、写本文的初衷

本来 Jacoco 已经流行了很多年了,各种文档和帖子已经描述的很完美了,但是多数文章都是针对某一特定形式做了总结和使用。相信很多负责整个公司项目的覆盖率任务的人们来说,还是要一种一种去研究、去应对,入坑、出坑不厌其烦。

也得益于今年上半年一直负责整个公司不同类型的项目的覆盖率统计技术的适配,对不同形式的项目均有一定的了解,在此记录一下,也不让千疮百孔的自己浪费掉这半年的精力,如果说可以帮到别人一星半点,那这篇文章就算是造福了。

由于本人能力有限、表达能力有限,也难免在文中会有错别字、技术描述不到位、或者总结有误的地方,还请大家原谅则个,我也争取不误导大家。

四、 投入覆盖率之前的思路

因为之前了解过一部分 Jacoco 的机制,也知道它提供了很多强大的功能,以满足不同形式的项目。但归根结底,Jacoco 提供了 api,可以让大家屏蔽不同类型的项目带来的困扰。

Jacoco 官方的 Api 示例
地址: https://www.jacoco.org/jacoco/trunk/doc/api.html

个人认为,以 Api 的方式来进行操作,可以有以下好处:
可以屏蔽不同方式的构建部署,如果你想把这个功能做成平台,那 api 想必是很好的一种方式。

也就是说,我只需要把 jacoco 插桩到测试服务器上,暴露 tcp 的 ip 和端口,剩余的提取代码执行数据、生成覆盖率报告,就可以用统一的方式进行就好了。
众所周知,jacoco 官方提供了 Maven 插件方式、Ant 的 xml 方式,均有对应的 dump 和 report 来进行覆盖率数据的 dump 和报告生成,如果有兴趣可以研究一下,我也不过于啰嗦。

五、对我司项目的梳理

我在另一篇关于覆盖率文章中有提到,我司是个老牌公司,项目杂乱无章,技术五花八门。截至2019年9月23日仍然有 j 泡在 jdk6 上的。所以我个人认为,影响 jacoco 使用过程的,可能存在于以下几点。

  1. jdk 版本。我司现有 jdk6、7、8.但实际上 jdk6 是个分水岭,其他的都基本可以用 jdk8 来适配。
  2. 构建工具。我司现有 Maven 构建、ANT 构建,想必有的公司还有用 gradle 的。
  3. 部署方式。Ant、Maven 插件启动、java -jar 启动、tomcat 启动 war 包 (打包方式就随便了)

稍后内容也都基于这几种不同实现方式做描述。如果接触项目多的,基本就知道,很多时候测试还是不介入测试环境的发布,这一方面源于开发的不信任,他们认为发布还是要抓在开发自己手里;另一方面也源于测试人员能力的跟不上,至少在我司很多测试人员确实不太懂如何发布(虽然现在慢慢有所缓解,越来越都的测试人员都从开发手中接了过来)。

线上部署、测试部署、开发部署,这几个不同场景,可能用的方式都不同,至少在我接触的项目大都是这样。开发喜欢用插件的方式启动部署,因为快嘛,而且 IDE 也支持,右键运行一下基本在 ide 就启动了,想想看如果你是开发,在你本地 IDE 里调试的时候,需要打个 war 包然后丢到 tomcat 里,再启动 tomcat,你也不太乐意。

六、jacoco 插桩的本质

废话不多说,步入正题。
在上面提到的几篇文章里,多数都提到了 jacoco 介入部署过程的本质,就是插桩,至于怎么插桩,那就跟接入阶段有关系了。可以是编译时插桩、也可以是运行时插桩,这就是所谓 Offline 模式和 On-the-fly 模式,我们也不过多于纠结,我们选择 on-the-fly 模式。

所以归结到本质,jacoco 的 on-the-fly 模式的插桩过程,其实就是在测试环境部署的时候,让 jacoco 的相关工具,介入部署过程,也就是介入 class 文件的加载,在加载 class 的时候,动态改变字节码结构,插入 jacoco 的探针。

本质: jacoco 以 tcpserver 方式进行插桩的本质,就是如果应用启动过程中,进行了 jacoco 插桩,且成功了。它会在你当前这个启动服务器中,在一个端口{$port}上,开启一个 tcp 服务,这个 tcp 服务,会一直接收 jacoco 的执行覆盖率信息并传到这个 tcp 服务上进行保存。他既然是个 tcp 服务,那 jacoco 也提供了一种以 api 的方式连接到这个 tcp 服务上,进行覆盖率数据的 dump 操作。
(细节可能描述的不是很精确,但差不多就是这么个过程。这个 tcp 服务,在你没有关闭应用的时候,是一直开着的,可以随时接受连接)

那最后再本质一点,就是介入下面这个命令的启动过程:

java -jar 

那问题就好办了,一种一种来对应起来。

七、 不同形式的插桩配置

提到介入启动过程,那就免不了提一下一个 jar 包。

jacocoagent.jar
下载地址:https://www.eclemma.org/jacoco/
下载后解压文件夹里,目录如下:

这个 jacocoagent.jar,就是启动应用时主要用来插桩的 jar 包。
请注意不要写错名称,里面有个很像的 jacocoant.jar,这个 jar 包是用 ant xml 方式操作 jacoco 时使用的,不要混淆。

以测试环境部署在 linux 服务器上为例,如果想在 windows 上测试也可以,把对应的值改成 windows 上识别的即可。

假设 jacocoagent.jar 的存放路径为:/home/admin/jacoco/jacocoagent.jar
以下都以 $jacocoJarPath 来替代这个路径,请注意这个路径不是死的,你可以修改。

依然是基于上述的几种不同方式,那我们针对不同形式·做插桩,也就是改变这几种不同形式的底层启动原理,也就是改动不同方式的 java 的启动参数,这对每一种启动方式都不太一样。但是改动 java 启动参数本质也是一样的,就是在 java -jar 启动的时候,加入-javaagent 参数

-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"

换成实际的信息为如下,请注意替换真实路径,这一句是需要介入应用启动过程的主要代码,针对每种不同的部署方式,需要加到不同的地方

-javaagent:/home/admin/jacoco/jacocoagent.jar=includes=*,output=tcpserver,port=2014,address=192.168.110.1

7.1 这句话的解释

  1. -javaagent
    jdk5 之后新增的参数,主要用来在运行 jar 包的时候,以一种方式介入字节码加载过程,如有兴趣自行百度。注意后面有个冒号:

  2. /home/admin/jacoco/jacocoagent.jar
    需要用来介入 class 文件加载过程的 jar 包,想深入了解的,百度 “插桩” 哈。
    这是一个 jar 包的绝对路径。

  3. includes=*
    这个代表了,启动时需要进行字节码插桩的包过滤,* 代表所有的 class 文件加载都需要进行插桩。
    假如你们公司内部代码都有相同的包前缀:com.mycompany
    你可以写成:

    includes=com.mycompany.*
    
  4. output=tcpserver
    这个地方不用改动,代表以 tcpserver 方式启动应用并进行插桩

  5. port=2014
    这是 jacoco 开启的 tcpserver 的端口,请注意这个端口不能被占用

  6. address=192.168.110.1
    这是对外开发的 tcpserver 的访问地址。可以配置 127.0.0.1,也可以配置为实际访问 ip
    配置为 127.0.0.1 的时候,dump 数据只能在这台服务器上进行 dump,就不能通过远程方式 dump 数据。
    配置为实际的 ip 地址的时候,就可以在任意一台机器上 (前提是 ip 要通,不通都白瞎),通过 ant xml 或者 api 方式 dump 数据。
    举个栗子:
    我如上配置了 192.168.110.1:2014 作为 jacoco 的 tcpserver 启动服务,
    那我可以在任意一台机器上进行数据的 dump,比如在我本机 windows 上用 api 或者 xml 方式调用 dump。
    如果我配置了 127.0.0.1:2014 作为启动服务器,那么我只能在这台测试机上进行 dump,其他的机器都无法连接到这个 tcpserver 进行 dump。

  7. 总结:
    这句内容,如下,格式是固定的,只有括号内的东西方可改变,其它尽量不要动,连空格都不要多:

    -javaagent:(/home/admin/jacoco/jacocoagent.jar)=includes=(*),output=tcpserver,port=(2014),address=(192.168.110.1)
    
比如我可以改成其他的:

```shell
-javaagent:/home/admin/jacoco_new/jacocoagent.jar=includes=com.company.*,output=tcpserver,port=2019,address=192.168.110.111

注意其他地方基本不用改动。

7.2 war 包方式启动

tomcat 的 war 包方式启动,假设 tomcat 路径为:$CATALINA_HOME= /usr/local/apache-tomcat-8.5.20,我们常用的命令存在于:$CATALINA_HOME\bin 下,有 startup.sh 和 shutdown.sh(windows 请自觉改为 bat,后续不再声明),其实这两个只是封装之后的脚本,底层调用的都是 $CATALINA_HOME\bin\catalina.sh(或者 bat),如图源码:

因此,只需要改动 catalina.sh 中的启动参数即可。
前面提到过,主要改动主要是改动 java -jar,tomcat 是通过一个 JAVA_OPTS 参数来控制额外的 java 启动参数的,我们只需要在合适的地方把上面的启动命令追加到 JAVA_OPTS 即可
打开 catalina.sh,找到合适的地方修改 JAVA_OPTS 参数:

理论上,任何地方修改 JAVA_OPTS 参数均可,但我们实验过后,在以下位置加入,是一定可以启动成功的,当然您也可以尝试其他位置.

JAVA_OPTS="$JAVA_OPTS -Dorg.apache.catalina.security.SecurityListener.UMASK=`umask`"

源脚本中有这个注释掉的地方,我们在下方修改 JAVA_OPTS:
在其下方,加一句:

JAVA_OPTS="$JAVA_OPTS -javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"

改完之后如下所示:

改完之后,就可以进行 startup.sh 的启动了,应用启动成功之后,可以在服务器上进行调试,查看 tcpserver 是否真的起来了。
判别方式如下 (该图中是现有的已经开启的服务,所以 ip 和端口跟前面的命令不一样,这点请注意,这里只是为了展示;后续几种方式判别方式相同,不再赘述了哈),这个端口在应用启动时被占用,在应用关闭时被释放,这个请注意检查:

如此,这个端口已经在监听了,证明这个测试环境已经把 jacoco 注入进去,那你对该测试环境的任何操作,代码执行信息都会被记录到这个 ip:port 开启的 tcp 服务中。

7.3 Maven 命令的插件启动方式

在我司,有的开发会喜欢用插件方式启动,在代码 pom 文件层级中,运行如下命令:

mvn clean install

mvn tomcat7:run -Dport=xxx

或者还有

mvn clean install

mvn spring-boot:run -Dport=xxx

这两套命令,本质上没什么差别,只是运行插件不一样,具体用什么命令,如果不清楚,最好是跟开发请教一下。
他们的意思是,在当前代码的 pom 文件层级运行,意思是通过 maven 的 tomcat 插件启动这个服务,这个服务启动在端口 xxx 上,注意这个端口是应用的访问端口,和 jacoco 的那个端口不是一回事.

对这种方式注入 jacoco,也是可以的。这种可以不用修改任何的配置文件,只需要在你启动的时候,临时修改变量就行了。
这种方式改变 java 的启动参数方式是这样:

export MAVEN_OPTS="-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"

这句命令加在哪里呢?就是 run 之前。为什么呢,因为这样一改,你的所有的 mvn 命令都会生效,但其实我们只想介入启动过程。
因此,前面提到的两套启动命令,就可以改成如下方式:

mvn clean install
export MAVEN_OPTS="-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"
mvn tomcat7:run -Dport=xxx
export MAVEN_OPTS=""

mvn clean install
export MAVEN_OPTS="-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1"
mvn spring-boot:run -Dport=xxx
export MAVEN_OPTS=""

当然,你的 run 命令,也可能是其他变种,比如:nohup mvn .... & 这种后台启动的方式,也是可以的。
最后修改为""是因为担心对后续的 mvn 命令产生影响,其实如果你切换了 terminal 窗口,这个临时变量就会失效,不会对环境造成污染。
如果应用启动成功了,就可以按照前面的方式,netstat 叛别一下 tcp 服务是否真的启动。

如果你设置了这个变量的位置不对,那你用 mvn 命令的时候,可能会出现如下的异常:

java.net.BindException: Address already in use: bind

这时候,就需要去检查一些,你配置的 jacoco 端口是不是在启动应用服务时已经被占用。
或者你临时设置了 MAVEN_OPTS 这个变量,启动之后又没有改回来,然后接着运行了 mvn 命令,这时候也会出现这种错误。
这里请务必关注。

提一句题外话,ANT 的方式是不是也可以通过临时修改 ANT_OPTS 参数进行启动 (因为 ANT 和 MAVEN 本是一家子吗,我才底层可能差异不是很大),我不曾做尝试,有兴趣的可以尝试下

7.4 ANT 构建,通过 xml 配置文件启动

这种方式可能实现启动应用的阶段不同,但大都配置在 build.xml 里,这里请根据不同的项目做不同的适配.
它的原理是,在 ant 的启动 target 中,有个的标签,给她增加一个 jvmarg 参数的子标签,如下代码:

<jvmarg value=”-javaagent:$jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1” />

比如我们的启动命令是这样:

ant -f build.xml clean  build  startJetty

以此启动之后,将会注入 jacoco 的代理,最终可以按照上面的方式判断端口是否启动。

7.5 java -jar 方式启动

这种最简单直接:

java -javaagent: $jacocoJarPath=includes=*,output=tcpserver,port=2014,address=192.168.110.1 -jar  xxxxxxxxxx.jar 

注意,javaagent 参数,一定要在 jar 包路径之前,尽量在-jar 之前,不然可能不会生效。
请注意 java -jar 命令的使用方式,在 jar 包前面传进去的是给 jvm 启动参数的,在 jar 包之后跟的是给 main 方法的。

启动后,依然按照前面的方式判断是否启动了监听端口。

八、关于启动之后

启动之后,就进行测试就可以了,跟平常不注入 jacoco 代理是无异的。

九、关于注意事项 (可能前面有啰嗦重复的,但需要注意)

  1. 修改 JAVA_OPTS 参数时,如果位置不对,可能造成代理无法启动。
  2. java -jar 启动时,-javaagent 参数,不能错误,否则可能造成代理不生效
  3. Export MAVEN_OPTS 参数时,后续的所有 mvn 命令,都会带上此参数,因此相当于每次执行 mvn 命令,都会尝试启动代理,因此可能会出现 address bind already in use 之类的异常抛出。 因此,我们只有在 mvn tomcat7:run 启动服务器时才需要启动代理,其他如 mvn 的编译、install 命令都不需要,所以在启动之后,把 MAVEN_OPTS 参数置空,或者重启一个 terminal 来执行命令
  4. 同一个 ip 地址上,部署多套服务器需要收集覆盖率时,端口自己规划好,不可重复。
  5. 测试执行信息的收集 (在应用的测试服务器)
  6. 测试执行信息的获取、以及生成覆盖率报告(可在测试服务器上、也可在统一的服务器上)
  7. 5 的收集在测试服务器上,6 的操作可以在测试服务器是,也可以是统一的服务器(我们选择后者)。
  8. 关闭应用服务时,务必不要强杀,请使用 kill -15 杀进程 (当然有时候,会出现 kill -15 杀不掉进程的时候,用 kIll -9 也无妨,这一点并不是很确定),否则,很有可能会造成覆盖率数据来不及保存而丢失。

十、说给想做平台的你

按照原来的流程,如果想做增量的覆盖率,那么有如下的步骤需要涉及,我们需要做的事情:

  1. 部署测试服务器(加入 jacoco 的代理,按照上面的方式进行即可) 2 需要知道上述部署时的版本代码,需要知道待比较的基线版本代码,并下载两个代码到某个路径下,并编译最新的代码 (至于需不需要编译,看你的需求,也可以用测试服务器上的,这样最准确。现编译的话,可能会编译机跟测试机的不同,造成生成的 class 文件不一致,这会导致覆盖率数据不准确)
  2. Dump 覆盖率执行数据
  3. 根据 dump 出来的执行数据 exec 文件,以及刚才对最新代码的编译出来的字节码 class 文件和 src 中的源代码进行报告生成
  4. 导出覆盖率数据报告(一般是在 linux 中执行,查看时需要到自己的 windows 或者 mac 上查看) 以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。

那么上述的步骤,其实可以都进行自动化配置。

  1. 部署。
    如果有 devops 平台的话,可以集成进去,端口要规划好。

  2. 基线代码、和最新代码
    可以用 jgit 和 svnkit 这两个工具进行代码下载和克隆。

  3. dump.
    用 API 去 dump,可以屏蔽不同启动方式,只需要有 tcp 的 serverip 和端口即可。

  4. report。
    用 jacoco 的 api 做。
    那唯一的差别,就是对项目层级的判定,比如多模块、比如可能项目的目录并不规范 (有的 maven 项目并没有把所有的代码放到 src/main/java 下),这些需要自己对公司项目进行适配。
    我司就是因为项目结构差别太大,所以适配的过程花了一番功夫。

5 导出报告。
提供下载,或者给出服务器存放的链接,都行,这个看个人实现就行了。

十一、一些坑

  1. ant 构建
    build.xml 中,有特定的 compile 阶段,这个自己去找。
    请务必保证,有

    debug="true"
    

    这个配置,不然,jacoco 是无法注入的,有的时候 ant 项目生成的数据为 0,就可以去排查下这里。
    如我司配置了两个,一个 compileDebug,一个 compile,在 compileDebug 阶段打开了 debug 的开关:

  2. 关于负载均衡
    有时候可能一个服务会有负载均衡出现,那么可以配置不同端口,如果在不同服务器上,那么 IP 和端口都可以不同。
    这时候,在 dump 数据的时候,只需要循环几个 ip:port(至于你想怎么传,那就是代码层面事情了)去 dump,保存到同一个文件中就行了。

  3. 做平台时 - 项目代码无法独立编译
    这个看怎么解决了,如果非要自己编译,那就让开发适配到可以独立编译。
    我这里是提供了 sftp 下载的方式,你告诉我你的代码在哪个服务器的那个路径,提供给我用户名密码,我用 java 的方式去 sftp 下载到平台部署的机器上。
    这样可以解决现编译的不匹配问题,也可以解决无法独立编译的问题。
    但是有几个遗留问题,你如何判定是不是要重新下载,你也会担心 sftp 下载下来的 class 和 java 代码跟测试机上的是否不一样。这个要看个人取舍,理论上 tcp 进行下载还是安全的。

  4. 如果注入 jacoco 的配置之后,端口确实没有起来或者 dump 的时候,tcpserver 连接不上
    可能原因有几种。

    • Tcp 端口确实没起来,这个在部署测试服务器的文档里有说明,部署后需要查看下是否真的起来。
    • Tcp 端口确实起来了,netstat 查看的时候也是显示正确。 这种还有两种可能。
      1. 确保 javaagent 参数中的 address 写的是真实 ip 地址,而不是 127.0.0.1 或者 localhost。
      2. 防火墙。防火墙开启的时候,阻碍了外部 ip 连接的进入,请关闭防火墙,或者配置防火墙策略。
  5. 覆盖率数据会丢失或者不准确
    举个栗子。
    8:30 的时候,执行了测试,生成了一次报告。此时 8.30 之前的数据,肯定是存在的。
    9:00 的时候,重新部署了,之前没有再次捞取执行信息,那重启之后,8.30-9.00 之间的执行记录可能很大概率丢失。
    所以,务必小心。

  6. 怎么确保报告准确,且尽量减少丢失?
    及时保存,及时收集,可以采用定时任务的方式。

  7. 应用的突然重启和服务器的断电状况怎么处理?
    天灾,没招。如果真的确实需要,可以在程序中加入定时收集,但是频率不一定好控制,而且当不再执行的时候,平白重复保存完全一模一样的执行信息,个人觉得意义不大,会对服务器磁盘造成巨大压力。具体解决方案还要看个人取舍。

  8. 造成覆盖率报告数据不准确的原因有哪些?
    最最最最底层的原因。 部署时的 class 文件和生成报告的时候,用的 class 文件不一致。有以下几种情况:

    • 测试服务器(就是你的应用所在的那个环境)中的 class 文件和我管理平台上编译环境不一致,导致产生的 class 文件跟部署时的 class 文件有差异。这个可以通过不手动编译,而是从 测试服务器部署位置的目录来拷贝传输,来解决,但现阶段,没做。
    • 测试服务器版本变更了,但是管理平台上的代码没变更(或者说新代码拉取下来了,但是没有重新编译。),导致 class 文件不一致
    • 管理平台上的新版本代码的版本号没有填写,默认每次拉取最新代码,这会导致生成报告的时候,源码变了,class 文件没变,覆盖率插桩收集的时候,用的还是老代码 所以,要想准确。需要保证,测试服务器部署时的代码版本和管理平台上写的版本号完全一致。

不知不觉,又写了这么多,哎,表达能力不行,又啰嗦了。不早了,先写到这里,稍后想到,再作补充把~~~~~~

十二、补充一些 API 相关的代码

PS:2019年9月24日09:20 追加一些用到的源码,昨天写完 22:20 了,着急赶末班车,就没来得及,今天补上一些。

覆盖率数据的获取

import org.jacoco.core.tools.ExecDumpClient;
import org.jacoco.core.tools.ExecFileLoader;
...

public void dumpExecDataToFile(String filePath) {
        logger.debug("开始dump覆盖率信息:{},到:{}文件中", this.jacocoAgentTcpServer,
                filePath);
        ExecDumpClient dumpClient = new ExecDumpClient();
        dumpClient.setDump(true);
        ExecFileLoader execFileLoader = null;
        try {
            execFileLoader = dumpClient.dump(
                    this.jacocoAgentTcpServer.getJacocoAgentIp(),
                    this.jacocoAgentTcpServer.getJacocoAgentPort());
                         // 这个后面的true,代表如果这个文件已经存在,且以前已经保存过数据,那么是可以追加的,也相当于覆盖率数据文件的合并
                        //如果设置为false,则会重置该文件,这在多节点负载均衡的时候尤其有用,可以把多个节点的数据组合合并之后再进行统计
            execFileLoader.save(new File(filePath), true);
        } catch (IOException e2) {
            logger.error("获取dump信息失败:{}", e2.getMessage());
            throw new BusinessValidationException("tcp服务连接失败,请查看tcp配置");
        }
    }

另外可以根据自己的需要,看下是否把以前的覆盖率数据做备份 (我们现在是做了备份、且做了定时 dump,防止覆盖率数据突然丢失),需要的时候从备份数据里拿,再从 tcpserver 中 dump,然后做合并,这个过程可能统计全量的时候尤其需要。

CodeCoverageDTO.java

该文件主要封装覆盖率数据生成报告的时候需要的一些属性,如数据文件、src 源码、class 文件、报告存放文件等等。

import java.io.File;

/**
 * @author : Administrator
 * @since : 2019年3月6日 下午7:53:02
 * @see :
 */
public class CodeCoverageFilesAndFoldersDTO {
    private File projectDir;

    /**
     * 覆盖率的exec文件地址
     */
    private File executionDataFile;

    /**
     * 目录下必须包含源码编译过的class文件,用来统计覆盖率。所以这里用server打出的jar包地址即可
     */
    private File classesDirectory;

    /**
     * 源码的/src/main/java,只有写了源码地址覆盖率报告才能打开到代码层。使用jar只有数据结果
     */
    private File sourceDirectory;
    private File reportDirectory;
    private File incrementReportDirectory;

    public File getProjectDir() {
        return projectDir;
    }

    //省略了getter和setter
}

ReportGenerator.java

这里生成报告的时候,其实默认应该已经有源码、exec 文件、class 文件了,至于 class 文件什么时候编译出来的或者怎么出来的,那应该在生成报告的前置步骤已经做好了。

private static void createReportWithMultiProjects(File reportDir,
            List<CodeCoverageFilesAndFoldersDTO> codeCoverageFilesAndFoldersDTOs)
            throws IOException {
        logger.debug("开始在:{}下生成覆盖率报告", reportDir);
        File coverageFolderFile = reportDir;
        if (coverageFolderFile.exists()) {
            FileUtil.forceDeleteDirectory(coverageFolderFile);
        }

        HTMLFormatter htmlFormatter = new HTMLFormatter();
        IReportVisitor iReportVisitor = null;

        boolean everCreatedReport = false;

        for (CodeCoverageFilesAndFoldersDTO codeCoverageFilesAndFoldersDTO : codeCoverageFilesAndFoldersDTOs) {
            // class文件为空或者不存在
            boolean classDirNotExists = (null == codeCoverageFilesAndFoldersDTO
                    .getClassesDirectory())
                    || (!(codeCoverageFilesAndFoldersDTO.getClassesDirectory()
                            .exists()));

            // class文件目录不存在
            boolean needNotToCreateReport = classDirNotExists;
            if (needNotToCreateReport) {
                logger.debug("目录:{}没有class文件,不生成报告",
                        codeCoverageFilesAndFoldersDTO.getProjectDir()
                                .getAbsolutePath());
                continue;
            }

            // 修改标志位
            everCreatedReport = true;
            logger.debug("正在为:{}生成报告", codeCoverageFilesAndFoldersDTO
                    .getProjectDir().getAbsolutePath());
            IBundleCoverage bundleCoverage = analyzeStructureWithOutChangeMethods(
                    codeCoverageFilesAndFoldersDTO);
            ExecFileLoader execFileLoader = getExecFileLoader(
                    codeCoverageFilesAndFoldersDTO);
            iReportVisitor = htmlFormatter
                    .createVisitor(new FileMultiReportOutput(
                            new File(coverageFolderFile.getAbsolutePath(),
                                    codeCoverageFilesAndFoldersDTO
                                            .getProjectDir().getName())));

            if (null != execFileLoader) {
                iReportVisitor.visitInfo(
                        execFileLoader.getSessionInfoStore().getInfos(),
                        execFileLoader.getExecutionDataStore().getContents());
            }

                        //这个地方之所以没有用一个固定的文件夹来指定,是因为我们的项目有的不标准,如果你们的项目是标准的,比如都在src/main/java下,那就可以直接用一个固定值
                         //我们这里为了防止src/java src/java/plugin src/plugin这种层级的源码出现,才做了适配
            ISourceFileLocator iSourceFileLocator = getSourceFileLocatorsUnderThis(
                    codeCoverageFilesAndFoldersDTO.getSourceDirectory());
            iReportVisitor.visitBundle(bundleCoverage, iSourceFileLocator);
            iReportVisitor.visitEnd();
        }

        if (!everCreatedReport) {
            throw new BusinessValidationException("从未生成报告,检查下工程是否未编译或者是否都是空工程");
        }
    }

private static ISourceFileLocator getSourceFileLocatorsUnderThis(
            File topLevelSourceFileFolder) {
        MultiSourceFileLocator iSourceFileLocator = new MultiSourceFileLocator(
                4);

                 //这里是获取当前给出的目录以及其下面的子目录中所包含的所有java文件
                  //实现方式其实就是递归遍历文件夹,并过滤出来java文件,写法比较简单就不贴了,自行实现即可
        List<File> sourceFileFolders = getSourceFileFoldersUnderThis(
                topLevelSourceFileFolder);

        for (File eachSourceFileFolder : sourceFileFolders) {
            iSourceFileLocator
                    .add(new DirectorySourceFileLocator(eachSourceFileFolder,
                            GlobalDefination.CHAR_SET_DEFAULT, 4));
        }
        return iSourceFileLocator;
    }

如果确实需要有些实现的源码,可以联系我或者从 github 上获取。
代码示例

备注:
这里关于 Jacoco 的一部分代码直接引用了AngryTester的代码,如果涉及到侵权请联系我,不过当前是为了个人使用,并不涉及商业,还望见谅~~~
关于 server 部分的,则大部分是我自己练习的代码,可以随意拿去用~~~
这个小工具只是为了给测试内部使用,其实并不具备完整项目的实力,所以代码和性能不一定很好,但我尽量按照阿里的规范来编写的代码,使其规范。

AngryTesterJacoco 的代码-org.jacoco.core.diff.DiffAST.java

这是代码比对源码,

public static List<MethodInfo> diffDir(final String ntag,
            final String otag) {// src1是整个工程中有变更的文件,src2是历史版本全量文件,都是相对路径,例如在当前工作空间下生成tag1和tag2
        final String pwd = new File(System.getProperty("user.dir"))
                .getAbsolutePath();// 同级目录
        final String parent = new File(System.getProperty("user.dir")).getParent();
        final String tag1Path = pwd;
        final String tag2Path = parent + SEPARATOR + otag;
        final List<File> files1 = getFileList(tag1Path);
        for (final File f : files1) {
            // 非普通类不处理
            if (!ASTGeneratror.isTypeDeclaration(f.getAbsolutePath())) {
                continue;
            }
                        //实现方法在这里,主要是做了路径的替换
            final File f2 = new File(
                    tag2Path + f.getAbsolutePath().replace(tag1Path, ""));
            diffFile(f.toString(), f2.toString());
        }
        return methodInfos;
    }

/**
     * @param baseDir 与当前项目空间同级的历史版本代码路径
     * @return
     */
    public static List<MethodInfo> diffBaseDir(final String baseDir) {
        final String pwd = new File(System.getProperty("user.dir"))
                .getAbsolutePath();// 同级目录
        final String parent = new File(System.getProperty("user.dir")).getParent();
        final String tag1Path = pwd;
        final String tag2Path = parent + SEPARATOR + baseDir;
        final List<File> files1 = getFileList(tag1Path);
        for (final File f : files1) {
            // 非普通类不处理
            if (!ASTGeneratror.isTypeDeclaration(f.getAbsolutePath())) {
                continue;
            }
            final File f2 = new File(
                    tag2Path + f.getAbsolutePath().replace(tag1Path, ""));
            diffFile(f.toString(), f2.toString());
        }
        return methodInfos;
    }

/**
     * 对比文件
     * 
     * @param nfile
     * @param ofile
     * @return
     */
    public static List<MethodInfo> diffFile(final String nfile,
            final String ofile) {
        final MethodDeclaration[] methods1 = ASTGeneratror.getMethods(nfile);
        if (!new File(ofile).exists()) {
            for (final MethodDeclaration method : methods1) {
                final MethodInfo methodInfo = methodToMethodInfo(nfile, method);
                methodInfos.add(methodInfo);
            }
        } else {
            final MethodDeclaration[] methods2 = ASTGeneratror
                    .getMethods(ofile);
            final Map<String, MethodDeclaration> methodsMap = new HashMap<String, MethodDeclaration>();
            for (int i = 0; i < methods2.length; i++) {
                methodsMap.put(
                        methods2[i].getName().toString()
                                + methods2[i].parameters().toString(),
                        methods2[i]);
            }
            for (final MethodDeclaration method : methods1) {
                // 如果方法名是新增的,则直接将方法加入List
                if (!isMethodExist(method, methodsMap)) {
                    final MethodInfo methodInfo = methodToMethodInfo(nfile,
                            method);
                    methodInfos.add(methodInfo);
                } else {
                    // 如果两个版本都有这个方法,则根据MD5判断方法是否一致
                    if (!isMethodTheSame(method,
                            methodsMap.get(method.getName().toString()
                                    + method.parameters().toString()))) {
                        final MethodInfo methodInfo = methodToMethodInfo(nfile,
                                method);
                        methodInfos.add(methodInfo);
                    }
                }
            }
        }
        return methodInfos;
    }

public static String MD5Encode(String s) {
        String MD5String = "";
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            BASE64Encoder base64en = new BASE64Encoder();
            MD5String = base64en.encode(md5.digest(s.getBytes("utf-8")));
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return MD5String;
    }


/**
     * 判斷方法是否一致
     * 
     * @param method1
     * @param method2
     * @return
     */
    public static boolean isMethodTheSame(final MethodDeclaration method1,
            final MethodDeclaration method2) {
        if (MD5Encode(method1.toString())
                .equals(MD5Encode(method2.toString()))) {
            return true;
        }
        return false;
    }

上面最后一个方法就是拿方法的详细信息来做 md5 的比对,所以这也就有了评论区的那个方法误判变更的来由。
不过这属于历史遗留问题,并不能算大事,想办法规避即可。

共收到 82 条回复 时间 点赞
匿名 #1 · 2019年09月23日

楼主肯定把增量覆盖率的私货藏起来了🔜

请来第三发,谢谢😄

增量来一发

--- 统一回复下一楼和三楼:
@link1220 @dancingcat_
其实增量的我觉得目前粗粒度的来说,AngryTester 做的已经差不多可以了,精细到方法。
那再有详细一点的可以用 gitdiff,做更精细化的检视,精确到某一行,这个看个人就行了。
但是精确到方法,用 MD5 来做精准化判定是否变更,可能会有一些问题存在,比如:

精细到方法粒度,可能小的变化就认为了变更。
像方法头部的注释,方法内部的局部变量优化,都可能带来方法的变更。

所以可以自行来决定如何判定方法变更,实现更细粒度的判别,不至于误判,可能你加了一个@param的注释,这个方法就进到变更覆盖率统计里了。
这个场景在我们这里遇到过,有人反馈说这个代码一点没有变,为啥统计成变更了,我分析过后就是这个原因。

AngryTester的原库在这里(可能发现我经常提到这个人,因为我很大一部分灵感与这个大兄弟,站在了他的肩膀上,哈哈,很感谢他)
地址: AngryTester/jacoco

顶,说的很详细透彻👏

simple 将本帖设为了精华贴 09月24日 14:52

加精理由:不加精说不过去

simple 回复

哇哦,首精唉。谢谢大佬😃

大佬。学习了

楼主再多分享点干货

陈老师白闹,肚子空空哪有干货,都是坏水😌

陈老师多分享下你的摄影技术

也得益于今年上半年一直负责整个公司不同类型的项目的覆盖率统计技术的适配,对不同形式的项目均有一定的了解,在此记录一下,也不让千疮百孔的自己浪费掉这半年的精力,如果说可以帮到别人一星半点,那这篇文章就算是造福了。

感谢楼主分享~~~超级棒~

咨询楼主一个问题:因为线下测试的时候会出现多次发布,怎么把这多次发布的覆盖度结果进行合并?原生的 jacoco 合并或追加都是在源码未发生变化的时候才可以,期待楼主解惑!
感谢楼主分享!

tkspeking 回复

就本质上来说,覆盖率报告的生成,只需要两个必须的东西,一个是 class 文件,一个是针对这些 class 产生的执行信息 (就是 exec 文件)。
当然了源码起到很重要的作用就是可以让你在报告里面来回跳转查看代码详情,如果没有源码,则没办法点进去方法。
综上来说,那也可以说成一个完善的覆盖率报告,可以有源码、class 文件、和对应的 exec 文件即可。

回到你的问题,针对你的变更,结合到这三个东西那就可以有以下合并的结论:

  1. 首先,最先想到的就是多次发布产生的exec 合并,当然这是 jacoco 原本就支持的,或者说是 exec 文件本身的特性 - 支持合并
  2. 第二,基于第一步,当然可以合并多次发布的报告,但也会有一些风险
  3. 第三,风险 1,就是 exec 虽然可以合并,但是每次发布的时候,class 文件的变更,可能会导致同一个 class 在不同阶段中会有不同的形态,那会不会有覆盖率数据不准的问题,这个有待讨论
  4. 第四,风险 2,多次发布,因为中间有停顿时间,或者覆盖率数据的收集有没有一定的时间间隔,会不会因为多次发布导致有些执行步骤的覆盖率信息没有收集到,这也在一定程度上可能会导致覆盖率数据的不准,甚至丢失
  5. 第五,风险 3,多次发布,有没有可能在写入 exec 的时候,会导致数据写到一半的时候被停机,导致 exec 文件本身变得不完整,这有可能导致覆盖率数据不准,甚至有可能导致文件不完整,让 jacoco 没法读取这些信息、

综上步骤,可以简单得出,你的问题,在一定程度上可以做到,但最终能到什么地步,取决于你对这几个风险的把控,要么你可以忽略,要么你可以深度解析一下源码,看看有没有什么解决方案或者规避方案。

当然,这也是我个人现阶段对它的认知,猜出来的解决方案,也可能不对,但总归是个思路~希望可以帮到您

多谢楼主回答问题!
现在从实际验证来看(可以确定楼主的第四、第五不存在,应用仅我一个人使用):

  1. 第一次源码文件有一个方法,测试使其已覆盖,并拉取 exec。
  2. 编辑源码文件,新增一个方法,测试使其覆盖,并拉取 exec。
  3. 合并两次结果文件,生成报告,此时仅能看到新增的方法被覆盖

我再想想其他办法,从报告生成方面想想办法,再次感谢

楼主 最近遇到一个问题,就是我们是用 duubo 分布式服务,其中 was 服务是部署在 tomcat 里面,我可以修改 tomcat 启动参数监控,但 das 服务是部署在其他机器里面,是一个服务进程 (java -cp "./bin:../libs/*:." -Ddubbo.shutdown.hook=true "com.yjhealth.pcloud.dat
) 这样启动的服务,好像监控不到。这个要怎么处理,我是 Jenkins 大包部署的
然后就是启动参数修改 不用 jacocoagent 安装路径吗

lyz362502 回复

没太明白你的问题出在哪儿,所以不能准确回答你的问题。。
但想说一点,就 jacoco 本身而言,如果你的服务启动的时候,不经过 jacocoagent 的代理程序,那么都是检测不到的。
当然了,即使你经过了 jacocoagent 的代理,也有可能是出不来的,出不来的原因就有很多了。

你可以再详细描述下问题。

lyz362502 回复

嗯嗯,希望可以帮到你了。
等你研究完了可分享出来供大家食用,哈哈

@ 风雨夜阑听 楼主你好,我们遇到这个问题 “do no match with execution data. For report generation the same class files must be used as at runtime”,“Execution data for class com/xxxx does not match”。我也同步了发布的编译的 JDk,但是还是没有解决。

Gavin 回复

这个看起来就是你的构建 jdk 和你运行时的 jdk 不一致,最好是要保持一致。

在哪儿构建,就在哪儿运行,如果不能做到这样,那就干脆生成报告的时候,class 文件、源代码、和 exec 文件直接从运行时的机器上复制。

你可以看下我上面的回复,生成报告,要想准确,就得源码、class 文件、exec 最好是同一批构建的产物,当然不排除有时候分开也可以,但最好是要保证同一批~~

希望可以帮到你😌

你可以了解下 class 文件的构造,开头有一串东西 (好像叫魔数啥的),是用来区分 jdk 相关的东西的,差别过大,可能就会被认为不匹配了。

@ 风雨夜阑听 多谢回复,构建 jdk 和运行时的 jdk 我都检查了,没有问题。现在编译打包是在 jenkins 的服务器,JDK 版本也是一致的。方便加一下微信吗?18520825002

楼主你好,首先非常感谢你的分享~
想问下,如果只想要知道代码变更的影响范围,来辅助测试的测试范围,可以通过插桩来实现吗?求指点

Janet 回复

谢阅。
影响范围这个话题是比较大的,也很难一句两句说清楚。不过按照我当前能力的理解,对测试来说,最粗粒度的可以做到检测出变更的方法或者类,出现在哪些请求的调用链上,也就是说被哪些类的哪些方法调用了,这才是测试应该关注的。如果其他的请求链路,没有出现变更方法的调用,那从一定程度上一定范围内是安全的。所以你可以按照这个思路来想想可以做点什么,至于用到的技术和一些,我有些建议,但我没有完全试验过,所以可以看看能否参考:

  • 1.你可以通过解析新版本的字节码,把整个应用的调用链路给解析出来,当然了这个调用链路多数情况下会有多颗树组成,对整个应用来说,最终会是一个由多个调用树组成的森林。这个用 ASM 字节码解析,应该可以做到,但数据量可能比较大,怎么存储怎么展示,可能需要细想,在 java 层可以想办法以 hashmap 来存。
  • 2. 上面的森林拿到之后,可以根据新代码的源码和基线代码的源码,来解析变更类和方法,看你怎么存储,差不多可以拿到一个变更方法的列表 (当然也会有该方法的类信息存在)。
  • 3. 结合上面的调用关系森林和变更方法的列表,可以初步判断在调用森林里,哪些树中的哪些路径是包含了变更方法的,针对这些请求链路,可以看看怎么补充测试。整个调用关系的存储,之前请教过思寒,可以尝试用 neo4j 或者 kibana 来处理,或者你有其他的思路也可以尝试下

当然了,以上只是我的设想,我之前也做过尝试,只简单做到了 1 和 2,至于 3 嘛,存储和展示都需要一定的技术和资源,我并没有深入研究下去,当然可能会有一定难度,但我之前想到的也就是这个思路。我这想的都比较浅,不知道会不会对你有所帮助。

感谢楼主这么认真的回复,我也有试想过尝试用变更代码的调用关系去判断影响范围,但觉得这样是远远不够的,可能会有更多更深的影响范围,有点累觉不爱了😂

问题描述:项目有 4 个应用包,一个放在 181 服务器 tomcat 里面,前端代码也是这个 tomcat,其他三个应用是放在 182 服务器,启动是用 java -jar 方式启动, 项目是用 dubbo 框架。 现在有两个问题:1. 181 我修改了 tomcat 配置,加了 JavaAgent 监控,可以拿到覆盖率,但 182 上面三个应用没有 tomcat 怎么拿到覆盖率? 我参试了在 java -jar 启动参数里面增加 JavaAgent,开启了三个监控端口,不知道是否可行? 2. 这 4 个应用的覆盖率报告如何合并然后显示在 Jenkins 里面?

lyz362502 回复

感谢提问,其实我对 jacoco 的理解也是有限的,很多原理性的东西或者更深入的东西我也不会😂
看完描述,我首先想到的是贤弟 (妹) 可能没有读完这个文章的内容,这个怪我文笔不好写的太啰嗦了,道个歉哈。
本身没有搞清楚你这四个工程是个怎样的结构,我姑且以为你这个四个工程属于父子工程(隶属一个 git url,而不是项目间交互),以此为基准,下面就聊聊你提到的几个点:


问点 1: 项目是用 dubbo 框架

答: 首先你提到 dubbo 框架让我心里一虚,因为我对 dubbo 的理解只停留在表面,所以我只能按照我理解的来说 (当然可能会错,这个需要你识别一下哈。)
dubbo 本身是基于接口进行的调用,所以你对 dubbo 的调用覆盖,势必要到 provider 端 (因为实现都在 provider 端),一般来说,dubbo 项目会专门提供一个 client 端的 jar 包或者 GAV(通常是纯接口),供 consumer 端调用,这至少在我理解的我们公司项目是这样。就比如你的 A B C D 四个项目,可能你对 A 项目发起的请求,它内部通过 dubbo 调用了 D(provider) 项目的接口,那么相当于你对 A 项目进行测试,那么这一部分测试一方面覆盖率你 A 项目的部分代码,另一部分也覆盖到了 D 项目的代码实现,虽然你在 A 项目中有用到一部分 D 项目的代码,但是可能它提供的都是接口,你 (应该) 是没办法统计到这些 D 项目中的具体实现。这个要计划清楚


问点 2: 但 182 上面三个应用没有 tomcat 怎么拿到覆盖率

答: jacoco 的本质工作机制,并不依赖于哪个 web 容器,它需要的是一个动态的或者静态字节码插桩,就是你这个应用或者 java 代码不管将来怎么运行,这并不重要,重要的是要给它插桩的时机 (也就是 class 的生成和运行)。这个点理清楚了,你下一个问题也就不是问题了。所以你这个问题就追溯到上面提到工作机制的 java -jar,如果你最后没有收到覆盖率数据,那我猜可能是启动命令写错了或者端口没有规划好,或者去参考下文章说的那些覆盖率报告为 0 的场景,有没有命中。


问点 3: 我参(尝?)试了在 java -jar 启动参数里面增加 JavaAgent,开启了三个监控端口,不知道是否可行

答: 肯定是可行的 ,完全支持这种机制。


问点 4: 这 4 个应用的覆盖率报告如何合并然后显示在 Jenkins 里面?

答前半句 (能否合并):

  • 肯定是 OK 的,具体去看下这个文章的主题 (十二),覆盖率数据是可以合并的。

答后半句 (能否在 Jenkins 上运行):

  • 我猜你想集成到 jenkins 上。这个我没法肯定回答,因为我暂时没有用过 jenkins 去统计这个覆盖率,我一直以为 jenkins 只支持单元测试的覆盖率统计 (可能显得很没文化,孤陋寡闻了哈)。 这个建议先去了解下你希望用 jenkins 能做到什么地步,如果它支持自定义覆盖率数据 (就是 exec 或者 ec 文件),那么结合源码和 class 文件目录,那你这个目标应该是没问题的,上面覆盖率数据合并之后丢给 jenkins 去统计就行了。 如果 jenkins 只是支持像执行单元测试那样才能统计,那我暂时没有好的建议,你或许可以发帖求助下社区。

想了解一下项目部署完生成的 class 文件以什么方式去获取的~

楼主你好,你有研究过在 Android 9 的源码代码里使用
1.cd /external/jacoco
EMMA_INSTRUMENT_STATIC=true mma
2.EMMA_INSTRUMENT_STATIC=true make ($APK_NAME)
通过这个方式来生成 coverage.em 文件吗?

楼主请问下,如下两种方式下的覆盖率如何统计
环境背景:android studio 下 sdk 项目,涉及有多个 module,同时有涉及 android ui 相关功能
1)测试中用产出 jar 包,用 jacoco 怎么做自动化测试覆盖率统计呢
2)在这个 sdk 项目中单独 module 创建的单测项目,如何统计所有 module 的覆盖率文件,就是合并在一起的那种呢

simple [精彩盘点] TesterHome 社区 2019 年 度精华帖 中提及了此贴 12月24日 23:00
simple [精彩盘点] TesterHome 社区 2019 年 度精华帖 中提及了此贴 12月24日 23:00

楼主好,如何才能在不重新重新部署的情况下,初始化覆盖率?让覆盖率恢复到 0%

Yangxb0903 回复

coverage.em 应该是在 jack-server 下生产的产物吧,Android 9 取消了 jack-server 编译,配置编译链后还是 offline 下生成插装 class。

有个问题想请教下,我将服务器上的 exec 文件远程 dump 到本地生成了一个报告,然后本地也有最新的代码和 class 文件,报告生成之后能看到覆盖率数据,但是发现到方法那一层之后就不能再点开了,页面上有个提示 “Source file "com/xx/xxx/xxx.java" was not found during generation of report.”,我想点开看具体哪些语句没有覆盖就没法看,只有一个数据,请问下楼主有遇到过类似的问题没?

Kyle 回复

请问,你解决这个问题了吗。我也遇到了。按照官网的意思是说,源文件路径可能不对,但是我的源路径是对的。

test小生 回复

这个问题已经解决了。是因为 ant build.xml 里的源码路径 好像只能写成这样。‘./src/main/java’

tkspeking 回复

你好,我也遇到了跟你一样的问题,请问你现在解决了么。。。

少年,想换工作么?哈哈。

刘晓光 回复

😂 啥情况

楼主有研究过 kotlin 的增量代码差异吗?

saii 回复

没有呢,暂时没涉及到 kotlin 的项目需求😂

saii 解决 jacoco 支持增量 kotlin 代码覆盖率 中提及了此贴 04月04日 01:55

老哥老哥,我有一个小问题。。。我司通过 jenkins 编译部署的 dubbo 项目,我在部署服务器上做了 Jacoco 和 ant 的两个的环境和配置。但是在修改启动服务脚本的时候发现一个难过的事情,因为通过 jenkins 编译部署,所以我所有 dubbo 服务用的都是一个启动脚本,如此一来......tcpserver 的端口只能起一个啊 TA T 我起来一个服务,其他服务全起不来了。。难过,有解吗老哥。

吴狗蛋儿 回复

你这个应该问题不大吧,即使你用脚本启动,也可以给 shell 脚本传端口参数吧。
不太清楚你 jenkins 去打包部署的时候是怎么做的,如果同一个项目起多个服务,那你用 jenkins 做也是 java 命令后面跟-Dspring.profile.active 这个参数或者是直接指定 port 吧,那应该直接把 jacoco 的配置传过去应该是类似的?

就是不知道我有没有准确理解你的意图

仅楼主可见

老哥老哥,是这样的,jacoco 如果用 tcp 开放 server 端的话.... 我所有服务都是一个启动脚本,但是如果我把-jacoco 这个启动配置加到启动脚本的时候, prot 用的只有一个端口,导致了只能启动一个服务,其他服务启动的时候,会提示端口占用。。不知道我这么说说明白没有。。 就是如何做到一个 jacoco 对多服务。

报告一般生成在哪里

老哥老哥 我还有一个小问题,就是我这里是 jar 包,所以我 class 路径和源文件路径是走到那一层,或者怎么写路径,我试了 xxx.jar/META-INF/xx 提示我找不到路径。

tkspeking 回复

请问你的这个情况解决了么。

hi 楼主 :
看到你上边有说到 “你可以通过解析新版本的字节码,把整个应用的调用链路给解析出来” 我想了解下 解析字节码拿到调用链路,是可以独立于源工程进行 还是需要在源工程内部进行解析,也就是说我有一个工程 A 我需要在 A 工程中添加字节码解析代码呢 还是可以新建一个工程 然后针对 A 工程的字节码进行解析就可以?

当然可以

吴狗蛋儿 回复

你这样做的目的是什么呢?
如果是单项目多工程,那一般也只有一个属于启动项目,其他属于 jar 包引入,这时候需要一个 jacoco 服务也就够了;
如果是多项目多工程,那项目之间也不需要组合统计吧?一个 jacoco 服务,对着多个系统服务,即使能启动起来不报端口占用错误,那也没什么太大意义吧。
你统计数据的时候还是要按照不同的项目或者不同的系统服务来分开,但是一个 jacoco 的话,多个系统的东西都堆到这一个服务里面,覆盖率的执行数据也是同一时间保存的,那不是纯粹增加解析的工作量么。

难道仅仅是为了给服务器节省一个 jacoco 服务的启动端口?

不知道我有没有说明白~~

(关于端口占用的问题,他只是启动的时候一个提示,你可以关注一下即使报了这个端口占用错误,系统还是不时能起来的,我印象中这个不影响应用的启动,只是显示启动这个 jacoco 的 tcp 服务失败,但实际上这个 jacoco 服务 (就是之前被另一个系统启动起来的那个) 能不能统计你这个新系统的覆盖数据,你可以尝试一下,看看拉去出来的覆盖率数据有没有你的新系统的代码,这个底层细节我没有深入去看。)

吴狗蛋儿 回复

这个没明白具体什么意思,可以描述清晰一些?或者直接上图~

北部晴天 回复

应该是最好独立的,只需要提供 class 文件就可以解析

部署测试服务器(加入 jacoco 的代理,按照上面的方式进行即可)2 需要知道上述部署时的版本代码,需要知道待比较的基线版本代码,并下载两个代码到某个路径下,并编译最新的代码 (至于需不需要编译,看你的需求,也可以用测试服务器上的,这样最准确。现编译的话,可能会编译机跟测试机的不同,造成生成的 class 文件不一致,这会导致覆盖率数据不准确)

楼主,这里面说的从测试服务器上获取代码和生成的 class 文件是怎么实现的呢?这个有实现的例子可以参考么?

星云 回复

这问题属于覆盖率范畴之外的通用功能了,用代码调用 sftp 下载、执行 scp 或者 xcopy 之类的命令,都可以。
给你个样例吧,这是从一个 linux 远程服务器上下载的,根据自己需要去调整,里面有的从远程服务器下载目录的时候 finally 里的 close 我给注释掉了,因为我认为下载目录不需要每个文件都执行重连,不是很规范,这个你自己调整吧。

当然里面还有一些本地代码,你想办法自己定义一下,或者删除掉就好了,这代码里的本地和远程,代指的是当前系统的服务器 (本地),测试服务器 (远程)。
(如果这里面涉及到下载文件的安全问题啥的,可以自己调一下)

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.administrator.platform.exception.base.BusinessValidationException;

import ch.ethz.ssh2.Connection;
import ch.ethz.ssh2.SCPClient;
import ch.ethz.ssh2.SCPInputStream;
import ch.ethz.ssh2.SCPOutputStream;
import ch.ethz.ssh2.SFTPv3Client;
import ch.ethz.ssh2.SFTPv3DirectoryEntry;
import ch.ethz.ssh2.SFTPv3FileAttributes;
import ch.ethz.ssh2.Session;
import ch.ethz.ssh2.StreamGobbler;

public class GanymedSshClient {
    private static GanymedSshClient instance;

    private Session session;
    private ServerInfo serverInfo;
    private Connection connection;

    private SCPClient scpClient;
    private SFTPv3Client sftPv3Client;

    private static final String MODE = "0644";
    private static final int MKDIR_POSIX_PERMISSION = 0755;

    private static final long LENGTH = 1024;
    private static final String REMOTE_PATH_SEPARATOR = "/";
    private static final String NO_SUCH_FILE_ERROR_MESSAGE = "No such file (SSH_FX_NO_SUCH_FILE:";

    private final List<SFTPv3DirectoryEntry> childrenList = new ArrayList<>();

    private static final Logger LOGGER = LoggerFactory
            .getLogger(GanymedSshClient.class);

    private GanymedSshClient() {

    }

    public Session getSession() {
        return session;
    }

    public void setSession(Session session) {
        this.session = session;
    }

    public ServerInfo getServerInfo() {
        return serverInfo;
    }

    public void setServerInfo(ServerInfo serverInfo) {
        this.serverInfo = serverInfo;
    }

    public GanymedSshClient(ServerInfo serverInfo) {
        this.serverInfo = serverInfo;
    }

    public GanymedSshClient(String host, int port, String username,
            String password) {
        this.serverInfo = new ServerInfo(host, port, username, password);
    }

    public GanymedSshClient(String host, String username, String password) {
        this.serverInfo = new ServerInfo(host, username, password);
    }

    public GanymedSshClient(String host) {
        this.serverInfo = new ServerInfo(host);
    }

    /**
     * 初始化session
     * 
     * @see :
     * @param :
     * @return : void
     */
    private void initConnection() {
        if (null == this.connection
                || !this.connection.isAuthenticationComplete()) {
            this.connection = new Connection(this.serverInfo.getHost());
            LOGGER.debug("开始初始化连接");
            try {
                this.connection.connect();
                boolean authed = connection.authenticateWithPassword(
                        this.serverInfo.getUsername(),
                        this.serverInfo.getPassword());

                if (!authed) {
                    throw new BusinessValidationException("初始化ssh连接失败,认证异常");
                }

                LOGGER.debug("CONNECTION 初始化完成");
            } catch (IOException e) {
                LOGGER.error("初始化连接失败,失败原因:{}", e.getMessage());
                throw new BusinessValidationException("初始化连接失败");
            }
        }
    }

    private Connection getConnection() {
        initConnection();
        return this.connection;
    }

    /**
     * 初始化session
     * 
     * @see :
     * @param :
     * @return : void
     */
    private void initSession() {
        if (null == this.session) {
            LOGGER.debug("初始化session");
            try {
                this.session = getConnection().openSession();

                if (null != this.session) {
                    LOGGER.debug("session初始化完成");
                }
            } catch (IOException e) {
                LOGGER.error("获取会话失败,失败原因:{}", e.getMessage());
            }

        }
    }

    private Session getCurrentSession() {
        initSession();

        return this.session;
    }

    private SCPClient getScpClient() {
        if (null == this.scpClient) {
            this.scpClient = new SCPClient(getConnection());
        }

        return this.scpClient;

    }

    private void close() {
        if (null != this.connection) {
            this.connection.close();
            this.connection = null;
            this.scpClient = null;
        }

        if (null != this.session) {
            this.session.close();
            this.session = null;
        }

        if (null != this.sftPv3Client) {
            this.sftPv3Client.close();
            this.sftPv3Client = null;
        }
    }

    /**
     * 上传文件
     * 
     * @see :
     * @param :
     * @return : void
     * @param localFile
     * @param remoteFileName
     * @param remoteFolder
     */
    public void uploadFile(String localFile, String remoteFolder) {
        LOGGER.debug("上传文件:{},到:{}", localFile, remoteFolder);
        try (SCPOutputStream os = getScpOutputStream(localFile, remoteFolder);
                FileInputStream fis = new FileInputStream(localFile);) {
            byte[] b = new byte[4096];
            int i;
            while ((i = fis.read(b)) != -1) {
                os.write(b, 0, i);
            }
            os.flush();
        } catch (IOException e) {
            LOGGER.error("scp 上传文件失败:{}", e.getMessage());
            throw new BusinessValidationException("上传文件失败");
        } finally {
            close();
        }
    }

    /**
     * 获取文件输出流失败
     * 
     * @see :
     * @param :
     * @return : SCPOutputStream
     * @param localFile
     * @param remoteFolder
     * @return
     */
    private SCPOutputStream getScpOutputStream(String localFile,
            String remoteFolder) {
        try {

            File file = new File(localFile);
            return getScpClient().put(file.getName(), file.length(),
                    remoteFolder, MODE);
        } catch (IOException e) {
            LOGGER.error("获取文件输出流失败:{}", e.getMessage());
            throw new BusinessValidationException("获取文件输出流失败");
        }
    }

    /**
     * 批量上传文件
     * 
     * @see :
     * @param :
     * @return : void
     * @param localFiles
     * @param remoteFolder
     */
    public void uploadFiles(String[] localFiles, String remoteFolder) {
        for (String localFile : localFiles) {
            File thisFile = new File(localFile);

            if (thisFile.exists() && thisFile.isDirectory()) {
                File[] files = thisFile.listFiles();

                String dirName = remoteFolder + REMOTE_PATH_SEPARATOR
                        + thisFile.getName();
                mkdir(dirName);
                uploadFiles(files, dirName);

            } else if (thisFile.exists() && thisFile.isFile()) {
                uploadFile(localFile, remoteFolder);
            }
        }
    }

    /**
     * 上传本地文件到远程服务器端,即将本地的文件localFile上传到远程Linux服务器中的remoteTargetDirectory目录下
     * 
     * @param localFileList
     * @param remoteTargetDirectory
     */
    public void uploadFiles(List<String> localFileList,
            String remoteTargetDirectory) {
        uploadFiles(localFileList.toArray(new String[] {}),
                remoteTargetDirectory);
    }

    /**
     * 上传本地文件到远程服务器端,即将本地的文件localFile上传到远程Linux服务器中的remoteTargetDirectory目录下
     * 
     * @param localFileList
     * @param remoteTargetDirectory
     */
    public void uploadFiles(File[] localFileList,
            String remoteTargetDirectory) {

        String[] filePaths = new String[localFileList.length];

        for (int i = 0; i < localFileList.length; i++) {
            filePaths[i] = localFileList[i].getAbsolutePath();
        }
        uploadFiles(filePaths, remoteTargetDirectory);
    }

    /**
     * 上传文件
     * 
     * @see :
     * @param :
     * @return : void
     * @param localFolder
     * @param remoteFolder
     */
    public void uploadFolder(String localFolder, String remoteFolder) {
        File localFileFolder = new File(localFolder);
        File[] files = localFileFolder.listFiles();
        uploadFiles(files, remoteFolder);
    }

    /**
     * 下载单个文件
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFile
     * @param destFile
     */
    public boolean downloadFile(String remoteFile, String remoteDir,
            String destFileFolder) {
        File destFile = new File(destFileFolder);
        if (!destFile.exists()) {
            destFile.mkdirs();
        }

        String localFile = remoteFile;

        /**
         * 2019年8月12日 20:38:22 modified by 孙留平
         * 
         * @see: 当远程文件中有$符号的时候,会被转义,因此远程的时候,需要换上传义字符,尤其是java的内部类被编译出来的class都是带有$符号的
         * 
         */
        if (remoteFile.contains("$")) {
            remoteFile = remoteFile.replace("$", "\\$");

        }

        try (SCPInputStream scpInputStream = getRemoteFileInputStream(
                remoteFile, remoteDir);
                FileOutputStream fos = new FileOutputStream(
                        new File(destFileFolder, localFile));) {
            String message = String.format("下载文件:%s/%s,到:%s", remoteDir,
                    remoteFile, destFileFolder);

            byte[] b = new byte[4096];
            int i;
            while ((i = scpInputStream.read(b)) != -1) {
                fos.write(b, 0, i);
            }
            fos.flush();
            LOGGER.debug("{}成功", message);
            return true;
        } catch (IOException e) {
            LOGGER.error("下载文件失败,原因:{}", e.getMessage());
            return false;
        }
        // finally {
        // close();
        // }
    }

    /**
     * 获取远程流
     * 
     * @see :
     * @param :
     * @return : SCPInputStream
     * @param remoteFile
     * @param remoteDir
     * @return
     */
    private SCPInputStream getRemoteFileInputStream(String remoteFile,
            String remoteDir) {
        SCPInputStream scpInputStream;
        try {
            scpInputStream = getScpClient()
                    .get(remoteDir + REMOTE_PATH_SEPARATOR + remoteFile);

            return scpInputStream;
        } catch (IOException e) {
            LOGGER.error("获取目录:{}下的文件:{}流失败,失败原因{}", remoteDir, remoteFile,
                    e.getMessage());
            throw new BusinessValidationException("获取输入流失败");
        }
    }

    /**
     * 下载单个文件
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFiles
     * @param destFile
     */
    public boolean downloadFiles(String[] remoteFiles, String remoteFileFolder,
            String destFileFolder) {
        File destFile = new File(destFileFolder);
        if (destFile.isFile()) {
            destFileFolder = destFile.getParent();
        }

        for (String string : remoteFiles) {
            downloadFile(string, remoteFileFolder, destFileFolder);
        }

        return true;
    }

    /**
     * 下载目录
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFileFolder
     * @param destFileFolder
     */
    public void downloadFolder(String remoteFileFolder, String destFileFolder) {
        // listRemoteDir(remoteFileFolder);
        List<SFTPv3DirectoryEntry> descendantsFiles = getChildren(
                remoteFileFolder);
        File destFolderFile = new File(destFileFolder);
        if (!destFolderFile.exists()) {
            destFolderFile.mkdirs();
        }
        LOGGER.debug("开始下载:从{}到:{}", remoteFileFolder, destFileFolder);
        for (SFTPv3DirectoryEntry sftPv3DirectoryEntry : descendantsFiles) {
            // 如果是文件夹,则下载文件夹
            if (sftPv3DirectoryEntry.attributes.isDirectory()) {
                downloadFolder(
                        remoteFileFolder + REMOTE_PATH_SEPARATOR
                                + sftPv3DirectoryEntry.filename,
                        destFileFolder + File.separator
                                + sftPv3DirectoryEntry.filename);
            } else if (sftPv3DirectoryEntry.attributes.isRegularFile()) {
                downloadFile(sftPv3DirectoryEntry.filename, remoteFileFolder,
                        destFileFolder);
            }
        }
    }

    /**
     * 下载目录,从远程数组,到本地文件夹,意思把数组中的远程目录都下载到同一个文件夹下,小心文件覆盖
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFileFolder
     * @param destFileFolder
     */
    public void downloadFolders(String[] remoteFileFolder,
            String destFileFolder) {
        for (String string : remoteFileFolder) {
            downloadFolder(string, destFileFolder);
        }
    }

    /**
     * 下载目录,从远程数组、到本地数组,一一对应
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFileFolder
     * @param destFileFolder
     */
    public void downloadFolders(String[] remoteFileFolder,
            String[] destFileFolder) {

        if (null == remoteFileFolder || null == destFileFolder) {
            throw new BusinessValidationException("远程文件夹和本地文件夹都不能为null");
        }

        if (remoteFileFolder.length == 0 || destFileFolder.length == 0) {
            throw new BusinessValidationException("远程文件夹和本地文件夹都不能为空");
        }

        if (remoteFileFolder.length != destFileFolder.length) {
            throw new BusinessValidationException("远程文件夹数组长度,和本地文件夹数组长度,必须得一样");
        }
        for (int i = 0; i < destFileFolder.length; i++) {
            downloadFolder(remoteFileFolder[i], destFileFolder[i]);
        }
    }

    /**
     * 执行命令
     * 
     * @see :
     * @param :
     * @return : void
     * @param command
     */
    public String executeShell(String command) {
        LOGGER.debug("执行命令:{}", command);
        Session currentSession = getCurrentSession();

        try (InputStream stdStream = new StreamGobbler(
                currentSession.getStdout());
                InputStream stdErrStream = new StreamGobbler(
                        currentSession.getStderr());

                BufferedReader bReader = new BufferedReader(
                        new InputStreamReader(stdStream));

                BufferedReader bufferedErrorReader = new BufferedReader(
                        new InputStreamReader(stdErrStream))) {
            currentSession.execCommand(command);
            StringBuilder outputBuilder = new StringBuilder();
            String line = null;
            while (true) {
                line = bReader.readLine();
                if (null == line) {
                    break;
                }
                outputBuilder.append(line).append("\n");
            }

            String errorLine = null;
            while ((errorLine = bufferedErrorReader.readLine()) != null) {
                outputBuilder.append(errorLine).append("\n");
            }

            LOGGER.debug("命令执行结果:\n{}", outputBuilder);
            return outputBuilder.toString();
        } catch (IOException e1) {
            LOGGER.error("执行命令失败:{}", e1.getMessage());
            throw new BusinessValidationException("执行命令失败");
        } finally {
            close();
        }
    }

    /**
     * 删除远程文件或者目录
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFileOrFolder
     */
    public boolean deleteRemoteFile(String remoteFile) {
        try {
            LOGGER.debug("尝试删除文件:{}", remoteFile);
            getSftpV3Client().rm(remoteFile);
            LOGGER.debug("删除文件成功:{}", remoteFile);

            return true;
        } catch (IOException e) {
            LOGGER.error("删除文件失败:{}", e.getMessage());

            if (e.getMessage().contains(NO_SUCH_FILE_ERROR_MESSAGE)) {
                LOGGER.debug("文件夹不存在,不需要删除");

                return true;
            }

            return false;
        } finally {
            close();
        }
    }

    /**
     * 删除远程文件或者目录
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFileOrFolder
     */
    public boolean deleteRemoteFile(String[] remoteFiles) {
        try {

            LOGGER.debug("尝试删除一组文件");
            for (String remoteFile : remoteFiles) {
                getSftpV3Client().rm(remoteFile);
                LOGGER.debug("删除文件成功:{}", remoteFile);
            }
            LOGGER.debug("批量删除文件成功");
            return true;
        } catch (IOException e) {
            LOGGER.error("删除文件失败:{}", e.getMessage());
            if (e.getMessage().contains(NO_SUCH_FILE_ERROR_MESSAGE)) {
                LOGGER.debug("文件不存在,不需要删除");

                return true;
            }

            return false;
        } finally {
            close();
        }
    }

    /**
     * 删除远程目录
     * 
     * @see :
     * @param :
     * @return : void
     * @param remoteFileOrFolder
     */
    public boolean deleteRemoteFileFolder(String remoteFileFolder) {
        try {
            LOGGER.debug("尝试删除文件夹:{}", remoteFileFolder);
            getSftpV3Client().rmdir(remoteFileFolder);
            LOGGER.debug("删除文件夹成功:{}", remoteFileFolder);

            return true;
        } catch (IOException e) {
            LOGGER.error("删除文件夹失败:{}", e.getMessage());

            if (e.getMessage().contains(NO_SUCH_FILE_ERROR_MESSAGE)) {
                LOGGER.debug("文件夹不存在,不需要删除");

                return true;
            }

            if (e.getMessage().contains("Failure (SSH_FX_FAILURE:")) {
                return deleteNoneEmptyRemoteFolder(remoteFileFolder);
            }

            return false;
        } finally {
            close();
        }
    }

    /**
     * 删除非空文件夹
     * 
     * @see :
     * @param :
     * @return : boolean
     * @param remoteFolder
     * @return
     */
    private boolean deleteNoneEmptyRemoteFolder(String remoteFolder) {
        LOGGER.debug("删除非空文件夹:{}", remoteFolder);
        String command = "rm -rf " + remoteFolder;
        String deleteNoneEmptyFolder = executeShell(command);
        LOGGER.debug("删除非空文件夹结果:{}", deleteNoneEmptyFolder);
        return true;

    }

    /**
     * 在远端linux上创建文件夹
     * 
     * @param dirName
     *            文件夹名称
     * @param posixPermissions
     *            目录或者文件夹的权限
     */
    public boolean mkdir(String dirName, int posixPermissions) {
        try {
            LOGGER.debug("创建文件夹:{}", dirName);
            getSftpV3Client().mkdir(dirName, posixPermissions);
            LOGGER.debug("创建文件夹:{}成功", dirName);
            return true;
        } catch (IOException e) {
            LOGGER.error("创建文件夹失败:{}", e.getMessage());
            return false;
        }
    }

    /**
     * 在远端linux上创建文件夹
     * 
     * @param dirName
     *            文件夹名称
     */
    public boolean mkdir(String dirName) {
        return mkdir(dirName, MKDIR_POSIX_PERMISSION);
    }

    /**
     * 在远程Linux服务器端移动文件或者文件夹到新的位置
     * 
     * @param oldPath
     * @param newPath
     */
    public boolean moveFileOrDir(String oldPath, String newPath) {
        try {
            LOGGER.debug("把文件或者文件夹从:{},移动到:{}", oldPath, newPath);
            getSftpV3Client().mv(oldPath, newPath);
            LOGGER.debug("把文件或者文件夹从:{},移动到:{}成功", oldPath, newPath);
            return true;
        } catch (Exception e) {
            LOGGER.error("移动文件失败:从何{}到:{}", oldPath, newPath);
            return false;
        }
    }

    /**
     * 获取sftpclient
     * 
     * @see :
     * @param :
     * @return : SFTPv3Client
     * @return
     */
    private SFTPv3Client getSftpV3Client() {
        try {
            if (null == this.sftPv3Client) {
                LOGGER.debug("初始化SFTPv3Client");
                this.sftPv3Client = new SFTPv3Client(getConnection());
                LOGGER.debug("初始化SFTPv3Client完成");
            }
            return this.sftPv3Client;
        } catch (IOException e) {
            LOGGER.error("获取SFTPv3Client失败,失败原因:{}", e.getMessage());
            throw new BusinessValidationException("获取SFTPv3Client失败");
        }
    }

    /**
     * 列举远程目录文件
     * 
     * @see :
     * @param :
     * @return : List<File>
     * @param remoteDir
     * @return
     */
    private void listRemoteDir(String remoteDir) {
        List<SFTPv3DirectoryEntry> children = null;
        try {
            children = getSftpV3Client().ls(remoteDir);
            if (children.isEmpty()) {
                return;
            }

            Iterator iterator = children.iterator();
            while (iterator.hasNext()) {
                SFTPv3DirectoryEntry thisChild = (SFTPv3DirectoryEntry) iterator
                        .next();

                SFTPv3FileAttributes attributes = thisChild.attributes;
                if (!".".equals(thisChild.filename)
                        && !"..".equals(thisChild.filename)) {
                    String childFolder = remoteDir + "/" + thisChild.filename;
                    if (attributes.isDirectory()) {
                        listRemoteDir(childFolder);
                    }
                    this.childrenList.add(thisChild);
                }
            }
        } catch (IOException e) {
            LOGGER.error("获取子孙文件或者文件夹失败:{}", e.getMessage());
            throw new BusinessValidationException("获取文件夹下的内容失败");
        }
    }

    /**
     * 列举远程目录文件
     * 
     * @see :
     * @param :
     * @return : List<File>
     * @param remoteDir
     * @return
     */
    private List<SFTPv3DirectoryEntry> getChildren(String remoteDir) {
        List<SFTPv3DirectoryEntry> children = null;
        List<SFTPv3DirectoryEntry> finalChildren = new ArrayList<>();
        try {
            children = getSftpV3Client().ls(remoteDir);
            Iterator iterator = children.iterator();
            while (iterator.hasNext()) {
                SFTPv3DirectoryEntry thisChild = (SFTPv3DirectoryEntry) iterator
                        .next();
                if (!".".equals(thisChild.filename)
                        && !"..".equals(thisChild.filename)) {
                    finalChildren.add(thisChild);
                }
            }
            return finalChildren;
        } catch (IOException e) {
            LOGGER.error("获取子孙文件或者文件夹失败:{}", e.getMessage());
            throw new BusinessValidationException("获取文件夹下的内容失败");
        }
        // finally {
        // close();
        // }
    }

    /**
     * 单例模式
     * 懒汉式
     * 线程安全
     * 
     * @return
     */
    public static GanymedSshClient getInstance() {
        if (null == instance) {
            synchronized (GanymedSshClient.class) {
                if (null == instance) {
                    instance = new GanymedSshClient();
                }
            }
        }
        return instance;
    }

    /**
     * 获取实例
     * 
     * @see :
     * @param :
     * @return : GanymedSshClient
     * @param ip
     * @param port
     * @param name
     * @param password
     * @return
     */
    public static GanymedSshClient getInstance(String ip, int port, String name,
            String password) {
        if (null == instance) {
            synchronized (GanymedSshClient.class) {
                if (null == instance) {
                    instance = new GanymedSshClient(ip, port, name, password);
                }
            }
        }
        return instance;
    }

    /**
     * 获取实例
     * 
     * @see :
     * @param :
     * @return : GanymedSshClient
     * @param ip
     * @param port
     * @param name
     * @param password
     * @return
     */
    public static GanymedSshClient getInstance(String ip, String name,
            String password) {
        if (null == instance) {
            synchronized (GanymedSshClient.class) {
                if (null == instance) {
                    instance = new GanymedSshClient(ip, name, password);
                }
            }
        }
        return instance;
    }

    /**
     * 获取实例
     * 
     * @see :
     * @param :
     * @return : GanymedSshClient
     * @param ip
     * @param port
     * @param name
     * @param password
     * @return
     */
    public static GanymedSshClient getInstance(String ip) {
        if (null == instance) {
            synchronized (GanymedSshClient.class) {
                if (null == instance) {
                    instance = new GanymedSshClient(ip);
                }
            }
        }
        return instance;
    }

    /**
     * 获取实例
     * 
     * @see :
     * @param :
     * @return : GanymedSshClient
     * @param ip
     * @param port
     * @param name
     * @param password
     * @return
     */
    public static GanymedSshClient getInstance(ServerInfo serverInfo) {
        if (null == instance) {
            synchronized (GanymedSshClient.class) {
                if (null == instance) {
                    instance = new GanymedSshClient(serverInfo);
                }
            }
        }
        return instance;
    }

    /**
     * 判断服务器是否可认证
     * 
     * @see :
     * @param :
     * @return : boolean
     * @return
     */
    public boolean serverCanBeAuthed() {
        try {
            initConnection();
            return true;
        } catch (Exception e) {
            LOGGER.error("认证授权失败,原因:{}", e.getMessage());
            return false;
        }
    }

    /**
     * 判断服务器是否可连通
     * 
     * @see :
     * @param :
     * @return : boolean
     * @return
     */
    public boolean serverIpCanBeConnected() {
        try {
            return InetAddress.getByName(this.serverInfo.getHost())
                    .isReachable(3000);
        } catch (UnknownHostException e) {
            LOGGER.error("测试连接服务器失败,失败原因:{}", e.getMessage());
            return false;
        } catch (IOException e) {
            LOGGER.error("连接服务器IO异常,失败原因:{}", e.getMessage());
            return false;
        }

    }

    public static void main(String[] args) {
        GanymedSshClient ganymedSshClient = new GanymedSshClient(
                "192.168.110.31", "admin", "admin");
        String destFolder = "E:\\test3\\tq-datamanagement";
        ganymedSshClient.downloadFolder(
                "/home/admin/codecoverge/tq-datamanagement", destFolder);
    }
}

感谢!确实同事也建议使用 scp 的方式,但我觉得用 docker 自动化部署的应用,在文件路径的查找与拷贝会不会在实现自动化存在问题而导致不稳定,所以想问下楼主有没有其他方法,没想到楼主把代码都贴出来了。非常感谢,我先按照这个思路试试看。

谢谢楼主指导 我想拿到一个调用链路,但是还没方向,能在指导一下吗 谢谢了

北部晴天 回复

想怎么指导呢~~~

就是没有思路拿到字节码文件怎么解析 TT

楼主请教一下,公司测试环境有 jar 包,不会有源文件放到服务器上的,java -jar 方式启动这种方式,怎么能得到测试报告呢

覆盖率平台刚搭建起来,目前两个比较棘手的问题,不知楼主有无遇到过

覆盖率平台刚搭建起来,目前两个比较棘手的问题,不知楼主有无遇到过
1.差异覆盖率。还没走到 jacoco 源码,暂时无解;目前用了 github 上的 diff-cover ,但是报告不行
2.服务 docker 化后,脚本注入、服务重启、dump 以及 exec 文件如何 copy 出来,一堆问题

文若 回复

你好像没有理清集成测试覆盖率的流程。
测试环境启动的时候,注入 jacoco,只是提供了一个 jacoco 的功能,同时在测试服务器上开启 tcp 服务存放当前应用的执行覆盖数据。
当然,这个 tcp 服务成功开启之后,是允许远程调用的,就是你可以在其他的服务器上甚至在你本机发起 tcp 连接,去获取它存放的覆盖数据。这一点 jacoco 提供的有专门的的 api。

你可以把这个过程理解为,测试环境集成 jacoco 功能且开启了一个 tcp 服务之后,你后续的覆盖率统计,已经基本和这个测试环境没什么关系了。

剩余的过程,就是你自己生成报告的方法了,总之生成报告只需要三个东西,上面有提到,至于你的源码存放在哪里并不重要,你只要保证你的测试环境启动的 jar 包的编译源码跟你生成报告时候用的源码是一致的就可以了。

不知道我有没有描述清楚。

北部晴天 回复

搜一下 ASM,了解一下他的一些机制,再看看有没有头绪

妖妖 回复

看你说的一堆问题,那就一个一个解决么,问题这种东西,解决一个少一个,干掉一个轻松一下。
回答你的第一个问题:总结为代码比对的问题。
差异化的覆盖,大致可以在两个阶段做处理,我提一些我的思路,看能不能对你有一点帮助。

  • 在 jacoco 自己的生成报告之前。

你去过滤 class 的信息,选择性生成报告,这样你可以只要那些过滤出来的变更代码的数据,原来的数据可以丢掉。当然需要你对 jacoco 自己的机制要清楚。

  • 在 jacoco 自己生成的报告之后。

你去针对它的报告,结合自己的代码比对结果对报告做定制。当然,这个时候你可能需要对它的百分比单独处理, 小心数据不准确。

回答你的第二个问题:关于容器的问题,我不是很精通。
但我猜测是否可以把你对应的那个 tcp 服务的端口也映射出去给宿主,dump 的时候请求直接发给主机。
至于怎么拿出来,那应该有不少方式把。

test小生 回复

兄弟,咋我的还是不行啊?只改一下路径就可以吗?

明白一些,源码和 class 文件服务器没有的话可以在本地生成,保证与 jar 包的编译源码一致就行,现在不清楚执行的 exec 文件在服务器哪里可以找到

仅楼主可见
文若 回复

换一下 jackson 的版本试试

楼主很赞👍 👍 👍

我用命令启动成功了 但是报告在哪里

仅楼主可见
仅楼主可见

你好像没有弄清楚它的工作原理吧,你这个命令只是起一个 java 服务的,与以前不一样的是,你起这个 java 服务的时候,注入了 jacoco 的代理,让你有对这个 java 服务统计代码执行状况的能力,后续的测试过程和生成报告是独立的哦

仅楼主可见
仅楼主可见

好的,祝你好运

楼主,麻烦请教下;我们最近在做单测覆盖率的收集,之前用的 jenkins jacoco 插件做的;现在 jenkins 不让用了;介绍一下前提,我们的工程是一个父子工程,分为 service(逻辑层),web,thrift;web 和 thrift 通过 jar 包方式引用 service。开发会在 web 和 thrift 中增加单测,然后我们的 pom 使用了 maven 的 jacoco 插件,有 pre-agent 和 report2 个 goal。我发现执行 mvn clean test 后,会在 web 和 thrfit 每个模块中增加一个 jacoco.exec,然后生成一个 html 报告。想问下楼主,这种情况下,service 里的代码能收集到嘛? 我们想做基于工程维度 (多模块) 的覆盖率报告生成,我看网上都是使用 aggregate 的 goal

Kyle 回复

可以指定 sourcefile 的位置

请问下楼主,改造的 jacoco 代码怎么调试啊?我用 java -javaagent:xxx -jar xxx.jar 方式运行的。想用 remote 远程调试,能监听到虚拟机,但是没在断点上停下来。ps:我之前用 javaassist 也写过一个 agent,是可以用这种方式调试的。

去翻了 jacoco+asm;楼主,上面讲到的调用链,没弄清楚调用链怎么生成出来,可能理解不够,或者方向不对;方便指点下关键代码或思路么?
我大致考虑是不是可以直接用 jacoco,在 jacoco 插桩位置标记序号,最终按需要列出代码,从代码中刷选出方法。。。

此外,如果想区分不同人员的执行记录,楼主有什么思路么?个人工作台拦截请求,并给请求加上唯一标签?但我没有思路怎么在 Java 服务中传递;

jy503160 回复

合并和调用链问题我最近在解决,可以留个联系方式一起讨论下

87楼 已删除

大家的源码 diff 都是怎么做的,谁能详细介绍下嘛?

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