背景
项目中需要过滤用户发送的聊天文本, 由于敏感词有将近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. 逐个将分割后的次添加到树中
使用:
- 分割待处理词句
- 从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
参考文章
关键词过滤扩展,用于检查一段文本中是否出现敏感词,基于Double-Array Trie 树实现
↑ 现成的php扩展, 同时支持 php5、php7
-
↑ 深入浅出讲解
trie_filter扩展 + swoole 实现敏感词过滤
↑ 简单的php高性能实现方式
来源:https://www.cnblogs.com/youjiaxing/p/10458239.html