欢迎光临散文网 会员登陆 & 注册

六脉神剑-我在公司造了六个轮子

2023-08-20 12:10 作者:好困想睡觉了啦  | 我要投稿

前言

相信很多开发都会有自己造轮子的想法,毕竟只有提效了才能创造更多的摸鱼空间。我呢,不巧就是高效的选手,也有幸自己设计并开发了好多轮子,并成功推广给整个团队使用。如果有看过我前面文章的读者朋友,肯定了解博主的工作情况,本职是一线业务搬砖党,所以轮子都是我闲暇和周末时间自己慢慢积累起来。后来实在是太好用了,我就开始推广,人人提效,人人如龙。但这东西吧,啥都挺好,就有一点不好,自从大家开发效率都提升了之后,领导给的开发工时更短了,淦。不说这些难过的事了,其实就我个人而言,组件也是我学习成长过程中的见证者,从一开始的磕磕绊绊到现在的信手拈来,回看当年的提交记录时,依旧觉得很有意思。

本文不仅有所有组件的包结构简析,还有对核心功能的精讲,更有我特别整理的版本更新记录,而且我还特别把提交时间给捞出来了。更新记录捞出来呢,主要也是想让读者从变更的过程中,去了解我在造轮子过程中遇到的问题,一些挣扎和选型的过程。当然有一些之前提到过的组件,我偷懒啦,放了之前文章的链接,不然这一万多字的文章装不下了。写完全篇后发现没有放我小仓库的连接,捞一下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的源码中也有大量参考案例。

  1. 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延。使用lua脚本执行以上操作时,比redis普通操作快80%左右

  2. 原子操作。Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件,无需使用事务。

  3. 复用。客户端发送的脚本会永久存在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开关配置体验

六脉神剑-我在公司造了六个轮子的评论 (共 条)

分享到微博请遵守国家法律