阅读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这样设计我也能理解。