重構(gòu)遺留應(yīng)用:案例研究
本文是InfoQ中的一篇關(guān)于遺留系統(tǒng)重構(gòu)的文章,該文基于一個真實(shí)案例,講述了如何在重構(gòu)遺留系統(tǒng)時編寫單元測試,以及單元測試又是如何確保了重構(gòu)的正確性。(2013.03.03最后更新) 遺留代碼臭不可聞。每個優(yōu)秀的開發(fā)者都想對這樣的代碼進(jìn)行重構(gòu),但為了重構(gòu),理想情況下,應(yīng)該有一組單元測試用例以防止程序退化。但為遺留程序編寫單元測試絕非易事;遺留代碼往往是一團(tuán)亂麻。對遺留程序編寫高效的單元,可能需要先對它進(jìn)行重構(gòu);而為了重構(gòu)它,你又需要單元測試去確保重構(gòu)不會破壞任何功能。所以這是一個先有雞,還是先有蛋的問題。通過對我曾經(jīng)工作過的一個真實(shí)案例的分享,本文描述了一種對遺留程序進(jìn)行安全重構(gòu)的方法論。問題陳述 在本文中,我使用一個真實(shí)案例闡述了在測試與重構(gòu)遺留系統(tǒng)時的一種實(shí)用的實(shí)踐過程。本案例由Java編寫,但這一做法應(yīng)該也適用于其它語言。為了照顧無經(jīng)驗(yàn)者,我對本案例的場景進(jìn)行了修改,并且對其進(jìn)行了簡化以使其更易于理解。本文介紹的實(shí)踐均有助于最近我對遺留系統(tǒng)的重構(gòu)。 這不是一篇 關(guān)于單元測試與重構(gòu)基本技能的文章。要對關(guān)于這方面的主題進(jìn)行更多學(xué)習(xí),你可以閱讀:Martin Fowler的Refactoring: Improving the Design of Existing Code,以及Joshua Kerievsky的Refactoring to Patterns。本文不會描述在現(xiàn)實(shí)項(xiàng)目中時常會遇到的復(fù)雜場景,但我希望本文能為處理這些復(fù)雜場景提供一些有用的方法。 在該案例研究中,我將使用的例子是一個虛構(gòu)的資源管理系統(tǒng),在該系統(tǒng)中,一個資源代表一個能被分配一些任務(wù)的人。可以認(rèn)為,一個資源能被分配一個Ticket--HR Ticket或IT Ticket。同時,一個資源也能被分配一個Request--HR Request或IT Request。資源經(jīng)理可以記錄資源要完成某項(xiàng)任務(wù)的評估時間。資源可以記錄他或她在完全這個Ticket和Request時所耗費(fèi)的實(shí)際時間。
柱狀圖中展示了資源的使用情況,包括評估時間與實(shí)際時間。
不復(fù)雜吧?然而,在真實(shí)系統(tǒng)中,資源會被分配到許多不同類型的任務(wù)。即使如此,從技術(shù)上看,這個設(shè)計并不復(fù)雜。然而,當(dāng)?shù)谝淮巫x到這些代碼,我感覺我好像是在看一塊化石。我能夠看到這些代碼是如何進(jìn)化的(或者更應(yīng)該說是退化)。最開始時,該系統(tǒng)只能處理Request,然后加入了處理Ticket和其它類型任務(wù)的功能。然后來一個開發(fā)工程師,他編寫了處理Request的代碼:從數(shù)據(jù)庫中解析數(shù)據(jù),將收集到的數(shù)據(jù)展示到柱狀圖中。他甚至都沒有將數(shù)據(jù)信息置于一個合適的對象中:class ResourceBreakdownService {
public Map search (Session context) throws SearchException{
//omitted twenty or so lines of code to pull search criteria out of context and verify them, such as the below:
if(resourceIds==null || resourceIds.size ()==0){
throw new SearchException(“Resource list is not provided”);
}
if(resourceId!=null || resourceIds.size()>0){
resourceObjs=resourceDAO.getResourceByIds(resourceIds);
}
//get workload for all requests
Map requestBreakDown=getResourceRequestsLoadBreakdown (resourceObjs,startDate,finishDate);
return requestBreakDown;
}
}
我能肯定你會立即被這段代碼的惡臭逼退。例如,你可能會馬上想到search不是一個有意義的名字,應(yīng)該使用Apache Commons類庫的CollectionUtil.isEmpty()去測試這個集合對象,你可能也會對返回結(jié)果Map提出質(zhì)疑。 但先等等,臭味還在繼續(xù)集聚中。又來了一個開發(fā)工程師,并使用相同的方法來處理Ticket,所以你會看到這樣的代碼:// get workload for all tickets
Map ticketBreakdown =getResourceRequestsLoadBreakdown(resourceObjs,startDate,finishDate,ticketSeverity);
Map result=new HashMap();
for(Iterator i = resourceObjs.iterator(); i.hasNext();) {
Resource resource=(Resource)i.next();
Map requestBreakdown2=(Map)requestBreakdown.get(resource);
List ticketBreakdown2=(List)ticketBreakdown.get(resource);
Map resourceWorkloadBreakdown=combineRequestAndTicket(requestBreakdown2, ticketBreakdown2);
result.put(resource,resourceWorkloadBreakdown)
}
return result;
先不必考慮命名,結(jié)構(gòu)的平衡性,以及其它美學(xué)方面的關(guān)切,最臭不可聞的就是返回的Map對象。Map是一個黑洞,它可以吸入任何對象,但不會為你提供足夠的線索去弄清楚它所包含的確切對象。 在這個例子中,{}表示一個Map對象,=>代表一個鍵-值對象,而[]代表一個集合對象:{resource with id 30000=> [
SummaryOfActualWorkloadForRequestType,
SummaryOfEstimatedWorkloadForRequestType,
{30240=>[
ActualWorkloadForReqeustWithId_30240,
EstimatedWorkloadForRequestWithId_30240],
30241=>[
ActualWorkloadForReqeustWithId_30241,
EstimatedWorkloadForRequestWithId_30241]
}
SummaryOfActualWorkloadForTicketType,
SummaryOfEstimatedWorkloadForTicketType,
{20000=>[
ActualWorkloadForTicketWithId_2000,
EstimatedWorkloadForTicketWithId_2000],
}
]
}
使用如此糟糕的數(shù)據(jù)結(jié)構(gòu),數(shù)據(jù)的組裝與分解邏輯確實(shí)不易讀懂,而且極其冗長。集成測試 希望到目前為止,我已經(jīng)讓你相信這些代碼確實(shí)很復(fù)雜。如果在開始重構(gòu)之前,我要首先解開這一團(tuán)亂麻,還得理解每一處代碼,那我可能會發(fā)瘋。為了保持頭腦清醒,我決定去理解程序的邏輯,最好是使用由上至下的途徑去進(jìn)行理解。即,不去閱讀代碼,也不去推演程序邏輯,而最好是去使用該系統(tǒng)并對其進(jìn)行調(diào)試,以便在總體上對其進(jìn)行理解。 在編寫單元測試時,我也使用相同的方法。傳統(tǒng)觀點(diǎn)是編寫小的單元測試去驗(yàn)證每一塊程序,如果每個測試都能正常通過,那么當(dāng)你把所有的程序匯集到一塊兒時,有很大的可能性,整個應(yīng)用都能夠正常運(yùn)行。但該方法不適用此處。ResourceBreakdownService是一個"
我編寫了一個簡單的單元測試,它反映了我對應(yīng)用整體結(jié)構(gòu)的理解:
public void testResourceBreakdown(){
Resource resource=createResource();
List requests=createRequests();
assignRequestToResource(resource, requests);
List tickets=createTickets();
assignTicketToResource(resource, tickets);
Map result=new ResourceBreakdownService().search(resource);
verifyResult(result,resource,requests,tickets);
}
請注意verifyResult()方法。首先,我通過遞歸地打印返回結(jié)果的內(nèi)容來了解它的結(jié)構(gòu)。verifyResult()方法使用這一結(jié)構(gòu)去驗(yàn)證返回值是否包含有正確的數(shù)據(jù):
private void verifyResult(Map result, Resource rsc, List<Request> requests, List<Ticket> tickets){
assertTrue(result.containsKey(rsc.getId()));
// in this simple test case, actual workload is empty
UtilizationBean emptyActualLoad=createDummyWorkload();
List resourceWorkLoad=result.get(rsc.getId());
UtilizationBean scheduleWorkload=calculateWorkload(rsc,requests);
assertEquals(emptyActualLoad,resourceWorkLoad.get(0));
assertEquals(scheduleWorkload,resourceWorkLoad.get(1));
Map requestDetailWorkload = (Map)resourceWorkLoad.get(3);
for (Request request : requests) {
assertTrue(requestDetailWorkload.containsKey(request.getId());
UtilizationBean scheduleWorkload0=calculateWorkload(rsc,request);
assertEquals(emptyActualLoad,requestDetailWorkload.get(request.getId()).get(0));
assertEquals(scheduleWorkload0,requestDetailWorkload.get(request.getId()).get(1));
}
// omit code to check tickets

}
繞開障礙
上述單元測試開起來簡單,但實(shí)際上復(fù)雜。首先,ResourceBreakdownService.search()方法與運(yùn)行時環(huán)境緊密綁定,它需要訪問數(shù)據(jù)庫,其它服務(wù),鬼知道還需要些什么。像許多遺留系統(tǒng)一樣,該資源管理系統(tǒng)沒有任何單元測試基礎(chǔ)架構(gòu)。為了訪問運(yùn)行時服務(wù),只能開啟整個系統(tǒng),這不僅昂貴,而也不方便。
啟動系統(tǒng)服務(wù)器端的類是ServerMain。該類也像一塊化石,通過它你能看到程序的演進(jìn)過程。該系統(tǒng)是10年前寫成的,那時還沒有Spring和Hibernate,只有JBoss和Tomcat的早期版本。在那時,勇敢的先行者們不得不新手動手做很多事情,所以他們創(chuàng)建了自用的集群,緩存服務(wù),以及連接池。后來,他們以某種方式加入了JBoss和Tomcat(但不幸地是,他們留下了自己創(chuàng)建的服務(wù),所以程序遺有了兩套事務(wù)管理機(jī)制和三種連接池。)
我決定將ServerMain復(fù)制到TestServerMain中,當(dāng)調(diào)用TestServerMain.main()時卻失敗了:
org.springframework.beans.factory.BeanInitializationException: Could not load properties; nested exception is
java.io.FileNotFoundException: class path resource [database.properties] cannot be opened because it does not exist
at
org.springframework.beans.factory.config.PropertyResourceConfigurer.postProcessBeanFactory(PropertyResourceConfigurer.java:78)
是的,很容易就能搞定!我弄來一個database.properties屬性文件,并把它置入測試類路徑中,再次啟動測試程序。這次則拋出一個異常:
java.io.FileNotFoundException: .\server.conf (The system cannot find the file specified)
at java.io.FileInputStream.open(Native Method)
at java.io.FileInputStream.<init>(FileInputStream.java:106)
at java.io.FileInputStream.<init>(FileInputStream.java:66)
at java.io.FileReader.<init>(FileReader.java:41)
at com.foo.bar.config.ServerConfigAgent.parseFile(ServerConfigAgent.java:1593)
at com.foo.bar.config.ServerConfigAgent.parseConfigFile(ServerConfigAgent.java:1720)
at com.foo.bar.config.ServerConfigAgent.parseConfigFile(ServerConfigAgent.java:1712)
at com.foo.bar.config.ServerConfigAgent.readServerConf(ServerConfigAgent.java:1581)
at com.foo.bar.ServerConfigFactory.initServerConfig(ServerConfigFactory.java:38)
at com.foo.bar.util.HibernateUtil.setupDatabaseProperties(HibernateUtil.java:207)
at com.foo.bar.util.HibernateUtil.doStart(HibernateUtil.java:135)
at com.foo.bar.util.HibernateUtil.<clinit>(HibernateUtil.java:125)
server.conf是存在于某處,但這種方法讓我很不爽。只不過是寫個單元測試,就馬上暴露出了程序中的問題。HibernateUtil,顧名思義,應(yīng)該只關(guān)注由database.properties文件提供的數(shù)據(jù)庫信息。那為什么它還需要訪問server.conf文件呢?該文件是用來配置服務(wù)器端參數(shù)的。有一線索可用來判斷代碼是否有異味:如果你感覺你是在讀一部偵探小說,并不停在問"為什么",那么這段代碼通常就不好。我已經(jīng)花了大量時間去閱讀ServerConfigFactory,HibernateUtil和ServerConfigAgent中的代碼,并且還嘗試著去弄清楚HibernateUtil是如何使用database.properties,但在這個問題上,使我急切地想得到一個運(yùn)行的服務(wù)器。除了這種方法以外,還有一種解決途徑,這個武器就是AspectJ。void around():
call(public static void com.foo.bar.ServerConfigFactory.initServerConfig()){
System.out.println("bypassing com.foo.bar.ServerConfigFactory.initServerConfig");
}
對不懂AspectJ的伙計們來說,上面那段話的意思是:當(dāng)運(yùn)行到對ServerConfigFactory.initServerConfig()的調(diào)用時,AspectJ會打印一條信息,然后直接返回,而不再進(jìn)入該方法本身。感覺這就像是駭客入侵,但這非常高效。遺留系統(tǒng)中滿是謎團(tuán)與問題。在任一給定的時刻,每個人都需要作出他具有戰(zhàn)略性的一擊。當(dāng)時,依據(jù)用戶滿意度,我做的最值得稱道的事情就是修復(fù)了資源管理系統(tǒng)中的缺陷,并改進(jìn)了它的性能。矯正其它方面的問題并不是我需要關(guān)注的。但是,我會把它記在頭里,之后我會轉(zhuǎn)回來解決ServerMain中的亂麻。 在HibernateUtil從server.conf中讀取必要信息這個調(diào)用點(diǎn),我指示它從database.properties中讀取這些信息:String around():call(public String com.foo.bar.config.ServerConfig.getJDBCUrl()){
// code omitted, reading from database.properties
}
String around():call(public String com.foo.bar.config.ServerConfig.getDBUser()){
// code omitted, reading from database.properties
}
你可能已經(jīng)猜到剩下的工作了:解決運(yùn)行時障礙,使它做起來更方便或更自然些。但如果已經(jīng)現(xiàn)成的Mock對象,那就復(fù)用它。例如,TestServerMain.main()會在如下位置失敗:- Factory name: java:comp/env/hibernate/SessionFactory
- JNDI InitialContext properties:{}
- Could not bind factory to JNDI
javax.naming.NoInitialContextException: Need to specify class name in environment or system property, or as an applet
parameter, or in an application resource file: java.naming.factory.initial
at javax.naming.spi.NamingManager.getInitialContext(NamingManager.java:645)
at javax.naming.InitialContext.getDefaultInitCtx(InitialContext.java:288)
這是因?yàn)闆]有啟動JBoss的命名服務(wù)。我可以使用相同的AspectJ技術(shù)去解決這個問題,但I(xiàn)nitialContext是一個很大的Java接口,它包含有許多方法,我不想去實(shí)現(xiàn)每個方法--那實(shí)在太繁冗了。隨便查了一下,發(fā)現(xiàn)Spring已經(jīng)提供一個Mock類SimpleNamingContext,所以就在測試用例使用這個類:SimpleNamingContextBuilder builder = new SimpleNamingContextBuilder();
builder.bind(“java:comp/env/hibernate/SessionFactory”,sessionFactory);
builder.activate();
經(jīng)過幾輪嘗試后,我已能成功執(zhí)行TestServerMain.main()方法了。與ServerMain相比,它要簡單多了,它模擬了許多JBoss服務(wù),并且它沒有糾結(jié)于集群管理。
創(chuàng)建構(gòu)建模塊 TestServerMain連接到一個真實(shí)的數(shù)據(jù)庫。遺留系統(tǒng)會將意想不到的邏輯隱藏于存儲過程中,更糟糕地是,甚至?xí)[藏于觸發(fā)器中。基于相同的整體結(jié)構(gòu)考慮,我認(rèn)為當(dāng)時試圖去理解隱藏于數(shù)據(jù)庫中的神秘邏輯并去模擬這樣的一個數(shù)據(jù)庫是不明智的,所以我決定讓測試用例去訪問真實(shí)的數(shù)據(jù)庫。 需要重復(fù)地執(zhí)行測試用例,以確保我對產(chǎn)品做的每一點(diǎn)兒改變都能夠通過測試。在每次執(zhí)行時,測試程序?qū)⒃跀?shù)據(jù)庫中創(chuàng)建資源與請求。不同于單元測試的傳統(tǒng)套路,有時候你并不想在每個用例執(zhí)行完成之后銷毀由該用例創(chuàng)建的數(shù)據(jù),以使運(yùn)行環(huán)境變得干凈。到目前為止,測試與重構(gòu)實(shí)踐是一種實(shí)情調(diào)查的探索方式--通過嘗試著進(jìn)行測試去學(xué)習(xí)遺留系統(tǒng)。你可能想檢查由測試用例在數(shù)據(jù)庫中創(chuàng)建的數(shù)據(jù),或者為了確定每一項(xiàng)功能都能如期運(yùn)行,你可能還想在運(yùn)行時系統(tǒng)中使用這些數(shù)據(jù)。這就意味著,測試用例必須在數(shù)據(jù)庫中創(chuàng)建獨(dú)一無二的數(shù)據(jù)實(shí)例,以避免與其它用例沖突。應(yīng)該有一些工具類去方便地創(chuàng)建這些數(shù)據(jù)實(shí)例。 此處有一個簡單的創(chuàng)建資源的構(gòu)建模塊:public static ResourceBuilder newResource (String userName) {
ResourceBuilder rb = new ResourceBuilder();
rb.userName = userName + UnitTestThreadContext.getUniqueSuffix();
return rb; }
public ResourceBuilder assignRole(String roleName) {
this.roleName = roleName + UnitTestThreadContext.getUniqueSuffix();
return this;
}
public Resource create() {
ResourceDAO resourceDAO = new ResourceDAO(UnitTestThreadContext.getSession());
Resource rs;
if (StringUtils.isNotBlank(userName)) {
rs = resourceDAO.createResource(this.userName);
} else {
throw new RuntimeException("must have a user name to create a resource");
}
if (StringUtils.isNotBlank(roleName)) {
Role role = RoleBuilder.newRole(roleName).create();
rs.addRole(role);
}
return rs;
}
public static void delete(Resource rs, boolean cascadeToRole) {
Session session = UnitTestThreadContext.getSession();
ResourceDAO resourceDAO = new ResourceDAO(session);
resourceDAO.delete(rs);
if (cascadeToRole) {
RoleDAO roleDAO = new RoleDAO(session);
List roles = rs.getRoles();
for (Object role : roles) {
roleDAO.delete((Role)role);
}
}
}
ResourceBuilder是Builder與Factory模式的實(shí)現(xiàn);你得悠著點(diǎn)兒去用它:ResourceBuilder.newResource(“Tom”).assignRole(“Developer”).create();
該類也包含一個戰(zhàn)場清掃方法:delete()。在早期的重構(gòu)實(shí)踐中,我沒有經(jīng)常調(diào)用這個方法,因?yàn)槲医?jīng)常啟動該系統(tǒng),然后引入測試用例中的數(shù)據(jù),并檢驗(yàn)柱狀圖是否正確。此處非常有用的一個類就是UnitTestThreadContext,它按照線程的不同去存儲Hibernate會話,并為每個你想創(chuàng)建的數(shù)據(jù)實(shí)體的名稱的后面加上一個唯一的字符串,因此保證了實(shí)體的唯一性。public class UnitTestThreadContext {
private static ThreadLocal<Session> threadSession=new ThreadLocal<Session>();
private static ThreadLocal<String> threadUniqueId=new ThreadLocal<String>();
private final static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH_mm_ss_S");
public static Session getSession(){>
Session session = threadSession.get();
if (session==null) {
throw new RuntimeException("Hibernate Session not set!");
}
return session;
}
public static void setSession(Session session) {
threadSession.set(session);
}
public static String getUniqueSuffix() {
String uniqueId = threadUniqueId.get();
if (uniqueId==null){
uniqueId = "-"+dateFormat.format(new Date());
threadUniqueId.set(uniqueId);
}
return uniqueId;
}
…
}
集中起來看 現(xiàn)在我可以啟動這個最低限度的基礎(chǔ)框架了,并能運(yùn)行簡單的測試用例:protected void setUp() throws Exception {
TestServerMain.run(); //setup a minimum running infrastructure
}
public void testResourceBreakdown(){
Resource resource=createResource(); //use ResourceBuilder to build unique resources
List requests=createRequests(); //use RequestBuilder to build unique requests
assignRequestToResource(resource, requests);
List tickets=createTickets(); //use TicketBuilder to build unique tickets
assignTicketToResource(resource, tickets);
Map result=new ResourceBreakdownService().search(resource);
verifyResult(result);
}
protected void tearDown() throws Exception {
// use TicketBuilder.delete() to delete tickets
// use RequestBuilder.delete() to delete requests
// use ResourceBuilder.delete() to delete resources
使用該方法,我繼續(xù)編寫更復(fù)雜的測試用例,重構(gòu)產(chǎn)品程序與測試程序。 靠著這些測試用例的武裝,我對"上帝類"ResourceBreakdownService一點(diǎn)點(diǎn)兒地進(jìn)行分解。我不會透露更多的細(xì)節(jié),這會使你難以理解;有許多書都可以教你如何安全地進(jìn)行重構(gòu)。
糟糕的Map_Of_Array_Of_Map_Of…數(shù)據(jù)結(jié)構(gòu)現(xiàn)在被組裝成ResourceLoadBucket,該類使用了Composite模式。它包含有特定級別的評估工效與實(shí)際工效,下一級別的工效可通過aggregate()方法聚合而成。最終程序要清潔的多,性能也更優(yōu)。它甚至暴露了一些隱藏于原有代碼復(fù)雜邏輯中的缺陷。當(dāng)然,我也根據(jù)這種方法改進(jìn)了單元測試。 貫穿于這次重構(gòu)實(shí)踐,我始終堅持的關(guān)鍵原則就是大局觀思想。我挑選著工作方向并持續(xù)堅持著大局觀原則,繞開當(dāng)前并不重要的任務(wù),然后構(gòu)建一個最低限度的測試基礎(chǔ)架構(gòu),我的團(tuán)隊(duì)則會繼續(xù)使用這個基礎(chǔ)架構(gòu)去重構(gòu)系統(tǒng)的其它部分。仍然在測試基礎(chǔ)架構(gòu)中保留了一些駭客侵入式的AspectJ程序,因?yàn)樵跇I(yè)務(wù)上沒有必要去清除它們。我不僅重構(gòu)了一個非常復(fù)雜的功能領(lǐng)域,而且還對遺留系統(tǒng)進(jìn)行了深入的認(rèn)知。對待一個遺留應(yīng)用,就如同對待一件易碎的瓷器,它不會給你帶來任何安全感。深入其中,并對其進(jìn)行重構(gòu),如此,遺留應(yīng)用才可能在未來得以幸存。