Java 框架的使用

在这里告诉你一些技巧,宝贵的经验应该遵循

配置

以下是应用的基本配置,具体为:

这里给出基本的应用配置,详见应用配置文件

应知应会

枚举的管理

枚举值应该放在哪里?

就像 dao 和 repository 一样,枚举值应该放在 enums 包下,统一管理

具体说明

我的项目名如何修改

在模板项目中 模板项目 中,默认的包名是 demo,一般情况下需要调整为您的项目名,修改后,要注意 Application 一些配置的调整

请注意下面的 basePackages 等,需要调整为和项目一样,例如 Entity、Manager、Controller 的扫描等

@EnableScheduling
@SpringBootApplication(scanBasePackages = {"com.qrqy"})
@EntityScan(basePackages = {"com.qrqy.crawler.dao.entity"})
@EnableJpaRepositories(
        basePackages = {"com.qrqy.crawler.dao.repository"},
        repositoryFactoryBeanClass = BaseSqlRepositoryFactoryBean.class
)
@EnableJpaAuditing
@EnableAspectJAutoProxy(proxyTargetClass = true)
@RestControllerAdvice(basePackages = {"com.qryq.crawler.controller"})
public class CrawlerApplication {

    public static void main(String[] args) {
        SpringApplication.run(CrawlerApplication.class, args);
    }

}

Service 层的参数校验

请注意,除了 Controller 中有参数校验的要求外,Service 层原则上也要有参数校验,那么提供了 ValidatorHelper 用于参数校验

    @Resource
    private ValidatorHelper validatorHelper;

使用时,在 Dto、Bo 中可以为字段增加@NotNull、@NotEmpty 的注解

@Data
public class UserRegisterDo implements Serializable {

    private static final long serialVersionUID = 5900096730059591927L;
    /**
     * 用户类型
     */
    @NotNull(message = "type不能为null")
    private UserTypeEnum type;
    /**
     * 昵称
     */
    private String nickName;
    /**
     * 真实名称
     */
    @NotEmpty(message = "realName不能为空")
    private String realName;

    /**
     * 手机号
     */
    @NotEmpty(message = "phoneNumber不能为空")
    private String phoneNumber;
    /**
     * 头像图片
     */
    private String avatar;
    /**
     * 账号名
     */
    //fixme 请注意,不要删除accountName
    @NotEmpty(message = "accountName不能为空")
    private String accountName;

    /**
     * md5加密密码
     */
    @NotEmpty(message = "md5Password不能为空")
    private String md5Password;

    /**
     * 性别
     */
    private GenderEnum gender;

}

然后需要显示的调用一下 validatorHelper.validate()方法,其中返回值为 Pair

        Pair<Boolean, String> pair = validatorHelper.validate(userRegisterDo);
        if (!pair.getFirst()) {
            throw new BizException(CommonResponseCode.PARAMS_ERROR, pair.getSecond());
        }

自定义注解

首先,在 3.0.12 前,各个 OSS 配置必须在 yum 文件中,框架方可启动,这个设定是不正确的,在 3.0.12 后,如果不使用 OSS,可以不配置 yum 文件。如果需要使用,则可以在 yum 中设置如下 OSS 的配置:

# 对象存储
ali:
  oss:
    endpoint: oss-cn-shanghai.aliyuncs.com
    accessKeyId: LTAIa0lirGM
    accessKeySecret: WCLbsOeCToDew2KPS9JsqXm
    publicBucket: cv-public
    privateBucket: cv-private
tencent:
  cos:
    #endpoint: cos.ap-beijing.myqcloud.com
    secretId: AKIDb090E82f16uSjjSu50e
    secretKey: PYR4QVKs9ByUkJbdOs52j9I
    region: ap-beijing
    publicBucket: ibd-avatar-1257584888
    privateBucket: ibd-file-1257584888
qiniu:
  kodo:
    accessKey: JXanCoTnA8JmWooPTqvK5RCHXb
    secretKey: ouc-dLEY42KijQ1mKk_M_3vNpmT
    publicDomain: ytcf.public.isart.me
    privateDomain: ytcf.public.isart.me
    region: qiniu.region.z1

