朱曄和你聊Spring系列S1E4:靈活但不算好用的Spring MVC



me.josephzhu


spring101
0.0.1-SNAPSHOT



org.springframework.boot
spring-boot-starter-web


org.springframework.boot
spring-boot-starter-thymeleaf





org.springframework.boot
spring-boot-maven-plugin




複製代碼

使用web來啟用Spring MVC,使用thymeleaf來啟用thymeleaf模板引擎。Thymeleaf是一個強大的Java模板引擎,可以脫離於Web單獨使用,本身就有非常多的可配置可擴展的點,這裡不展開討論,詳見官網。 接下去我們創建主程序:

package me.josephzhu.spring101webmvc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Spring101WebmvcApplication {
public static void main(String[] args) {
SpringApplication.run(Spring101WebmvcApplication.class, args);
}
}
以及一個測試Controller:
package me.josephzhu.spring101webmvc;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Controller
public class MyController {
@GetMapping("shop")
public ModelAndView shop() {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("shop");
modelAndView.addObject("items",
IntStream.range(1, 5)
.mapToObj(i -> new MyItem("item" + i, i * 100))
.collect(Collectors.toList()));
return modelAndView;
}
}
複製代碼

這裡使用到了一個自定義的類:

package me.josephzhu.spring101webmvc;
import lombok.AllArgsConstructor;
import lombok.Data;
@AllArgsConstructor
@Data
public class MyItem {
private String name;
private Integer price;
}
複製代碼

最後我們需要在resources目錄下創建一個templates目錄,在目錄下再創建一個shop.html模板文件:





Hello Shop


Hello Shop





... ...



複製代碼

我們看到有了SpringBoot,創建一個Spring MVC程序整個過程非常簡單: 1 引入starter 2 創建@Controller,設置@RequestMapping 3 創建模板文件 沒有任何配置工作,一切都是starter自動配置。

快速配置ViewController

幾乎所有Spring MVC的擴展點都集成在了接口中,要進行擴展很簡單,實現這個接口,加上@Configuration和@EnableWebMvc註解,實現需要的方法即可。 我們先用它來快速配置一些ViewController:

package me.josephzhu.spring101webmvc;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.http.HttpStatus;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.*;
import org.springframework.web.servlet.resource.GzipResourceResolver;
import org.springframework.web.servlet.resource.VersionResourceResolver;
import java.util.List;
@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/hello").setViewName("helloworld");
registry.addRedirectViewController("/", "/hello");
registry.addStatusController("/user", HttpStatus.BAD_REQUEST);
}
}
複製代碼

代碼中多貼了一些後面會用到的import在這裡可以忽略。這裡我們配置了三套策略: 1 訪問/會跳轉到/hello 2 訪問/hello會訪問helloworld這個view 3 訪問/user會給出400的錯誤代碼 這裡我們在templats目錄再添加一個空白的helloworld.html:





Hello World <div class="lazy-load-ad"></div>


Hello World


複製代碼

這種配置方式可以省一些代碼量,但是我個人認為在這裡做配置可讀性一般。

定製路徑匹配

我們還可以實現路徑匹配策略的定製:

@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.setUseTrailingSlashMatch(false);
}
複製代碼

比如這樣就關閉了結尾為/的匹配(默認開啟)。試著訪問http://localhost:8080/shop/得到如下錯誤:

2018-10-02 18:58:16.581 WARN 20264 --- [nio-8080-exec-1] o.s.web.servlet.PageNotFound : No mapping found for HTTP request with URI [/shop/] in DispatcherServlet with name 'dispatcherServlet'
複製代碼

這個方法可以針對路徑匹配進行相當多的配置,具體請參見文檔,這裡只列出了其中的一個功能。

配置靜態資源

在配置類加上下面的代碼:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new GzipResourceResolver())
.addResolver(new VersionResourceResolver()
.addFixedVersionStrategy("1.0.0", "/**"));
}
複製代碼

這就實現了靜態資源路由到static目錄,並且為靜態資源啟用了Gzip壓縮和基於版本號的緩存。配置後我們在resources目錄下創建一個static目錄,然後隨便創建一個a.html文件,試試訪問這個文件,測試可以發現:http://localhost:8080/static/1.0.0/a.html和http://localhost:8080/static/a.html都可以訪問到這個文件。

解析自定義的參數

HandlerMethodArgumentResolver接口這是一個非常非常重要常用的擴展點。通過這個接口,我們可以實現通用方法來裝配HandlerMethod上的自定義參數,我們現在來定義一個MyDevice類型,然後我們希望框架可以在所有出現MyDevice參數的時候自動為我們從Header裡獲取相應的設備信息構成MyDevice對象(如果我們API的使用者是客戶端應用程序,這是不是一個挺常見的需求)。

