如何优雅地过滤敏感词
敏感詞過濾功能在很多地方都會用到,理論上在Web應用中,只要涉及用戶輸入的地方,都需要進行文本校驗,如:XSS校驗、SQL注入檢驗、敏感詞過濾等。今天著重講講如何優雅高效地實現敏感詞過濾。
敏感詞過濾方案一
先講講筆者在上家公司是如何實現敏感詞過濾的。當時畢竟還年輕,所以使用的是最簡單的過濾方案。簡單來說就是對于要進行檢測的文本,遍歷所有敏感詞,逐個檢測輸入的文本中是否含有指定的敏感詞。這種方式是最簡單的敏感詞過濾方案了,實現起來不難,示例代碼如下:
@Testpublic void test1(){Set<String> sensitiveWords=new HashSet<>();sensitiveWords.add("shit");sensitiveWords.add("傻逼");sensitiveWords.add("笨蛋");String text="你是傻逼啊";for(String sensitiveWord:sensitiveWords){if(text.contains(sensitiveWord)){System.out.println("輸入的文本存在敏感詞。——"+sensitiveWord);break;}}}代碼十分簡單,也確實能夠滿足要求。但是這個方案有一個很大的問題是,隨著敏感詞數量的增多,敏感詞檢測的時間會呈線性增長。由于之前的項目的敏感詞數量只有幾十個,所以使用這種方案不會存在太大的性能問題。但是如果項目中有成千上萬個敏感詞,使用這種方案就會很耗CPU了。
敏感詞過濾方案二
在網上查了下敏感詞過濾方案,找到了一種名為DFA的算法,即Deterministic Finite Automaton算法,翻譯成中文就是確定有窮自動機算法。它的基本思想是基于狀態轉移來檢索敏感詞,只需要掃描一次待檢測文本,就能對所有敏感詞進行檢測,所以效率比方案一高不少。
假設我們有以下5個敏感詞需要檢測:傻逼、傻子、傻大個、壞蛋、壞人。那么我們可以先把敏感詞中有相同前綴的詞組合成一個樹形結構,不同前綴的詞分屬不同樹形分支,以上述5個敏感詞為例,可以初始化成如下2棵樹:
?
把敏感詞組成成樹形結構有什么好處呢?最大的好處就是可以減少檢索次數,我們只需要遍歷一次待檢測文本,然后在敏感詞庫中檢索出有沒有該字符對應的子樹就行了,如果沒有相應的子樹,說明當前檢測的字符不在敏感詞庫中,則直接跳過繼續檢測下一個字符;如果有相應的子樹,則接著檢查下一個字符是不是前一個字符對應的子樹的子節點,這樣迭代下去,就能找出待檢測文本中是否包含敏感詞了。
我們以文本“你是不是傻逼”為例,我們依次檢測每個字符,因為前4個字符都不在敏感詞庫里,找不到相應的子樹,所以直接跳過。當檢測到“傻”字時,發現敏感詞庫中有相應的子樹,我們把他記為tree-1,接著再搜索下一個字符“逼”是不是子樹tree-1的子節點,發現恰好是,接下來再判斷“逼”這個字符是不是葉子節點,如果是,則說明匹配到了一個敏感詞了,在這里“逼”這個字符剛好是tree-1的葉子節點,所以成功檢索到了敏感詞:“傻逼”。大家發現了沒有,在我們的搜索過程中,我們只需要掃描一次被檢測文本就行了,而且對于被檢測文本中不存在的敏感詞,如這個例子中的“壞蛋”和“壞人”,我們完全不會掃描到,因此相比方案一效率大大提升了。
在Java中,我們可以用HashMap來存儲上述的樹形結構,還是以上述敏感詞為例,我們把每個敏感詞字符串拆散成字符,再存儲到HashMap中,可以這樣存:
{"傻": {"逼": {"isEnd": "Y"},"子": {"isEnd": "Y"},"大": {"個": {"isEnd": "Y"}}} }首先將每個詞的第一個字符作為key,value則是另一個HashMap,value對應的HashMap的key為第二個字符,如果還有第三個字符,則存儲到以第二個字符為key的value中,當然這個value還是一個HashMap,以此類推下去,直到最后一個字符,當然最后一個字符對應的value也是HashMap,只不過這個HashMap只需要存儲一個結束標志就行了,像上述的例子中,我們就存了一個{"isEnd","Y"}的HashMap,來表示這個value對應的key是敏感詞的最后一個字符。
同理,“壞人”和“壞蛋”這2個敏感詞也是按這樣的方式存儲起來,這里就不羅列出來了。
用HashMap存儲有什么好處呢?我們知道HashMap在理想情況下可以以O(1)的時間復雜度進行查詢,所以我們在遍歷待檢測字符串的過程中,可以以O(1)的時間復雜度檢索出當前字符是否在敏感詞庫中,效率比方案一提升太多了。
接下來上代碼。
首先是初始化敏感詞庫:
private Map<Object,Object> sensitiveWordsMap;private static final String END_FLAG="end";private void initSensitiveWordsMap(Set<String> sensitiveWords){if(sensitiveWords==null||sensitiveWords.isEmpty()){throw new IllegalArgumentException("Senditive words must not be empty!");}sensitiveWordsMap=new HashMap<>(sensitiveWords.size());String currentWord;Map<Object,Object> currentMap;Map<Object,Object> subMap;Iterator<String> iterator = sensitiveWords.iterator();while (iterator.hasNext()){currentWord=iterator.next();if(currentWord==null||currentWord.trim().length()<2){ //敏感詞長度必須大于等于2continue;}currentMap=sensitiveWordsMap;for(int i=0;i<currentWord.length();i++){char c=currentWord.charAt(i);subMap=(Map<Object, Object>) currentMap.get(c);if(subMap==null){subMap=new HashMap<>();currentMap.put(c,subMap);currentMap=subMap;}else {currentMap= subMap;}if(i==currentWord.length()-1){//如果是最后一個字符,則put一個結束標志,這里只需要保存key就行了,value為null可以節省空間。//如果不是最后一個字符,則不需要存這個結束標志,同樣也是為了節省空間。currentMap.put(END_FLAG,null);}}}}代碼的邏輯上面已經說過了,就是循環敏感詞集合,將他們放到HashMap中,這里不再贅述。
接下來是敏感詞的掃描:
public enum MatchType {MIN_MATCH("最小匹配規則"),MAX_MATCH("最大匹配規則");String desc;MatchType(String desc) {this.desc = desc;} }public Set<String> getSensitiveWords(String text,MatchType matchType){if(text==null||text.trim().length()==0){throw new IllegalArgumentException("The input text must not be empty.");}Set<String> sensitiveWords=new HashSet<>();for(int i=0;i<text.length();i++){int sensitiveWordLength = getSensitiveWordLength(text, i, matchType);if(sensitiveWordLength>0){String sensitiveWord = text.substring(i, i + sensitiveWordLength);sensitiveWords.add(sensitiveWord);if(matchType==MatchType.MIN_MATCH){break;}i=i+sensitiveWordLength-1;}}return sensitiveWords;}public int getSensitiveWordLength(String text,int startIndex,MatchType matchType){if(text==null||text.trim().length()==0){throw new IllegalArgumentException("The input text must not be empty.");}char currentChar;Map<Object,Object> currentMap=sensitiveWordsMap;int wordLength=0;boolean endFlag=false;for(int i=startIndex;i<text.length();i++){currentChar=text.charAt(i);Map<Object,Object> subMap=(Map<Object,Object>) currentMap.get(currentChar);if(subMap==null){break;}else {wordLength++;if(subMap.containsKey(END_FLAG)){endFlag=true;if(matchType==MatchType.MIN_MATCH){break;}else {currentMap=subMap;}}else {currentMap=subMap;}}}if(!endFlag){wordLength=0;}return wordLength;}其中,MatchType表示匹配規則,有時候我們只需要找到一個敏感詞就可以了,有時候則需要知道待檢測文本中到底包含多少個敏感詞,前者對應的是最小匹配原則,后者則是最大匹配原則。
getSensitiveWordLength方法的作用是根據給定的待檢測文本及起始下標,還有匹配規則,計算出待檢測文本中的敏感詞長度,如果不存在,則返回0,存在則返回匹配到的敏感詞長度。
getSensitiveWords方法則是掃描一遍待檢測文本,逐個檢測每個字符是否在敏感詞庫中,然后將檢測到的敏感詞截取出來放到集合中返回給客戶端。
最后寫個測試用例測一下:
public static void main(String[] args) {Set<String> sensitiveWords=new HashSet<>();sensitiveWords.add("你是傻逼");sensitiveWords.add("你是傻逼啊");sensitiveWords.add("你是壞蛋");sensitiveWords.add("你個大笨蛋");sensitiveWords.add("我去年買了個表");sensitiveWords.add("shit");TextFilter textFilter=new TextFilter();textFilter.initSensitiveWordsMap(sensitiveWords);String text="你你你你是傻逼啊你,說你呢,你個大笨蛋。";System.out.println(textFilter.getSensitiveWords(text,MatchType.MAX_MATCH));}結果輸出如下:
可以看到,我們成功地過濾出了敏感詞。
敏感詞過濾方案三
方案二在性能上已經可以滿足需求了,但是卻很容易被破解,比如說,我在待檢測文本中的敏感詞中間加個空格,就可以成功繞過了。要解決這個問題也不難,有一個簡單的方法是初始化一個無效字符庫,比如:空格、*、#、@等字符,然后在檢測文本前,先將待檢測文本中的無效字符去除,這樣的話被檢測字符就不存在這些無效字符了,因此還是可以繼續用方案二進行過濾。只要被檢測文本不要太長,那么我們只要在方案二的基礎上再多掃描一次被檢測文本去除無效字符就行了,這個性能損耗也還是可以接受的。
如果敏感詞是英文,則還要考慮大小寫的問題。有一個比較簡單的解決方案是在初始化敏感詞時,將敏感詞都以小寫形式存儲。同時,在檢測文本時,也統一將待檢測文本轉化為小寫,這樣就能解決大小寫的問題了。
比較棘手的是中文跟拼音混合的情況,比如“傻逼”這個敏感詞,可以通過“sha逼”這種中文跟拼音混合的方式輕松繞過,對于這種情況我目前還沒想到比較好的解決方案,有想法的讀者可以在文末留言。
完整代碼:https://github.com/hzjjames/TextFilter?
?
總結
以上是生活随笔為你收集整理的如何优雅地过滤敏感词的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 视觉统计计数方案
- 下一篇: python time模块