TWJUG《重構專題》學習材料導讀

- notes refactoring twjug

為什麼想做重構練習呢?

因為發現了適合的材料啊!

又是新的一年啊!(2022)

每年的新開始,就會想要有一種 reset 的感覺。先不要追逐最新最潮的技術,回頭盤點一些其實該牢牢握在手上的小工具。(詸之聲:這聽起來適合在年底做吧!?)

至於,要選擇什麼樣的知識來討論這讓我苦惱了很久,最終是選擇了「重構」作為主要想面對的主題。因為,過去幾年與一些試著轉職的網友們聊過,也看過了一些人在培訓機構實作的作品。而他們的作品,不少是公開放在網路上的,這些東西都可以成為練習重構的材料。儘管,在少數幾個專案中,並沒有辦法涵蓋重構名錄上的各種問題,但常見的 bad smell 與新手常見實作型式,反倒是更適合用來討論的例子。

我們選用的例子會是在 GitHub 用 資策會 能搜尋到的 Java Web 專案,畢竟這是培訓的大宗。在幾年前開始接觸到這類的「作品」,去年和網友討論他的專題內容時又再看了一下程式,突然發現同樣的結構,同樣的初學者「樣式」反覆出現在不同屆的專題原始碼內。突然意識到,這是一種 legacy code 的世代複製。這是當下能力所及的實作,若是在入行幾年代有所成長,再回頭看看原來的專題,是不是會有不同想法呢?

所以,我們可以想像著在有了經驗後,怎麼去給「當時的自己」改善的建議。用這個出發點,來用網路上公開的專案進行「重構」的練習。我們得強調,是取得材料然後練習重構所需的「觀點」與「技法」。在過程中,我們會試著用不同的「觀點」來點出問題,並練習可以把程式碼變得更好的方法。

學習材料

透過 GitHub 的搜尋功能,加上 Java 語言的過濾條件。我們可以找出需要的材料:

由於這是個 Eclipse 專案,我們配合它使用 Eclipse 匯入查看:

你可以看到專案中有相當多的 package,並且都是呈對的:

這是典型的資策會專案的組織型式。通常 xxx.yyy package 會是由同一個人實作,而 xxx.yyy 內的 Controller 會是一隻 Servlet 與 URL Pattern 的搭配,多數使用傳統的 Deployment Descriptor (也就是 web.xml) 註冊,在少量的 Servlet 內會看以 @WebServlet 註冊的型式。

依先前訪談的經驗,一個小組會有若干人組成。看似 最沒問題的,大概會是前端頁面的部分。因為,這專題的主要目標是在展現身為 Java Web 應用程式的初級開發者能力所及的範圍,前端部分多為傳統的非前後端分離的實作方式,搭配 jQuery 進行 AJAX 開發,只有特地為了 AJAX 準備的 Request 部分能沾上前後端分離的邊角。剩下的部分,就依頁面或是「模組」來均攤到各組員身上,而這裡的模組就是我們前面看到的一組 xxx.yyy package。

這樣的 Project Layout 只有方便「圈地」,沒有太大實質的意義。因為,多半是用在寫履歷時,描述合作專案中哪些「模組」是由自身實作的,而不用零散地指出哪些 class 是由自身實作的。可是,這樣的圈地並不會起太大的效用,因為如唐伯虎點秋香提到的:

紅花需要有綠葉陪襯

一眼望去都是綠葉是無法吸引人的注意力的。

我會試著整理它,也許會有一些評批,要針對的並不是實作者,因為那就是培訓單位在有限的時間內,配合整個班級能達到的進度去實作出來的「實力公約數」。去 GitHub 上多查不同的組別,有些也是有進入到 Spring Boot 與 JPA 時代的,但多數都還在很初級的階段。在這個初級的階段,反而是在社群推廣中能協助的,畢竟,我們樂於作為新手的墊腳石,即便新手不知道如何求助於人。

目標與限制

我們的目標是利用這些程式,由他的現狀透過重構設計模式脫離初級開發者後的經驗來改善它。大致上,我會先做個簡單地限制:

先不要進入框架的時代

例如:

儘可能使用標準函式庫提供的工具,但輔助一些 Open Source 的 Library 為主。

除此之外,我們會大量鎖定在各種該「提示」的 Bad Smell 上,特別是新手容易寫出的 Duplicated Code,並且特別容易在各種型式上重複:

重複會造成諸多的維護問題,他必需被適當地收納。而學習這些「收納」的技巧,也不太需要經年累月的累積才行。單純地,看過了、知道了、用上了、學到了的流程罷了。

PS. 其他太微小的 Coding Style 問題並不會有主題的型式去討論,多數情況會去忽略它或是以碎唸的型式出現在文章或影片中,那些是開發者自己需要養成的習慣。

基本問題列管

在這先粗略地列一些顯而易見的問題,但這並不包含全部的問題。可以隨著有興趣一起參與討論的朋友,自行列出可以提出「改善建議」的項目來追加。

Controller (servlet)

  1. 重複的 encoding 設定,這其實可以是 Filter 去處理的
public void doPost(HttpServletRequest req, HttpServletResponse res)
                              throws ServletException, IOException {
  req.setCharacterEncoding("UTF-8");
  res.setContentType("text/html; charset=UTF-8");
  // ...
}
  1. 使用者認證採用設計不良,採「明碼」存放在資料庫內
  2. 肥大的商業邏輯都擠在 Servlet 內,取其中一隻 Servlet 簡化後的結構如下:
