前言项目用到了敏感词过滤（数据清洗），之前是通过查询敏感词库进行模糊匹配然后过滤的，但这样的查表的效率很低，且跨微服务调用时时间损耗会更高，故此记录改造正文首先想到的是将敏感词从库表里放入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的配置，可以忽略。
Bean Validation 分组校验分组校验是指同一个Bean中，通过分组来实现组隔离校验，而不用重复建立Bean，可以有效减少代码的编写量，也减少了阅读的障碍。首先举个例子@Data @AllArgsConstructor @NoArgsConstructor @Schema(name = "统一下单 request dto") @SuppressWarnings("all") public class PayOrderUnifiedReqDTO { /** * 用户 IP */ @NotEmpty(message = "用户 IP 不能为空") private String userIp; // ========== 业务相关字段 ========== /** * 业务订单编号 */ @NotEmpty(message = "业务订单编号不能为空") private String merchantOrderId; /** * 商品标题 */ @NotEmpty(message = "业务商品标题不能为空") @Length(max = 32, message = "商品标题不能超过 32") private String subject; /** * 商品描述信息 */ @NotEmpty(message = "业务商品描述信息不能为空") @Length(max = 128, message = "商品描述信息长度不能超过128") private String body; /** * 业务商品类型 */ @NotNull(message = "业务商品类型") private Integer bizType; /** * 支付结果的 notify 回调地址 */ @NotEmpty(message = "支付结果的回调地址不能为空") @URL(message = "支付结果的 notify 回调地址必须是 URL 格式") private String notifyUrl; // /** // * 支付结果的 return 回调地址 // */ // @URL(message = "支付结果的 return 回调地址必须是 URL 格式") // private String returnUrl; // ========== 订单相关字段 ========== /** * 支付金额，单位：分 */ @NotNull(message = "支付金额不能为空") @DecimalMin(value = "0", inclusive = false, message = "支付金额必须大于零") private Integer amount; /** * 支付过期时间 */ @NotNull(message = "支付过期时间不能为空") private LocalDateTime expireTime; // ========== 拓展参数 ========== /** * 支付渠道的额外参数 * 例如说，微信公众号需要传递 openid 参数 */ private Map<String, String> channelExtras; @NotNull(message = "设备类型不能为空", groups = {H5_WX.class, ANDROID_H5_WX.class, IOS_H5_WX.class}) private PayDeviceTypeEnum deviceType; @NotNull(message = "iOS平台BundleID", groups = {H5_WX.class, IOS_H5_WX.class}) private String bundleId; @NotNull(message = "Android平台PackageName", groups = {H5_WX.class, ANDROID_H5_WX.class}) private String packageName; public interface WX_APP { } public interface H5_WX { } public interface IOS_H5_WX extends H5_WX { } public interface ANDROID_H5_WX extends H5_WX { } public interface PC_ALIPAY { }其中中的groups = {H5_WX.class, IOS_H5_WX.class} 代表bundleId这个参数只有在属于H5_WX或者IOS_H5_WX分组时，才会校验参数，对于没有添加groups的属性参数，无论分组如何都全局校验。@NotNull(message = "iOS平台BundleID", groups = {H5_WX.class, IOS_H5_WX.class}) private String bundleId;用法也很简单，在controller中校验时，加上对应的校验组 @Validated({PayOrderUnifiedReqDTO.H5_WX.class}) 即可。@PostMapping("/valid") public CommonResult<?> valid(@RequestBody @Validated({PayOrderUnifiedReqDTO.H5_WX.class}) PayOrderUnifiedReqDTO req) { // 业务逻辑 return CommonResult.success(); }
前言springcloud项目通常会遇到以下2个问题：配置项目redis组件时，有时会遇到一些序列化的问题，如当使用jackson的时，使用@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)注解，会报这个实体不是一个数组的问题集成通用redis依赖组件，可以让所有的项目共同依赖默认一个redis过期时间，更好的利用redis资源正文项目依赖Springboot : 2.6.3Springcloud: 2021.0.1Springcoud Alibaba: 2021.0.1.0redis依赖<?xml version="1.0" encoding="UTF-8"?> framework-common com.xxx 2.0-SNAPSHOT 4.0.0 ​ framework-common-redis org.springframework.boot spring-boot-starter-data-redis 配置redis序列@Configuration @AutoConfigureBefore(RedisAutoConfiguration.class) public class BaseRedisConfig { ​ @Bean @ConditionalOnMissingBean(name = "redisTemplate") public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(factory); redisTemplate.setKeySerializer(RedisSerializer.string()); redisTemplate.setHashKeySerializer(RedisSerializer.string()); redisTemplate.setValueSerializer(RedisSerializer.json()); redisTemplate.setHashValueSerializer(RedisSerializer.json()); return redisTemplate; } ​ } ​ /** * redis 默认缓存时间 */ @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory); //设置Redis缓存有效期为7天 RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() // 使的reids注解缓存的key中 "::"2个冒号转换成":"一个冒号，统一redis key的风格 .computePrefixWith(name -> name + ":") .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer())).entryTtl(Duration.ofDays(7)); return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration); } }代码解释@AutoConfigureBefore注解是Spring Boot的一个注解，用于指定一个配置类在另外一个配置类之前自动配置，以确保它们的顺序正确。它的作用是控制自动配置的顺序，以便在自动配置时确定配置类的优先级。使用@AutoConfigureBefore注解需要在配置类上加上该注解，并指定目标配置类的全限定名或Class对象。例如，我们可以在自定义的Redis配置类上使用@AutoConfigureBefore注解，指定目标为框架提供的默认Redis配置类，使自定义Redis配置类在框架默认Redis配置类之前进行自动配置。示例代码如下所示：@Configuration @AutoConfigureBefore(RedisAutoConfiguration.class) public class CustomRedisConfiguration { // 自定义 Redis 配置 }在上述代码中，我们在配置类CustomRedisConfiguration上使用@AutoConfigureBefore注解，指定目标为Spring Boot框架提供的默认Redis配置类RedisAutoConfiguration，以确保自定义的Redis配置类在框架默认Redis配置类之前自动配置。总而言之，@AutoConfigureBefore注解可以帮助我们控制自动配置的顺序，确保在自动配置时配置类的优先级正确。RedisSerializer.string()实际为StringRedisSerializer.UTF_8，获取一个简单的 String byte[]（和返回）序列化程序，使用 UTF-8 作为默认值，简单来讲，就是key必须是String类型，这个在使用BoundXXXOperations方法时，若key不为String类型，则会抛出错误。redisTemplate.setValueSerializer(RedisSerializer.json()); redisTemplate.setHashValueSerializer(RedisSerializer.json());以上两句代码指将普通数据类型的value和哈希结构的value值使用json序列化，看下具体内容：static RedisSerializer<Object> json() { return new GenericJackson2JsonRedisSerializer(); }实际上是通过了jackson来进行序列化和逆序列化。使用json序列化的好处是可以使用@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)来序列化bean，这个注解在入库或者缓存后，会加上@class，使得逆序列化更为准确，且可实现多态逆序列化。computePrefixWith(name -> name + ":")可以让使得springboot-data-reids注解缓存的key中的::两个冒号转换成:一个冒号，统一redis key的风格，更好的管理redis键值。xxx::date::1234 转变成了 xxx:data:1234添加spring.factoriesspring.factories 是 Spring 框架中的一个特殊文件，在 Spring Boot 中被广泛使用。它的作用是使开发者可以为自己的应用程序添加自定义配置，例如注册自定义的 Bean 或者自定义的 Starter。具体地说，spring.factories 文件中包含了多个键值对，其中键是某个接口或者类的全限定名，而值是对应该接口或类实现的工厂类的全限定名。当 Spring Boot 启动时，会扫描项目中的所有 spring.factories 文件，并读取其中的配置信息，最终根据这些配置信息初始化并启动Spring 上下文。Spring Boot 中的 Starter 就是通过使用 spring.factories 实现的。我们可以构建一个 Starter（一个所需依赖的组合）并定义其spring.factories 文件来注册自定义的自动配置。这样，当使用该 Starter 的应用程序启动时，Spring Boot就会根据 spring.factories 中的配置信息自动装配所需的 Bean，为提供了开发者非常方便的方式来为自己的应用程序添加自定义配置。在resource下新建META-INF目录，在META-INF目录下新建spring.factories文件，在文件里加入之前自定义的BaseRedisConfig.org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.xxx.common.redis.config.BaseRedisConfig打包maven clean install使用其他项目引入依赖framework-common-redisyml文件配置redis：spring: redis: database: 0 port: 6379 host: localhost password: L1lvlrGZV6Kl0bhS lettuce: pool: # 连接池最大连接数 max-active: 200 # 连接池最大阻塞等待时间（使用负值表示没有限制） max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0ps：若使用 Spring boot3则pom文件为spring: data: redis: database: 0 port: 6379 host: localhost password: L1lvlrGZV6Kl0bhS lettuce: pool: # 连接池最大连接数 max-active: 200 # 连接池最大阻塞等待时间（使用负值表示没有限制） max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0