在框架项目中,自定义了一些注解可以使用,在 HttpMessageConvertersConfigurer.java 文件中,配置了 Filter,加入了一些序列化的注解,具体为:

七牛 OSS

@QiniuKodo,通过该注解,自动将 Vo 中的七牛云链接转换为 public 的链接或者 private 的链接

    @QiniuKodo(type = BucketType.PRIVATE)      //将imgUrl转化为私有仓库
    private String imgUrl;

    @QiniuKodo(type = BucketType.PUBLIC)       //将imgUrl转化为公有仓库
    private String imgUrl;

阿里 OSS 注解

@AliOss,通过该注解,自动将 Vo 中的阿里云链接转换为 public 的链接或者 private 的链接

    @AliOss(type = BucketType.PRIVATE)      //将imgUrl转化为私有仓库
    private String imgUrl;

    @AliOss(type = BucketType.PUBLIC)       //将imgUrl转化为公有仓库
    private String imgUrl;

腾讯 OSS 注解

@TencentCos,通过该注解,自动将 Vo 中国的腾讯云链接转换为 public 的链接或者 private 的链接

    @TencentCos(type = BucketType.PRIVATE)
    private String imgUrl;

    @TencentCos(type = BucketType.PUBLIC)
    private String imgUrl;

枚举值序列化注解

@DoNotSerialize,通过该注解,可以避免 Qo 和 Vo 序列化,,具体详见问题说明点击查看open in new window


	@DoNotSerialize
	@ApiModelProperty(value = "子系统", example = "")
	@NotNull(message = "subSys不能为null")
	private transient SubSysEnum subSys;

脱敏注解

@Desensitization,可以将手机号、身份证号进行脱敏,例如手机号脱敏为 158****3291,

    @Desensitization(type = DesensitizationType.PHONE)
    private String mobileNumber;

    @Desensitization(type = DesensitizationType.ID_CARD)
    private String id_card_no;

框架的调用建议

Controller、BizService、Service、Manager、Repostory 属于层层递进的关系

  • Controller:接收参数(Qo)、进行校验、整理报文并返回(Vo)
  • BizService:业务级别的服务,例如支付订单成功,需要修改订单状态、下发下单短信、通知物流发货...,那么可以封装一个业务 Service,即 BizService
  • Service:业务服务,例如订单服务、用户服务等
  • Manager:单表的管理
  • Repository:单表的 CRUD

可以现在一些项目进行参考

系统安全

一般重要项目都有安全等级要求,确保安全是一个套路,按如下操作基本搞定 99%。

应用安全

客观来说,我们能够保障的就是应用层面的安全,通过接口加密可以搞定 99%

接口加密

接口加密是重要的防渗透的方案,一旦接口被加密,大大的提升了接口安全性,哪怕接口有越权等漏洞,那也得破掉加密才可以利用,一般安全团队搞不定

接口加密可以参考禁毒项目,Java 框架必须在 3.0.12 以上,在 yml 中,配置 cryto 配置项即可,采用 AES 加密:

  • desSecretKey、desIv 需要和前端同步,AES 加密所需秘钥和偏移量
  • force:是否加密,我们的建议是测试环境不加密,便于调试;生产环境加密,提升安全性。即 application-dev.yml 的 force=false,application-prod.yml 的 force=true
  • timeCheck:时间戳校验,为了防止报文被反复使用,增加了时间戳校验,前端发送报文时加入 timestamp,提升了参数的随机性,超过 30 秒的报文,意味着超时,进一步提升安全性 -excludeRoutes:不要加密控制的接口
#通信body加解密
crypto:
  desSecretKey: b2c17b46e2b1415392aab5a82869856c
  desIv: 61960842
  force: true
  timeCheck: false
  excludeRoutes:
    - /**/no-need-to-crypt

接口加密前端代码

后端通过一个配置文件,就在框架中搞定了加密,那么前端调用接口时,也提供了加密工具

  • WebConsole 的加密工具

这些工具都是延续下来的,可能有点封装冗余,但是好用,如有问题请 call TerryQi

在请求拦截器和响应拦截器中使用加解密控制

  • 请求拦截器
