六脉神剑-我在公司造了六个轮子
前言
相信很多开发都会有自己造轮子的想法,毕竟只有提效了才能创造更多的摸鱼空间。我呢,不巧就是高效的选手,也有幸自己设计并开发了好多轮子,并成功推广给整个团队使用。如果有看过我前面文章的读者朋友,肯定了解博主的工作情况,本职是一线业务搬砖党,所以轮子都是我闲暇和周末时间自己慢慢积累起来。后来实在是太好用了,我就开始推广,人人提效,人人如龙。但这东西吧,啥都挺好,就有一点不好,自从大家开发效率都提升了之后,领导给的开发工时更短了,淦。不说这些难过的事了,其实就我个人而言,组件也是我学习成长过程中的见证者,从一开始的磕磕绊绊到现在的信手拈来,回看当年的提交记录时,依旧觉得很有意思。
本文不仅有所有组件的包结构简析,还有对核心功能的精讲,更有我特别整理的版本更新记录,而且我还特别把提交时间给捞出来了。更新记录捞出来呢,主要也是想让读者从变更的过程中,去了解我在造轮子过程中遇到的问题,一些挣扎和选型的过程。当然有一些之前提到过的组件,我偷懒啦,放了之前文章的链接,不然这一万多字的文章装不下了。写完全篇后发现没有放我小仓库的连接,捞一下gitee.com/cloudswzy/g…,给需要的读者们,里面有下面组件的部分功能抽取。
Tool-Box(工具箱)
包结构简析
├─annotation-注解
│ IdempotencyCheck.java-幂等性校验,带参数
│ JasyptField.java-加密字段,标记字段用
│ JasyptMethod.java-标记方法加密还是解密
│ LimitMethod.java-限流器
├─aop
│ IdempotencyCheckHandler.java-幂等性校验切面
│ JasyptHandler.java-数据加密切面
│ LimitHandler.java-基于漏斗思想的限流器
├─api
│ GateWayApi.java--对外接口请求
├─common
│ CheckUrlConstant.java--各个环境的接口请求链接常量
│ JasyptConstant.java--加密解密标识常量
├─config
│ SpringDataRedisConfig.java--SpringDataRedis配置类,包含jedis配置、spring-cache配置、redisTemplate配置--2.3.4版本废弃
│ CaffeineConfig.java--本地缓存caffeine通用配置
│ MyRedissonConfig.java--Redisson配置--2.3.4版本废弃
│ ThreadPoolConfig.java--线程池配置
│ ToolApplicationContextInitializer.java--启动后检查参数
│ ToolAutoConfiguration.java--统一注册BEAN
│ RedisPlusConfig.java--统一Redis入口配置类--2.3.4版本整合
├─exception
│ ToolException.java-工具箱异常
├─pojo
│ ├─message--邮件及消息通知用
│ │ EmailAttachmentParams.java
│ │ EmailBodyDTO.java
│ │ NoticeWechatDTO.java
│ └─user--用户信息提取
│ UserHrDTO.java
│ UserInfoDTO.java
├─properties--自定义spring配置参数提醒
│ ToolProperties.java
├─service
│ DateMybatisHandler.java--Mybatis扩展,用于日期字段增加时分秒
│ HrTool.java--OA信息查询
│ JasyptMybatisHandler.java--Mybatis扩展,整合Jasypt用于字段脱敏
│ LuaTool.java--redis的lua脚本工具
│ MessageTool.java--消息通知类
│ SpringTool.java--spring工具类 方便在非spring管理环境中获取bean
│ CachePlusTool.java--二级缓存工具类
└─util
MapUtil.java--Map自用工具类,用于切分Map支持多线程
核心功能点
缓存(Redis和Caffeine)
关联类SpringDataRedisConfig,CaffeineConfig,MyRedissonConfig
xml复制代码<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.21.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.1.18.RELEASE</version> <exclusions> <exclusion> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> </exclusion> <exclusion> <groupId>org.slf4j</groupId> <artifactId>jul-to-slf4j</artifactId> </exclusion> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </exclusion> <exclusion> <artifactId>lettuce-core</artifactId> <groupId>io.lettuce</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.3.2</version> </dependency> <!-- 不可升级,3.x以上最低jdk11--> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>2.9.3</version> </dependency>
关于依赖,说明一下情况,公司的框架提供的Spring Boot版本是2.1.X版本,spring-boot-starter-data-redis在2.X版本是默认使用lettuce,当然也是因为lettuce拥有比jedis更优异的性能。为什么这里排除了呢?原因是低版本下,lettuce存在断连问题,阿里云-通过客户端程序连接Redis,上面这篇文章关于客户端的推荐里面,理由写得很清楚了,就不细说了。但是我个人推荐引入Redisson,这是我目前用过最好用的Redis客户端。
ini复制代码import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import com.xx.tool.exception.ToolException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.interceptor.KeyGenerator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisClusterConfiguration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.jedis.JedisClientConfiguration; import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.util.StringUtils; import redis.clients.jedis.JedisPoolConfig; import java.time.Duration; import java.util.Arrays; /** * @Classname SpringDataRedisConfig * @Date 2021/3/25 17:53 * @Author WangZY * @Description SpringDataRedis配置类,包含jedis配置、spring-cache配置、redisTemplate配置 */ @Configuration public class SpringDataRedisConfig { @Autowired private ConfigurableEnvironment config; /** * 定义Jedis客户端,集群和单点同时存在时优先集群配置 */ @Bean public JedisConnectionFactory redisConnectionFactory() { String redisHost = config.getProperty("spring.redis.host"); String redisPort = config.getProperty("spring.redis.port"); String cluster = config.getProperty("spring.redis.cluster.nodes"); String redisPassword = config.getProperty("spring.redis.password"); JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); // 默认阻塞等待时间为无限长,源码DEFAULT_MAX_WAIT_MILLIS = -1L // 最大连接数, 根据业务需要设置,不能超过实例规格规定的最大连接数。 jedisPoolConfig.setMaxTotal(100); // 最大空闲连接数, 根据业务需要设置,不能超过实例规格规定的最大连接数。 jedisPoolConfig.setMaxIdle(60); // 关闭 testOn[Borrow|Return],防止产生额外的PING。 jedisPoolConfig.setTestOnBorrow(false); jedisPoolConfig.setTestOnReturn(false); JedisClientConfiguration jedisClientConfiguration = JedisClientConfiguration.builder().usePooling() .poolConfig(jedisPoolConfig).build(); if (StringUtils.hasText(cluster)) { // 集群模式 String[] split = cluster.split(","); RedisClusterConfiguration clusterServers = new RedisClusterConfiguration(Arrays.asList(split)); if (StringUtils.hasText(redisPassword)) { clusterServers.setPassword(redisPassword); } return new JedisConnectionFactory(clusterServers, jedisClientConfiguration); } else if (StringUtils.hasText(redisHost) && StringUtils.hasText(redisPort)) { // 单机模式 RedisStandaloneConfiguration singleServer = new RedisStandaloneConfiguration(redisHost, Integer.parseInt(redisPort)); if (StringUtils.hasText(redisPassword)) { singleServer.setPassword(redisPassword); } return new JedisConnectionFactory(singleServer, jedisClientConfiguration); } else { throw new ToolException("spring.redis.host及port或spring.redis.cluster" + ".nodes必填,否则不可使用RedisTool以及Redisson"); } } /** * 配置Spring-Cache内部使用Redis,配置序列化和过期时间 */ @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisSerializer<String> redisSerializer = new StringRedisSerializer(); Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper om = new ObjectMapper(); // 防止在序列化的过程中丢失对象的属性 om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); // 开启实体类和json的类型转换,该处兼容老版本依赖,不得修改 om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jackson2JsonRedisSerializer.setObjectMapper(om); // 配置序列化(解决乱码的问题) RedisCacheConfiguration config = RedisCacheConfiguration. defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)) .disableCachingNullValues()// 不缓存空值 .entryTtl(Duration.ofMinutes(30));//30分钟不过期 return RedisCacheManager .builder(connectionFactory) .cacheDefaults(config) .build(); } /** * @Author WangZY * @Date 2021/3/25 17:55 * @Description 如果配置了KeyGenerator ,在进行缓存的时候如果不指定key的话,最后会把生成的key缓存起来, * 如果同时配置了KeyGenerator和key则优先使用key。 **/ @Bean public KeyGenerator keyGenerator() { return (target, method, params) -> { StringBuilder key = new StringBuilder(); key.append(target.getClass().getSimpleName()).append("#").append(method.getName()).append("("); for (Object args : params) { key.append(args).append(","); } key.deleteCharAt(key.length() - 1); key.append(")"); return key.toString(); }; } /** * @Author WangZY * @Date 2021/7/2 11:50 * @Description springboot 2.2以下版本用,配置redis序列化 **/ @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializer json = new Jackson2JsonRedisSerializer(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); json.setObjectMapper(mapper); //注意编码类型 template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(json); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(json); template.afterPropertiesSet(); return template; } }
SpringDataRedisConfig的配置文件里面,对Jedis做了一个简单的配置,设置了最大连接数,阻塞等待时间默认无限长就不用配置了,除此之外对集群和单点的配置做了下封装。Spring-Cache也属于常用,由于其默认实现是依赖于本地缓存Caffeine,所以还是替换一下,并且重写了keyGenerator,让默认生成的key具有可读性。Spring-Cache和RedisTemplate的序列化配置相同,key采用String是为了在图形化工具查询时方便找到对应的key,value采用Jackson序列化是为了压缩数据同时也是官方推荐。
ini复制代码import com.xx.tool.exception.ToolException; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.ClusterServersConfig; import org.redisson.config.Config; import org.redisson.config.SingleServerConfig; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; /** * @Classname MyRedissonConfig * @Date 2021/6/4 14:04 * @Author WangZY * @Description Redisson配置 */ @Configuration public class MyRedissonConfig { @Autowired private ConfigurableEnvironment config; /** * 对 Redisson 的使用都是通过 RedissonClient 对象 */ @Bean(destroyMethod = "shutdown") // 服务停止后调用 shutdown 方法。 public RedissonClient redisson() { String redisHost = config.getProperty("spring.redis.host"); String redisPort = config.getProperty("spring.redis.port"); String cluster = config.getProperty("spring.redis.cluster.nodes"); String redisPassword = config.getProperty("spring.redis.password"); Config config = new Config(); //使用String序列化时会出现RBucket<Integer>转换异常 //config.setCodec(new StringCodec()); if (ObjectUtils.isEmpty(redisHost) && ObjectUtils.isEmpty(cluster)) { throw new ToolException("spring.redis.host及port或spring.redis.cluster" + ".nodes必填,否则不可使用RedisTool以及Redisson"); } else { if (StringUtils.hasText(cluster)) { // 集群模式 String[] split = cluster.split(","); List<String> servers = new ArrayList<>(); for (String s : split) { servers.add("redis://" + s); } ClusterServersConfig clusterServers = config.useClusterServers(); clusterServers.addNodeAddress(servers.toArray(new String[split.length])); if (StringUtils.hasText(redisPassword)) { clusterServers.setPassword(redisPassword); } //修改命令超时时间为40s,默认3s clusterServers.setTimeout(40000); //修改连接超时时间为50s,默认10s clusterServers.setConnectTimeout(50000); } else { // 单机模式 SingleServerConfig singleServer = config.useSingleServer(); singleServer.setAddress("redis://" + redisHost + ":" + redisPort); if (StringUtils.hasText(redisPassword)) { singleServer.setPassword(redisPassword); } singleServer.setTimeout(40000); singleServer.setConnectTimeout(50000); } } return Redisson.create(config); } }
Redisson没啥好说的,太香了,redisson官方中文文档,中文文档更新慢而且有错误,建议看英文的。这里配置很简单,主要是针对集群和单点还有超时时间做了封装,重点是学会怎么玩Redisson,下面给出分布式锁和缓存场景的代码案例。低版本下的SpringDataRedis我是真的不推荐使用,之前我也封装过RedisTemplate,但是后来发现Redisson性能更强,功能更丰富,所以直接转用Redisson,组件中也没有提供RedisTemplate的封装。
csharp复制代码@Autowired private RedissonClient redissonClient; //分布式锁 public void xxx(){ RLock lock = redissonClient.getLock("锁名"); boolean locked = lock.isLocked(); if (locked) { //被锁了 }else{ try { lock.lock(); //锁后的业务逻辑 } finally { lock.unlock(); } } } //缓存应用场景 public BigDecimal getIntervalQty(int itemId, Date startDate, Date endDate) { String cacheKey = "dashboard:intervalQty:" + itemId + "-" + startDate + "-" + endDate; RBucket<BigDecimal> bucket = redissonClient.getBucket(cacheKey); BigDecimal cacheValue = null; try { //更新避免Redis报错版本 cacheValue = bucket.get(); } catch (Exception e) { log.error("redis连接异常", e); } if (cacheValue != null) { return cacheValue; } else { BigDecimal intervalQty = erpInfoMapper.getIntervalQty(itemId, startDate, endDate); BigDecimal res = Optional.ofNullable(intervalQty).orElse(BigDecimal.valueOf(0)).setScale(2, RoundingMode.HALF_UP); bucket.set(res, 16, TimeUnit.HOURS); return res; } }
我是几个月前发现设置String序列化方式时,使用RBucket<>进行泛型转换会报类型转换错误的异常。官方在3.18.0版本才修复了这个问题,不过我推荐没有图形客户端可视化需求的使用默认编码即可,有更高的压缩率,并且目前使用没有出现过转换异常。
当下Redis可视化工具最推荐官方的RedisInsight-v2,纯免费、好用还持续更新,除此之外推荐使用Another Redis Desktop Manager。
本地缓存之王Caffeine,哈哈,不知道从哪看的了,反正就是牛。我参考官网WIKI的例子做了一个简单的封装吧,提供了一个能应付常见场景的实例可以直接使用,我个人更推荐根据实际场景自己新建实例。默认提供一个最多元素为10000,初始元素为1000,过期时间设置为16小时的缓存实例,使用方法如下。更多操作看官方文档,Population zh CN · ben-manes/caffeine Wiki。
typescript复制代码@Autowired @Qualifier("commonCaffeine") private Cache<String, Object> caffeine; Object countryObj = caffeine.getIfPresent("country"); if (Objects.isNull(countryObj)) { //缓存没有,从数据库获取并填入缓存 caffeine.put("country", country); return country; } else { //缓存有,直接强制转换后返回 return (Map<String, String>) countryObj; } import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration; /** * @author WangZY * @classname CaffeineConfig * @date 2022/5/31 16:37 * @description 本地缓存caffeine通用配置 */ @Configuration public class CaffeineConfig { @Bean public Cache<String, Object> commonCaffeine() { return Caffeine.newBuilder() //初始大小 .initialCapacity(1000) //PS:expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准。 //最后一次写操作后经过指定时间过期 // .expireAfterWrite(Duration.ofMinutes(30)) //最后一次读或写操作后经过指定时间过期 .expireAfterAccess(Duration.ofHours(16)) // 最大数量,默认基于缓存内的元素个数进行驱逐 .maximumSize(10000) //打开数据收集功能 hitRate(): 查询缓存的命中率 evictionCount(): 被驱逐的缓存数量 averageLoadPenalty(): 新值被载入的平均耗时 // .recordStats() .build(); //// 查找一个缓存元素, 没有查找到的时候返回null // Object obj = cache.getIfPresent(key); //// 查找缓存,如果缓存不存在则生成缓存元素, 如果无法生成则返回null // obj = cache.get(key, k -> createExpensiveGraph(key)); //// 添加或者更新一个缓存元素 // cache.put(key, graph); //// 移除一个缓存元素 // cache.invalidate(key); //// 批量失效key // cache.invalidateAll(keys) //// 失效所有的key // cache.invalidateAll() } }
Redis客户端整合
Redis断连从框架层面该如何抢救?,变更的原因见这篇文章,变更后版本号为2.3.4,文章历史记录保留。
Redis工具
基于漏斗思想的限流器
关联类LimitMethod,LimitHandler,LuaTool
java复制代码import com.xx.framework.base.config.BaseEnvironmentConfigration; import com.xx.tool.annotation.LimitMethod; import com.xx.tool.exception.ToolException; import com.xx.tool.service.LuaTool; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.After; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** * @Author WangZY * @Date 2022/2/21 17:21 * @Description 基于漏斗思想的限流器 **/ @Aspect @Component @Slf4j public class LimitHandler { @Autowired private LuaTool luaTool; @Autowired private BaseEnvironmentConfigration baseEnv; @Pointcut("@annotation(com.ruijie.tool.annotation.LimitMethod)") public void pointCut() { } @Around("pointCut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); LimitMethod limitMethod = methodSignature.getMethod().getAnnotation(LimitMethod.class); int limit = limitMethod.limit(); String application = baseEnv.getProperty("spring.application.name"); String methodName = methodSignature.getName(); //当没有自定义key时,给一个有可读性的默认值 String key = ""; if (ObjectUtils.isEmpty(application)) { throw new ToolException("当前项目必须拥有spring.application.name才能使用限流器"); } else { key = application + ":limit:" + methodName; } long judgeLimit = luaTool.judgeLimit(key, limit); if (judgeLimit == -1) { throw new ToolException("系统同时允许执行最多" + limit + "次当前方法"); } else { log.info(methodSignature.getDeclaringTypeName() + "." + methodName + "在系统中允许同时执行" + limit + "次当前方法,当前执行中的有" + judgeLimit + "个"); Object[] objects = joinPoint.getArgs(); return joinPoint.proceed(objects); } } /** * spring4/springboot1: * 正常:@Around-@Before-method-@Around-@After-@AfterReturning * 异常:@Around-@Before-@After-@AfterThrowing * spring5/springboot2: * 正常:@Around-@Before-method-@AfterReturning-@After-@Around * 异常:@Around-@Before-@AfterThrowing-@After */ @After("pointCut()") public void after(JoinPoint joinPoint) { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); LimitMethod limitMethod = methodSignature.getMethod().getAnnotation(LimitMethod.class); int limit = limitMethod.limit(); String application = baseEnv.getProperty("spring.application.name"); String methodName = methodSignature.getName(); if (StringUtils.hasText(application)) { String key = application + ":limit:" + methodName; long nowCount = luaTool.returnCount(key); log.info(methodSignature.getDeclaringTypeName() + "." + methodName + "在系统中允许同时执行最多" + limit + "次当前方法,执行完毕后返还次数,现仍执行中的有" + nowCount + "个"); } } }
整个限流器以漏斗思想为基础构建,也就是说,我只限制最大值,不过和时间窗口算法有区别的一点是,多了归还次数的动作,这里把他放在@After,确保无论如何都会执行。为了保证易用性,会生成Redis的默认key,我的选择是用application(应用名) + ":limit:" + methodName(方法名),达到了key不重复和易读的目标。
ini复制代码/** * 限流器-漏斗算法思想 * * @param key 被限流的key * @param limit 限制次数 * @return 当前时间范围内正在执行的线程数 */ public long judgeLimit(String key, int limit) { RScript script = redissonClient.getScript(new LongCodec()); return script.eval(RScript.Mode.READ_WRITE, "local count = redis.call('get', KEYS[1]);" + "if count then " + "if count>=ARGV[1] then " + "count=-1 " + "else " + "redis.call('incr',KEYS[1]);" + "end; " + "else " + "count = 1;" + "redis.call('set', KEYS[1],count);" + "end;" + "redis.call('expire',KEYS[1],ARGV[2]);" + "return count;", RScript.ReturnType.INTEGER, Collections.singletonList(key), limit, 600); } /** * 归还次数-漏斗算法思想 * * @param key 被限流的key * @return 正在执行的线程数 */ public long returnCount(String key) { RScript script = redissonClient.getScript(new LongCodec()); return script.eval(RScript.Mode.READ_WRITE, "local count = tonumber(redis.call('get', KEYS[1]));" + "if count then " + "if count>0 then " + "count=count-1;" + "redis.call('set', KEYS[1],count);" + "redis.call('expire',KEYS[1],ARGV[1]); " + "else " + "count = 0;" + "end; " + "else " + "count = 0;" + "end;" + "return count;", RScript.ReturnType.INTEGER, Collections.singletonList(key), 600); }
核心就是Lua脚本,推荐使用的原因如下,感兴趣的话可以自学一下,上面阿里云的文章里也有案例可以参考,包括Redisson的源码中也有大量参考案例。
减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延。使用lua脚本执行以上操作时,比redis普通操作快80%左右
原子操作。Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件,无需使用事务。
复用。客户端发送的脚本会永久存在redis中,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑。
说一下我写的脚本逻辑,首先获取当前key对应的值count,如果count不为null的情况下,再判断是否大于limit,如果大于说明超过漏斗最大值,将count设置为-1,标记为超过限制。如果小于limit,则将count值自增1.如果count为null,说明第一次进入,设置count为1。最后再刷新key的有效期并返回count值,用于切面逻辑判断。归还逻辑和进入逻辑相同,反向思考即可。
总结一下,限流器基于Lua+AOP,切点是@LimitMethod,注解参数是同时运行次数,使用场景是前后端的接口。@Around运行实际方法前进行限流(使用次数自增),@After后返还使用次数。作用是限制同时运行线程数,只有限流没有降级处理,超过的抛出异常中断方法。
读者提问:脚本最后一行失效时间重置的意图是啥?
换个相反的角度来看,如果去掉了重置失效时间的代码,是不是会存在一点问题?比如刚好进入限流后,此时流量为N,方法还没有运行完毕,这个key失效了。那么按照代码逻辑来看,生成一个新的key就从0开始,但是明明之前我还有N个流量没有执行完毕,也就是表面上看key的结果是最新的1,但实际上是1+N,这样流量就不准了。所以我这重置了下超时时间,确保方法在超时时间内运行完毕能顺利归还,保证流量数更新正确。
幂等性校验器
ini复制代码import com.alibaba.fastjson.JSON; import com.x.framework.base.RequestContext; import com.xx.framework.base.config.BaseEnvironmentConfigration; import com.xx.tool.annotation.IdempotencyCheck; import com.xx.tool.exception.ToolException; import com.xx.tool.service.LuaTool; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.DigestUtils; import org.springframework.util.ObjectUtils; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.HashMap; import java.util.Map; /** * @Author WangZY * @Date 2022/2/21 17:21 * @Description 幂等性校验切面 **/ @Aspect @Component @Slf4j public class IdempotencyCheckHandler { @Autowired private LuaTool luaTool; @Autowired private BaseEnvironmentConfigration baseEnv; @Pointcut("@annotation(com.ruijie.tool.annotation.IdempotencyCheck)") public void pointCut() { } @Around("pointCut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Object[] objects = joinPoint.getArgs(); IdempotencyCheck check = methodSignature.getMethod().getAnnotation(IdempotencyCheck.class); int checkTime = check.checkTime(); String checkKey = check.checkKey(); String application = baseEnv.getProperty("spring.application.name"); String methodName = methodSignature.getName(); String key = ""; if (ObjectUtils.isEmpty(application)) { throw new ToolException("当前项目必须拥有spring.application.name才能使用幂等性校验器"); } else { key = application + ":" + methodName + ":"; } if (ObjectUtils.isEmpty(checkKey)) { String userId = RequestContext.getCurrentContext().getUserId(); String digest = DigestUtils.md5DigestAsHex(JSON.toJSONBytes(getRequestParams(joinPoint))); key = key + userId + ":" + digest; } else { key = key + checkKey; } long checkRes = luaTool.idempotencyCheck(key, checkTime); if (checkRes == -1) { log.info("幂等性校验已开启,当前Key为{}", key); } else { throw new ToolException("防重校验已开启,当前方法禁止在" + checkTime + "秒内重复提交"); } return joinPoint.proceed(objects); } /*** * @Author WangZY * @Date 2020/4/16 18:56 * @Description 获取入参 */ private String getRequestParams(ProceedingJoinPoint proceedingJoinPoint) { Map<String, Object> requestParams = new HashMap<>(16); //参数名 String[] paramNames = ((MethodSignature) proceedingJoinPoint.getSignature()).getParameterNames(); //参数值 Object[] paramValues = proceedingJoinPoint.getArgs(); for (int i = 0; i < paramNames.length; i++) { Object value = paramValues[i]; //如果是文件对象 if (value instanceof MultipartFile) { MultipartFile file = (MultipartFile) value; //获取文件名 value = file.getOriginalFilename(); requestParams.put(paramNames[i], value); } else if (value instanceof HttpServletRequest) { requestParams.put(paramNames[i], "参数类型为HttpServletRequest"); } else if (value instanceof HttpServletResponse) { requestParams.put(paramNames[i], "参数类型为HttpServletResponse"); } else { requestParams.put(paramNames[i], value); } } return JSON.toJSONString(requestParams); } } /** * @author WangZY * @date 2022/4/25 17:41 * @description 幂等性校验 **/ public long idempotencyCheck(String key, int expireTime) { RScript script = redissonClient.getScript(new LongCodec()); return script.eval(RScript.Mode.READ_WRITE, "local exist = redis.call('get', KEYS[1]);" + "if not exist then " + "redis.call('set', KEYS[1], ARGV[1]);" + "redis.call('expire',KEYS[1],ARGV[1]);" + "exist = -1;" + "end;" + "return exist;", RScript.ReturnType.INTEGER, Collections.singletonList(key), expireTime); }
幂等性校验器基于Lua和AOP,切点是@IdempotencyCheck,注解参数是单次幂等性校验有效时间和幂等性校验Key,使用场景是前后端的接口。通知部分只有@Around,Key值默认默认为应用名(spring.application.name):当前方法名:当前登录人ID(没有SSO就是null):入参的md5值,如果checkKey不为空就会替换入参和当前登录人--->应用名:当前方法名:checkKey。作用是在checkTime时间内相同checkKey只能运行一次。
Lua脚本的写法因为没有加减,所以比限流器简单。这里还有个要点就是为了保证key值长度可控,将参数用MD5加密,对一些特殊的入参也要单独做处理。
发号器
ini复制代码/** * 单号按照keyPrefix+yyyyMMdd+4位流水号的格式生成 * * @param keyPrefix 流水号前缀标识--用作redis key名 * @return 单号 */ public String generateOrder(String keyPrefix) { RScript script = redissonClient.getScript(new LongCodec()); long between = ChronoUnit.SECONDS.between(LocalDateTime.now(), LocalDateTime.of(LocalDate.now(), LocalTime.MAX)); Long eval = script.eval(RScript.Mode.READ_WRITE, "local sequence = redis.call('get', KEYS[1]);" + "if sequence then " + "if sequence>ARGV[1] then " + "sequence = 0 " + "else " + "sequence = sequence+1;" + "end;" + "else " + "sequence = 1;" + "end;" + "redis.call('set', KEYS[1], sequence);" + "redis.call('expire',KEYS[1],ARGV[2]);" + "return sequence;", RScript.ReturnType.INTEGER, Collections.singletonList(keyPrefix), 9999, between); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); String dateNow = LocalDate.now().format(formatter); int len = String.valueOf(eval).length(); StringBuilder res = new StringBuilder(); for (int i = 0; i < 4 - len; i++) { res.append("0"); } res.append(eval); return keyPrefix + dateNow + res; }
发号器逻辑很简单,单号按照keyPrefix+yyyyMMdd+4位流水号的格式生成。Redis获取当前keyPrefix对应的key,如果没有则返回1,如果存在,判断是否大于9999,如果大于返回错误,如果小于就将value+1,并且设置过期时间直到今天结束。
加密解密
关联类JasyptField,JasyptMethod,JasyptHandler,JasyptConstant,JasyptMybatisHandler
提供注解JasyptField用于对象属性以及方法参数。提供注解JasyptMethod用于注解在方法上。此加密方式由切面方式实现,使用时请务必注意切面使用禁忌。
使用案例
less复制代码public class UserVO { private String userId; private String userName; @JasyptField private String password; } @PostMapping("test111") @JasyptMethod(type = JasyptConstant.ENCRYPT) public void test111(@RequestBody UserVO loginUser) { System.out.println(loginUser.toString()); LoginUser user = new LoginUser(); user.setUserId(loginUser.getUserId()); user.setUserName(loginUser.getUserName()); user.setPassword(loginUser.getPassword()); loginUserService.save(user); } @GetMapping("test222") @JasyptMethod(type = JasyptConstant.DECRYPT) public UserVO test222(@RequestParam(value = "userId") String userId) { LoginUser one = loginUserService.lambdaQuery().eq(LoginUser::getUserId, userId).one(); UserVO user = new UserVO(); user.setUserId(one.getUserId()); user.setUserName(one.getUserName()); user.setPassword(one.getPassword()); return user; } @GetMapping("test333") @JasyptMethod(type = JasyptConstant.ENCRYPT) public void test111(@JasyptField @RequestParam(value = "userId") String userId) { LoginUser user = new LoginUser(); user.setUserName(userId); loginUserService.save(user); } 配置文件 # jasypt加密配置 jasypt.encryptor.password=wzy
效果如下
为什么选择jasypt这个框架呢?是之前看到有人推荐,加上可玩性不错,配置文件、代码等场景都能用上,整合也方便就直接用了。这个切面换成别的加密解密也是一样的玩法,用这个主要是还附赠配置文件加密的方法。除以上用法,还扩展了Mybatis,这里对String类型做了脱敏处理,当然用别的解密方式也可以的。
Mybatis扩展使用
使用时,如果是mybatis-plus,务必在表映射实体类上增加注解@TableName(autoResultMap = true),在对应字段上加 typeHandler = JasyptMybatisHandler.class
java复制代码import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.TypeHandler; import org.jasypt.encryption.StringEncryptor; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; /** * @Author WangZY * @Date 2021/9/15 11:15 * @Description Mybatis扩展,整合Jasypt用于字段脱敏 **/ @Component public class JasyptMybatisHandler implements TypeHandler<String> { /** * mybatis-plus需在表实体类上加 @TableName(autoResultMap = true) * 属性字段上需加入 @TableField(value = "item_cost", typeHandler = JasyptMybatisHandler.class) */ private final StringEncryptor encryptor; public JasyptMybatisHandler(StringEncryptor encryptor) { this.encryptor = encryptor; } @Override public void setParameter(PreparedStatement preparedStatement, int i, String s, JdbcType jdbcType) throws SQLException { if (StringUtils.isEmpty(s)) { preparedStatement.setString(i, ""); } else { preparedStatement.setString(i, encryptor.encrypt(s.trim())); } } @Override public String getResult(ResultSet resultSet, String s) throws SQLException { if (StringUtils.isEmpty(resultSet.getString(s))) { return resultSet.getString(s); } else { return encryptor.decrypt(resultSet.getString(s).trim()); } } @Override public String getResult(ResultSet resultSet, int i) throws SQLException { if (StringUtils.isEmpty(resultSet.getString(i))) { return resultSet.getString(i); } else { return encryptor.decrypt(resultSet.getString(i).trim()); } } @Override public String getResult(CallableStatement callableStatement, int i) throws SQLException { if (StringUtils.isEmpty(callableStatement.getString(i))) { return callableStatement.getString(i); } else { return encryptor.decrypt(callableStatement.getString(i).trim()); } } }
线程池
scss复制代码import com.xx.tool.properties.ToolProperties; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ThreadPoolExecutor; /** * @Author WangZY * @Date 2020/2/13 15:51 * @Description 线程池配置 */ @EnableConfigurationProperties({ToolProperties.class}) @Configuration public class ThreadPoolConfig { @Autowired private ToolProperties prop; /** * 默认CPU密集型--所有参数均需要在压测下不断调整,根据实际的任务消耗时间来设置参数 * CPU密集型指的是高并发,相对短时间的计算型任务,这种会占用CPU执行计算处理 * 因此核心线程数设置为CPU核数+1,减少线程的上下文切换,同时做个大的队列,避免任务被饱和策略拒绝。 */ @Bean("cpuDenseExecutor") public ThreadPoolTaskExecutor cpuDense() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); //获取逻辑可用CPU数 int logicCpus = Runtime.getRuntime().availableProcessors(); if (prop.getPoolCpuNumber() != null) { //如果是核心业务,需要保活足够的线程数随时支持运行,提高响应速度,因此设置核心线程数为压测后的理论最优值 executor.setCorePoolSize(prop.getPoolCpuNumber() + 1); //设置和核心线程数一致,用队列控制任务总数 executor.setMaxPoolSize(prop.getPoolCpuNumber() + 1); //Spring默认使用LinkedBlockingQueue executor.setQueueCapacity(prop.getPoolCpuNumber() * 30); } else { executor.setCorePoolSize(logicCpus + 1); executor.setMaxPoolSize(logicCpus + 1); executor.setQueueCapacity(logicCpus * 30); } //默认60秒,维持不变 executor.setKeepAliveSeconds(60); //使用自定义前缀,方便问题排查 executor.setThreadNamePrefix(prop.getPoolName()); //默认拒绝策略,抛异常 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } /** * 默认io密集型 * IO密集型指的是有大量IO操作,比如远程调用、连接数据库 * 因为IO操作不占用CPU,所以设置核心线程数为CPU核数的两倍,保证CPU不闲下来,队列相应调小一些。 */ @Bean("ioDenseExecutor") public ThreadPoolTaskExecutor ioDense() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); int logicCpus = Runtime.getRuntime().availableProcessors(); if (prop.getPoolCpuNumber() != null) { executor.setCorePoolSize(prop.getPoolCpuNumber() * 2); executor.setMaxPoolSize(prop.getPoolCpuNumber() * 2); executor.setQueueCapacity(prop.getPoolCpuNumber() * 10); } else { executor.setCorePoolSize(logicCpus * 2); executor.setMaxPoolSize(logicCpus * 2); executor.setQueueCapacity(logicCpus * 10); } executor.setKeepAliveSeconds(60); executor.setThreadNamePrefix(prop.getPoolName()); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } @Bean("cpuForkJoinPool") public ForkJoinPool cpuForkJoinPool() { int logicCpus = Runtime.getRuntime().availableProcessors(); return new ForkJoinPool(logicCpus + 1); } @Bean("ioForkJoinPool") public ForkJoinPool ioForkJoinPool() { int logicCpus = Runtime.getRuntime().availableProcessors(); return new ForkJoinPool(logicCpus * 2); } }
线程池对传统的ThreadPoolTaskExecutor和新锐的ForkJoinPool提供了常见的CPU和IO密集型的通用解。核心线程数和最大线程数设置为一致,通过队列控制任务总数,这是基于我对目前项目使用情况的一个经验值判断。如果是非核心业务,不需要保活这么多核心线程数,可以设置的小一些,最大线程数设置成压测最优结果即可。
更新记录
版本号发布时间更新记录0.62021/6/21 14:13初始化组件,增加Hr信息查询、消息通知、Redis、Spring工具0.72021/6/21 18:39增加Redisson配置类0.82021/6/22 14:15优化包结构,迁移maven仓库坐标0.92021/6/22 15:09增加说明文档1.02021/7/2 11:51增加Redis配置类,配置Spring Data Redis1.22021/7/15 11:25Hr信息查询增加新方法1.2.52021/8/3 18:361.增加加密解密切面2.增加启动校验参数类1.32021/8/4 10:31加密解密切面BUG FIXED1.4.02021/8/10 10:14Redisson配置类增加Redis-Cluster集群支持1.4.52021/9/14 16:03增加Excel模块相关类1.5.02021/9/14 16:51增加@Valid快速失败机制1.6.02021/9/15 15:041.加密解密切面支持更多入参,BUG FIXED2.增加脱敏用Mybatis扩展1.6.82021/9/17 11:29增加主站用待办模块相关类1.6.92021/10/27 13:19脱敏用Mybatis扩展BUG FIXED1.7.02021/10/28 20:43更新邮件发送人判断,优化消息通知工具1.7.12021/11/15 10:07待办参数移除强制校验1.7.22021/11/23 14:08邮件发送增加附件支持1.7.52021/12/9 11:081.待办及Excel模块迁移至组件Business-Common2.增加spring-cache配置redis3.ToolException继承AbstractRJBusinessException,能被全局异常监听2.0.02022/1/7 11:22完全去除业务部分,迁移至组件Business-Common2.0.22022/1/13 15:44增加统一注册类ToolAutoConfiguration2.0.52022/3/14 15:11消息通知工具使用resttemplate默认编码格式不支持中文问题解决2.0.62022/3/24 23:49Redisson编码更换String,方便图形可视化2.0.72022/3/30 14:22Redisson及Mybatis依赖版本升级2.0.82022/4/12 11:57增加线程池配置2.0.92022/4/15 18:25增加漏桶算法限流器2.1.02022/4/18 14:29漏桶算法限流器优化,切面顺序调整2.1.12022/4/26 9:56新增幂等性校验工具2.1.22022/4/26 16:13幂等性校验支持文件、IO流等特殊参数2.1.32022/4/29 14:231.移除redisTool,推荐使用Redisson2.修改单号生成器BUG2.1.42022/5/18 11:291.修复了自2.1.0版本以来的限流器BUG2.优化了缓存配置类的过时代码2.1.62022/5/24 17:44配合架构组升级新网关2.1.72022/6/8 14:01增加Caffeine配置2.1.82022/7/12 10:191.回归fastjson1,避免fastjson2版本兼容性BUG2.forkjoinpool临时参数2.1.92022/7/27 13:59优化消息通知工具,增加发送人参数2.2.02022/8/25 9:241.增加ForkJoinPool类型的线程池默认配置2.线程池参数增加配置化支持2.2.22022/9/19 17:08修改Redisson编码为默认编码,String编码不支持RBucket的泛型(Redisson3.18.0已修复该问题)2.2.32022/9/21 19:06调大Redisson命令及连接超时参数2.2.42022/9/27 11:52消息通知工具BUG FIXED,避免空指针2.2.52022/12/16 18:46增加工具类Map切分2.2.82022/12/18 13:19增加Mybatis扩展,日期转换处理器2.2.92023/2/10 22:30Redisson及Lombok依赖版本升级2.3.02023/5/6 10:26重写Redis配置类,增加SpringDataRedisConfig2.3.12023/5/7 19:051.线程池参数调整2.优化注释2.3.32023/7/24 18:511.redis加载新增开关2.新增二级缓存工具类2.3.42023/5/7 19:05优化Redis开关配置体验