version: '3.2' services: redis5: image: redis:5 container_name: redis5 restart: always environment: - ALLOW_EMPTY_PASSWORD=yes command: redis-server --appendonly yes --requirepass 123456 volumes: - redis:/data #数据文件挂载 ports: - 6379:6379 nacos-registry: image: nacos/nacos-server:v2.2.3 container_name: nacos environment: - PREFER_HOST_MODE=hostname - MODE=standalone - SPRING_DATASOURCE_PLATFORM=mysql - MYSQL_SERVICE_HOST=mysql - MYSQL_SERVICE_DB_NAME=nacos - MYSQL_SERVICE_PORT=3306 - MYSQL_SERVICE_USER=root - MYSQL_SERVICE_PASSWORD=123456 - MYSQL_SERVICE_DB_PARAM=characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false&allowPublicKeyRetrieval=true # volumes: # - /docker/service/env/nacos:/home/nacos/init.d ports: - "8848:8848" - "9555:9555" - "9848:9848" - "9849:9849" restart: on-failure external_links: - mysql:db #可以用db这个域名访问mysql服务 depends_on: - mysql mysql: image: mysql container_name: mysql command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --lower_case_table_names=1 --log_bin_trust_function_creators=1 --max_allowed_packet=100G --tmp_table_size=256M --key_buffer_size=512M --innodb_log_file_size=1024M restart: always environment: MYSQL_ROOT_PASSWORD: 123456 #设置root帐号密码 ports: - 3306:3306 volumes: - mysql8:/var/lib/mysql #数据文件挂载 volumes: redis: external: true mysql8: external: true
Swagger 枚举字段显示在接口文档中，并自定义枚举返回
Swagger 枚举字段显示在接口文档中，并自定义枚举返回最近使用枚举做为接收参数，接收枚举中的某一个属性，返回对应的枚举，但是发现这种方法不能在swagger文档中体现，故此拓展springfox插件来实现。⚠️ 本次修改兼容 v2 v3先来看下效果：请求参数显示枚举信息响应显示枚举信息接下来实操请求参数和响应显示枚举信息创建枚举对应显示信息注解其中 value 为入参数值，desc 为入参的描述@Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) public @interface EnableSwaggerEnum { String value() default "value"; String desc() default "desc"; } @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface EnableSwaggerResponseEnum { String value() default "value"; String desc() default "desc"; }创建一个公共修改model由于要兼容v2 和 v3,故此需要创建一个修改的公共model，用来存放修改的枚举信息。其中，displayValues是用来显示各个枚举值和信息的字符串，allowableListValues 为实际要显示的入场值。@Data public class EnumMidModel { private String displayValues; private AllowableListValues allowableListValues; }封装modelpublic class SwaggerEnumUtils { /** * 封装枚举消息 * * @param type 此解析类型具有的类型擦除 Class<?> * @param enableSwaggerEnum 自定义swagger枚举限制注释 {@link EnableSwaggerEnum} * @return {@link EnumMidModel} * @author Mc * @since 2023/3/1 20:24 */ protected static EnumMidModel getEnumMidModel(Class<?> type, EnableSwaggerEnum enableSwaggerEnum) { EnumMidModel enumMidModel = new EnumMidModel(); String value = enableSwaggerEnum.value(); String desc = enableSwaggerEnum.desc(); Object[] enumConstants = type.getEnumConstants(); // 拼装显示内容 final String displayValues = Arrays.stream(enumConstants) .filter(Objects::nonNull) .map( item -> { Object valueVal = ReflectUtil.getFieldValue(item, value); Object descVal = ReflectUtil.getFieldValue(item, desc); return valueVal.toString() + " : " + descVal.toString(); } ) .collect(Collectors.joining("】\n【", "\n【", "】")); List<String> allowableValue = Arrays.stream(enumConstants) .filter(Objects::nonNull) .map(item -> ReflectUtil.getFieldValue(item, value).toString()) .collect(Collectors.toList()); AllowableListValues allowableListValues = new AllowableListValues(allowableValue, "string"); enumMidModel.setDisplayValues(displayValues); enumMidModel.setAllowableListValues(allowableListValues); return enumMidModel; } }使用插件拓展直接上代码，内容为分别继承ModelPropertyBuilderPlugin、ParameterBuilderPlugin、ModelPropertyBuilderPlugin插件，改写3个插件的apply()方法，supports()方法代表兼容哪种，可以写死为true。静态代码块里的内容为响应添加制定义枚举。@Component @Slf4j @Primary public class EnumPropertyModelOperationBuilder implements ModelPropertyBuilderPlugin, ParameterBuilderPlugin, OperationBuilderPlugin { static Map<String, String> responseMap = new HashMap<>(); static { Class<EnableSwaggerResponseEnum> enableSwaggerResponseEnumClass = EnableSwaggerResponseEnum.class; Set<Class<?>> classes = ClassUtil.scanPackageByAnnotation("com.xinwei", enableSwaggerResponseEnumClass); if (CollUtil.isNotEmpty(classes)) { for (Class<?> aClass : classes) { if (aClass.isEnum()) { Object[] enumConstants = aClass.getEnumConstants(); Class<?>[] interfaces = aClass.getInterfaces(); boolean flag = false; for (Class<?> anInterface : interfaces) { if (anInterface.equals(IErrorCode.class)) { flag = true; break; } } if (!flag) { break; } EnableSwaggerResponseEnum annotation = AnnotationUtil.getAnnotation(aClass, EnableSwaggerResponseEnum.class); String desc = annotation.desc(); String value = annotation.value(); Arrays.stream(enumConstants).filter(Objects::nonNull).forEach(item -> { Object valueVal = ReflectUtil.getFieldValue(item, value); Object descVal = ReflectUtil.getFieldValue(item, desc); responseMap.put(valueVal.toString(), descVal.toString()); }); } } } } /** * {@link ApiModelProperty} 枚举处理 * 可选值必须为 {@link AllowableListValues},否则会带上旧的枚举name * {@link EnumerationElementFacetBuilder#allowedValues(Collection)} 不会清理旧的选项 * {@link EnumerationElementFacetBuilder#allowedValues(AllowableValues)} 会清理旧的选项 * * @param context {@link ParameterContext} * @author Mc * @since 2023/3/1 14:16 */ @Override @Order(Ordered.HIGHEST_PRECEDENCE + 999) public void apply(ModelPropertyContext context) { Optional<ApiModelProperty> annotation = Optional.empty(); if (context.getAnnotatedElement().isPresent()) { annotation = ApiModelProperties.findApiModePropertyAnnotation(context.getAnnotatedElement().get()); } if (context.getBeanPropertyDefinition().isPresent()) { annotation = Annotations.findPropertyAnnotation( context.getBeanPropertyDefinition().get(), ApiModelProperty.class); } final Class<?> rawPrimaryType = context.getBeanPropertyDefinition().get().getRawPrimaryType(); //过滤得到目标类型 if (annotation.isPresent() && Enum.class.isAssignableFrom(rawPrimaryType)) { EnableSwaggerEnum enableSwaggerEnum = AnnotationUtils.findAnnotation(rawPrimaryType, EnableSwaggerEnum.class); if (null != enableSwaggerEnum) { EnumMidModel enumMidModel = SwaggerEnumUtils.getEnumMidModel(rawPrimaryType, enableSwaggerEnum); context.getSpecificationBuilder() .description(annotation.get().value() + enumMidModel.getDisplayValues()) .enumerationFacet(e -> e.allowedValues(enumMidModel.getAllowableListValues())); } } } @Override public boolean supports(@NonNull DocumentationType documentationType) { return true; } /** * {@link Parameter} 枚举处理 * 可选值必须为 {@link AllowableListValues},否则会带上旧的枚举name * {@link EnumerationElementFacetBuilder#allowedValues(Collection)} 不会清理旧的选项 * {@link EnumerationElementFacetBuilder#allowedValues(AllowableValues)} 会清理旧的选项 * * @param context {@link ParameterContext} * @author Mc * @since 2023/3/1 14:16 */ @Override @Order(Ordered.HIGHEST_PRECEDENCE + 2999) public void apply(ParameterContext context) { Optional<Parameter> annotation = context.resolvedMethodParameter().findAnnotation(Parameter.class); if (annotation.isPresent()) { Class<?> type = context.resolvedMethodParameter().getParameterType().getErasedType(); if (Enum.class.isAssignableFrom(type)) { EnableSwaggerEnum enableSwaggerEnum = AnnotationUtils.findAnnotation(type, EnableSwaggerEnum.class); if (enableSwaggerEnum != null) { EnumMidModel enumMidModel = SwaggerEnumUtils.getEnumMidModel(type, enableSwaggerEnum); context.requestParameterBuilder() .description(annotation.get().description() + enumMidModel.getDisplayValues()) .query(a -> a.enumerationFacet(e -> e.allowedValues(enumMidModel.getAllowableListValues()))); } } } } /** * {@link io.swagger.v3.oas.annotations.Operation} 枚举处理 * 可选值必须为 {@link AllowableListValues},否则会带上旧的枚举name * * @param context {@link ParameterContext} * @author Mc * @since 2023/3/1 14:16 */ @Override @Order(Ordered.HIGHEST_PRECEDENCE + 999) public void apply(OperationContext context) { OperationBuilder operationBuilder = context.operationBuilder(); Operation build = operationBuilder.build(); // 添加响应 addResponseInfo(operationBuilder, build); List<ResolvedMethodParameter> parameters = context.getParameters(); parameters.forEach(parameter -> { ResolvedType parameterType = parameter.getParameterType(); Class<?> type = parameterType.getErasedType(); if (Enum.class.isAssignableFrom(type)) { EnableSwaggerEnum enableSwaggerEnum = AnnotationUtils.findAnnotation(type, EnableSwaggerEnum.class); if (enableSwaggerEnum != null) { EnumMidModel enumMidModel = SwaggerEnumUtils.getEnumMidModel(type, enableSwaggerEnum); Set<RequestParameter> requestParameters = build.getRequestParameters(); Set<RequestParameter> newParameters = new HashSet<>(); for (RequestParameter requestParameter : requestParameters) { if (requestParameter.getName().equals(parameter.defaultName().orElse(""))) { ParameterSpecification specification = requestParameter.getParameterSpecification(); Optional<SimpleParameterSpecification> query = specification.getQuery(); if (query.isPresent()) { SimpleParameterSpecification simpleParameterSpecification = query.get(); List<ElementFacet> newFacets = new ArrayList<>(); EnumerationFacet enumerationFacet = new EnumerationFacet(enumMidModel.getAllowableListValues().getValues()); newFacets.add(enumerationFacet); ReflectUtil.setFieldValue(simpleParameterSpecification, "facets", newFacets); } requestParameter = new RequestParameter(requestParameter.getName(), requestParameter.getIn(), enumMidModel.getDisplayValues() , requestParameter.getRequired(), requestParameter.getDeprecated(), requestParameter.getHidden(), specification, requestParameter.getScalarExample(), requestParameter.getExamples(), requestParameter.getPrecedence(), requestParameter.getExtensions(), requestParameter.getParameterIndex() ); } newParameters.add(requestParameter); } operationBuilder.requestParameters(newParameters); } } }); } private static void addResponseInfo(OperationBuilder operationBuilder, Operation build) { if (MapUtil.isNotEmpty(responseMap)) { List<Response> newResponseList = new ArrayList<>(); SortedSet<Response> responses = build.getResponses(); if (CollUtil.isNotEmpty(responses)) { newResponseList.addAll(responses); } for (Map.Entry<String, String> responseInfo : responseMap.entrySet()) { Example someDescription = new ExampleBuilder() .description(responseInfo.getValue()) .mediaType(MediaType.APPLICATION_JSON_VALUE) .id(responseInfo.getKey()) .value(R.failed(Long.valueOf(responseInfo.getKey()), responseInfo.getValue(), null)).build(); Response response = new ResponseBuilder() .code(responseInfo.getKey()) .description(responseInfo.getKey() + "_" + responseInfo.getValue()) .examples(Collections.singleton(someDescription)) .build(); newResponseList.add(response); } operationBuilder.responses(newResponseList); } } }需要添加枚举显示的枚举类，添加注解其中，value="code" 代表对应枚举属性code，入场使用code 接收参数，desc="name" 代表对应描述为name，文档会显示对应code的name描述需要添加响应显示的枚举类，添加注解@EnableSwaggerResponseEnum搞定看效果
常用的@Superwarnning 注解大全​ 使用idea的时候，有时避免不了会被编辑器提示警告，虽然不影响代码和程序的正常观看和运行，但总之是不美观，这时便可以用@Superwarnning注解来抑制警告来。尽量在代码编辑无法穷善的时候使用@SuppressWarnings("unchecked")//执行了未检查的转换时的警告，例如当使用集合时没有用泛型 (Generics) 来指定集合保存的类型。 @SuppressWarnings("unused") //未使用的变量 @SuppressWarnings("resource") //有泛型未指定类型 @SuppressWarnings("path") //在类路径、源文件路径等中有不存在的路径时的警告 @SuppressWarnings("deprecation") //使用了不赞成使用的类或方法时的警告 @SuppressWarnings("fallthrough") //当 Switch 程序块直接通往下一种情况而没有 break; 时的警告 @SuppressWarnings("serial")//某类实现Serializable(序列化)， 但没有定义 serialVersionUID 时的警告 @SuppressWarnings("rawtypes") //没有传递带有泛型的参数 @SuppressWarnings("finally") //任何 finally 子句不能正常完成时的警告。 @SuppressWarnings("try") // 没有catch时的警告 @SuppressWarnings("DuplicatedCode") //重复代码警告抑制 @SuppressWarnings("SpellCheckingInspection") // 抑制错误拼写 @SuppressWarnings("DuplicatedCode") // 抑制重复代码提示 @SuppressWarnings("all") //所有类型的警告 @SuppressWarnings("UnusedParameters") // 未使用的参数 // 阿里巴巴插件 @SuppressWarnings("AlibabaClassNamingShouldBeCamel") //【xXxXXxX】不符合UpperCamelCase命名风格 @SuppressWarnings（"AlibabaLowerCamelCaseVariableNaming") // 变量名【xxXX】不符合lowerCamelCase命名风格
使用SpringCloud Alibaba结合Springboot3.0 搭建一个demo
Springboot 3 出来也有一段时间了，Springcloud alibaba也在适配当中，本demo使用Springboot 3 + Spingcloud alibaba + nacos等组件，完成一个项目工程的简单搭建组件Nacos : 2.0.3Maven：3.0.8依赖版本springboot :3.0.4springcloud:2022.0.1springcloud alibaba :2.2.10-RC1spring-cloud-starter-alibaba-nacos-discovery:2022.0.0.0-RC1spring-cloud-starter-alibaba-nacos-config：2022.0.0.0-RC1springcloud alibaba 2.2.10-RC1 版本提供的Nacos依赖，经测试无法成功注册，故使用2022.0.0.0-RC1版本项目POM<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.0.4</version> <relativePath/> </parent> <groupId>com.piemoe</groupId> <artifactId>springboot3</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboot3</name> <description>springboot3</description> <properties> <java.version>17</java.version> </properties> ​ <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2022.0.1</version> <type>pom</type> <scope>import</scope> </dependency> ​ <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.2.10-RC1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> ​ <dependencies> ​ <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> ​ <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2022.0.0.0-RC1</version> </dependency> ​ ​ <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2022.0.0.0-RC1</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> ​ ​ </dependencies> ​ <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> ​ </project> ​启动类@SpringBootApplication public class Springboot3Application { ​ public static void main(String[] args) { SpringApplication.run(Springboot3Application.class, args); } ​ }配置文件bootstrap.ymlserver: port: 13000 ​ spring: main: banner-mode: off application: name: demo profiles: active: local ​ rpc: addr: 127.0.0.1:8848 ​ logging: file: name: logs/${spring.application.name}/all.logbootstrap-local.ymlspring: cloud: nacos: discovery: server-addr: ${rpc.addr} namespace: ${spring.profiles.active} config: server-addr: ${rpc.addr} namespace: ${spring.profiles.active} file-extension: ymlNacos配置demo: name: xxxxx项目已提交到github，有兴趣的可以去看下 仓库
MyBatis-PlusMyBatis-Plus（简称MP）是MyBatis社区的一个增强工具包，它在MyBatis的基础上进行了扩展，封装了很多有用的功能，提高了开发效率。MyBatis-Plus中包含了许多常用的增删改查的方法，还有分页查询、条件构造器、乐观锁、多租户、性能分析等功能。以下是MyBatis-Plus的一些特点：简化开发：MyBatis-Plus可以通过代码自动生成器快速生成CRUD方法的代码，极大地提高了开发效率。增删改查方法：MyBatis-Plus封装了通用的增删改查方法，无需手写SQL语句。集成分页插件：MyBatis-Plus集成了分页插件，使用简单，可以轻松实现分页操作。条件构造器：MyBatis-Plus提供了条件构造器，可以通过代码的形式构造复杂的SQL查询语句。乐观锁：MyBatis-Plus提供了乐观锁的功能，避免了并发操作时的数据冲突。多租户：MyBatis-Plus支持多租户，可以通过配置实现多租户系统的开发。性能分析：MyBatis-Plus集成了性能分析插件，可以分析SQL查询的性能瓶颈，帮助优化SQL语句，提高查询效率。总之，MyBatis-Plus是一个非常优秀的MyBatis增强工具包，其功能强大且易于使用，在实际开发中也能够显著地提高开发效率和工作效率。Java OptionalJava Optional是Java 8中引入的一个类，用于表示一个值可能存在也可能不存在。它的主要目的是减少空指针异常。Java Optional类有两个重要的方法:isPresent()和get()。isPresent()方法用于检查值是否存在，get()方法用于获取值。如果值不存在，get()方法会抛出NoSuchElementException异常。Java Optional还有很多有用的方法，比如orElse()和orElseGet()方法。这些方法可以帮助我们处理值不存在的情况。Java Optional是一个泛型类，因此可以用于处理任何类型的值。使用Java Optional的主要优点是可以避免空指针异常，提高代码健壮性。堵点MyBatis-Plus提供了很好的CURD体验，大大加强了开发的效率，其Lambda表达式式的方式也大大增强了用户的体验，但是MyBatis-Plus返回的参数仍然会在实际项目中被忽略空指针问题，笔者的朋友和同事就经常因为空指针问题（主要是忘记）经常被测试吐槽，故此通过可以使用Optional来增强Mybatis-plus的体验，一定程度上可以避免“忘记”判空带来的空指针问题。代码项目中引入MyBatis-Plus，新建OptionalBaseMapper文件，实现BaseMapper，具体代码如下：@SuppressWarnings({"DuplicatedCode", "unused"}) public interface OptionalBaseMapper<T> extends BaseMapper<T> { // ======================================= 批量插入 ======================================== /** * 批量插入 仅适用于mysql * * @param entityList 实体列表 * @return 影响行数 */ Integer insertBatchSomeColumn(Collection<T> entityList); /** * 逐条插入，适合少量数据插入，或者对性能要求不高的场景 * * 如果大量，请使用 {@link com.baomidou.mybatisplus.extension.service.impl.ServiceImpl#saveBatch(Collection)} 方法 * 使用示例，可见 RoleMenuBatchInsertMapper、UserRoleBatchInsertMapper 类 * * @param entities 实体们 */ default void insertBatch(Collection<T> entities) { entities.forEach(this::insert); } // ======================================= 查询数量 ======================================== /** * 根据 Wrapper 条件，查询总记录数 * * @param queryWrapper 实体对象封装操作类（可以为 null） */ default Optional<Long> getCount(Wrapper<T> queryWrapper) { Long count = selectCount(queryWrapper); return ObjectUtil.isNull(count) ? Optional.empty() : Optional.of(count); } /** * 查询指定字段值数量 * * @param field field * @param value value */ default Optional<Long> selectCount(String field, Object value) { Long count = selectCount(new QueryWrapper<T>().eq(field, value)); return ObjectUtil.isNull(count) ? Optional.empty() : Optional.of(count); } /** * 查询指定字段值数量 * * @param field field * @param value value */ default Optional<Long> selectCount(SFunction<T, ?> field, Object value) { Long count = selectCount(new LambdaQueryWrapper<T>().eq(field, value)); return ObjectUtil.isNull(count) ? Optional.empty() : Optional.of(count); } // ======================================= 查询一个 ======================================== /** * 根据 ID 查询 * * @param id 主键ID */ default Optional<T> getById(Serializable id) { T t = selectById(id); return ObjectUtil.isNull(t) ? Optional.empty() : Optional.of(t); } /** * 根据 entity 条件，查询一条记录 * 查询一条记录，例如 qw.last("limit 1") 限制取一条记录, 注意：多条数据会报异常 * * @param queryWrapper 实体对象封装操作类（可以为 null） */ default Optional<T> getOne(Wrapper<T> queryWrapper) { T t = selectOne(queryWrapper); return ObjectUtil.isNull(t) ? Optional.empty() : Optional.of(t); } /** * 查询一个 * * @param field field * @param value value */ default Optional<T> getOne(String field, Object value) { return getOne(new QueryWrapper<T>().eq(field, value)); } /** * 查询一个 * * @param field field * @param value value */ default Optional<T> getOne(SFunction<T, ?> field, Object value) { return getOne(new LambdaQueryWrapper<T>().eq(field, value)); } /** * 查询一个 * * @param field1 field1 * @param value1 value1 * @param field2 field2 * @param value2 value2 */ default Optional<T> getTwo(String field1, Object value1, String field2, Object value2) { return getOne(new QueryWrapper<T>().eq(field1, value1).eq(field2, value2)); } /** * 查询一个 * * @param field1 field1 * @param value1 value1 * @param field2 field2 * @param value2 value2 */ default Optional<T> getTwo(SFunction<T, ?> field1, Object value1, SFunction<T, ?> field2, Object value2) { return getOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2)); } // ======================================= 查询多个 ======================================== /** * 查询（根据ID 批量查询） * * @param idList 主键ID列表(不能为 null 以及 empty) */ default Optional<List<T>> listBatchIds(Collection<? extends Serializable> idList) { List<T> list = selectBatchIds(idList); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 根据 entity 条件，查询全部记录 * * @param queryWrapper 实体对象封装操作类（可以为 null） */ default Optional<List<T>> list(Wrapper<T> queryWrapper) { List<T> list = selectList(queryWrapper); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 列表查询 * * @param field field * @param value value */ default Optional<List<T>> list(String field, Object value) { List<T> list = selectList(new QueryWrapper<T>().eq(field, value)); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 列表查询 * * @param field field * @param value value */ default Optional<List<T>> list(SFunction<T, ?> field, Object value) { List<T> list = selectList(new LambdaQueryWrapper<T>().eq(field, value)); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 列表查询 * * @param field field * @param values values */ default Optional<List<T>> list(String field, Collection<?> values) { List<T> list = selectList(new QueryWrapper<T>().in(field, values)); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 列表查询 * * @param field field * @param values values */ default Optional<List<T>> list(SFunction<T, ?> field, Collection<?> values) { List<T> list = selectList(new LambdaQueryWrapper<T>().in(field, values)); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 查询（根据 columnMap 条件） * * @param columnMap 表字段 map 对象 */ default Optional<List<T>> listByMaps(Map<String, Object> columnMap) { List<T> list = selectByMap(columnMap); return CollUtil.isEmpty(list) ? Optional.empty() : Optional.of(list); } /** * 根据 Wrapper 条件，查询全部记录 * * @param queryWrapper 实体对象封装操作类（可以为 null） */ default Optional<List<Map<String, Object>>> maps(Wrapper<T> queryWrapper) { List<Map<String, Object>> maps = selectMaps(queryWrapper); return CollUtil.isEmpty(maps) ? Optional.empty() : Optional.of(maps); } /** * 根据 Wrapper 条件，查询全部记录 * 注意： 只返回第一个字段的值 * * @param queryWrapper 实体对象封装操作类（可以为 null） */ default Optional<List<Object>> listObjs(Wrapper<T> queryWrapper) { List<Object> objects = selectObjs(queryWrapper); return CollUtil.isEmpty(objects) ? Optional.empty() : Optional.of(objects); } // ======================================= 分页 ======================================== /** * 分页 * * @param pageParam 分页参数 需继承PageParam * @param queryWrapper queryWrapper */ default PageResult<T> selectPage(PageParam pageParam, @Param("ew") Wrapper<T> queryWrapper) { // MyBatis Plus 查询 IPage<T> mpPage = MyBatisUtils.buildPage(pageParam); selectPage(mpPage, queryWrapper); // 转换返回 return new PageResult<>(mpPage.getRecords(), mpPage.getTotal(), mpPage.getPages(), mpPage.getCurrent(), mpPage.getSize()); } /** * 根据 entity 条件，查询全部记录（并翻页） * * @param sortingFields 排序字段 * @param pageParam 分页查询条件 * @param queryWrapper 实体对象封装操作类（可以为 null） */ default PageResult<T> selectPage(PageParam pageParam, @Param("ew") Wrapper<T> queryWrapper, Collection<SortingField> sortingFields) { // MyBatis Plus 查询 IPage<T> mpPage = MyBatisUtils.buildPage(pageParam, sortingFields); selectPage(mpPage, queryWrapper); // 转换返回 return new PageResult<>(mpPage.getRecords(), mpPage.getTotal(), mpPage.getPages(), mpPage.getCurrent(), mpPage.getSize()); } }最后，在dao层，继承从BaseMapper换成OptionalBaseMapper即可。
前言数据脱敏是一种数据保护技术，它可以对敏感数据进行处理，使其失去原始意义，从而保护数据隐私。通俗地说，就是将原始数据中敏感信息的部分替换或删除，例如：手机号码中的一部分数字替换为星号，或是姓名中的一些字母用其他字母代替等等。数据脱敏可以保护个人隐私，防止个人信息被泄露、滥用，同时也符合现代法律法规对个人信息保护的要求，是网络安全和信息保护中不可或缺的一环。本文将通过使用注解完成对字段的自定义脱敏功能。正文本文将介绍2种脱敏的方式，分别为：注解方式脱敏和lambda方式脱敏注解脱敏定义脱敏注解@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @JacksonAnnotationsInside @JsonSerialize(using = SensitiveSerialize.class) public @interface Dm { /** * 脱敏信息类型 */ DmTypeEnum type(); /** * 前置不需要打码的长度 * 只有 {@link DmTypeEnum} 为 {@link DmTypeEnum#CUSTOMER} 时可用 */ int prefixNoMaskLen() default 0; /** * 后置不需要打码的长度 * 只有 {@link DmTypeEnum} 为 {@link DmTypeEnum#CUSTOMER} 时可用 */ int suffixNoMaskLen() default 0; /** * 用什么打码 * 只有 {@link DmTypeEnum} 为 {@link DmTypeEnum#CUSTOMER} 时可用 */ char symbol() default '*'; }type()方法为可以支持实现的脱敏信息类型，通过使用hutool工具，可以看到它支持以下枚举，故拉取出来形成类型枚举：public enum DmTypeEnum { /** * 用户自定义 */ CUSTOMER, /** * 用户id */ BACK_ID, /** * 中文名 */ CHINESE_NAME, /** * 身份证号 */ ID_CARD, /** * 座机号 */ FIXED_PHONE, /** * 手机号 */ MOBILE_PHONE, /** * 地址 */ ADDRESS, /** * 电子邮件 */ EMAIL, /** * 密码 */ PASSWORD, /** * 中国大陆车牌，包含普通车辆、新能源车辆 */ CAR_LICENSE, /** * 银行卡 */ BANK_CARD }关键语句在@JsonSerialize(using = SensitiveSerialize.class)上，@JsonSerialize 是一个注解，它可以用来指定 Java 对象序列化成 JSON 格式时的序列化规则。具体而言，它可以指定序列化时属性名的命名方式、日期格式化等等。它的作用是使得 Java 对象可以方便地转化为 JSON 格式，这对于 Web 开发、移动应用开发等场景非常有用，故此我们在这里自定义一个实现，并在注解中引用。@NoArgsConstructor @AllArgsConstructor @Slf4j public class SensitiveSerialize<T> extends JsonSerializer<T> implements ContextualSerializer { /** * 脱敏类型 */ private DmTypeEnum sensitiveTypeEnum; /** * 前几位不脱敏 */ private Integer prefixNoMaskLen; /** * 最后几位不脱敏 */ private Integer suffixNoMaskLen; /** * 打码字符 */ private char symbol; @Override public void serialize(final T value, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException { Object desensitized = DmUtils.desensitized(value.toString(), sensitiveTypeEnum, prefixNoMaskLen, suffixNoMaskLen, symbol); if (desensitized instanceof String) { jsonGenerator.writeString(desensitized.toString()); return; } if (desensitized instanceof Number) { jsonGenerator.writeNumber(DmUtils.backId()); return; } log.error("[数据脱敏]未知的信息类型：{}", sensitiveTypeEnum); jsonGenerator.writeString(StrUtil.EMPTY); } @Override public JsonSerializer<?> createContextual(final SerializerProvider serializerProvider, final BeanProperty beanProperty) throws JsonMappingException { if (beanProperty != null) { if ( Objects.equals(beanProperty.getType().getRawClass(), String.class) || Objects.equals(beanProperty.getType().getSuperClass().getRawClass(), Number.class) ) { Dm sensitive = beanProperty.getAnnotation(Dm.class); if (sensitive == null) { sensitive = beanProperty.getContextAnnotation(Dm.class); } if (sensitive != null) { return new SensitiveSerialize<T>(sensitive.type(), sensitive.prefixNoMaskLen(), sensitive.suffixNoMaskLen(), sensitive.symbol()); } } return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty); } return serializerProvider.findNullValueSerializer(null); } }脱敏工具类（对照hutool脱敏方法）@Slf4j public class DmUtils { /** * 脱敏，使用默认的脱敏策略 * * desensitized("100", DesensitizedUtils.DesensitizedType.USER_ID)) = "0" * desensitized("段正淳", DesensitizedUtils.DesensitizedType.CHINESE_NAME)) = "段**" * desensitized("51343620000320711X", DesensitizedUtils.DesensitizedType.ID_CARD)) = "5***************1X" * desensitized("09157518479", DesensitizedUtils.DesensitizedType.FIXED_PHONE)) = "0915*****79" * desensitized("18049531999", DesensitizedUtils.DesensitizedType.MOBILE_PHONE)) = "180****1999" * desensitized("北京市海淀区马连洼街道289号", DesensitizedUtils.DesensitizedType.ADDRESS)) = "北京市海淀区马********" * desensitized("duandazhi-jack@gmail.com.cn", DesensitizedUtils.DesensitizedType.EMAIL)) = "d*************@gmail.com.cn" * desensitized("1234567890", DesensitizedUtils.DesensitizedType.PASSWORD)) = "**********" * desensitized("苏D40000", DesensitizedUtils.DesensitizedType.CAR_LICENSE)) = "苏D4***0" * desensitized("11011111222233333256", DesensitizedUtils.DesensitizedType.BANK_CARD)) = "1101 **** **** **** 3256" * * * @param message 字符串 * @param desensitizedType 脱敏类型;可以脱敏：用户id、中文名、身份证号、座机号、手机号、地址、电子邮件、密码 * @param startInclude 开始排除 类型为 CUSTOMER 时可用 * @param endExclude 结束排除 类型为 CUSTOMER 时可用 * @param symbol 脱敏字符 默认为'*' 类型为 CUSTOMER 时可用 * @return 脱敏之后的字符串 */ public static Object desensitized(Object message, DmTypeEnum desensitizedType, int startInclude, int endExclude, char symbol) { if (message instanceof Number && StrUtil.isBlank(message.toString())) { return -1L; } if (StrUtil.isBlank(message.toString())) { return StrUtil.EMPTY; } String newStr; switch (desensitizedType) { case CUSTOMER: newStr = customer(String.valueOf(message), startInclude, endExclude, symbol); break; case BACK_ID: log.info("[数据脱敏]后台用户id脱敏，值为：{}", message); return backId(); case CHINESE_NAME: newStr = chineseName(String.valueOf(message)); break; case ID_CARD: newStr = idCardNum(String.valueOf(message), 1, 2); break; case FIXED_PHONE: newStr = fixedPhone(String.valueOf(message)); break; case MOBILE_PHONE: newStr = mobilePhone(String.valueOf(message)); break; case ADDRESS: newStr = address(String.valueOf(message), 8); break; case EMAIL: newStr = email(String.valueOf(message)); break; case PASSWORD: newStr = password(String.valueOf(message)); break; case CAR_LICENSE: newStr = carLicense(String.valueOf(message)); break; case BANK_CARD: newStr = bankCard(String.valueOf(message)); break; default: newStr = ""; } return newStr; } public static String replace(CharSequence str, int startInclude, int endExclude, char symbol) { return CharSequenceUtil.replace(str, startInclude, endExclude, symbol); } public static String customer(String customer, int startInclude, int endExclude, char replacedChar) { if (StrUtil.isBlank(customer)) { return ""; } if (startInclude > customer.length() || endExclude > customer.length() || (startInclude + endExclude) > customer.length()) { log.warn("[信息脱敏]自定义长度不正确"); return replace(customer, startInclude, customer.length() - 2, replacedChar); } return replace(customer, startInclude, endExclude, replacedChar); } /** * 【后台用户id】不对外提供backId * * @return 脱敏后的主键 */ public static Long backId() { return -1L; } /** * 【中文姓名】只显示第一个汉字，其他隐藏为2个星号，比如：李** * * @param fullName 姓名 * @return 脱敏后的姓名 */ public static String chineseName(String fullName) { if (StrUtil.isBlank(fullName)) { return StrUtil.EMPTY; } return StrUtil.hide(fullName, 1, fullName.length()); } /** * 【身份证号】前1位 和后2位 * * @param idCardNum 身份证 * @param front 保留：前面的front位数；从1开始 * @param end 保留：后面的end位数；从1开始 * @return 脱敏后的身份证 */ public static String idCardNum(String idCardNum, int front, int end) { //身份证不能为空 if (StrUtil.isBlank(idCardNum)) { return StrUtil.EMPTY; } //需要截取的长度不能大于身份证号长度 if ((front + end) > idCardNum.length()) { return StrUtil.EMPTY; } //需要截取的不能小于0 if (front < 0 || end < 0) { return StrUtil.EMPTY; } return StrUtil.hide(idCardNum, front, idCardNum.length() - end); } /** * 【固定电话 前四位，后两位 * * @param num 固定电话 * @return 脱敏后的固定电话； */ public static String fixedPhone(String num) { if (StrUtil.isBlank(num)) { return StrUtil.EMPTY; } return StrUtil.hide(num, 4, num.length() - 2); } /** * 【手机号码】前三位，后4位，其他隐藏，比如135****2210 * * @param num 移动电话； * @return 脱敏后的移动电话； */ public static String mobilePhone(String num) { if (StrUtil.isBlank(num)) { return StrUtil.EMPTY; } return StrUtil.hide(num, 3, num.length() - 4); } /** * 【地址】只显示到地区，不显示详细地址，比如：北京市海淀区**** * * @param address 家庭住址 * @param sensitiveSize 敏感信息长度 * @return 脱敏后的家庭地址 */ public static String address(String address, int sensitiveSize) { if (StrUtil.isBlank(address)) { return StrUtil.EMPTY; } int length = address.length(); return StrUtil.hide(address, length - sensitiveSize, length); } /** * 【电子邮箱】邮箱前缀仅显示第一个字母，前缀其他隐藏，用星号代替，@及后面的地址显示，比如：d**@126.com * * @param email 邮箱 * @return 脱敏后的邮箱 */ public static String email(String email) { if (StrUtil.isBlank(email)) { return StrUtil.EMPTY; } int index = StrUtil.indexOf(email, '@'); if (index <= 1) { return email; } return StrUtil.hide(email, 1, index); } /** * 【密码】密码的全部字符都用*代替，比如：****** * * @param password 密码 * @return 脱敏后的密码 */ public static String password(String password) { if (StrUtil.isBlank(password)) { return StrUtil.EMPTY; } return StrUtil.repeat('*', password.length()); } /** * 【中国车牌】车牌中间用*代替 * eg1：null -》 "" * eg1："" -》 "" * eg3：苏D40000 -》 苏D4***0 * eg4：陕A12345D -》 陕A1****D * eg5：京A123 -》 京A123 如果是错误的车牌，不处理 * * @param carLicense 完整的车牌号 * @return 脱敏后的车牌 */ public static String carLicense(String carLicense) { if (StrUtil.isBlank(carLicense)) { return StrUtil.EMPTY; } // 普通车牌 if (carLicense.length() == 7) { carLicense = StrUtil.hide(carLicense, 3, 6); } else if (carLicense.length() == 8) { // 新能源车牌 carLicense = StrUtil.hide(carLicense, 3, 7); } return carLicense; } /** * 银行卡号脱敏 * eg: 1101 **** **** **** 3256 * * @param bankCardNo 银行卡号 * @return 脱敏之后的银行卡号 */ public static String bankCard(String bankCardNo) { if (StrUtil.isBlank(bankCardNo)) { return bankCardNo; } bankCardNo = StrUtil.trim(bankCardNo); if (bankCardNo.length() < 9) { return bankCardNo; } final int length = bankCardNo.length(); final int midLength = length - 8; final StringBuilder buf = new StringBuilder(); buf.append(bankCardNo, 0, 4); for (int i = 0; i < midLength; ++i) { if (i % 4 == 0) { buf.append(CharUtil.SPACE); } buf.append('*'); } buf.append(CharUtil.SPACE).append(bankCardNo, length - 4, length); return buf.toString(); } }lambda脱敏注解方式脱敏的好处是可以在变量上更方便的进行脱敏，但是这种方式对数据格式支持不高，一般只支持基本的数据类型，无法对实体内嵌套的数据进行脱敏，故此可以通过lambda方式进行脱敏。首先自定义一个函数public interface DmParam<Parent, Children> extends Serializable { /** * 执行函数 * * @param parameter 参数 * @return 函数执行结果 * @throws Exception 自定义异常 */ Children call(Parent parameter) throws Exception; /** * 执行函数，异常包装为RuntimeException * * @param parameter 参数 * @return 函数执行结果 */ default Children callWithServiceException(Parent parameter) { try { return call(parameter); } catch (Exception e) { throw new ServiceException("信息脱敏失败"); } } }既然通过lambda方式获取字段，那么反射也是必须的public class FieldUtil { /** * 通过lambda获取字段 * * @param func 方法 * @return {@link Field} */ public static <T> Field getField(DmParam<T, ?> func) { FieldMeta fieldMeta = getFieldMeta(func); return ReflectUtil.getField(fieldMeta.getGetInstantiatedClass(), fieldMeta.getPropertyName()); } /** * 通过lambda设置字段名称 * * @param func 方法 * @param val 设置的值 */ public static <T> void setField(Object object, DmParam<T, ?> func, String val) { FieldMeta fieldMeta = getFieldMeta(func); ReflectUtil.setFieldValue(object, fieldMeta.getPropertyName(), val); } /** * 通过lambda获取字段名称 * * @param func 方法 * @return {@link String} */ public static <T> FieldMeta getFieldMeta(DmParam<T, ?> func) { // 反射读取 Method writeReplaceMethod; try { writeReplaceMethod = func.getClass().getDeclaredMethod("writeReplace"); // 设置可访问 writeReplaceMethod.setAccessible(true); SerializedLambda serializedLambda; serializedLambda = (SerializedLambda) writeReplaceMethod.invoke(func); String implMethodName = serializedLambda.getImplMethodName(); String propertyName = methodToProperty(implMethodName); String clazzPath = serializedLambda.getImplClass().replace('/', '.'); Class<String> clazz = ClassUtil.getClass(clazzPath); return new FieldMeta(propertyName, implMethodName, clazz); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { log.error("[FieldUtil]反射获取字段失败", e); throw new ServiceException(e); } } /** * 通过lambda获取字段名称 * * @param func 方法 * @return {@link String} */ public static <T> String getFieldName(DmParam<T, ?> func) { // 反射读取 Method writeReplaceMethod; try { writeReplaceMethod = func.getClass().getDeclaredMethod("writeReplace"); // 设置可访问 writeReplaceMethod.setAccessible(true); SerializedLambda serializedLambda; serializedLambda = (SerializedLambda) writeReplaceMethod.invoke(func); String implMethodName = serializedLambda.getImplMethodName(); return methodToProperty(implMethodName); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { log.error("[FieldUtil]反射获取字段失败", e); throw new ServiceException(e); } } /** * 根据方法名转换为字段 * * @param implMethodName 方法名 * @return {@link String} */ public static String methodToProperty(String implMethodName) { final String defaultIs = "is"; final String defaultGet = "get"; final String defaultSet = "set"; if (implMethodName.startsWith(defaultIs)) { implMethodName = implMethodName.substring(2); } else if (implMethodName.startsWith(defaultGet) || implMethodName.startsWith(defaultSet)) { implMethodName = implMethodName.substring(3); } else { throw new ServiceException("Error parsing property name '" + implMethodName + "'. Didn't start with 'is', 'get' or 'set'."); } if (implMethodName.length() == 1 || (implMethodName.length() > 1 && !Character.isUpperCase(implMethodName.charAt(1)))) { implMethodName = implMethodName.substring(0, 1).toLowerCase(Locale.ENGLISH) + implMethodName.substring(1); } return implMethodName; } }定义脱敏方法：对于单个实体public class DmLambdaWrapper<T> { T val; @SuppressWarnings("rawtype") public DmLambdaWrapper(T val) { this.val = val; } /** * 获取脱敏实例 * * @param val 待脱敏的实体 * @return {@link DmLambdaWrapper<T>} */ public static <T> DmLambdaWrapper<T> getInstance(T val) { return new DmLambdaWrapper<>(val); } /** * 关联单个字段 * * @param field 关联字段 * @return {@link DmLambdaWrapper<R>} */ public <R> DmLambdaWrapper<R> ivkOne(DmParam<T, R> field) { R call = field.callWithServiceException(val); return new DmLambdaWrapper<>(call); } /** * 关联集合 * * @param listMapper 关联集合字段 * @param predicate 脱敏预言 * @return {@link DmListLambdaWrapper} */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper, Predicate<C> predicate) { S cs = listMapper.callWithServiceException(val); List<C> collect = cs.stream().filter(predicate).collect(Collectors.toList()); return new DmListLambdaWrapper<>(collect); } /** * 关联集合-无预言（集合内所有对应字段都将脱敏） * * @param listMapper 关联集合字段 * @return {@link DmListLambdaWrapper} */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper) { S cs = listMapper.callWithServiceException(val); return new DmListLambdaWrapper<>(cs); } /** * 脱敏字符串 * * @param dmType 脱敏类型 * @param mapper 脱敏字段 * @return {@link DmLambdaWrapper<T>} */ public <R extends String> DmLambdaWrapper<T> dmStr(DmTypeEnum dmType, DmParam<T, R> mapper) { R call = mapper.callWithServiceException(val); if (null != call) { String desensitized = DmUtils.desensitized(call, dmType, 1, 3, '*').toString(); FieldUtil.setField(val, mapper, desensitized); // 反射获取字段名称 } return new DmLambdaWrapper<>(val); } /** * 脱敏多个字段 相同脱敏类型多字段性能要优于 {@link DmLambdaWrapper#dmStr(DmTypeEnum, DmParam)} * * @param dmType 脱敏类型 * @param mappers 脱敏字段 * @return {@link DmLambdaWrapper<T>} * @author Mc * @date 2022/12/16 17:32 */ @SafeVarargs public final <R extends String> DmLambdaWrapper<T> dmStr(DmTypeEnum dmType, final DmParam<T, R>... mappers) { for (DmParam<T, R> mapper : mappers) { R call = mapper.callWithServiceException(val); if (null != call) { String desensitized = DmUtils.desensitized(call, dmType, 1, 3, '*').toString(); FieldUtil.setField(val, mapper, desensitized); // 反射获取字段名称 } } return new DmLambdaWrapper<>(val); } }对于List集合脱敏@Slf4j public class DmListLambdaWrapper<T extends Collection<F>, F> { T listVal; public DmListLambdaWrapper(T listVal) { this.listVal = listVal; } /** * 关联集合 带过滤 * * @param listMapper 集合Mapper * @param predicate 过滤预言 * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:24 */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper, Predicate<C> predicate) { S cs = listMapper.callWithServiceException(listVal); List<C> collect = cs.stream().filter(predicate).collect(Collectors.toList()); return new DmListLambdaWrapper<>(collect); } /** * 挂念集合 不带过滤 * * @param listMapper 集合Mapper * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:26 */ public <C, S extends Collection<C>> DmListLambdaWrapper<Collection<C>, C> ivkList(DmParam<T, S> listMapper) { S cs = listMapper.callWithServiceException(listVal); return new DmListLambdaWrapper<>(cs); } /** * 脱敏字符串字段 * * @param dmType 脱敏类型 * @param mapper 脱敏字段 * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:26 */ public <C extends String> DmListLambdaWrapper<Collection<F>, F> dmList2Str(DmTypeEnum dmType, DmParam<F, C> mapper) { for (F f : listVal) { C message = mapper.callWithServiceException(f); if (null != message) { String desensitized = DmUtils.desensitized(message, dmType, 1, 3, '*').toString(); FieldUtil.setField(f, mapper, desensitized); // 反射获取字段名称 } } return new DmListLambdaWrapper<>(listVal); } /** * 脱敏多个字符串字段，相同类型性能优先于多次使用 {@link DmListLambdaWrapper#dmList2Str(DmTypeEnum, DmParam)} * * @param dmType 脱敏类型 * @param mappers 脱敏字段 * @return {@link DmListLambdaWrapper} * @author Mc * @date 2022/12/16 17:27 */ @SafeVarargs public final <C extends String> DmListLambdaWrapper<Collection<F>, F> dmList2Str(DmTypeEnum dmType, final DmParam<F, C>... mappers) { for (F f : listVal) { for (DmParam<F, C> mapper : mappers) { C message = mapper.callWithServiceException(f); if (null != message) { String desensitized = DmUtils.desensitized(message, dmType, 1, 3, '*').toString(); FieldUtil.setField(f, mapper, desensitized); // 反射获取字段名称 } } } return new DmListLambdaWrapper<>(listVal); } }其他的方式可以自由拓展用法：DmLambdaWrapper.getInstance(user.getUserDetail()) .dmStr(DmTypeEnum.MOBILE_PHONE, UserDetail::getMobile) .dmStr(DmTypeEnum.EMAIL, UserDetail::getEmail);上面的例子将user里的userDetail字段中的mobile、email进行了相对应的数据脱敏。
使用mqtt发送消息Spring starter最近有个项目需要发送视频弹幕，实现的手段还是不少的，这里用Mqtt来实现。准备首先是环境准备，这里使用 EMQX 5.0 社区版作为Mqtt的消息服务器，使用Docker进行搭建：version: '3.2' services: emqx: image: emqx/emqx:v5.0.0 container_name: emqx environment: EMQX_NAME: xinwei-emqx restart: always ports: - '1883:1883' - '8081:8081' - '8083:8083' - '8883:8883' - '8084:8084' - '8099:8080' - '18083:18083' volumes: - /service/env/emqx/data:/opt/emqx/data - /service/env/emqx/etc:/opt/emqx/etc - /service/env/emqx/log:/opt/emqx/log正文Maven具体版本可以由项目的spring-boot版本决定。<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-integration</artifactId> </dependency> <dependency> <groupId>org.springframework.integration</groupId> <artifactId>spring-integration-stream</artifactId> </dependency> <dependency> <groupId>org.springframework.integration</groupId> <artifactId>spring-integration-mqtt</artifactId> </dependency>配置配置类本文不对mqtt进行鉴权，需要的可以自行拓展@Data @ConfigurationProperties(prefix = "spring.mqtt") @RefreshScope public class MqttProperties { private String url; private AuthTypeEnum authType = AuthTypeEnum.NONE; private String username; private String password; private String wsUrl; private String client; private Topic defaultSendTopic = new Topic("xinwei_mqtt_default", 1); private List<Topic> receiveTopics; private Integer keepAlive = 15; private Integer completionTimeout = 3000; /** * Mqtt 权限认证类型枚举 */ @NoArgsConstructor public enum AuthTypeEnum { /** * 无 */ NONE, /** * 客户端id */ CLIENT_ID, /** * 用户名 */ USERNAME } @Data @NoArgsConstructor @AllArgsConstructor public static class Topic { private String name; private Integer qos; } }注入配置消息发送@AutoConfiguration @EnableConfigurationProperties(MqttProperties.class) public class MqttConfiguration { @Bean public MessageChannel mqttValueInputChannel() { return new DirectChannel(); } /** * 不缓存消息 * * @return 消息通道 */ @Bean public MessageChannel mqttOutputClearSessionChannel() { return new DirectChannel(); } /** * 缓存消息 * * @return 消息通道 */ @Bean public MessageChannel mqttOutputRetainSessionChannel() { return new DirectChannel(); } @Bean public MessageProducer valueInbound(MqttProperties mqttProperties) { MqttPahoMessageDrivenChannelAdapter adapter = new MqttPahoMessageDrivenChannelAdapter( mqttProperties.getClient() + StrPool.DASHED + "receiver", mqttClearSessionClientFactory(mqttProperties), mqttProperties.getReceiveTopics().stream() .map(MqttProperties.Topic::getName) .toArray(String[]::new)); adapter.setCompletionTimeout(mqttProperties.getCompletionTimeout()); adapter.setConverter(new DefaultPahoMessageConverter()); adapter.setQos(mqttProperties.getReceiveTopics().stream() .mapToInt(MqttProperties.Topic::getQos) .toArray()); adapter.setOutputChannel(mqttValueInputChannel()); return adapter; } @Bean @ServiceActivator(inputChannel = "mqttOutputClearSessionChannel") public MessageHandler clearSessionOutbound(MqttProperties mqttProperties) { MqttPahoMessageHandler messageHandler = new MqttPahoMessageHandler(mqttProperties.getClient() + StrPool.DASHED + "clear-session-sender", mqttClearSessionClientFactory(mqttProperties)); messageHandler.setAsync(true); messageHandler.setDefaultQos(mqttProperties.getDefaultSendTopic().getQos()); messageHandler.setDefaultTopic(mqttProperties.getDefaultSendTopic().getName()); return messageHandler; } @Bean public MqttPahoClientFactory mqttClearSessionClientFactory(MqttProperties mqttProperties) { DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory(); factory.setConnectionOptions(getMqttClearSessionConnectOptions(mqttProperties)); return factory; } @Bean public MqttConnectOptions getMqttClearSessionConnectOptions(MqttProperties mqttProperties) { MqttConnectOptions mqttConnectOptions = new MqttConnectOptions(); if (mqttProperties.getAuthType().equals(MqttProperties.AuthTypeEnum.USERNAME)) { mqttConnectOptions.setUserName(mqttProperties.getUsername()); mqttConnectOptions.setPassword(mqttProperties.getPassword().toCharArray()); } mqttConnectOptions.setServerURIs(new String[]{mqttProperties.getUrl()}); mqttConnectOptions.setKeepAliveInterval(mqttProperties.getKeepAlive()); return mqttConnectOptions; } @Bean @ServiceActivator(inputChannel = "mqttOutputRetainSessionChannel") public MessageHandler retainSessionOutbound(MqttProperties mqttProperties) { MqttPahoMessageHandler messageHandler = new MqttPahoMessageHandler(mqttProperties.getClient() + StrPool.DASHED + "retain-session-sender", mqttRetainSessionClientFactory(mqttProperties)); messageHandler.setAsync(true); messageHandler.setDefaultQos(mqttProperties.getDefaultSendTopic().getQos()); messageHandler.setDefaultTopic(mqttProperties.getDefaultSendTopic().getName()); return messageHandler; } @Bean public MqttPahoClientFactory mqttRetainSessionClientFactory(MqttProperties mqttProperties) { DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory(); factory.setConnectionOptions(getMqttRetainSessionConnectOptions(mqttProperties)); return factory; } @Bean public MqttConnectOptions getMqttRetainSessionConnectOptions(MqttProperties mqttProperties) { MqttConnectOptions mqttConnectOptions = new MqttConnectOptions(); if (mqttProperties.getAuthType().equals(MqttProperties.AuthTypeEnum.USERNAME)) { mqttConnectOptions.setUserName(mqttProperties.getUsername()); mqttConnectOptions.setPassword(mqttProperties.getPassword().toCharArray()); } mqttConnectOptions.setCleanSession(false); mqttConnectOptions.setServerURIs(new String[]{mqttProperties.getUrl()}); mqttConnectOptions.setKeepAliveInterval(mqttProperties.getKeepAlive()); return mqttConnectOptions; } }消息接收/** * 消息接收 * * @author Mc */ @Slf4j @AutoConfiguration(after = MqttConfiguration.class) public class MqttReceiveConfiguration { @Bean @ServiceActivator(inputChannel = "mqttValueInputChannel") public MessageHandler handlerValue() { return message -> { MessageHeaders headers = message.getHeaders(); //获取消息Topic String receivedTopic = (String) headers.get(MqttHeaders.RECEIVED_TOPIC); log.info("获取到的消息的topic :{} ", receivedTopic); //获取消息体 String payload = (String) message.getPayload(); log.info("获取到的消息的payload :{} ", payload); }; } }imports在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中放入配置文件路径com.xxx.cloud.mqtt.config.MqttConfiguration com.xxx.cloud.mqtt.config.MqttReceiveConfiguration消息发送清除session@MessagingGateway(defaultRequestChannel = "mqttOutputClearSessionChannel") public interface MqttClearSessionSendHandler { /** * 使用 Default Topic & Default Qos 发送数据 * * @param data string */ void sendToMqtt(String data); /** * 使用 Default Topic & 自定义 Qos 发送数据 * * @param qos 自定义 Qos * @param data string */ void sendToMqtt(@Header(MqttHeaders.QOS) Integer qos, String data); /** * 使用 自定义 Topic & Default Qos 发送数据 * * @param topic 自定义 Topic * @param data string */ void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, String data); /** * 使用 自定义 Topic & 自定义 Qos 发送数据 * * @param topic 自定义 Topic * @param qos 自定义 Qos * @param data string */ void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) Integer qos, String data); /** * 使用 自定义 Topic & 自定义 Qos 发送数据 * * @param topic 自定义 Topic * @param qos 自定义 Qos * @param payload 自定义负载 */ void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) int qos, byte[] payload);不清除session@MessagingGateway(defaultRequestChannel = "mqttOutputRetainSessionChannel") public interface MqttRetainSessionSendHandler { /** * 使用 Default Topic & Default Qos 发送数据 * * @param data string */ void sendToMqtt(String data); /** * 使用 Default Topic & 自定义 Qos 发送数据 * * @param qos 自定义 Qos * @param data string */ void sendToMqtt(@Header(MqttHeaders.QOS) Integer qos, String data); /** * 使用 自定义 Topic & Default Qos 发送数据 * * @param topic 自定义 Topic * @param data string */ void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, String data); /** * 使用 自定义 Topic & 自定义 Qos 发送数据 * * @param topic 自定义 Topic * @param qos 自定义 Qos * @param data string */ void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) Integer qos, String data); /** * 使用 自定义 Topic & 自定义 Qos 发送数据 * * @param topic 自定义 Topic * @param qos 自定义 Qos * @param payload 自定义负载 */ void sendToMqtt(@Header(MqttHeaders.TOPIC) String topic, @Header(MqttHeaders.QOS) int qos, byte[] payload); }使用引入依赖配置文件spring: mqtt: url: tcp://xxx.xxx.com:1883 ws-url: ws://xxx.xxx.com:8083 auth-type: NONE username: admin password: public # ca-crt: classpath:/certs/broker.emqx.io-ca.crt client: ${spring.application.name} receive-topics: - qos: 1 name: driver/${spring.application.name}/device/+ - qos: 1 name: driver/${spring.application.name}/gateway/+ default-send-topic: qos: 1 name: default/${spring.application.name} keep-alive: 15 completion-timeout: 3000注入后调用方法即可发送@Resource MqttClearSessionSendHandler sendHandler;
前言项目用到了敏感词过滤（数据清洗），之前是通过查询敏感词库进行模糊匹配然后过滤的，但这样的查表的效率很低，且跨微服务调用时时间损耗会更高，故此记录改造正文首先想到的是将敏感词从库表里放入redis缓存，这样可以有效避免重复查库，其次通过redis可以较少微服务调用，直接从redis缓存使用。但是一段语句中，是否存在敏感词，直接匹配redis缓存中的敏感词库的话，效率依旧不高，故此我们可以引入知名的DFA算法。DFA算法介绍DFA(确定有限状态自动机) 算法是一种用于解决文本匹配问题的算法。它通过比较输入文本和预定义的状态机的状态转换路径，来判断是否存在匹配。DFA 通常用于字符串匹配。它利用预先创建好的有限状态自动机，自动机包含一个有限的状态集合和一个输入符号集合，自动机从一个状态转移到另一个状态，根据输入符号，自动机判断出输入符号是否与预定义的字符串匹配。 其主要思想是将匹配过程建模为状态机，并使用状态机的转移过程进行匹配。下面是一些使用 DFA 的例子：字符串匹配：在一个输入文本中查找一个特定的字符串是否存在。如果它存在，就返回它在文本中的位置。如果不存在，则返回-1。词法分析：将输入的字符流识别成包含令牌、标记或关键字的单词流。例如，在编译器中，它用于将代码解析成令牌流。自然语言处理：用于命名实体识别等自然语言处理任务。在这些任务中，它可以使用 DFA 对文本进行分词，并构建一个有限状态自动机，以识别关键字或语法结构。过滤器：用于过滤输入文本中的某些内容。在这些任务中，它可以使用 DFA 对文本进行匹配和替换，以过滤或修改输入文本中的内容。更多的介绍可以看这篇文章：基于DFA敏感词查询的算法简析 - 李晓晖 - 博客园 (cnblogs.com)DFA实现站在巨人的肩膀上才能看的更高，故此直接使用hutools工具的DFA算法！DFA查找 (hutool.cn)敏感词过滤实现本人根据自己日常编码的使用需求，大致分为2种使用场景：方法内过滤这个很好理解，就是方法体内进行过滤，这个直接使用hutool提供的方法结合redis使用即可，不再赘述。方法调用时过滤作为入参参数过滤，即方法调用时过滤根据2个场景又可以分成2种风格：遇到敏感词即禁止（截止模式）替换敏感词为其他符号（置换模式）敏感词首先定义一个敏感词库，使用hash的方式将其存入redis，这部分实现很简单，不再赘述。截止模式此模式为遇到敏感词即报错提示，通过统一异常管理可以快捷通知用户内容包含敏感词首先定义一个注解：/** * 自定义敏感词过滤注解 * 若包含敏感词，则返回false 不替换敏感词 效率更高 * @author mc */ @Target(value = { ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy = SwfValidator.class) public @interface SwfParam { String message() default "内容含有敏感词汇，请检查！"; }这个注解可以在方法、字段、构造器和入参字段字段使用，message为报错的信息，@Constraint(validatedBy = SwfValidator.class)为检验方法，是具体的过滤方法。/** * 自定义敏感词校验注解实现 * * @author mc */ public class SwfValidator implements ConstraintValidator<SwfParam, String> { @Resource ISwfService swfService; @Override public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { return containsKeyWord(value); } public boolean containsKeyWord(String value) { WordTree sensitiveWordTree = new WordTree(); Set<String> sensitiveWordSet = swfService.getRemoteSensitiveWordMap(true); sensitiveWordTree.addWords(sensitiveWordSet); String match = sensitiveWordTree.match(value); if (StrUtil.isNotBlank(match)) { Console.log("敏感词为：{}",match); } return null == match || match.isEmpty(); } }ISwfService为通过redis获取敏感词的接口，具体实现省略。置换模式此模式为遇到敏感词不报错，通过将敏感词置换为固定的字符来实现敏感词过滤还是使用注解package com.xinwei.swf.annotation; import com.xinwei.swf.enums.SwfModeEnum; import com.xinwei.swf.enums.SwfRuleEnum; import java.lang.annotation.*; /** * 自定义敏感词过滤注解 * 适用于方法参数过滤, 用于“替换”敏感词 * @author mc */ @Target(value = { ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Swf { /** * 待过滤敏感词 */ String text() default ""; /** * 默认以该文本替换敏感词 */ String replace() default "*"; /** * 过滤参数位置 从0开始 */ int[] paramPosition(); /** * 过滤模式 */ SwfModeEnum mode() default SwfModeEnum.THROW_EXCEPTION; /** * 自定义异常消息,仅当过滤模式为SwfModeEnum.THROW_EXCEPTION 启用 */ String exceptionMsg() default ""; /** * 自定义过滤词汇 */ String[] customWords() default {}; /** * 自定义过滤规则 */ SwfRuleEnum rule() default SwfRuleEnum.BOTH; /** * 是否强制过滤 * 默认强制过滤，若不存在过滤词，则抛出错误 * 非强制过滤若无过滤词，则不过滤 */ boolean isForce() default true; }通过注解方法可以提供很多功能，如replace()可以将敏感词替换为制定的字符。mode()方法可以选择截止模式或置换模式，customWords()可以提供手动的额外敏感词库，rule()是选择通过数据库或redis换取敏感词的方式。/** * 过滤模式 * * @author mc * @date 2022年05月11日 11:19 */ @Getter public enum SwfModeEnum { /** * 抛出异常 */ THROW_EXCEPTION(0), /** * 替换敏感词 */ REPLACE(1); private final int value; SwfModeEnum(int value) { this.value = value; } } /** * 自定义过滤规则 * * @author mc */ @Getter public enum SwfRuleEnum { /** * 过滤全部数据库+自定义过滤词汇 */ BOTH(0), /** * 只过滤数据库的敏感词汇 */ REMOTE(1), /** * 只过滤自定义的敏感词汇 */ CUSTOM(2); private final int value; SwfRuleEnum(int value) { this.value = value; } }通过切面配合注解使用@Aspect @Component @Slf4j public class SwfAspect { @Resource ISwfService swfService; @Value("${spring.messages.enabled:false}") private String messagesEnabled; @Value("${spring.application.name}") private String serviceName; @Around("@annotation(swf)") public Object doBefore(ProceedingJoinPoint point, Swf swf) { return handleSwf(point, swf); } @SneakyThrows private Object handleSwf(ProceedingJoinPoint point, Swf swf) { final String trueStr = "true"; SwfRuleEnum rule = swf.rule(); boolean forceFiltering = swf.isForce(); String[] customWords = swf.customWords(); Set<String> sensitiveWordSet; WordTree sensitiveWordTree = new WordTree(); //获取数据库或redis 敏感词 sensitiveWordSet = swfService.getRemoteSensitiveWordMap(forceFiltering); //规则判断 if (rule == SwfRuleEnum.BOTH) { sensitiveWordTree.addWords(sensitiveWordSet); //若有自定义敏感词 if (customWords.length > 0) { sensitiveWordTree.addWords(customWords); } } else if (rule == SwfRuleEnum.REMOTE) { sensitiveWordTree.addWords(sensitiveWordSet); } else { sensitiveWordTree.addWords(customWords); } Object[] args = point.getArgs(); int[] paramPositionArray = swf.paramPosition(); for (int position : paramPositionArray) { Object arg = args[position]; String text = String.valueOf(arg); List<String> strings = sensitiveWordTree.matchAll(text); if (SwfModeEnum.THROW_EXCEPTION.equals(swf.mode())) { if (CollectionUtil.isNotEmpty(strings)) { log.error("["+serviceName+"]敏感词为：{}", strings.toString()); if (trueStr.equals(messagesEnabled)) { throw new GlobalException(swf.exceptionMsg()); } else { throw new GlobalException("内容包含敏感词"); } } } else { for (String string : strings) { text = text.replace(string, swf.replace()); } arg = text; args[position] = arg; if (CollectionUtil.isNotEmpty(strings)) { log.error("["+serviceName+"]敏感词为：{}", strings); } } } return point.proceed(args); } }@Value("${spring.messages.enabled:false}")是i18n的配置，可以忽略。
支持IDEA的Java规范代码格式化配置文件详情参阅：编辑器配置 (editorconfig.org)更多配置详情参阅：EditorConfig Properties · editorconfig/editorconfig Wiki (github.com)配置详解：在项目根目录下创建 .editorconfig文件，加入如下内容：# http://editorconfig.org root = true # 空格替代Tab缩进在各种编辑工具下效果一致 [*] indent_style = space indent_size = 4 charset = utf-8 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true [*.java] ########################## idea java doc 格式化设置 start ########################## # 是否开启doc注释格式化 ij_java_doc_enable_formatting = true # 是否对齐doc参数注释 ij_java_doc_align_param_comments = true # 是否对齐异常注释 ij_java_doc_align_exception_comments = true # 是否保留空参注释 ij_java_doc_keep_empty_parameter_tag = false # 是否保留空返回注释 ij_java_doc_keep_empty_return_tag = false # 是否保留空抛错注释 ij_java_doc_keep_empty_throws_tag = false # 是否将空行加入 标签 ij_java_doc_add_p_tag_on_empty_lines = true # 是否在参数说明后面加入空行 ij_java_doc_add_blank_line_after_param_comments = true # 是否在方法说明后加入空行 ij_java_doc_add_blank_line_after_description = true # 是否使用@throw而不是@exception ij_java_doc_use_throws_not_exception_tag = true ########################## idea java doc 格式化设置 end ########################## ########################## idea java 空格 格式化设置 start ########################## # 括号之前 ij_java_space_before_for_parentheses = true ij_java_space_before_if_parentheses = true ij_java_space_before_while_parentheses = true ij_java_space_before_switch_parentheses = true ij_java_space_before_try_parentheses = true ij_java_space_before_catch_parentheses = true ij_java_space_before_synchronized_parentheses = true # 左大括号之前 ij_java_space_before_class_left_brace = true ij_java_space_before_method_left_brace = true ij_java_space_before_for_left_brace = true ij_java_space_before_if_left_brace = true ij_java_space_before_else_left_brace = true ij_java_space_before_do_left_brace = true ij_java_space_before_while_left_brace = true ij_java_space_before_switch_left_brace = true ij_java_space_before_try_left_brace = true ij_java_space_before_catch_left_brace = true ij_java_space_before_finally_left_brace = true ij_java_space_before_synchronized_left_brace = true # 运算符周围 ij_java_spaces_around_additive_operators = true ij_java_spaces_around_assignment_operators = true ij_java_spaces_around_bitwise_operators = true ij_java_spaces_around_equality_operators = true ij_java_spaces_around_lambda_arrow = true ij_java_spaces_around_logical_operators = true ij_java_spaces_around_multiplicative_operators = true ij_java_spaces_around_relational_operators = true ij_java_spaces_around_shift_operators = true # 关键字之前 ij_java_space_before_else_keyword = true ij_java_space_before_while_keyword = true ij_java_space_before_catch_keyword = true ij_java_space_before_finally_keyword = true # 类型实参 ij_java_space_after_comma_in_type_arguments = true # 三元运算中 ij_java_space_before_quest = true ij_java_space_after_quest = true ij_java_space_before_colon = true ij_java_space_after_colon = true # 其他 ij_java_space_after_comma = true ij_java_space_after_for_semicolon = true ij_java_space_after_type_cast = true ij_java_spaces_around_annotation_eq = true ij_java_space_before_colon_in_foreach = true ij_java_spaces_around_type_bounds_in_type_parameters = true ########################## idea java 空格 格式化设置 end ########################## ########################## idea java 空白行 格式化设置 started ########################## ij_java_keep_blank_lines_in_declarations = 2 ij_java_keep_blank_lines_before_right_brace = 2 ij_java_keep_blank_lines_between_package_declaration_and_header = 2 ij_java_keep_blank_lines_in_code = 2 ########################## idea java 空白行 格式化设置 end ########################## ij_java_continuation_indent_size = 8 [*.{json,yml,yaml}] indent_size = 2 [*.md] insert_final_newline = false trim_trailing_whitespace = false
使用Lock4j完成一个分布式锁和幂等的Spring Boot Starter组件
前言分布式锁​ 分布式锁是在分布式系统中保证同步和协调的一种机制。它通常用于协调多个进程或节点之间的访问和资源使用，以防止并发冲突和数据损坏。​ 分布式锁的实现通常采用一种称为互斥体的同步原语。互斥体是一种锁定机制，可以用来确保一个进程或节点在任何时候都具有对某个资源的独占访问权限。在分布式环境中，互斥体必须经过适当的设计和实现，以确保它能够正确地工作和协调不同的节点和进程。​ 常见的分布式锁实现方式包括：基于Redis的分布式锁、ZooKeeper的分布式锁、数据库乐观锁等。这些实现方式中，Redis和ZooKeeper常常被使用在大规模的分布式系统中，因为它们提供了可靠的数据存储和高效的访问控制机制。而数据库乐观锁则主要用于小规模分布式系统中，因为它比较简单直接，但是更加依赖于网络的性能。幂等​ 幂等是指对同一操作，多次执行所产生的影响与执行一次的影响相同。也就是说，即使同一操作被执行多次，也不会对系统产生额外的影响。在计算机科学中，幂等性是一种非常重要的概念，常常用于设计和实现高可靠性、可复用性、可扩展性的分布式系统和网络服务。常见的幂等操作包括查询数据库、修改状态、发送邮件等。选型[scode type='info']本次使用lock4j+redisson实现分布式锁和幂等组件，其中，redisson的版本不应该过高，要和Spring Boot版本对应，否则无法正常启动。[/scode]Spring Boot：2.6lock4j: 2.2.3官方仓库：lock4j: 基于Spring AOP 的声明式和编程式分布式锁，支持RedisTemplate、Redisson、Zookeeper (gitee.com)正文首先搭建一个Spring Boot 2.6.3 的项目，搭建过程省略。引入lock4j+redisson依赖<dependency> <groupId>com.baomidou</groupId> <artifactId>lock4j-redisson-spring-boot-starter</artifactId> <version>2.2.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </dependency>分布式锁首先对lock4j进行一些配置，默认的lock4j在缓存中是以lock4j为前缀的key，但是为了保持项目的一致（比如某个微服务就以它的项目名称为开头）就需要修改其默认的key前缀。@AutoConfigureBefore(LockAutoConfiguration.class) public class MyLock4jConfig { // 引入默认的配置 private final Lock4jProperties properties; // 修改lock4j前缀 自定义默认使用key 项目名称:lock4j:*** public MyLock4jConfig(Lock4jProperties properties, ApplicationName applicationName) { properties.setLockKeyPrefix(applicationName.getServiceName() + ":lock4j"); this.properties = properties; } @Bean @ConditionalOnMissingBean @SuppressWarnings("all") // 这里加入 @ConditionalOnMissingBean注解，目的是为了项目代码里可以灵活重写 // @SuppressWarnings("all")可以抑制idea的警告 public LockTemplate lockTemplate(List<LockExecutor> executors) { LockTemplate lockTemplate = new LockTemplate(); lockTemplate.setProperties(properties); lockTemplate.setExecutors(executors); return lockTemplate; } // 默认的锁失败策略 这里进行自定义 @Bean public DefaultLockFailureStrategy lockFailureStrategy() { return new DefaultLockFailureStrategy(); } }ApplicationName是自定义的获取项目spring.application.name的配置，具体如下：@Configuration @Data public class ApplicationName { @Value("${spring.application.name}") private String serviceName; }实现自定义锁失败策略，实则就是打印语句并抛出一个错误，要不要捕获并打印错误取决于你的项目。GlobalException是我自定义的错误。@Slf4j public class DefaultLockFailureStrategy implements LockFailureStrategy { @Override public void onLockFailure(String key, Method method, Object[] arguments) { log.debug("[onLockFailure][线程：{} 获取锁失败 key：{} 获取失败：{}]", Thread.currentThread().getName(), key, arguments); throw new GlobalException(REQUEST_LOCKED); } }resource/META-INF/spring.factories中加入配置，实际路径按照你的MyLock4jConfig的路径命名。org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.xxx.common.protection.lock4j.config.MyLock4jConfig,\ com.xxx.web.name.ApplicationName幂等组件幂等我们使用注解来实现，新建一个名为Idempotent的注解@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { /** * 幂等的超时时间，默认为 1 秒 */ int timeout() default 1; /** * 时间单位，默认为 SECONDS 秒 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * 提示信息，正在执行中的提示 */ String message() default "请求过于频繁，请稍后再试"; /** * 使用的 Key 解析器 */ Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class; /** * 使用的 Key 参数 */ String keyArg() default ""; }接下来新建配合注解的切面逻辑@Aspect @Slf4j public class IdempotentAspect { private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers; private final IdempotentRedisDAO idempotentRedisDAO; public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) { this.keyResolvers = CollUtil.toMap(keyResolvers, new HashMap<>(), IdempotentKeyResolver::getClass); this.idempotentRedisDAO = idempotentRedisDAO; } @Before(value = "@annotation(idempotent)") public void before(JoinPoint joinPoint, Idempotent idempotent) { // 获取 key 解析器 IdempotentKeyResolver idempotentKeyResolver = keyResolvers.get(idempotent.keyResolver()); Assert.notNull(idempotentKeyResolver, "对应解析器不存在"); // 解析key String key = idempotentKeyResolver.resolver(joinPoint, idempotent); // 锁定key boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit()); // 锁定失败 if (!success) { log.info("[before][方法({}) 参数({}) 重复请求，幂等组件已过滤]", joinPoint.getSignature().getName(), JSONUtil.toJsonStr(joinPoint.getArgs())); throw new GlobalException(REQUEST_LOCKED); } } }IdempotentRedisDAO提供一个redis键的获取方法，规则是当键已存在时，就不能获取，不存在时新建键，这样当再来相同请求时，会因为已存在键而直接返回错误信息。定义这个键，第一个补位时项目名称，第二个补位是对应幂等的接口public interface Lock4jRedisKeyConstants { String IDEMPOTENT_KEY = "%s:idempotent:%s"; }IdempotentRedisDAO的实现方式有很多，这里使用setIfAbsent()方法@AllArgsConstructor public class IdempotentRedisDAO { public static final String IDEMPOTENT_KEY = Lock4jRedisKeyConstants.IDEMPOTENT_KEY; private final ApplicationName applicationName; private final StringRedisTemplate redisTemplate; public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) { String redisKey = formatKey(key); return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit); } private String formatKey(String key) { return String.format(IDEMPOTENT_KEY, applicationName.getServiceName(), key); } }实现接口信息幂等，实际使用接口方法名称+入参共同作为幂等校验信息，这样短时间内相同方法+相同参数就会被拦截。此外，由于方法名称+入参的字符串可能会太长，会影响redis的效率，故此将其进行MD5加密。之前自定义的Idempotent注解使用的 Key 解析器可以扩展，这里实现了2种，一种是利用SPEL进行校验，匹配入参中的某些字段，另一种是方法名称+入参实现，匹配所有入参。SPEL方式public class SPelIdempotentKeyResolver implements IdempotentKeyResolver { private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); private final ExpressionParser expressionParser = new SpelExpressionParser(); @Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { // 获得被拦截方法参数名列表 Method method = getMethod(joinPoint); Object[] args = joinPoint.getArgs(); String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); // 准备 Spring EL 表达式解析的上下文 StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); if (ArrayUtil.isNotEmpty(parameterNames)) { for (int i = 0; i < parameterNames.length; i++) { evaluationContext.setVariable(parameterNames[i], args[i]); } } // 解析参数 Expression expression = expressionParser.parseExpression(idempotent.keyArg()); return expression.getValue(evaluationContext, String.class); } private static Method getMethod(JoinPoint point) { // 处理，声明在类上的情况 MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); if (!method.getDeclaringClass().isInterface()) { return method; } // 处理，声明在接口上的情况 try { return point.getTarget().getClass().getDeclaredMethod( point.getSignature().getName(), method.getParameterTypes()); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } }全部匹配方式public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver { @Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { String methodName = joinPoint.getSignature().toString(); // 参数 Object[] args = joinPoint.getArgs(); // 参数窜 String argsStr = StrUtil.join(",", args); // 使用md5 加密 保证key不会过长 return SecureUtil.md5(methodName + argsStr); } }