2.5萬字長文簡單總結SpringMVC請求引數接收

throwable發表於2020-07-14

這是公眾號《Throwable文摘》釋出的第22篇原創文章,暫時收錄於專輯《架構與實戰》。暫定下一篇釋出的長文是《圖文分析JUC同步器框架》,下一篇釋出的短文是《SpringBoot2.x入門:引入jdbc模組與JdbcTemplate簡單使用》。

前提

在日常使用SpringMVC進行開發的時候,有可能遇到前端各種型別的請求引數,這裡做一次相對全面的總結。SpringMVC中處理控制器引數的介面是HandlerMethodArgumentResolver,此介面有眾多子類,分別處理不同(註解型別)的引數,下面只列舉幾個子類:

  • RequestParamMethodArgumentResolver:解析處理使用了@RequestParam註解的引數、MultipartFile型別引數和Simple型別(如longint等型別)引數。
  • RequestResponseBodyMethodProcessor:解析處理@RequestBody註解的引數。
  • PathVariableMapMethodArgumentResolver:解析處理@PathVariable註解的引數。

實際上,一般在解析一個控制器的請求引數的時候,用到的是HandlerMethodArgumentResolverComposite,裡面裝載了所有啟用的HandlerMethodArgumentResolver子類。而HandlerMethodArgumentResolver子類在解析引數的時候使用到HttpMessageConverter(實際上也是一個列表,進行遍歷匹配解析)子類進行匹配解析,常見的如MappingJackson2HttpMessageConverter(使用Jackson進行序列化和反序列化)。

spmvc-p-14

HandlerMethodArgumentResolver子類到底依賴什麼HttpMessageConverter例項實際上是由請求頭中的Content-Type(在SpringMVC中統一命名為MediaType,見org.springframework.http.MediaType)決定的,因此我們在處理控制器的請求引數之前必須要明確外部請求的Content-Type到底是什麼。上面的邏輯可以直接看原始碼AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters,思路是比較清晰的。在@RequestMapping註解中,producesconsumes屬性就是和請求的Accept或者響應的Content-Type相關的:

  • consumes屬性:指定處理請求的提交內容型別(Content-Type),例如application/jsontext/html等等,只有命中了對應的Content-Type的值才會接受該請求。
  • produces屬性:指定返回的內容型別,僅當某個請求的請求頭中的(Accept)型別中包含該指定型別才返回,如果返回的是JSON資料一般考慮使用application/json;charset=UTF-8

另外提一點,SpringMVC中預設使用Jackson作為JSON的工具包,如果不是完全理解透整套原始碼的運作,一般不是十分建議修改預設使用的MappingJackson2HttpMessageConverter(例如有些人喜歡使用FastJson,實現HttpMessageConverter引入FastJsonHTTP訊息轉換器,其實這種做法並不推薦)。

SpringMVC請求引數接收

其實一般的表單或者JSON資料的請求都是相對簡單的,一些複雜的處理主要包括URL路徑引數、檔案上傳、陣列或者列表型別資料等。另外,關於引數型別中存在日期型別屬性(例如java.util.Datejava.sql.Datejava.time.LocalDatejava.time.LocalDateTimejava.time.ZonedDateTime等等),解析的時候一般需要自定義實現的邏輯實現String-->日期型別的轉換。其實道理很簡單,日期相關的型別對於每個國家、每個時區甚至每個使用者來說認知都不一定相同,所以SpringMVC並沒有對於日期時間型別的解析提供一個通用的解決方案。在演示一些例子可能用到下面的模特類:

@Data
public class User {

    private String name;
    private Integer age;
    private List<Contact> contacts;
}

@Data
public class Contact {

    private String name;
    private String phone;
}

下面主要以HTTPGET方法和POST方法提交在SpringMVC體系中正確處理引數的例子進行分析,還會花精力整理SpringMVC體系中獨有的URL路徑引數處理的一些技巧以及最常見的日期引數處理的合理實踐(對於GET方法和POST方法提交的引數處理,基本囊括了其他如DELETEPUT等方法的引數處理,隨機應變即可)。

GET方法請求引數處理

