zoukankan      html  css  js  c++  java
  • [原创] Trie树 php 实现敏感词过滤

    背景

    项目中需要过滤用户发送的聊天文本, 由于敏感词有将近2W条, 如果用 str_replace 来处理会炸掉的.

    网上了解了一下, 在性能要求不高的情况下, 可以自行构造 Trie树(字典树), 这就是本文的由来.

    简介

    Trie树是一种搜索树, 也叫字典树、单词查找树.

    DFA可以理解为DFA(Deterministic Finite Automaton), 即

    这里借用一张图来解释Trie树的结构:

    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

    参考文章

  • 相关阅读:
    CSS 字体
    列表排列
    IE6 fixed 页面抖动
    HTML中css和js链接中的版本号(刷新缓存)
    CSS3 box-shadow
    CSS3的文字阴影—text-shadow
    [LeetCode][JavaScript]Add Digits
    [LeetCode][JavaScript]Max Points on a Line
    [LeetCode][JavaScript]Subsets II
    [LeetCode][JavaScript]Subsets
  • 原文地址:https://www.cnblogs.com/youjiaxing/p/10458239.html
Copyright © 2011-2022 走看看