上一篇:https://blog.csdn.net/LawssssCat/article/details/105080690
下一篇:https://blog.csdn.net/LawssssCat/article/details/105257360

准备 RESTful API

上一章环境搭建,搭建结果: 下载

<mark>这章准备一堆 api ,后面基于这些 api 做 认证授权</mark>

主要内容

  1. 处理静态资源和异常
  2. 配置***
  3. 文件上传下载
  4. 异步请求开发
  5. Restful API 开发常用辅助框架
    生成服务文档
    mock : 伪造服务

GET /user (用户分页查询)(Pageable封装)

先写好测试类,指定期望的响应码 200 和 期望得到的数据长度 3

测试类

package cn.vshop.security.web.controller;

// 注意:这里用了静态导入了三个方法
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

/** * '@RunWith' 一个注解,能用来指定用什么运行器,来运行测试 * <p> * SpringRunner 运行器,会在测试开始的时候自动创建Spring的应用上下文 ApplicationContext * (如果不用SpringRunner作为运行器,默认会走SpringBoot提供的运行方法,即使用生产环境的入口做为启动的入口。) * (本例中,生产环境的入口为DemoApplication的main方法) * <p> * 这样做,避免了很多不必要的启动,如避免了内置的tomcat的启动(在测试环境不可能用到tomcat) * * @author alan smith * @version 1.0 * @date 2020/3/29 1:04 */
@RunWith(SpringRunner.class)
// @SpringBootTest会被SpringBoot在测试环境创建,并为其注入依赖(即会使@Autowired等注解生效)
@SpringBootTest
public class UserControllerTest {

    @Autowired
    private WebApplicationContext wac;

    // 伪造的一个mvc环境,发送和判断rest请求是否符合预期
    private MockMvc mockMvc;

    /** * 被@Before注解的方***在每个用例执行前执行 * 用于在每个测试案例执行前,伪造出mvc环境 */
    @Before
    public void setup() {
        // 通过 MockMvcBuilders 的 webAppContextSetup(WebApplicationContext context) 方法
        // 获取 DefaultMockMvcBuilder,再调用 build() 方法,初始化 MockMvc
        mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
    }

    /** * 用户查询成功的测试案例 */
    @Test
    public void whenQuerySuccess() throws Exception {
        mockMvc
                // 通过 perform(执行) 模拟执行发送请求,并接受返回值
                .perform(
                        // 模拟一个 get 请求
                        get("/user")
                                // 指定请求的类型为:application/json;charset=UTF-8
                                .contentType(MediaType.APPLICATION_JSON_UTF8)
                                //请求中包含的参数
                                .param("username", "jojo")
                                .param("age", "18")
                )
                // 写期望服务器端返回的东西
                .andExpect(
                        // 期望响应码是200
                        status().isOk()
                )
                // 写期望服务器端返回的东西
                .andExpect(
                        //解析返回的json内容
                        //“$.length()”的语法参考:https://github.com/jayway/JsonPath
                        //意思是:获取的响应是个数组,数组长度为3
                        jsonPath("$.length()").value(3)
                );
    }

}

(不是重点)jsonPath 的表达式参考:https://github.com/jayway/JsonPath
写的挺好的,还有例子

启动测试

启动报错说期望200响应码就对了,因为我们接口都还没写呢。

<mark>技巧:Java静态导入(import static)</mark>
.
在 Java 5 开始,我们能在导包的 import 后面 加上 static,表明这个包静态导入。
静态导入的好处是,可以不用谢导入部分的方法名。
JAVA静态导入(import static)详解

比如说:
<mark>上面代码就用到了静态导入,</mark>
由于经常要用到(下面)两个类的方法

  • MockMvcRequestBuilders (构建模拟的请求)
  • MockMvcResultMatchers (对模拟的请求的响应进行判断)

因此导入的时候就可以这么写(下面)

// Java静态导入方法
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

那么调用的时候直接写方法名即可

get()
jsonPath()
status()

相反如果是不是静态

import org.springframework.test.web.servlet.request.MockMvcResultMatchers;

调用时候就要
MockMvcResultMatchers.jsonPath()
MockMvcResultMatchers.status()

在链式调用的情况下听难受的。

<mark>这种写法可有可无,不过下面的测试方法均用这种静态导包 (能看懂就行)</mark>


接口实现

编写controller
编写完 controller,再运行测试应该就不报错了。

因为符合了预期的两个结果:200响应码,3条数据

package cn.vshop.security.web.controller;

import cn.vshop.security.dto.User;
import cn.vshop.security.dto.UserQueryCondition;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.builder.ReflectionToStringBuilder;
import org.apache.commons.lang.builder.ToStringStyle;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;

/** * @author alan smith * @version 1.0 * @date 2020/3/29 1:04 */
@Slf4j
@RestController
public class UserController {

    @RequestMapping(value = "/user", method = RequestMethod.GET)
    public List<User> query(
            UserQueryCondition uqc,
            /* * Pageable: 是springdata提供的一个分页查询接口 * 默认的实现是 PageRequest * 分页相关的属性分别为: * page 当前页码值 * size 当前页数据量 * sort 排序规则 * * @PageableDefault 指定一些默认值 */
            @PageableDefault(page = 1, size = 3, sort = "username,asc") Pageable p

    ) {
        // 格式化打印对象
        log.info(ReflectionToStringBuilder.toString(uqc, ToStringStyle.MULTI_LINE_STYLE));
        log.info(ReflectionToStringBuilder.toString(p, ToStringStyle.MULTI_LINE_STYLE));

        // 模拟查询结果
        ArrayList<User> users = new ArrayList<>();
        // 简单的包含用户密码信息
        users.add(new User(uqc.getUsername() + 1, "123"));
        users.add(new User(uqc.getUsername() + 2, "222"));
        users.add(new User(uqc.getUsername() + 3, "333"));

        return users;
    }
}

UserQueryCondition :查询 user 条件 VO 类

package cn.vshop.security.dto;

import lombok.Data;

/** * user 查询的VO类 * * @author alan smith * @version 1.0 * @date 2020/3/29 1:39 */
@Data
public class UserQueryCondition {
    private String username;
    private Integer age;
}

user

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class User {

    private String username;
    private String password;
}

如果不是返回3条数据,测试仍然失败,会报下面错


GET /user/1(用户详情)(JsonView过滤)

接口:GET /user/1

