让 CheckStyle 支持增量检查的一次落地经验总结

背景

大家都知道静态代码检查工具有很多,譬如阿里的 p3c、sonar 挂钩的一堆插件等。但是这些东西对于一个已存在的项目不够友好,因为旧代码一扫描会出现一堆问题,修复带来的成本又很高,所以这些工具都比较适合新项目或者初期介入,对于老项目就显得很蛋疼了。

因此有必要做到增量检查;一种就是针对版本控制的 changed 进行增量,这种情况会涉及到老文件修改一处全部问责的问题;另一种是针对新增文件进行增量,这种情况保证了从此刻开始新文件的约束。

由于单纯靠大家的自觉性是很难保证规范化的,即便 review 代码也总是会有疏忽,所以静态代码检查就有必要做到方便开发的同时也方便测试验收,故而需要二次自动化把关,所以经过调研最终选择了 checkstyle 进行定制。

经过定制后可以做到本地 IDE 开发实时新增文件规范提示,CI 构建机通过 gradle task 生成报告;一次主动规范化和一次强制报告检测就做到了严格执行,直到修复完才允许进入测试流程。

checkstyle

关于它是什么?怎么用?能干啥?这里不再详细介绍,重点关注其源码及配置可以在哪里获取。

通过分析上面主要流程和文档,我们进行了简单二次定制来适应新增加文件的规范检查,主要新增了 gradle 新增文件检查和 IDEA 插件新增文件检查,具体如下文。

gradle 脚本增量定制

gradle 自身是内置了 checkstyle 插件的,我们要做的就是新增文件增量检查,具体核心 task 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
......
apply plugin: 'checkstyle'

def checkStyleFile = rootProject.file("checks.xml")
def reportHtmlFile = rootProject.file(project.buildDir.path + "/reports/checkstyle.html")
def reportXmlFile = rootProject.file(project.buildDir.path + "/reports/checkstyle.xml")

checkstyle {
toolVersion "8.12"
configFile checkStyleFile
showViolations = true
ignoreFailures = true
}

tasks.withType(Checkstyle) {
classpath = files()
source "${project.rootDir}"

exclude '**/gen/**'
exclude '**/build/**'
exclude '**/androidTest/**'
exclude "**/test/**"

reports {
html {
enabled true
destination reportHtmlFile
}
xml {
enabled true
destination reportXmlFile
}
}
}

task checkstyleAll(type: Checkstyle) {
}

task checkstyleVcsChanged(type: Checkstyle) {
def changedFiles = getFilterFiles('dr')
if (changedFiles.isEmpty()) {
enabled = false
println("git status no changed, checkstyleChanged disable!")
}
include changedFiles
}

task checkstyleVcsNew(type: Checkstyle) {
def changedFiles = getFilterFiles('A')
if (changedFiles.isEmpty()) {
enabled = false
println("git status no new file to add, checkstyleChanged disable!")
}
include changedFiles
}

def getFilterFiles(diffFilter) {
def files = getChangedFiles(diffFilter)
if (files.isEmpty()) {
return files
}

List<String> filterFiles = new ArrayList<>()
for (file in files) {
if (file.endsWith(".java") || file.endsWith(".xml")) {
filterFiles.add(file)
}
}

println("\nDiff need checkstyle filter "+diffFilter+"->"+filterFiles)
return filterFiles
}

def getChangedFiles(diffFilter) {
def ghprbTargetBranch = System.getenv("ghprbTargetBranch")
def ghprbSourceBranch = System.getenv("ghprbSourceBranch")
def targetBranch
if (project.hasProperty("ghprbTargetBranch")) {
targetBranch = "origin/${project.property("ghprbTargetBranch")}"
} else {
targetBranch = ghprbTargetBranch ? "origin/${ghprbTargetBranch}" : "" /*getParentBranch() 可以主动获取父分支,或者指定*/
}
def sourceBranch = ghprbSourceBranch ? "origin/${ghprbSourceBranch}" : ""
println("Target branch: $targetBranch")
println("Source branch: $sourceBranch")

List<String> files = new ArrayList<>()
if (targetBranch.isEmpty()) {
return files
}

def systemOutStream = new ByteArrayOutputStream()
def command = "git diff --name-status --diff-filter=$diffFilter $targetBranch $sourceBranch"
command.execute().waitForProcessOutput(systemOutStream, System.err)
def allFiles = systemOutStream.toString().trim().split('\n')
systemOutStream.close()

Pattern statusPattern = Pattern.compile("(\\w)\\t+(.+)")
for (file in allFiles) {
Matcher matcher = statusPattern.matcher(file)
if (matcher.find()) {
files.add(matcher.group(2))
}
}
println("\nDiff filter "+diffFilter+"->"+files)
files
}
......