axios.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    // let each request carry token
    // this example using the JWT token
    // Authorization is a custom headers key
    // please modify it according to the actual situation
    const token = getToken();
    if (token) {
      if (!config.headers) {
        config.headers = {};
      }
      config.headers.Authorization = `Bearer ${token}`;
    }
    if (ENCRYPTION === "true") {
      config.data = requestEntrance(config.data); //进行数据加密
    }
    return config;
  },
  (error) => {
    // do something
    return Promise.reject(error);
  }
);
  • 响应拦截器
axios.interceptors.response.use(
  (response: AxiosResponse<HttpResponse>) => {
    let res = response.data;
    if (ENCRYPTION === 'true') {
      res = <HttpResponse>responseEntrance(res);
    }
    // if the custom code is not 20000, it is judged as an error.
    if (res.code !== 200) {
      Message.error({
        content: res.message || 'Error',
        duration: 5 * 1000,
      });
      // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
      if (
        [50008, 50012, 50014].includes(res.code) &&
        response.config.url !== '/api/user/info'
      ) {
        Modal.error({
          title: 'Confirm logout',
          content:
            'You have been logged out, you can cancel to stay on this page, or log in again',
          okText: 'Re-Login',
          async onOk() {
            const userStore = useUserStore();

            await userStore.logout();
            window.location.reload();
          },
        });
      }
      return Promise.reject(new Error(res.message || 'Error'));
    }
    return res;
  },
  (error) => {
    Message.error({
      content: error.msg || 'Request Error',
      duration: 5 * 1000,
    });
    return Promise.reject(error);
  }
);

前端配置文件

# 加密相关
VITE_APP_ENCRYPTION = true
VITE_APP_AES_KEY = 'b2c17b46e2b1415392aab5a82869856c'
VITE_APP_AES_IV = '61960842'

接口加密 Uniapp 代码

import { aesEncrypt } from "./aes_app";

// 请求拦截器中
if (setting.cryptFlag) {
  config = aesEncrypt(config);
}

//响应拦截器中
if (setting.cryptFlag) {
  data = aesDecrypt(response).data;
}

再进一步

客观来说,上面的接口加密基本搞定 99%的问题,但我们可以做的更好一些,那么在 sys_account_auth 中,存储着用户的鉴权信息,包括:

  • token:用户的唯一标识
  • secretKey、secretValue:个性化的秘钥

那么,我们可以登陆接口用同一加密秘钥,但是其他接口使用用户个性的秘钥,这样基本不存在秘钥泄露的问题。

因为一般前端最多只是混淆代码,是做不了代码加密的,如果你的业务很有价值,攻击人员可以在混淆的前端代码中拿到公共的秘钥,进而伪造报文——通过个性化的秘钥,又进一步提升了安全性。

数据安全

在一些场景,需要对数据库的数据进行加密,例如用户的姓名、手机号、身份证号和银行卡号信息,统称为人员的信息"四要素",那么当两个以上的信息同时存在时,就要考虑信息安全问题。 当然,这个与项目要求有关系,有些项目涉及到了较多的用户个人信息,那么就要考虑处理信息安全问题。

数据脱敏

信息安全问题不光是技术问题,也是产品问题,例如在非必要展示信息时,用户的身份证号应该是211004********03013,手机号应该是 131****4543,展示是进行脱敏。

那么可以用框架中自带的脱敏注解,在 Vo 中加入即可,这样就可以将全部的返回报文进行脱敏了。

数据加密

所谓数据加密,是在数据库中,不再明文存储信息,防止由于数据泄露导致用户的个人隐私泄露。

在 Entity 中,增加了 @Convert(converter = CryptoAttributeConverter.class) 注解,确保数据在数据库中是加密的。那么加密的影响是:

  • 在检索数据时,必须全部匹配才可以了,不能进行模糊匹配,也就是不能用 字段_Like 的检索条件
  • 其余 JPA 中的封装的检索方法都是 ok 的
    /**
     * 微信号
     */
    @Column(name = "wx_num")
    @Convert(converter = CryptoAttributeConverter.class)
    private String wxNum;

文件加密

部分应用会存储用户的照片和身份证信息,那么:

  • 文件名要无规律,即不允许通过用户 id、身份证号等信息来命名图片,图片名必须是无规则的名称,在 CommonUtil 中有工具
  • 图片一般存储在 OSS 中,OSS 中基本都有私有桶,提供文件临时访问的私有链接,进一步提升安全性,框架中针对主流 OSS 已经封装

