敏感詞過濾功能在不少地方都會用到,理論上在Web應用中,只要涉及用戶輸入的地方,都須要進行文本校驗,如:XSS校驗、SQL注入檢驗、敏感詞過濾等。今天着重講講如何優雅高效地實現敏感詞過濾。git
敏感詞過濾方案一github
先講講筆者在上家公司是如何實現敏感詞過濾的。當時畢竟還年輕,因此使用的是最簡單的過濾方案。簡單來講就是對於要進行檢測的文本,遍歷全部敏感詞,逐個檢測輸入的文本中是否含有指定的敏感詞。這種方式是最簡單的敏感詞過濾方案了,實現起來不難,示例代碼以下:算法
@Test
public 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了。bash
敏感詞過濾方案二性能
在網上查了下敏感詞過濾方案,找到了一種名爲DFA的算法,即Deterministic Finite Automaton算法,翻譯成中文就是肯定有窮自動機算法。它的基本思想是基於狀態轉移來檢索敏感詞,只須要掃描一次待檢測文本,就能對全部敏感詞進行檢測,因此效率比方案一高很多。測試
假設咱們有如下5個敏感詞須要檢測:傻逼、傻子、傻大個、壞蛋、壞人。那麼咱們能夠先把敏感詞中有相同前綴的詞組合成一個樹形結構,不一樣前綴的詞分屬不一樣樹形分支,以上述5個敏感詞爲例,能夠初始化成以下2棵樹:ui
把敏感詞組成成樹形結構有什麼好處呢?最大的好處就是能夠減小檢索次數,咱們只須要遍歷一次待檢測文本,而後在敏感詞庫中檢索出有沒有該字符對應的子樹就好了,若是沒有相應的子樹,說明當前檢測的字符不在敏感詞庫中,則直接跳過繼續檢測下一個字符;若是有相應的子樹,則接着檢查下一個字符是否是前一個字符對應的子樹的子節點,這樣迭代下去,就能找出待檢測文本中是否包含敏感詞了。this
咱們以文本「你是否是傻逼」爲例,咱們依次檢測每一個字符,由於前4個字符都不在敏感詞庫裏,找不到相應的子樹,因此直接跳過。當檢測到「傻」字時,發現敏感詞庫中有相應的子樹,咱們把他記爲tree-1,接着再搜索下一個字符「逼」是否是子樹tree-1的子節點,發現剛好是,接下來再判斷「逼」這個字符是否是葉子節點,若是是,則說明匹配到了一個敏感詞了,在這裏「逼」這個字符恰好是tree-1的葉子節點,因此成功檢索到了敏感詞:「傻逼」。你們發現了沒有,在咱們的搜索過程當中,咱們只須要掃描一次被檢測文本就好了,並且對於被檢測文本中不存在的敏感詞,如這個例子中的「壞蛋」和「壞人」,咱們徹底不會掃描到,所以相比方案一效率大大提高了。spa
在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){ //敏感詞長度必須大於等於2
continue;
}
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逼」這種中文跟拼音混合的方式輕鬆繞過,對於這種狀況我目前還沒想到比較好的解決方案,有想法的讀者能夠在文末留言。