package me.josephzhu.spring101webmvc;
import lombok.Data;
@Data
public class MyDevice {
private String type;
private String version;
private String screen;
}
複製代碼

然後是自定義的HandlerMethodArgumentResolver實現:

package me.josephzhu.spring101webmvc;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
public class DeviceHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
return methodParameter.getParameterType().equals(MyDevice.class);
}
@Override
public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
MyDevice myDevice = new MyDevice();
myDevice.setType(nativeWebRequest.getHeader("device.type"));
myDevice.setVersion(nativeWebRequest.getHeader("device.version"));
myDevice.setScreen(nativeWebRequest.getHeader("device.screen"));
return myDevice;
}
}
複製代碼

實現分兩部分,第一部分告訴框架,我們這個ArgumentResolver支持解析怎麼樣的參數。這裡我們的實現是根據參數類型,還有很多時候可以通過檢查是否參數上有額外的自定義註解來實現(後面也會有例子)。第二部分就是真正的實現了,實現非常簡單,從請求頭裡獲取相應的信息構成我們的MyDevice對象。 要讓這個Resolver被MVC框架識別到,我們需要繼續擴展剛才的WebConfig類,加入下面的代碼:

@Override
public void addArgumentResolvers(List resolvers) {
resolvers.add(new DeviceHandlerMethodArgumentResolver());

}
複製代碼

然後,我們寫一個例子來測試一下:

package me.josephzhu.spring101webmvc;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@RestController
@Slf4j
@RequestMapping("api")
public class MyRestController {
@RequestMapping(value = "items", method = RequestMethod.GET)
public List getItems(MyDevice device) {
log.debug("Device : " + device);
List myItems = new ArrayList<>();
myItems.add(new MyItem("aa", 10));
myItems.add(new MyItem("bb", 20));
return myItems;
}
}
複製代碼

這裡因為用了debug,所以需要在配置文件中打開debug日誌級別:

logging.level.me.josephzhu.spring101webmvc=DEBUG
複製代碼

測試一下:

curl -X GET \
http://localhost:8080/api/items \
-H 'device.screen: 1280*800' \

-H 'device.type: android' \
-H 'device.version: 1.1'
複製代碼

可以在控制檯看到這樣的日誌:

2018-10-02 19:10:56.667 DEBUG 20325 --- [nio-8080-exec-9] m.j.spring101webmvc.MyRestController : Device : MyDevice(type=android, version=1.1, screen=1280*800)
複製代碼

可以證明我們方法中定義的MyDevice的確是從請求中獲取到了正確的結果。大家可以發揮一下想象,ArgumentResolver不但可以做類似參數自動裝配(從各個地方獲取必要的數據)的工作,而且還可以做驗證工作。大家可以仔細看一下resolveArgument方法的參數,是不是相當於要啥有啥了(當前參數定義、當前請求、Model容器以及綁定工廠)。

自定義ResponseBody後處理

在剛才的實現中,我們直接返回了List數據,對於API來說,我們一般會定義一套API的結果對象,包含API的數據、成功與否結果、錯誤消息、簽名等等內容,這樣客戶端可以做簽名驗證,然後是根據成功與否來決定是要解析數據還是直接提示錯誤,比如:

package me.josephzhu.spring101webmvc;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class APIResponse {

T data;
boolean success;
String message;
String sign;
}
複製代碼

如果我們在每個API方法中去返回這樣的APIResponse當然可以實現這個效果,還有一種通用的實現方式是使用ResponseBodyAdvice:

package me.josephzhu.spring101webmvc;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@ControllerAdvice
public class APIResponseBodyAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class extends HttpMessageConverter>> converterType) {
return !returnType.getParameterType().equals(APIResponse.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class extends HttpMessageConverter>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
String sign = "";
Sign signAnnotation = returnType.getMethod().getAnnotation(Sign.class);
if (signAnnotation != null)
sign = "abcd";
return new APIResponse(body, true, "", sign);
}
}
複製代碼

通過定義@ControllerAdvice註解來啟用這個Advice。在實現上也是兩部分,第一部分告訴框架我們這個Advice支持的是非APIResponse類型(如果返回的對象已經是APIResponse了,我們當然就不需要再包裝一次了)。第二部分是實現,這裡的實現很簡單,我們先檢查一下方法上是否有Sign這個註解,如果有的話進行簽名(這裡的邏輯是寫死的簽名),然後把得到的body塞入APIResponse後返回。 這裡補上Sign註解的實現:

package me.josephzhu.spring101webmvc;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sign {
}
複製代碼

這是一個空註解,沒啥可以說的,下面我們來測試一下這個ResponseBodyAdvice:

@RequestMapping(value = "item/{id}", method = RequestMethod.GET)
public MyItem getItem(@PathVariable("id") String id) {
Integer i = null;
try {
i = Integer.parseInt(id);
} catch (NumberFormatException ex) {
}
if (i == null || i < 1)
throw new IllegalArgumentException("不合法的商品ID");
return new MyItem("item" + id, 10);
}
複製代碼

訪問http://localhost:8080/api/item/23後得到如下圖的結果:

朱曄和你聊Spring系列S1E4:靈活但不算好用的Spring MVC

是不是很方便呢?這個API包裝的過程可以由框架進行,無需每次手動來做。

自定義異常處理

如果我們訪問http://localhost:8080/api/item/0會看到錯誤白頁,針對錯誤處理,我們希望:

  1. 可以使用統一的APIResponse方式進行錯誤返回
  2. 可以記錄錯誤信息以便查看 實現這個功能非常簡單,我們可以通過@ExceptionHandler實現:
package me.josephzhu.spring101webmvc;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import javax.servlet.http.HttpServletRequest;
@ControllerAdvice(annotations = RestController.class)
@Slf4j
public class MyRestExceptionHandler {
@ExceptionHandler
@ResponseBody
public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
log.error(String.format("訪問 %s -> %s 出錯了!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(null, false, ex.getMessage(), "");
}
}
複製代碼

注意幾點:

  1. 我們可以使用@ControllerAdvice的annotations來關聯我們需要攔截的Controller類型
  2. handle方法支持相當多的參數,可謂是要啥有啥,這裡貼下官方文檔說明的截圖(在這裡我們使用了ServletRequest來獲取請求地址,使用了HandlerMethod來獲取當前執行的方法):
朱曄和你聊Spring系列S1E4:靈活但不算好用的Spring MVC


訪問地址http://localhost:8080/api/item/sd可以看到如下輸出:

朱曄和你聊Spring系列S1E4:靈活但不算好用的Spring MVC

(注意,處理簽名的ResponseBodyAdvice並不會針對這個返回進行處理,因為之前實現的時候我們就判斷了返回內容不是APIResponse才去處理,在自己正式的實現中你可以實現的更合理,讓簽名的處理邏輯同時適用出現異常的情況)日誌中也出現了錯誤信息:

2018-10-02 19:48:41.450 ERROR 20422 --- [nio-8080-exec-6] m.j.s.MyRestExceptionHandler : 訪問 /api/item/sd -> public me.josephzhu.spring101webmvc.MyItem me.josephzhu.spring101webmvc.MyRestController.getItem(java.lang.String) 出錯了!
java.lang.IllegalArgumentException: 不合法的商品ID
at me.josephzhu.spring101webmvc.MyRestController.getItem(MyRestController.java:34) ~[classes/:na]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_161]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_161]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_161]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_161]
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:209) ~[spring-web-5.0.9.RELEASE.jar:5.0.9.RELEASE]
複製代碼

自動處理參數類型轉換

比如有這麼一個需求,我們希望可以接受自定義的枚舉作為參數,而且枚舉的名字不一定需要和請求的參數完全大小寫匹配,這個時候我們需要實現自己的轉換器:

package me.josephzhu.spring101webmvc;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterFactory;
import java.util.Arrays;
public class MyConverterFactory implements ConverterFactory {
@Override
public Converter getConverter(Class targetType) {
return new String2EnumConverter(targetType);
}
class String2EnumConverter> implements Converter {
private Class enumType;
private String2EnumConverter(Class enumType) {
this.enumType = enumType;
}
@Override
public T convert(String source) {
return Arrays.stream(enumType.getEnumConstants())

.filter(e -> e.name().equalsIgnoreCase(source))
.findAny().orElse(null);
}
}
}
複製代碼

這裡實現了一個從字符串到自定義枚舉的轉換,在搜索枚舉名字的時候我們忽略了大小寫。 接下去我們通過WebConfig來註冊這個轉換器工廠:

@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverterFactory(new MyConverterFactory());
}
複製代碼

來寫一段代碼測試一下:

@GetMapping("search")
public List search(@RequestParam("type") ItemTypeEnum itemTypeEnum) {
return IntStream.range(1, 5)
.mapToObj(i -> new MyItem(itemTypeEnum.name() + i, i * 100))
.collect(Collectors.toList());
}
複製代碼

這是一個Get請求的API,接受一個type參數,參數是一個自定義枚舉:

package me.josephzhu.spring101webmvc;
public enum ItemTypeEnum {
BOOK, TOY, TOOL
}
複製代碼

很明顯枚舉的名字都是大寫的,我們來訪問一下地址http://localhost:8080/api/search?type=TOy 測試一下程序是否可以正確匹配:

朱曄和你聊Spring系列S1E4:靈活但不算好用的Spring MVC

TOy的搜索參數匹配到了TOY枚舉,結果符合我們的預期。

自定義攔截器

