重構控制器


要使用 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 是其實作類別之一。

SpringInitializergetServletMappings 中,可以看到預設 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 方法,並呼叫了 DefaultServletHandlerConfigurerenable 方法,這會註冊一個 DefaultServletHttpRequestHandler,在 DispatcherServlet 無法找到對應的處理器來處理請求的話,就會交給 DefaultServletHttpRequestHandler,而 DefaultServletHttpRequestHandler 會將請求轉給 Servlet 容器的預設 Servlet(也就是容器本身提供靜態資源的 Servlet)。

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

package cc.openhome.web;

import org.springframework.context.annotation.Configuration;

@Configuration
public class RootConfig {
}

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

使用 Spring MVC 的話,可以在一個類別中集中管理相關的方法,例如將 RegisterVerifyForgotResetPassword 中的程式碼重構至 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);
    }    
}

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

首先,必須標註 @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,因此改從 HttpServletRequestgetServletContext 來取得 ServletContext

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

倒是將 IndexUser 重構至 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 找到以上的範例專案。