John Jiang

          a cup of Java, cheers!
          https://github.com/johnshajiang/blog

             :: 首頁 ::  :: 聯系 :: 聚合  :: 管理 ::
            131 隨筆 :: 1 文章 :: 530 評論 :: 0 Trackbacks
          重構遺留應用:案例研究
          本文是InfoQ中的一篇關于遺留系統重構的文章,該文基于一個真實案例,講述了如何在重構遺留系統時編寫單元測試,以及單元測試又是如何確保了重構的正確性。(2013.03.03最后更新)

              遺留代碼臭不可聞。每個優秀的開發者都想對這樣的代碼進行重構,但為了重構,理想情況下,應該有一組單元測試用例以防止程序退化。但為遺留程序編寫單元測試絕非易事;遺留代碼往往是一團亂麻。對遺留程序編寫高效的單元,可能需要先對它進行重構;而為了重構它,你又需要單元測試去確保重構不會破壞任何功能。所以這是一個先有雞,還是先有蛋的問題。通過對我曾經工作過的一個真實案例的分享,本文描述了一種對遺留程序進行安全重構的方法論。

          問題陳述
              在本文中,我使用一個真實案例闡述了在測試與重構遺留系統時的一種實用的實踐過程。本案例由Java編寫,但這一做法應該也適用于其它語言。為了照顧無經驗者,我對本案例的場景進行了修改,并且對其進行了簡化以使其更易于理解。本文介紹的實踐均有助于最近我對遺留系統的重構。
              這不是一篇 關于單元測試與重構基本技能的文章。要對關于這方面的主題進行更多學習,你可以閱讀:Martin FowlerRefactoring: Improving the Design of Existing Code,以及Joshua KerievskyRefactoring to Patterns。本文不會描述在現實項目中時常會遇到的復雜場景,但我希望本文能為處理這些復雜場景提供一些有用的方法。
              在該案例研究中,我將使用的例子是一個虛構的資源管理系統,在該系統中,一個資源代表一個能被分配一些任務的人。可以認為,一個資源能被分配一個Ticket--HR Ticket或IT Ticket。同時,一個資源也能被分配一個Request--HR Request或IT Request。資源經理可以記錄資源要完成某項任務的評估時間。資源可以記錄他或她在完全這個Ticket和Request時所耗費的實際時間。

              柱狀圖中展示了資源的使用情況,包括評估時間與實際時間。

              不復雜吧?然而,在真實系統中,資源會被分配到許多不同類型的任務。即使如此,從技術上看,這個設計并不復雜。然而,當第一次讀到這些代碼,我感覺我好像是在看一塊化石。我能夠看到這些代碼是如何進化的(或者更應該說是退化)。最開始時,該系統只能處理Request,然后加入了處理Ticket和其它類型任務的功能。然后來一個開發工程師,他編寫了處理Request的代碼:從數據庫中解析數據,將收集到的數據展示到柱狀圖中。他甚至都沒有將數據信息置于一個合適的對象中:
          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不是一個有意義的名字,應該使用Apache Commons類庫的CollectionUtil.isEmpty()去測試這個集合對象,你可能也會對返回結果Map提出質疑。
              但先等等,臭味還在繼續集聚中。又來了一個開發工程師,并使用相同的方法來處理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;

              先不必考慮命名,結構的平衡性,以及其它美學方面的關切,最臭不可聞的就是返回的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],
               }                 
               ]
          }

              使用如此糟糕的數據結構,數據的組裝與分解邏輯確實不易讀懂,而且極其冗長。

          集成測試
              希望到目前為止,我已經讓你相信這些代碼確實很復雜。如果在開始重構之前,我要首先解開這一團亂麻,還得理解每一處代碼,那我可能會發瘋。為了保持頭腦清醒,我決定去理解程序的邏輯,最好是使用由上至下的途徑去進行理解。即,不去閱讀代碼,也不去推演程序邏輯,而最好是去使用該系統并對其進行調試,以便在總體上對其進行理解。
              在編寫單元測試時,我也使用相同的方法。傳統觀點是編寫小的單元測試去驗證每一塊程序,如果每個測試都能正常通過,那么當你把所有的程序匯集到一塊兒時,有很大的可能性,整個應用都能夠正常運行。但該方法不適用此處。ResourceBreakdownService是一個"
              我編寫了一個簡單的單元測試,它反映了我對應用整體結構的理解:
          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()方法。首先,我通過遞歸地打印返回結果的內容來了解它的結構。verifyResult()方法使用這一結構去驗證返回值是否包含有正確的數據:
          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
                 
          }

          繞開障礙
              上述單元測試開起來簡單,但實際上復雜。首先,ResourceBreakdownService.search()方法與運行時環境緊密綁定,它需要訪問數據庫,其它服務,鬼知道還需要些什么。像許多遺留系統一樣,該資源管理系統沒有任何單元測試基礎架構。為了訪問運行時服務,只能開啟整個系統,這不僅昂貴,而也不方便。
              啟動系統服務器端的類是ServerMain。該類也像一塊化石,通過它你能看到程序的演進過程。該系統是10年前寫成的,那時還沒有Spring和Hibernate,只有JBoss和Tomcat的早期版本。在那時,勇敢的先行者們不得不新手動手做很多事情,所以他們創建了自用的集群,緩存服務,以及連接池。后來,他們以某種方式加入了JBoss和Tomcat(但不幸地是,他們留下了自己創建的服務,所以程序遺有了兩套事務管理機制和三種連接池。)
              我決定將ServerMain復制到TestServerMain中,當調用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,顧名思義,應該只關注由database.properties文件提供的數據庫信息。那為什么它還需要訪問server.conf文件呢?該文件是用來配置服務器端參數的。有一線索可用來判斷代碼是否有異味:如果你感覺你是在讀一部偵探小說,并不停在問"為什么",那么這段代碼通常就不好。我已經花了大量時間去閱讀ServerConfigFactoryHibernateUtilServerConfigAgent中的代碼,并且還嘗試著去弄清楚HibernateUtil是如何使用database.properties,但在這個問題上,使我急切地想得到一個運行的服務器。除了這種方法以外,還有一種解決途徑,這個武器就是
          AspectJ

          void around():
              call(
          public static void com.foo.bar.ServerConfigFactory.initServerConfig()){
              System.out.println(
          "bypassing com.foo.bar.ServerConfigFactory.initServerConfig");
          }

              對不懂AspectJ的伙計們來說,上面那段話的意思是:當運行到對ServerConfigFactory.initServerConfig()的調用時,AspectJ會打印一條信息,然后直接返回,而不再進入該方法本身。感覺這就像是駭客入侵,但這非常高效。遺留系統中滿是謎團與問題。在任一給定的時刻,每個人都需要作出他具有戰略性的一擊。當時,依據用戶滿意度,我做的最值得稱道的事情就是修復了資源管理系統中的缺陷,并改進了它的性能。矯正其它方面的問題并不是我需要關注的。但是,我會把它記在頭里,之后我會轉回來解決ServerMain中的亂麻。
              在HibernateUtilserver.conf中讀取必要信息這個調用點,我指示它從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
          }

              你可能已經猜到剩下的工作了:解決運行時障礙,使它做起來更方便或更自然些。但如果已經現成的Mock對象,那就復用它。例如,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)

              這是因為沒有啟動JBoss的命名服務。我可以使用相同的AspectJ技術去解決這個問題,但InitialContext是一個很大的Java接口,它包含有許多方法,我不想去實現每個方法--那實在太繁冗了。隨便查了一下,發現Spring已經提供一個Mock類SimpleNamingContext,所以就在測試用例使用這個類:
          SimpleNamingContextBuilder builder = new SimpleNamingContextBuilder();
          builder.bind(“java:comp
          /env/hibernate/SessionFactory”,sessionFactory);
          builder.activate();

              經過幾輪嘗試后,我已能成功執行TestServerMain.main()方法了。與ServerMain相比,它要簡單多了,它模擬了許多JBoss服務,并且它沒有糾結于集群管理。

          創建構建模塊
              TestServerMain連接到一個真實的數據庫。遺留系統會將意想不到的邏輯隱藏于存儲過程中,更糟糕地是,甚至會隱藏于觸發器中。基于相同的整體結構考慮,我認為當時試圖去理解隱藏于數據庫中的神秘邏輯并去模擬這樣的一個數據庫是不明智的,所以我決定讓測試用例去訪問真實的數據庫。
              需要重復地執行測試用例,以確保我對產品做的每一點兒改變都能夠通過測試。在每次執行時,測試程序將在數據庫中創建資源與請求。不同于單元測試的傳統套路,有時候你并不想在每個用例執行完成之后銷毀由該用例創建的數據,以使運行環境變得干凈。到目前為止,測試與重構實踐是一種實情調查的探索方式--通過嘗試著進行測試去學習遺留系統。你可能想檢查由測試用例在數據庫中創建的數據,或者為了確定每一項功能都能如期運行,你可能還想在運行時系統中使用這些數據。這就意味著,測試用例必須在數據庫中創建獨一無二的數據實例,以避免與其它用例沖突。應該有一些工具類去方便地創建這些數據實例。
              此處有一個簡單的創建資源的構建模塊:
          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模式的實現;你得悠著點兒去用它:
          ResourceBuilder.newResource(“Tom”).assignRole(“Developer”).create();

              該類也包含一個戰場清掃方法:delete()。在早期的重構實踐中,我沒有經常調用這個方法,因為我經常啟動該系統,然后引入測試用例中的數據,并檢驗柱狀圖是否正確。
          此處非常有用的一個類就是UnitTestThreadContext,它按照線程的不同去存儲Hibernate會話,并為每個你想創建的數據實體的名稱的后面加上一個唯一的字符串,因此保證了實體的唯一性。
          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;        
               }

               …
           }

          集中起來看
              現在我可以啟動這個最低限度的基礎框架了,并能運行簡單的測試用例:
          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

              使用該方法,我繼續編寫更復雜的測試用例,重構產品程序與測試程序。
              靠著這些測試用例的武裝,我對"上帝類"ResourceBreakdownService一點點兒地進行分解。我不會透露更多的細節,這會使你難以理解;有許多書都可以教你如何安全地進行重構。


              糟糕的Map_Of_Array_Of_Map_Of…數據結構現在被組裝成ResourceLoadBucket,該類使用了Composite模式。它包含有特定級別的評估工效與實際工效,下一級別的工效可通過aggregate()方法聚合而成。最終程序要清潔的多,性能也更優。它甚至暴露了一些隱藏于原有代碼復雜邏輯中的缺陷。當然,我也根據這種方法改進了單元測試。
              貫穿于這次重構實踐,我始終堅持的關鍵原則就是大局觀思想。我挑選著工作方向并持續堅持著大局觀原則,繞開當前并不重要的任務,然后構建一個最低限度的測試基礎架構,我的團隊則會繼續使用這個基礎架構去重構系統的其它部分。仍然在測試基礎架構中保留了一些駭客侵入式的AspectJ程序,因為在業務上沒有必要去清除它們。我不僅重構了一個非常復雜的功能領域,而且還對遺留系統進行了深入的認知。對待一個遺留應用,就如同對待一件易碎的瓷器,它不會給你帶來任何安全感。深入其中,并對其進行重構,如此,遺留應用才可能在未來得以幸存。
          posted on 2013-03-03 22:44 John Jiang 閱讀(2198) 評論(0)  編輯  收藏 所屬分類: 翻譯Methodology
          主站蜘蛛池模板: 汝南县| 宿松县| 达日县| 顺昌县| 江城| 上虞市| 湖口县| 广饶县| 固始县| 抚顺市| 兴国县| 巩义市| 叙永县| 米泉市| 庄浪县| 林芝县| 冕宁县| 马鞍山市| 兴文县| 云霄县| 汾阳市| 东丽区| 蕉岭县| 商都县| 古蔺县| 托克托县| 华容县| 兰坪| 当涂县| 衡南县| 行唐县| 延寿县| 望城县| 沐川县| 林芝县| 博湖县| 大化| 宜兰市| 伊通| 晋宁县| 昭通市|