本文档讲述内容与代码示例可参见 Shoulder-Demo1 |
能力激活方式:
<dependency>
<groupId>cn.itlym</groupId>
<artifactId>shoulder-starter-web</artifactId>
<version>0.8.1<version>
</dependency>
compile 'cn.itlym:shoulder-starter-web:0.8.1'
RestController AOP 增强
当引入 shoulder-web-starter
后,以下功能将自动启用
AOP 日志
Shoulder
为您自动记录请求路径、参数、HTTP状态码、耗时、出参、HttpHeaders 等信息
shoulder:
web:
# RestController AOP Log
log:
# 日志开关
enable: true
# 打印格式类型(彩色多行格式 / JSON格式)
type: colorful / json
# true 请求、响应一起打印; false 请求时打印一次、响应时打印一次
mergeReqAndResp: true
# 打印日志使用的 Logger 名称,这将影响日志输出位置、日志级别等
useCallerLogger: true
AOP 异常处理
自动捕获抛出的异常并转为合适的返回值,若为 BaseRuntimeException
还会返回对应的错误码,以下是使用 Shoulder
前后编码对比
@GetMapping("1")
public String case1() {
// 使用 shoulder 框架:不需要管异常,框架会自动记录日志与包装返回值
bizProcess();
}
// 未使用 shoulder 框架:需要去关注异常,记录日志等
@GetMapping("2")
public BaseResult<Object> badCase() {
try {
Object businessResult = bizProcess();
return BaseResult.success(businessResult);
} catch (Exception e) {
// 根据异常分类
BaseResult<String> errorResponse = new BaseResult<>();
if (e instanceof MyEx1) {
// 记录 error 级别的日志,返回 500 错误码
log.errorWithErrorCode("0x000a01", "发生了一个异常", e);
errorResponse.setCode("0x000a01");
errorResponse.setMsg(e.getMessage());
} else if (e instanceof MyEx2) {
// 记录 warn 级别的日志,返回 400 错误码
log.warnWithErrorCode("0x000a02", "发生了一个很神奇的异常", e);
errorResponse.setCode("0x000a02");
errorResponse.setMsg(e.getMessage());
}
return errorResponse;
}
}
您可以通过 shoulder.web.handleGlobalException 决定是否开启此能力,默认开启
|
AOP 统一响应格式
Shoulder
会将 RestController
的返回值包装为 BaseResult<Object>
格式,这有利于多人协作工程中规范返回值格式,也利于重构非标准返回值项目
如以下 RestController 的代码中 public String autoWrapCase1()
方法会返回 BaseResult<String>
。
@RestController
@RequestMapping("response")
public class RestfulResponseDemoController {
@GetMapping("1")
public String autoWrapCase1() {
// 自动包装为 BaseResult<String> 格式,实际返回 {"success":"true","data":"myData","code":"0","msg":"success"} 而非 "myData"
return "myData";
}
@GetMapping("2")
public Map<String, User> autoWrapCase2() {
// 自动包装为 BaseResult<String> 格式,实际返回 {"success":"true","data":{...省略},"code":"0","msg":"success"}
Map<String, User> map = new HashMap<>(2);
map.put("1", new User("id1", "name1"));
map.put("2", new User("id2", "name2"));
return map;
}
}
若若类 / 方法上存在 @SkipResponseWrap
注解,将跳过包装;
返回值为 BaseResult
或其子类说明已经是标准返回格式了,框架也会跳过包装
@RestController
@RequestMapping("response")
public class RestfulResponseDemoController {
@SkipResponseWrap
// 若类 / 方法上 存在 @SkipResponseWrap 注解,则 Shoulder 跳过自动包装,返回原来的内容
@GetMapping("1")
public String skipCase1() {
return "noWarp";
}
@GetMapping("2")
// 若返回值已经是 BaseResult,则框架不会额外做任何处理
public BaseResult<String> skipCase2() {
BaseResult<String> response = new BaseResult<>();
response.setCode("0");
response.setMsg("success");
response.setData("data");
return response;
}
@GetMapping("3")
public MyResponse<String> skipCase3() {
// 若 MyResponse 继承于 BaseResult ,则 Shoulder 不会自动包装
CustomizedResponse<String> response = new CustomizedResponse<>();
response.setCode("0");
response.setMsg("msg");
response.setData("data");
response.addArgs("for", "bar");
return response;
}
}
若您想整体跳过一些 Controller 的包装,可以在 application.yml 中做如下配置
|
shoulder:
web:
# UnionRespFormat
restResponse:
# 整体功能开关
autoWrapFormat: true
# 哪些路径下的 RestController 接口不会被包装为统一返回值,默认空,即都包装
skipWrapPathPatterns:
- '/ui/**'
Web 安全
防重复提交 @RejectRepeatSubmit
在 注册
、抢票
、核销优惠码
等场景中,希望避免重复操作浪费业务资源,有了 Shoulder
您只需要在接口上添加 @RejectRepeatSubmit
即可与前端配合实现防重复提交。
-
渲染页面时,前端生成一个 token
-
提交接口前,前端(客户端)在
HttpHeader
的__repeat_token
写入该 token -
服务端在处理请求时去
Session
/Redis
中寻找是否存在该值,若存在说明已收到请求,则拒绝本次处理
@RestController
@RequestMapping("demo")
public class DemoController {
@PostMapping("0")
@RejectRepeatSubmit
public BaseResult<String> myMethod() {
BaseResult<String> response = new BaseResult<>();
response.setCode("0");
response.setMsg("success");
response.setData("data");
return response;
}
}
您可以通过配置来修改默认值 |
shoulder:
web:
waf:
# Security: repeatSubmit
repeatSubmit:
# 功能总开关
enable: true
# 从请求 Header 取哪个字段作为 防重复提交token
requestTokenName: '__repeat_token'
# 服务端 session 中的 key 名称
sessionTokenName: '__repeat_token'
TODO 后续做成更通用的全局幂等组件,支持回滚等。
内置过滤器
引入 shoulder-web-starter
后,以下Web Filter将也将自动启用:
-
Xss 防护
-
用户 信息提取过滤器
-
租户 信息提取过滤器
-
tracer 信息提取过滤器
相当于默认激活了以下配置
shoulder:
web:
filter:
# Security: Xss
xss:
enable: true
order: 0
pathPatterns:
- /**
excludePathPatterns:
- /**/health
# just for test env
mockUser:
enable: true
order: 0
pathPatterns:
- /**
excludePathPatterns:
- /**/health
# advance: tenant
tenant:
...
# advance: tenant
tracer:
...
上传文件校验 @FileType
只需一个注解 @FileType
框架自动校验,帮您轻松应对文件注入等网络攻击,不再担心文件解析漏洞,使用前后对比如下:
// fold:on
// @fold:off
@Validated
@RestController
@RequestMapping("validate/file")
public class FileUploadController {
@PostMapping("1")
public String case1(@FileType(allowSuffix = "png", maxSize = "10MB") @NotNull MultipartFile uploadFile) {
return uploadFile.getOriginalFilename();
}
@PostMapping("2")
public String case2(@FileType(allowSuffix = {"yml", "properties"}, maxSize = "1MB") @NotNull MultipartFile uploadFile) {
return uploadFile.getOriginalFilename();
}
}
// fold:on
// fold:off
@Validated
@RestController
@RequestMapping("validate/file")
public class FileUploadController {
private String allowNamePattern = "";
private String forbiddenNamePattern = "";
@PostMapping("0")
public String notRecommended(MultipartFile uploadFile) throws IOException {
// 校验:文件非空
if (uploadFile == null) {
System.out.println("fileName: null");
return "0";
}
// 校验:文件名后缀
String fileName = uploadFile.getOriginalFilename();
fileName.endsWith(".png");
// 校验:文件名格式正确
boolean invalidFileName = !RegexpUtils.matches(fileName, allowNamePattern);
if (invalidFileName) {
// 省略每种校验失败组装返回值结果、记录日志...
}
// 校验:文件名不含禁止的字符
boolean noForbiddenPattern = !RegexpUtils.matches(fileName, forbiddenNamePattern);
if (!noForbiddenPattern) {
// 省略每种校验失败组装返回值结果、记录日志...
}
// 校验:文件头正确
// 从上传文件的 inputStream 中读取前 x 个字节(具体字节数与类型相关)
uploadFile.getInputStream();
// 比较正确的文件头
boolean invalidFileHeader = ...;
if (invalidFileHeader) {
// 省略每种校验失败组装返回值结果、记录日志...
}
// 校验文件大小
long maxSize_1MB = 1024 * 1024;
boolean sizeOk = uploadFile.getSize() < maxSize_1MB;
if (!sizeOk) {
// 省略每种校验失败组装返回值结果、记录日志...
}
System.out.println("fileName: " + fileName);
return "0";
}
}
通用重定向接口
默认提供了 /redirect/**
接口支持统一跳转,统一跳转前缀,为前端框架基础能力做铺垫。如 /redirect/https://doc.itlym.cn
则会跳转到 https://doc.itlym.cn
。
您可以通过 shoulder.web.commonEndpoint.enable 决定是否开启此能力,默认开启
|
内置扩展
字典管理
-
提供字典、字典项相关功能:查询、搜索
-
支持底层使用
Enum
orDB
创建表结构
CREATE TABLE IF NOT EXISTS tb_dictionary_type
(
id bigint unsigned auto_increment comment '主键' primary key,
biz_id varchar(64) not null comment '业务唯一标识(不可修改;业务键拼接并哈希)',
version int default 0 not null comment '数据版本号:用于幂等防并发',
description varchar(255) null comment '备注:介绍为啥添加这一条记录,这条记录干啥的,哪里用,怎么用',
delete_version bigint unsigned default 0 not null comment '删除标记:0-未删除;否则为删除时间',
display_name varchar(64) not null comment '名称',
display_order int not null comment '顺序',
source varchar(64) not null comment '数据来源',
creator varchar(64) not null comment '创建人编号',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
modifier varchar(64) not null comment '最近修改人编码',
update_time datetime default CURRENT_TIMESTAMP not null comment '最后修改时间'
)
comment 'tb_dictionary_type';
-- H2 数据库中,索引名需要全局唯一,一般数据库的索引名只需要表内唯一即可
CREATE INDEX IF NOT EXISTS idx_dic_type_bizid on tb_dictionary_type (biz_id);
CREATE INDEX IF NOT EXISTS idx_dic_type_order on tb_dictionary_type (display_order);
CREATE TABLE IF NOT EXISTS tb_dictionary_item
(
id bigint unsigned auto_increment comment '主键' primary key,
biz_id varchar(64) not null comment '业务唯一标识(不可修改;业务键拼接并哈希)',
version int default 0 not null comment '数据版本号:用于幂等防并发',
description varchar(255) null comment '备注:介绍为啥添加这一条记录,这条记录干啥的,哪里用,怎么用',
dictionary_type varchar(64) not null comment '字典类型编码',
name varchar(64) not null comment '名称',
display_name varchar(64) not null comment '展示名称',
display_order int not null comment '顺序',
parent_id bigint null comment '父节点id',
delete_version bigint unsigned default 0 not null comment '删除标记:0-未删除;否则为删除时间',
creator varchar(64) not null comment '创建人编号',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
modifier varchar(64) not null comment '最近修改人编码',
update_time datetime default CURRENT_TIMESTAMP not null comment '最后修改时间'
)
comment 'tb_dictionary_item';
CREATE INDEX IF NOT EXISTS idx_bizid on tb_dictionary_item (biz_id);
CREATE INDEX IF NOT EXISTS idx_pid on tb_dictionary_item (parent_id);
批处理
-
异步校验、导入、导出、批处理历史记录
相当于默认激活了以下配置
shoulder:
web:
ext:
# Ext: dictionary
dictionary:
enable: true
order: 0
pathPatterns:
- /**
excludePathPatterns:
- /**/health
# just for test env
mockUser:
enable: true
order: 0
pathPatterns:
- /**
excludePathPatterns:
- /**/health
# advance: tenant
tenant:
...
# advance: tenant
tracer:
...