閱讀PDF版本html
本文會以一些例子來展示Spring MVC的常見功能和一些擴展點,而後咱們來討論一下Spring MVC好用很差用。java
基於以前的parent模塊,咱們來建立一個新的模塊:android
<?xml version="1.0" encoding="UTF-8"?> <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>me.josephzhu</groupId> <artifactId>spring101-webmvc</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <name>spring101-webmvc</name> <description></description> <parent> <groupId>me.josephzhu</groupId> <artifactId>spring101</artifactId> <version>0.0.1-SNAPSHOT</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
使用web來啓用Spring MVC,使用thymeleaf來啓用thymeleaf模板引擎。Thymeleaf是一個強大的Java模板引擎,能夠脫離於Web單獨使用,自己就有很是多的可配置可擴展的點,這裏不展開討論,詳見官網。 接下去咱們建立主程序: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; } }
這裏使用到了一個自定義的類:spring
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模板文件:apache
<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd"> <html lang="en"> <head> <meta charset="UTF-8"> <title>Hello Shop</title> </head> <body> Hello Shop <table> <tr th:each="item : ${items}"> <td th:text="${item.name}">...</td> <td th:text="${item.price}">...</td> </tr> </table> </body> </html>
咱們看到有了SpringBoot,建立一個Spring MVC程序整個過程很是簡單: 1 引入starter 2 建立@Controller,設置@RequestMapping 3 建立模板文件 沒有任何配置工做,一切都是starter自動配置。api
幾乎全部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:mvc
<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd"> <html lang="en"> <head> <meta charset="UTF-8"> <title>Hello World</title> </head> <body> Hello World </body> </html>
這種配置方式能夠省一些代碼量,可是我我的認爲在這裏作配置可讀性通常。app
咱們還能夠實現路徑匹配策略的定製:
@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<HandlerMethodArgumentResolver> 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<MyItem> getItems(MyDevice device) { log.debug("Device : " + device); List<MyItem> 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容器以及綁定工廠)。
在剛纔的實現中,咱們直接返回了List<MyItem>數據,對於API來講,咱們通常會定義一套API的結果對象,包含API的數據、成功與否結果、錯誤消息、簽名等等內容,這樣客戶端能夠作簽名驗證,而後是根據成功與否來決定是要解析數據仍是直接提示錯誤,好比:
package me.josephzhu.spring101webmvc; import lombok.AllArgsConstructor; import lombok.Data; @Data @AllArgsConstructor public class APIResponse<T> { 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<Object> { @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後獲得以下圖的結果:
是否是很方便呢?這個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來獲取當前執行的方法):
訪問地址http://localhost:8080/api/item/sd能夠看到以下輸出:
(注意,處理簽名的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<String, Enum> { @Override public <T extends Enum> Converter<String, T> getConverter(Class<T> targetType) { return new String2EnumConverter(targetType); } class String2EnumConverter<T extends Enum<T>> implements Converter<String, T> { private Class<T> enumType; private String2EnumConverter(Class<T> 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<MyItem> 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 測試一下程序是否能夠正確匹配:
TOy的搜索參數匹配到了TOY枚舉,結果符合咱們的預期。
最後,咱們來看看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.MyItem> me.josephzhu.spring101webmvc.MyRestController.search(me.josephzhu.spring101webmvc.ItemTypeEnum)] executeTime : 22ms
頁面上也能夠引用到咱們添加進去的對象:
<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-4.dtd"> <html lang="en"> <head> <meta charset="UTF-8"> <title>Hello World</title> </head> <body> Hello World <div th:text="${executionTime}"></div> </body> </html>
攔截器是很是通用的一個擴展,能夠全局實現權限控制、緩存、動態修改結果等等功能。
本文咱們經過一個一個例子展示了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的實現是亮點: 1 ActionFilter機制。Controller裏面的每個方法稱做Action,咱們能夠在每個Action上加上各類註解來啓用ActionFilter,ActionFilter能夠針對Action執行前、後、出異常等等狀況作回調處理。ASP.NET MVC的ActionFilter的Filer級別是方法,粒度上比攔截器精細不少,並且配置更直觀。Spring MVC雖然除了攔截器還有ArgumentResolver以及ReturnValueHandler能夠分別進行參數處理和返回值處理,可是這兩套擴展體系也是基於框架層面的,若是要和方法打通還須要自定義註解來實現。總以爲Spring MVC的這三套擴展點相互配合功能上雖然完整,可是有種支離破碎的感受,若是咱們真的要實現不少功能的,話可能會在這裏有至關多的if-else,沒有ActionFilter來得直觀。 2 方法的返回值能夠是ModelAndView,能夠是直接輸出到@ResponseBody的自定義類型,這兩種輸出類型的分法能夠知足咱們的需求,可是總感受很彆扭。在ASP.NET MVC中的方法返回抽象爲了ActionResult,能夠是ViewResult、JsonResult、FileContentResult、RedirectResult、FilePathResult、JavaScriptResult等等,正如其名,看到返回值咱們就能夠看到方法實際的輸出表現,很是直觀容易理解。 ASP.NET MVC並無大量依賴IOC和AOP來實現,而是由框架的總體結構實現了插件機制,本質上這和Spring的風格就不一樣,加上Spring MVC從簡化Servlet開始演化,二者理念上的區別也決定了設計上的區別,所以Spring MVC這樣設計我也能理解。