前言

项目用到了敏感词过滤(数据清洗),之前是通过查询敏感词库进行模糊匹配然后过滤的,但这样的查表的效率很低,且跨微服务调用时时间损耗会更高,故此记录改造

正文

首先想到的是将敏感词从库表里放入redis缓存,这样可以有效避免重复查库,其次通过redis可以较少微服务调用,直接从redis缓存使用。但是一段语句中,是否存在敏感词,直接匹配redis缓存中的敏感词库的话,效率依旧不高,故此我们可以引入知名的DFA算法。

DFA算法介绍

DFA(确定有限状态自动机) 算法是一种用于解决文本匹配问题的算法。它通过比较输入文本和预定义的状态机的状态转换路径,来判断是否存在匹配。

DFA 通常用于字符串匹配。它利用预先创建好的有限状态自动机,自动机包含一个有限的状态集合和一个输入符号集合,自动机从一个状态转移到另一个状态,根据输入符号,自动机判断出输入符号是否与预定义的字符串匹配。 其主要思想是将匹配过程建模为状态机,并使用状态机的转移过程进行匹配。

下面是一些使用 DFA 的例子:

  1. 字符串匹配:在一个输入文本中查找一个特定的字符串是否存在。如果它存在,就返回它在文本中的位置。如果不存在,则返回-1。
  2. 词法分析:将输入的字符流识别成包含令牌、标记或关键字的单词流。例如,在编译器中,它用于将代码解析成令牌流。
  3. 自然语言处理:用于命名实体识别等自然语言处理任务。在这些任务中,它可以使用 DFA 对文本进行分词,并构建一个有限状态自动机,以识别关键字或语法结构。
  4. 过滤器:用于过滤输入文本中的某些内容。在这些任务中,它可以使用 DFA 对文本进行匹配和替换,以过滤或修改输入文本中的内容。

更多的介绍可以看这篇文章:

基于DFA敏感词查询的算法简析 - 李晓晖 - 博客园 (cnblogs.com)

DFA实现

站在巨人的肩膀上才能看的更高,故此直接使用hutools工具的DFA算法!

DFA查找 (hutool.cn)

敏感词过滤实现

本人根据自己日常编码的使用需求,大致分为2种使用场景:

  1. 方法内过滤

    这个很好理解,就是方法体内进行过滤,这个直接使用hutool提供的方法结合redis使用即可,不再赘述。

  2. 方法调用时过滤

    作为入参参数过滤,即方法调用时过滤

根据2个场景又可以分成2种风格:

  1. 遇到敏感词即禁止(截止模式)
  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的配置,可以忽略。

如果觉得我的文章对你有用,请随意赞赏