本文为霍格沃兹测试学院优秀学员关于 Jacoco 的小结和踩坑记录。
六、注意事项汇总
- 修改 JAVA_OPTS 参数时,如果位置不对,可能造成代理无法启动。
- java -jar 启动时,-javaagent 参数,不能错误,否则可能造成代理不生效。
- Export MAVEN_OPTS 参数时,后续的所有 mvn 命令,都会带上此参数,因此相当于每次执行 mvn 命令,都会尝试启动代理,因此可能会出现 address bind already in use 之类的异常抛出。因此,我们只有在 mvn tomcat7:run 启动服务器时才需要启动代理,其他如 mvn 的编译、install 命令都不需要,所以在启动之后,把 MAVEN_OPTS 参数置空,或者重启一个 terminal 来执行命令。
- 同一个 ip 地址上,部署多套服务器需要收集覆盖率时,端口自己规划好,不可重复。
- 测试执行信息的收集 (在应用的测试服务器)。
- 测试执行信息的获取、以及生成覆盖率报告(可在测试服务器上、也可在统一的服务器上)。
- 5 的收集在测试服务器上,6 的操作可以在测试服务器是,也可以是统一的服务器(我们选择后者)。
- 关闭应用服务时,务必不要强杀,请使用 kill -15 杀进程 (当然有时候,会出现 kill -15 杀不掉进程的时候,用 kIll -9 也无妨,这一点并不是很确定),否则,很有可能会造成覆盖率数据来不及保存而丢失。
七、说给想做平台的你
按照原来的流程,如果想做增量的覆盖率,那么有如下的步骤需要涉及,我们需要做的事情:
- 部署测试服务器(加入 Jacoco 的代理,按照上面的方式进行即可)。
- 需要知道上述部署时的版本代码,需要知道待比较的基线版本代码,并下载两个代码到某个路径下,并编译最新的代码 (至于需不需要编译,看你的需求,也可以用测试服务器上的,这样最准确。现编译的话,可能会编译机跟测试机的不同,造成生成的 class 文件不一致,这会导致覆盖率数据不准确)。
- Dump 覆盖率执行数据。
- 根据 dump 出来的执行数据 exec 文件,以及刚才对最新代码的编译出来的字节码 class 文件和 src 中的源代码进行报告生成。
- 导出覆盖率数据报告(一般是在 Linux 中执行,查看时需要到自己的 Windows 或者 Mac 上查看)。
以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。
那么上述的步骤,其实可以都进行自动化配置。
- 部署
如果有 devops 平台的话,可以集成进去,端口要规划好。
- 基线代码、和最新代码
可以用 jgit 和 svnkit 这两个工具进行代码下载和克隆。
- dump.
用 API 去 dump,可以屏蔽不同启动方式,只需要有 TCP 的 serverip 和端口即可。
- report
用 Jacoco 的 API 做。
那唯一的差别,就是对项目层级的判定,比如多模块、比如可能项目的目录并不规范 (有的 maven 项目并没有把所有的代码放到 src/main/java 下),这些需要自己对公司项目进行适配。我司就是因为项目结构差别太大,所以适配的过程花了一番功夫。
- 导出报告
提供下载,或者给出服务器存放的链接,都行,这个看个人实现就行了。
八、一些坑
- Ant 构建
build.xml 中,有特定的 compile 阶段,这个自己去找。请务必保证,有:
debug="true"
这个配置,不然 Jacoco 是无法注入的,有的时候 ant 项目生成的数据为 0,就可以去排查下这里。
比如我司配置了两个,一个 compileDebug, 一个 compile,在 compileDebug 阶段打开了 debug 的开关:
- 关于负载均衡
有时候可能一个服务会有负载均衡出现,那么可以配置不同端口,如果在不同服务器上,那么 IP 和端口都可以不同。
这时候,在 dump 数据的时候,只需要循环几个 ip:port(至于你想怎么传,那就是代码层面事情了)去 dump,保存到同一个文件中就行了。
- 做平台时-项目代码无法独立编译
这个看怎么解决了,如果非要自己编译,那就让开发适配到可以独立编译。
我这里是提供了 sftp 下载的方式,你告诉我你的代码在哪个服务器的那个路径,提供给我用户名密码,我用 Java 的方式去 sftp 下载到平台部署的机器上。这样可以解决现编译的不匹配问题,也可以解决无法独立编译的问题。
但是有几个遗留问题,你如何判定是不是要重新下载,你也会担心 sftp 下载下来的 class 和 java 代码跟测试机上的是否不一样。这个要看个人取舍,理论上 TCP 进行下载还是安全的。
- 如果注入 Jacoco 的配置之后,端口确实没有起来或者 dump 的时候,TCPserver 连接不上
可能原因有几种。
- TCP 端口确实没起来,这个在部署测试服务器的文档里有说明,部署后需要查看下是否真的起来。
- TCP 端口确实起来了,netstat 查看的时候也是显示正确。
这里还有两种可能。
- 确保 javaagent 参数中的 address 写的是真实 ip 地址,而不是 127.0.0.1 或者 localhost。
- 防火墙。防火墙开启的时候,阻碍了外部 ip 连接的进入,请关闭防火墙,或者配置防火墙策略。
举个栗子。
8:30 的时候,执行了测试,生成了一次报告。此时 8.30 之前的数据,肯定是存在的。
9:00 的时候,重新部署了,之前没有再次捞取执行信息,那重启之后,8.30-9.00 之间的执行记录可能很大概率丢失。所以,务必小心。
- 怎么确保报告准确,且尽量减少丢失?
及时保存,及时收集,可以采用定时任务的方式。
- 应用的突然重启和服务器的断电状况怎么处理?
天灾,没招。如果真的确实需要,可以在程序中加入定时收集,但是频率不一定好控制,而且当不再执行的时候,平白重复保存完全一模一样的执行信息,个人觉得意义不大,会对服务器磁盘造成巨大压力。具体解决方案还要看个人取舍。
- 造成覆盖率报告数据不准确的原因有哪些?
最最最最底层的原因 —— 部署时的 class 文件和生成报告的时候,用的 class 文件不一致。有以下几种情况:
- 测试服务器(就是你的应用所在的那个环境)中的 class 文件和我管理平台上编译环境不一致,导致产生的 class 文件跟部署时的 class 文件有差异。这个可以通过不手动编译,而是从测试服务器部署位置的目录来拷贝传输,来解决,但现阶段,没做。
- 测试服务器版本变更了,但是管理平台上的代码没变更(或者说新代码拉取下来了,但是没有重新编译。),导致 class 文件不一致。
- 管理平台上的新版本代码的版本号没有填写,默认每次拉取最新代码,这会导致生成报告的时候,源码变了,class 文件没变,覆盖率插桩收集的时候,用的还是老代码。所以,要想准确。需要保证,测试服务器部署时的代码版本和管理平台上写的版本号完全一致。
九、补充一些 API 相关的代码
覆盖率数据的获取
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 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 sourceFileFolders = getSourceFileFoldersUnderThis( topLevelSourceFileFolder); for (File eachSourceFileFolder : sourceFileFolders) { iSourceFileLocator .add(new DirectorySourceFileLocator(eachSourceFileFolder, GlobalDefination.CHAR_SET_DEFAULT, 4)); } return iSourceFileLocator; }
如果确实需要有些实现的源码,可以联系我或者从 github 上获取。
代码示例 GitHub 地址:
https://github.com/yelanting/ManagerPlatformAdministrator.git
备注:这里关于 Jacoco 的一部分代码直接引用了 AngryTester 项目的代码,
https://testerhome.com/AngryTester如果涉及到侵权请联系我,目前并未作商用;关于 server 部分的,则大部分是我自己练习的代码,可以随意拿去用,这个小工具只是为了给测试内部使用,其实并不具备完整项目的实力,所以代码和性能不一定很好,但我尽量按照阿里的规范来编写的代码,使其规范。
AngryTesterJacoco 的代码
-org.Jacoco.core.diff.DiffAST.java
这是代码比对源码,
public static List 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 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 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 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 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 methodsMap = new HashMap(); for (int i = 0; i
上面最后一个方法就是拿方法的详细信息来做 md5 的比对,所以这也就有了评论区的那个方法误判变更的来由。不过这属于历史遗留问题,并不能算大事,想办法规避即可。
十、总结
以上,本文是对上一篇文章 Java 端覆盖率探索的一个细化,文中总结的内容,得益于站在巨人的肩膀上,参考了以下资料和课程。这里推荐大家学习,也期待一起探讨。