在實作 MVC 架構中的控制器時,是否感覺到一些相似的程式邏輯,像是取得請求參數、請求轉發、重新導向等,例如,每一次內部轉發時,總是寫著相同的程式碼:
request.getRequestDispatcher(PATH).forward(request, response);
這是一種重複嗎?是的!而且由於採 MVC 架構,如果不是為了重新導向,實際上某些控制器中,並非真正需要 HttpServletResponse
實例,只是為了滿足 RequestDispatcher
的 forward
必須有 HttpServletResponse
實例罷了,而且在路徑設定上,因為微網誌應用程式的 JSP 都是放在 /WEB-INF/jsp 中,這部份也形成了重複的資訊。
在 Spring MVC 中,前端控制器 DispatcherServlet
可以處理的事情很多,例如這些重複的邏輯與資訊就可以交由它來處理,令實作控制器時更為簡化,更能彰顯程式意圖。
首先,來處理一下內部轉發的重複邏輯與資訊,這可以先在 WebConfig
中添加一些設定:
package cc.openhome.web;
...略
@Configuration
@EnableWebMvc
@PropertySource("classpath:path.properties")
@ComponentScan("cc.openhome.controller")
public class WebConfig implements WebMvcConfigurer {
...略
@Bean
public ViewResolver viewResolver() {
InternalResourceViewResolver resolver =
new InternalResourceViewResolver();
resolver.setPrefix("/WEB-INF/jsp/");
resolver.setSuffix(".jsp");
resolver.setExposeContextBeansAsAttributes(true);
return resolver;
}
}
在這邊設定了 ViewResolver
,它負責解析Spring的視圖(View)相關元件,根據不同的實作類別,可以替換不同的頁面呈現技術,這邊的 InternalResourceViewResolver
負責處理內部轉發,可以設定前置與後置字串,這會與控制器中方法的傳回字串結合,例如,若控制器傳回 "member"
,實際上就會轉發至 "/WEB-INF/jsp/member.jsp"
,稍後也會看到,如果在控制器的方法中,透過注入的 Model
加入相關屬性,這些屬性會成為 JSP 頁面上可以存取的屬性。
配合 InternalResourceViewResolve
的設定,path.properties 中的路徑資訊可以調整為:
path.url.member=/member
path.url.index=/
path.view.register_success=register_success
path.view.register_form=register
path.view.verify=verify
path.view.forgot=forgot
path.view.reset_password_form=reset_password
path.view.reset_password_success=reset_success
path.view.index=index
path.view.user=user
path.view.member=member
接著先來簡化 AccessController
:
package cc.openhome.controller;
...略
@Controller
public class AccessController {
@Value("#{'redirect:' + '${path.url.member}'}")
private String REDIRECT_MEMBER_PATH;
@Value("#{'redirect:' + '${path.url.index}'}")
private String REDIRECT_INDEX_PATH;
@Value("${path.view.index}")
private String INDEX_PATH;
@Autowired
private UserService userService;
@PostMapping("login")
public String login(
@RequestParam String username,
@RequestParam String password,
HttpServletRequest request) {
Optional<String> optionalPasswd = userService.encryptedPassword(username, password);
try {
request.login(username, optionalPasswd.get());
request.getSession().setAttribute("login", username);
return REDIRECT_MEMBER_PATH;
} catch(NoSuchElementException | ServletException e) {
request.setAttribute("errors", Arrays.asList("登入失敗"));
List<Message> newest = userService.newestMessages(10);
request.setAttribute("newest", newest);
return INDEX_PATH;
}
}
@GetMapping("logout")
public String logout(HttpServletRequest request) throws ServletException {
request.logout();
return REDIRECT_INDEX_PATH;
}
}
由於有些控制器的方法中會進行頁面重新導向,為了區別重新導向時的路徑,可以在路徑前加上 "redirect:"
,看到此字串前置,就知道該進行重新導向,而不是頁面轉發。
在這邊看到了 @RequestParam
,這告訴 Spring MVC 取得並注入與參數名稱相同的請求參數,@RequestParam
的 required
預設為 true
,因此瀏覽器必須傳送此請求參數。
實際上,如果參數名稱與請求參數相同,僅有 @RequestParam String username
的情況下,@RequestParam
也是可以省略的,這就看 @RequestParam
對於程式碼的閱讀上是否有幫助,決定要不要標註了。
注意到方法的傳回值是 String
,Spring 會根據 InternalResourceViewResolve
的設定來轉發至對應之頁面,或者是看到 "redirect:"
開頭字串時進行重新導向,因此不需要自行取得 RequestDispatcher
進行轉發,或者是透過 HttpServletResponse
的 sendRedirect
方法重新導向了。
因此就這個控制器來說,兩個方法實際上都只需要注入 HttpServletRequest
實例,非必要的受檢例外(Checked Exception)宣告也可以刪除了。
由於這個應用程式,目前依賴在 Web 容器的安全管理之上,因此還是需要 HttpServletRequest
的 login
、logout
方法,然而,透過適當的 Spring 特性,其他的控制器可以簡化到不需要 Servlet API,例如 MemberController
:
package cc.openhome.controller;
...略
import cc.openhome.model.Message;
import cc.openhome.model.UserService;
@Controller
public class MemberController {
...略
@GetMapping("member")
@PostMapping("member")
public String member(
@SessionAttribute("login") String username,
Model model) {
List<Message> messages = userService.messages(username);
model.addAttribute("messages", messages);
return MEMBER_PATH;
}
@PostMapping("new_message")
protected String newMessage(
@RequestParam String blabla,
@SessionAttribute("login") String username,
Model model) {
if(blabla.length() == 0) {
return REDIRECT_MEMBER_PATH;
}
if(blabla.length() <= 140) {
userService.addMessage(username, blabla);
return REDIRECT_MEMBER_PATH;
}
else {
model.addAttribute("messages", userService.messages(username));
return MEMBER_PATH;
}
}
@PostMapping("del_message")
protected String delMessage(
@RequestParam String millis,
@SessionAttribute("login") String username) {
if(millis != null) {
userService.deleteMessage(username, millis);
}
return REDIRECT_MEMBER_PATH;
}
}
MemberController
中的方法,原本需要透過 HttpSession
來取得 "login"
屬性,透過 @SessionAttribute
的話,Spring 會自動取得並注入至方法。
這邊一併注入了 Model
實例,被添加至 Model
實例中的屬性,在 Spring 處理之後,預設會複製給 HttpServletRequest
成為其屬性之一。
如此之來,藉由標註與注入物件,可以發現 MemberController
中沒有任何 Servlet API 出現了。
為什麼強調沒有Servlet API出現?如果需要對控制器進行單元測試,Servlet API 會是個麻煩,因為相關實例是由容器的實作提供,若要自行實作一些假物件,也就是所謂的 Mock 物件,也會有一定的複雜度,然而,像這邊的 MemberController
,方法的參數型態是 String
或 Model
,後者也只有幾個方法需要實作,進行單元測試時就會簡單許多。
當然,這得是你真的想進行單元測試啦!不用只是為了去除 Servlet API 而想盡辦法去除 Servlet API,這只是自找麻煩,Spring MVC 終究是基於 Servlet 容器,若使用 Servlet API 可以簡單地解決需求,維護上也方便,使用 Servlet API 並沒有什麼不好。
Model
中添加的屬性,在 Spring 處理之後,預設會複製給 HttpServletRequest
成為其屬性之一,若要令複製給 HttpSession
成為屬性,可以透過 @SessionAttributes
在控制器宣告,例如 AccountController
可以修改為:
package cc.openhome.controller;
...略
@Controller
@SessionAttributes("token")
public class AccountController {
...略
@GetMapping("reset_password")
public String resetPasswordForm(
@RequestParam String name,
@RequestParam String email,
@RequestParam String token,
Model model) {
Optional<Account> optionalAcct =
userService.accountByNameEmail(name, email);
if(optionalAcct.isPresent()) {
Account acct = optionalAcct.get();
if(acct.getPassword().equals(token)) {
model.addAttribute("acct", acct);
model.addAttribute("token", token);
return RESET_PASSWORD_FORM_PATH;
}
}
return REDIRECT_INDEX_PATH;
}
...略
}
在 AccountController
上使用 @SessionAttributes
指定了 "token"
會是 HttpSession
的屬性,因此若透過注入的 Model
添加的屬性名稱是 "token"
,在 Spring 的處理之後,會複製一份至 HttpSession
成為其屬性之一。
最後來看看如何簡化 DisplayController
:
package cc.openhome.controller;
...略
@Controller
public class DisplayController {
...略
@GetMapping("user/{username}")
public String user(
@PathVariable("username") String username,
Model model) {
model.addAttribute("username", username);
if(userService.userExisted(username)) {
List<Message> messages =
userService.messages(username);
model.addAttribute("messages", messages);
} else {
model.addAttribute("errors",
Arrays.asList(String.format("%s 還沒有發表訊息", username)));
}
return USER_PATH;
}
}
在這邊可以看到,@RequestMapping
可以在路徑設定上指定佔位變數,之後就可以透過 @PathVariable
注入變數實際的值,這麼一來,就不用自行解析 URI 來取得使用者名稱了。
現在可以試著執行應用程式,看看功能是否一切如常,並與先前文件中的控制器程式碼相比較,看看是否簡潔了許多。
你可以在 gossip 中找到以上的範例專案。