正在显示
7 个修改的文件
包含
1024 行增加
和
0 行删除
README.md
0 → 100644
| 1 | +# 高效敏感词过滤 | ||
| 2 | + | ||
| 3 | +[TOC] | ||
| 4 | + | ||
| 5 | +## 性能概述 | ||
| 6 | + | ||
| 7 | +在共60M穿越小说上测试,单核性能为80M字符每秒(i7 2.3GHz)。 | ||
| 8 | +相比类似原理的正向最大匹配分词,性能一般在1M字节每秒左右有很大提升,类似的优化方式可以用在分词器上。 | ||
| 9 | + | ||
| 10 | +``` | ||
| 11 | +敏感词 14580 条 | ||
| 12 | +共加载 599254 行,30613005 字符。 | ||
| 13 | +共耗时 0.381 秒, 速度为 80349.1字符/毫秒 | ||
| 14 | +``` | ||
| 15 | + | ||
| 16 | +## 优化方式 | ||
| 17 | + | ||
| 18 | +主要的优化目标是速度,从以下方面优化: | ||
| 19 | +1. 敏感词都是2个字以上的, | ||
| 20 | +2. 对于句子中的一个位置,用2个字符的hash在稀疏的hash桶中查找,如果查不到说明一定不是敏感词,则继续下一个位置。 | ||
| 21 | +3. 2个字符(2x16位),可以预先组合为1个int(32位)的mix,即使hash命中,如果mix不同则跳过。 | ||
| 22 | +4. StringPointer,在不生成新实例的情况下计算任意位置2个字符的hash和mix | ||
| 23 | +5. StringPointer,尽量减少实例生成和char数组的拷贝。 | ||
| 24 | + | ||
| 25 | +## 敏感词库 | ||
| 26 | + | ||
| 27 | +自带敏感词库拷贝自 https://github.com/observerss/textfilter ,并删除如`女人`、`然后`这样的几个常用词。 | ||
| 28 | +如果需要自带敏感词的实例,可以直接使用下面的方式: | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +```java | ||
| 32 | +// 使用默认的单例(即加载了自带敏感词库的) | ||
| 33 | +SensitiveFilter filter = SensitiveFilter.DEFAULT; | ||
| 34 | +// 对一个句子过滤 | ||
| 35 | +System.out.println(filter.filter("会上,主席进行了发言。", '*')); | ||
| 36 | +``` | ||
| 37 | + | ||
| 38 | +打印结果 | ||
| 39 | + | ||
| 40 | +``` | ||
| 41 | +会上,**进行了发言。 | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +## 代码只有3个类如下 | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +### SensitiveFilter.java | ||
| 48 | +```java | ||
| 49 | +package com.odianyun.util.sensi; | ||
| 50 | + | ||
| 51 | +import java.io.BufferedReader; | ||
| 52 | +import java.io.IOException; | ||
| 53 | +import java.io.InputStreamReader; | ||
| 54 | +import java.io.Serializable; | ||
| 55 | +import java.nio.charset.StandardCharsets; | ||
| 56 | + | ||
| 57 | +/** | ||
| 58 | + * 敏感词过滤器,以过滤速度优化为主。<br/> | ||
| 59 | + * * 增加一个敏感词:{@link #put(String)} <br/> | ||
| 60 | + * * 过滤一个句子:{@link #filter(String, char)} <br/> | ||
| 61 | + * * 获取默认的单例:{@link #DEFAULT} | ||
| 62 | + * | ||
| 63 | + * @author ZhangXiaoye | ||
| 64 | + * @date 2017年1月5日 下午4:18:38 | ||
| 65 | + */ | ||
| 66 | +public class SensitiveFilter implements Serializable{ | ||
| 67 | + | ||
| 68 | + private static final long serialVersionUID = 1L; | ||
| 69 | + | ||
| 70 | + /** | ||
| 71 | + * 默认的单例,使用自带的敏感词库 | ||
| 72 | + */ | ||
| 73 | + public static final SensitiveFilter DEFAULT = new SensitiveFilter( | ||
| 74 | + new BufferedReader(new InputStreamReader( | ||
| 75 | + ClassLoader.getSystemResourceAsStream("sensi_words.txt") | ||
| 76 | + , StandardCharsets.UTF_8))); | ||
| 77 | + | ||
| 78 | + /** | ||
| 79 | + * 为2的n次方,考虑到敏感词大概在10k左右, | ||
| 80 | + * 这个数量应为词数的数倍,使得桶很稀疏 | ||
| 81 | + * 提高不命中时hash指向null的概率, | ||
| 82 | + * 加快访问速度。 | ||
| 83 | + */ | ||
| 84 | + static final int DEFAULT_INITIAL_CAPACITY = 131072; | ||
| 85 | + | ||
| 86 | + /** | ||
| 87 | + * 类似HashMap的桶,比较稀疏。 | ||
| 88 | + * 使用2个字符的hash定位。 | ||
| 89 | + */ | ||
| 90 | + protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY]; | ||
| 91 | + | ||
| 92 | + /** | ||
| 93 | + * 构建一个空的filter | ||
| 94 | + * | ||
| 95 | + * @author ZhangXiaoye | ||
| 96 | + * @date 2017年1月5日 下午4:18:07 | ||
| 97 | + */ | ||
| 98 | + public SensitiveFilter(){ | ||
| 99 | + | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + /** | ||
| 103 | + * 加载一个文件中的词典,并构建filter<br/> | ||
| 104 | + * 文件中,每行一个敏感词条<br/> | ||
| 105 | + * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/> | ||
| 106 | + * <b>注意:</b>读取中的{@link IOException}不会抛出 | ||
| 107 | + * | ||
| 108 | + * @param reader | ||
| 109 | + * @author ZhangXiaoye | ||
| 110 | + * @date 2017年1月5日 下午4:21:06 | ||
| 111 | + */ | ||
| 112 | + public SensitiveFilter(BufferedReader reader){ | ||
| 113 | + try{ | ||
| 114 | + for(String line = reader.readLine(); line != null; line = reader.readLine()){ | ||
| 115 | + put(line); | ||
| 116 | + } | ||
| 117 | + reader.close(); | ||
| 118 | + }catch(IOException e){ | ||
| 119 | + e.printStackTrace(); | ||
| 120 | + } | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + /** | ||
| 124 | + * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/> | ||
| 125 | + * 此方法(构建)并不是主要的性能优化点。 | ||
| 126 | + * | ||
| 127 | + * @param word | ||
| 128 | + * @author ZhangXiaoye | ||
| 129 | + * @date 2017年1月5日 下午2:35:21 | ||
| 130 | + */ | ||
| 131 | + public void put(String word){ | ||
| 132 | + if(word == null || word.trim().length() < 2){ | ||
| 133 | + return; | ||
| 134 | + } | ||
| 135 | + StringPointer sp = new StringPointer(word.trim()); | ||
| 136 | + // 计算头两个字符的hash | ||
| 137 | + int hash = sp.nextTwoCharHash(0); | ||
| 138 | + // 计算头两个字符的mix表示(mix相同,两个字符相同) | ||
| 139 | + int mix = sp.nextTwoCharMix(0); | ||
| 140 | + // 转为在hash桶中的位置 | ||
| 141 | + int index = hash & (nodes.length - 1); | ||
| 142 | + | ||
| 143 | + // 从桶里拿第一个节点 | ||
| 144 | + SensitiveNode node = nodes[index]; | ||
| 145 | + if(node == null){ | ||
| 146 | + // 如果没有节点,则放进去一个 | ||
| 147 | + node = new SensitiveNode(mix); | ||
| 148 | + // 并添加词 | ||
| 149 | + node.words.add(sp); | ||
| 150 | + // 放入桶里 | ||
| 151 | + nodes[index] = node; | ||
| 152 | + }else{ | ||
| 153 | + // 如果已经有节点(1个或多个),找到正确的节点 | ||
| 154 | + for(;node != null; node = node.next){ | ||
| 155 | + // 匹配节点 | ||
| 156 | + if(node.headTwoCharMix == mix){ | ||
| 157 | + node.words.add(sp); | ||
| 158 | + return; | ||
| 159 | + } | ||
| 160 | + // 如果匹配到最后仍然不成功,则追加一个节点 | ||
| 161 | + if(node.next == null){ | ||
| 162 | + new SensitiveNode(mix, node).words.add(sp); | ||
| 163 | + return; | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + /** | ||
| 170 | + * 对句子进行敏感词过滤 | ||
| 171 | + * | ||
| 172 | + * @param sentence 句子 | ||
| 173 | + * @param replace 敏感词的替换字符 | ||
| 174 | + * @return 过滤后的句子 | ||
| 175 | + * @author ZhangXiaoye | ||
| 176 | + * @date 2017年1月5日 下午4:16:31 | ||
| 177 | + */ | ||
| 178 | + public String filter(String sentence, char replace){ | ||
| 179 | + // 先转换为StringPointer | ||
| 180 | + StringPointer sp = new StringPointer(sentence); | ||
| 181 | + | ||
| 182 | + // 标示是否替换 | ||
| 183 | + boolean replaced = false; | ||
| 184 | + | ||
| 185 | + // 匹配的起始位置 | ||
| 186 | + int i = 0; | ||
| 187 | + while(i < sp.length - 2){ | ||
| 188 | + /* | ||
| 189 | + * 移动到下一个匹配位置的步进: | ||
| 190 | + * 如果未匹配为1,如果匹配是匹配的词长度 | ||
| 191 | + */ | ||
| 192 | + int step = 1; | ||
| 193 | + // 计算此位置开始2个字符的hash | ||
| 194 | + int hash = sp.nextTwoCharHash(i); | ||
| 195 | + /* | ||
| 196 | + * 根据hash获取第一个节点, | ||
| 197 | + * 真正匹配的节点可能不是第一个, | ||
| 198 | + * 所以有后面的for循环。 | ||
| 199 | + */ | ||
| 200 | + SensitiveNode node = nodes[hash & (nodes.length - 1)]; | ||
| 201 | + /* | ||
| 202 | + * 如果非敏感词,node基本为null。 | ||
| 203 | + * 这一步大幅提升效率 | ||
| 204 | + */ | ||
| 205 | + if(node != null){ | ||
| 206 | + /* | ||
| 207 | + * 如果能拿到第一个节点, | ||
| 208 | + * 才计算mix(mix相同表示2个字符相同)。 | ||
| 209 | + * mix的意义和HashMap先hash再equals的equals部分类似。 | ||
| 210 | + */ | ||
| 211 | + int mix = sp.nextTwoCharMix(i); | ||
| 212 | + /* | ||
| 213 | + * 循环所有的节点,如果非敏感词, | ||
| 214 | + * mix相同的概率非常低,提高效率 | ||
| 215 | + */ | ||
| 216 | + for(; node != null; node = node.next){ | ||
| 217 | + /* | ||
| 218 | + * 对于一个节点,先根据头2个字符判断是否属于这个节点。 | ||
| 219 | + * 如果属于这个节点,看这个节点的词库是否命中。 | ||
| 220 | + * 此代码块中访问次数已经很少,不是优化重点 | ||
| 221 | + */ | ||
| 222 | + if(node.headTwoCharMix == mix){ | ||
| 223 | + /* | ||
| 224 | + * 查出比剩余sentence小的最大的词。 | ||
| 225 | + * 例如剩余sentence为"色情电影哪家强?", | ||
| 226 | + * 这个节点含三个词从小到大为:“色情”、“色情电影”、“色情信息”。 | ||
| 227 | + * 则取到的word为“色情电影” | ||
| 228 | + */ | ||
| 229 | + StringPointer word = node.words.floor(sp.substring(i)); | ||
| 230 | + /* | ||
| 231 | + * 仍然需要再判断一次,例如“色情信息哪里有?”, | ||
| 232 | + * 如果节点只包含“色情电影”一个词, | ||
| 233 | + * 仍然能够取到word为“色情电影”,但是不该匹配。 | ||
| 234 | + */ | ||
| 235 | + if(word != null && sp.nextStartsWith(i, word)){ | ||
| 236 | + // 匹配成功,将匹配的部分,用replace制定的内容替代 | ||
| 237 | + sp.fill(i, i + word.length, replace); | ||
| 238 | + // 跳过已经替代的部分 | ||
| 239 | + step = word.length; | ||
| 240 | + // 标示有替换 | ||
| 241 | + replaced = true; | ||
| 242 | + // 跳出for循环(然后是while循环的下一个位置) | ||
| 243 | + break; | ||
| 244 | + } | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + | ||
| 249 | + // 移动到下一个匹配位置 | ||
| 250 | + i += step; | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + // 如果没有替换,直接返回入参(节约String的构造copy) | ||
| 254 | + if(replaced){ | ||
| 255 | + return sp.toString(); | ||
| 256 | + }else{ | ||
| 257 | + return sentence; | ||
| 258 | + } | ||
| 259 | + } | ||
| 260 | + | ||
| 261 | +} | ||
| 262 | +``` | ||
| 263 | + | ||
| 264 | +### SensitiveNode.java | ||
| 265 | + | ||
| 266 | + | ||
| 267 | +```java | ||
| 268 | +package com.odianyun.util.sensi; | ||
| 269 | + | ||
| 270 | +import java.io.Serializable; | ||
| 271 | +import java.util.TreeSet; | ||
| 272 | + | ||
| 273 | +/** | ||
| 274 | + * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词 | ||
| 275 | + * | ||
| 276 | + * @author ZhangXiaoye | ||
| 277 | + * @date 2017年1月5日 下午5:06:26 | ||
| 278 | + */ | ||
| 279 | +public class SensitiveNode implements Serializable{ | ||
| 280 | + | ||
| 281 | + private static final long serialVersionUID = 1L; | ||
| 282 | + | ||
| 283 | + /** | ||
| 284 | + * 头两个字符的mix,mix相同,两个字符相同 | ||
| 285 | + */ | ||
| 286 | + protected final int headTwoCharMix; | ||
| 287 | + | ||
| 288 | + /** | ||
| 289 | + * 所有以这两个字符开头的词表 | ||
| 290 | + */ | ||
| 291 | + protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>(); | ||
| 292 | + | ||
| 293 | + /** | ||
| 294 | + * 下一个节点 | ||
| 295 | + */ | ||
| 296 | + protected SensitiveNode next; | ||
| 297 | + | ||
| 298 | + public SensitiveNode(int headTwoCharMix){ | ||
| 299 | + this.headTwoCharMix = headTwoCharMix; | ||
| 300 | + } | ||
| 301 | + | ||
| 302 | + public SensitiveNode(int headTwoCharMix, SensitiveNode parent){ | ||
| 303 | + this.headTwoCharMix = headTwoCharMix; | ||
| 304 | + parent.next = this; | ||
| 305 | + } | ||
| 306 | + | ||
| 307 | +} | ||
| 308 | +``` | ||
| 309 | + | ||
| 310 | +### StringPointer.java | ||
| 311 | + | ||
| 312 | +```java | ||
| 313 | +package com.odianyun.util.sensi; | ||
| 314 | + | ||
| 315 | +import java.io.Serializable; | ||
| 316 | +import java.util.HashMap; | ||
| 317 | +import java.util.TreeMap; | ||
| 318 | + | ||
| 319 | +/** | ||
| 320 | + * 没有注释的方法与{@link String}类似<br/> | ||
| 321 | + * <b>注意:</b>没有(数组越界等的)安全检查<br/> | ||
| 322 | + * 可以作为{@link HashMap}和{@link TreeMap}的key | ||
| 323 | + * | ||
| 324 | + * @author ZhangXiaoye | ||
| 325 | + * @date 2017年1月5日 下午2:11:56 | ||
| 326 | + */ | ||
| 327 | +public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{ | ||
| 328 | + | ||
| 329 | + private static final long serialVersionUID = 1L; | ||
| 330 | + | ||
| 331 | + protected final char[] value; | ||
| 332 | + | ||
| 333 | + protected final int offset; | ||
| 334 | + | ||
| 335 | + protected final int length; | ||
| 336 | + | ||
| 337 | + private int hash = 0; | ||
| 338 | + | ||
| 339 | + public StringPointer(String str){ | ||
| 340 | + value = str.toCharArray(); | ||
| 341 | + offset = 0; | ||
| 342 | + length = value.length; | ||
| 343 | + } | ||
| 344 | + | ||
| 345 | + public StringPointer(char[] value, int offset, int length){ | ||
| 346 | + this.value = value; | ||
| 347 | + this.offset = 0; | ||
| 348 | + this.length = value.length; | ||
| 349 | + } | ||
| 350 | + | ||
| 351 | + /** | ||
| 352 | + * 计算该位置后(包含)2个字符的hash值 | ||
| 353 | + * | ||
| 354 | + * @param i 从 0 到 length - 2 | ||
| 355 | + * @return hash值 | ||
| 356 | + * @author ZhangXiaoye | ||
| 357 | + * @date 2017年1月5日 下午2:23:02 | ||
| 358 | + */ | ||
| 359 | + public int nextTwoCharHash(int i){ | ||
| 360 | + return 31 * value[offset + i] + value[offset + i + 1]; | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + /** | ||
| 364 | + * 计算该位置后(包含)2个字符和为1个int型的值<br/> | ||
| 365 | + * int值相同表示2个字符相同 | ||
| 366 | + * | ||
| 367 | + * @param i 从 0 到 length - 2 | ||
| 368 | + * @return int值 | ||
| 369 | + * @author ZhangXiaoye | ||
| 370 | + * @date 2017年1月5日 下午2:46:58 | ||
| 371 | + */ | ||
| 372 | + public int nextTwoCharMix(int i){ | ||
| 373 | + return (value[offset + i] << 16) | value[offset + i + 1]; | ||
| 374 | + } | ||
| 375 | + | ||
| 376 | + /** | ||
| 377 | + * 该位置后(包含)的字符串,是否以某个词(word)开头 | ||
| 378 | + * | ||
| 379 | + * @param i 从 0 到 length - 2 | ||
| 380 | + * @param word 词 | ||
| 381 | + * @return 是否? | ||
| 382 | + * @author ZhangXiaoye | ||
| 383 | + * @date 2017年1月5日 下午3:13:49 | ||
| 384 | + */ | ||
| 385 | + public boolean nextStartsWith(int i, StringPointer word){ | ||
| 386 | + // 是否长度超出 | ||
| 387 | + if(word.length > length - i){ | ||
| 388 | + return false; | ||
| 389 | + } | ||
| 390 | + // 从尾开始判断 | ||
| 391 | + for(int c = word.length - 1; c >= 0; c --){ | ||
| 392 | + if(value[offset + i + c] != word.value[word.offset + c]){ | ||
| 393 | + return false; | ||
| 394 | + } | ||
| 395 | + } | ||
| 396 | + return true; | ||
| 397 | + } | ||
| 398 | + | ||
| 399 | + /** | ||
| 400 | + * 填充(替换) | ||
| 401 | + * | ||
| 402 | + * @param begin 从此位置开始(含) | ||
| 403 | + * @param end 到此位置结束(不含) | ||
| 404 | + * @param fillWith 以此字符填充(替换) | ||
| 405 | + * @author ZhangXiaoye | ||
| 406 | + * @date 2017年1月5日 下午3:29:21 | ||
| 407 | + */ | ||
| 408 | + public void fill(int begin, int end, char fillWith){ | ||
| 409 | + for(int i = begin; i < end; i ++){ | ||
| 410 | + value[offset + i] = fillWith; | ||
| 411 | + } | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + public int length(){ | ||
| 415 | + return length; | ||
| 416 | + } | ||
| 417 | + | ||
| 418 | + public char charAt(int i){ | ||
| 419 | + return value[offset + i]; | ||
| 420 | + } | ||
| 421 | + | ||
| 422 | + public StringPointer substring(int begin){ | ||
| 423 | + return new StringPointer(value, offset + begin, length - begin); | ||
| 424 | + } | ||
| 425 | + | ||
| 426 | + public StringPointer substring(int begin, int end){ | ||
| 427 | + return new StringPointer(value, offset + begin, end - begin); | ||
| 428 | + } | ||
| 429 | + | ||
| 430 | + @Override | ||
| 431 | + public CharSequence subSequence(int start, int end) { | ||
| 432 | + return substring(start, end); | ||
| 433 | + } | ||
| 434 | + | ||
| 435 | + public String toString(){ | ||
| 436 | + return new String(value, offset, length); | ||
| 437 | + } | ||
| 438 | + | ||
| 439 | + public int hashCode() { | ||
| 440 | + int h = hash; | ||
| 441 | + if (h == 0 && length > 0) { | ||
| 442 | + for (int i = 0; i < length; i++) { | ||
| 443 | + h = 31 * h + value[offset + i]; | ||
| 444 | + } | ||
| 445 | + hash = h; | ||
| 446 | + } | ||
| 447 | + return h; | ||
| 448 | + } | ||
| 449 | + | ||
| 450 | + public boolean equals(Object anObject) { | ||
| 451 | + if (this == anObject) { | ||
| 452 | + return true; | ||
| 453 | + } | ||
| 454 | + if (anObject instanceof StringPointer) { | ||
| 455 | + StringPointer that = (StringPointer)anObject; | ||
| 456 | + if (length == that.length) { | ||
| 457 | + char v1[] = this.value; | ||
| 458 | + char v2[] = that.value; | ||
| 459 | + for(int i = 0; i < this.length; i ++){ | ||
| 460 | + if(v1[this.offset + i] != v2[that.offset + i]){ | ||
| 461 | + return false; | ||
| 462 | + } | ||
| 463 | + } | ||
| 464 | + return true; | ||
| 465 | + } | ||
| 466 | + } | ||
| 467 | + return false; | ||
| 468 | + } | ||
| 469 | + | ||
| 470 | + @Override | ||
| 471 | + public int compareTo(StringPointer that) { | ||
| 472 | + int len1 = this.length; | ||
| 473 | + int len2 = that.length; | ||
| 474 | + int lim = Math.min(len1, len2); | ||
| 475 | + char v1[] = this.value; | ||
| 476 | + char v2[] = that.value; | ||
| 477 | + | ||
| 478 | + int k = 0; | ||
| 479 | + while (k < lim) { | ||
| 480 | + char c1 = v1[this.offset + k]; | ||
| 481 | + char c2 = v2[that.offset + k]; | ||
| 482 | + if (c1 != c2) { | ||
| 483 | + return c1 - c2; | ||
| 484 | + } | ||
| 485 | + k++; | ||
| 486 | + } | ||
| 487 | + return len1 - len2; | ||
| 488 | + } | ||
| 489 | + | ||
| 490 | +} | ||
| 491 | +``` | ||
| 492 | + |
pom.xml
0 → 100644
| 1 | +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| 2 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
| 3 | + <modelVersion>4.0.0</modelVersion> | ||
| 4 | + | ||
| 5 | + <groupId>com.odianyun.util</groupId> | ||
| 6 | + <artifactId>sensitive-words</artifactId> | ||
| 7 | + <version>1.0.1</version> | ||
| 8 | + <packaging>jar</packaging> | ||
| 9 | + | ||
| 10 | + <name>sensitive-words</name> | ||
| 11 | + <url>http://www.odianyun.com</url> | ||
| 12 | + | ||
| 13 | + <properties> | ||
| 14 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | ||
| 15 | + </properties> | ||
| 16 | + | ||
| 17 | + <dependencies> | ||
| 18 | + <dependency> | ||
| 19 | + <groupId>junit</groupId> | ||
| 20 | + <artifactId>junit</artifactId> | ||
| 21 | + <version>3.8.1</version> | ||
| 22 | + <scope>test</scope> | ||
| 23 | + </dependency> | ||
| 24 | + </dependencies> | ||
| 25 | + | ||
| 26 | +</project> |
| 1 | +package com.odianyun.util.sensi; | ||
| 2 | + | ||
| 3 | +import java.io.BufferedReader; | ||
| 4 | +import java.io.IOException; | ||
| 5 | +import java.io.InputStreamReader; | ||
| 6 | +import java.io.Serializable; | ||
| 7 | +import java.nio.charset.StandardCharsets; | ||
| 8 | + | ||
| 9 | +/** | ||
| 10 | + * 敏感词过滤器,以过滤速度优化为主。<br/> | ||
| 11 | + * * 增加一个敏感词:{@link #put(String)} <br/> | ||
| 12 | + * * 过滤一个句子:{@link #filter(String, char)} <br/> | ||
| 13 | + * * 获取默认的单例:{@link #DEFAULT} | ||
| 14 | + * | ||
| 15 | + * @author ZhangXiaoye | ||
| 16 | + * @date 2017年1月5日 下午4:18:38 | ||
| 17 | + */ | ||
| 18 | +public class SensitiveFilter implements Serializable{ | ||
| 19 | + | ||
| 20 | + private static final long serialVersionUID = 1L; | ||
| 21 | + | ||
| 22 | + /** | ||
| 23 | + * 默认的单例,使用自带的敏感词库 | ||
| 24 | + */ | ||
| 25 | + public static final SensitiveFilter DEFAULT = new SensitiveFilter( | ||
| 26 | + new BufferedReader(new InputStreamReader( | ||
| 27 | + ClassLoader.getSystemResourceAsStream("sensi_words.txt") | ||
| 28 | + , StandardCharsets.UTF_8))); | ||
| 29 | + | ||
| 30 | + /** | ||
| 31 | + * 为2的n次方,考虑到敏感词大概在10k左右, | ||
| 32 | + * 这个数量应为词数的数倍,使得桶很稀疏 | ||
| 33 | + * 提高不命中时hash指向null的概率, | ||
| 34 | + * 加快访问速度。 | ||
| 35 | + */ | ||
| 36 | + static final int DEFAULT_INITIAL_CAPACITY = 131072; | ||
| 37 | + | ||
| 38 | + /** | ||
| 39 | + * 类似HashMap的桶,比较稀疏。 | ||
| 40 | + * 使用2个字符的hash定位。 | ||
| 41 | + */ | ||
| 42 | + protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY]; | ||
| 43 | + | ||
| 44 | + /** | ||
| 45 | + * 构建一个空的filter | ||
| 46 | + * | ||
| 47 | + * @author ZhangXiaoye | ||
| 48 | + * @date 2017年1月5日 下午4:18:07 | ||
| 49 | + */ | ||
| 50 | + public SensitiveFilter(){ | ||
| 51 | + | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /** | ||
| 55 | + * 加载一个文件中的词典,并构建filter<br/> | ||
| 56 | + * 文件中,每行一个敏感词条<br/> | ||
| 57 | + * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/> | ||
| 58 | + * <b>注意:</b>读取中的{@link IOException}不会抛出 | ||
| 59 | + * | ||
| 60 | + * @param reader | ||
| 61 | + * @author ZhangXiaoye | ||
| 62 | + * @date 2017年1月5日 下午4:21:06 | ||
| 63 | + */ | ||
| 64 | + public SensitiveFilter(BufferedReader reader){ | ||
| 65 | + try{ | ||
| 66 | + for(String line = reader.readLine(); line != null; line = reader.readLine()){ | ||
| 67 | + put(line); | ||
| 68 | + } | ||
| 69 | + reader.close(); | ||
| 70 | + }catch(IOException e){ | ||
| 71 | + e.printStackTrace(); | ||
| 72 | + } | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + /** | ||
| 76 | + * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/> | ||
| 77 | + * 此方法(构建)并不是主要的性能优化点。 | ||
| 78 | + * | ||
| 79 | + * @param word | ||
| 80 | + * @author ZhangXiaoye | ||
| 81 | + * @date 2017年1月5日 下午2:35:21 | ||
| 82 | + */ | ||
| 83 | + public void put(String word){ | ||
| 84 | + if(word == null || word.trim().length() < 2){ | ||
| 85 | + return; | ||
| 86 | + } | ||
| 87 | + StringPointer sp = new StringPointer(word.trim()); | ||
| 88 | + // 计算头两个字符的hash | ||
| 89 | + int hash = sp.nextTwoCharHash(0); | ||
| 90 | + // 计算头两个字符的mix表示(mix相同,两个字符相同) | ||
| 91 | + int mix = sp.nextTwoCharMix(0); | ||
| 92 | + // 转为在hash桶中的位置 | ||
| 93 | + int index = hash & (nodes.length - 1); | ||
| 94 | + | ||
| 95 | + // 从桶里拿第一个节点 | ||
| 96 | + SensitiveNode node = nodes[index]; | ||
| 97 | + if(node == null){ | ||
| 98 | + // 如果没有节点,则放进去一个 | ||
| 99 | + node = new SensitiveNode(mix); | ||
| 100 | + // 并添加词 | ||
| 101 | + node.words.add(sp); | ||
| 102 | + // 放入桶里 | ||
| 103 | + nodes[index] = node; | ||
| 104 | + }else{ | ||
| 105 | + // 如果已经有节点(1个或多个),找到正确的节点 | ||
| 106 | + for(;node != null; node = node.next){ | ||
| 107 | + // 匹配节点 | ||
| 108 | + if(node.headTwoCharMix == mix){ | ||
| 109 | + node.words.add(sp); | ||
| 110 | + return; | ||
| 111 | + } | ||
| 112 | + // 如果匹配到最后仍然不成功,则追加一个节点 | ||
| 113 | + if(node.next == null){ | ||
| 114 | + new SensitiveNode(mix, node).words.add(sp); | ||
| 115 | + return; | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + /** | ||
| 122 | + * 对句子进行敏感词过滤 | ||
| 123 | + * | ||
| 124 | + * @param sentence 句子 | ||
| 125 | + * @param replace 敏感词的替换字符 | ||
| 126 | + * @return 过滤后的句子 | ||
| 127 | + * @author ZhangXiaoye | ||
| 128 | + * @date 2017年1月5日 下午4:16:31 | ||
| 129 | + */ | ||
| 130 | + public String filter(String sentence, char replace){ | ||
| 131 | + // 先转换为StringPointer | ||
| 132 | + StringPointer sp = new StringPointer(sentence); | ||
| 133 | + | ||
| 134 | + // 标示是否替换 | ||
| 135 | + boolean replaced = false; | ||
| 136 | + | ||
| 137 | + // 匹配的起始位置 | ||
| 138 | + int i = 0; | ||
| 139 | + while(i < sp.length - 2){ | ||
| 140 | + /* | ||
| 141 | + * 移动到下一个匹配位置的步进: | ||
| 142 | + * 如果未匹配为1,如果匹配是匹配的词长度 | ||
| 143 | + */ | ||
| 144 | + int step = 1; | ||
| 145 | + // 计算此位置开始2个字符的hash | ||
| 146 | + int hash = sp.nextTwoCharHash(i); | ||
| 147 | + /* | ||
| 148 | + * 根据hash获取第一个节点, | ||
| 149 | + * 真正匹配的节点可能不是第一个, | ||
| 150 | + * 所以有后面的for循环。 | ||
| 151 | + */ | ||
| 152 | + SensitiveNode node = nodes[hash & (nodes.length - 1)]; | ||
| 153 | + /* | ||
| 154 | + * 如果非敏感词,node基本为null。 | ||
| 155 | + * 这一步大幅提升效率 | ||
| 156 | + */ | ||
| 157 | + if(node != null){ | ||
| 158 | + /* | ||
| 159 | + * 如果能拿到第一个节点, | ||
| 160 | + * 才计算mix(mix相同表示2个字符相同)。 | ||
| 161 | + * mix的意义和HashMap先hash再equals的equals部分类似。 | ||
| 162 | + */ | ||
| 163 | + int mix = sp.nextTwoCharMix(i); | ||
| 164 | + /* | ||
| 165 | + * 循环所有的节点,如果非敏感词, | ||
| 166 | + * mix相同的概率非常低,提高效率 | ||
| 167 | + */ | ||
| 168 | + for(; node != null; node = node.next){ | ||
| 169 | + /* | ||
| 170 | + * 对于一个节点,先根据头2个字符判断是否属于这个节点。 | ||
| 171 | + * 如果属于这个节点,看这个节点的词库是否命中。 | ||
| 172 | + * 此代码块中访问次数已经很少,不是优化重点 | ||
| 173 | + */ | ||
| 174 | + if(node.headTwoCharMix == mix){ | ||
| 175 | + /* | ||
| 176 | + * 查出比剩余sentence小的最大的词。 | ||
| 177 | + * 例如剩余sentence为"色情电影哪家强?", | ||
| 178 | + * 这个节点含三个词从小到大为:“色情”、“色情电影”、“色情信息”。 | ||
| 179 | + * 则取到的word为“色情电影” | ||
| 180 | + */ | ||
| 181 | + StringPointer word = node.words.floor(sp.substring(i)); | ||
| 182 | + /* | ||
| 183 | + * 仍然需要再判断一次,例如“色情信息哪里有?”, | ||
| 184 | + * 如果节点只包含“色情电影”一个词, | ||
| 185 | + * 仍然能够取到word为“色情电影”,但是不该匹配。 | ||
| 186 | + */ | ||
| 187 | + if(word != null && sp.nextStartsWith(i, word)){ | ||
| 188 | + // 匹配成功,将匹配的部分,用replace制定的内容替代 | ||
| 189 | + sp.fill(i, i + word.length, replace); | ||
| 190 | + // 跳过已经替代的部分 | ||
| 191 | + step = word.length; | ||
| 192 | + // 标示有替换 | ||
| 193 | + replaced = true; | ||
| 194 | + // 跳出for循环(然后是while循环的下一个位置) | ||
| 195 | + break; | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + // 移动到下一个匹配位置 | ||
| 202 | + i += step; | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + // 如果没有替换,直接返回入参(节约String的构造copy) | ||
| 206 | + if(replaced){ | ||
| 207 | + return sp.toString(); | ||
| 208 | + }else{ | ||
| 209 | + return sentence; | ||
| 210 | + } | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | +} |
| 1 | +package com.odianyun.util.sensi; | ||
| 2 | + | ||
| 3 | +import java.io.Serializable; | ||
| 4 | +import java.util.TreeSet; | ||
| 5 | + | ||
| 6 | +/** | ||
| 7 | + * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词 | ||
| 8 | + * | ||
| 9 | + * @author ZhangXiaoye | ||
| 10 | + * @date 2017年1月5日 下午5:06:26 | ||
| 11 | + */ | ||
| 12 | +public class SensitiveNode implements Serializable{ | ||
| 13 | + | ||
| 14 | + private static final long serialVersionUID = 1L; | ||
| 15 | + | ||
| 16 | + /** | ||
| 17 | + * 头两个字符的mix,mix相同,两个字符相同 | ||
| 18 | + */ | ||
| 19 | + protected final int headTwoCharMix; | ||
| 20 | + | ||
| 21 | + /** | ||
| 22 | + * 所有以这两个字符开头的词表 | ||
| 23 | + */ | ||
| 24 | + protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>(); | ||
| 25 | + | ||
| 26 | + /** | ||
| 27 | + * 下一个节点 | ||
| 28 | + */ | ||
| 29 | + protected SensitiveNode next; | ||
| 30 | + | ||
| 31 | + public SensitiveNode(int headTwoCharMix){ | ||
| 32 | + this.headTwoCharMix = headTwoCharMix; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + public SensitiveNode(int headTwoCharMix, SensitiveNode parent){ | ||
| 36 | + this.headTwoCharMix = headTwoCharMix; | ||
| 37 | + parent.next = this; | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | +} |
| 1 | +package com.odianyun.util.sensi; | ||
| 2 | + | ||
| 3 | +import java.io.Serializable; | ||
| 4 | +import java.util.HashMap; | ||
| 5 | +import java.util.TreeMap; | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * 没有注释的方法与{@link String}类似<br/> | ||
| 9 | + * <b>注意:</b>没有(数组越界等的)安全检查<br/> | ||
| 10 | + * 可以作为{@link HashMap}和{@link TreeMap}的key | ||
| 11 | + * | ||
| 12 | + * @author ZhangXiaoye | ||
| 13 | + * @date 2017年1月5日 下午2:11:56 | ||
| 14 | + */ | ||
| 15 | +public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{ | ||
| 16 | + | ||
| 17 | + private static final long serialVersionUID = 1L; | ||
| 18 | + | ||
| 19 | + protected final char[] value; | ||
| 20 | + | ||
| 21 | + protected final int offset; | ||
| 22 | + | ||
| 23 | + protected final int length; | ||
| 24 | + | ||
| 25 | + private int hash = 0; | ||
| 26 | + | ||
| 27 | + public StringPointer(String str){ | ||
| 28 | + value = str.toCharArray(); | ||
| 29 | + offset = 0; | ||
| 30 | + length = value.length; | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + public StringPointer(char[] value, int offset, int length){ | ||
| 34 | + this.value = value; | ||
| 35 | + this.offset = 0; | ||
| 36 | + this.length = value.length; | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + /** | ||
| 40 | + * 计算该位置后(包含)2个字符的hash值 | ||
| 41 | + * | ||
| 42 | + * @param i 从 0 到 length - 2 | ||
| 43 | + * @return hash值 | ||
| 44 | + * @author ZhangXiaoye | ||
| 45 | + * @date 2017年1月5日 下午2:23:02 | ||
| 46 | + */ | ||
| 47 | + public int nextTwoCharHash(int i){ | ||
| 48 | + return 31 * value[offset + i] + value[offset + i + 1]; | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + /** | ||
| 52 | + * 计算该位置后(包含)2个字符和为1个int型的值<br/> | ||
| 53 | + * int值相同表示2个字符相同 | ||
| 54 | + * | ||
| 55 | + * @param i 从 0 到 length - 2 | ||
| 56 | + * @return int值 | ||
| 57 | + * @author ZhangXiaoye | ||
| 58 | + * @date 2017年1月5日 下午2:46:58 | ||
| 59 | + */ | ||
| 60 | + public int nextTwoCharMix(int i){ | ||
| 61 | + return (value[offset + i] << 16) | value[offset + i + 1]; | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + /** | ||
| 65 | + * 该位置后(包含)的字符串,是否以某个词(word)开头 | ||
| 66 | + * | ||
| 67 | + * @param i 从 0 到 length - 2 | ||
| 68 | + * @param word 词 | ||
| 69 | + * @return 是否? | ||
| 70 | + * @author ZhangXiaoye | ||
| 71 | + * @date 2017年1月5日 下午3:13:49 | ||
| 72 | + */ | ||
| 73 | + public boolean nextStartsWith(int i, StringPointer word){ | ||
| 74 | + // 是否长度超出 | ||
| 75 | + if(word.length > length - i){ | ||
| 76 | + return false; | ||
| 77 | + } | ||
| 78 | + // 从尾开始判断 | ||
| 79 | + for(int c = word.length - 1; c >= 0; c --){ | ||
| 80 | + if(value[offset + i + c] != word.value[word.offset + c]){ | ||
| 81 | + return false; | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + return true; | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + /** | ||
| 88 | + * 填充(替换) | ||
| 89 | + * | ||
| 90 | + * @param begin 从此位置开始(含) | ||
| 91 | + * @param end 到此位置结束(不含) | ||
| 92 | + * @param fillWith 以此字符填充(替换) | ||
| 93 | + * @author ZhangXiaoye | ||
| 94 | + * @date 2017年1月5日 下午3:29:21 | ||
| 95 | + */ | ||
| 96 | + public void fill(int begin, int end, char fillWith){ | ||
| 97 | + for(int i = begin; i < end; i ++){ | ||
| 98 | + value[offset + i] = fillWith; | ||
| 99 | + } | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + public int length(){ | ||
| 103 | + return length; | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + public char charAt(int i){ | ||
| 107 | + return value[offset + i]; | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + public StringPointer substring(int begin){ | ||
| 111 | + return new StringPointer(value, offset + begin, length - begin); | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + public StringPointer substring(int begin, int end){ | ||
| 115 | + return new StringPointer(value, offset + begin, end - begin); | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + @Override | ||
| 119 | + public CharSequence subSequence(int start, int end) { | ||
| 120 | + return substring(start, end); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + public String toString(){ | ||
| 124 | + return new String(value, offset, length); | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + public int hashCode() { | ||
| 128 | + int h = hash; | ||
| 129 | + if (h == 0 && length > 0) { | ||
| 130 | + for (int i = 0; i < length; i++) { | ||
| 131 | + h = 31 * h + value[offset + i]; | ||
| 132 | + } | ||
| 133 | + hash = h; | ||
| 134 | + } | ||
| 135 | + return h; | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + public boolean equals(Object anObject) { | ||
| 139 | + if (this == anObject) { | ||
| 140 | + return true; | ||
| 141 | + } | ||
| 142 | + if (anObject instanceof StringPointer) { | ||
| 143 | + StringPointer that = (StringPointer)anObject; | ||
| 144 | + if (length == that.length) { | ||
| 145 | + char v1[] = this.value; | ||
| 146 | + char v2[] = that.value; | ||
| 147 | + for(int i = 0; i < this.length; i ++){ | ||
| 148 | + if(v1[this.offset + i] != v2[that.offset + i]){ | ||
| 149 | + return false; | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + return true; | ||
| 153 | + } | ||
| 154 | + } | ||
| 155 | + return false; | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + @Override | ||
| 159 | + public int compareTo(StringPointer that) { | ||
| 160 | + int len1 = this.length; | ||
| 161 | + int len2 = that.length; | ||
| 162 | + int lim = Math.min(len1, len2); | ||
| 163 | + char v1[] = this.value; | ||
| 164 | + char v2[] = that.value; | ||
| 165 | + | ||
| 166 | + int k = 0; | ||
| 167 | + while (k < lim) { | ||
| 168 | + char c1 = v1[this.offset + k]; | ||
| 169 | + char c2 = v2[that.offset + k]; | ||
| 170 | + if (c1 != c2) { | ||
| 171 | + return c1 - c2; | ||
| 172 | + } | ||
| 173 | + k++; | ||
| 174 | + } | ||
| 175 | + return len1 - len2; | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | +} |
src/main/resources/sensi_words.txt
0 → 100644
此 diff 太大无法显示。
| 1 | +package com.odianyun.util.sensi; | ||
| 2 | + | ||
| 3 | +import java.io.BufferedReader; | ||
| 4 | +import java.io.File; | ||
| 5 | +import java.io.FileInputStream; | ||
| 6 | +import java.io.InputStreamReader; | ||
| 7 | +import java.io.PrintStream; | ||
| 8 | +import java.util.ArrayList; | ||
| 9 | +import java.util.List; | ||
| 10 | + | ||
| 11 | +import junit.framework.TestCase; | ||
| 12 | + | ||
| 13 | +public class SensitiveFilterTest extends TestCase{ | ||
| 14 | + | ||
| 15 | + public void test() throws Exception{ | ||
| 16 | + | ||
| 17 | + SensitiveFilter filter = SensitiveFilter.DEFAULT; | ||
| 18 | + | ||
| 19 | + System.out.println(filter.filter("会上,主席进行了发言。", '*')); | ||
| 20 | + | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | + public void testSpeed() throws Exception{ | ||
| 24 | + | ||
| 25 | + PrintStream ps = new PrintStream("/data/敏感词替换结果.txt"); | ||
| 26 | + | ||
| 27 | + File dir = new File("/data/穿越小说2011-10-14"); | ||
| 28 | + | ||
| 29 | + List<String> testSuit = new ArrayList<String>(1048576); | ||
| 30 | + long length = 0; | ||
| 31 | + | ||
| 32 | + for(File file: dir.listFiles()){ | ||
| 33 | + if(file.isFile() && file.getName().endsWith(".txt")){ | ||
| 34 | + BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "gb18030")); | ||
| 35 | + for(String line = br.readLine(); line != null; line = br.readLine()){ | ||
| 36 | + if(line.trim().length() > 0){ | ||
| 37 | + testSuit.add(line); | ||
| 38 | + length += line.length(); | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | + br.close(); | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + System.out.println(String.format("共加载 %d 行,%d 字符。", testSuit.size(), length)); | ||
| 46 | + | ||
| 47 | + | ||
| 48 | + SensitiveFilter filter = SensitiveFilter.DEFAULT; | ||
| 49 | + | ||
| 50 | + int replaced = 0; | ||
| 51 | + | ||
| 52 | + for(String line: testSuit){ | ||
| 53 | + if(! line.contains("`")){ | ||
| 54 | + String result = filter.filter(line, '`'); | ||
| 55 | + if(result.contains("`")){ | ||
| 56 | + ps.println(line); | ||
| 57 | + ps.println(result); | ||
| 58 | + ps.println(); | ||
| 59 | + replaced ++; | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | + } | ||
| 63 | + ps.close(); | ||
| 64 | + | ||
| 65 | + long timer = System.currentTimeMillis(); | ||
| 66 | + for(String line: testSuit){ | ||
| 67 | + filter.filter(line, '*'); | ||
| 68 | + } | ||
| 69 | + timer = System.currentTimeMillis() - timer; | ||
| 70 | + System.out.println(String.format("共耗时 %1.3f 秒, 速度为 %1.1f字符/毫秒", timer * 1E-3, length / (double) timer)); | ||
| 71 | + System.out.println(String.format("其中 %d 行有替换", replaced)); | ||
| 72 | + | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | +} |
-
请 注册 或 登录 后发表评论