Facade

December 30, 2021

在我的《Servlet&JSP 技術手冊 - 邁向 Spring Boot》中有個微網誌綜合練習,是從無到有,從 Servlet 開始建立一個微網誌應用程式,從中觀察一個應用程式的演化。

散落各地的輸入輸出

例如其中第 5 章寫到…會員註冊時,會透過檢查使用者資料夾是否存在,確定新註冊的使用者名稱可否存在,若尚未存在就可建立使用者資料夾與相關檔案,這些程式碼是位於 Register 這個 Servlet 中:

public class Register extends HttpServlet {
    ...

    protected void doPost(
            HttpServletRequest request, HttpServletResponse response)
                 throws ServletException, IOException {
        ...
        
        String path;
        if(errors.isEmpty()) {
            path = SUCCESS_PATH;
            tryCreateUser(email, username, password);
        } else {
            path = ERROR_PATH;
            request.setAttribute("errors", errors);
        }

        request.getRequestDispatcher(path).forward(request, response);
    }
    
    ...

    private void tryCreateUser(
           String email, String username, String password) throws IOException {
        var userhome = Paths.get(USERS, username);
        if(Files.notExists(userhome)) {
            createUser(userhome, email, password);
        }
    }


    private void createUser(Path userhome, String email, String password)
                     throws IOException {
        Files.createDirectories(userhome);
        
        var salt = ThreadLocalRandom.current().nextInt();
        var encrypt = String.valueOf(salt + password.hashCode());
        
        var profile = userhome.resolve("profile");
        try(var writer = Files.newBufferedWriter(profile)) {
            writer.write(String.format("%s\t%s\t%d", email, encrypt, salt));
        }
    }
}

登入檢查時,透過檢查使用者資料夾是否存在,並讀取使用者資料以確認登入密碼是否正確,這是實作在 Login 這個 Servlet 中:

public class Login extends HttpServlet {
    ...
    
    protected void doPost(
	        HttpServletRequest request, HttpServletResponse response) 
	                        throws ServletException, IOException {
        ...

	    String page;
	    if(isInputted(username, password) && login(username, password)) {
	        if(request.getSession(false) != null) {
	            request.changeSessionId();
	        }
	        request.getSession().setAttribute("login", username);
	        page = SUCCESS_PATH;
	    } else {
	        page = ERROR_PATH;
	    }
	    
	    response.sendRedirect(page);
    }
	
    ...

    private boolean login(String username, String password) 
                          throws IOException {
		var userhome = Paths.get(USERS, username);
		return Files.exists(userhome) && isCorrectPassword(password, userhome);
    }

    private boolean isCorrectPassword(
            String password, Path userhome) throws IOException {
        var profile = userhome.resolve("profile");
        try(var reader = Files.newBufferedReader(profile)) {
            var data = reader.readLine().split("\t");
            var encrypt = Integer.parseInt(data[1]);
            var salt = Integer.parseInt(data[2]);
            return password.hashCode() + salt == encrypt;
        }
    } 
}

訊息的新增、訊息的刪除、訊息的取得等,也都是使用檔案輸入輸出,散落在各個 Servlet 中,發現了什麼?從會員註冊開始、會員登入、訊息新增、讀取、顯示等,相關程式碼都與檔案讀寫有關,這些程式碼散落在各個 Servlet,造成維護上的麻煩,何謂維護上的麻煩?如果將來會員相關資訊不再以檔案儲存,而要改為資料庫儲存,那要修改幾個 Servlet?會員訊息處理相關程式碼,繼續散落在各個物件,會造成職責分散的問題,將來會員訊息處理的相關程式碼,會越來越難以維護。

建立 UserService

為了解決以上問題,可以將以上提到的相關程式碼,集中在一個 UserService 類別中,會員註冊、會員登入、訊息新增、讀取、顯示等需求,都由 UserService 類別提供:

public class UserService {
    private final String USERS;

    public UserService(String USERS) {
        this.USERS = USERS;
    }

    public void tryCreateUser(
           String email, String username, String password) throws IOException {
        var userhome = Paths.get(USERS, username);
        if(Files.notExists(userhome)) {
            createUser(userhome, email, password);
        }
    }

