重構控制器


要使用 Spring MVC,可以在 bulid.gradle 中加入:

compile 'org.springframework:spring-context:5.1.2.RELEASE' 
compile 'org.springframework:spring-webmvc:5.1.2.RELEASE' 

之前使用 Spring 核心時看過,必須有相關設定檔,以及讀取設定、維護 Bean 的核心元件存在,而為了要能將請求都交由 Spring MVC 來管理,也必須有個角色,可以接受全部的請求,判斷由哪個元件來處理,在設計上,這樣的角色稱之為前端控制器(Front Controller),而 Spring MVC 中擔任此角色的是 DispatcherServlet,只不過,DispatcherServlet 處理的事情更多。

想要設置 DispatcherServlet,現在的 Spring MVC 版本建議在應用程式初始化之前時進行,這可以繼承 AbstractAnnotationConfigDispatcherServletInitializer 來達成:

package cc.openhome.web;

import org.springframework.web.servlet.support.*;

public class SpringInitializer
     extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class<?>[] {WebConfig.class};
    }

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[] {RootConfig.class};
    }

    @Override
    protected String[] getServletMappings() {
        return new String[] {"/"};
    }
}

AbstractAnnotationConfigDispatcherServletInitializer 會在應用程式初始化時,動態進行 Servlet 的建立、設定與註冊,原理在於 Spring 運用了 Servlet 3.x 之後的 ServletContainerInitializer 機制,Web 應用程式中只要有 Spring MVC 的 JAR 檔案,就會自動尋找 WebApplicationInitializer 的實作類別進行初始化,而 AbstractAnnotationConfigDispatcherServletInitializer 是其實作類別之一。

在 SpringInitializer 的 getServletMappings 中,可以看到預設 Servlet 的 URI 模式設定,當找不到適合的 URI 模式對應時,就會使用 DispatcherServlet 來處理。

Spring MVC 建議將 Web 層次的元件與其他元件分開設定,Web 層次的元件設定,可以實作 getServletConfigClasses 方法,這邊指定了 WebConfig 為設定檔,至於其他元件,可以實作 getRootConfigClasses,這邊指定了 RootConfig 為設定檔。

WebConfig 的內容設定如下:

package cc.openhome.web;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
@ComponentScan("cc.openhome.controller")
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }
}

@Configuration 與 @ComponentScan 之前都談過了,@ComponentScan 指定了掃描 cc.openhome.controller 套件,稍後會看到該套件中的類別會設定相關標註來實作控制器,@ComponentScan 會自動掃描並建立指定套件中的相關元件;另外,為了能使用 Spring MVC 的功能,加註了 @EnableWebMVC。

Web 組態檔必須實作 WebMvcConfigurer,這個介面要實作的方法其實蠻多的,不過因為 Java 8 之後,介面可以有預設方法,因此只要實作感興趣的方法就可以了,在過去的話,為了避免實作上的麻煩,則是繼承 WebMvcConfigurerAdapter。

因為 DispatcherServlet 設定為預設 Servlet,當找不到適合的 URI 模式對應時,就會使用 DispatcherServlet 來處理,然而這卻包括了網頁上連結的圖片、CSS 等靜態資源請求。

為了能正常回應靜態資源請求,在上頭實作了 configureDefaultServletHandling 方法,並呼叫了 DefaultServletHandlerConfigurer 的 enable 方法,這會註冊一個 DefaultServletHttpRequestHandler,在 DispatcherServlet 無法找到對應的處理器來處理請求的話,就會交給 DefaultServletHttpRequestHandler,而 DefaultServletHttpRequestHandler 會將請求轉給 Servlet 容器的預設 Servlet(也就是容器本身提供靜態資源的 Servlet)。

由於目前僅打算運用 Spring MVC 的最小集合,暫時不會使用 Spring 來管理 UserService、AccountDAOJdbcImpl、MessageDAOJdbcImpl、DataSource 等元件之組合,因此 RootConfig 內容先保持為空:

package cc.openhome.web;

import org.springframework.context.annotation.Configuration;

@Configuration
public class RootConfig {
}

接下來可以重構控制器了,目前的微網誌應用程式,在 cc.openhome.controller 有 11 個 Servlet,實際上,某些 Servlet 的職責彼此間有相關性,例如,Register、Verify、Forgot、ResetPassword,都是與帳號管理有關,而 Member、NewMessage、DelMessage 都是登入為會員身份才能使用等。