测试类 UserControllerTest 中添加测试用例

    /** * 调用 /user/1 成功 */
    @Test
    public void whenGenInfoSuccess() throws Exception {
        mockMvc.perform(get("/user/1").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.username").value("boo"));
    }

    /** * 调用 /user/1 失败 */
    @Test
    public void whenGetInfoFail() throws Exception {
        mockMvc.perform(
                    // 接口要求"/user/数字"
                    get("/user/a").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(
                    // 希望抛出4xx异常
                    status().is4xxClientError()
                );
    }

修改User类(指定 JsonView 的多个视图)

controller 返回对象,对象转成 json字符串 的过程中,我们不希望把 密码 传出去
如果重新写一个VO对象,过于麻烦,而且后期VO对象过多难以管理。

我们可以用 JsonView 注解解决这个没问题:<mark>@JsonView注解可以用来过滤序列化对象的字段属性</mark>

@JsonView 使用步骤

  • 使用接口来声明多个视图
  • 在值对象的get方法上指定视图
  • 在Controller方法上指定视图
package cn.vshop.security.dto;

import com.fasterxml.jackson.annotation.JsonView;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

/** * @author alan smith * @version 1.0 * @date 2020/3/29 1:07 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class User {

    /** * 1. 使用接口来声明多个视图 */
    interface UserSimpleView {
    }

    interface UserDetailView extends UserSimpleView {
    }

    /** * 2. 在值对象的get方法上指定视图 */
    @JsonView(UserSimpleView.class)
    private String username;
    /** * 因为,UserDetailView 继承了 UserSimpleView * 因此,被UserSimpleView注解的属性(如username)也是会被UserDetailView包含 */
    @JsonView(UserDetailView.class)
    private String password;
    /** * 余额 */
    @JsonView(UserSimpleView.class)
    private Integer balance;
}

@JsonIgnore:默认情况下忽略某些属性

添加 getInfo 接口实现,并且添加 @JsonView注解

同时改了一点东西:

  • @GetMapping 替换 @RequestMapping
  • 在类上声明 @RequestMapping("/user") 注解
package cn.vshop.security.web.controller;

import cn.vshop.security.dto.User;
import cn.vshop.security.dto.UserQueryCondition;
import com.fasterxml.jackson.annotation.JsonView;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.builder.ReflectionToStringBuilder;
import org.apache.commons.lang.builder.ToStringStyle;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;

/** * @author alan smith * @version 1.0 * @date 2020/3/29 1:04 */
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    /** * 3. 在Controller方法上指定视图 * 指定JsonView,声明对象Json转换后要留下的字符串 */
    @JsonView(User.UserSimpleView.class)
    @GetMapping
    public List<User> query(
            UserQueryCondition uqc,
            /* * Pageable: 是springdata提供的一个分页查询接口 * 默认的实现是 PageRequest * 分页相关的属性分别为: * page 当前页码值 * size 当前页数据量 * sort 排序规则 * * @PageableDefault 指定一些默认值 */
            @PageableDefault(page = 1, size = 3, sort = "username,asc") Pageable p
    ) {
        // 格式化打印对象
        log.info(ReflectionToStringBuilder.toString(uqc, ToStringStyle.MULTI_LINE_STYLE));
        log.info(ReflectionToStringBuilder.toString(p, ToStringStyle.MULTI_LINE_STYLE));

        // 模拟查询结果
        ArrayList<User> users = new ArrayList<>();
        // 简单的包含用户密码信息
        users.add(new User(uqc.getUsername() + 1, "123", 100));
        users.add(new User(uqc.getUsername() + 2, "222", 101));
        users.add(new User(uqc.getUsername() + 3, "333", 111));

        return users;
    }

    /** * 3. 在Controller方法上指定视图 * 指定JsonView,声明对象Json转换后要留下的字符串 */
    @JsonView(User.UserDetailView.class)
    @GetMapping(
            // 正则表达式判断请求 id 为数字
            // (如果直接用 Integer id 接收, 需要进行mvc的异常处理,没必要)
            "/{id:\\d+}"
    )
    public User getInfo(@PathVariable("id") String id) {
        User user = new User("boo", "111", 100);
        return user;
    }
}

修改后的测试类

修改:在 mockMVC 后添加 .andReturn().getResponse().getContentAsString(); 获取返回值。并且直接打印在控制台

package cn.vshop.security.web.controller;

// 注意:这里用了静态导入了三个方法

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import lombok.extern.slf4j.Slf4j;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

/** * '@RunWith' 一个注解,能用来指定用什么运行器,来运行测试 * <p> * SpringRunner 运行器,会在测试开始的时候自动创建Spring的应用上下文 ApplicationContext * (如果不用SpringRunner作为运行器,默认会走SpringBoot提供的运行方法,即使用生产环境的入口做为启动的入口。) * (本例中,生产环境的入口为DemoApplication的main方法) * <p> * 这样做,避免了很多不必要的启动,如避免了内置的tomcat的启动(在测试环境不可能用到tomcat) * * @author alan smith * @version 1.0 * @date 2020/3/29 1:04 */
@RunWith(SpringRunner.class)
// @SpringBootTest会被SpringBoot在测试环境创建,并为其注入依赖(即会使@Autowired等注解生效)
@SpringBootTest
@Slf4j
public class UserControllerTest {

    @Autowired
    private WebApplicationContext wac;

    // 伪造的一个mvc环境,发送和判断rest请求是否符合预期
    private MockMvc mockMvc;

    /** * 被@Before注解的方***在每个用例执行前执行 * 用于在每个测试案例执行前,伪造出mvc环境 */
    @Before
    public void setup() {
        // 通过 MockMvcBuilders 的 webAppContextSetup(WebApplicationContext context) 方法
        // 获取 DefaultMockMvcBuilder,再调用 build() 方法,初始化 MockMvc
        mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
    }

    /** * 用户查询成功的测试案例 */
    @Test
    public void whenQuerySuccess() throws Exception {
        String result = mockMvc
                // 通过 perform(执行) 模拟执行发送请求,并接受返回值
                .perform(
                        // 模拟一个 get 请求
                        get("/user")
                                // 指定请求的类型为:application/json;charset=UTF-8
                                .contentType(MediaType.APPLICATION_JSON_UTF8)
                                //请求中包含的参数
                                .param("username", "jojo")
                                .param("age", "18")
                )
                // 写期望服务器端返回的东西
                .andExpect(
                        // 期望响应码是200
                        status().isOk()
                )
                // 写期望服务器端返回的东西
                .andExpect(
                        //解析返回的json内容
                        //“$.length()”的语法参考:https://github.com/jayway/JsonPath
                        //意思是:获取的响应是个数组,数组长度为3
                        jsonPath("$.length()").value(3)
                )
                // 把接收到的响应返回,以字符串的形式
                .andReturn().getResponse().getContentAsString();
        // 打印响应返回值
        log.info(result);
    }