    private void createUser(Path userhome, String email, String password)
                      throws IOException {
        Files.createDirectories(userhome);
         
        var salt = ThreadLocalRandom.current().nextInt();
        var encrypt = String.valueOf(salt + password.hashCode());
         
        var profile = userhome.resolve("profile");
        try(var writer = Files.newBufferedWriter(profile)) {
            writer.write(String.format("%s\t%s\t%d", email, encrypt, salt));
        }
    }

    public boolean login(String username, String password) throws IOException {
        var userhome = Paths.get(USERS, username);
        return Files.exists(userhome) && 
                   isCorrectPassword(password, userhome);
    }

    private boolean isCorrectPassword(
            String password, Path userhome) throws IOException {
        var profile = userhome.resolve("profile");
        try(var reader = Files.newBufferedReader(profile)) {
            var data = reader.readLine().split("\t");
            var encrypt = Integer.parseInt(data[1]);
            var salt = Integer.parseInt(data[2]);
            return password.hashCode() + salt == encrypt;
        }
    } 

    ...
}

然後,各個 Servlet,就可以將使用者建立等相關職責,委託給 UserService。例如 Register

public class Register extends HttpServlet {
    private UserService userService;
    ...

    protected void doPost(HttpServletRequest request,
                              HttpServletResponse response)
                    throws ServletException, IOException {
        ...
        String path;
        if(errors.isEmpty()) {
            path = SUCCESS_PATH;
            userService.tryCreateUser(email, username, password);
        } else {
            path = ERROR_PATH;
            request.setAttribute("errors", errors);
        }

        request.getRequestDispatcher(path).forward(request, response);
    }
}

或者是 Login

public class Login extends HttpServlet {
    private UserService userService;
    ...

    protected void doPost(HttpServletRequest request, 
	                      HttpServletResponse response) 
	                        throws ServletException, IOException {
	    ...        
        String page;
        if(isInputted(username, password) &&
               userService.login(username, password)) {
            if(request.getSession(false) != null) {
                request.changeSessionId();
            }
            request.getSession().setAttribute("login", username);
            page = SUCCESS_PATH;
        } else {
            page = ERROR_PATH;
        }
        
        response.sendRedirect(page);
    }
}

各個 Servlet 也都是委託 UserService 來完成使用者相關任務,Servlet 閱讀起來清爽多了,不用被迫看一些輸入輸出相關程式碼,而且將來要改存儲方案,只要修改 UserService 就可以了,Servlet 不用修改。

UserService 這類角色,將與 Servlet 不相關的 API 封裝起來,提供一個 Servlet 關心的服務介面,Gof 中稱為 Facade。

上面這個例子在書裡頭,有會進一步重構,因為 UserService 還負責了訊息的新增、訊息的刪除、訊息的取得等,後來重構出 AccountDAOMessageDAO,然而在 UserService 公開行為不變的情況下,Servlet 並不用有對應的修改。

從另一個角度來看。或許你一開始就有 AccountDAOMessageDAO,若直接在 Servlet 中使用這兩個 DAO,就會表示與它們產生了相依,如果你在 Servlet 中用了更多其他職責的物件,就會與更多的物件產生相依,這時就要檢視一下 Servlet 了,是不是建立 Facade 角色的物件,將 Servlet 實際要的行為,定義為 Facade 的公開行為,將相依的物件封裝到其中,看看是不是好維護一些呢?

不就是單純的封裝?

你會說,這不就是單純只是封裝的概念嗎?有必要特別取個 Facade,讓它看來高上大嗎?當然,許多模式基本上都是以封裝為出發點,單就這點而言,Facade 確實不值一提!

只不過,你做得到嗎?或者問一下,你有常常對程式碼做這類檢視、重構的動作嗎?如果你看到一個兩萬多行的 Servlet,你會去重構它嗎?

喔!那種兩萬多行的 Servlet 是真實存在,而且我親眼目賭過的!會有這種東西存在,原因是很多,有時純綷就是開發者懶,每當想要有什麼功能,就往某個類別塞,久而久之它就長大了,然後隨著維護者的世代交接,逐漸成了歷史共業之類的。

Facade 的概念是很簡單沒錯,就是單純的關注分離,問題在於有沒有想去實行,或許 Facade 這個模式的存在,比較像是童子軍法則中「離開營地前,讓營地比使用前更加乾淨」的概念,是想要開發者時時拿這個名稱出來,檢討一下自己是否對程式碼作檢視、重構的動作。