使用 Spring MVC 的話,可以在一個類別中集中管理相關的方法,例如將 Register、Verify、Forgot、 ResetPassword 中的程式碼重構至 AccountController:

package cc.openhome.controller;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import cc.openhome.model.Account;
import cc.openhome.model.EmailService;
import cc.openhome.model.UserService;

@Controller
public class AccountController {
    @Value("/gossip")
    private String REDIRECT_INDEX_PATH;

    @Value("/WEB-INF/jsp/register_success.jsp")
    private String REGISTER_SUCCESS_PATH;

    @Value("/WEB-INF/jsp/register.jsp")
    private String REGISTER_FORM_PATH;

    @Value("/WEB-INF/jsp/verify.jsp")
    private String VERIFY_PATH;

    @Value("/WEB-INF/jsp/forgot.jsp")
    private String FORGOT_PATH;

    @Value("/WEB-INF/jsp/reset_password.jsp")
    private String RESET_PASSWORD_FORM_PATH;

    @Value("/WEB-INF/jsp/reset_success.jsp")
    private String RESET_PASSWORD_SUCCESS_PATH;

    private final Pattern emailRegex = Pattern.compile(
            "^[_a-z0-9-]+([.][_a-z0-9-]+)*@[a-z0-9-]+([.][a-z0-9-]+)*$");
    private final Pattern passwdRegex = Pattern.compile("^\\w{8,16}$");
    private final Pattern usernameRegex = Pattern.compile("^\\w{1,16}$");

    @GetMapping("register")
    public void registerForm(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        request.getRequestDispatcher(REGISTER_FORM_PATH)
               .forward(request, response);
    }