有了上面的 task 我们就可以通过命令行生成检查报告了。在 Android 开发中,我们可以直接将这个 task 自动介入 build 流程,具体如下:

1
2
3
4
5
......
project.afterEvaluate {
preBuild.dependsOn 'checkstyleVcsNew' //增量任务名
}
......

由此就可以在构建机上自动进行报告产出,以便测试进行督促修复。

IDEA 插件增量定制

幸运的是有人针对 checkstyle 开发了 IDEA 插件,而 Android Studio 是基于 IDEA 定制的,所以我们对这个插件进行了二次开发定制,以便支持新增文件增量检查,具体定制代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
* 老项目中已存在大量代码已经无法一次性修复所有格式检查,故而对所有新增 java 文件进行严格格式检查。
* 仅支持 VCS 版本控制项目。
* 本文件实现参照 checkstyle-IDEA 插件源码的 CheckStyleInspection.java 类
*/
public class CheckStyleVcsNewInspection extends LocalInspectionTool {

private static final Logger LOG = Logger.getInstance(CheckStyleVcsNewInspection.class);
private static final List<Problem> NO_PROBLEMS_FOUND = Collections.emptyList();

private final CheckStyleInspectionPanel configPanel = new CheckStyleInspectionPanel();

private CheckStylePlugin plugin(final Project project) {
final CheckStylePlugin checkStylePlugin = project.getComponent(CheckStylePlugin.class);
if (checkStylePlugin == null) {
throw new IllegalStateException("Couldn't get checkstyle plugin");
}
return checkStylePlugin;
}

@Nullable
public JComponent createOptionsPanel() {
return configPanel;
}

@Override
public ProblemDescriptor[] checkFile(@NotNull final PsiFile psiFile,
@NotNull final InspectionManager manager,
final boolean isOnTheFly) {
if (isVcsNewFile(psiFile)) {
final Module module = moduleOf(psiFile);
return asProblemDescriptors(asyncResultOf(() -> inspectFile(psiFile, module, manager), NO_PROBLEMS_FOUND), manager);
}
return null;
}

//重点,自己需要别的增量形式时自己修改这里吧!!!
private boolean isVcsNewFile(PsiFile psiFile) {
if (psiFile == null) {
return false;
}

final FileStatus fileStatus = FileStatusManager.getInstance(psiFile.getProject()).getStatus(psiFile.getVirtualFile());
boolean ret = fileStatus == FileStatus.UNKNOWN || fileStatus == FileStatus.ADDED;
LOG.debug(" VCS file "+psiFile.getName()+" status: "+ret+", fileStatus="+fileStatus.getText());
return ret;
}

@Nullable
private Module moduleOf(@NotNull final PsiFile psiFile) {
return ModuleUtil.findModuleForPsiElement(psiFile);
}

@Nullable
public List<Problem> inspectFile(@NotNull final PsiFile psiFile,
@Nullable final Module module,
@NotNull final InspectionManager manager) {
LOG.debug("Inspection has been invoked.");

final CheckStylePlugin plugin = plugin(manager.getProject());

ConfigurationLocation configurationLocation = null;
final List<ScannableFile> scannableFiles = new ArrayList<>();
try {
configurationLocation = plugin.getConfigurationLocation(module, null);
if (configurationLocation == null || configurationLocation.isBlacklisted()) {
return NO_PROBLEMS_FOUND;
}

scannableFiles.addAll(ScannableFile.createAndValidate(singletonList(psiFile), plugin, module));

return checkerFactory(psiFile.getProject())
.checker(module, configurationLocation)
.map(checker -> checker.scan(scannableFiles, plugin.configurationManager().getCurrent().isSuppressErrors()))
.map(results -> results.get(psiFile))
.map(this::dropIgnoredProblems)
.orElse(NO_PROBLEMS_FOUND);

} catch (ProcessCanceledException | AssertionError e) {
LOG.debug("Process cancelled when scanning: " + psiFile.getName());
return NO_PROBLEMS_FOUND;

} catch (CheckStylePluginParseException e) {
LOG.debug("Parse exception caught when scanning: " + psiFile.getName(), e);
return NO_PROBLEMS_FOUND;

} catch (Throwable e) {
handlePluginException(e, psiFile, plugin, configurationLocation, manager.getProject());
return NO_PROBLEMS_FOUND;

} finally {
scannableFiles.forEach(ScannableFile::deleteIfRequired);
}
}

private List<Problem> dropIgnoredProblems(final List<Problem> problems) {
return problems.stream()
.filter(problem -> problem.severityLevel() != SeverityLevel.Ignore)
.collect(toList());
}

private void handlePluginException(final Throwable e,
final @NotNull PsiFile psiFile,
final CheckStylePlugin plugin,
final ConfigurationLocation configurationLocation,
final @NotNull Project project) {
if (e.getCause() != null && e.getCause() instanceof FileNotFoundException) {
disableActiveConfiguration(plugin, project);

} else if (e.getCause() != null && e.getCause() instanceof IOException) {
showWarning(project, message("checkstyle.file-io-failed"));
blacklist(configurationLocation);

} else {
LOG.warn("CheckStyle threw an exception when scanning: " + psiFile.getName(), e);
showException(project, e);
blacklist(configurationLocation);
}
}

private void disableActiveConfiguration(final CheckStylePlugin plugin, final Project project) {
plugin.configurationManager().disableActiveConfiguration();
showWarning(project, message("checkstyle.configuration-disabled.file-not-found"));
}

private void blacklist(final ConfigurationLocation configurationLocation) {
if (configurationLocation != null) {
configurationLocation.blacklist();
}
}

@NotNull
private ProblemDescriptor[] asProblemDescriptors(final List<Problem> results, final InspectionManager manager) {
return ofNullable(results)
.map(problems -> problems.stream()
.map(problem -> problem.toProblemDescriptor(manager))
.toArray(ProblemDescriptor[]::new))
.orElseGet(() -> ProblemDescriptor.EMPTY_ARRAY);
}

private CheckerFactory checkerFactory(final Project project) {
return ServiceManager.getService(project, CheckerFactory.class);
}
}

