# 大話重構 Part1 基礎篇 ###### 閱讀人:薛威明 ![](https://hackmd.io/_uploads/S1xqAAfk2.jpg) ###### [重點整理](https://hackmd.io/@voxar/H1U0Y2T13)、[投影片影片](https://www.youtube.com/playlist?list=PLDE-E73wU5urgMZvpCEUhbvYvq1-n1Z0Z)、 [博客來連結](https://www.books.com.tw/products/0010687580) --- # 前言 ---- 一套軟體,第一次被開發時,一切都十分清晰: 清晰的業務需求、清晰的設計思維、清晰的程式碼 ---- 經歷幾次需求變更與維護之後 一切就變得不那麼清晰了 ---- 隨著時間推移 軟體經歷數十次的變更與維護,情況越來越糟 最初的設計師不願再看到自己的程式碼選擇離去 ---- 繼任者無所適從,看不懂前人設計 每次修改如同走鋼索 ---- 測試人員成為唯一希望 當有新技術出現,更凸顯原有的破舊與不堪 --- ## 設計之初投入更多精力 ## 能解決問題嗎? ---- 認為可能發生的變更從未發生 原本的設計變擺設 沒考慮到的變更卻發生 品質開始下降 ---- ### 交付第一個版本之後 ### 客戶的需求就開始變更了 客戶使用軟體遇到不那麼簡單、 不那麼主要、不那麼核心的情況 軟體就無法處理 --- ## 為何學重構 程式隨者開發仍可維持 * 可讀 * 可覆用 * 可維護 * 可擴展 --- # CH1 重構: ## 改變既有程式碼的一劑良藥 ---- ### 重構的兩難 ---- 後有追兵: 大型遺留系統,不改:面對越來越多需求,修改難度高,維護成本高,落後使用新技術的競爭者 ---- 前有懸岩: 改,怕出問題,若已上線,風險太大 ---- * 軟體外部品質 = 企業信譽 * 軟體內部品質被漠視 ---- * 程式越寫越爛 -> 效能越低 -> 結構看不懂 * 剛畢業大學生耳濡目染 -> 天真以為程式就是這樣 --- ## 什麼是重構 * 嚴謹而安全的過程 * 保證軟體改善同時,不會引入新的Bug ---- 1. ### 不改變外部行為的基礎上, 1. ### 改變內部結構,使其易於閱讀、維護和變更 ---- ### 不改變外部行為 * 輸入輸出前後是一致的 ---- ### 測試就是系統重構的保險鎖 * 貫穿重構過程的是測試 * 從手工漸漸轉為自動化 ---- ### 第一步是建立測試 * 一開始就自動化測試是不切實際 #### 遺留系統: * 設計混亂 * 介面不清晰 * 程式依賴 --- ## 小步快跑 vs 大格局 **每次重構週期:10min ~ 1hr** * 週期越長 = 問題複雜 = 出錯機率高 ---- ### 兩頂帽子 1. 先只重構,不新增功能,有能力適應新需求 2. 再增加新功能,滿足新需求 --- ## 重構第一步:可讀性 * 業務邏輯清晰 * O:方法提煉 -> 類別、介面獨立 * X:簡單千行程式碼或是注釋 ---- 讀參數檔建立物件。依據步驟先拉方法出來 ```java= public abstract class XmlBuildFactoryTemplate { /** * 初始化工廠。根據路徑讀取XML檔,將XML檔案中的資料裝載到工廠中 * @param path XML檔的路徑 */ public void initFactory(String path){ //尋找XML文件,讀取資料流程 findXmlFile(path); //解析XML文件,返回根 readXmlStream(inputStream); //根據XML檔建立類別,放入工廠中 buildFactory(root); } /** * 讀取一個XML的檔,輸出其資料流程 * @param path XML檔的路徑 * @return InputStream檔輸入流 */ protected InputStream findXmlFile (Stringpath) { ... } /** * 讀取並解析一個XML的檔輸入流,以Element的形式獲取XML的根,返回之 * @param inputStream 檔輸入流 * @return ElementXML的根 */ protected Element readXmlStream (InputStream inputStream) { ... } /** * 用「從一個XML的檔中讀取的資料」建構工廠 * @param root 從一個XML的檔中讀取的資料的根 */ protected abstract void buildFactory (Element root); } ``` ---- 未來因應不同的讀檔方式 ,入不同繼承讀檔的介面 ![](https://hackmd.io/_uploads/r1Iv5opkh.jpg) ---- ### 領域驅動設計 DDD - Domain-Driven Design * 物件導向設計的類別、方法、關聯,應當與現實世界中的事物、行為及相互關係對應起來 * 幫助理解 --- # Ch2 重構方法工具箱 ---- 重構 = 一系列的等量變換 * 就像數學算式,每行算式都是等式,可以交換 * 過程雖然改變,但等號頭尾是不變的 --- ## HelloWorld重構 ---- ### 原始 ```java= public class HelloWorld { public String sayHello(Date now, String user){ Calendar c; int h; String s = null; c = Calendar.getInstance(); c.setTime (now); h = c.get (Calendar.HOUR_OF_DAY); if (h> 6 && h<12) { s = "Good morning!"; }else if (h>=12 && h<19) { s = "Good afternoon!"; }else{ s = "Good night!"; } s = "Hi, "+user+". "+s; return s; } } ``` ---- #### 問題 * 沒有註解 * 命名不易懂 * 沒分行,可讀性低 --- ### 第一版重構 #### (註解、重新命名、調整次數、分行) ```java= /** * The Refactoring's hello-world program * @author fangang */ public class HelloWorld { /** * Say hello to everyone * @param now * @param user * @return the words what to say */ public String sayHello (Date now, String user) { //Get current hour of day Calendar calendar = Calendar.getInstance(); calendar.setTime (now); int hour = calendar.get (Calendar. HOUR OF DAY); //Get the right words to say hello String words = null; if (hour>=6 && hour<12) { words = "Good afternoon!"; }else if (hour>=12 && hour<19) { words = "Good afternoon!"; }else{ words = "Good night!"; } words "Hi, "+user+". "+words; return words; } } ``` ---- #### 問題 * 內聚性不佳 一個函式同時處理時間和問候語 --- ### 第二版重構 #### (抽取方法) ```java= /** *The Refactoring's hello-world program * @author fangang */ public class HelloWorld { /** * Say hello to everyone * @param now * @param user * @return the words what to say */ public String sayHello (Date now, String user) { int hour = getHour (now); return "Hi, "+user+". "+getSecondGreeting (hour); } /** * Get current hour of day. * @param now * @return current hour of day */ private int getHour (Date now) { Calendar calendar = Calendar.getInstance(); calendar.setTime (now); return calendar.get (Calendar.HOUR_OF+DAY); } /** * Get the second greeting. * @param hour * @return the second greeting */ private String getSecondGreeting (int hour) { if (hour>=6 && hour<12){ return "Good morning!"; }else if(hour>=12 && hour<19){ return "Good afternoon!"; }else{ return "Good night!"; } } } ``` ---- #### 問題 * 內聚性不佳 一個類別同時處理時間和問候語 --- ### 第三次重構 #### (抽取類別) ---- #### 處理時間類別 ```java= /** * A utility about time. * @author fangang */ public class DateUtil { /** * Get hour of day. * @param date * @return hour of day */ public int getHour(Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); return calendar.get(Calendar.HOUR_OF_DAY); } } ``` ---- #### 處理問候語類別 ```java= /** * All kinds of greeting. * @author fangang */ public class Greeting { /** * Get the first greeting. * @param user * @return Hi, {user}. */ public String getFirstGreeting (String user) { return "Hi, "+user+". "; } /* *Get the second greeting. * @param hour * @return if in the morning, say "Good morning!", * if in the afternoon, say "Good afternoon!", * else say "Good night!". */ public String getSecondGreeting (int hour) { if (hour>=6 && hour<12) { return "Good morning!"; }else if (hour>=12 && hour<19) { return "Good afternoon!"; }else{ return "Good night!"; } } ``` ---- #### HelloWorld本體 ```java= /* * The Refactoring's hello-world program * @author fangang */ public class HelloWorld { /** * Say hello to everyone * @param now * @param user * @return the words what to say */ public String sayHello (Date now, String user) { DateUtil dateUtil = new DateUtil(); int hour = dateUtil.getHour (now); Greeting greeting = new Greeting (); return greeting.getFirstGreeting (user) + greeting.getSecondGreeting (hour); } } ``` ---- #### 問題 * 擴展性不好 如果問候語要加半夜 ```java= public String getSecondGreeting (int hour) { if (hour>=6 && hour<12) { return "Good morning!"; }else if (hour>=12 && hour<19) { return "Good afternoon!"; }else{ return "Good night!"; } ``` --- ### 第四次重構 #### (抽取介面) ---- #### 問候語改成介面 ```java= /* * Greeting rules interface * @author fangang */ public interface GreetingRule { /** * @param hour * @return whether the rule is right */ public boolean isRight (int hour); /** * @return the greeting words */ public String getGreeting(); } ``` ---- #### 實作各問候語(morning為例) ```java= /* * The greeting in the morning * @author fangang */ public class MorningGreeting implements GreetingRule { /* (non-Javadoc) * @see org.refactoring.helloworld...GreetingRule#getGreeting () */ @Override public String getGreeting() { return "Good morning!"; } /* (non-Javadoc) * @see org.refactoring.helloworld...GreetingRule#isRight(int) */ @Override public boolean isRight(int hour) { if (hour>=6 && hour<12){ return true; } return false; } } ``` ---- #### 改寫Greeting.getSecondGreeting() ```java= /* * Get the second greeting. * @param hour * @return if in the morning, say "Good morning!", * if in the afternoon, say "Good afternoon!", * if in the evening, say "Good evening! * else, say "Good night!". */ public String getSecondGreeting (int hour) { for (GreetingRule greetingRule : greetingRules) { if(greetingRule.isRight (hour)) { return greetingRule.getGreeting(); } } throw new RuntimeException("Error when greeting! "); } ``` --- * 上述僅重構教學範例 * 實務上,簡單的需求不要「過度設計」 * 書籍附錄列出重構方法 《Refactoring: Improving the Design of Existing Code》 --- ## 等量變換 * 程式碼還是那些程式碼 * 執行結果還是那些結果 -- ### 但結構發生變化 * 可讀性 (函式、變數命名並加上註解) * 可維護 (重新組織函數到各自函式或物件中) * 易變更 (抽象、介面的提取) --- ## 重構方法的層次 1. 方法的重構 * 抽取方法 3. 物件的重構 * 抽取類別 * 抽取介面 5. 物件間的重構 6. 繼承體系間的重構 7. 組織資料的重構 8. 體系架構的重構 --- # CH3 # 小步快跑的開發模式 --- ## 大格局你傷不起 * 需重構文件 : 設計錯了誰負責 * 過去維護解決的問題都在細節設計 : 易忽略 * 結果新系統=舊系統翻版 : 已修復問題再現 * 數月的光陰,成功機率50% ---- ### 小步快跑 錯誤發現得越早,損失越小 ---- #### 個人小結 * 驗證範圍太大 -> 驗收時間越長 -> 風險越大 * 專案管理是要讓風險變小 --- ## 再次HelloWord重構 * 使用者資料透過Id從資料庫取得 * 問候語加入特殊節日(e.g. 新年、情人節) * 支援多語系(e.g.英文、中文) ---- ### 不同的人不同的重構 1. 抽取方法: * getHour() * getFirstGreeting() * getSecondGreeting() 2. 抽取類別: * GreetingToUser * GreetingAboutTime * DateUtil ---- ![](https://hackmd.io/_uploads/rk3ZcwlZn.jpg) --- # Ch4 # 保險索下的系統重構 --- 如果無法保證每一步都是正確的 小步快跑也不能解決風險 ---- 測試是保險索 確保每次重構是正確的 ---- 重構前先製作測試 1. 系統測試:透過文件,設計測試案例,往往是手工 2. 單元測試:自動化測試程式 ---- 重構測試層級 1. 函式 -> 類別 -> 介面、抽象 2. 底層、DAO、BUS、Web ---- ### 不要過早編寫自動化測試 隨者重構深入,原本針對函式的測式將失效而遺棄 ---- #### 難以自動化測試範例一:Web * 問:需啟動很多設備。 Request、Response、Session各自擁有許多變數難以Mock * 解:資料結構分WEB層及BUS層 WEB僅負責資料傳遞 BUS測試業務邏輯 ---- #### 難以自動化測試範例一:存取資料庫 * 問:無法控制資料庫狀態 * 解:資料結構分DAO層 整個Mock掉 驗證參數是否與預期一致 --- ## 加上測試的重構 CH3 helloWorld為例 1. 程式碼整理 2. 分成getFirstGreeting()和getSecondGreeting()和GetHour() 3. GreetingToUser和GreetingAboutTime 4. DataUtil和GreetingAboutTime.getGreeting()加入新需求 5. 資料庫取得資訊 ---- ### 自動測試 (範例使用JUnit) * 測試資料夾 Test/[原始資料夾路徑] * 測試目錄 Test.[原始namespace] * 測試類別 [原來類別名稱]Test * 測試函式 Test開頭 ---- ```java= /** * Test for {@link org.refactoring.helloWorld.resource.HelloWorld} * @author fangang */ public class HelloWorldTest { private HelloWorld helloWorld = null; /** * @throws java.lang.Exception */ @Before public void setup() throws Exception { helloWorld = new HelloWorld(); } /** * @throws java.lang.Exception */ @After public void tearDown() throws Exception { helloWorld = null; } ... ``` ---- #### 測試早安 ```java=22 /** * Test method for {@link org... HelloWorld#sayHello (java.util.Date, java.lang.String)}. */ @Test public void testSayHelloInTheMorning() { Date now = DateUtil.createDate(2014, 9, 7, 9, 23, 11); String user = "鲍曉妹"; String result = ""; result = helloWorld.sayHello (now, user); assertThat(result, is ("Hi, 鲍曉妹. Good morning!")); } ``` ---- #### 測試午安 ```java=33 /** * Test method for {@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}. */ @Test public void testSayHelloInTheAfternoon () { Date now = DateUtil.createDate (2014, 9, 7, 15, 7, 10); String user = "關二鍋"; String result = ""; result = helloWorld.sayHello (now, user); assertThat (result, is ("Hi, 關二鍋. Good afternoon!")); } ``` ---- #### 測試晚安 ```java=44 /** * Test method for {@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}. */ @Test public void testSayHelloAtNight () { Date now = DateUtil.createDate (2014, 9, 7, 21, 30, 10); String user = "IT工程師"; String result = ""; result = helloWorld. sayHello (now, user); assertThat (result, is ("Hi, IT工程師. Good night!")); } ``` --- #### 第一次重構 (程式碼整理) * 變數命名 * 分行 * 註解 直接測試通過 --- #### 第二次重構 (抽取方法) * getFirstGreeting() * getSecondGreeting() * GetHour() 直接測試通過 --- #### 第三次重構 (抽取類別) * GreetingToUser * GreetingAboutTime 直接測試通過 --- #### 第四次重構 (兩頂帽子) * DataUtil * GreetingAboutTime.getGreeting() * 加入新需求 (半夜好、新年好、情人節好) 測試失敗 testSayHelloAtNight() ---- **預期Good night!變成Good evening!** ```java /** * @return the greeting about time */ public String getGreeting () { DateUtil dateUtil = new DateUtil (date); int month = dateUtil.getMonth(); int day = dateUtil.getDay(); int hour = dateUtil.getHour (); if (month= 1 && day==1) return "Happy New Year! "; if (month==2 && day==14) return "Happy Valentine's Day! "; if (month=3 && day==8) return "Happy Women's Day! "; if (month==5 && day==1) return "Happy Labor Day! "; ... if (hour>=6 && hour<12) return "Good morning!"; if (hour==12) return "Good noon! "; if (hour>=12 && hour<19) return "Good afternoon! if (hour>=19 && hour<22) return "Good evening! "; return "Good night! "; } ``` ---- 新需求發生的時候 測試案例就需要更新 --- ### 第五次重構 ### (使用者及問候語透過資料庫引入) ---- #### 難以自動化測試 * Web -> 分層,針對BUS測試業務邏輯 * 資料庫 -> Mock不驗證,指定輸入回指定定輸出 ---- ![](https://hackmd.io/_uploads/H1Z-6DxZn.jpg) ---- #### Test ```java= private HelloWorld helloWorld = null; private GreetingToUserImpl greetingToUser = null; private GreetingAbout Time Impl greetingAboutTime = null; private final static List<GreetingRule> GREETING_RULES = getRules(); /** * @throws java.lang.Exception */ @Before public void setUp() throws Exception { helloWorld = new HelloWorld(); greetingToUser new GreetingToUserImpl(); greetingAboutTime = new GreetingAboutTimeImpl(); helloWorld.setGreetingToUser (greetingToUser); helloWorld.setGreetingAboutTime (greetingAboutTime); } /** * @throws java.lang.Exception */ @After public void tearDown() throws Exception { helloWorld = null; greetingToUser = null; greetingAboutTime = null; } ``` ---- ```java=27 /** * Test method for (@link org...HelloWorld#sayHello(java.util.Date, java.lang.String)}. */ @Test public void testSayHelloInTheMorning () { final Date now = DateUtil.createDate (2014, 9, 7, 9, 23, 11); final long userId = 2014090701; UserDao userDao = createMock (UserDao.class); GreetingRuleDao greetingRuleDao = createMock (GreetingRuleDao.class); expect (userDao.loadUser (userId)).andAnswer (new IAnswer<User>() { @Override public User answer() throws Throwable { User user = new User(); user.setUserId (userId); user.setName("鮑曉妹"); return user; } }); expect (greetingRuleDao.findAllGreetingRules ()) .andAnswer (new IAnswer<List<GreetingRule>>(){ @Override public List<GreetingRule> answer() throws Throwable { return GREETING_RULES; } }); replay (userDao); replay(greetingRuleDao); greetingToUser.setUserDao (userDao); greetingAboutTime.setGreetingRuleDao (greetingRule Dao); String result = helloWorld.sayHello(now, userId); Assert.assertEquals("Hi, 鮑曉妹. Good morning!", result); verify(userDao); verify(greetingRuleDao); } ``` --- ### DAO層Mock掉 ---- Mock Class ```java UserDao userDao = createMock (UserDao.class); GreetingRuleDao greetingRuleDao = createMock (GreetingRuleDao.class); ``` ---- 指定input回傳指定output ```java final long userId = 2014090701; expect (userDao.loadUser (userId)).andAnswer (new IAnswer<User>() { @Override public User answer() throws Throwable { User user = new User(); user.setUserId (userId); user.setName("鮑曉妹"); return user; } }); ``` ---- ![](https://hackmd.io/_uploads/rJn28QWZh.jpg) --- ### if 第五次重構沒分層的測試 ```java= Date now = DataUtil.getNow(); String user = SessionUtil.getCurrentUser (session); HelloWorld helloWorld = new HelloWorld(); String greeting = helloWorld.sayHello (now, user); request.setAttribute ("greeting", greeting); ``` * 不適合自動測試 * 應採用手動測試 * 重構之初通常不適合建立自動化測試 --- [>> Part2 實踐篇](https://hackmd.io/@voxar/S1efbNudDh)
{"metaMigratedAt":"2023-06-17T22:43:36.636Z","metaMigratedFrom":"YAML","title":"大話重構 Part1 基礎篇","breaks":true,"slideOptions":"{\"progress\":true,\"slideNumber\":true}","contributors":"[{\"id\":\"3877c546-06f0-440b-a33d-99383a2ceb45\",\"add\":28742,\"del\":12813}]","description":"Part1 基礎篇"}
    392 views
   Owned this note