github地址
jar包已上传至中央仓库,代码还有很多问题,后续再逐渐修复
引入方式
<dependency>
<groupId>com.github.softwarevax</groupId>
<artifactId>support-springboot-starter</artifactId>
<version>0.0.3.RELEASE</version>
</dependency>
1、统一返回结果
统一返回结果,只针对数据接口,即添加@ResponseBody注解的接口,@ResponseBody的作用是将java对象转为json格式的数据。统一返回结果的好处是方便以后更换返回的实体,也不用每个返回结果,都用一个实体包装起来,先看功能实现效果。
1.1、返回字符串
字符串不做处理,也可以做处理,看具体需要,这里是做了处理的。
请求:GET http://localhost:8080/support/aaa/string/hello
@ResponseBody
@GetMapping("/string/hello")
public String string() {
return "hello";
}
返回JSON字符串
{"code":200,"data":"hello","flag":true,"message":"接口调用成功"}
1.2、返回指定的DTO
返回指定的DTO,不做处理
请求:GET http://localhost:8080/support/aaa/dto/hello
@ResponseBody
@GetMapping("/dto/hello")
public ResultDto<String> dto() {
return ResultDto.successT("hello dto");
}
返回DTO实体
{
"flag": true,
"data": "hello dto",
"message": "接口调用成功",
"code": 200
}
1.3、返回普通实体
返回普通实体,自动加上包装实体,User是业务实体
请求:GET http://localhost:8080/support/aaa/object/hello
@ResponseBody
@GetMapping("/object/hello")
public User object() {
User user = new User();
user.setId("id");
user.setUserName("object");
user.setSex("man");
return user;
}
返回DTO实体,自动包装
{
"flag": true,
"data": {
"id": "id",
"userName": "object",
"sex": "man"
},
"message": "接口调用成功",
"code": 200
}
1.4、返回程序异常
返回异常信息,如果设置了全局异常处理,会先进入全局异常处理,然后到切面
请求:GET http://localhost:8080/support/aaa/exception/hello
@ResponseBody
@GetMapping("/exception/hello")
public User exception() {
User user = new User();
user.setId("id");
user.setUserName("object");
user.setSex("man");
Assert.isTrue(1 == 0, "1不等于0");
return user;
}
返回1:有全局异常处理时
{
"flag": false,
"data": "1不等于0",
"message": "接口调用失败",
"code": 500
}
返回2:没有全局异常处理时
{
"flag": true,
"data": {
"timestamp": "2022-07-07T06:09:19.973+0000",
"status": 500,
"error": "Internal Server Error",
"message": "1不等于0",
"path": "/support/aaa/exception/hello"
},
"message": "接口调用成功",
"code": 200
}
1.5、不需要处理返回结果
在接口上添加注解:@IgnoreResultWrapper即可
1.6、切面实现统一返回结果
对返回结果,默认是进行实体包装的,实现统一结果返回,实现接口ResponseBodyAdvice,并设置为切面即可完成,github地址
public class ResultAspect implements ResponseBodyAdvice<Object> {
@Autowired
private ResultConstant constant;
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
//判断是否有加自定义注解,有就跳过,不返回返回结果包装实体
AnnotatedElement annotatedElement = methodParameter.getAnnotatedElement();
IgnoreResultWrapper ignoreResultWrapper = AnnotationUtils.findAnnotation(annotatedElement, IgnoreResultWrapper.class);
return ignoreResultWrapper == null;
}
@Override
public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
// 可考虑使用对象池
IResult result = BeanUtils.instantiateClass(constant.getWrapperImpl());
if (o instanceof String) {
// String要特殊处理
return result.returnString(o);
} else if(o.getClass() == LinkedHashMap.class && HttpServletUtils.getResponseStatus() >= 500) {
// 异常时的处理,返回时出错,说明客户端请求正常,不会是4xx
result.error((LinkedHashMap) o);
} else if (IResult.class.isAssignableFrom(o.getClass())) {
//如果是实现IResult的实体,则直接返回
return o;
}
return result.returnDto(o);
}
}
2、注解实现分页
注解分页是依赖PageHelper,故需要引入相关jar包,另外只用于Mybatis,github地址
2.1、不添加分页注解
请求:
POST http://localhost:8080/support/aaa/user/list?pageSize=1&pageNum=1
Content-Type: application/x-www-form-urlencoded
@ResponseBody
@ApiOperation("用户列表查询")
@PostMapping("/user/list")
public PageInfo<User> queryList(User user) {
List<User> list = userService.list();
return new PageInfo<>(list);
}
返回
{
"flag": true,
"data": {
"total": 2,
"list": [
{
"id": "1",
"userName": "1",
"sex": "1"
},
{
"id": "2",
"userName": "2",
"sex": "2"
}
],
"pageNum": 1,
"pageSize": 2,
"size": 2,
"startRow": 0,
"endRow": 1,
"pages": 1,
"prePage": 0,
"nextPage": 0,
"isFirstPage": true,
"isLastPage": true,
"hasPreviousPage": false,
"hasNextPage": false,
"navigatePages": 8,
"navigatepageNums": [
1
],
"navigateFirstPage": 1,
"navigateLastPage": 1
},
"message": "接口调用成功",
"code": 200
}
虽然返回的实体中有分页的相关属性,原因是返回实体是PageInfo,里面含有有些默认的属性,查看SQL日志,就会发现实际没有执行count查询,传入的pageSize是1,但是返会的仍然是两条
2.2、添加分页注解
请求:
POST http://localhost:8080/support/aaa/user/list?pageSize=1&pageNum=1
Content-Type: application/x-www-form-urlencoded
@Pagination
@ResponseBody
@ApiOperation("用户列表查询")
@PostMapping("/user/list")
public PageInfo<User> queryList(User user) {
List<User> list = userService.list();
return new PageInfo<>(list);
}
返回:
{
"flag": true,
"data": {
"total": 2,
"list": [
{
"id": "1",
"userName": "1",
"sex": "1"
}
],
"pageNum": 1,
"pageSize": 1,
"size": 1,
"startRow": 1,
"endRow": 1,
"pages": 2,
"prePage": 0,
"nextPage": 2,
"isFirstPage": true,
"isLastPage": false,
"hasPreviousPage": false,
"hasNextPage": true,
"navigatePages": 8,
"navigatepageNums": [
1,
2
],
"navigateFirstPage": 1,
"navigateLastPage": 2
},
"message": "接口调用成功",
"code": 200
}
2.3、分页和不分页同时支持
有些数据是比较少量的,但是在某些情况下,既想用作列表,又想作为下拉选择,注解加上属性skipIfMissing=true,在缺失pageSize和pageNum属性时,跳过分页功能查全部,也不会报错。
请求:
@Pagination(skipIfMissing = true)
@ResponseBody
@ApiOperation("用户列表查询")
@PostMapping("/user/list")
public PageInfo<User> queryList(User user) {
List<User> list = userService.list();
return new PageInfo<>(list);
}
2.4、注解Pagination的属性
如果系统的某些接口,页码字段名不是pageNum,如:pageNo,可以使用pageNum设置,pageSize同理,也可以全局设置属性名。orderBy可用于排序,详见2.5。maxPageSize是限制接口的最大请求数据量,当表中的数据很多时,接口设置pageSize很大,导致接口响应很慢,如果这种请求很多,甚至会影响整个系统,默认不做限制,可全局配置,也可以针对个别接口配置。pageSize超过maxPageSize时,会抛出异常。
@Inherited
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Pagination {
/**
* @return 页大小的参数名
*/
String pageSize() default "";
/**
* @return 页码参数名
*/
String pageNum() default "";
/**
* @return 排序参数名
*/
String orderBy() default "";
/**
* @return 允许的最大页大小,小于或等于0时,不限制
*/
int maxPageSize() default 0;
/**
* @return 如果没有拿到分页相关参数,是否跳过分页,默认不跳过
* 跳过:即使没拿到,也不会报错,但分页不起效果,此时可当作列表查询使用
* 不跳过:没拿到参数时,抛出异常
*/
boolean skipIfMissing() default false;
}
2.5、实现列表字段排序
字段排序针对分页功能,如果分页功能没生效,排序也不会生效
请求:
POST http://localhost:8080/support/aaa/user/list?pageSize=1&pageNum=1&orderBy=userName desc
Content-Type: application/x-www-form-urlencoded
@Pagination(skipIfMissing = true)
@ResponseBody
@ApiOperation("用户列表查询")
@PostMapping("/user/list")
public PageInfo<User> queryList(User user) {
List<User> list = userService.list();
return new PageInfo<>(list);
}
Mybatis的xml
<resultMap id="t_user" type="com.github.softwarevax.support.demo.entity.User">
<id column="id" property="id"/>
<result column="user_name" property="userName"/>
<result column="sex" property="sex"/>
</resultMap>
<select id="list" resultMap="t_user">
SELECT id, user_name, sex FROM t_user
</select>
sql日志
说明:userName是实体中的属性,而SQL中排序是使用的是数据库字段user_name。
前端只对实体属性可见,对数据库字段不可见
1、结果使用resultMap映射,则直接取对应关系
2、结果使用resulttype映射,可以取别名,类似: select user_name userName from t_user。如果不取别名,就将驼峰转为下划线进行匹配
在进行联表查询是,使用这个会比较方便,PageHelper排序,需要设置表别名,不然无法知道字段是哪个表的
注意:当联表查询时,会存在有多个相同字段的情况,如表t1和t2都有字段name,但是在resultMap映射中,并没有列举出该字段,而前端又要对改字段进行排序,这时就无法知道需要对哪个表的name字段进行排序了。只要是resultMap有映射关系的,使用排序就不存在问题。
3、方法(含接口)动态切面
切面的用途有很多,比如系统鉴权、系统日志、方法耗时统计等等,但是平时见到的切面,大多是静态,写死在代码中的,同一套系统日志切面,每个应用使用时,都要复制一份。因为
@Pointcut(POINT_CUT_INCLUDE)
public void pointCut() {
}
这一串代码无法设置成配置的。特别是POINT_CUT_INCLUDE。实现DynamicMethodMatcherPointcut,可实现动态切面,里面有一些规则,根据具体情况而定。
3.1、配置切面
@Bean
@ConditionalOnProperty(value = "support.method.enable", havingValue = "true")
public DefaultPointcutAdvisor methodAdvisor() {
Assert.hasText(methodConstant.getExpress(), "请配置切点表达式");
MethodInterceptorAdvisor interceptor = new MethodInterceptorAdvisor();
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
// 读取配置文件的切面表达式
pointcut.setExpression(methodConstant.getExpress());
logger.info("method aop expression = {}", methodConstant.getExpress());
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor();
advisor.setPointcut(pointcut);
advisor.setAdvice(interceptor);
advisor.setOrder(methodConstant.getOrder());
return advisor;
}
public interface MethodInterceptor extends Interceptor {
Object invoke(MethodInvocation invocation) throws Throwable;
}
MethodInterceptorAdvisor需要实现接口MethodInterceptor,该接口只有一个待实现的方法invoke,在invoke中执行MethodInvocation.proceed()就是执行切点方法,也就是环绕通知,那在执行MethodInvocation.proceed()前的操作,就是前置通知,之后的操作就是后置通知。代码大致如下:
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
try {
// 前置通知
advice.before(invocation);
// 环绕通知
Object ret = invocation.proceed();
// 返回通知
advice.afterReturn(ret, invocation);
return ret;
} catch (Exception e) {
// 异常通知
advice.throwException(invocation, e);
throw e;
} finally {
// 后置通知
advice.after(invocation);
}
}
环绕通知需要创建代理对象,且一个方法存在于多个切面时,还涉及到代理对象的嵌套,好像有点类似于Mybatis的插件原理。此处使用其他4个通知类型,基本可以完成绝大部分的需要。
3.2、功能配置
#方法请求记录
support.method.enable=true
# 切面表达式,第一个星号:方法返回类型不限制,第二个星号:类名不限制,第三个星号:方法名不限制,符号".."表示包及其子包
support.method.express=execution(* com.github.softwarevax.support.demo..*.*(..))
# 自定义类,实现接口MethodInvokeNoticer可得到切面收集的一些数据
support.method.noticers=com.github.softwarevax.support.demo.custom.MyMethodListener
# 是否持久化,实现了相关类,开启后需要创建对应的表,数据虽然是异步处理的,但是是单个入库的,后期处理成批量的,如批量1000条,超多指定时间还没有1000条也要入库
support.method.persistence=true
# 用来标记操作名称的,默认取的swagger的注解ApiOperation,可自己实现,优先取注解中的属性value属性,value没有就取name属性
support.method.method-tag=io.swagger.annotations.ApiOperation
# 如果启用了持久化,每次启动时,是否重置表数据,默认不重置
support.method.reset-every-time=true
3.3、数据库表结构
数据持久化只是提供了默认的实现方式,如果有不符合要求的,可实现接口MethodInvokeNoticer,完成自定义处理,如参数值的长度超过4000,就会被截取。
-- method 所有切点方法,只在第一次进入时入库
DROP TABLE IF EXISTS `t_method`;
CREATE TABLE `t_method` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`application` varchar(100) COLLATE utf8_bin DEFAULT NULL COMMENT '应用名spring.application.name, 如果没设置,则取contextPath',
`launch_time` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '启动时间,格式:yyyyMMddHHmmss',
`expose` tinyint(1) DEFAULT NULL COMMENT '是否是接口',
`method` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '方法简称',
`method_tag` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '方法标记',
`full_method_name` varchar(800) COLLATE utf8_bin DEFAULT NULL COMMENT '方法全称',
`return_type` varchar(200) COLLATE utf8_bin DEFAULT NULL COMMENT '返回类型',
`parameter` varchar(500) COLLATE utf8_bin DEFAULT NULL COMMENT '参数列表',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `lanuch_method` (`launch_time`,`full_method_name`)
) ENGINE=InnoDB AUTO_INCREMENT=131 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='应用方法';
-- t_method_invoke 方法调用记录,每次调用都会记录
DROP TABLE IF EXISTS `t_method_invoke`;
CREATE TABLE `t_method_invoke` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`session_id` varchar(40) COLLATE utf8_bin DEFAULT NULL COMMENT '会话id',
`invoke_id` bigint(20) DEFAULT NULL COMMENT '调用id,同一个请求,该值相同',
`method_id` int(11) DEFAULT NULL COMMENT '方法id,见表t_method',
`expose` tinyint(1) DEFAULT NULL COMMENT '是否是接口',
`parameter_val` varchar(4000) COLLATE utf8_bin DEFAULT NULL COMMENT '参数值,多个参数会转为json字符串,超过4000字符,会被截取',
`return_val` varchar(4000) COLLATE utf8_bin DEFAULT NULL COMMENT '同参数值',
`start_time` datetime DEFAULT NULL COMMENT '方法开始时间',
`elapsed_time` int(11) DEFAULT NULL COMMENT '运行时长,单位:毫秒',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=77 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='应用方法调用';
-- t_method_interface接口方法,如果方法是接口方法,特别处理,在第一次进入时处理
DROP TABLE IF EXISTS `t_method_interface`;
CREATE TABLE `t_method_interface` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_id` int(11) DEFAULT NULL COMMENT '方法id',
`method` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '请求方式,get,post。。。,如果有支持多个,逗号分割',
`mappings` varchar(500) COLLATE utf8_bin DEFAULT NULL COMMENT '请求路径,不含contextPath,如果支持多个,逗号分割',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='应用接口方法';
-- t_method_interface_invoke 类似于t_method_invoke,不过是接口调用
DROP TABLE IF EXISTS `t_method_interface_invoke`;
CREATE TABLE `t_method_interface_invoke` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`invoke_id` int(11) DEFAULT NULL COMMENT 't_method_invoke.id',
`scheme` varchar(40) COLLATE utf8_bin DEFAULT NULL COMMENT '协议',
`method` varchar(20) COLLATE utf8_bin DEFAULT NULL COMMENT '方法,get,post。。。',
`remote_addr` varchar(50) COLLATE utf8_bin DEFAULT NULL COMMENT '请求端地址',
`headers` varchar(2000) COLLATE utf8_bin DEFAULT NULL COMMENT '请求头,超过2000被截取',
`payload` varchar(4000) COLLATE utf8_bin DEFAULT NULL COMMENT '静荷载,超过4000被截取',
`response_status` int(11) DEFAULT NULL COMMENT '响应码',
`response_body` varchar(4000) COLLATE utf8_bin DEFAULT NULL COMMENT '响应体',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='应用接口方法调用';
3.4、表数据截图
t_method表数据,配置method_tag可用于记录操作日志,若service或mapper的方法不方便使用swagger的注解,可自定义注解,含有value和name属性记录操作名即可。
t_method_invoke表数据,sessionId是会话id,invokeId是一次请求的id,controller–>service–>mapper
t_method_interface表数据
t_method_interface_invoke表数据
3.5、自定义MethodInvokeNoticer
MyMethodListener不需要放入到Spring容器中,将MyMethodListener全类名配置成属性support.method.noticers值即可
public class MyMethodListener implements MethodInvokeNoticer {
private Logger logger = LoggerFactory.getLogger(MyMethodListener.class);
@Override
public void callBack(InvokeMethod method) {
logger.info("方法[{}]耗时{}毫秒", method.getFullMethodName(), method.getElapsedTime());
}
}
日志:
[2022-07-07 17:30:10] [c.g.s.s.d.c.MyMethodListener] [task-2] [INFO ] 方法[com.github.softwarevax.support.demo.service.impl.UserServiceImpl.list()]耗时257毫秒
[2022-07-07 17:30:10] [c.g.s.s.d.c.MyMethodListener] [task-3] [INFO ] 方法[com.github.softwarevax.support.demo.controller.TestController.queryList(com.github.softwarevax.support.demo.entity.User)]耗时514毫秒
[2022-07-07 17:30:10] [c.g.s.s.d.c.MyMethodListener] [task-1] [INFO ] 方法[com.github.softwarevax.support.demo.mapper.UserMapper.list()]耗时254毫秒