接着在 plugin 配置文件新增加如下配置:

1
2
3
4
5
6
<localInspection implementationClass="org.infernus.idea.checkstyle.CheckStyleVcsNewInspection"
bundle="org.infernus.idea.checkstyle.CheckStyleBundle"
key="inspection.cvs.display-name"
groupKey="inspection.group"
level="WARNING"
enabledByDefault="true"/>

重新编译发布插件即可,想要再改别的也可以自行修改定制。关于 IDEA 插件如何开发,后面有机会再介绍吧。

接着安装插件,我们就可以做到增量新增文件的规范检查,如下设置:

checkstyle-config

然后我们就可以愉快的使用了,具体效果参见原 checkstyle idea plugin,只是多了增量操作。

总结

至此就完成了一套完整的增量式代码规范检查的流程,而规范规则可以自己继续依照 checkstyle 的 xml 进行编写,不过一定要注意 xml 中属性的版本兼容性问题,具体可以参考官方文档。

当然,你如果觉得仅对新增文件检查不合适,则可以对上面核心代码的版本 changed 部分进行自己的配置修改,譬如对 changed 的文件都进行检查,或者是指定白名单目录等,这些都完全取决于你自己的团队需求。

至此就落地了代码规范检查,其中踩坑问题主要是 git 基线分支名获取问题,参照了很多方式和官方文档都没有找到好的解决方案,找到的方案都只能在一般的分支结构工作,结构复杂后就乱套了。不知道哪位大佬有好的建议,欢迎指点一二。

工匠若水 wechat
关注码农每日一题,每天都有短小精干的收获.
您的收获就是对我最大的鼓励
0%