天天看點

代碼自動生成在重構中的一次探索

導語:EventBus 已經火了很長一段時間了。最近我們項目決定引入EventBus,替換我們播放器現在的事件總線架構,以解決我們存在的一些問題。

騰訊視訊的播放器架構是基于總線設計的,不同的功能子產品被抽象成一個個插件管理器,挂載在總線上,收聽、釋出事件,完成業務邏輯處理。

代碼自動生成在重構中的一次探索

圖 1

上圖是播放器的總線示意圖,每個節點表示一個邏輯插件,紅色的線條代表總線。插件可以有子插件,父插件要負責将事件派發給它的子插件。

代碼自動生成在重構中的一次探索

圖 2

上面三個類圖中,Event是描述事件的類,不同的事件通過不同的id值來區分。IEventProxy即是播放器的總線,publish(Event event)方法負責将事件抛到總線上。Plugin即是插件的抽象類,當總線上有新事件到達時,插件的onEvent(Event event)方法會被調用,onEvent方法内部根具事件的id值辨識不同的事件,做相應的業務邏輯處理。擁有子插件的插件,還需要循環調用mChildPlugins的onEvent(Event event)方法,将事件傳遞給子插件處理。

下面是典型的插件onEvent方法代碼片段:

一個插件将事件釋出到總線上的代碼示例:

通過之前對播放器架構的介紹,我們可以發現,我們的事件機制還是比較簡陋。主要存在以下幾點缺陷:

1、 插件代碼結構不夠松散,所有事件響應處理都在onEvent方法中處理。

2、 事件過度廣播。當一個事件發生時,所有插件的onEvent方法都會被調用執行,浪費了cpu時間片,程式執行效率不高。

3、 事件類型不安全。每個事件隻能攜帶一個Object的對象message,事件收聽者如果要解析message,收聽者隻能靠“猜”,是否猜中取決于釋出該事件的人是否按照收聽者的意願攜帶指定類型的message。如果沒有通過instanceof校驗而直接強轉,極有可能發生強轉失敗。

4、 事件參數不可拓展。事件隻能攜帶一個Object的message。一旦某事件攜帶某種類型的message,該事件攜帶的message類型不能再變更,一旦變更,所有收聽該事件的插件也必須要修改代碼。

基于此,我們決定引入EventBus開源庫來重構我們的事件機制。

了解過EventBus的同學都知道,EventBus的核心是使用反射。不同的事件用不同的類型來表示,插件類要收聽某一事件,就要聲明一個相應的方法來接收事件。例如,已知有AEvent,BEvent,CEvent三種事件,有X、Y、Z三個插件,假設X插件收聽AEvent,Y插件收聽BEvent,Z插件收聽CEvent,則X、Y、Z三個插件類中需如下聲明:

當我們需要釋出某AEvent時,需要調用EventBus的post方法:

更多如何使用EventBus及EventBus原理的知識,這篇文章不作講解,您可以搜尋其它文章或者在GitHub上了解。

通過以上分析,我們這次重構的主要工作内容就明确了:

1、 将Event類中所有預定義的事件全部映射成具體的類,即有多少Event id就有多少Event類的原則。比如,我們需要将Event.PageEvent.UPDATE_VIDEO轉換成UpdateVideoEvent.java。

2、 将插件的onEvent方法中switch語句中的每一條case語句映射為一個方法聲明,即有多少case就有多少方法原則。例如在上述代碼示例中的case Event.PageEvent.UPDATE_VIDEO:

3、 将所有使用IEventProxy釋出事件的地方,全部修改為使用EventBus的post方法。比如有:

如果耐心把這篇文章看到這裡的話,大家可能會覺得,你要做的工作很簡單嘛,無壓力,so easy。

開始工作之前,老大都要求我們先把工作量評估出來。由于代碼中有多少事件,有多少個插件,每個插件具體收聽處理了多少種事件,這是很難統計出來的,特别是最後一點。不過,工作量肯定和插件的個數,以及插件的代碼規模肯定是成正比的,我隻需要把這兩點統計出來,估計一個大概的工作量還是可以的。于是,有下面的統計表:

代碼自動生成在重構中的一次探索

圖 3

