[TOC]php
项目中须要过滤用户发送的聊天文本, 因为敏感词有将近2W条, 若是用 str_replace
来处理会炸掉的.html
网上了解了一下, 在性能要求不高的状况下, 能够自行构造 Trie树(字典树), 这就是本文的由来.c++
Trie树是一种搜索树, 也叫字典树、单词查找树.git
DFA能够理解为DFA(Deterministic Finite Automaton), 即github
这里借用一张图来解释Trie树的结构:redis
Trie能够理解为肯定有限状态自动机,即DFA。在Trie树中,每一个节点表示一个状态,每条边表示一个字符,从根节点到叶子节点通过的边即表示一个词条。查找一个词条最多耗费的时间只受词条长度影响,所以Trie的查找性能是很高的,跟哈希算法的性能至关。算法
上面实际保存了json
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
关键词过滤扩展,用于检查一段文本中是否出现敏感词,基于Double-Array Trie 树实现
↑ 现成的php扩展, 同时支持 php五、php7
↑ 深刻浅出讲解
trie_filter扩展 + swoole 实现敏感词过滤
↑ 简单的php高性能实现方式