Last-Modified: 2019年5月10日15:25:35php
↑ 現成的php擴展, 同時支持 php五、php7html
↑ 深刻淺出講解c++
↑ 簡單的php高性能實現方式git
項目中須要過濾用戶發送的聊天文本, 因爲敏感詞有將近2W條, 若是用 str_replace
來處理會炸掉的. github
網上了解了一下, 在性能要求不高的狀況下, 能夠自行構造 Trie樹(字典樹), 這就是本文的由來.redis
Trie樹是一種搜索樹, 也叫字典樹、單詞查找樹.算法
DFA能夠理解爲DFA(Deterministic Finite Automaton), 即json
這裏借用一張圖來解釋Trie樹的結構:數組
Trie能夠理解爲肯定有限狀態自動機,即DFA。在Trie樹中,每一個節點表示一個狀態,每條邊表示一個字符,從根節點到葉子節點通過的邊即表示一個詞條。查找一個詞條最多耗費的時間只受詞條長度影響,所以Trie的查找性能是很高的,跟哈希算法的性能至關。
上面實際保存了緩存
abcd abd b bcd efg hij
特色:
在PHP中, 能夠很方便地使用數組來存儲樹形結構, 以如下敏感詞字典爲例:
大傻子 大傻 傻子
↑ 內容純粹是爲了舉例...遊戲聊天平常屏蔽內容
則存儲結構爲
{ "大": { "傻": { "end": true "子": { "end": true } } }, "傻": { "子": { "end": true }, } }
簡單點的能夠考慮使用 HashMap 之類的來實現
或者參考 這篇文章 , 使用 Four-Array Trie,Triple-Array Trie和Double-Array Trie 結構來設計(名稱與內部使用的數組個數有關)
不管是在構造字典樹或過濾敏感文本時, 都須要將其分割, 須要考慮到unicode字符
有一個簡單的方法:
$str = "a笨蛋123"; // 待分割的文本 $arr = preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY); // 分割後的文本 // 輸出 array(6) { [0]=> string(1) "a" [1]=> string(3) "笨" [2]=> string(3) "蛋" [3]=> string(1) "1" [4]=> string(1) "2" [5]=> string(1) "3" }
匹配規則需加
u
修飾符,/u
表示按unicode(utf-8)匹配(主要針對多字節好比漢字), 不然會沒法正常工做, 以下示例 ↓$str = "a笨蛋123"; // 待分割的文本 $arr = preg_split("//", $str, -1, PREG_SPLIT_NO_EMPTY); // 分割後的文本 // array(10) { [0]=> string(1) "a" [1]=> string(1) "�" [2]=> string(1) "�" [3]=> string(1) "�" [4]=> string(1) "�" [5]=> string(1) "�" [6]=> string(1) "�" [7]=> string(1) "1" [8]=> string(1) "2" [9]=> string(1) "3" }
構建:
1. 分割敏感詞 2. 逐個將分割後的次添加到樹中
使用:
class SensitiveWordFilter { protected $dict; protected $dictFile; /** * @param string $dictFile 字典文件路徑, 每行一句 */ public function __construct($dictFile) { $this->dictFile = $dictFile; $this->dict = []; } public function loadData($cache = true) { $memcache = new Memcache(); $memcache->pconnect("127.0.0.1", 11212); $cacheKey = __CLASS__ . "_" . md5($this->dictFile); if ($cache && false !== ($this->dict = $memcache->get($cacheKey))) { return; } $this->loadDataFromFile(); if ($cache) { $memcache->set($cacheKey, $this->dict, null, 3600); } } /** * 從文件加載字典數據, 並構建 trie 樹 */ public function loadDataFromFile() { $file = $this->dictFile; if (!file_exists($file)) { throw new InvalidArgumentException("字典文件不存在"); } $handle = @fopen($file, "r"); if (!is_resource($handle)) { throw new RuntimeException("字典文件沒法打開"); } while (!feof($handle)) { $line = fgets($handle); if (empty($line)) { continue; } $this->addWords(trim($line)); } fclose($handle); } /** * 分割文本(注意ascii佔1個字節, unicode...) * * @param string $str * * @return string[] */ protected function splitStr($str) { return preg_split("//u", $str, -1, PREG_SPLIT_NO_EMPTY); } /** * 往dict樹中添加語句 * * @param $wordArr */ protected function addWords($words) { $wordArr = $this->splitStr($words); $curNode = &$this->dict; foreach ($wordArr as $char) { if (!isset($curNode)) { $curNode[$char] = []; } $curNode = &$curNode[$char]; } // 標記到達當前節點完整路徑爲"敏感詞" $curNode['end']++; } /** * 過濾文本 * * @param string $str 原始文本 * @param string $replace 敏感字替換字符 * @param int $skipDistance 嚴格程度: 檢測時容許跳過的間隔 * * @return string 返回過濾後的文本 */ public function filter($str, $replace = '*', $skipDistance = 0) { $maxDistance = max($skipDistance, 0) + 1; $strArr = $this->splitStr($str); $length = count($strArr); for ($i = 0; $i < $length; $i++) { $char = $strArr[$i]; if (!isset($this->dict[$char])) { continue; } $curNode = &$this->dict[$char]; $dist = 0; $matchIndex = [$i]; for ($j = $i + 1; $j < $length && $dist < $maxDistance; $j++) { if (!isset($curNode[$strArr[$j]])) { $dist ++; continue; } $matchIndex[] = $j; $curNode = &$curNode[$strArr[$j]]; } // 匹配 if (isset($curNode['end'])) { // Log::Write("match "); foreach ($matchIndex as $index) { $strArr[$index] = $replace; } $i = max($matchIndex); } } return implode('', $strArr); } /** * 確認所給語句是否爲敏感詞 * * @param $strArr * * @return bool|mixed */ public function isMatch($strArr) { $strArr = is_array($strArr) ? $strArr : $this->splitStr($strArr); $curNode = &$this->dict; foreach ($strArr as $char) { if (!isset($curNode[$char])) { return false; } } // return $curNode['end'] ?? false; // php 7 return isset($curNode['end']) ? $curNode['end'] : false; } }
字典文件示例:
敏感詞1 敏感詞2 敏感詞3 ...
使用示例:
$filter = new SensitiveWordFilter(PATH_APP . '/config/dirty_words.txt'); $filter->loadData() $filter->filter("測試123文本",'*', 2)
原始敏感詞文件大小: 194KB(約20647行)
生成字典樹後佔用內存(約): 7MB
構建字典樹消耗時間: 140ms+ !!!
php 的內存佔用這點...先放着
構建字典樹消耗時間這點是能夠優化的: 緩存!
因爲php腳本不是常駐內存類型, 每次新的請求到來時都須要構建字典樹.
咱們經過將生成好的字典樹數組緩存(memcached 或 redis), 在後續請求中每次都從緩存中讀取, 能夠大大提升性能.
通過測試, 構建字典樹的時間從 140ms+ 下降到 6ms 不到,
注意:
序列化上述生成的Trie數組後的字符長度:
提示: 所以若整個字典過大, 致使存入memcached時超出單個value大小限制時(默認是1M), 能夠考慮手動 json 序列化數組再保存.
↑ ...剛發現memcache存入value時提供壓縮功能, 能夠考慮使用
如果將過濾敏感字功能獨立爲一個常駐內存的服務, 則構建字典樹這個過程只須要1次, 後續值須要處理過濾文本的請求便可.
若是是PHP, 能夠考慮使用 Swoole
因爲項目當前敏感詞詞庫僅2W條左右, 並且訪問瓶頸並不在此, 所以暫時使用上述方案.ab測試時單個
如果詞庫達上百萬條, 那估計得考慮一下弄成常駐內存的服務了
這裏有一篇 文章 測試了使用 Swoole(
swoole_http_server
) + trie-filter 擴展, 詞庫量級200W