@ControllerAdvice


若要對傳播至 Servlet 容器的例外進行處理,可以在 web.xml 中設定 <error-page> 並指定 <exception-type> 等資訊。例如:

<web-app …>
    <error-page>
        <exception-type>java.lang.NullPointerException</exception-type>
        <location>/report.jsp</location>
    </error-page>
</web-app>

如果在 <location> 中設定的是 JSP 頁面,該頁面必須設定 isErrorPage 屬性為 true,才可以使用 exception 隱含物件。

這類傳播至 Servlet 容器的例外,基本上應該是底層無法處理的執行時期例外,傳播至 Servlet 容器之目的,是希望頁面呈現出對使用者有用的資訊,知道到底發生了什麼事情。

在 web.xml 設定,顯然是個針對整個應用程式,在使用 Spring MVC 時,如果想要針對控制器呢?可以使用 @ControllerAdvice,透過它的 basePackagesbasePackageClassesannotations 等屬性,指定要介入哪些控制器之中,如果不設定,那就是適用全部的控制器。例如:

package cc.openhome.aspect;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;

@ControllerAdvice
public class ExceptionCtrlAdvice {

    @ExceptionHandler(NullPointerException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String nullPointExHandler(NullPointerException ex) {
        // ... 做一些例外訊息的處理,像是日誌之類的 ...
        // ...bla...bla
        // 傳回顯示錯誤的頁面
        return "report"; 
    }
}

@ExceptionHandler 可標示特定的例外,被標示的方法將在例外發生時執行,可搭配 @ResponseStatus 指定回應狀態碼(@ResponseStatus 也可以個別地標示在控制器的處理器方法上)。

@ControllerAdvice 處理的範疇是整個控制器,如果你要進一步地,將例外處理的範疇,限定在某個控制器的處理器方法上,可以使用〈攔截處理器〉中談到的方式。

@ControllerAdvice 通常會用於例外處理,然而除了可以使用 @ExceptionHandler 之外,還可以使用 @ModelAttribute@InitBinder

@ModelAttribute 標註的方法,不需要傳回值,可以接受 Model 實例,會在控制器中每個處理器方法被執行前呼叫,如果處理器中使用的 Model,在處理器執行前進行某些屬性設定,就可以集中至 @ModelAttribute 標註的方法中進行。

@InitBinder 標註的方法,可以接受 WebDataBinder 實例,這個實例擁有許多可以處理請求欄位的方法,像是常見的 setAllowedFieldssetRequiredFieldscheckAllowedFieldscheckRequiredFields 等。

透過 WebDataBinder,也可以註冊自訂的屬性編輯器,自訂的屬性編輯器可以實作 PropertyEditor,這也是個自 Spring 1.x 就存在的 API,在我舊版文件的〈使用 PropertyEditor〉也談過,通常實作時為了方便,會繼承 PropertyEditorSupport,例如,若想實現一個從 Epoch 毫秒數轉 LocalDateTime 的屬性編輯器,可以如下:

...略
public class LocalDateTimeEditor extends PropertyEditorSupport {
    private ZoneId taipei = ZoneId.of("Asia/Taipei");

    @Override
    public void setAsText(String millis) throws IllegalArgumentException {
        setValue(
            Instant.ofEpochMilli(Long.parseLong(millis))
                   .atZone(taipei)
                   .toLocalDateTime()
        );
    }

    @Override    
    public String getAsText() {
        LocalDateTime localDateTime = (LocalDateTime) getValue();
        return String.valueOf(
            localDateTime.atZone(taipei).toInstant().toEpochMilli()
        );
    }
}

當必須從指定的物件轉換為字串時,會呼叫 getAsText 方法,而接收到參數要將之轉換為指定的物件時,會呼叫 setAsText 方法,你可以在 @ControllerAdvice 中標示某個方法為 @InitBinder 並註冊屬性編輯器:

@InitBinder
public void initBinder(WebDataBinder binder) {
    binder.registerCustomEditor(LocalDateTime.class, new LocalDateTimeEditor());
}

若某個控制器的處理器方法,想要從 millis 請求參數直接轉為 LocalDateTime 實例,可以如下標註:

@PostMapping("do_foo")
protected String doFoo(@RequestParam("millis") LocalDateTime localDateTime) {
    /// ...做點事
}

實際上,Spring 本身也有 @DateTimeFormat(針對舊式的 DateTime API,而不是 Java 8 新日期時間 API)、@NumberFormat 等可以直接使用,例如,若輸入的日期格式字串可符合 Date 格式轉換,可以如下轉為 Date

@PostMapping("do_foo")
protected String doFoo(@DateTimeFormat(iso=ISO.DATE) Date date) {
    /// ...做點事
}

若不使用 @DateTimeFormat,想要自定義格式轉換的話,可以在 @InitBinder 註冊 CustomDateEditor

@InitBinder
public void initBinder(WebDataBinder binder) {
    binder.registerCustomEditor(
        new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd"), false)
    );
}

WebDataBinder 也可以透過 setValidator 來設定自定義的驗證器,setValidator 接受 Validator 實作物件,這也是個自 Spring 1.x 就存在的 API,在我舊版文件的〈實作 Validator〉也談過:

package org.springframework.validation;

public interface Validator {
    boolean supports(Class clazz);
    void validate(Object obj, Errors errors);
}

supports 方法表示是否支援傳入類別的驗證,只有在傳回 true的情況下,才會使用 validate 方法,在 validate 方法的參數中,obj 表示已從請求參數轉換過來的物件,可以對 validate 方法中進行一些驗證。

如果相對應的方法,並不需要在控制器之間共用,@ExceptionHandler@ModelAttribute@InitBinder 等,也可以直接標示在控制器之中。