public class XXXYYY extends HttpServlet {

	public void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
		doPost(req, res);
	}

	public void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {

		req.setCharacterEncoding("UTF-8");
		String action = req.getParameter("action");

		if ("getAllMemMat_record_Display".equals(action)) { // 來自select_page.jsp的請求
		}

		if ("insert".equals(action)) { // 來自addMat_record.jsp的請求
		}

		if ("delete".equals(action)) { // 來自listAllMem.jsp
		}

		if ("ajaxInsert".equals(action)) { // 來自addMat_record.jsp的請求
		}

		if ("ajaxDelete".equals(action)) { // 來自listAllMem.jsp
		}

	}
}

相似的結構,重複了整個專案。這每一個 condition 的 block 內都有著不少行數的實作,夾雜了 Business Logic 與 DAO 的操作。

  1. (承 3.) 由於缺乏適當的分層,讓程式看起來不好維護,閱讀時的心裡負擔比較大。至少能切出三層來,讓 Business Logic 放到 Service 內去實作,Controller 就純粹負責 Persentation (收參數 -> 委派 service -> 輸出結果):
controller -> service -> dao

更進一步,若能實作一個小巧的 Dispatcher 去收納「海量」Controller 的 Routing 問題,那會讓程式更加簡潔。

Data Access Object

  1. 無意義地抽象化,特別是每一個 Dao 都有自己的 interface,分別給 JDBC 實作與 JNDI 實作使用。除了 Connection 來源不同之外,沒必要維護 2 份一樣的 Dao。還會其中一個修改了,另 一個忘了修改。應該抽象化的對象是 ConnectionFactory。(可以由 YouTube 影片參考比較的結果)

  2. 重複而瑣碎的 JDBC API

	@Override
	public void insertWithLespic(LessonVO lessonVO, List<LespicVO> list) {
		Connection con = null;
		PreparedStatement pstmt = null;

		try {

			try {
				Class.forName(driver);

				con = DriverManager.getConnection(url, userid, passwd);

				// 新增課程
				String cols[] = { "LES_NO" };
				pstmt = con.prepareStatement(INSERT_STMT, cols);
				pstmt.setString(1, lessonVO.getDs_no());
				pstmt.setString(2, lessonVO.getDs_name());
				pstmt.setString(3, lessonVO.getLes_name());
				pstmt.setString(4, lessonVO.getLes_info());
				pstmt.setString(5, lessonVO.getCoach());
				pstmt.setInt(6, lessonVO.getCost());
				pstmt.setInt(7, lessonVO.getDays());
				pstmt.setDate(8, lessonVO.getSignup_startdate());
				pstmt.setDate(9, lessonVO.getSignup_enddate());
				pstmt.setString(10, lessonVO.getLes_state());
				pstmt.setInt(11, lessonVO.getLes_max());
				pstmt.setInt(12, lessonVO.getLes_limit());
				pstmt.setDate(13, lessonVO.getLes_startdate());
				pstmt.setDate(14, lessonVO.getLes_enddate());
				pstmt.setString(15, lessonVO.getLess_state());
				pstmt.executeUpdate();

				// 擷取對應的自增值主鍵
				String next_les_no = null;
				ResultSet rs = pstmt.getGeneratedKeys();
				if (rs.next()) {
					next_les_no = rs.getString(1);
					System.out.println("自增主鍵值=" + next_les_no + "(剛新增成功的課程編號)");
				} else {
					System.out.println("未取得自增值主鍵");
				}
				rs.close();

				// 在同時新增課程照片
				LespicJDBCDAO dao = new LespicJDBCDAO();
				System.out.println("list.size()-A=" + list.size());
				for (LespicVO lespics : list) {
					lespics.setLes_no(next_les_no);
					dao.insert2(lespics, con);
				}

				// 設定於pstmt.excuteUpdate()之後
				con.commit();
				con.setAutoCommit(true);
				System.out.println("list.size()-B=" + list.size());
				System.out.println("新增課程編號" + next_les_no + "時,共有" + list.size() + "張照片同時被新增");

			} catch (SQLException se) {
				if (con != null) {
					try {
						// 3●設定於當有exception發生時之catch區塊內
						System.err.print("Transaction is being ");
						System.err.println("rolled back-由-dept");
						con.rollback();
					} catch (SQLException excep) {
						throw new RuntimeException("rollback error occured. " + excep.getMessage());
					}
				}
				throw new RuntimeException("A database error occured. " + se.getMessage());
				// Clean up JDBC resources
			}
		} catch (ClassNotFoundException e) {
			throw new RuntimeException("Couldn't load database driver. " + e.getMessage());
		} finally {
			if (pstmt != null) {
				try {
					pstmt.close();
				} catch (SQLException se) {
					se.printStackTrace(System.err);
				}
			}
			if (con != null) {
				try {
					con.close();
				} catch (Exception e) {
					e.printStackTrace(System.err);
				}
			}
		}
	}
	
  1. (承 2.) 對於 Connection、Statement 與 ResultSet 的管理,需要更加簡化。而不是各別手擼 try-catch-finally
  2. (承 2.) 對於資料狀態的轉移,必需要更加容易。而非人工去呼叫 set 與 get。

結語

上述列出來的問題,是一些顯而易見的問題。同時,也可能是「拿著作品去面試」會被挑出來討論的問題。希望這個系列,能告訴「小時候不懂事」的自己,用成熟的方法去處理它們。

後續,我們會在 TWJUG 線上聚會的時間,逐步抓一點出來討論研究。