需求
部分字段(可能为富文本)需要做敏感词过滤,敏感词词库由产品给出。
前置条件
项目使用的spring cloud全家桶,springboot版本2.1.0 ,项目中集成了spring Data elasticsearch 单独为一个子模块被各个服务引用,elasticsearch 插件版本为6.6.2,并且es插件中已经安装ik中文分词器
设计要求
1、敏感词能精确过滤,速度尽量快
2、如果敏感词有更新,要求能实时更新,并且更新过程尽量简单
实现过程
第一次方案设计
1、把敏感词导入数据库,再把数据库中的敏感词同步至ES
2、如果敏感词有更新,则通过任务调度器把数据同步至ES
3、使用IK分词器对输入文本进行分词,分词结果在ES中进行匹配查找,其中同步至ES的敏感词类型设置为 keyword,查找匹配使用termsQuery等精确匹配
测试结果:
1、ik分词器分词不理想,输入一段文本,由于IK分词器没有按照预期的分词结果进行分词,导致没有过滤出敏感词
2、teamsQuery默认一次只能输入1024个词,超过则会报错,尝试修改ES设置,增大参数,但是按照网上查找的资料设置失败。修复方案为写一个IK分词器工具类,对输入的文本进行分词检查,如果分词结果大于1024个,则去重后分组匹配,最后把结果放入一个list返回
过程要点
设置字段类型为Keyword,设置为keyword类型,则在索引过程中不分再词
@Field(type = FieldType.Keyword)
private String word;
IK分词工具类:
public List<String> getAnalyzes(String index, String analyzer, String text) {
//调用ES客户端分词器进行分词
AnalyzeRequestBuilder ikRequest = new AnalyzeRequestBuilder(elasticsearchTemplate.getClient(),
AnalyzeAction.INSTANCE, index, text).setAnalyzer(analyzer);
List<AnalyzeResponse.AnalyzeToken> ikTokenList = ikRequest.execute().actionGet().getTokens();
// 赋值
List<String> searchTermList = new ArrayList<>();
if (ToolUtil.isNotEmpty(ikTokenList)) {
ikTokenList.forEach(ikToken -> {
searchTermList.add(ikToken.getTerm());
});
}
return searchTermList;
}
匹配方式:
//sensitives = getAnalyzes(...)
QueryBuilder queryBuilder = QueryBuilders.termsQuery("word", sensitives);
QueryBuilder queryBuilder = QueryBuilders.multiMatchQuery(word, "word").analyzer(analyzer).operator(Operator.OR);
term为精确匹配,即不分词,而带match的查询则是先分词,再进行匹配
同步数据至es
@Override
public void fetchFromMysql() {
log.info("从数据库同步敏感词至ES");
list = ;//查询数据库
if (CollectionUtil.isNotEmpty(list)) {
log.info("从数据库中查询到的敏感词总大小list.size={}", list.size());
repository.deleteAll();
repository.saveAll(list);
}
log.info("敏感词同步结束");
}
第二次方案优化
1、主要是对分词进行优化,考虑到IK分词器支持自定义字典,于是把敏感词在导入数据库的同时,也生成一份字典,并把它配置到ik分词器插件中,使用的是本地字典
测试结果
1、已经达到初步预期,输入文本能按照预期的敏感词进行分词,并且返回正确的结果
2、缺点也很明显,如果有敏感词更新,一是需要更新数据库,然后通过调度器同步至ES,二是要更新ik分词器的字典,并且要重启es才能生效,第二点是麻烦,生产环境ES不能随便重启
过程要点
1、创建字典 sensitive_word.dic,其中一个词一行
2、上传sensitive_word.dic至服务器es的插件安装目录,如
[root@izwz970jhtovyr0spqrk88z ~]# cd /data/env/es66/plugins/ik/config/
[root@izwz970jhtovyr0spqrk88z config]# ll
总用量 8304
-rw-r--r-- 1 root root 5225922 2月 19 00:07 extra_main.dic
-rw-r--r-- 1 root root 63188 2月 19 00:07 extra_single_word.dic
-rw-r--r-- 1 root root 63188 2月 19 00:07 extra_single_word_full.dic
-rw-r--r-- 1 root root 10855 2月 19 00:07 extra_single_word_low_freq.dic
-rw-r--r-- 1 root root 156 2月 19 00:07 extra_stopword.dic
-rw-r--r-- 1 root root 700 4月 9 10:24 IKAnalyzer.cfg.xml
-rw-r--r-- 1 root root 3058510 2月 19 00:07 main.dic
-rw-r--r-- 1 root root 123 2月 19 00:07 preposition.dic
-rw-r--r-- 1 root root 1824 2月 19 00:07 quantifier.dic
-rw-r--r-- 1 root root 41628 4月 8 11:51 sensitive_word.dic
-rw-r--r-- 1 root root 164 2月 19 00:07 stopword.dic
-rw-r--r-- 1 root root 192 2月 19 00:07 suffix.dic
-rw-r--r-- 1 root root 752 2月 19 00:07 surname.dic
3、修改IKAnalyzer.cfg.xml文件,去掉扩展字典的注释,并赋值为上面上传的字典名称,如下示例:
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict">sensitive_word.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords"></entry>
<!--用户可以在这里配置远程扩展字典 -->
<!--<entry key="remote_ext_dict"></entry>-->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>
4、重启es
第三次方案优化
1、主要对第二次优化方案的缺点进行改进,通过查找文档发现使用IK分词器的远程字典,可以达到热更新的效果
测试结果
1、分词结果已经达到预期
2、敏感词如果有更新,直接修改数据库的数据,然后通过调度器同步至ES即可
3、如果在生产过程中,ES崩溃重启,需要手动执行一次调度器同步数据至ES
过程要点
1、新增一个数据接口,数据接口返回为查询es上的敏感词,这里的坑是es的热更新请求是两次,第一次请求,检验响应的Header中的Last-Modified 和eTag是否有变化,有变化,再次请求,并且重新加载敏感词(ES的热更新监听是每分钟请求一次)
@GetMapping("/getSensitiveWords")
public void loadDic(HttpServletRequest request, HttpServletResponse response) {
log.debug("================检查敏感词是否更新=================");
Object object = RedisUtil.get(CacheConst.SENSITIVE_WORD_IS_UPDATED);
String content = "";
//ES进行两次请求,第一次校验Last-Modified 和eTag,如果这两个有变化,就会进行第二次请求,第二次请求时,才会把内容写入
SensitiveWordResponseVO vo;
if (ToolUtil.isNotEmpty(object)) {
vo = (SensitiveWordResponseVO) object;
log.info("【检查敏感词是否更新】,当前vo={}", JsonUtil.toJson(vo));
if (vo.getUpdateTime() == 2) {
log.debug("【检查敏感词是否更新】vo.getUpdateTime=1");
}
if (vo.getUpdateTime() == 1) {
log.debug("【检查敏感词是否更新】vo.getUpdateTime=1");
content = updateWords(content);
} else {
log.debug("【检查敏感词是否更新】vo.getUpdateTime={}", vo.getUpdateTime());
}
} else {
vo = new SensitiveWordResponseVO();
log.debug("【检查敏感词是否更新】通过redis未查找到对应vo");
content = updateWords(content);
vo.setUpdateTime(2);
long now = System.currentTimeMillis();
vo.setETag(String.valueOf(now));
vo.setLastModified(String.valueOf(now));
log.debug("【检查敏感词是否更新】更新redis完成,vo={}", vo);
}
OutputStream out = null;
try {
log.debug("【检查敏感词是否更新】执行response");
out = response.getOutputStream();
response.setHeader("Last-Modified", vo.getLastModified());
response.setHeader("ETag", vo.getETag());
response.setContentType("text/plain; charset=utf-8");
out.write(content.getBytes("utf-8"));
out.flush();
} catch (IOException e) {
log.error("【检查敏感词是否更新】catch IOException={}", e);
} finally {
vo.setUpdateTime(vo.getUpdateTime() - 1);
if (vo.getUpdateTime() >= 0) {
RedisUtil.set(CacheConst.SENSITIVE_WORD_IS_UPDATED, vo, CacheConst.TTL_YEAR);
}
log.debug("【检查敏感词是否更新】执行response finally vo={}", vo);
if (null != out) {
try {
out.close();
} catch (IOException e) {
log.error("【检查敏感词是否更新】catch IOException={}", e);
}
}
}
}
private String updateWords(String content) {
list =;//查询es数据
if (CollectionUtil.isNotEmpty(list)) {
List<String> words = list.stream().map(XXX::getWord).distinct().collect(Collectors.toList());
content = StrUtil.join("\n", words);
}
return content;
}
2、调度器更新同步数据库数据至ES方法:
@Override
public void fetchFromMysql() {
log.info("从数据库同步敏感词至ES");
List<XXX> list = this.baseMapper.listDocument();
if (CollectionUtil.isNotEmpty(list)) {
log.info("从数据库中查询到的敏感词总大小list.size={}", list.size());
repository.deleteAll();
repository.saveAll(list);
SensitiveWordResponseVO vo = new SensitiveWordResponseVO();
vo.setUpdateTime(2);
long now = System.currentTimeMillis();
vo.setETag(String.valueOf(now));
vo.setLastModified(String.valueOf(now));
log.info("es同步完成,更新redis");
RedisUtil.set(CacheConst.SENSITIVE_WORD_IS_UPDATED, vo, CacheConst.TTL_YEAR);
log.info("更新redis完成,vo={}", vo);
}
log.info("敏感词同步结束");
}
总结
1、基本实现需求,过滤效果达到预期,并且速度也比较理想
2、加深了对es的使用