HTTP(s)協議使用GET方法進行請求的時候,提交的引數位於URL模式的Query部分,也就是URL?識別符號之後的引數,格式是key1=value1&key2=value2GET方法請求引數可以有多種方法獲取:

  1. 使用@RequestParam註解處理。
  2. 使用物件接收,注意物件的屬性名稱要和Query中的引數名稱一致。
  3. 使用HttpServletRequest例項提供的方法(不推薦,存在硬編碼)。

假設請求的URLhttp://localhost:8080/get?name=doge&age=26,那麼控制器如下:

@Slf4j
@RestController
public class SampleController {

    @GetMapping(path = "/get1")
    public void get1(@RequestParam(name = "name") String name,
                     @RequestParam(name = "age") Integer age) {
        log.info("name:{},age:{}", name, age);
    }

    @GetMapping(path = "/get2")
    public void get2(UserVo vo) {
        log.info("name:{},age:{}", vo.getName(), vo.getAge());
    }

    @GetMapping(path = "/get3")
    public void get3(HttpServletRequest request) {
        String name = request.getParameter("name");
        String age = request.getParameter("age");
        log.info("name:{},age:{}", name, age);
    }

    @Data
    public static class UserVo {

        private String name;
        private Integer age;
    }
}

表單引數

表單引數,一般對應於頁面上<form>標籤內的所有<input>標籤的name-value聚合而成的引數,一般Content-Type指定為application/x-www-form-urlencoded,表單引數值也就是會進行(URL)編碼。下面介紹幾種常見的表單引數提交的引數形式。

  • 【非物件】- 非物件型別單個引數接收。

spmvc-p-1

對應的控制器如下:

@PostMapping(value = "/post")
public String post(@RequestParam(name = "name") String name,
                   @RequestParam(name = "age") Integer age) {
    String content = String.format("name = %s,age = %d", name, age);
    log.info(content);
    return content;
}

說實話,如果有毅力的話,所有的複雜引數的提交最終都可以轉化為多個單引數接收,不過這樣做會產生十分多冗餘的程式碼,而且可維護性比較低。這種情況下,用到的引數處理器是RequestParamMapMethodArgumentResolver

  • 【物件】 - 物件型別引數接收。

我們接著寫一個介面用於提交使用者資訊,用到的是上面提到的模特類,主要包括使用者姓名、年齡和聯絡人資訊列表,這個時候,我們目標的控制器最終編碼如下:

@PostMapping(value = "/user")
public User saveUser(User user) {
    log.info(user.toString());
    return user;
}

加入強行指定Content-Typeapplication/x-www-form-urlencoded,需要構造請求引數格式如下:

spmvc-p-2

因為沒有使用註解,最終的引數處理器為ServletModelAttributeMethodProcessor,主要是把HttpServletRequest中的表單引數封裝到MutablePropertyValues例項中,再通過引數型別例項化(通過構造反射建立User例項),反射匹配屬性進行值的填充。另外,請求複雜引數裡面的列表屬性請求引數看起來比較奇葩,實際上和在.properties檔案中新增最終對映到Map型別的引數的寫法是一致的,所以對於巢狀陣列或者列表型別的第一層索引要寫成firstLevel[index].fieldName的形式。那麼,能不能把整個請求引數塞在一個欄位中提交呢?

spmvc-p-3

直接這樣做是不行的,因為實際提交的Form表單,keyuser字串,value實際上也是一個字串,缺少一個String->User型別的轉換器,實際上RequestParamMethodArgumentResolver依賴WebConversionServiceConverter例項列表進行引數轉換,而預設的Converter列表中肯定不會存在自定義轉換String->User型別的轉換器:

spmvc-p-4

解決辦法還是有的,新增一個自定義的org.springframework.core.convert.converter.Converter實現即可:

@Component
public class StringUserConverter implements Converter<String, User> {

    @Autowaired
    private ObjectMapper objectMapper;

    @Override
    public User convert(String source) {
        try {
               return objectMapper.readValue(source, User.class);
            } catch (IOException e) {
               throw new IllegalArgumentException(e);
        }
    }
}

