Trie樹 php 實現敏感詞過濾

Last-Modified: 2019年5月10日15:25:35php

參考文章

背景

項目中須要過濾用戶發送的聊天文本, 因爲敏感詞有將近2W條, 若是用 str_replace 來處理會炸掉的. github

網上了解了一下, 在性能要求不高的狀況下, 能夠自行構造 Trie樹(字典樹), 這就是本文的由來.redis

簡介

Trie樹是一種搜索樹, 也叫字典樹、單詞查找樹.算法

DFA能夠理解爲DFA(Deterministic Finite Automaton), 即json

這裏借用一張圖來解釋Trie樹的結構:數組

img

Trie能夠理解爲肯定有限狀態自動機,即DFA。在Trie樹中,每一個節點表示一個狀態,每條邊表示一個字符,從根節點到葉子節點通過的邊即表示一個詞條。查找一個詞條最多耗費的時間只受詞條長度影響,所以Trie的查找性能是很高的,跟哈希算法的性能至關。

上面實際保存了緩存

abcd
abd
b
bcd
efg
hij

特色:

  • 全部詞條的公共前綴只存儲一份
  • 只需遍歷一次待檢測文本
  • 查找消耗時間只跟待檢測文本長度有關, 跟字典大小無關

存儲結構

PHP

在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"
}

示例代碼 php

構建:

1. 分割敏感詞
    2. 逐個將分割後的次添加到樹中

使用:

  1. 分割待處理詞句
  2. 從Trie樹根節點開始逐個匹配
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 不到,

注意:

  • memcached 默認會自動序列化緩存的數組(serialize), 取出時自動反序列化(unserialize)
  • 如果redis, 則須要手動, 可選擇 json 存取

序列化上述生成的Trie數組後的字符長度:

  • serialize: 426KB
  • json: 241KB

提示: 所以若整個字典過大, 致使存入memcached時超出單個value大小限制時(默認是1M), 能夠考慮手動 json 序列化數組再保存.

↑ ...剛發現memcache存入value時提供壓縮功能, 能夠考慮使用

常駐服務

如果將過濾敏感字功能獨立爲一個常駐內存的服務, 則構建字典樹這個過程只須要1次, 後續值須要處理過濾文本的請求便可.

若是是PHP, 能夠考慮使用 Swoole

因爲項目當前敏感詞詞庫僅2W條左右, 並且訪問瓶頸並不在此, 所以暫時使用上述方案.

ab測試時單個

如果詞庫達上百萬條, 那估計得考慮一下弄成常駐內存的服務了

這裏有一篇 文章 測試了使用 Swoole(swoole_http_server) + trie-filter 擴展, 詞庫量級200W

相關文章
相關標籤/搜索