基础架构安全

我们在项目开发中,使用很多的中间件和工具,例如 Jenkins、Nacos、Mysql、Redis、ES、xxl-job 等

一个基本概念,这些中间件都需要设定密码访问,并且端口要禁用,不允许外部访问,即:

  • 生产环境必做网路策略,对外只开放 80 和 443(all source ip)
  • 为了便于连生产排查问题,上 VPN 或者入访控制,公司有固定公网 IP 地址,例如 3306(Mysql)、6370(Redis)、22(SSH)等仅对公司的公网 IP 开放准入
  • 例如 Swagger 在配置文件中设定 show=false,生产环境无法访问

总结

安全是一种思想意识,不同的系统要求不同的安全等级,在实施项目的过程中,要有意识的提升系统安全性。通过以上手段,基本 Cover 住全部的安全问题,如果有你有搞不定的问题、或者有更好的想法,请 call TerryQi。

如何管理接口

开发框架采用 swagger 结合 ApiFox 结合来进行接口管理,为什么这么选择?通用性强,集中进行接口管理

一般情况下,只要 copy 历史项目的配置即可

请后端人员下载 apifox,统一进行接口调试和管理

Swagger 接口服务

使用 swagger 进行接口,在应用 swagger 配置

swagger:
  show: false           //生产环境中不展示swagger

在应用中进行接口配置,在 swagger 包中进行接口管理,其中我们基本的定义是:

  • /api/admin:管理后台接口,一般有 token 校验
  • /api/common:通用接口,一般不需要 token 校验
  • /api/app:应用接口,例如给小程序、Web、App 的接口放在这里

PS:2023-08-14 进行了优化,因为发现 implements WebMvcConfigure 更好一些,之前使用 WebMvcConfigurationSupport 只能生效一次,是不推荐的

package com.isart.art.base.swagger;

import com.google.common.base.Predicates;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * @author TerryQi
 * @since 2022/7/17 16:54
 */
@Configuration
@EnableSwagger2
public class SwaggerConfiguration implements WebMvcConfigurer {

    @Value("${swagger.show}")
    private boolean show;

    /**
     * 接口文檔的版本
     */
    private final String VERSION = "1.0.1";

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("doc.html")
                .addResourceLocations("classpath:/META-INF/resources/");

        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

    /**
     * 管理后台的接口
     */
    @Bean
    public Docket adminConfig() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.any())
                //不显示错误的接口地址
                .paths(Predicates.not(PathSelectors.regex("/error.*")))//错误路径不监控
                .paths(PathSelectors.regex("/api/admin/.*"))// 对根下所有路径进行监控
                .paths(PathSelectors.any())
                .build()
                .apiInfo(adminInfo())
                .groupName("Admin")
                .enable(show);
    }

    private ApiInfo adminInfo() {
        return new ApiInfoBuilder()
                .title("管理后台-API文档")
                .description("管理后台接口")
                .version(VERSION) //版本号
                .build();
    }


    @Bean
    public Docket commonConfig() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.any())
                //不显示错误的接口地址
                .paths(Predicates.not(PathSelectors.regex("/error.*")))//错误路径不监控
                .paths(PathSelectors.regex("/api/common/.*"))// 对根下所有路径进行监控
                .paths(PathSelectors.any())
                .build()
                .apiInfo(commonInfo())
                .groupName("Common")
                .enable(show);
    }

    private ApiInfo commonInfo() {
        return new ApiInfoBuilder()
                .title("通用-API文档")
                .description("通用接口")
                .version(VERSION) //版本号
                .build();
    }


    @Bean
    public Docket providerConfig() {
        return new Docket(DocumentationType.SWAGGER_2)
                .select()
                .apis(RequestHandlerSelectors.any())
                //不显示错误的接口地址
                .paths(Predicates.not(PathSelectors.regex("/error.*")))//错误路径不监控
                .paths(PathSelectors.regex("/provider/.*"))// 对根下所有路径进行监控
                .paths(PathSelectors.any())
                .build()
                .apiInfo(providerInfo())
                .groupName("Provider")
                .enable(show);
    }

    private ApiInfo providerInfo() {
        return new ApiInfoBuilder()
                .title("Provider-接口文档")
                .description("Provider接口")
                .version(VERSION) //版本号
                .build();
    }

}