橫坐标是代碼行數,縱坐标是在插件個數。插件總個數有151個,總代碼行數47000多行。按照每200行代碼1個小時的工作速度,每天8小時不停寫代碼,一個人也要整整30個工作日,還不包括自測,代碼稽核等等其它工作量。我拿着這個表就去找老大說,兩個人需要三周的工作量。結果老大直接跟我說,幫手沒有,你一個人先搞,看看進度咋樣(好吧,其實老大是對這個評估不滿意)。

就這樣,兩眼一抹黑,踏上了EventBus重構之路。

第一天,我先入手了幾個插件類。遇到需要映射的XXX事件,就手動建立其對應于的XXXEvent.java檔案,此操作大概需要近一分鐘。将switch中的語句寫成對應的方法,然後把case中的語句複制到方法體中,此操作視語句長度及case分支的多少,耗時不等。最後将onEvent方法删除。就這樣一天工作下來,不斷重複着這樣的工作,一個八百多行的插件竟耗費了我半天工作時間,極其煩躁,而且人工修改還特别容易出錯,比如拼寫錯誤,漏掉case分支等等,帶來的後果直接表現在代碼運作不正确,而後續卻難以排查。

于是,我有一個大膽的想法。程式員是腦力勞動者,任何時候,都不應該成為搬運工。是否能夠編寫腳本或者自動化工具,自動化的完成重構工作。

使用注解解析自動生成檔案

我們都知道,EventBus是通過注解來實作的。通過注解解析,在編譯階段生成了一個java檔案,這個檔案被稱作SubscribeInfoIndex,其寫死了每個使用了Subscribe注解的類的資訊。

受到EventBus的啟發,我們的事件類是否也能通過注解解析的方式生成呢?答案是肯定的。關于注解解析相關的知識可參看我的另一篇KM《apt與JavaPoet 自動生成代碼》,由于篇幅限制,這裡不做講解。

首先,自定義一個注解:

packageName 屬性指明該Event 類對應生成的新Event檔案的包路徑。

然後在Event.java中使用該注解:

代碼自動生成在重構中的一次探索

圖 4

代碼自動生成在重構中的一次探索

圖 5

(注:PlayerEvent 和UIEvent是Event中定義的内部類,事件Id定義在内部類中。除此之外,還有AudioEvent、PageEvent等)。

編寫注解解析器,注解解析器的邏輯也比較簡單:

代碼自動生成在重構中的一次探索

圖 6

例如,PlayerEvent.INIT對應生成的檔案如下:

代碼自動生成在重構中的一次探索

圖 7

現在,我們剩下的工作是如何完成代碼自動替換,将publish替換為post,将case替換為方法。

我首先想到的是使用正規表達式,通過對源檔案進行掃描,将比對的代碼行替換為指定代碼。比如,我們使用正規表達式^\s\w+\.publish\s\(\s(.+)\s(,\s(\w+)\s)?\)來比對代碼中的mEventProxy.publish()方法調用,然後将其替換為相應的post。但是,我們僅僅通過正則比對,沒有辦法确定比對到的就是IEventProxy類中com.tencent.qqlive.ona.player.event.IEventProxy.publishEvent(com.tencent.qqlive.ona.player.event.Event)的方法調用。例如,完全有可能有一個類A,它内部也聲明了一個public void publish(SomeKind params)方法,我們的正則也會比對,導緻錯誤替換。另外,case語句的替換也是更加的困難。首先,哪些類中的onEvent方法的switch case需要被替換?隻有那些繼承自Plugin的類才需要替換,如何判斷一個類是否繼承自Plugin也是很難判斷的,不但有直接繼承,還有間接的繼承。

是以,正則比對這條路是走不通了,有太多文法、語義上的資訊我們需要知道後才能處理。

那麼,如何去做文法解析呢?寫一個java文法解析器吧。但是我最多隻有一個月的時間,好像不太現實。

不能自己寫就隻能搜尋下是否有現成的文法解析庫,還真有!

JavaSymbolResolver介紹

JavaSymbolResolver是一個用于Java文法語義解析的庫,其實作基礎是JavaParser庫。比如,有下面代碼:

對于表達式a + 1中的a,JavaParser隻能告訴我們a是一個變量,而JavaSymbolResolver則能識别出這裡的a是一個變量,其類型是String。

又例如,有如下A、B兩個類:

JavaSymbolResolver能夠識别出,b + 1表達式中的b即是B類中的b, 而且其初始值為2。

