簡化控制器


在實作 MVC 架構中的控制器時,是否感覺到一些相似的程式邏輯,像是取得請求參數、請求轉發、重新導向等,例如,每一次內部轉發時,總是寫著相同的程式碼:

request.getRequestDispatcher(PATH).forward(request, response);

這是一種重複嗎?是的!而且由於採 MVC 架構,如果不是為了重新導向,實際上某些控制器中,並非真正需要 HttpServletResponse 實例,只是為了滿足 RequestDispatcherforward 必須有 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 取得並注入與參數名稱相同的請求參數,@RequestParamrequired 預設為 true,因此瀏覽器必須傳送此請求參數。

實際上,如果參數名稱與請求參數相同,僅有 @RequestParam String username 的情況下,@RequestParam 也是可以省略的,這就看 @RequestParam 對於程式碼的閱讀上是否有幫助,決定要不要標註了。

注意到方法的傳回值是 String,Spring 會根據 InternalResourceViewResolve 的設定來轉發至對應之頁面,或者是看到 "redirect:" 開頭字串時進行重新導向,因此不需要自行取得 RequestDispatcher 進行轉發,或者是透過 HttpServletResponsesendRedirect 方法重新導向了。

因此就這個控制器來說,兩個方法實際上都只需要注入 HttpServletRequest 實例,非必要的受檢例外(Checked Exception)宣告也可以刪除了。

由於這個應用程式,目前依賴在 Web 容器的安全管理之上,因此還是需要 HttpServletRequestloginlogout 方法,然而,透過適當的 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,方法的參數型態是 StringModel,後者也只有幾個方法需要實作,進行單元測試時就會簡單許多。

當然,這得是你真的想進行單元測試啦!不用只是為了去除 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 中找到以上的範例專案。