這是公眾號《Throwable文摘》釋出的第22篇原創文章,暫時收錄於專輯《架構與實戰》。暫定下一篇釋出的長文是《圖文分析JUC同步器框架》,下一篇釋出的短文是《SpringBoot2.x入門:引入jdbc模組與JdbcTemplate簡單使用》。
前提
在日常使用SpringMVC
進行開發的時候,有可能遇到前端各種型別的請求引數,這裡做一次相對全面的總結。SpringMVC
中處理控制器引數的介面是HandlerMethodArgumentResolver
,此介面有眾多子類,分別處理不同(註解型別)的引數,下面只列舉幾個子類:
RequestParamMethodArgumentResolver
:解析處理使用了@RequestParam
註解的引數、MultipartFile
型別引數和Simple
型別(如long
、int
等型別)引數。RequestResponseBodyMethodProcessor
:解析處理@RequestBody
註解的引數。PathVariableMapMethodArgumentResolver
:解析處理@PathVariable
註解的引數。
實際上,一般在解析一個控制器的請求引數的時候,用到的是HandlerMethodArgumentResolverComposite
,裡面裝載了所有啟用的HandlerMethodArgumentResolver
子類。而HandlerMethodArgumentResolver
子類在解析引數的時候使用到HttpMessageConverter
(實際上也是一個列表,進行遍歷匹配解析)子類進行匹配解析,常見的如MappingJackson2HttpMessageConverter
(使用Jackson
進行序列化和反序列化)。
而HandlerMethodArgumentResolver
子類到底依賴什麼HttpMessageConverter
例項實際上是由請求頭中的Content-Type
(在SpringMVC
中統一命名為MediaType
,見org.springframework.http.MediaType
)決定的,因此我們在處理控制器的請求引數之前必須要明確外部請求的Content-Type
到底是什麼。上面的邏輯可以直接看原始碼AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
,思路是比較清晰的。在@RequestMapping
註解中,produces
和consumes
屬性就是和請求的Accept
或者響應的Content-Type
相關的:
consumes
屬性:指定處理請求的提交內容型別(Content-Type
),例如application/json
、text/html
等等,只有命中了對應的Content-Type
的值才會接受該請求。produces
屬性:指定返回的內容型別,僅當某個請求的請求頭中的(Accept
)型別中包含該指定型別才返回,如果返回的是JSON
資料一般考慮使用application/json;charset=UTF-8
。
另外提一點,SpringMVC
中預設使用Jackson
作為JSON
的工具包,如果不是完全理解透整套原始碼的運作,一般不是十分建議修改預設使用的MappingJackson2HttpMessageConverter
(例如有些人喜歡使用FastJson
,實現HttpMessageConverter
引入FastJson
做HTTP
訊息轉換器,其實這種做法並不推薦)。
SpringMVC請求引數接收
其實一般的表單或者JSON
資料的請求都是相對簡單的,一些複雜的處理主要包括URL
路徑引數、檔案上傳、陣列或者列表型別資料等。另外,關於引數型別中存在日期型別屬性(例如java.util.Date
、java.sql.Date
、java.time.LocalDate
、java.time.LocalDateTime
、java.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;
}
下面主要以HTTP
的GET
方法和POST
方法提交在SpringMVC
體系中正確處理引數的例子進行分析,還會花精力整理SpringMVC
體系中獨有的URL
路徑引數處理的一些技巧以及最常見的日期引數處理的合理實踐(對於GET
方法和POST
方法提交的引數處理,基本囊括了其他如DELETE
、PUT
等方法的引數處理,隨機應變即可)。
GET方法請求引數處理
HTTP(s)
協議使用GET
方法進行請求的時候,提交的引數位於URL
模式的Query
部分,也就是URL
的?
識別符號之後的引數,格式是key1=value1&key2=value2
。GET
方法請求引數可以有多種方法獲取:
- 使用
@RequestParam
註解處理。 - 使用物件接收,注意物件的屬性名稱要和
Query
中的引數名稱一致。 - 使用
HttpServletRequest
例項提供的方法(不推薦,存在硬編碼)。
假設請求的URL
為http://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
)編碼。下面介紹幾種常見的表單引數提交的引數形式。
- 【非物件】- 非物件型別單個引數接收。
對應的控制器如下:
@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-Type
為application/x-www-form-urlencoded
,需要構造請求引數格式如下:
因為沒有使用註解,最終的引數處理器為ServletModelAttributeMethodProcessor
,主要是把HttpServletRequest
中的表單引數封裝到MutablePropertyValues
例項中,再通過引數型別例項化(通過構造反射建立User
例項),反射匹配屬性進行值的填充。另外,請求複雜引數裡面的列表屬性請求引數看起來比較奇葩,實際上和在.properties
檔案中新增最終對映到Map
型別的引數的寫法是一致的,所以對於巢狀陣列或者列表型別的第一層索引要寫成firstLevel[index].fieldName
的形式。那麼,能不能把整個請求引數塞在一個欄位中提交呢?
直接這樣做是不行的,因為實際提交的Form
表單,key
是user
字串,value
實際上也是一個字串,缺少一個String->User
型別的轉換器,實際上RequestParamMethodArgumentResolver
依賴WebConversionService
中Converter
例項列表進行引數轉換,而預設的Converter
列表中肯定不會存在自定義轉換String->User
型別的轉換器:
解決辦法還是有的,新增一個自定義的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
註解處理:
後端控制器的程式碼也比較簡單:
@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
,那麼通過匹配實際請求的URL
和URL
模板就能提取到userId
為1。在SpringMVC
中,URL
模板中的路徑引數叫做Path Variable
,對應註解@PathVariable
,對應的引數處理器為PathVariableMethodArgumentResolver
。注意一點是,@PathVariable的解析是按照value(name)屬性進行匹配,和URL引數的順序是無關的。舉個簡單的例子:
後臺的控制器如下:
@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-data
,POST
方式進行提交:
假設在電腦的磁碟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
軟體抓個包看下:
可知MultipartFile
例項的主要屬性分別來自Content-Disposition
、Content-Type
和Content-Length
,另外,InputStream
用於讀取請求體的最後部分(檔案的位元組序列)。引數處理器用到的是RequestPartMethodArgumentResolver
(記住一點,使用了@RequestPart
和MultipartFile
一定是使用此引數處理器)。在其他情況下,使用@RequestParam
和MultipartFile
或者僅僅使用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;
}
其他引數
其他引數主要包括請求頭、Cookie
、Model
、Map
等相關引數,還有一些並不是很常用或者一些相對原生的屬性值獲取(例如HttpServletRequest
、HttpServletResponse
或者它們內建的例項方法等)不做討論。
請求頭
請求頭的值主要通過@RequestHeader
註解的引數獲取,引數處理器是RequestHeaderMethodArgumentResolver
,需要在註解中指定請求頭的Key
。簡單實用如下:
控制器方法程式碼:
@PostMapping(value = "/header")
public String header(@RequestHeader(name = "Content-Type") String contentType) {
return contentType;
}
Cookie
Cookie
的值主要通過@CookieValue
註解的引數獲取,引數處理器為ServletCookieValueMethodArgumentResolver
,需要在註解中指定Cookie
的Key
。控制器方法程式碼如下:
@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)
中,區別下面三種情況:
@ModelAttribute
使用在方法(返回值)上,方法沒有返回值(void
型別),Model(Map)
引數需要自行設定。@ModelAttribute
使用在方法(返回值)上,方法有返回值(非void
型別),返回值會新增到Model(Map)
引數,key
由@ModelAttribute
的value
指定,否則會使用返回值型別字串(首寫字母變為小寫,如返回值型別為Integer
,則key
為integer
)。@ModelAttribute
使用在方法引數中,則可以獲取同一個控制器中的已經設定的@ModelAttribute
對應的值。
在一個控制器(使用了@Controller
的Spring
元件)中,如果存在一到多個使用了@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)
,見全域性異常處理器DefaultHandlerExceptionResolver
。Errors
型別的引數處理器為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-Type
為x-www-form-urlencoded
,不能使用application/json
的方式:
控制器程式碼為:
@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
集合,可以有兩種選擇:
- 使用
MultipartHttpServletRequest
引數,直接呼叫getFiles
方法獲取MultipartFile
列表。 - 使用
@RequestParam
註解修飾MultipartFile
列表,引數處理器是RequestParamMethodArgumentResolver
,其實就是第1種方式的封裝而已。
控制器方法程式碼如下:
@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;
}
使用字串接收後再轉換的缺點就是模板程式碼太多,編碼風格不夠簡潔,重複性工作太多,如果有程式碼潔癖或者類似筆者這樣是一個節能主義者,一般不會選用這種方式。
二、使用註解@DateTimeFormat或者@JsonFormat
@DateTimeFormat
註解配合@RequestBody
的引數使用的時候,會發現丟擲InvalidFormatException
異常,提示轉換失敗,這是因為在處理此註解的時候,只支援Form
表單提交(Content-Type
為x-www-form-urlencoded
),例子如下:
@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個字元。*
匹配0個或者多個字元。**
匹配路徑中0個或者多個目錄。- 正則支援,如
{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
。上面的例子只是列舉了SpringMVC
中URL
匹配的典型例子,並沒有深入展開。
最後,org.springframework.util.AntPathMatcher
作為一個工具類,可以單獨使用,不僅僅可以用於匹配URL
,也可以用於匹配系統檔案路徑,不過需要使用其帶引數構造改變內部的pathSeparator
變數,例如:
AntPathMatcher antPathMatcher = new AntPathMatcher(File.separator);
小結
筆者在前一段時間曾經花大量時間梳理和分析過Spring
、SpringMVC
的原始碼,但是後面一段很長的時間需要進行業務開發,對架構方面的東西有點生疏了,畢竟東西不用就會生疏,這個是常理。這篇文章基於一些SpringMVC
的原始碼經驗總結了請求引數的處理相關的一些知識,希望幫到自己和大家。
參考資料:
- spring-boot-web-starter:2.3.0.RELEASE原始碼。
(本文完 c-7-d e-a-20180512 r-a-20200713 舊文重發 封面圖來源於日漫《神風怪盜》)
公眾號《Throwable文摘》(id:throwable-doge),不定期推送架構設計、併發、原始碼探究相關的原創文章: