SpringMvc 初体验之常用的组件与配置 (四)

1,116 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

  • 拦截器
  • 全局声明
  • 文件上传/下载
  • HttpMessageConverter
  • SpringMvc的测试;

SpringMvc 除了常用的Controller 控制器以外,还有很多组件是需要我们了解的。

自定义拦截器

​ 拦截器可以处理在请求前后的业务逻辑,类似 Servlet 的Filter。实现上,一般是继承 HandlerInterceptorAdapter 类 或者 实现 HandlerInterceptor 即可实现自定义的拦截器。

​ 然后通过在实现 WebMvcConfigurer 的实现类中重写 addInterceptors 方法,将自定义的拦截器bean注册到SpringMvc的拦截器链中。

  • 继承 HandlerInterceptorAdapter 类,自定义拦截器
public class XssInterceptor extends HandlerInterceptorAdapter {

    /**
     * 请求前处理
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("拦截器前置方法");
        long beginTime = System.currentTimeMillis();
        request.setAttribute("beginTime",beginTime);
        return super.preHandle(request, response, handler);
    }

    /**
     * 请求后处理
     * @param request
     * @param response
     * @param handler  执行方法
     * @param modelAndView 模型视图
     * @throws Exception
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

        long beginTime = (Long)request.getAttribute("beginTime");

        request.removeAttribute("beginTime");

        System.out.println("请求耗时 :" + (System.currentTimeMillis() - beginTime) );

    }
}
  • 在mvc的配置类中,重写addInterceptor 方法,注册自定义的拦截器;
@EnableWebMvc
@Configuration
@ComponentScan(value = "com.example.controller")
public class WebMvcConfig implements WebMvcConfigurer {

    /**
     * 视图解析器的bean配置
     * @return
     */
    @Bean
    public ViewResolver viewResolver() {
        System.out.println("viewResolver init");
        InternalResourceViewResolver resolver = new InternalResourceViewResolver();
        /**
         * 如果需要在jsp中使用jstl标签的话,需要加上这个视图,且要用这个引用,否则会报找不到方法的500错误
         * 项目中使用JSTL,SpringMVC会把视图由InternalView转换为JstlView。
         * 若使用Jstl的fmt标签,需要在SpringMVC的配置文件中配置国际化资源文件。
         * 需要引入 jstl.jar和standard.jar
         */
//        resolver.setViewClass(org.springframework.web.servlet.view.JstlView.class);
        resolver.setPrefix("/WEB-INF/page/");
        resolver.setSuffix(".jsp");
        resolver.setExposeContextBeansAsAttributes(true);
        return resolver;
    }


    @Bean
    public XssInterceptor initXssInterceptor() {
        return new XssInterceptor();
    }

    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }

    /**
     * 静态资源的配置
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        System.out.println("addResourceHandlers init....");
        registry.addResourceHandler("/css/**").addResourceLocations("/WEB-INF/statics/css/");
        registry.addResourceHandler("/js/**").addResourceLocations("/WEB-INF/statics/js/");
        registry.addResourceHandler("/image/**").addResourceLocations("WEB-INF/statics/image/");
    }

    /**
     * 注册拦截器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(initXssInterceptor());
    }
}

全局声明配置

​ 通过使用注解@ControllerAdvice 可以将对于控制器的全局配置都放在一个位置里,注解了@Controller 的类的方法可使用@ExceptionHandler、@InitBinder、@ModelAttritbute 注解到方法上,这对所有注解了@RequestMapping 的控制器内的方法有效。

  • @ExceptionHandler:用于全局处理控制器里的异常;
  • @InitBinder :用来设置 WebDataBinder,WebDataBinder 用来自动绑定前台请求参数到Model 中;
  • @ModelAttribute:用于绑定键值对到Model对象中,在 @ControllerAdvice中注解表示所有的@RequestMappering注解的方法都能获取到该键值对;

常用于处理全局异常的配置。

juejin.cn/post/703414…

文件的上传与下载

SpringMvc有着文件上传解析的接口,MultipartResolver,里面定义了判断请求是否为文件上传且解析Http请求为文件类型。 默认有两个实现:CommonsMultipartResolverStandardServletMultipartResolver 。具体可在MultipartResolver接口中查看注释。

这里以CommonsMultipartResolver实现来写一个demo。

  1. 引入依赖

CommonsMultipartResolver实现依赖于commons-fileupload包,需要额外引入

    <!-- 上传 -->
        <dependency>
            <groupId>commons-fileupload</groupId>
            <artifactId>commons-fileupload</artifactId>
            <version>1.3.1</version>
        </dependency>

        <!-- 非必须,方便文件的写入-->
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.8.0</version>
        </dependency>
  1. 配置文件上传解析的bean

这里用了CommonsMultipartResolver实现

   @Bean
    public MultipartResolver multipartResolver() {
        CommonsMultipartResolver resolver = new CommonsMultipartResolver();
        resolver.setMaxUploadSize(100 * 1024 * 1024);   // 10M
        return resolver;
    }
  1. 编写上传接口
@Controller
public class UploadController {

    /**
     * 页面跳转
     */
    @RequestMapping(value = "/toUpload")
    public String toUpload() {
        return "upload";
    }

    /**
     * 上传接口
     * @param file 使用 MultipartFile 作为参数属性,接收multipart/form-data类型的参数文件
     */
    @ResponseBody
    @RequestMapping(value = "/upload",method = RequestMethod.POST)
    public String uploadFile(MultipartFile file) {
        try {
            FileUtils.writeByteArrayToFile(new File("E:\\code\\log\\" + file.getOriginalFilename()),file.getBytes());
            return "ok";
        }catch (Exception e) {
            e.printStackTrace();
            return "wrong";
        }
    }
}

  1. 前端页面测试
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<body>
<div class="icon-upload">
    <form action="upload" enctype="multipart/form-data" method="post">
        <input type="file" name="file"/><br/>
        <input type="submit" value="上传"/>
    </form>
</div>
</body>
</html>

原理可在前端控制器DispatcherServlet中查看控制流程。简单来说就是会先判断是否存在 multipartResolver的bean,然后判断该请求是否为文件上传,如果是的话,将文件解析为参数。然后就是SpringMvc的请求流程,如先找到HandlerAdpter、执行各拦截器的方法等。最后会清除用于文件上传的资源,例如用于存储上传文件的存储。(貌似是存储在硬盘的某个临时文件中的)

HttpMessageConverter

HttpMessageConverter 是用来处理request和reponse里的数据的。Spring内部有大量的HttpMessageConverter实现类,在WebMvcConfigurationSupport#addDefaultHttpMessageConverters中可以看到。常用来解决请求时参数对方法参数的转换等。

自定义HttpMessageConverter

假设场景是解析特定字符串为某个对象,如参数的格式是x-y这样的格式,而在接口方法中需要转换为DemoObj(x,y)这样的实例对象,那么就需要自定义转换器类实现了。

1. 继承AbstractHttpMessageConverter

public class DemoMessageConverter extends AbstractHttpMessageConverter<DemoObj> {
    @Override
    protected boolean supports(Class<?> clazz) {
        return DemoObj.class.isAssignableFrom(clazz);   // 判断当前参数对象是否为DemoObj这个类
    }

    /**、
     * 处理请求数据,即将x-y转为DemoObj对象
     */
    @Override
    protected DemoObj readInternal(Class<? extends DemoObj> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        String tmp = StreamUtils.copyToString(inputMessage.getBody(), Charset.forName("UTF-8"));    // 将请求体转为String类型
        String[] tmpArr = tmp.split("-"); // 该请求参数的格式为x-y
        return new DemoObj(Integer.parseInt(tmpArr[0]),tmpArr[1]);  // 转换为目标对象
    }

    /**
     * 处理返回数据,即将DemoObj转为 x-y 的格式
     */
    @Override
    protected void writeInternal(DemoObj demoObj, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        String result = demoObj.getId() + "-" + demoObj.getName();
        outputMessage.getBody().write(result.getBytes());
    }
}

2. WebMvcConfig下新增消息转换器

这里用了Java配置,且用了extendMessageConverters方法新增消息转换器。 注册转换器还有个方法:configureMessageConverters,该方法会覆盖掉SpringMvc默认注册的多个HttpMessageConverter。

@EnableWebMvc
@Configuration
@ComponentScan(value = "com.example.controller")
public class WebMvcConfig implements WebMvcConfigurer {
//... 省略其它配置
  /**
     * 新增消息处理器
     * @param converters
     */
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(demoMessageConverter());
    }

    @Bean
    public DemoMessageConverter demoMessageConverter() {
        return new DemoMessageConverter();
    }
}

3. Controller

@RestController
public class ConvertDemoController {

    @RequestMapping(value = "/convert")
    public DemoObj convert(@RequestBody DemoObj demoObj) {
        System.out.println("请求demo转换器");
        return demoObj;
    }
}

类型转换器Convertor

​ Convertor 是SpringMvc 提供给我们使用的类型转换器接口,通过自定义的类型转换器就可实现在请求之前对参数进行类型转换;

自定义类型转换器

​ 假设需要自定义请求的格式,需要将特殊的String字符串解析成Java Bean,那么就可以利用自定义类型转换器做请求参数的解析并转化为bean。

1. 实现Converter接口

实现 Formatter 接口,重写parse 和 print 方法。

Formatter 只能将String 转换为另一种Java类型。

JavaBean的格式是这样的。

public class Student {

    private int sid;

    private String name;

    private int age;

    private LocalDate createTime;
    
    // 省略 set get...
}

1. 实现 Formatter

  • StringToLocalDateFormatter 类型转换器

    目的是将请求参数中的String日期转换为LocalDate的类型。

public class StringToLocalDateFormatter implements Formatter<LocalDate> {

    private DateTimeFormatter formatter;
    private String datePattern;

    public StringToLocalDateFormatter(String datePattern) {
        this.datePattern = datePattern;
        this.formatter = DateTimeFormatter.ofPattern(datePattern);
    }

    /**
     * 利用指定的 Local 将一个String解析成目标类型
     */
    @Override
    public LocalDate parse(String source, Locale time) throws ParseException {
        System.out.println("StringToLocalDateFormatter converter..");
        return LocalDate.parse(source,DateTimeFormatter.ofPattern(datePattern));
    }

    @Override
    public String print(LocalDate date, Locale locale) {
        return date.format(formatter);
    }
}

2. Java配置新增自定义的类型转换器

@EnableWebMvc
@Configuration
@ComponentScan(value = "com.example.controller")
public class WebMvcConfig implements WebMvcConfigurer {
	
	
	@Bean
    public StringToLocalDateFormatter stringToLocalDateFormatter() {
        return new StringToLocalDateFormatter("yyyy-MM-dd");
    }
    
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatter(stringToLocalDateFormatter());
    }

}

3. 接口请求

@RestController
@RequestMapping("/student")
public class StudentController {

    @GetMapping(value = "/converter")
    public String getStudentInfo(Student student) {
        System.out.println(student);
        return "success:" + student;
    }
}

当使用 localhost:8080/student/converter?sid=122&name=zhangsan&age=34&createTime=2021-11-07 访问接口时,即可将String 的日期转换为LocalDate的格式,同时注入Student的对象中。

相关Resolver

​ Resolver 解析器,除开常见的视图解析器(InternalResourceViewResolver) 以外,参数解析器 以及 返回值解析器也是SpringMvc 常用的组件。

HandlerMethodArgumentResolver

请求参数解析器,包含以下两个方法:

  • supportsParameter:判断是否支持解析;返回true表示进入解析方法;
  • resolveArgument:解析参数,注入值;

注:在常用的HandlerAdapter:RequestMappingHandlerAdapter#getDefaultArgumentResolvers方法中可以看到很多默认的参数解析器。

以获取用户登录态为例

1. 自定义注解

@Login,方法注解,注解在方法上表明需要进行登录拦截