上面這種做法屬於曲線救國的做法,不推薦使用在生產環境,但是如果有些第三方介面的對接無法避免這種引數(這個還真碰到多,有一些遠古的遺留系統比較容易出現各種奇葩的操作),可以選擇這種實現方式。

  • 【陣列】 - 列表或者陣列型別引數。

極度不推薦使用在application/x-www-form-urlencoded這種媒體型別的表單提交的形式下強行使用列表或者陣列型別引數,除非是為了相容處理歷史遺留系統的引數提交處理。例如提交的引數形式是:

list = ["string-1", "string-2", "string-3"]

那麼表單引數的形式要寫成:

name value
list[0] string-1
list[1] string-2
list[2] string-3

控制器的程式碼如下:

@PostMapping(path = "/list")
public void list(@RequestParam(name="list") List<String> list) {
    log.info(list);
}

一個更加複雜的例子如下,假設想要提交的報文格式如下:

user = [{"name":"doge-1","age": 21},{"name":"doge-2","age": 22}]

那麼表單引數的形式要寫成:

name value
user[0].name doge-1
user[0].age 21
user[1].name doge-2
user[1].age 22

控制器的程式碼如下:

@PostMapping(path = "/user")
public void saveUsers(@RequestParam(name="user") List<UserVo> users) {
    log.info(users);
}

@Data
public class UserVo{

    private String name;
    private Integer age;
}

這種傳參格式其實並不靈活,甚至有可能降低開發效率和引數可讀性。

JSON引數

一般來說,直接在POST請求中的請求體提交一個JSON字串這種方式對於SpringMVC來說是比較友好的,只需要把Content-Type設定為application/json,然後直接上傳一個原始的JSON字串即可,控制器方法引數使用@RequestBody註解處理:

spmvc-p-5

後端控制器的程式碼也比較簡單:

@PostMapping(value = "/user-2")
public User saveUser2(@RequestBody User user) {
    log.info(user.toString());
    return user;
}

因為使用了@RequestBody註解,最終使用到的引數處理器為RequestResponseBodyMethodProcessor,實際上會用到MappingJackson2HttpMessageConverter進行引數型別的轉換,底層依賴到Jackson相關的包。推薦使用這種方式,這是最常用也是最穩健的JSON引數處理方式

URL路徑引數

URL路徑引數,或者叫請求路徑引數是基於URL模板獲取到的引數,例如/user/{userId}是一個URL模板(URL模板中的引數佔位符是{}),實際請求的URL/user/1,那麼通過匹配實際請求的URLURL模板就能提取到userId為1。在SpringMVC中,URL模板中的路徑引數叫做Path Variable,對應註解@PathVariable,對應的引數處理器為PathVariableMethodArgumentResolver注意一點是,@PathVariable的解析是按照value(name)屬性進行匹配,和URL引數的順序是無關的。舉個簡單的例子:

spmvc-p-6

後臺的控制器如下:

@GetMapping(value = "/user/{name}/{age}")
public String findUser1(@PathVariable(value = "age") Integer age,
                        @PathVariable(value = "name") String name) {
    String content = String.format("name = %s,age = %d", name, age);
    log.info(content);
    return content;
}

這種用法被廣泛使用於Representational State Transfer(REST)的軟體架構風格,個人覺得這種風格是比較靈活和清晰的(從URL和請求方法就能完全理解介面的意義和功能)。下面再介紹兩種相對特殊的使用方式。

  • 帶條件的URL引數。

其實路徑引數支援正規表示式,例如我們在使用/sex/{sex}介面的時候,要求sex必須是F(Female)或者M(Male),那麼我們的URL模板可以定義為/sex/{sex:M|F},程式碼如下:

@GetMapping(value = "/sex/{sex:M|F}")
public String findUser2(@PathVariable(value = "sex") String sex){
    log.info(sex);
    return sex;
}

只有/sex/F或者/sex/M的請求才會進入findUser2()控制器方法,其他該路徑字首的請求都是非法的,會返回404狀態碼。這裡僅僅是介紹了一個最簡單的URL引數正規表示式的使用方式,更強大的用法可以自行摸索。

  • @MatrixVariable的使用。

