本文档讲述内容与代码示例可参见 Shoulder-Demo1

能力激活方式:

Maven
<dependency>
    <groupId>cn.itlym</groupId>
    <artifactId>shoulder-starter-web</artifactId>
    <version>0.8.1<version>
</dependency>
Gradle
compile 'cn.itlym:shoulder-starter-web:0.8.1'

RestController AOP 增强

当引入 shoulder-web-starter 后,以下功能将自动启用

AOP 日志

Shoulder 为您自动记录请求路径、参数、HTTP状态码、耗时、出参、HttpHeaders 等信息

YAML
shoulder:
  web:
    # RestController AOP Log
    log:
      # 日志开关
      enable: true
      # 打印格式类型(彩色多行格式 / JSON格式)
      type: colorful / json
      # true 请求、响应一起打印;  false 请求时打印一次、响应时打印一次
      mergeReqAndResp: true
      # 打印日志使用的 Logger 名称,这将影响日志输出位置、日志级别等
      useCallerLogger: true

AOP 异常处理

自动捕获抛出的异常并转为合适的返回值,若为 BaseRuntimeException 还会返回对应的错误码,以下是使用 Shoulder 前后编码对比

使用后
@RestController
@RequestMapping("demo")
public class DemoController {

    @GetMapping("1")
    public String case1() {
        // 使用 shoulder 框架:不需要管异常,框架会自动记录日志与包装返回值
        bizProcess();
    }

     private Object bizProcess() {
        // 模拟一个会抛出多种异常的业务方法
        throw new BaseRuntimeException("0x000a01", "demo ex1");
     }
}
使用前
@RestController
@RequestMapping("demo")
public class DemoController {

    // 未使用 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;
        }
    }

     private Object bizProcess() {
        // 模拟一个会抛出多种异常的业务方法
        throw new BaseRuntimeException("0x000a01", "demo ex1");
     }
}
您可以通过 shoulder.web.handleGlobalException 决定是否开启此能力,默认开启

AOP 统一响应格式

Shoulder 会将 RestController 的返回值包装为 BaseResult<Object> 格式,这有利于多人协作工程中规范返回值格式,也利于重构非标准返回值项目

如以下 RestController 的代码中 public String autoWrapCase1() 方法会返回 BaseResult<String>

统一响应格式
import org.shoulder.core.dto.response.BaseResult;
import org.shoulder.web.annotation.SkipResponseWrap;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@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 或其子类说明已经是标准返回格式了,框架也会跳过包装

统一响应格式
import org.shoulder.core.dto.response.BaseResult;
import org.shoulder.web.annotation.SkipResponseWrap;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@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 中做如下配置
YAML
shoulder:
  web:
    # UnionRespFormat
    restResponse:
      # 整体功能开关
      autoWrapFormat: true
      # 哪些路径下的 RestController 接口不会被包装为统一返回值,默认空,即都包装
      skipWrapPathPatterns:
        - '/ui/**'

Web 安全

防重复提交 @RejectRepeatSubmit

注册抢票核销优惠码 等场景中,希望避免重复操作浪费业务资源,有了 Shoulder 您只需要在接口上添加 @RejectRepeatSubmit 即可与前端配合实现防重复提交。

  1. 渲染页面时,前端生成一个 token

  2. 提交接口前,前端(客户端)在 HttpHeader__repeat_token 写入该 token

  3. 服务端在处理请求时去 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;
    }

}
您可以通过配置来修改默认值
YAML
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 信息提取过滤器

相当于默认激活了以下配置

YAML
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

import org.shoulder.core.util.RegexpUtils;
import org.shoulder.validate.annotation.FileType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

// @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

import org.shoulder.core.util.RegexpUtils;
import org.shoulder.validate.annotation.FileType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

// 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";
    }
}

文件 MIME 类型

列举了常用 MIME 类型方便文件类型校验和识别,见:MIMEEnum

