六、注意事项汇总
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 文件不一致,这会导致覆盖率数据不准确)。
3、Dump 覆盖率执行数据。
4、根据 dump 出来的执行数据 exec 文件,以及刚才对最新代码的编译出来的字节码 class 文件和 src 中的源代码进行报告生成。
以上五个步骤,对获取覆盖率数据缺一不可,不然无法出增量覆盖率数据。
那么上述的步骤,其实可以都进行自动化配置。
部署
如果有 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<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 上获取。
代码示例 GitHub 地址:
https://github.com/yelanting/ManagerPlatformAdministrator.git
备注:
这里关于 Jacoco 的一部分代码直接引用了 AngryTester 项目的代码,https://testerhome.com/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 的比对,所以这也就有了评论区的那个方法误判变更的来由。
不过这属于历史遗留问题,并不能算大事,想办法规避即可。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理