Swagger 接口地址

默认 swagger 的访问地址是http://localhost:8087/swagger-ui.html,如果使用提供的 swagger 配置,则有 Admin、App、Common 三个分组

swagger接口地址截图

ApiFox 管理接口

创建项目,在项目下新建 管理后台、通用接口、APP 接口,将 Swagger 的接口配置进入,手动导入(可以配置 3 个小时一同步),咻一下接口文档生成了

ApiFox接口生成

接口参数校验

项目中使用 spring validation 来进行参数校验,其中 Validate 的校验注解不再赘述

在 Controller 的形态,自动生成的 Controller 都已经添加

@RestController
@Slf4j
@Validated
@Api(tags = {"市场价格"}, value = "市场价格接口")
@RequestMapping("/api/common/product")
public class CfProductCommonController {

    @Resource
    private UserUtil userUtil;

    @Resource
    private CfProductManager productManager;

    @ApiOperation(value = "获取市场价格(不分页)", response = CfProductListVo.class)
    @RequestMapping(value = "/list", method = RequestMethod.POST)
    public ICommonResult list(@Valid @RequestBody CfProductQueryQo request) {

特殊说明的是,如果需要层级校验(即 Qo 里面还嵌套了 Qo),则在嵌套对象上增加@Valid 注解即可

/**
 * @author TerryQi
 * @since 2022/8/13 16:56
 */
@Data
public class SupplierQuoteSignUpQo implements IBaseQo {

    @ApiModelProperty(value = "采购子任务id", example = "")
    @NotNull(message = "ID不能为空!")
    private Integer purchaseSubTaskId;

    @ApiModelProperty(value = "报价明细", example = "")
    @NotEmpty(message = "报价明细不能为空!")
    @Valid
    private List<QuoteSummaryDo> quoteSummaryList;

}

参数校验异常接收

目前封装再 developer-web 框架中的异常统一拦截器中

    @org.springframework.web.bind.annotation.ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public CommonObjectResult<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("参数校验异常,message:{}", e.getMessage());
        e.printStackTrace();
        StringBuilder sb = new StringBuilder();
        List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
        String message = allErrors.stream().map(s -> s.getDefaultMessage()).collect(Collectors.joining(";"));
        return new CommonObjectResult<>(BizException.PARAMS_ERROR, message);
    }

在 Controller 中接口的@RequestBody 前面加@Valid 注解即可,例如

    @ApiOperation(value = "创建卡卷", response = BizCardVo.class)
    @RequestMapping(value = "/create", method = RequestMethod.POST)
    public ICommonResult create(@Valid @RequestBody BizCardCreateQo request) {

        BizCardEntity entity = BeanUtil.copyProperties(request, BizCardEntity.class);
        entity.setLeftNum(entity.getTotalNum());
        cardManager.save(entity);
        BizCardVo vo = BeanUtil.copyProperties(entity, BizCardVo.class);

        return new CommonObjectResult<>(vo);
    }

配置文件

本地配置

很多情况下,我们需要针对开发环境和生产环境做一些区分的配置,例如:

  • 在下发验证码时,本地开发环境/测试环境不下发验证码,验证码默认为 0000;生产环境才真实下发验证码

那么我们建议的配置方案是

  1. 在项目中,建立 config 包,里面有 LocalConfig 类

在 yml 文件中

#本地环境配置
local:
  evn: dev   #环境信息,prod、dev等,主要控制验证码下发,例如dev环境,验证码就是0000,不真正下发
@Component
@Data
public class LocalConfig {

    //本地环境配置
    @Value("${local.env}")
    public String env = "dev"; //默认是ENV环境

    public final String ENV_DEV = "dev";
    public final String ENV_PROD = "prod";

}