    /** * 调用 /user/1 成功 */
    @Test
    public void whenGetInfoSuccess() throws Exception {
        String result = mockMvc.perform(get("/user/1").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.username").value("boo"))
                .andReturn().getResponse().getContentAsString();
        log.info(result);
    }

    /** * 调用 /user/1 失败 */
    @Test
    public void whenGetInfoFail() throws Exception {
        mockMvc.perform(
                // 接口要求"/user/数字"
                get("/user/a").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(
                        // 希望抛出4xx异常
                        status().is4xxClientError()
                );
    }

}

测试:whenQuerySuccess

<mark>注意:这时候的 User 数据是 没有 password 的,因为 @JsonView 起作用</mark>

测试:whenGetInfoSuccess
<mark>注意:这时候的 User 数据是 有 password 的,因为 @JsonView 起作用</mark>

测试:whenGetInfoFail

三个测试同时运行

POST /user(创建用户)(参数校验和日期处理)

<mark>尤其在前后台分离,分布式,多渠道应用场景中,
后台应该以时间戳(timestamp)形式存储日期,不应该存储日期格式。
日期格式由前端决定如何展示</mark>

接口:POST /user

添加测试方法:whenCreateSuccess

    /** * 添加用户 * 调用 /user 成功 */
    @Test
    public void whenCreateSuccess() throws Exception {
        // 模拟前台以时间戳形式向后台传时间
        long timestamp = new Date().getTime();
        // post的内容(这里故意让username的值为空字符串 "" )
        String content = " {\"username\":\"\",\"password\":\"222\",\"balance\":100, \"birthday\":" + timestamp + "}";
        log.info(content);

        String result = mockMvc
                .perform(
                        // 发送post请求
                        post("/user")
                                .contentType(MediaType.APPLICATION_JSON_UTF8)
                                // 设置post的内容
                                .content(content)
                )
                // 响应200请求
                .andExpect(status().isOk())
                // 期望响应user的id=1
                .andExpect(jsonPath("$.id").value("1"))
                .andReturn().getResponse().getContentAsString();

        log.info( result);
    }

如果测试报错: Request method ‘POST’ not supported

是因为没有写接口,post 请求会被 get /user 接口获取,返回 405 状态码

在 User 类上添加 id 和 birthday 属性

添加后,controller 创建 User 的行上可能会报错,因为lambok自动生成全属性构造 @AllArgsConstructor,在构造函数里面添加新属性值就ok 了(也可以直接用 空构造 @NoArgsConstructor

    @JsonView(UserSimpleView.class)
    private String id ;
    @JsonView(UserSimpleView.class)
    private Date birthday ;

Controller 中添加方法

    /** * 模拟数据库创建用户 */
    @PostMapping
    public User createUser(@RequestBody User user) {
        user.setId("1");
        log.info(ReflectionToStringBuilder.toString(user, ToStringStyle.MULTI_LINE_STYLE));
        return user;
    }

测试:whenCreateSuccess

<mark>注意:这里请求中包含的是时间戳格式(long),响应收到的也是时间戳格式(long),所有前端语言也是能以时间戳形式装换到想要的日期格式的,这样就避免的后台因为识别不出格式而丢失数据甚至报错的情况</mark>

如果不加 @RequestBody,springmvc 无法 前端传来请求content中的 json 串
@RequestBody的使用


接下来,添加 @ValidBindingResult 验证参数的合法性并处理校验结果

添加 @NotBlank

在User 类中添加 @NotBlank注解,表明这个属性不能为 null、空字符串或者 空格 ·_·


添加@Valid
Controller 的 createUser 方法中 添加@Valid,进而让 User 类上 @NotBlank注解 在 createUser 方法上生效

如果故意把请求中的 username 改为不符合规则的 空格 " "
会返回 400 状态码 Bad Request
表示 <mark>服务端接收到了请求,但是拒绝服务</mark>

上面通过 @NotBlank 和 @Valid 校验出了错误,我们希望服务端能处理错误

用 BindingResult 参数处理校验结果

为了让服务端自定义处理 400 错误请求,我们添加 BindingResult 参数

只需要在 Controller 上加上这个类型的参数即可

    /** * 模拟数据库创建用户 */
    @PostMapping
    public User createUser(@Valid @RequestBody User user, BindingResult errors) {

        if(errors.hasErrors()) {
            // 有错误的话就把错误打印,并正常返回
            errors.getAllErrors().forEach(e -> log.error(e.getDefaultMessage()));
        }

        user.setId("1");
        log.info(ReflectionToStringBuilder.toString(user, ToStringStyle.MULTI_LINE_STYLE));
        return user;
    }

再次运行测试:whenCreateSuccess

我们可以看到

  • 测试测功:响应了200,并返回 id=1 响应返回值
  • 而同时用 @NotBlank 校验出传参有误
  • 和用 BindingResult 处理了参数错误

【整理】Hibernate Validator

除了上面用到的 @NotBlank 可以被 @Valid 触发,判断字符串是否有字符

下面是其他可用的 hibernate 校验注解

IBM Developer:Bean Validation 技术规范特性概述

官方文档:https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/

注解 解释
@NotNull 不能为空
@Null 值必须 为空
@Pattern(regex=) 字符串必须匹配 正则表达式
@Size(min=,max=) 集合的元素 数量 必须在 min 和 max之间
@CreditCardNumber(ignoreNonDigiCharacters=) 字符串必须是 信用卡号 (😃 按美国的标准校验的 😃)
@Email 字符串必须是 Email 地址
@Length(min=,max=) 校验字符串的长度
@NotBlank 字符串必须有字符
@NotEmpty 字符串 不为null 或集合有元素
@Range(min=,max=) 数字必须大于等于min小于等于max
@SafeHtml 字符串是安全的 html
@URL 字符串是合法的 URL
@AssertFalse 值必须是 false
@AssertTrue 值必须是 true
@DecimalMax(value=,inclusive=) 值必须 小于等于(inclusive=true) / 小于(inclusive=false) value属性指定的值。
支持类型:BigDecimal、BigInteger、CharSequence、byte、short、int、long
<mark>不支持:double 和 float</mark>
注意:String实现了CharSequence接口,所以支持String的
@DecimalMin(value=,inclusive=) 值必须 大于等于(inclusive=true) / 大于(inclusive=false) value属性指定的值。
支持类型:BigDecimal、BigInteger、CharSequence、byte、short、int、long
<mark>不支持:double 和 float</mark>
注意:String实现了CharSequence接口,所以支持String的
@Digits(integer=,fraction=) 数字格式校验。integer指定整 数部分的最大长度 ,fraction指定 小数部分的最大长度
@Future 值必须是 未来的日期
@Past 值必须是 过去的日期
@Max(value=) 值必须 小于等于value 指定的值。<mark>不能注解在字符串类型的属性上</mark>。
@Min(value=) 值必须 大于等于value 指定的值。<mark>不能注解在字符串类型的属性上</mark>。

每个注解都有一个属性:groups,结合@Validated注解
能实现 <mark>应对不同的情况,进行不同的规则校验</mark>
参考




PUT /user(修改用户)(自定义校验注解)

怎么自定义注解,参考:文章1文章2

hitbernate 官方提供了很多默认的校验注解,已经很全面了
(官方的图)

但有的情况下我们还是需要自定义校验,如:

  • 密码和验证密码是否一致
  • 用户名是否重复
  • 传入的状态码是否在自定义的枚举类中(如男1女0) 🐶
  1. 下面为了演示,我们就自定义一个校验注解 @NotUserNameDuplicated:判断用户名是否重复
    (<mark>当然,这种做法在事务处理上可能会有问题,后面再处理</mark>)

  2. 同时使用 @Past 注解校验 birthday 属性合法(不是一个未来的时间)

测试方法 whenUpdateSuccess

    /** * 修改用户 * 调用 PUT /user 成功 */
    @Test
    public void whenUpdateSuccess() throws Exception {
        //伪造一个未来的时间
        // long time = System.currentTimeMillis() + 365 * 24 * 60 * 1000;
        // 或者:
        // 指定时区为系统默认时区
        ZoneId zone = ZoneId.systemDefault();
        // 指定时间戳为未来一年后的时间
        long timestamp = LocalDateTime.now().plusYears(1)
                // 上面的时区设置进来
                .atZone(zone)
                // 获取这个时区这个时刻的时间戳
                .toInstant().toEpochMilli();
        String content = "{\"id\":\"1\",\"username\":\"foo\",\"password\":\"222\",\"balance\":100, \"birthday\":" + timestamp + "}";
        log.info(content);
        String result = mockMvc.perform(
                put("/user/1")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(content))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value("1"))
                .andReturn().getResponse().getContentAsString();

        log.info(result);
    }

直接运行,是熟悉的错误 405:请求类型不支持

User类添加校验规则

package cn.vshop.security.dto;

import cn.vshop.security.validator.NotUserNameDuplicated;
import com.fasterxml.jackson.annotation.JsonView;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.hibernate.validator.constraints.NotBlank;

import javax.validation.constraints.Past;
import java.util.Date;

/** * @author alan smith * @version 1.0 * @date 2020/3/29 1:07 */
@NotUserNameDuplicated(username="username", userid="id", message = "用户名重复")
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class User {

    /** * 1. 使用接口来声明多个视图 */
    public interface UserSimpleView {
    }

    public interface UserDetailView extends UserSimpleView {
    }

    @JsonView(UserSimpleView.class)
    private String id;

    @NotBlank(message = "用户名不能为空")
    @JsonView(UserSimpleView.class) //2. 在值对象的get方法上指定视图
    private String username;

    /** * 因为,UserDetailView 继承了 UserSimpleView * 因此,被UserSimpleView注解的属性(如username)也是会被UserDetailView包含 */
    @NotBlank(message = "密码不能为空")
    @JsonView(UserDetailView.class)
    private String password;

    /** * 余额 */
    @JsonView(UserSimpleView.class)
    private Integer balance;

    @Past(message = "生日不能为未来时间")
    @JsonView(UserSimpleView.class)
    private Date birthday;

}

NotUserNameDuplicated 注解的实现

package cn.vshop.security.validator;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

/** * 校验用户名是否已存在,通过查询数据库的用户id和用户名 * 如果id为空,认为是插入,不允许用户名重复 * 如果id不为空,认为是修改,不允许用户名重复,但允许用户名与id上的用户名重复 * * @author alan smith * @version 1.0 * @date 2020/3/30 9:45 */
// 指定此注解可注解在类class、接口interface、注解@interface、枚举enum上
@Target({ElementType.TYPE})
// 指定为程序运行时起作用的注解
@Retention(RetentionPolicy.RUNTIME)
// 指定校验的执行器
@Constraint(validatedBy = NotUserNameDuplicatedValidator.class)
// (非必要)是否被javaDoc收录为api
@Documented
public @interface NotUserNameDuplicated {
    //message、groups和payload三个属性必须加上

    // 提示信息,可以从ValidationMessages.properties里提取,可以实现国际化效果
    String message() default "username is duplicated";

    // 可以用于做分组校验(不同情况制定不同校验规则)
    Class<?>[] groups() default {};

    // 挂载一些元数据(metaData),如管理系统的访问权限、文件拥有者等数据,少用
    Class<? extends Payload>[] payload() default {};

    String username();

    String userid();
}

实现 注解的验证器:NotUserNameDuplicatedValidator

package cn.vshop.security.validator;


import cn.vshop.security.dto.User;
import cn.vshop.security.service.UserService;
import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/** * 自定义的注解验证方法 * 实现ConstraintValidator<T,T> * 两个泛型类型为: * 第一个:自定义的注解 * 第二个:传递的值例如你定义在field字段上,那么这个类型就是你定义注解的那个字段类型 * ConstraintValidator<NotUserNameDuplicated, Object> * * 在这边只要实现了ConstraintValidator<T,T>,那么你的这个方法就会被spring容器纳入管理 * 因此你就可以很方便的在这个验证方法中注入spring管理的类去进行业务逻辑验证 * * @author alan smith * @version 1.0 * @date 2020/3/30 14:25 */
public class NotUserNameDuplicatedValidator implements ConstraintValidator<NotUserNameDuplicated, Object> {

    // 此类会自动被创建为bean放入容器,因此,可以直接使用@Autowired
    @Autowired
    private UserService userService;

    private String username;
    private String userid;

    /** * 初始化方法 */
    @Override
    public void initialize(NotUserNameDuplicated constraint) {
        userid = constraint.userid();
        username = constraint.username();
    }

    /** * 验证方法 * 验证成功返回: true * 验证失败返回: false */
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        try {
            String name = BeanUtils.getProperty(obj, username);
            String id = BeanUtils.getProperty(obj, userid);

            if (!StringUtils.isEmpty(id)) {
                // id不为空,认为是修改
                User u1 = userService.selectById(id);
                // 用户名和id上的用户名一致,则返回true
                if (u1 != null && name.equals(u1.getUsername())) {
                    return true;
                }
            }

            User u2 = userService.selectByUsername(name);
            return u2 == null;

        } catch (final Exception ignore) {
            // name 为空 或者其他错误
            // return false ;
        }
        return false;
    }
}

最后是个常见的类 userService 和他的实现

package cn.vshop.security.service;

import cn.vshop.security.dto.User;

/** * @author alan smith * @version 1.0 * @date 2020/3/30 14:38 */
public interface UserService {
    /** * 数据库查询用户,通过用户名 * * @param username 用户名 * @return 查询到的用户 */
    User selectByUsername(String username);

    /** * 数据库查询用户,通过ID * * @param id 用户ID * @return 查询到的用户 */
    User selectById(String id);
}

package cn.vshop.security.service.impl;

import cn.vshop.security.dto.User;
import cn.vshop.security.service.UserService;
import org.springframework.stereotype.Service;

/** * @author alan smith * @version 1.0 * @date 2020/3/30 14:38 */
@Service
public class UserServiceImpl implements UserService {

    private static User user;

    static {
        user = new User();
        user.setId("1");
        user.setUsername("boo");
    }

    @Override
    public User selectByUsername(String username) {
        return user;
    }

    @Override
    public User selectById(String id) {
        return user;
    }
}

运行测试:whenUpdateSuccess

同理,你可以试下实现 @FieldMatch 注解,用于验证 密码验证密码邮箱验证邮箱 是否一致。
参考:使用Hibernate Validator进行跨字段验证

DELETE /user/1 (删除用户)

后面会用到自定义的JsonResult作为返回类型,而现在还没用到,因此目前直接先不做返回

测试

    /** * 删除用户 * 调用 DELETE /user/1 成功 */
    @Test
    public void whenDeleteSuccess() throws Exception {
        mockMvc.perform(delete("/user/1").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk()) ;

        // 后面重构,controller返回自定义json类时,再判断返回状态
    }

接口实现

    /** * 模拟数据库删除用户 */
    @DeleteMapping("/{id:\\d+}")
    public void delete(@PathVariable("id") String id) {
        log.info(id);
    }

POST /file(文件上传下载)

编写测试代码

    /** * 上传文件 * 调用 /file 成功 */
    @Test
    public void whenUploadSuccess() throws Exception {
        // 模拟一个上传的文件
        MockMultipartFile file = new MockMultipartFile(
                // 文件上传后的名字
                "file",
                // 文件在客户端的名字
                "test.txt",
                // 请求体类型
                "multipart/form-data",
                // 文件内容(字节码形式)
                "hello upload".getBytes("UTF-8")
        );
        mockMvc.perform(fileUpload("/file")
                // 设置要上传的文件
                .file(file))
                .andExpect(status().isOk());
    }

FilterController 实现

package cn.vshop.security.web.controller;

import cn.vshop.security.dto.FileInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.UUID;

/** * @author alan smith * @version 1.0 * @date 2020/4/1 17:55 */
@Slf4j
@RequestMapping("/file")
@RestController
public class FileController {

    @PostMapping
    public FileInfo upload(@RequestBody MultipartFile file) throws IOException {
        // 文件上传名称
        log.info("name={}", file.getName());
        // 文件原始名称
        log.info("originalFilename={}", file.getOriginalFilename());
        // 文件的尺寸
        log.info("size={}", file.getSize());

        String folder = FileController.class.getResource("").getPath();
        log.info("folder:{}", folder);
        String uuid = UUID.randomUUID().toString();
        File localFile = new File(folder, uuid + ".txt");

        file.transferTo(localFile);

        return new FileInfo(localFile.getAbsolutePath());
    }

}

测试结果

另外这里是 idea http Client 的上传文件测试代码
(<mark>下面讲的《测试工具:IntelliJ IDEA HTTP Client 》这工具会讲怎么用</mark>)

POST http://{{host}}/file
Content-Type: multipart/form-data; boundary=WebAppBoundary

--WebAppBoundary
Content-Disposition: form-data; name="file"; filename="file.txt"

hello world!
--WebAppBoundary--

###

(点击放大)响应结果

至此的代码可以在github获取 https://github.com/LawssssCat/v-security/tree/v1.1

GET /file/id(文件下载)

添加io工具包依赖

<!--io工具包-->
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
</dependency>

添加 download 方法

在上面写好的 FileController 类中

package cn.vshop.security.web.controller;

import cn.vshop.security.dto.FileInfo;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.UUID;

/** * @author alan smith * @version 1.0 * @date 2020/4/1 17:55 */
@Slf4j
@RequestMapping("/file")
@RestController
public class FileController {

    private final static String folder = FileController.class.getResource("").getPath();

    /** * 客户端 上传文件 * * @param file 上传的文件 * @return 文件的名字 * @throws IOException */
    @PostMapping
    public FileInfo upload(@RequestBody MultipartFile file) throws IOException {
        // 文件上传名称
        log.info("name={}", file.getName());
        // 文件原始名称
        log.info("originalFilename={}", file.getOriginalFilename());
        // 文件的尺寸
        log.info("size={}", file.getSize());

        log.info("folder:{}", folder);
        String uuid = UUID.randomUUID().toString();
        File localFile = new File(folder, uuid + ".txt");

        file.transferTo(localFile);

        return new FileInfo(localFile.getAbsolutePath());
    }

    /** * 向客户端传输下载响应 * * @param id 文件名 * @param request 请求 * @param response 响应 */
    @GetMapping("/{id}")
    public void download(@PathVariable("id") String id, HttpServletRequest request, HttpServletResponse response) {
        try (
                // java 7 新语法,可以把要在finally中释放的资源放在try的括号中定义
                // try块结束后,资源自动释放(语法糖)
                FileInputStream in = new FileInputStream(new File(folder, id + ".txt"));
                ServletOutputStream out = response.getOutputStream();
        ) {
            // 告诉浏览器,这是下载文件响应
            response.setContentType("application/x-download");
            // 设置附件(attachment)的名字(即文件的默认名称)
            response.addHeader("Content-Disposition", "attachment;filename-test.txt");

            // 将输入流写入到输出流(即把文件的内容写入响应中)
            IOUtils.copy(in, out);
            out.flush();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

运行程序

编写 idea http client 测试脚本
(下一章《测试工具:IntelliJ IDEA HTTP Client 》将讲如何使用,可以用postman代替)

POST http://{{host}}/file
Content-Type: multipart/form-data; boundary=WebAppBoundary

--WebAppBoundary
Content-Disposition: form-data; name="file"; filename="file.txt"

hello world!
--WebAppBoundary--

> {%
// js 语法
  // 文件路径 如 F:\\environment\\java\\workspace\\v-security\\v-security-demo\\target\\classes\\cn\\vshop\\security\\web\\controller\\3b48c253-a29c-4d33-aafd-9cd27cb6e806.txt
  var p = response.body.path ;
  // 解析出文件名 3b48c253-a29c-4d33-aafd-9cd27cb6e806.txt
  var id = p.substring(p.lastIndexOf("\\")+1);
  // 设为全局变量,让下面的请求使用
  client.global.set("id", id) ;
  // 控制台打印(方便测试)
  client.log(id)
 %}

###
# 这里的id是上面定义好的全局变量
GET http://{{host}}/file/{{id}}

###

运行结果

至此的代码可以在github获取 https://github.com/LawssssCat/v-security/tree/v1.2

测试工具:IntelliJ IDEA HTTP Client

参考

下面需要用到工具访问写好的接口

<mark>这里选择用 idea 提供的 http client 【推荐:⭐️⭐️⭐️】</mark>
(不用idea的,可以用chrome的http client或者postman代替)

下面基本展示一下 idea 的 httpclient 工具的使用

启动程序
首先,启动上面写好的主程序


# 发送请求

添加httpclient文件(.http后缀即可)

写入请求内容

# 请求路径
GET http://localhost:8080/user
# 请求头
Content-Type: application/json
{
# 请求体
}

# 三个井号分割开两个请求
###

idea2019.3以后的,可以安装插件:https://plugins.jetbrains.com/plugin/13121-http-client
(2019.3.3 版自带)

  • add request
    提供一些请求模板
  • convert from cURL
    自动对 cURL 命令转换成 http client 请求
    最重要是可以从其他测试工具中导入请求,步骤:工具⇒ cURL ⇒ http client
    (postman为例)



  • Examples
    提供四个例子

发送请求
点击运行键即可发送请求,并且在控制台看到响应


# 环境变量

官方文档:https://www.jetbrains.com/help/idea/http-client-in-product-code-editor.html#example-working-with-environment-files

可能会用一些常用的变量,如:域名、用户名、密码、令牌等
并且这些变量会随环境的变化而变化,如生产环境的域名和开发的域名肯定不同。
这时,<mark>我们可以把这些变量抽出放到一个文件中</mark>

创建文件:http-client.env.json

在项目路径创建 http-client.env.json 文件,可以在里面设置环境变量

设置环境变量

这里设置

  • 开发环境 “development” 的host
  • 用户测试环境 uat 的 host
  • 生产环境 production 的 host
{
  "development" : {
    "host" : "localhost:8080"
  },
  "uat" : {
    "host" : "uat.vshop.cn"
  },
  "production" : {
    "host" : "vshop.cn"
  }
}

发送请求
回到 users.http

把域名该成 {{host}}

GET http://{{host}}/user
Content-Type: application/json

###

选择生产环境运行,也是可以成功获得响应的

另外,如果有一些变量不想公开,如生产环境的账号密码。
可以创建文件 http-client.private.env.json ,把不想公开的变量放进去,变量也会起作用。
(并且,http-client.private.env.json的优先级大于 http-client.env.json ,当变量冲突,前者的变量会覆盖后者。)

最后,把文件名放入 .gitignore 中进行排除,即可避免变量公开。

# 响应处理脚本(Response handler script)

官方文档:https://www.jetbrains.com/help/idea/http-client-in-product-code-editor.html#using-response-handler-scripts

  • 我们很多时候不会一个 会话 只发送一个 请求 ,而是在一个会话中发送多个请求。
  • 并且,会根据不同响应,发送不同的请求或者请求体。

这就需要响应脚本进行处理。
刚好 idea 的 http client 提供了 响应处理脚本 的功能

(下面看 Examples 里面的一个例子)

脚本使用方法两个(下图)

  • .http 文件中直接写脚本,用 >{% ... %} 包裹
  • 直接导入 js脚本 , 用 > 文件url 的方式
    (这种方式,需要引入 JavaScript Library | HTTP Response Handler.)

脚本的编写

脚本可以 javascript(ECMAScript 5.1)写。
<mark>主要涉及到两个类</mark>:

  • client:存储了会话(session)元数据(metadata)。
  • response:存储响应信息(content type、status、response body 等等)

【这里】API

  • client.global.set{{...}}
    <mark>用 client.global.set("variable_name", variable ) 存的数据,可以在下面的 HTTP requests 请求中用 {{variable_name}} 取出来</mark>
    (上面的图片就是例子)

  • client.testclient.assert
    可以写测试方法,并对结果进行校验。这和我们上面写的测试类类似
    开启测试:client.test(testName, function)
    校验结果:client.assert(condition, message)

    GET https://httpbin.org/status/200
    
    > {%
        client.test("Request executed successfully", function() {
            client.assert(response.status === 200, "Response status is not 200");
        });
    %}
    
  • 其他 API
    可以(ctrl+左键)点击client进入源码去看(下面是去注解简化版)

    var client = new HttpClient();
    var response = new HttpResponse();
    
    function HttpClient() {
     this.global = new Variables();
     this.test = function (testName, func) {};
     this.assert = function (condition, message) {};
     this.log = function (text) {};
    }
    
    function Variables() {
     this.set = function (varName, varValue) {};
     this.get = function (varName) {return varValue};
     this.isEmpty = function () {return true};
     /** * Removes variable 'varName'. */
     this.clear = function (varName) {
     };
     /** * Removes all variables. */
     this.clearAll = function () {
     };
    }
    
    function HttpResponse() {
     this.body = " ";
     this.headers = new ResponseHeaders();
     this.status = 200;
     this.contentType = new ContentType
    }
    
    function ResponseHeaders() {
     this.valueOf = function (headerName) {return headerValue};
     this.valuesOf = function (headerName) {return headerValue};
    }
    
    function ContentType() {
     this.mimeType = "application/json";
     this.charset = "utf-8";
    }
    
    

    到这里,其实你也发现了,idea的httpclient完全可以替代我们前面写的测试代码。
    从写代码测试代码的初衷来看:

    1. 快速测试接口
    2. 重构时,能快速找出变化了,有问题的接口

    .
    是的。萝卜青菜,各有所爱

到这里,你会发现,IntelliJ IDEA HTTP Client 的优点有

  • 在同一窗口实现开发和测试
  • 测试脚本可以实现串联的接口调用,提高测试效率
  • 可上传的测试脚本,在多人协同合作的环境下,共享接口请求代码变得非常简单

.
人生苦短,及时行乐。

处理异常

我们先看一个现象

http client 访问

# 故意把请求id写错
GET http://{{host}}/user/a
Content-Type: application/json

返回 json 错误信息

用浏览器访问

http://localhost:8080/user/a

却是一个页面

mvc基础:错误页面可以在 /error 里面自定义

<mark>出现异常时,为什么脚本发送请求返回 json,浏览器(如chrome)返回 html?</mark>
这是 SpringBoot 的默认错误处理机制请求头中ACCEPT内容 有关。
SpringBoot 的默认异常处理交给 BasicErrorController


而浏览器和脚本发送的请求头是不一样的(当然我们可以让他们一样)
(如下面例子)

# 一个不404的API
# GET http://{{host}}/asdfsda

###
# 浏览器发送的请求,默认添加 “Accept: text/html”
GET http://{{host}}/asdfsda
# springboot看到这个请求头,处理异常时默认返回html
Accept: text/html

###
# js脚本默认发送的请求,默认为 “Accept: */*
GET http://{{host}}/asdfsda
# springboot看到这个请求,处理异常时默认解析成json
Accept: */*

###


因此,最终错误返回的形式不一样

# 全局异常、404 异常

上面也看到了,springboot 默认把 404 异常交给 BasicErrorController,而我们希望让异常全部交给我们自己处理。

  • 那么,我们就需要实现 ErrorController 接口
  • 并且让接口的实现交给 springmvc 管理(添加@Controller注解)
  • 最后,用 @ControllerAdvice 增强我们的实现类,让他能处理异常

怎么知道的?看 ErrorMvcAutoConfiguration 可以知道,只要容器中有 ErrorController 的实现,就不会创建 BasicErrorController

添加 ErrorController 并增强

package cn.vshop.security.web.controller;

import cn.vshop.security.exception.ServiceException;
import org.springframework.boot.autoconfigure.web.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/** * "@ControllerAdvice" 是Controller的增强 * 用来对: * 1.@ExceptionHandler 异常处理 * 2.@InitBinder 用于初始化 WebDataBinder(表单数据绑定) * 3.@ModelAttribute 被@ModelAttribute注释的方***在此controller每个方法执行前被执行 * <p> * 这些注解进行处理,可以跨域多个 controller类 * * @author alan smith * @version 1.0 * @date 2020/3/31 14:58 */
@ControllerAdvice
@Controller // 使ErrorController接口生效,从而让 404 错误被我们捕获
public class GlocalExceptionHandler implements ErrorController {

    private final static String PAGE_NOT_FOUND = "/error" ;

    @Override
    public String getErrorPath() {
        return PAGE_NOT_FOUND;
    }

    @RequestMapping(PAGE_NOT_FOUND)
    public void toCustomHandler() {
        throw new ServiceException("Page not found") ;
    }


    /** * 拦截并处理 ServiceException 及其子类异常 * * @param se 拦截到的异常 * @return map类型数据,供jackson解析成json格式 */
    @ExceptionHandler(ServiceException.class)
    @ResponseBody
    // 响应码设置为 400(bad request)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, Object> handleServiceException(ServiceException se) {
        HashMap<String, Object> result = new HashMap<>();

        result.put("message", se.getMessage());
        return result;
    }

}

启动程序


运行测试脚本

  • 统一了异常返回
  • 并且返回的是自定义的异常处理
# 故意把请求id写错
GET http://{{host}}/user/a
Content-Type: application/json
###
GET http://{{host}}/user/a
Content-Type: application/json
Accept: text/html,*/*; ### 

为什么要封装一个 ServiceException ?

  • 好管理
  • 而且,封装了之后,还可以对其子类进行进一步操作

过滤、拦截、切片

非常重要的三个概念,后面用三方类的时候需要有的概念。

  • 过滤器(Filter)
    JEE的规范,处于最外层,<mark>能拿到请求(request)和响应(response)</mark>,但也只能拿到这两个东西。

    doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)

    由于是JEE规范的东西,因此无法操作spring,即 <mark>无法知道请求被哪个controller处理</mark>
    <mark>但可以通过FilterRegistrationBean把它注册到spring中,被spring管理</mark>
    第三方Filter插件的运行原理,下面演示)

  • ***(Interceptor)
    和Filter相反,***是Spring提供的类,天生就能让spring管理(当然,也需要注册),
    <mark>相比起 Filter ,Interceptor 除了能拿到 request 和 response,还能拿到 handler (Controller 的处理器)</mark>
    postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler, ModelAndView modelAndView)

  • 切片(Aspect)
    上面讲了 <mark>***(interceptor)可以获得request 、response 和 handler ,但他无法获取 handler 上的参数</mark>
    这时候就需要用到 切面(Aspect)了,

<mark>下面各自创一个类</mark>


# Filter

filter应该都懂,两个重点

  • JEE的规范,处于最外层
  • SpringBoot 环境不能直接创建,需要通过 FilterRegistrationBean

创建 filter 类

package cn.vshop.security.web.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StopWatch;

import javax.servlet.*;
import java.io.IOException;

/** * @author alan smith * @version 1.0 * @date 2020/3/31 18:00 */
 // JEE 标准,默认不收spring管理
// 不要直接使用@Component,Spring无法管理,必须借助FilterRegistrationBean创建
//@Component
@Slf4j
public class TimeFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("time filter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("time filter start");
        StopWatch watch = new StopWatch();
        watch.start("filter");
        filterChain.doFilter(servletRequest, servletResponse);
        watch.stop();
        System.out.println(watch.prettyPrint());
        log.info("time filter finish");
    }

    @Override
    public void destroy() {
        log.info("time filter destroy");
    }
}

创建 webConfig ,执行web相关注册

package cn.vshop.security.web.config;

import cn.vshop.security.web.filter.TimeFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.ArrayList;

/** * 把第三方的filter加入到spring的容器中 * * @author alan smith * @version 1.0 * @date 2020/3/31 18:06 */
@Configuration
public class WebConfig {

    /** * 使用 FilterRegistrationBean 而不是直接 @Component 一个Filter的好处是: * + 可以通过setUrlPatterns,指定哪些路径通过***,哪些路径不通过 */
    @Bean
    public FilterRegistrationBean timeFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        Filter filter = new TimeFilter();
        registrationBean.setFilter(filter);

        ArrayList<String> urls = new ArrayList<>();
        urls.add("/filter") ;
        registrationBean.setUrlPatterns(urls);

        return registrationBean ;
    }
}

运行结果

访问: http://localhost:8080/filter

<mark>所有URL符合过滤规则的请求,都会经过过滤器</mark>

DELETE  http://{{host}}/filter
###
PUT http://{{host}}/filter
### 
# 等等

# Interceptor

实现接口 HandlerInterceptor

package cn.vshop.security.web.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/** * @author alan smith * @version 1.0 * @date 2020/3/31 19:21 */
@Slf4j
// spring 提供的类,默认被spring管理
@Component
public class TimeInterceptor implements HandlerInterceptor {

    // 控制器(controller)方法处理前调用
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
        log.info("@@@@ interceptor preHandle");
        long start = System.currentTimeMillis();
        // 为了在方法间传递信息,类似可用ThreadLocal
        httpServletRequest.setAttribute("start", start);
        // 返回false将不继续执行下面方法
        return true;
    }

    // 控制器(controller)方法处理后调用
    // 但是,如果controler执行期间出现异常,postHandle将不被调用
    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
        log.info("@@@@ interceptor postHandle");
        long start = (long) httpServletRequest.getAttribute("start");
        log.info("@@@@ 用时:{}", System.currentTimeMillis() - start);
    }

    // postHandle方法处理后调用
    // 无论,controler执行期间是否出现异常,afterCompletion都将被调用
    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
        log.info("@@@@ interceptor afterCompletion");
        long start = (long) httpServletRequest.getAttribute("start");
        log.info("@@@@ 用时:{}", System.currentTimeMillis() - start);
        log.info("@@@@ exception:{}", e);
    }
}

在 webMVCConfig 配置类中注册 interceptor

package cn.vshop.security.web.config;

import cn.vshop.security.web.filter.TimeFilter;
import cn.vshop.security.web.interceptor.TimeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

import javax.servlet.Filter;
import java.util.ArrayList;

/** * 把第三方的filter加入到spring的容器中 * * @author alan smith * @version 1.0 * @date 2020/3/31 18:06 */
@Configuration
// 继承 WebMvc注册类的适配:WebMvcConfigurerAdapter
public class WebConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private TimeInterceptor timeInterceptor;

    // 将***注册进springmvc
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(timeInterceptor).addPathPatterns("/filter");
    }

    /** * 使用 FilterRegistrationBean 而不是直接 @Component 一个Filter的好处是: * + 可以通过setUrlPatterns,指定哪些路径通过***,哪些路径不通过 */
    @Bean
    public FilterRegistrationBean timeFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        Filter filter = new TimeFilter();
        registrationBean.setFilter(filter);

        ArrayList<String> urls = new ArrayList<>();
        urls.add("/filter");
        registrationBean.setUrlPatterns(urls);

        return registrationBean;
    }
}

创建 Filtercontroller

package cn.vshop.security.web.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/** * @author alan smith * @version 1.0 * @date 2020/3/31 23:24 */
@Slf4j
@RequestMapping("/filter")
@RestController
public class FilterController {
    @RequestMapping
    public String ok() {
        log.info("ok !");
        return "filter ok !";
    }
}

运行测试脚本

GET  http://{{host}}/filter
Content-Type: application/json
###

测试结果

(点击放大)和这图还差个切面(aspect)

注意,如果中间controller了异常(如:没有加 FilterController 这个类),
<mark>postHandle 方法将不执行</mark>
并且最终的执行顺序也非常让人头疼(下图)

# Aspect

官方文档:切点表达式 Examples

面向切面编程三要素:

  • 切片(切谁)
  • 切点(在哪切)
  • 增强(切了干嘛)

v-security-demo 添加依赖

<!--切面-->
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-aop</artifactId>
 </dependency>

编写aspect类

package cn.vshop.security.web.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.util.Date;

/** * @author alan smith * @version 1.0 * @date 2020/4/1 0:38 */
@Slf4j
// 定义为切面
@Aspect
// 让spring容器管理
@Component
public class TimeAspect {

    // @Around 包围方式调用切点
    // 切入点表达式: UserController的任何返回值的任何参数的任何方法
    @Around("execution(* cn.vshop.security.web.controller.FilterController.*(..))")
    public Object handControllerMethod(
            // 当前切点
            ProceedingJoinPoint pj
    ) throws Throwable {

        log.info("### ### time aspect start");

        long start = new Date().getTime();

        // aspect相比于interceptor的优点,能获取参数
        Object[] args = pj.getArgs();
        for (Object arg : args) {
            log.info("arg is {}", arg);
        }

        Object result = pj.proceed();

        long end = new Date().getTime();
        log.info("### ### time aspect 耗时:{}", (end - start));

        log.info("### ### time aspect end");

        return result;

    }
}

修改controller

让其随便接收一个参数

运行,调用脚本测试

###
GET http://{{host}}/filter?id=1

# 或者
###
POST  http://{{host}}/filter
# 表单
Content-Type:application/x-www-form-urlencoded

id=1

###

控制台打印

至此的代码可以在github获取 https://github.com/LawssssCat/v-security/tree/v1.2

done.