业务场景
- 在系统数据库中,可能存在一些用户名称,昵称,评论中有些词汇,出于保护隐私或是不符合规范等原因,不能直接展示在前端页面上,这样的敏感词需要用 * 号代替。
实现步骤
- 将系统需要替换的敏感词保存在数据库中,在项目启动后,获取敏感词库,保存到Redis缓存中。
- 编写敏感词序列化类 SensitiveWordSerializer 。
- 在需要敏感词过滤的实体类对应的字段上加上注解 @JSONField(serializeUsing = SensitiveWordSerializer.class)。
- 编写FastJson配置类 设置
fastjson
的全局序列化和反序列化的特性,使用FastJsonHttpMessageConverter
替换spring boot默认实现(MappingJackson2HttpMessageConverter
)作为HttpMessageConverters
的首选实现。
具体代码如下
需要的依赖坐标
<!-- fastjson 依赖 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<!-- redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
创建敏感词数据库
- 建库语句
DROP TABLE IF EXISTS `sys_sensitive_word`; CREATE TABLE `sys_sensitive_word` ( `id` varchar(100) NOT NULL COMMENT 'id', `name` varchar(100) DEFAULT NULL COMMENT '敏感词文本', `gmt_create` datetime DEFAULT NULL, `gmt_modified` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='敏感词表';
- 插入敏感词数据
- 编写相关的敏感词实体类,service,mapper等代码
@Data @TableName("sys_sensitive_word") public class SensitiveWord { @TableId(value = "id", type = IdType.ASSIGN_ID) private Integer id; @TableField("name") private String name; @ApiModelProperty(value = "创建时间") @TableField(fill = FieldFill.INSERT) private Date gmtCreate; @ApiModelProperty(value = "更新时间") @TableField(fill = FieldFill.INSERT_UPDATE) private Date gmtModified; }
编写敏感词初始化类
- 敏感词初始化类,负责读出数据库中的敏感词表,封装数据结构加载将redis中
/** * 敏感词库初始化并放入redis中 */ @Slf4j @Component public class SensitiveWordInit { public static final String SENSITIVE_WORD_KEY = "SENSITIVE_WORD_KEY"; /** * 敏感词库 */ private HashMap sensitiveWordMap; @Autowired private SensitiveWordService sensitiveWordService; private RedisTemplate<String, Object> redisTemplate; @Autowired public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } /** * 初始化敏感词 * * @return */ public Map<String,Object> initKeyWord() { try { List<SensitiveWord> sensitiveWords = sensitiveWordService.selectAll(); // 从敏感词集合对象中取出敏感词并封装到Set集合中 Set<String> keyWordSet = new HashSet(); for (SensitiveWord s : sensitiveWords) { keyWordSet.add(s.getName().trim()); } // 将敏感词库加入到HashMap中 addSensitiveWordToHashMap(keyWordSet); RedisOperation redisOperation = new RedisOperation(this.redisTemplate); redisOperation.set(SENSITIVE_WORD_KEY,sensitiveWordMap); } catch (Exception e) { log.error("初始化敏感词失败"); } return sensitiveWordMap; } public Map getSensitiveWordFromRedis() { RedisOperation redisOperation = new RedisOperation(this.redisTemplate); Map map = redisOperation.get(SENSITIVE_WORD_KEY); return map; } /** * 封装敏感词库 * * @param keyWordSet */ private void addSensitiveWordToHashMap(Set<String> keyWordSet) { // 初始化HashMap对象并控制容器的大小 sensitiveWordMap = new HashMap<String,Object>(keyWordSet.size()); // 敏感词 String key = null; // 用来按照相应的格式保存敏感词库数据 Map nowMap = null; // 用来辅助构建敏感词库 Map<String, String> newWorMap ; // 使用一个迭代器来循环敏感词集合 Iterator<String> iterator = keyWordSet.iterator(); while (iterator.hasNext()) { key = iterator.next(); // 等于敏感词库,HashMap对象在内存中占用的是同一个地址,所以此nowMap对象的变化,sensitiveWordMap对象也会跟着改变 nowMap = sensitiveWordMap; for (int i = MagicNum.ZERO; i < key.length(); i++) { // 截取敏感词当中的字,在敏感词库中字为HashMap对象的Key键值 char keyChar = key.charAt(i); // 判断这个字是否存在于敏感词库中 Object wordMap = nowMap.get(keyChar); if (wordMap != null) { nowMap = (Map) wordMap; } else { newWorMap = new HashMap(); newWorMap.put("isEnd", "0"); nowMap.put(keyChar, newWorMap); nowMap = newWorMap; // 如果该字是当前敏感词的最后一个字,则标识为结尾字 if (i == key.length() - MagicNum.ONE) { nowMap.put("isEnd", "1"); } } } }}}
- 附上上面涉及到的RedisOperation工具类
/** * 主要把一些常用的redis操作使用redisTemplate包装为redis命令名的方式 */ public final class RedisOperation { private static final String UNCHECKED = "unchecked"; private RedisTemplate<String, Object> redisTemplate; public RedisOperation(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } /** * 返回与键 key 相关联的值 */ @SuppressWarnings(UNCHECKED) public <V> V get(String key) { return (V) redisTemplate.opsForValue().get(key); } /** * 删除key */ public Boolean del(String key) { return redisTemplate.delete(key); } /** * 批量删除 */ public Long del(Collection<String> keys) { return redisTemplate.delete(keys); } /** * 返回给定的一个或多个字符串键的值 */ public List<Object> mget(String... keys) { return redisTemplate.opsForValue().multiGet(Arrays.asList(keys)); } /** * 将value对象序列化后的字符串 关联到key<br/> * 如果key已经持有其他值,SET就覆写旧值,无视类型。 */ public void set(String key, Object value) { redisTemplate.opsForValue().set(key, value); } /** * 同时为多个键设置值<br/> * 如果某个给定键已经存在,那么MSET将使用新值去覆盖旧值 */ public void mset(Map<String, Object> map) { redisTemplate.opsForValue().multiSet(map); } /** * 同时为多个键设置值<br/> * 如果键key已经存在,则MSETNX命令不做任何动作 */ public void msetnx(Map<String, Object> map) { redisTemplate.opsForValue().multiSetIfAbsent(map); } /** * 将键key的值设置为value对象的序列化字符串,并将键key的生存时间设置为timeout<br/> * 如果键key已经存在,那么将覆盖已有的值 */ public void setex(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } /** * 只在键key不存在的情况下, 将键key的值设置为value<br/> * 若键key已经存在,则SETNX命令不做任何动作 */ public void setnx(String key, Object value) { redisTemplate.opsForValue().setIfAbsent(key, value); } /** * 只在键key不存在的情况下,将键key的值设置为value,且同时设置过期时间<br/> * 若键key已经存在,则SETNX命令不做任何动作。 */ public void setnx(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); } /** * 为键key储存的数字值加上一 */ public Long incr(String key) { return incr(key, 1); } /** * 为键key储存的数字值加上增量increment */ public Long incr(String key, long increment) { return redisTemplate.opsForValue().increment(key, increment); } /** * 将键key储存的整数值减去减量decrement */ public Long decr(String key, long decrement) { return redisTemplate.opsForValue().decrement(key, decrement); } /** * 为键key储存的数字减一 */ public Long decr(String key) { return decr(key, 1); } /** * HGET命令在默认情况下返回给定域的值<br/> * 如果给定域不存在于哈希表中,又或者给定的哈希表并不存在,那么命令返回 nil */ @SuppressWarnings(UNCHECKED) public <V> V hget(String key, String field) { return (V) redisTemplate.opsForHash().get(key, field); } /** * 如果给定的域不存在于哈希表,那么返回一个nil值<br/> * 因为不存在的key被当作一个空哈希表来处理,所以对一个不存在的key进行HMGET操作将返回一个只带有 nil 值的表 */ public List<Object> hmget(String key, String... fields) { return redisTemplate.opsForHash().multiGet(key, new ArrayList<>(Arrays.asList(fields))); } /** * 返回哈希表 key 中,所有的域和值 */ public Map<Object, Object> hgetAll(String key) { return redisTemplate.opsForHash().entries(key); } /** * 如果给定的哈希表并不存在,那么一个新的哈希表将被创建并执行HSET操作<br/> * 如果域field已经存在于哈希表中, 那么它的旧值将被新值 value 覆盖。 */ public <V> void hset(String key, String field, V value) { redisTemplate.opsForHash().put(key, field, value); } /** * 同时将多个field-value(域-值)对设置到哈希表key中<br/> * 此命令会覆盖哈希表中已存在的域<br/> * 如果 key 不存在,一个空哈希表被创建并执行 HMSET 操作。 */ public void hmset(String key, Map<String, ?> data) { redisTemplate.opsForHash().putAll(key, data); } /** * 检查给定field是否存在于哈希表hash当中 */ public Boolean hexists(String key, String field) { return redisTemplate.opsForHash().hasKey(key, field); } /** * 删除哈希表 key 中的一个或多个指定域,不存在的域将被忽略 */ public Long hdel(String key, Object... fields) { return redisTemplate.opsForHash().delete(key, fields); } /** * 将一个或多个值 value 插入到列表 key 的表尾(最右边)<br/> * 如果 key 不存在,一个空列表会被创建并执行 LPUSH 操作<br /> * 当 key 存在但不是列表类型时,返回一个错误 */ public Long rpush(String key, Object... values) { return redisTemplate.opsForList().rightPushAll(key, values); } /** * 将一个或多个值 value 插入到列表 key 的表头<br /> * 如果 key 不存在,一个空列表会被创建并执行 LPUSH 操作<br /> * 当 key 存在但不是列表类型时,返回一个错误 */ public Long lpush(String key, Object... values) { return redisTemplate.opsForList().leftPushAll(key, values); } /** * 移除并返回列表 key 的头元素 */ @SuppressWarnings(UNCHECKED) public <V> V lpop(String key) { return (V) redisTemplate.opsForList().leftPop(key); } /** * 移除并返回列表 key 的尾元素 */ @SuppressWarnings(UNCHECKED) public <V> V rpop(String key) { return (V) redisTemplate.opsForList().rightPop(key); } /** * 将一个或多个 member 元素加入到集合 key 当中,已经存在于集合的 member 元素将被忽略 */ @SuppressWarnings(UNCHECKED) public <V> Long sadd(String key, V... members) { return redisTemplate.opsForSet().add(key, members); } /** * 判断 member 元素是否集合 key 的成员 */ public Boolean sismember(String key, Object member) { return redisTemplate.opsForSet().isMember(key, member); } /** * 返回集合中的一个随机元素 */ @SuppressWarnings(UNCHECKED) public <V> V srandmember(String key) { return (V) redisTemplate.opsForSet().randomMember(key); } /** * 返回集合中的随机元素。<p/> * 如果 count 为正数,且小于集合基数,那么命令返回一个包含 count 个元素的数组,数组中的元素各不相同。如果 count 大于等于集合基数,那么返回整个集合。<p/> * 如果 count 为负数,那么命令返回一个数组,数组中的元素可能会重复出现多次,而数组的长度为 count 的绝对值。 */ public List<Object> srandmembers(String key, long count) { return redisTemplate.opsForSet().randomMembers(key, count); } /** * 返回集合 key 中的所有成员。<br /> * 不存在的 key 被视为空集合 */ public Set<Object> smembers(String key) { return redisTemplate.opsForSet().members(key); } /** * 返回集合key的基数(集合中元素的数量) */ public Long scard(String key) { return redisTemplate.opsForSet().size(key); } /** * 移除集合key中的一个或多个member元素,不存在的member元素会被忽略。<br /> * 当key不是集合类型,返回一个错误 */ public Long srem(String key, Object... values) { return redisTemplate.opsForSet().remove(key, values); } /** * 将一个元素及其score值加入到有序集key当中 */ public <V> Boolean zadd(String key, V value, double score) { return redisTemplate.opsForZSet().add(key, value, score); } /** * 返回有序集key中,成员member的score值 */ public Double zscore(String key, Object member) { return redisTemplate.opsForZSet().score(key, member); } /** * 为有序集key的成员member的score值加上增量increment */ public Double zincrby(String key, Object member, double increment) { return redisTemplate.opsForZSet().incrementScore(key, member, increment); } /** * 返回有序集key中,指定区间内的成员 */ public Set<Object> zrange(String key, long start, long end) { return redisTemplate.opsForZSet().range(key, start, end); } /** * 返回有序集key中,指定区间内的成员。且带有score */ public Set<ZSetOperations.TypedTuple<Object>> zrangewithscore(String key, long start, long end) { return redisTemplate.opsForZSet().rangeWithScores(key, start, end); } /** * 返回有序集key中,所有score值介于min和max之间(包括等于min或max)的成员。<br /> * 有序集成员按score值递增(从小到大)次序排列 */ public Set<Object> zrangebyscore(String key, double min, double max) { return redisTemplate.opsForZSet().rangeByScore(key, min, max); } /** * 返回有序集key中,指定区间内的成员<br /> * 成员的位置按score值递减(从大到小)来排列 */ public Set<Object> zrevrange(String key, long start, long end) { return redisTemplate.opsForZSet().reverseRange(key, start, end); } /** * 返回有序集key中,指定区间内的成员。且带有score */ public Set<ZSetOperations.TypedTuple<Object>> zrevrangewithscores(String key, long start, long end) { return redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end); } /** * 返回有序集key中,所有score值介于min和max之间(包括等于min或max)的成员。<br /> * 有序集成员按score值递增(从大到小)次序排列 */ public Set<Object> zrevrangebyscore(String key, double min, double max) { return redisTemplate.opsForZSet().reverseRangeByScore(key, min, max); } /** * 移除有序集key中,所有score值介于min和max之间(包括等于min或max)的成员 */ public Long zremrangebyscore(String key, double min, double max) { return redisTemplate.opsForZSet().removeRangeByScore(key, min, max); } /** * 移除有序集key中,指定排名(rank)区间内的所有成员。 */ public Long zremrangebyrank(String key, long start, long end) { return redisTemplate.opsForZSet().removeRange(key, start, end); } /** * 移除有序集key中的一个或多个成员,不存在的成员将被忽略 */ public Long zrem(String key, Object... values) { return redisTemplate.opsForZSet().remove(key, values); } /** * 获取元素value在有序集合中的位置排名 */ public Long zrank(String key, Object value) { return redisTemplate.opsForZSet().reverseRank(key, value); } /** * 当key存在且是有序集类型时,返回有序集的基数。当key不存在时,返回0 */ public Long zcard(String key) { return redisTemplate.opsForZSet().size(key); } /** * 返回有序集key中,score值在min和max之间(默认包括score值等于min或max)的成员的数量 */ public Long zcount(String key, double min, double max) { return redisTemplate.opsForZSet().count(key, min, max); } /** * 设置过期时间 * * @param timeout 过期时间,单位毫秒 */ public Boolean expire(String key, long timeout) { return redisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS); } /** * 获取key的存活时长,单位毫秒 */ public Long ttl(String key) { return redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); } /** * 判断key是否存在 */ public Boolean exist(String key) { return redisTemplate.hasKey(key); } /** * 获取底层redisTemplate */ public RedisTemplate<String, Object> getRedisTemplate() { return redisTemplate; } }
编写敏感词序列化类
- 敏感词序列化类
/** * 敏感词过滤工具类 * 使用方法 @JSONField(serializeUsing = SensitiveWordSerializer.class) */ @Component public class SensitiveWordSerializer implements ObjectSerializer { /** * 敏感词库 */ private static Map<String,Object> sensitiveWordMap = null; private static SensitiveWordInit wordInit; @Autowired public void setRedisTemplate(SensitiveWordInit sensitiveWordInit) { SensitiveWordSerializer.wordInit = sensitiveWordInit; } @PostConstruct public void initSensitiveWord() { sensitiveWordMap = SensitiveWordSerializer.wordInit.initKeyWord(); } /** * 获取敏感词内容 * * @param txt * @param matchType * @return 敏感词内容 */ public Set<String> getSensitiveWord(String txt, int matchType) { Set<String> sensitiveWordList = new HashSet(); for (int i = MagicNum.ZERO; i < txt.length(); i++) { int length = checkSensitiveWord(txt, i, matchType); if (length > MagicNum.ZERO) { // 将检测出的敏感词保存到集合中 sensitiveWordList.add(txt.substring(i, i + length)); i = i + length - MagicNum.ONE; } } return sensitiveWordList; } /** * 替换敏感词 * * @param txt * @param matchType * @param replaceChar * @return */ public String replaceSensitiveWord(String txt, int matchType, String replaceChar) { sensitiveWordMap = wordInit.getSensitiveWordFromRedis(); String resultTxt = txt; Set<String> set = getSensitiveWord(txt, matchType); Iterator<String> iterator = set.iterator(); String word ; String replaceString ; while (iterator.hasNext()) { word = iterator.next(); replaceString = getReplaceChars(replaceChar, word.length()); resultTxt = resultTxt.replaceAll(word, replaceString); } return resultTxt; } /** * 替换敏感词内容 * * @param replaceChar * @param length * @return */ private String getReplaceChars(String replaceChar, int length) { StringBuilder resultReplace = new StringBuilder(replaceChar); for (int i = MagicNum.ONE; i < length; i++) { resultReplace.append(replaceChar); } return resultReplace.toString(); } /** * 检查敏感词数量 * * @param txt * @param beginIndex * @param matchType * @return */ public int checkSensitiveWord(String txt, int beginIndex, int matchType) { boolean flag = false; // 记录敏感词数量 int matchFlag = MagicNum.ZERO; String word ; Map<String,Object> nowMap = sensitiveWordMap; for (int i = beginIndex; i < txt.length(); i++) { word = String.valueOf(txt.charAt(i)); // 判断该字是否存在于敏感词库中 nowMap = (Map<String,Object>) nowMap.get(word); if (nowMap != null) { matchFlag++; // 判断是否是敏感词的结尾字,如果是结尾字则判断是否继续检测 if ("1".equals(nowMap.get("isEnd"))) { flag = true; // 判断过滤类型,如果是小过滤则跳出循环,否则继续循环 } } else { break; } } if (!flag) { matchFlag = 0; } return matchFlag; } @Override public void write(JSONSerializer serializer, Object object, Object fieldName, Type fieldType, int features) throws IOException { String value = replaceSensitiveWord((String) object, MagicNum.TWO, "*"); serializer.write(value); } }
在需要敏感词过滤的实体上加上注解
- 加上注解 @JSONField(serializeUsing = SensitiveWordSerializer.class)
@Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("sys_user") @ApiModel(description = "用户实体类") public class UserDTO { @TableId(value = "id", type = IdType.ASSIGN_ID) private String id; @TableField(value = "username") // username需要敏感词过滤 @JSONField(serializeUsing = SensitiveWordSerializer.class) private String username; //...... }
Fastjson配置类
- 设置
fastjson
的全局序列化和反序列化的特性,使用FastJsonHttpMessageConverter
替换spring boot默认实现(MappingJackson2HttpMessageConverter
)作为HttpMessageConverters
首选实现/** * Fastjson全局序列化配置 * */ @Configuration @Slf4j public class FastJsonConfig { private static final Map<SerializerFeature, String> SERIALIZER_FEATURES = new EnumMap<>(SerializerFeature.class); private static final Map<Feature, String> PARSER_FEATURES = new EnumMap<>(Feature.class); static { SERIALIZER_FEATURES.put(SerializerFeature.WriteMapNullValue, "WriteMapNullValue:输出值为null的字段"); SERIALIZER_FEATURES.put(SerializerFeature.WriteDateUseDateFormat, "WriteDateUseDateFormat:根据全局日期格式格式化"); SERIALIZER_FEATURES.put(SerializerFeature.WriteBigDecimalAsPlain, "WriteBigDecimalAsPlain:大数序列化为文本"); SERIALIZER_FEATURES.put(SerializerFeature.DisableCircularReferenceDetect, "DisableCircularReferenceDetect:关闭循环引用发现"); PARSER_FEATURES.put(Feature.AllowISO8601DateFormat, "AllowISO8601DateFormat:支持ISO8601格式的日期"); PARSER_FEATURES.put(Feature.DisableCircularReferenceDetect, "DisableCircularReferenceDetect:关闭循环引用发现"); log.debug("全局启用Fastjson下列序列化选项"); for (Map.Entry<SerializerFeature, String> entry : SERIALIZER_FEATURES.entrySet()) { JSON.DEFAULT_GENERATE_FEATURE |= entry.getKey().getMask(); log.debug(entry.getValue()); } log.debug("全局启用Fastjson下列反序列化选项"); for (Map.Entry<Feature, String> entry : PARSER_FEATURES.entrySet()) { JSON.DEFAULT_PARSER_FEATURE |= entry.getKey().getMask(); log.debug(entry.getValue()); } } @Bean public HttpMessageConverters fastJsonHttpMessageConverters() { log.debug("启用FastJsonHttpMessageConverter,将其添加至同类HttpMessageConverter之前"); return new HttpMessageConverters(new FastJsonHttpMessageConverter()); } }
执行效果
- 数据库中有一条用户数据,用户昵称为”笨蛋芝麻馅笨蛋“,”笨蛋“是我们的敏感词,启动项目,调用分页查询的接口,返回的用户昵称敏感词部分被*号代替:
- 数据库情况
- 调用接口查找用户信息结果,发现用户昵称中的敏感词成功被*号所代替。