前言
先來說我為什么要做增量掃描這個事情,畢竟代碼掃描已經老生常談了,業界方案一搜一大堆,有什么好講的,大部人看到這篇文章的時候肯定這么想吧,但是注意今天我要分享的不是全量掃描,我分享的是從無到有實現增量掃描的過程,有的時候實現一個方案從來不是重點,我們對于方案的認知程度才是我們自己最重要的收獲 ̄▽ ̄ 。
再來說說怎么樣的代碼掃描才算是高效的,我是這么理解的:
不能增量檢查的代碼掃描都是耍流氓,以前的代碼一大堆問題,誰有耐心全部去解決
不能自動化的代碼掃描都是欺騙我們感情,不是每個人都有良好的意識每次都去檢查的
不能撤銷提交的代碼掃描都是自己騙自己,檢查出來問題不改,這樣的代碼掃描要來何用
不能持續集成的代碼掃描都是不專業的,問題要快上線了才發現,這樣的代碼掃描風險多高
開發缺的從來就不是工具,我們缺的是無縫嵌入的自動化流程、自我Code Review的意識,意識比工具重要。
這里扯了一些大道理,大家諒解,口號喊得響,大家才有興趣看嘛。后面全是干貨,大家放心,嘿嘿。
方案介紹
OkLint作為一個Gradle插件,使用起來超簡單,他能在你提交時發現增量問題,撤銷提交并給你發郵件。
在根目錄下的build.gradle寫
allprojects {
apply plugin: 'oklint'
}
方案思考
在講具體實現之前,先來講講我對于高效的代碼掃描是怎么想的。
高效的代碼掃描我覺得有五個方案:
方案一是Android Studio自帶的錯誤提示功能,他有個好處就是實時發現問題,缺點就是有些問題隱藏在花花綠綠的代碼里,你要指定你想檢查的問題為error才能暴露出來,這樣就需要在每臺電腦上都改動一下,太麻煩了。
-
方案二是Android Studio的增量代碼掃描功能,缺點就是不能自動化,不能在團隊內很好落實,不利于統計問題和持續集成。
image.png 方案三是用Sonar持續集成,但是他有個問題是不能增量,我們團隊用過,最后因為以前問題太多根本推行不起來,相信好多團隊都是這樣吧。
方案四是用Android Gradle插件2.3.0以后提供的新功能 baseline,他也是全量掃描,但是他能增量顯示問題,這個方案后期和Sonar持續集成,可以作為Plan B。
方案五是我現在用的方案,增量代碼掃描和git hooks搭配使用,味道更好。剛開始的思路是在git commit之前掃描增量代碼,結果發現lint掃描比較慢(我嘗試改了,改了以后確實快了但是有些問題就掃描不到了,畢竟掃描代碼還是需要整個項目的代碼才能更好的找到問題)。后面我聽取了同事峰哥的意見,采用另外一個思路,偷偷在git commit之后去掃描。有些人要問了為什么不在gitlab上的webhook里面執行,嗯你很機智,這樣實現也有很大的優點,但是我更想及時檢查每一次改動,越早發現越好解決問題。
個人覺得上面五個方案,方案四和方案五雙管齊下效果更好。方案五負責及時檢查每一次改動,方案四負責發現全量代碼潛在的問題。
方案對比
方案做出來了,要是不對比一下,就沒辦法愉快地吹NB了。
功能 | OkLint | Lint命令行 | Android Gradle 插件 | Android Studio |
---|---|---|---|---|
增量 | 可以 | 不行 | 不行,2.3.0后支持全量掃描增量顯示 | 可以 |
自動化 | 可以 | 不行 | 不行 | 不行 |
持續集成 | 可以 | 不行 | 不行 | 不行 |
代碼回滾 | 可以 | 不行 | 不行 | 可以 |
自動發送企業微信和郵件 | 可以 | 不行 | 不行 | 不行 |
只掃描優先級高的問題 | 可以 | 配置麻煩 | 配置麻煩 | 配置麻煩 |
Android有自己的Code Lint
,但是他只能全量掃描,而且沒法只掃描優先級高的。固然Android Studio可以在提交前面執行code analysis,但是作為一個團隊你很難落實讓每個人每次提交代碼都去執行,就算執行了你也不能保證他一定去改正這個問題,就算他改了這個問題,你也不能保證多個分支合并的代碼沒有問題,所以一個能自動在git commit時掃描增量代碼的工具還是很有必要的。
方案實現
思路其實很簡單的,流程很簡單
gradle插件copy git hooks------> git hooks自動執行增量掃描的任務------> git diff找到增量代碼------> lint-api.jar調用project.addfile() 掃描增量代碼------>javamail發送問題郵件------>git reset回滾代碼
好了現在你已經得到我的大乘佛法了,你可以屁顛屁顛地回大唐娶妻生子走向人生巔峰了,我保證我不阻止你。
找到增量代碼
這個命令感謝我的另一個同事馬老板,他坐為旁邊,我每次急躁的時候他都耐心幫我找答案。
private List<String> getPostCommitChange() {
ArrayList<String> filterList = new ArrayList<String>()
try {
String projectDir = getProject().getProjectDir()
String commond = "git diff --name-only --diff-filter=ACMRTUXB HEAD~1 HEAD~0 $projectDir"
String changeInfo = commond.execute(null, project.getRootDir()).text.trim()
if (changeInfo == null || changeInfo.empty) {
return filterList
}
String[] lines = changeInfo.split("\\n")
return lines.toList()
} catch (Exception e) {
return filterList
}
}
用git diff命令找到剛提交的commit都改動了哪些文件,我講一下他的每個參數的意思
- git diff 比較兩個commit
- HEAD~1是前一個commit,HEAD~0是當前的commit,有個注意點HEAD~1 HEAD~0 的先后順序,剛開始寫反了,增加的文件變成了刪除的文件
- diff-filter是篩選文件類型,沒寫D用來去除刪除的文件
- name-only用來只列出文件名
- projectDir一定要寫,不然git不知道要找哪個項目,而且注意我這里寫的是當前module dir,確保每個module只檢查自己的改動,用來加快掃描速度和防止掃描出來重復的問題。
這里著重說一下在gralde里寫命令的一個注意點,要執行帶有單引號的命令會執行為空的問題
譬如
git status -s | grep -v '^D'//列出當前要提交的commit變動了哪些文件并排除刪除的文件
你以為"git status -s | grep -v '^D'".execute
就行了嗎,太天真了,執行結果為空,剛開始我以為只要加上轉義符就行,結果還是不行。后面反復實驗發現要這么寫
["/bin/bash", "-c", "git status -s | grep -v '^D'"].execute()
增量代碼掃描具體實現
原理比較長,怕大家看的似懂非懂,我先給結果,這樣比較好。看到一些不明白的名詞可以先忽略掉,后面原理里面會提,我盡量講的淺顯易懂。
我寫了一個增量掃描的task,然后寫了一個LintClinet
,這個LintClient
會掃描代碼,它繼承android gradle的LintGradleClient
,task會調用這個client的run方法,run方法就是掃描方法。
而增量掃描的關鍵性代碼是修改LintGradleClient
的createLintRequest
方法,往project加入要掃描的文件
@Override
protected LintRequest createLintRequest(@NonNull List<File> files) {
//注意這個project是com.android.tools.lint.detector.api.project
LintRequest lintRequest = super.createLintRequest(files);
for (Project project : lintRequest.getProjects()) {
project.addFile(changefile);//加入要掃描的文件
addChangeFiles(project);
}
return lintRequest;
}
有個注意點我要提一下
LintGradleClient
構造函數需要參數,除了variant可以為空,其他都不能為空。因為不在android gradle插件內部,所以有些參數獲取需要動一些腦筋。
LintGradleClient(
IssueRegistry registry,//掃描規則
LintCliFlags flags,
org.gradle.api.Project gradleProject,//gradle 項目
AndroidProject modelProject,// android項目
File sdkHome,// android sdk目錄
Variant variant,//編譯的Variant
BuildToolInfo buildToolInfo) {//編譯工具包
篇幅有限,參數講太多反而把大家搞糊涂,我就講一個參數,如何獲取AndroidProject
private AndroidProject getAndroidProject() {
GradleConnector gradleConn = GradleConnector.newConnector()
gradleConn.forProjectDirectory(getProject().getProjectDir())
AndroidProject modelProject = gradleConn.connect().getModel(AndroidProject.class)
return modelProject
}
增量代碼掃描原理分析
剛開始想的很簡單呀,命令行 Lint不是也能掃描代碼嗎,那里面肯定有指定掃描文件和目錄的參數吧,別說還真有, --sources <dir>
,結果一試,發現是有結果,但是掃描出來的問題根本不是那個文件的問題呀,然后我同事說在他電腦卻提示不能掃描gradle項目,一下子就蒙蔽了,無從下手的感覺,剛開始我以為命令沒用對,但是改來改去都不對,后面我嘗試去除里面的gradle project判斷限制,然后指定掃描文件,還是掃描不出該有的問題,我就先暫停這個方案的研究。
既然上面這條路走不通,我就去找android studio的源碼看他是怎么實現增量掃描的,結果在Android Studio源碼里面,搜索lint根本沒有找到任何相關的代碼,后面發現其實是在另外的Plugin源碼里。不過他依賴于Intellij Module
,Module會找到每個類,那我又沒有Module這個上下文,這么說這個方案還是走不通。
那就再換一個思路,Android Gradle插件不是也可以實現Lint掃描,那我改一改不就可以增量掃描,結果一拿到他的代碼就感覺無從下手,改來改去都不對呀,不知道哪一行代碼可以實現增量掃描,就算后面完成了增量掃碼,掃描也很慢。
帶著上面的幾個坑,我研究了Lint內部的實現原理找到了增量代碼掃描的實現方法
- 為什么命令行Lint 掃描不出增量代碼的問題
- android studio是怎么實現lint增量掃描的
我先講一下關于Lint的預備知識,然后再來講上面幾個問題,方便大家更好理解
Lint掃描內部原理
其實無論是Lint命令行、android gradle插件、android studio都依賴了兩個jar
- lint-api.jar:lint-api是代碼掃描的具體實現
- lint-check.jar:lint-check是默認的掃描規則
lint-api.jar內部實現原理:
LintDriver調用analyze()分析LintRequest中的文件------>checkProject----->runFileDetectors----->check對應文件的Visitor,譬如JavaPsiVisitor分析java文件,AsmVisitor分析class文件等
下面講講三種方式分別怎么實現的
Lint命令行:
lint.sh------>lint.jar------>LintCliClient 的run(IssueRegistry registry, List<File> files)------>LintDriver analyze分析 project
Lint Gradle Task:
Lint.groovy------>LintGradleClient的run(IssueRegistry registry)------>LintDriver analyze分析 LintGradleProject
Android Studio:
AndroidLintGlobalInspectionContext------> performPreRunActivities-----> LintDriver analyze分析IntellijLintProject
明白了原理,我們回到上面兩個問題
-
為什么命令行Lint 掃描不出增量代碼的問題
我舉個例子:
譬如有個TestActivity里面寫了靜態的activity變量,LeakDetector會去檢查這個情況,但是直接lint --sources app/src/com/demo/TestActivity.java .
你會發現掃描不出這個錯誤或者提示'app' is a Gradle project. To correctly analyze Gradle projects, you should run "gradlew :lint" instead. [LintError]
,其實這兩個問題都是同一個原因。
LeakDetector會去判斷靜態變量是不是Activity類,但是變量的PsiField卻是com.demo.TestActivity
不是'android'開頭,這樣就掃描不出問題了。
@Override
public void visitField(PsiField field) {
String fqn= field.getType().getCanonicalText();
if (fqn.startsWith("android.")) {//fqn變量是com.demo.TestActivity
if (isLeakCandidate(cls, mContext.getEvaluator())
&& !isAppContextName(cls, field)) {
String message = "Do not place Android context classes in static fields; "
+ "this is a memory leak (and also breaks Instant Run)";
report(field, modifierList, message);
}
}
}
那為什么fqn不是android.app.activity
呢,因為lint命令行會把lib目錄下面jar的class加入掃描形成抽象語法樹,但是gradle項目是compile jar的,不在lib目錄下面,這就是為什么高版本的lint里面提示不能掃描gradle項目。這也側面說明了命令行lint走不通
-
android studio是怎么實現lint增量掃描的
android studio內部會掃描IntellijLintProject
中的文件,IntellijLintProject
是由
create(IntellijLintClient client, List<VirtualFile> files,Module... modules)
生成的,那就只要找到文件加入project的代碼就能找到增量代碼掃描的方案了。
if (project != null) {
project.setDirectLibraries(Collections.<Project>emptyList());
if (file != null) {
project.addFile(VfsUtilCore.virtualToIoFile(file));
}
}
那為什么addfile以后LintDriver
會增量掃描呢,拿java文件掃描舉個例子,LintDriver
會判斷subset是不是為空,不為空就不掃描JavaSourceFolders
,只掃描增量文件。
List<File> files = project.getSubset();
if (files != null) {//判斷是不是要增量掃描
checkIndividualJavaFiles(project, main, checks, files);
} else {
List<File> sourceFolders = project.getJavaSourceFolders();
List<File> testFolders = scope.contains(Scope.TEST_SOURCES)
? project.getTestSourceFolders() : Collections.emptyList();
checkJava(project, main, sourceFolders, testFolders, checks);
}
只掃描優先級高的問題
雖然Lint支持配置lint.xml去忽略Issue,但是只能一個個忽略,我的方案是設置優先級低的規則為Severity.IGNORE,LintDirver會忽略Severity.IGNORE的規則
@Override
public Severity getSeverity(Issue issue) {
Severity severity = super.getSeverity(issue);
if (onlyHighPriority) {
if (issue.getCategory().compareTo(Category.USABILITY) < 0 && issue.getPriority() > 4) {//只掃描優先級比較高的規則
return severity;
}
return Severity.IGNORE;
}
return severity;
}
自動執行代碼掃描
Git Hooks提供了post-commit實現commit之后自動執行任務,但是你會發現在post-commit里寫 ./gradlew Lint,還是要等lint任務執行完了才commit成功。我發現只要在shell腳本里加入&>/dev/null就可以后臺執行了。
nohup ./gradlew LintIncrement &>/dev/null &
自動同步Git Hooks
如果Git Hooks腳本需要每臺電腦自己去復制,這明顯不利于團隊合作,而且不方便后面更新腳本,我選擇用Gradle命令復制到指定目錄,但是這里有個問題,gradle插件能帶資源文件嗎,如果沒有專門學過gradle說不定一時無從下手,還好我剛好以前看過fastdex里面是怎么解決的,通過getResourceAsStream可以復制Gradle插件resources下面的文件
public static void copyResourceFile(String name, File dest) throws IOException {
FileOutputStream os = null;
File parent = dest.getParentFile();
if (parent != null && (!parent.exists())) {
parent.mkdirs();
}
InputStream is = null;
try {
is = FileUtils.class.getResourceAsStream("/" + name);
os = new FileOutputStream(dest, false);
byte[] buffer = new byte[BUFFER_SIZE];
int length;
while ((length = is.read(buffer)) > 0) {
os.write(buffer, 0, length);
}
} finally {
if (is != null) {
is.close();
}
if (os != null) {
os.close();
}
}
}
復制腳本installGitHooks是這樣實現的,finalizedBy保證它在build任務后面自動執行,它會把/resource/post-commit文件復制到工程.git/hooks/post-commit。chmod -R +x .git/hooks/一定要寫,不然沒有權限
private void createGitHooksTask(Project project) {
def preBuild = project.tasks.findByName("preBuild")
if (preBuild == null) {
throw new GradleException("lint need depend on preBuild and clean task")
return
}
def installGitHooks = project.getTasks().create("installGitHooks")
.doLast {
File postCommitFile = new File(project.rootProject.rootDir, PATH_POST_COMMIT)
if (lintIncrementExtension.isCheckPostCommit()) {
FileUtils.copyResourceFile("post-commit", postCommitFile)
} else {
if (preCommitDestFile.exists()) {
preCommitDestFile.delete()
}
}
Runtime.getRuntime().exec("chmod -R +x .git/hooks/")
}
preBuild.finalizedBy installGitHooks
}
Gradle插件實現發送郵件
原來打算直接用shell腳本里面的sendmail去發送郵件的,但是聽同事說如果mac上沒有登錄郵箱是沒法發送成功的,我就用了javamail,網上的方案大多數是在java里面實現javamail,在gradle里面發送郵件的方案比較少,我嘗試了多次才解決。
首先在gradle插件的build.gradle里面加入javamail
的依賴,剛開始我是直接compile了,但是運行以后提示我沒找到javamail的類,原來是要ant能找到javamail的類才行
configurations {
antClasspath
}
dependencies {
antClasspath 'ant:ant-javamail:1.+'
antClasspath 'javax.activation:activation:1.1.1'
antClasspath 'javax.mail:mail:1.+'
}
ClassLoader antClassLoader = org.apache.tools.ant.Project.class.classLoader
configurations.antClasspath.each { File jar ->
antClassLoader.addURL( jar.toURI().toURL() )
}
然后在gralde里面執行發送任務
void send(File file) {
getProject().ant.mail(
from: fromMail,// 發件方
tolist: toList,//收件方
ccList: ccList,//抄送方
message: message,//消息內容
subject: subject,//標題
mailhost: mailhost,//SMTP轉發服務器
messagemimetype: "text/html",//消息格式
files: file.getAbsolutePath()//發送文件目錄
)
}
這里有幾個注意點
-
mailhost
填入不需要SSL 認證的smtp服務器,不然你就需要輸入賬號和密碼才能發送郵件 - message里面換行,不能用
\n
,因為messagemimetype
是html格式,要使用<br>
發現問題回滾代碼
if (lintClient.haveErrors() ) {
"git reset HEAD~1".execute(null, project.getRootDir())
}
如何調試gradle 插件
我原來看了幾篇Lint原理分析就打算去實現增量掃描,然后發現看和做還是不一樣的,中間遇到好多問題,還好gradle插件可以調試。
第一步 點擊edit configurations
第二步 創建remote,默認選項就可以
第三步 在你要運行的gradle任務里面加入
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005
第四步,先點擊運行你要運行的gradle任務,gradle會等待你點擊remote,然后就可以調試了
Lint版本變動
發現android gradle最新的幾個版本對于lint做了一些優化,我順便提一下。
- 2.3.0以后運行
./gradlew lint
會更快,Google實現了LintCharSequence
來完成數據的存儲和傳參,實現了內存中只有一份拷貝 - 2.3.0以后
lint-report.html
是material design,更好看、更方便查問題 - 2.3.0以后支持baseline增量顯示bug
- 3.0.0以后自定義lint規則就不用像原來美團的方法一樣麻煩了,官方支持
- 掃描會更快,uast語法樹替換了現在的psi和lombok語法樹
尾聲
回過頭來看,其實增量掃描也很簡單,就一行關鍵性代碼project.addfile(file)
。
最后講一下大家關心的開源問題吧,那要等在公司內部穩定運行以后在公司Github地址開源,畢竟我們是一款嚴肅的產品嘛。