JavaSymbolResolver的這些強大的符号解析能力要基于JavaParser的文法解析。JavaParser接受一個java檔案(或者代碼片段),然後輸出一個叫CompliationUnit的對象,叫編譯單元,其内部結構是一個樹形結構,被稱作抽象文法樹Abstract Syntax Tree(AST)。JavaParser 将源代碼中的一個類定義、一個方法聲明、一句方法調用語句,甚至一個break語句,都抽象為AST上的一個節點(Node),而ComplationUnit則是樹的根節點,AST完整的描述了一個java檔案。

代碼自動生成在重構中的一次探索

圖 8

例如,有如下代碼:

通過JavaParser處理後,輸出如下文法樹:

代碼自動生成在重構中的一次探索

圖 9

上圖中展示了輸出的ComplationUnit中包含了三個子節點,一個package申明,一個import申明,一個類定義。上圖并沒有完整的描述整個文法數,綠色三角形的部分被省略了,下圖展示了省略的MethodDeclatation部分:

代碼自動生成在重構中的一次探索

圖 10

通過其四個節點,我們可看出其傳回類型是void,方法名是main,方法參數是String args,以及其方法體:

代碼自動生成在重構中的一次探索

圖 11

可以看到,即使是System.out.print(LocalDateTime.now());這麼一句代碼,也可以完整的描述成一顆樹。

有了AST後,我們如何周遊這棵樹呢?JavaPaser已經為我們把周遊樹的代碼封裝好了,并且提供了Visitor類,基于通路者模式,你隻需要實作不同的Visitor類來處理具體的節點,而不是将精力放在編寫如何周遊樹的代碼上。

前面我們已經說過,JavaSymbolResolver是建立在JavaParser上的,JavaSymbolResolver借助JavaParser的AST樹,便可實作其符号解析。比如,當判斷一個MethodCallExpr是否是對com.tencent.qqlive.ona.player.event.IEventProxy.publishEvent(com.tencent.qqlive.ona.player

.event.Event)的調用時,JavaSymbolResolver提供的solve方法,不斷回溯目前節點的父節點,以找到這個MethodCallExpr方法調用聲明的原型MethodDeclaration,MethodDeclaration記錄了方法聲明的全限定名,通過将全限定名與com.tencent.qqlive.ona.player.event.IEventProxy.publishEvent(com.tencent.qqlive.ona.player

.event.Event)比較是否相等,我們便可得出結果。

一開始,我是通過建立工程,然後在工程build.gradle檔案中,引入JavaSymbolResolver庫的:

在開發過程中,我發現這個庫現在還很不穩定,有許多bug。例如,使用Lexical-Preserving Printing模式解析的AST,JavaSymbolResolver根本沒有辦法解析,會直接crash,是以導緻我隻能使用Pretty Printing模式解析java檔案。有一些内部接口,JavaSymbolResolver也不能正确解析,比如,有如下代碼:

遺憾的JavaSolverResolver 無法解析出ClassB的類型,因為ClassA.AnInterface無法解析出來,因為AnInterface沒有定義在ClassA中,但是,我們都知道,從java文法的角度,ClassB這麼寫是完全正确的!

由于JavaSymbolResolver目前存在一些氣人bug,是以我不得不下載下傳他的源碼,以修複這些阻礙我的bug,希望JavaSymbolResolver盡快修複這些bug。

下面兩張圖是我用beyong compare将處理後的檔案和處理之前的檔案進行的對比,左邊是處理後的檔案,右邊是原始檔案。第一張圖可以看出onEvent整個被删除了,第二張圖可以看到處理後的檔案末尾添加了很多@Subscrbe注解的方法,第三張圖看到原始檔案中的mEventProxy.publish()方法已經被替換成了對應的mEventBus.post()。

代碼自動生成在重構中的一次探索

圖 12

代碼自動生成在重構中的一次探索

圖 13

代碼自動生成在重構中的一次探索

圖 14

本文主要記述了我如何通過編寫工具自動生成代碼的方式,提高代碼重構的效率。原本計劃需要共計60人日的工作量,實際一個人隻用了不到三周的時間便完成了任務。另外,本文還對注解解析,JavaSymbolResolver及JavaParser的基礎知識進行了講解。

由于文章已經比較長了,篇幅限制,本文并未對實作自動化工具的代碼實作細節進行過多的講解,這部分内容待到以後來分享了。