package cn.lili.common.sensitive; import lombok.extern.slf4j.Slf4j; import java.io.Serializable; import java.util.List; import java.util.NavigableSet; /** * 敏感词过滤器 * * @author Bulbasaur * @version v1.0 * @since v1.0 * 2020-02-25 14:10:16 */ @Slf4j public class SensitiveWordsFilter implements Serializable { /** * 字符* */ public final static char WILDCARD_STAR = '*'; /** * 为2的n次方,考虑到敏感词大概在10k左右, * 这个数量应为词数的数倍,使得桶很稀疏 * 提高不命中时hash指向null的概率, * 加快访问速度。 */ static final int DEFAULT_INITIAL_CAPACITY = 131072; /** * 类似HashMap的桶,比较稀疏。 * 使用2个字符的hash定位。 */ protected static SensitiveWordsNode[] nodes = new SensitiveWordsNode[0]; /** * 更新中的nodes,用于防止动态更新时,原有nodes被清空,导致无法正常写入过滤词 */ protected static SensitiveWordsNode[] nodesUpdate; /** * 过滤铭感次 * * @param sentence 过滤赐予 * @return */ public static String filter(String sentence) { return filter(sentence, WILDCARD_STAR); } /** * 判断内容是否包含敏感词,如果不包含 filter方法会原封不动的返回 * * @param content * @return true:包含 false:不包含 */ public static Boolean includeSentenceWord(String content) { String filter = filter(content, WILDCARD_STAR); return !content.equals(filter); } /** * 对句子进行敏感词过滤
* 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:
* * @param sentence 句子 * @param replace 敏感词的替换字符 * @return 过滤后的句子 */ public static String filter(String sentence, char replace) { //先转换为StringPointer StringPointer sp = new StringPointer(sentence + " "); //标示是否替换 boolean replaced = false; //匹配的起始位置 int i = 0; while (i < sp.length - 2) { /* * 移动到下一个匹配位置的步进: * 如果未匹配为1,如果匹配是匹配的词长度 */ int step = 1; //计算此位置开始2个字符的hash int hash = sp.nextTwoCharHash(i); //如果没有敏感词,则直接返回内容 if (nodes.length == 0) { return sentence; } /* * 根据hash获取第一个节点, * 真正匹配的节点可能不是第一个, * 所以有后面的for循环。 */ SensitiveWordsNode node = nodes[hash & (nodes.length - 1)]; /* * 如果非敏感词,node基本为null。 * 这一步大幅提升效率 */ if (node != null) { /* * 如果能拿到第一个节点, * 才计算mix(mix相同表示2个字符相同)。 * mix的意义和HashMap先hash再equals的equals部分类似。 */ int mix = sp.nextTwoCharMix(i); /* * 循环所有的节点,如果非敏感词, * mix相同的概率非常低,提高效率 */ outer: for (; node != null; node = node.next) { /* * 对于一个节点,先根据头2个字符判断是否属于这个节点。 * 如果属于这个节点,看这个节点的词库是否命中。 * 此代码块中访问次数已经很少,不是优化重点 */ if (node.headTwoCharMix == mix) { /* * 查出比剩余sentence小的最大的词。 * 例如剩余sentence为"色情电影哪家强?", * 这个节点含三个词从小到大为:"色情"、"色情电影"、"色情信息"。 * 则从“色情电影”开始向前匹配 */ NavigableSet desSet = node.words.headSet(sp.substring(i), true); if (desSet != null) { for (StringPointer word : desSet.descendingSet()) { /* * 仍然需要再判断一次,例如"色情信息哪里有?", * 如果节点只包含"色情电影"一个词, * 仍然能够取到word为"色情电影",但是不该匹配。 */ if (sp.nextStartsWith(i, word)) { //匹配成功,将匹配的部分,用replace制定的内容替代 sp.fill(i, i + word.length, replace); //跳过已经替代的部分 step = word.length; //标示有替换 replaced = true; //跳出循环(然后是while循环的下一个位置) break outer; } } } } } } //移动到下一个匹配位置 i += step; } //如果没有替换,直接返回入参(节约String的构造copy) if (replaced) { String res = sp.toString(); return res.substring(0, res.length() - 2); } else { return sentence; } } /** * 初始化敏感词 */ public static void init(List words) { log.info("开始初始化敏感词"); nodesUpdate = new SensitiveWordsNode[DEFAULT_INITIAL_CAPACITY]; for (String word : words) { put(word); } nodes = nodesUpdate; } /** * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃
* 此方法(构建)并不是主要的性能优化点。 * * @param word 敏感词 * @return 操作结果 */ public static boolean put(String word) { //长度小于2的不加入 if (word == null || word.trim().length() < 2) { return false; } //两个字符的不考虑 if (word.length() == 2 && word.matches("\\w\\w")) { return false; } StringPointer sp = new StringPointer(word.trim()); //计算头两个字符的hash int hash = sp.nextTwoCharHash(0); //计算头两个字符的mix表示(mix相同,两个字符相同) int mix = sp.nextTwoCharMix(0); //转为在hash桶中的位置 int index = hash & (nodesUpdate.length - 1); //从桶里拿第一个节点 SensitiveWordsNode node = nodesUpdate[index]; if (node == null) { //如果没有节点,则放进去一个 node = new SensitiveWordsNode(mix); //并添加词 node.words.add(sp); //放入桶里 nodesUpdate[index] = node; } else { //如果已经有节点(1个或多个),找到正确的节点 for (; node != null; node = node.next) { //匹配节点 if (node.headTwoCharMix == mix) { node.words.add(sp); return true; } //如果匹配到最后仍然不成功,则追加一个节点 if (node.next == null) { new SensitiveWordsNode(mix, node).words.add(sp); return true; } } } return true; } /** * 移除敏感词 * * @param word * @return */ public static void remove(String word) { StringPointer sp = new StringPointer(word.trim()); //计算头两个字符的hash int hash = sp.nextTwoCharHash(0); //计算头两个字符的mix表示(mix相同,两个字符相同) int mix = sp.nextTwoCharMix(0); //转为在hash桶中的位置 int index = hash & (nodes.length - 1); SensitiveWordsNode node = nodes[index]; for (; node != null; node = node.next) { //匹配节点 if (node.headTwoCharMix == mix) { node.words.remove(sp); } } } }