簡化控制器


在實作 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 中找到以上的範例專案。