已收集的MIME类型
application/zip
image/jpeg
text/html
video/mp4

... 更多MIME 类型 ...

application/envoy
application/fractals
application/futuresplash
application/hta
application/internet-property-stream
application/java-archive
application/mac-binhex40
application/msword
application/octet-stream
application/oda
application/olescript
application/pdf
application/pics-rules
application/pkcs10
application/pkix-crl
application/postscript
application/rtf
application/set-payment-initiation
application/set-registration-initiation
application/vnd.ms-excel
application/vnd.ms-outlook
application/vnd.ms-pkicertstore
application/vnd.ms-pkiseccat
application/vnd.ms-pkistl
application/vnd.ms-powerpoint
application/vnd.ms-project
application/vnd.ms-works
application/vnd.openxmlformats-officedocument.presentationml.presentation
application/vnd.openxmlformats-officedocument.presentationml.slideshow
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.openxmlformats-officedocument.wordprocessingml.document
application/winhlp
application/x-bcpio
application/x-cdf
application/x-compress
application/x-compressed
application/x-cpio
application/x-csh
application/x-director
application/x-dvi
application/x-gtar
application/x-gzip
application/x-hdf
application/x-internet-signup
application/x-iphone
application/x-javascript
application/x-latex
application/x-msaccess
application/x-mscardfile
application/x-msclip
application/x-msdownload
application/x-msmediaview
application/x-msmetafile
application/x-msmoney
application/x-mspublisher
application/x-msschedule
application/x-msterminal
application/x-mswrite
application/x-netcdf
application/x-perfmon
application/x-pkcs12
application/x-pkcs7-certificates
application/x-pkcs7-certreqresp
application/x-pkcs7-mime
application/x-pkcs7-signature
application/x-rar-compressed
application/x-sh
application/x-shar
application/x-shockwave-flash
application/x-stuffit
application/x-sv4cpio
application/x-sv4crc
application/x-tar
application/x-tcl
application/x-tex
application/x-texinfo
application/x-tika-java-web-archive
application/x-troff
application/x-troff-man
application/x-troff-me
application/x-troff-ms
application/x-ustar
application/x-wais-source
application/x-x509-ca-cert
application/ynd.ms-pkipko
audio/basic
audio/mid
audio/mpeg
audio/x-aiff
audio/x-mpegurl
audio/x-pn-realaudio
audio/x-wav
image/bmp
image/cis-cod
image/gif
image/ief
image/pipeg
image/png
image/svg+xml
image/tiff
image/vnd.microsoft.icon
image/webp
image/x-cmu-raster
image/x-cmx
image/x-icon
image/x-portable-anymap
image/x-portable-bitmap
image/x-portable-graymap
image/x-portable-pixmap
image/x-rgb
image/x-xbitmap
image/x-xpixmap
image/x-xwindowdump
message/rfc822
text/css
text/h323
text/iuls
text/plain
text/richtext
text/scriptlet
text/tab-separated-values
text/webviewhtml
text/x-component
text/x-setext
text/x-vcard
video/mpeg
video/quicktime
video/x-flv
video/x-la-asf
video/x-ms-asf
video/x-msvideo
video/x-sgi-movie
x-world/x-vrml

通用重定向接口

默认提供了 /redirect/** 接口支持统一跳转,统一跳转前缀,为前端框架基础能力做铺垫。如 /redirect/https://doc.itlym.cn 则会跳转到 https://doc.itlym.cn

您可以通过 shoulder.web.commonEndpoint.enable 决定是否开启此能力,默认开启

内置扩展

字典管理

  • 提供字典、字典项相关功能:查询、搜索

  • 支持底层使用 Enum or DB

创建表结构

字典管理-表结构
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);

标签管理

  • 提供标签、标签映射相关功能:标签管理、标签搜索

创建表结构

批处理

  • 异步校验、导入、导出、批处理历史记录

相当于默认激活了以下配置

YAML
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:
            ...

探索 & 扩展更多能力