Java 覆蓋率 Jacoco 插樁的不同形式總結和踩坑記錄

風雨夜闌聽發表於2019-09-23

關於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的比對,所以這也就有了評論區的那個方法誤判變更的來由。
不過這屬於歷史遺留問題,並不能算大事,想辦法規避即可。

相關文章