上一篇:https://blog.csdn.net/LawssssCat/article/details/105080690
下一篇:https://blog.csdn.net/LawssssCat/article/details/105257360
准备 RESTful API
上一章环境搭建,搭建结果: 下载
<mark>这章准备一堆 api ,后面基于这些 api 做 认证授权</mark>
主要内容
- 处理静态资源和异常
- 配置***
- 文件上传下载
- 异步请求开发
- 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的使用
接下来,添加 @Valid
和 BindingResult
验证参数的合法性并处理校验结果
添加 @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 地址 | |
@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(修改用户)(自定义校验注解)
hitbernate 官方提供了很多默认的校验注解,已经很全面了
(官方的图)
但有的情况下我们还是需要自定义校验,如:
- 密码和验证密码是否一致
- 用户名是否重复
- 传入的状态码是否在自定义的枚举类中(如男1女0) 🐶
-
下面为了演示,我们就自定义一个校验注解
@NotUserNameDuplicated
:判断用户名是否重复
(<mark>当然,这种做法在事务处理上可能会有问题,后面再处理</mark>) -
同时使用
@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类添加校验规则
- 类上加自定义注解 @NotUsernameDuplicated
(因为验证涉及到两个属性,因此不能在单个属性上加注释了,参考:使用Hibernate Validator进行跨字段验证) - birthday 上加 @Past
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
提供四个例子
发送请求
点击运行键即可发送请求,并且在控制台看到响应
# 环境变量
可能会用一些常用的变量,如:域名、用户名、密码、令牌等
并且这些变量会随环境的变化而变化,如生产环境的域名和开发的域名肯定不同。
这时,<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)
- 我们很多时候不会一个
会话
只发送一个请求
,而是在一个会话中发送多个请求。 - 并且,会根据不同响应,发送不同的请求或者请求体。
这就需要响应脚本进行处理。
刚好 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.test 和 client.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完全可以替代我们前面写的测试代码。
从写代码测试代码的初衷来看:- 快速测试接口
- 重构时,能快速找出变化了,有问题的接口
.
是的。萝卜青菜,各有所爱
到这里,你会发现,IntelliJ IDEA HTTP Client 的优点有
- 在同一窗口实现开发和测试
- 测试脚本可以实现串联的接口调用,提高测试效率
- 可上传的测试脚本,在多人协同合作的环境下,共享接口请求代码变得非常简单
.
人生苦短,及时行乐。
处理异常
我们先看一个现象
http client 访问
# 故意把请求id写错
GET http://{{host}}/user/a
Content-Type: application/json
返回 json 错误信息
用浏览器访问
却是一个页面
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.