    @PostMapping("register")
    public void register(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String email = request.getParameter("email");
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        String password2 = request.getParameter("password2");

        List<String> errors = new ArrayList<>(); 
        if (!validateEmail(email)) {
            errors.add("未填寫郵件或格式不正確");
        }
        if(!validateUsername(username)) {
            errors.add("未填寫使用者名稱或格式不正確");
        }
        if (!validatePassword(password, password2)) {
            errors.add("請確認密碼符合格式並再度確認密碼");
        }

        String path;
        if(errors.isEmpty()) {
            path = REGISTER_SUCCESS_PATH;

            UserService userService = (UserService) request.getServletContext().getAttribute("userService");
            EmailService emailService = (EmailService) request.getServletContext().getAttribute("emailService");
            Optional<Account> optionalAcct = userService.tryCreateUser(email, username, password);
            if(optionalAcct.isPresent()) {
                emailService.validationLink(optionalAcct.get());
            } else {
                emailService.failedRegistration(username, email);
            }
        } else {
            path = REGISTER_FORM_PATH;
            request.setAttribute("errors", errors);
        }

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

    @GetMapping("verify")
    public void verify(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String email = request.getParameter("email");
        String token = request.getParameter("token");
        UserService userService = (UserService) request.getServletContext().getAttribute("userService");
        request.setAttribute("acct", userService.verify(email, token));
        request.getRequestDispatcher(VERIFY_PATH).forward(request, response);
    }

    @PostMapping("forgot")
    public void forgot(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        String name = request.getParameter("name");
        String email = request.getParameter("email");

        UserService userService =
                (UserService) request.getServletContext().getAttribute("userService");
        Optional<Account> optionalAcct = userService.accountByNameEmail(name, email);

        if(optionalAcct.isPresent()) {
            EmailService emailService = 
                    (EmailService) request.getServletContext().getAttribute("emailService");
            emailService.passwordResetLink(optionalAcct.get());
        }

        request.setAttribute("email", email);
        request.getRequestDispatcher(FORGOT_PATH)
               .forward(request, response);
    }

    @GetMapping("reset_password")
    public void resetPasswordForm(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String name = request.getParameter("name");
        String email = request.getParameter("email");
        String token = request.getParameter("token");

        UserService userService = (UserService) request.getServletContext().getAttribute("userService");
        Optional<Account> optionalAcct = userService.accountByNameEmail(name, email);

        if(optionalAcct.isPresent()) {
            Account acct = optionalAcct.get();
            if(acct.getPassword().equals(token)) {
                request.setAttribute("acct", acct);
                request.getSession().setAttribute("token", token);
                request.getRequestDispatcher(RESET_PASSWORD_FORM_PATH)
                       .forward(request, response);
                return;
            }
        }
        response.sendRedirect(REDIRECT_INDEX_PATH);
    }

    @PostMapping("reset_password")
    public void resetPassword(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String token = request.getParameter("token");
        String storedToken = (String) request.getSession().getAttribute("token");
        if(storedToken == null || !storedToken.equals(token)) {
            response.sendRedirect("/gossip");
            return;
        }

        String name = request.getParameter("name");
        String email = request.getParameter("email");
        String password = request.getParameter("password");
        String password2 = request.getParameter("password2");

        UserService userService = (UserService) request.getServletContext().getAttribute("userService");

        if (!validatePassword(password, password2)) {
            Optional<Account> optionalAcct = userService.accountByNameEmail(name, email);
            request.setAttribute("errors", Arrays.asList("請確認密碼符合格式並再度確認密碼"));
            request.setAttribute("acct", optionalAcct.get());

            request.getRequestDispatcher(RESET_PASSWORD_FORM_PATH)
                   .forward(request, response);
        } else {
            userService.resetPassword(name, password);
            request.getRequestDispatcher(RESET_PASSWORD_SUCCESS_PATH)
                   .forward(request, response);
        }    
    }

    private boolean validateEmail(String email) {
        return email != null && emailRegex.matcher(email).find();
    }

    private boolean validateUsername(String username) {
        return username != null && usernameRegex.matcher(username).find();
    }


    private boolean validatePassword(String password, String password2) {
        return password != null && 
               passwdRegex.matcher(password).find() && 
               password.equals(password2);
    }    
}

絕大多數的程式碼,都是從既有的 Register、Verify、Forgot、ResetPassword 重構而來,這邊說明一下有修改的部份。

首先,必須標註 @Controller 表示這是個控制器,而控制器不用實作任何介面或繼承任何類別,因此這不再是個 Servlet 了,因而無法標註 Servlet 初始參數,這邊改用 @Value 與值域來取代,目前暫時寫死了字串值,然而從之前的文件你知道,@Value 可以使用 Place Holder 從 .properties 取得值,之後會進行這類修改。

趁著重構控制器的機會,值域名稱也做了一些修改,以更彰顯各個值域的作用

@GetMapping、@PostMapping 可用來標註,哪個 URI 請求模式可以呼叫哪個方法,每個 HTTP 方法,都有對應的 @XXXMapping 標註,在過去可透過 @RequestMapping(value = "uri_pattern", method = RequestMethod.GET) 方式來設定,因為 @RequestMapping 預設是允許全部的 HTTP 方法,@GetMapping 等是後來新增的標註,用以簡化設定。

由於這個類別不是個 Servlet 了,無法直接呼叫 getServletContext,因此改從 HttpServletRequest 的 getServletContext 來取得 ServletContext。

依照類似的做法,你可以將 Member、NewMessage、DelMessage 重構至 MemberController,將 Login、Logout 重構至 AccessController,這邊就不列出 MemberController、AccessController 的程式碼了,可以自行參考範例檔案的內容。

倒是將 Index、User 重構至 DisplayController 時要注意一下:

package cc.openhome.controller;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import cc.openhome.model.Message;
import cc.openhome.model.UserService;


@Controller
public class DisplayController {
    @Value("/WEB-INF/jsp/index.jsp")
    private String INDEX_PATH;

    @Value("/WEB-INF/jsp/user.jsp")
    private String USER_PATH;

    @GetMapping("/")
    public void index(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {

        UserService userService = (UserService) request.getServletContext().getAttribute("userService");
        List<Message> newest = userService.newestMessages(10);
        request.setAttribute("newest", newest);

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

    @GetMapping("user/*")
    public void user(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {

        String username = getUsername(request);
        UserService userService = (UserService) request.getServletContext().getAttribute("userService");

        request.setAttribute("username", username);
        if(userService.userExisted(username)) {
            List<Message> messages = userService.messages(username);
            request.setAttribute("messages", messages);
        } else {
            request.setAttribute("errors", Arrays.asList(String.format("%s 還沒有發表訊息", username)));
        }

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

    private String getUsername(HttpServletRequest request) {
        return request.getRequestURI().replace("/gossip/user/", "");
    }
}

原本的 User,URI 模式是 "/user/*",在 @GetMapping 時可以改設定為 "user/*",然而,這會使得 request.getPathInfo 的傳回 null,因此在這邊暫時改用 request.getRequestURI 傳回完整的請求URI,再從中擷取使用者名稱,不過,其實還有更方便的方式取得這項資訊,之後會談到。

你可以在 gossip 找到以上的範例專案。