  1. 在各处引用时,使用 LocalConfig,具体详见验证码下发 Service
public boolean sendVerifyCode(SendVerifyCodeDo sendVerifyCodeDo) {

        boolean result = false;
        SysVerifyCodeEntity verifyCodeEntity = new SysVerifyCodeEntity();
        verifyCodeEntity.setPhoneNumber(sendVerifyCodeDo.getPhoneNumber());
        String code = RandomStringUtils.random(4, false, true);
        //开发环境下,全部的验证码都是0000
        if (localConfig.env.equals(localConfig.ENV_DEV)) {
            code = "0000";
            result = true;
        } else {
            try {
                result = smsService.sendSmsCode(sendVerifyCodeDo.getPhoneNumber(), code);
            } catch (ClientException e) {
                log.error("", e);
            }
        }
        if (result) {
            verifyCodeEntity.setCode(code);
            verifyCodeManager.save(verifyCodeEntity);
        }
        return result;
    }

PS:为什么不选择使用 spring.profile.active 的配置呢? 因为很多情况下,我们本地/开发环境也要模拟生产,所以另外设置了 local.env 这个配置变量,好处就是可控,这仅仅是我们团队的理解和规范

关于枚举的序列化处理

在 yml 文件中,进行如下配置,可以实现对于枚举的序列化处理

请注意,在 3.0.6 版本之后已经不再建议对枚举值进行序列化处理了,建议上是对 enum-convertor 处理一下,直接返回 code 值,这样主要是前端 get 到报文后,直接可以 edit 回来,否则存在问题

# 请求响应过滤器
http-convertor:
  enum-convertor: true #是否开启枚举转换,默认枚举转化是开启的
  exclude-urls: #不进行转换的url列表,即虽然enum-convertor是true
    - /provider/** #通过startWith来匹配

接口报文不进行 Qo 映射

目前,我们的框架中,会将全部的 Post 请求 body 中的 json 转化为 Qo 对象,以便于 Controller 中的操作。但是部分场景下,是不能进行请求的对象转化的。例如:微信支付回调通知,微信请求的报文为 xml 格式,因此使用 fastjson 进行 parseObject 时将报错

怎么办?应该针对某些接口,不适用 fastjson 进行 Qo 对象的映射,新增配置项

exclude-urls:是一个数组,哪些接口不希望使用 fastjson 进行 Qo 对象的映射,就把哪些接口放进来

# 不进行body的json化
body-json:
  exclude-urls: #不进行转换的url列表,即虽然enum-convertor是true
    - /api/common/payNotify/wxPay #通过startWith来匹配

例如微信支付通知接口

    @SneakyThrows
    @ApiOperation(value = "微信支付回调", response = ActivityWorkWithFavorVo.class)
    @RequestMapping(value = "/wxPay", method = RequestMethod.POST)
    public String wxPay(@RequestBody String xmlData) {

        WxPayOrderNotifyResult notifyResult = this.wxPayService.parseOrderNotifyResult(xmlData);

        String outTradeNo = notifyResult.getOutTradeNo();
        BmsPayOrderEntity orderEntity = payOrderManager.findByTradeNo(outTradeNo);
        if (orderEntity != null) {
            PayNotifyDo payNotifyDo = new PayNotifyDo();
            payNotifyDo.setTradeNo(outTradeNo);
            payOrderService.payNotify(payNotifyDo);
        }
        return WxPayNotifyResponse.success("成功");
    }

关于日志

项目通过 logback 进行日志管理,主要有 3 个优化点:

  1. 日志的 traceId 不重复

在日志配置文件中,通过 MDC 配置上下文参数,在 TokenAuthenticationFilter 过滤器中,设置了 traceId

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        MDC.put(TRACE_ID, UUID.randomUUID().toString());

        log.info("request path:{},query str:{},form:{}", request.getRequestURI(), request.getQueryString(), JSON.toJSONString(request.getParameterMap()));

    ...
    }

    @Override
    public void destroy() {
        MDC.clear();
    }

在 logback.xml 中,原生成 traceId 的方法不再采用,使用[%X{TRACE_ID}]的方式来获取 traceId

    <property name="CONSOLE_LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss} [%X{TRACE_ID}] -%clr(%-5level) %logger{26} - %msg%n"/>
    <property name="File_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss} [%X{TRACE_ID}] %-5level %logger{26} - %msg%n"/>

2.打印完整 sql,且分析 sql 的执行时长

客观来说,以往开启 show-sql 没有什么意义,因为没有执行参数,所以无法了解具体代码的执行过程,本次使用 log4jdbc-log4j2-jdbc4.1 作为 mysql 的数据库连接池驱动,完善了打印 sql

  • pom.xml 文件,引入 log4jdbc-log4j2-jdbc4.1
        <!--SQL 日志管理-->
        <dependency>
            <groupId>org.bgee.log4jdbc-log4j2</groupId>
            <artifactId>log4jdbc-log4j2-jdbc4.1</artifactId>
            <version>1.16</version>
        </dependency>
  • application-dev.yml 文件配置数据库链接和驱动,请注意 url 和 driver-class-name
spring:
  datasource:
    url: jdbc:log4jdbc:mysql://140.143.236.86:3306/ytcfdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    username: ytcfdb
    password: xG2LHb2sABshKFtt
    driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
  • 增加 log4jdbc.log4j2.properties 文件
# If you use SLF4J. First, you need to tell log4jdbc-log4j2 that you want to use the SLF4J logger
log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
  1. 日志保存在 jar 包同级目录下,./log 文件中,一般情况巡查 web_error.log,发现问题后到 web_info.log 根据 traceId 查询更进一步信息,请求参数的打印在加解密的 Advice 中实现

在框架中,CryptoRequestBodyAdvice.java、CryptoResponseBodyAdvice.java 为请求和响应的拦截器

怎么查看日志?

首先,日志的巡检非常重要,很多情况下是我们排查问题的关键,那么日志目前都存放在./logs 目录下

以碧桂园项目为例,我们项目通过 jenkins 部署,bgy 部署的路径是/home/app/services/bgy,日志在./logs 文件夹下

[root@VM-0-16-centos bgy]# pwd
/home/app/services/bgy
[root@VM-0-16-centos bgy]# ls -la
total 80120
drwxrwxrwx 4 root root     4096 Jul 29 10:44 .
drwxrwxrwx 6 app  app      4096 Aug 12 23:50 ..
-rwxrwxrwx 1 app  app  81960162 Aug 31 20:07 bgy-application-3.0.0.jar
drwxrwxr-x 3 app  app      4096 Jul 29 10:44 d:\
drwxrwxrwx 3 app  app      4096 Aug  1 15:14 logs
-rwxrwxrwx 1 app  app     49356 Aug 31 23:52 output
-rwxrwxrwx 1 root root      248 Jul 23 17:36 run.sh
[root@VM-0-16-centos bgy]#

  • web_debug.log:调试级别日志
  • web_info.log:info 级日志
  • web_error.log: 错误日志
[root@VM-0-16-centos bgy]# cd logs/
[root@VM-0-16-centos logs]# ls
2022-07  web_debug.log  web_error.log  web_info.log

可以 tail -f -n 100 web_error.log 查看日志,注意:在 3.0.2 版本框架中,对日志的流水号进行了升级(Guide)[/java/Guide]

其中1b813238e895426090a10280e109fd01为流水号

17:55:25 [1b813238e895426090a10280e109fd01] ERROR c.q.d.w.c.ExceptionHandler - 数据访问异常,message:query did not return a unique result: 88; nested exception is javax.persistence.NonUniqueResultException: query did not return a unique result: 88

在 web_info 中可以检索更多的上下文信息


tail -f -n 10000 web_info.log | grep "1b813238e895426090a10280e109fd01"


18:12:06 [1b813238e895426090a10280e109fd01] INFO  c.i.b.s.TokenAuthenticationFilter - request path:/api/app/ad/list,query str:null,form:{}
18:12:06 [1b813238e895426090a10280e109fd01] INFO  c.i.b.s.TokenAuthenticationFilter - token:96eab904639c40b8957b54aacfa9e338
18:12:06 [1b813238e895426090a10280e109fd01] INFO  c.i.b.s.TokenAuthenticationFilter - current user:{"accountNonExpired":true,"accountNonLocked":true,"authorities":[],"avatar":"https://thirdwx.qlogo.cn/mmopen/vi_32/cUbPnFTebbQwytQibib5gwo2tO45qFSyBiaLlaibZ84wibpD8eQojS5ibqibOgadQ7Ny3OziakBiaicYO3zI5Fice1YBHNpag/132","communityId":65,"credentialsNonExpired":true,"enabled":true,"gender":"UNKNOWN","nickName":"O.Ohuang","phoneNumber":"13555883891","realName":"黄伟","roleList":[],"status":"1","token":"96eab904639c40b8957b54aacfa9e338","userId":127,"username":"黄伟","uuid":"2e8d8b4543664e99abafa30a06e63118"}
18:12:06 [1b813238e895426090a10280e109fd01] INFO  c.q.d.w.s.CryptoRequestBodyAdvice - [REQUEST], path : /api/app/ad/list, Body : {"size":20,"page":0,"communityId":65,"status":"1"}
18:12:06 [1b813238e895426090a10280e109fd01] INFO  c.q.d.w.s.CryptoResponseBodyAdvice - [RESPONSE], time : 4, Body : {"code":200,"message":"success","result":[{"adId":15,"communityId":65,"createdAt":"2022-08-25 15:19:03","createdBy":126,"gameId":1,"img":"http://bgyyx.public.isart.me/bgyyx/202208/1661411938925.webp","sort":1,"status":"1","title":"11","version":0},{"adId":16,"communityId":65,"createdAt":"2022-08-25 15:19:15","createdBy":126,"gameId":1,"img":"http://bgyyx.public.isart.me/bgyyx/202208/1661411950733.jpg","sort":2,"status":"1","title":"222","version":0},{"adId":17,"communityId":65,"createdAt":"2022-08-25 15:19:26","createdBy":126,"gameId":1,"img":"http://bgyyx.public.isart.me/bgyyx/202208/1661411961585.jpg","sort":4,"status":"1","title":"333","version":0},{"adId":18,"communityId":65,"createdAt":"2022-08-25 15:19:42","createdBy":126,"gameId":1,"img":"http://bgyyx.public.isart.me/bgyyx/202208/1661411977086.jpg","sort":66,"status":"1","title":"555","version":0}]}

如何提升批量插入/更新效率

首先 JPA 有批量操作管理,但是要求表的 ID 定义不能为GenerationType.IDENTITY,我们当前的规范不能满足,那么有一个思路是进行多线程的操作,那么操作时有前提:

  • 不能加入事务
  • 只适合于单表操作
  • 业务不能有耦合,不能对插入顺序有要求

在电商助手的选品管理功能中,最后要批量向表中插入 2000 条数据,业务比较独立,最终通过多线程的办法将插入效率提升了 近 100 倍,多线程插入数据的性能瓶颈在 DB

/**
     * 通过多线程方式,批量保存数据
     *
     * @param entityList
     */
    private void batchSaveSkuFiltSelection(List<PmsSkuFiltSelectionEntity> entityList) {

        Integer threadCount = 100;

        // 截断表,经过与学哥的沟通,每次只保留一套数据即可
        skuFiltSelectionManager.truncateTable();

        LocalDateTime time1 = LocalDateTime.now();

        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(20, threadCount
                , 4, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadPoolExecutor.AbortPolicy());

        List<List<PmsSkuFiltSelectionEntity>> splitList = ListUtils.splitList(entityList, threadCount);

        // 记录单个任务的执行次数
        CountDownLatch countDownLatch = new CountDownLatch(splitList.size());
        for (List<PmsSkuFiltSelectionEntity> singleList : splitList) {
            // 线程池执行
            threadPool.execute(new Thread(() -> {
                skuFiltSelectionManager.saveAll(singleList);
                countDownLatch.countDown();
            }));
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        LocalDateTime time2 = LocalDateTime.now();

        Duration duration = Duration.between(time1, time2);
        log.info("保存数据耗时:{}秒", duration.getSeconds());

    }

其中 ListUtils 在电商助手项目中存在

Truncate 表

有时候需要 Truncate 表时,应该在 repository 中编写原生语句,truncate 表的效率比较高,适合于清空表的场景

    @Transactional
    @Modifying
    @Query(value = "truncate table `pms_sku_filt_selection`", nativeQuery = true)
    public void truncateTable();

Last Updated:
Contributors: TerryQi