最後,我們來看看Spring MVC最通用的擴展點,也就是攔截器。

朱曄和你聊Spring系列S1E4:靈活但不算好用的Spring MVC

這個圖清晰展現了攔截器幾個重要方法事件節點。在這個例子中,我們利用preHandle和postHandle兩個方法實現可以統計請求執行耗時的攔截器:

package me.josephzhu.spring101webmvc;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
public class ExecutionTimeHandlerInterceptor extends HandlerInterceptorAdapter {

private static final String START_TIME_ATTR_NAME = "startTime";
private static final String EXECUTION_TIME_ATTR_NAME = "executionTime";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
long startTime = System.currentTimeMillis();
request.setAttribute(START_TIME_ATTR_NAME, startTime);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
long startTime = (Long) request.getAttribute(START_TIME_ATTR_NAME);
long endTime = System.currentTimeMillis();
long executionTime = endTime - startTime;
String time = "[" + handler + "] executeTime : " + executionTime + "ms";
if (modelAndView != null) {
modelAndView.addObject(EXECUTION_TIME_ATTR_NAME, time);
}
log.debug(time);
}
}
複製代碼

在實現的時候,我們不僅僅把執行時間輸出到了日誌,而且還通過修改ModelAndView對象把這個信息加入到了視圖模型內,這樣頁面也可以展現這個時間。要啟用攔截器,我們還需要配置WebConfig:

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new ExecutionTimeHandlerInterceptor());
}
複製代碼

接下去我們運行剛才那個例子,可以看到如下的日誌輸出:

2018-10-02 19:58:22.189 DEBUG 20422 --- [nio-8080-exec-9] m.j.s.ExecutionTimeHandlerInterceptor : [public java.util.List me.josephzhu.spring101webmvc.MyRestController.search(me.josephzhu.spring101webmvc.ItemTypeEnum)] executeTime : 22ms 

複製代碼

頁面上也可以引用到我們添加進去的對象:





Hello World


Hello World



複製代碼

攔截器是非常通用的一個擴展,可以全局實現權限控制、緩存、動態修改結果等等功能。

總結和討論Spring MVC

本文我們通過一個一個例子展現了Spring MVC的一些重要擴展點:

  1. 使用攔截器做執行時間統計
  2. 自定義ResponseBodyAdvice來處理API的包裝
  3. 自定義ExceptionHandler來統計錯誤處理
  4. 自定義ConverterFactory來解析轉換枚舉
  5. 自定義ArgumentResolver來組裝設備信息參數
  6. 快速實現靜態資源、路徑匹配以及ViewController的配置

其實Spring MVC還有很多擴展點,比如模型參數綁定和校驗、允許我們實現動態的RequestMapping甚至是DispatcherServlet進行擴展,你可以繼續自行研究。 最後,我想說說我對Spring MVC的看法,總體上我覺得Spring MVC實現很靈活,擴展點很多,幾乎每一個組件都是松耦合,允許我們自己定義和替換。但是我覺得它的實現有點過於鬆散。ASP.NET MVC的實現我就挺喜歡,相比Spring MVC,ASP.NET MVC的兩個ActionFilter和ActionResult的實現是亮點:

  • ActionFilter機制。Controller裡面的每一個方法稱作Action,我們可以在每一個Action上加上各種註解來啟用ActionFilter,ActionFilter可以針對Action執行前、後、出異常等等情況做回調處理。ASP.NET MVC的ActionFilter的Filer級別是方法,粒度上比攔截器精細很多,而且配置更直觀。Spring MVC雖然除了攔截器還有ArgumentResolver以及ReturnValueHandler可以分別進行參數處理和返回值處理,但是這兩套擴展體系也是基於框架層面的,如果要和方法打通還需要自定義註解來實現。總覺得Spring MVC的這三套擴展點相互配合功能上雖然完整,但是有種支離破碎的感覺,如果我們真的要實現很多功能的,話可能會在這裡有相當多的if-else,沒有ActionFilter來得直觀。
  • 方法的返回值可以是ModelAndView,可以是直接輸出到@ResponseBody的自定義類型,這兩種輸出類型的分法可以滿足我們的需求,但是總感覺很彆扭。在ASP.NET MVC中的方法返回抽象為了ActionResult,可以是ViewResult、JsonResult、FileContentResult、RedirectResult、FilePathResult、JavaScriptResult等等,正如其名,看到返回值我們就可以看到方法實際的輸出表現,非常直觀容易理解。 ASP.NET MVC並沒有大量依賴IOC和AOP來實現,而是由框架的整體結構實現了插件機制,本質上這和Spring的風格就不同,加上Spring MVC從簡化Servlet開始演化,兩者理念上的區別也決定了設計上的區別,因此Spring MVC這樣設計我也能理解。


分享到:


相關文章: