要使用 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 找到以上的範例專案。