MatrixVariable也是URL引數的一種,對應註解@MatrixVariable,不過它並不是URL中的一個值(這裡的值指定是兩個"/"之間的部分),而是值的一部分,它通過";"進行分隔,通過"="進行K-V設定。說起來有點抽象,舉個例子:假如我們需要打電話給一個名字為doge,性別是男,分組是碼畜的程式設計師,GET請求的URL可以表示為:/call/doge;gender=male;group=programmer,我們設計的控制器方法如下:

@GetMapping(value = "/call/{name}")
public String find(@PathVariable(value = "name") String name,
                   @MatrixVariable(value = "gender") String gender,
                   @MatrixVariable(value = "group") String group) {
    String content = String.format("name = %s,gender = %s,group = %s", name, gender, group);
    log.info(content);
    return content;
}

當然,如果你按照上面的例子寫好程式碼,嘗試請求一下該介面發現是報錯的:400 Bad Request - Missing matrix variable 'gender' for method parameter of type String。這是因為@MatrixVariable註解的使用是不安全的,在SpringMVC中預設是關閉對其支援。要開啟對@MatrixVariable的支援,需要設定RequestMappingHandlerMapping#setRemoveSemicolonContent方法為false

@Configuration
public class CustomMvcConfiguration implements InitializingBean {

    @Autowired
    private RequestMappingHandlerMapping requestMappingHandlerMapping;

    @Override
    public void afterPropertiesSet() throws Exception {
        requestMappingHandlerMapping.setRemoveSemicolonContent(false);
    }
}

除非有很特殊的需要,否則不建議使用@MatrixVariable

檔案上傳

檔案上傳在使用POSTMAN模擬請求的時候需要選擇form-dataPOST方式進行提交:

spmvc-p-8

假設在電腦的磁碟D盤根目錄有一個圖片檔案叫doge.jpg,現在要通過本地服務介面把檔案上傳,控制器的程式碼如下:

@PostMapping(value = "/file1")
public String file1(@RequestPart(name = "file1") MultipartFile multipartFile) {
    String content = String.format("name = %s,originName = %s,size = %d",
            multipartFile.getName(), multipartFile.getOriginalFilename(), multipartFile.getSize());
    log.info(content);
    return content;
}

控制檯輸出是:

name = file1,originName = doge.jpg,size = 68727

可能有點疑惑,引數是怎麼來的,我們可以用Fildder軟體抓個包看下:

spmvc-p-7

可知MultipartFile例項的主要屬性分別來自Content-DispositionContent-TypeContent-Length,另外,InputStream用於讀取請求體的最後部分(檔案的位元組序列)。引數處理器用到的是RequestPartMethodArgumentResolver(記住一點,使用了@RequestPartMultipartFile一定是使用此引數處理器)。在其他情況下,使用@RequestParamMultipartFile或者僅僅使用MultipartFile(引數的名字必須和POST表單中的Content-Disposition描述的name一致)也可以接收上傳的檔案資料,主要是通過RequestParamMethodArgumentResolver進行解析處理的,它的功能比較強大,具體可以看其supportsParameter方法,這兩種情況的控制器方法程式碼如下:

@PostMapping(value = "/file2")
public String file2(MultipartFile file1) {
    String content = String.format("name = %s,originName = %s,size = %d",
            file1.getName(), file1.getOriginalFilename(), file1.getSize());
    log.info(content);
    return content;
}

@PostMapping(value = "/file3")
public String file3(@RequestParam(name = "file1") MultipartFile multipartFile) {
    String content = String.format("name = %s,originName = %s,size = %d",
            multipartFile.getName(), multipartFile.getOriginalFilename(), multipartFile.getSize());
    log.info(content);
    return content;
}

其他引數

其他引數主要包括請求頭、CookieModelMap等相關引數,還有一些並不是很常用或者一些相對原生的屬性值獲取(例如HttpServletRequestHttpServletResponse或者它們內建的例項方法等)不做討論。

請求頭

請求頭的值主要通過@RequestHeader註解的引數獲取,引數處理器是RequestHeaderMethodArgumentResolver,需要在註解中指定請求頭的Key。簡單實用如下:

spmvc-p-9

控制器方法程式碼:

@PostMapping(value = "/header")
public String header(@RequestHeader(name = "Content-Type") String contentType) {
    return contentType;
}

Cookie的值主要通過@CookieValue註解的引數獲取,引數處理器為ServletCookieValueMethodArgumentResolver,需要在註解中指定CookieKey。控制器方法程式碼如下:

@PostMapping(value = "/cookie")
public String cookie(@CookieValue(name = "JSESSIONID") String sessionId) {
    return sessionId;
}

Model型別引數

Model型別引數的處理器是ModelMethodProcessor,實際上處理此引數是直接返回ModelAndViewContainer例項中的Model(具體是ModelMap型別),因為要橋接不同的介面和類的功能,因此回撥的例項是BindingAwareModelMap型別,此型別繼承自ModelMap同時實現了Model介面。舉個例子:

@GetMapping(value = "/model")
public String model(Model model, ModelMap modelMap) {
    log.info("{}", model == modelMap);
    return "success";
}

注意呼叫此介面,控制檯輸出INFO日誌內容為:true。還要注意一點:ModelMap或者Model中新增的屬性項會附加到HttpRequestServlet例項中帶到頁面中進行渲染,使用模板引擎的前提下可以直接在模板檔案內容中直接使用佔位符提取這些屬性值。

@ModelAttribute引數

@ModelAttribute註解處理的引數處理器為ModelAttributeMethodProcessor@ModelAttribute的功能原始碼的註釋如下:

Annotation that binds a method parameter or method return value to a named model attribute, exposed to a web view.

簡單來說,就是通過key-value形式繫結方法引數或者方法返回值到Model(Map)中,區別下面三種情況:

  1. @ModelAttribute使用在方法(返回值)上,方法沒有返回值(void型別), Model(Map)引數需要自行設定。
  2. @ModelAttribute使用在方法(返回值)上,方法有返回值(非void型別),返回值會新增到Model(Map)引數,key@ModelAttributevalue指定,否則會使用返回值型別字串(首寫字母變為小寫,如返回值型別為Integer,則keyinteger)。
  3. @ModelAttribute使用在方法引數中,則可以獲取同一個控制器中的已經設定的@ModelAttribute對應的值。

在一個控制器(使用了@ControllerSpring元件)中,如果存在一到多個使用了@ModelAttribute的方法,這些方法總是在進入控制器方法之前執行,並且執行順序是由載入順序決定的(具體的順序是帶引數的優先,並且按照方法首字母升序排序),舉個例子:

@Slf4j
@RestController
public class ModelAttributeController {

    @ModelAttribute
    public void before(Model model) {
        log.info("before..........");
        model.addAttribute("before", "beforeValue");
    }

    @ModelAttribute(value = "beforeArg")
    public String beforeArg() {
        log.info("beforeArg..........");
        return "beforeArgValue";
    }

    @GetMapping(value = "/modelAttribute")
    public String modelAttribute(Model model, @ModelAttribute(value = "beforeArg") String beforeArg) {
        log.info("modelAttribute..........");
        log.info("beforeArg..........{}", beforeArg);
        log.info("{}", model);
        return "success";
    }

    @ModelAttribute
    public void after(Model model) {
        log.info("after..........");
        model.addAttribute("after", "afterValue");
    }

    @ModelAttribute(value = "afterArg")
    public String afterArg() {
        log.info("afterArg..........");
        return "afterArgValue";
    }
}

呼叫此介面,控制檯輸出日誌如下:

after..........
before..........
afterArg..........
beforeArg..........
modelAttribute..........
beforeArg..........beforeArgValue
{after=afterValue, before=beforeValue, afterArg=afterArgValue, beforeArg=beforeArgValue}

可以印證排序規則和引數設定、獲取的結果和前面的分析是一致的。

Errors或者BindingResult引數

Errors其實是BindingResult的父介面,BindingResult主要用於回撥JSR引數校驗異常的屬性項,如果JSR303校驗異常,一般會丟擲MethodArgumentNotValidException異常,並且會返回400(Bad Request),見全域性異常處理器DefaultHandlerExceptionResolverErrors型別的引數處理器為ErrorsMethodArgumentResolver。舉個例子:

@PostMapping(value = "/errors")
public String errors(@RequestBody @Validated ErrorsModel errors, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        for (ObjectError objectError : bindingResult.getAllErrors()) {
            log.warn("name={},message={}", objectError.getObjectName(), objectError.getDefaultMessage());
        }
    }
    return errors.toString();
}

//ErrorsModel
@Data
@NoArgsConstructor
public class ErrorsModel {

    @NotNull(message = "id must not be null!")
    private Integer id;

    @NotEmpty(message = "errors name must not be empty!")
    private String name;
}

呼叫介面控制檯Warn日誌如下:

name=errors,message=errors name must not be empty!

一般情況下,不建議用這種方式處理JSR校驗異常的屬性項,因為會涉及到大量的重複的硬編碼工作,建議:方式一直接繼承ResponseEntityExceptionHandler覆蓋對應的方法或者方式二同時使用@ExceptionHandler@(Rest)ControllerAdvice註解進行異常處理。例如:

@RestControllerAdvice
public class ApplicationRestControllerAdvice{

    @ExceptionHandler(BusinessException.class)
    public Response handleBusinessException(BusinessException e, HttpServletRequest request){
        // 這裡處理異常和返回值
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Response handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request){
        // 這裡處理異常和返回值
    }
}

值得注意的是,SpringBoot某個版本之後,把JSR303相關的依賴抽離到spring-boot-starter-validation依賴中,如果要使用JSR303相關相關校驗功能,必須獨立引入此starter

@Value引數

控制器方法的引數可以是@Value註解修飾的引數,會從Environment例項中裝配和轉換屬性值到對應的引數中(也就是引數的來源並不是請求體,而是上下文中已經載入和處理完成的環境屬性值),引數處理器為ExpressionValueMethodArgumentResolver。舉個例子:

@GetMapping(value = "/value")
public String value(@Value(value = "${spring.application.name}") String name) {
    log.info("spring.application.name={}", name);
    return name;
}

spring.application.name屬性一般在配置檔案中指定,在載入配置檔案屬性的時候新增到全域性的Environment中。

Map型別引數

Map型別引數的範圍相對比較廣,對應一系列的引數處理器,注意區別使用了上面提到的部分註解的Map型別和完全不使用註解的Map型別引數,兩者的處理方式不相同。下面列舉幾個相對典型的Map型別引數處理例子。

不使用任何註解的Map<String,Object>引數

這種情況下引數實際上直接回撥ModelAndViewContainer中的ModelMap例項,引數處理器為MapMethodProcessor,往Map引數中新增的屬性將會帶到頁面中。

使用@RequestParam註解的Map<String,Object>引數

這種情況下的引數處理器為RequestParamMapMethodArgumentResolver,使用的請求方式需要指定Content-Typex-www-form-urlencoded,不能使用application/json的方式:

spmvc-p-10

控制器程式碼為:

@PostMapping(value = "/map")
public String mapArgs(@RequestParam Map<String, Object> map) {
    log.info("{}", map);
    return map.toString();
}

使用@RequestHeader註解的Map<String,Object>引數

這種情況下的引數處理器為RequestHeaderMapMethodArgumentResolver,作用是獲取請求的所有請求頭的Key-Value

使用@PathVariable註解的Map<String,Object>引數

這種情況下的引數處理器為PathVariableMapMethodArgumentResolver,作用是獲取所有路徑引數封裝為Key-Value結構。

MultipartFile集合-批量檔案上傳

批量檔案上傳的時候,我們一般需要接收一個MultipartFile集合,可以有兩種選擇:

  1. 使用MultipartHttpServletRequest引數,直接呼叫getFiles方法獲取MultipartFile列表。
  2. 使用@RequestParam註解修飾MultipartFile列表,引數處理器是RequestParamMethodArgumentResolver,其實就是第1種方式的封裝而已。

spmvc-p-11

控制器方法程式碼如下:

@PostMapping(value = "/parts")
public String partArgs(@RequestParam(name = "file") List<MultipartFile> parts) {
    log.info("{}", parts);
    return parts.toString();
}

日期型別引數處理