@Target(value ={ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Login {
}

@LoginUser,参数注解,用于解析请求注入到方法参数

/**
 * 登录用户信息
*/
@Target(value = {ElementType.PARAMETER})  //作用在参数上
@Retention(RetentionPolicy.RUNTIME)  //运行时检查
public @interface LoginUser {

}

2. 自定义拦截器

自定义权限(token)拦截器,拦截方法上注解了 @Login的方法,解析请求头的数据,放入到请求属性中,用于后面的参数解析注入;

/**
 * 权限(token)验证
 */
@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {


    @Autowired
    private JwtUtils jwtUtils;

    public static final String USER_KEY = "userId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Login annotation;
        if(handler instanceof HandlerMethod) {
            annotation = ((HandlerMethod) handler).getMethodAnnotation(Login.class);
        }else{
            return true;
        }

        if(annotation == null){
            return true;
        }

        //获取用户凭证
        String token = request.getHeader(jwtUtils.getHeader());
        if(StringUtils.isBlank(token)){
            token = request.getParameter(jwtUtils.getHeader());
        }

        //凭证为空
        if(StringUtils.isBlank(token)){
            throw new RRException(jwtUtils.getHeader() + "不能为空", HttpStatus.UNAUTHORIZED.value());
        }

        Claims claims = jwtUtils.getClaimByToken(token);
        if(claims == null || jwtUtils.isTokenExpired(claims.getExpiration())){
            throw new RRException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
        }

        //设置userId到request里,后续根据userId,获取用户信息
        request.setAttribute(USER_KEY, Integer.valueOf(claims.getSubject()));

        return true;
    }

}

3. 自定义方法参数解析器

/**
 * 有@LoginUser注解的方法参数,注入当前登录用户
 */
@Component
@Slf4j
public class LoginUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {

    @Autowired
    private UserService userService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(User.class) && parameter.hasParameterAnnotation(LoginUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
                                  NativeWebRequest request, WebDataBinderFactory factory) throws Exception {
        //获取用户ID
        Object object = request.getAttribute(AuthorizationInterceptor.USER_KEY, RequestAttributes.SCOPE_REQUEST);
        if(object == null){
            return null;
        }

        log.info("获取到的用户ID为{}",object);
        //获取用户信息
        User user = userService.getUserById((Integer)object);

        return user;
    }
}
  1. mvc配置 自定义拦截器和参数解析器
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private AuthorizationInterceptor authorizationInterceptor;
    @Autowired
    private LoginUserHandlerMethodArgumentResolver loginUserHandlerMethodArgumentResolver;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 拦截user路径下的请求
        registry.addInterceptor(authorizationInterceptor).addPathPatterns("/user/**");
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserHandlerMethodArgumentResolver);
    }
}
  1. 在Handler上使用注解,实现用户登录态的自动注入;
@Login
@GetMapping(value = "/user/devices")
public R getDeviceList(@LoginUser User user){
	// do something
}

注解了 @LoginUser 的 User对象,将从请求头中自动注入当前登录态的用户信息。

HandlerMethodReturnValueHandler

返回值解析处理器

在Controller方法中加上@ResponseBody可以将返回值解析为Json 格式,而这即是实现了HandlerMethodReturnValueHandler接口的。

实现类在RequestResponseBodyMethodProcessor

public interface HandlerMethodReturnValueHandler {

    /**
    * 是否支持该类型,当返回true时表示使用该实现
    * param returnType:返回类型
    **/
	boolean supportsReturnType(MethodParameter returnType);

    /***
    * 当supportsReturnType返回true时调用该方法,处理返回值
    */
	void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
			ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;

}

测试

SpringMvc的测试需要Spring的上下文,以及Servlet相关的一些模拟对象,如MockMvc、MockHttpServletRequest、MockHttpServletResponse、MockHttpSession等。

添加依赖

  <!-- 测试 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>${spring-version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>

相关注解

  • @RunWith(SpringJUnit4ClassRunner.class)

    表示使用Spring集成的Juint4测试。

  • @ContextConfiguration(locations = "classpath:application-context.xml")

    表示指定Spring上下文的环境,这里指定的xml文件。也可以指向Java配置。

测试

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:application-context.xml")
@WebAppConfiguration        // 表示加载的ApplicationContext是一个WebApplicationContext
public class StudentControllerTest {

    private MockMvc mockMvc;    // 模拟的mvc对象

    @Autowired
    private StudentService service;

    @Autowired
    WebApplicationContext wac;  // web的应用上下文

    @Autowired
    MockHttpSession session;

    @Autowired
    MockHttpServletRequest request;

    @Before
    public void setUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
    }

    @Test
    public void testIndexPage() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/index"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("page"));
    }