• 给 Spring Boot 添加敏感字过滤功能


    [首先声明, 这个功能的代码不是我写的, 是 GitHub 上的, 我只是做了一些修改]

    功能代码地址: https://github.com/elulis/sensitive-words

    我当时遇到的问题以及后来的修改方法: https://github.com/elulis/sensitive-words/issues/15

    顺便贴一下代码原文吧, 作者见注释

    StringPointer.java
    import java.io.Serializable;
    import java.util.HashMap;
    import java.util.TreeMap;
    
    /**
     * 没有注释的方法与{@link String}类似<br/>
     * <b>注意:</b>没有(数组越界等的)安全检查<br/>
     * 可以作为{@link HashMap}和{@link TreeMap}的key
     * 
     * @author ZhangXiaoye
     * @date 2017年1月5日 下午2:11:56
     */
    public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
        
        private static final long serialVersionUID = 1L;
    
        protected final char[] value;
        
        protected final int offset;
        
        protected final int length;
        
        private int hash = 0;
        
        public StringPointer(String str){
            value = str.toCharArray();
            offset = 0;
            length = value.length;
        }
        
        public StringPointer(char[] value, int offset, int length){
            this.value = value;
            this.offset = offset;
            this.length = length;
        }
        
        /**
         * 计算该位置后(包含)2个字符的hash值
         * 
         * @param i 从 0 到 length - 2
         * @return hash值
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午2:23:02
         */
        public int nextTwoCharHash(int i){
            return 31 * value[offset + i] + value[offset + i + 1];
        }
        
        /**
         * 计算该位置后(包含)2个字符和为1个int型的值<br/>
         * int值相同表示2个字符相同
         * 
         * @param i 从 0 到 length - 2
         * @return int值
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午2:46:58
         */
        public int nextTwoCharMix(int i){
            return (value[offset + i] << 16) | value[offset + i + 1];
        }
        
        /**
         * 该位置后(包含)的字符串,是否以某个词(word)开头
         * 
         * @param i 从 0 到 length - 2
         * @param word 词
         * @return 是否?
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午3:13:49
         */
        public boolean nextStartsWith(int i, StringPointer word){
            // 是否长度超出
            if(word.length > length - i){
                return false;
            }
            // 从尾开始判断
            for(int c =  word.length - 1; c >= 0; c --){
                if(value[offset + i + c] != word.value[word.offset + c]){
                    return false;
                }
            }
            return true;
        }
        
        /**
         * 填充(替换)
         * 
         * @param begin 从此位置开始(含)
         * @param end 到此位置结束(不含)
         * @param fillWith 以此字符填充(替换)
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午3:29:21
         */
        public void fill(int begin, int end, char fillWith){
            for(int i = begin; i < end; i ++){
                value[offset + i] = fillWith;
            }
        }
        
        public int length(){
            return length;
        }
        
        public char charAt(int i){
            return value[offset + i];
        }
        
        public StringPointer substring(int begin){
            return new StringPointer(value, offset + begin, length - begin);
        }
        
        public StringPointer substring(int begin, int end){
            return new StringPointer(value, offset + begin, end - begin);
        }
    
        @Override
        public CharSequence subSequence(int start, int end) {
            return substring(start, end);
        }
        
        public String toString(){
            return new String(value, offset, length);
        }
        
        public int hashCode() {
            int h = hash;
            if (h == 0 && length > 0) {
                for (int i = 0; i < length; i++) {
                    h = 31 * h + value[offset + i];
                }
                hash = h;
            }
            return h;
        }
        
        public boolean equals(Object anObject) {
            if (this == anObject) {
                return true;
            }
            if (anObject instanceof StringPointer) {
                StringPointer that = (StringPointer)anObject;
                if (length == that.length) {
                    char v1[] = this.value;
                    char v2[] = that.value;
                    for(int i = 0; i < this.length; i ++){
                        if(v1[this.offset + i] != v2[that.offset + i]){
                            return false;
                        }
                    }
                    return true;
                }
            }
            return false;
        }
    
        @Override
        public int compareTo(StringPointer that) {
            int len1 = this.length;
            int len2 = that.length;
            int lim = Math.min(len1, len2);
            char v1[] = this.value;
            char v2[] = that.value;
    
            int k = 0;
            while (k < lim) {
                char c1 = v1[this.offset + k];
                char c2 = v2[that.offset + k];
                if (c1 != c2) {
                    return c1 - c2;
                }
                k++;
            }
            return len1 - len2;
        }
    
    }
    SensitiveNode.java
    import java.io.Serializable;
    import java.util.TreeSet;
    
    /**
     * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词
     * 
     * @author ZhangXiaoye
     * @date 2017年1月5日 下午5:06:26
     */
    public class SensitiveNode implements Serializable{
        
        private static final long serialVersionUID = 1L;
    
        /**
         * 头两个字符的mix,mix相同,两个字符相同
         */
        protected final int headTwoCharMix;
        
        /**
         * 所有以这两个字符开头的词表
         */
        protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>();
        
        /**
         * 下一个节点
         */
        protected SensitiveNode next;
        
        public SensitiveNode(int headTwoCharMix){
            this.headTwoCharMix = headTwoCharMix;
        }
        
        public SensitiveNode(int headTwoCharMix, SensitiveNode parent){
            this.headTwoCharMix = headTwoCharMix;
            parent.next = this;
        }
    
    }
    SensitiveFilter.java
    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.io.Serializable;
    import java.nio.charset.StandardCharsets;
    import java.util.Iterator;
    import java.util.NavigableSet;
    
    /**
     * 敏感词过滤器,以过滤速度优化为主。<br/>
     * * 增加一个敏感词:{@link #put(String)} <br/>
     * * 过滤一个句子:{@link #filter(String, char)} <br/>
     * * 获取默认的单例:{@link #DEFAULT}
     * 
     * @author ZhangXiaoye
     * @date 2017年1月5日 下午4:18:38
     */
    public class SensitiveFilter implements Serializable{
        
        private static final long serialVersionUID = 1L;
    
        /**
         * 默认的单例,使用自带的敏感词库
         */
        public static final SensitiveFilter DEFAULT = new SensitiveFilter(
                new BufferedReader(new InputStreamReader(
                        ClassLoader.getSystemResourceAsStream("sensi_words.txt")
                        , StandardCharsets.UTF_8)));
        
        /**
         * 为2的n次方,考虑到敏感词大概在10k左右,
         * 这个数量应为词数的数倍,使得桶很稀疏
         * 提高不命中时hash指向null的概率,
         * 加快访问速度。
         */
        static final int DEFAULT_INITIAL_CAPACITY = 131072;
        
        /**
         * 类似HashMap的桶,比较稀疏。
         * 使用2个字符的hash定位。
         */
        protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
        
        /**
         * 构建一个空的filter
         * 
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午4:18:07
         */
        public SensitiveFilter(){
            
        }
        
        /**
         * 加载一个文件中的词典,并构建filter<br/>
         * 文件中,每行一个敏感词条<br/>
         * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
         * <b>注意:</b>读取中的{@link IOException}不会抛出
         * 
         * @param reader 
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午4:21:06
         */
        public SensitiveFilter(BufferedReader reader){
            try{
                for(String line = reader.readLine(); line != null; line = reader.readLine()){
                    put(line);
                }
                reader.close();
            }catch(IOException e){
                e.printStackTrace();
            }
        }
        
        /**
         * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
         * 此方法(构建)并不是主要的性能优化点。
         * 
         * @param word
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午2:35:21
         */
        public 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 & (nodes.length - 1);
            
            // 从桶里拿第一个节点
            SensitiveNode node = nodes[index];
            if(node == null){
                // 如果没有节点,则放进去一个
                node = new SensitiveNode(mix);
                // 并添加词
                node.words.add(sp);
                // 放入桶里
                nodes[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 SensitiveNode(mix, node).words.add(sp);
                        return true;
                    }
                }
            }
            return true;
        }
        
        /**
         * 对句子进行敏感词过滤<br/>
         * 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/><code>
         * String result = filter.filter(sentence, '*');<br/>
         * if(result != sentence){<br/>
         * &nbsp;&nbsp;// 有敏感词<br/>
         * }
         * </code>
         * 
         * @param sentence 句子
         * @param replace 敏感词的替换字符
         * @return 过滤后的句子 
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午4:16:31
         */
        public 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);
                /*
                 * 根据hash获取第一个节点,
                 * 真正匹配的节点可能不是第一个,
                 * 所以有后面的for循环。
                 */
                SensitiveNode 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<StringPointer> 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){
                return sp.toString();
            }else{
                return sentence;
            }
        }
    
    }

    然后我的应用方式, 就是调用  SensitiveFilter 的单例创建对象

    controller 模块

        @RequestMapping(value = "sensitive", method = RequestMethod.GET,produces = MediaType.APPLICATION_JSON_VALUE)
        public SysResult<String> SensitiveCheck(String context){
            return groupService.SensitiveCheck(context);
        }

    service 模块

        /** 敏感字检测
         *
         * @param context 用户输入的文本
         * */
        public SysResult<String> SensitiveCheck(String context){
            SensitiveFilter filter = SensitiveFilter.DEFAULT;
            String filted = filter.filter(context, '*');
            if(context != filted){
                System.out.println("句子中有敏感词"+filted);
                return SysResult.failure("句子中有敏感词");
            }
            return SysResult.success("");
        }

    然后我遇到的问题就是  SensitiveFilter 类初始化失败

    原因就是

        /**
         * 默认的单例,使用自带的敏感词库
         */
        public static final SensitiveFilter DEFAULT = new SensitiveFilter(
                new BufferedReader(new InputStreamReader(
                        ClassLoader.getSystemResourceAsStream("sensi_words.txt")
                        , StandardCharsets.UTF_8)));
    
        /**
         * 加载一个文件中的词典,并构建filter<br/>
         * 文件中,每行一个敏感词条<br/>
         * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
         * <b>注意:</b>读取中的{@link IOException}不会抛出
         * 
         * @param reader 
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午4:21:06
         */
        public SensitiveFilter(BufferedReader reader){
            try{
                for(String line = reader.readLine(); line != null; line = reader.readLine()){
                    put(line);
                }
                reader.close();
            }catch(IOException e){
                e.printStackTrace();
            }
        }

    这个初始化的方式只能读取本地运行时 resources 中的  sensi_words.txt 敏感字字典文件, 然后项目上线后读取不到这个文件了, 所以类初始化失败

    我的修改如下------------------------------------------------

        /**
         * 默认的单例,使用自带的敏感词库
         */
        public static final SensitiveFilter DEFAULT = new SensitiveFilter();
    
        /**
         * 加载一个文件中的词典,并构建filter<br/>
         * 文件中,每行一个敏感词条<br/>
         * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
         * <b>注意:</b>读取中的{@link IOException}不会抛出
         *
         * @author ZhangXiaoye
         * @date 2017年1月5日 下午4:21:06
         */
        public SensitiveFilter(){
            File file = null;
            try{
                file = ResourceUtils.getFile("/home/xxxxxx/sensi_words.txt");
                InputStream inputStream = new FileInputStream(file);
                BufferedReader reader = new BufferedReader(new InputStreamReader( inputStream , StandardCharsets.UTF_8));
                for(String line = reader.readLine(); line != null; line = reader.readLine()){
                    put(line);
                }
                reader.close();
            }catch(IOException e){
                e.printStackTrace();
            }
        }

    因为我使用的是 Linux 系统来运行 tomcat, 所以将文件读取的目录指定为 

     /home/xxxxxx/sensi_words.txt

    而且最好跟管理tomcat的用户是同一个权限的用户目录下

  • 相关阅读:
    poj3673
    poj3438
    poj3461
    poj3518
    poj3672
    变秃了,也变强了!爆肝吐血整理出的超硬核JVM笔记分享!
    左手字节,右手阿里,我是如何通阿里架构师的java面试文档,拿到多家大厂offer的
    Java异常处理与常用类
    copy_{to, from}_user()的思考
    vi文本编辑器常用指令功能
  • 原文地址:https://www.cnblogs.com/unityworld/p/14360589.html
Copyright © 2020-2023  润新知