日期引數處理個人認為是請求引數處理中最複雜的,因為一般日期處理的邏輯不是通用的,過多的定製化處理導致很難有一個統一的標準處理邏輯去處理和轉換日期型別的引數。不過,這裡介紹幾個通用的方法,以應對各種奇葩的日期格式。下面介紹的例子中全部使用JDK8中引入的日期時間API,圍繞java.util.Date為核心的日期時間API的使用方式類同。

一、統一以字串形式接收

這種是最原始但是最奏效的方式,統一以字串形式接收,然後自行處理型別轉換,下面給個小例子:

static DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

@PostMapping(value = "/date1")
public String date1(@RequestBody UserDto userDto) {
    UserEntity userEntity = new UserEntity();
    userEntity.setUserId(userDto.getUserId());
    userEntity.setBirthdayTime(LocalDateTime.parse(userDto.getBirthdayTime(), FORMATTER));
    userEntity.setGraduationTime(LocalDateTime.parse(userDto.getGraduationTime(), FORMATTER));
    log.info(userEntity.toString());
    return "success";
}

@Data
public class UserDto {

    private String userId;
    private String birthdayTime;
    private String graduationTime;
}

@Data
public class UserEntity {

    private String userId;
    private LocalDateTime birthdayTime;
    private LocalDateTime graduationTime;
}

spmvc-p-12

使用字串接收後再轉換的缺點就是模板程式碼太多,編碼風格不夠簡潔,重複性工作太多,如果有程式碼潔癖或者類似筆者這樣是一個節能主義者,一般不會選用這種方式。

二、使用註解@DateTimeFormat或者@JsonFormat

@DateTimeFormat註解配合@RequestBody的引數使用的時候,會發現丟擲InvalidFormatException異常,提示轉換失敗,這是因為在處理此註解的時候,只支援Form表單提交(Content-Typex-www-form-urlencoded),例子如下:

spmvc-p-13

@Data
public class UserDto2 {

    private String userId;
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime birthdayTime;
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime graduationTime;
}


@PostMapping(value = "/date2")
public String date2(UserDto2 userDto2) {
    log.info(userDto2.toString());
    return "success";
}

