前言
项目用到了敏感词过滤(数据清洗),之前是通过查询敏感词库进行模糊匹配然后过滤的,但这样的查表的效率很低,且跨微服务调用时时间损耗会更高,故此记录改造
正文
首先想到的是将敏感词从库表里放入redis缓存,这样可以有效避免重复查库,其次通过redis可以较少微服务调用,直接从redis缓存使用。但是一段语句中,是否存在敏感词,直接匹配redis缓存中的敏感词库的话,效率依旧不高,故此我们可以引入知名的DFA算法。
DFA算法介绍
DFA(确定有限状态自动机) 算法是一种用于解决文本匹配问题的算法。它通过比较输入文本和预定义的状态机的状态转换路径,来判断是否存在匹配。
DFA 通常用于字符串匹配。它利用预先创建好的有限状态自动机,自动机包含一个有限的状态集合和一个输入符号集合,自动机从一个状态转移到另一个状态,根据输入符号,自动机判断出输入符号是否与预定义的字符串匹配。 其主要思想是将匹配过程建模为状态机,并使用状态机的转移过程进行匹配。
下面是一些使用 DFA 的例子:
- 字符串匹配:在一个输入文本中查找一个特定的字符串是否存在。如果它存在,就返回它在文本中的位置。如果不存在,则返回-1。
- 词法分析:将输入的字符流识别成包含令牌、标记或关键字的单词流。例如,在编译器中,它用于将代码解析成令牌流。
- 自然语言处理:用于命名实体识别等自然语言处理任务。在这些任务中,它可以使用 DFA 对文本进行分词,并构建一个有限状态自动机,以识别关键字或语法结构。
- 过滤器:用于过滤输入文本中的某些内容。在这些任务中,它可以使用 DFA 对文本进行匹配和替换,以过滤或修改输入文本中的内容。
更多的介绍可以看这篇文章:
基于DFA敏感词查询的算法简析 - 李晓晖 - 博客园 (cnblogs.com)
DFA实现
站在巨人的肩膀上才能看的更高,故此直接使用hutools
工具的DFA算法!
敏感词过滤实现
本人根据自己日常编码的使用需求,大致分为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
的配置,可以忽略。