//或者像下面這樣
@PostMapping(value = "/date2")
public String date2(@RequestParam("name"="userId")String userId,
                    @RequestParam("name"="birthdayTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime birthdayTime,
                    @RequestParam("name"="graduationTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime graduationTime) {
    return "success";
}

@JsonFormat註解可使用在Form表單或者JSON請求引數的場景,因此更推薦使用@JsonFormat註解,不過注意需要指定時區(timezone屬性,例如在中國是東八區GMT+8),否則有可能導致出現時差,舉個例子:

@PostMapping(value = "/date2")
public String date2(@RequestBody UserDto2 userDto2) {
    log.info(userDto2.toString());
    return "success";
}

@Data
public class UserDto2 {

    private String userId;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime birthdayTime;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime graduationTime;
}

一般選用LocalDateTime作為日期欄位引數的型別,因為它的轉換相對於其他JDK8的日期時間型別簡單

三、Jackson序列化和反序列化定製

因為SpringMVC預設使用Jackson處理@RequestBody的引數轉換,因此可以通過定製序列化器和反序列化器來實現日期型別的轉換,這樣我們就可以使用application/json的形式提交請求引數。這裡的例子是轉換請求JSON引數中的字串為LocalDateTime型別,屬於JSON反序列化,因此需要定製反序列化器:

@PostMapping(value = "/date3")
public String date3(@RequestBody UserDto3 userDto3) {
    log.info(userDto3.toString());
    return "success";
}

@Data
public class UserDto3 {

    private String userId;
    @JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
    private LocalDateTime birthdayTime;
    @JsonDeserialize(using = CustomLocalDateTimeDeserializer.class)
    private LocalDateTime graduationTime;
}

public class CustomLocalDateTimeDeserializer extends LocalDateTimeDeserializer {

    public CustomLocalDateTimeDeserializer() {
        super(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }
}

四、最佳實踐

前面三種方式都存在硬編碼等問題,其實最佳實踐是直接修改MappingJackson2HttpMessageConverter中的ObjectMapper對於日期型別處理預設的序列化器和反序列化器,這樣就能全域性生效,不需要再使用其他註解或者定製序列化方案(當然,有些時候需要特殊處理定製),或者說,在需要特殊處理的場景才使用其他註解或者定製序列化方案。使用鉤子介面Jackson2ObjectMapperBuilderCustomizer可以實現對容器中的ObjectMapper單例中的屬性定製:

@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer(){
	return customizer->{
		customizer.serializerByType(LocalDateTime.class,new LocalDateTimeSerializer(
				DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
		customizer.deserializerByType(LocalDateTime.class,new LocalDateTimeDeserializer(
				DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
	};
}

這樣就能定製化MappingJackson2HttpMessageConverter中持有的ObjectMapper,上面的LocalDateTime序列化和反序列化器對全域性生效。

請求URL匹配

前面基本介紹完了主流的請求引數處理,其實SpringMVC中還會按照URL的模式進行匹配,使用的是Ant路徑風格,處理工具類為org.springframework.util.AntPathMatcher,從此類的註釋來看,匹配規則主要包括下面四點

  1. ?匹配1個字元。
  2. *匹配0個或者多個字元
  3. **匹配路徑中0個或者多個目錄
  4. 正則支援,如{spring:[a-z]+}將正規表示式[a-z]+匹配到的值,賦值給名為spring的路徑變數。

舉些例子:

'?'形式的URL

@GetMapping(value = "/pattern?")
public String pattern() {
	return "success";
}

/pattern  404 Not Found
/patternd  200 OK
/patterndd  404 Not Found
/pattern/  404 Not Found
/patternd/s  404 Not Found

'*'形式的URL

@GetMapping(value = "/pattern*")
public String pattern() {
	return "success";
}

/pattern  200 OK
/pattern/  200 OK
/patternd  200 OK
/pattern/a  404 Not Found

'**'形式的URL

@GetMapping(value = "/pattern/**/p")
public String pattern() {
	return "success";
}

/pattern/p  200 OK
/pattern/x/p  200 OK
/pattern/x/y/p  200 OK

{spring:[a-z]+}形式的URL

@GetMapping(value = "/pattern/{key:[a-c]+}")
public String pattern(@PathVariable(name = "key") String key) {
    return "success";
}

/pattern/a  200 OK
/pattern/ab  200 OK
/pattern/abc  200 OK
/pattern  404 Not Found
/pattern/abcd  404 Not Found

上面的四種URL模式可以組合使用,千變萬化。

URL匹配還遵循精確匹配原則,也就是存在兩個模式對同一個URL都能夠匹配成功,則選取最精確的URL匹配,進入對應的控制器方法,舉個例子:

@GetMapping(value = "/pattern/**/p")
public String pattern1() {
    return "success";
}

@GetMapping(value = "/pattern/p")
public String pattern2() {
    return "success";
}

上面兩個控制器,如果請求URL/pattern/p,最終進入的方法為pattern2。上面的例子只是列舉了SpringMVCURL匹配的典型例子,並沒有深入展開。

最後,org.springframework.util.AntPathMatcher作為一個工具類,可以單獨使用,不僅僅可以用於匹配URL,也可以用於匹配系統檔案路徑,不過需要使用其帶引數構造改變內部的pathSeparator變數,例如:

AntPathMatcher antPathMatcher = new AntPathMatcher(File.separator);

小結

筆者在前一段時間曾經花大量時間梳理和分析過SpringSpringMVC的原始碼,但是後面一段很長的時間需要進行業務開發,對架構方面的東西有點生疏了,畢竟東西不用就會生疏,這個是常理。這篇文章基於一些SpringMVC的原始碼經驗總結了請求引數的處理相關的一些知識,希望幫到自己和大家。

參考資料:

  • spring-boot-web-starter:2.3.0.RELEASE原始碼。

(本文完 c-7-d e-a-20180512 r-a-20200713 舊文重發 封面圖來源於日漫《神風怪盜》)

公眾號《Throwable文摘》(id:throwable-doge),不定期推送架構設計、併發、原始碼探究相關的原創文章:

相關文章