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

2023/5/12追加更新,文章补漏、私货分享

前言

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

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

│ CaffeineConfig.java--本地缓存caffeine通用配置

│ MyRedissonConfig.java--Redisson配置

│ ThreadPoolConfig.java--线程池配置

│ ToolApplicationContextInitializer.java--启动后检查参数

│ ToolAutoConfiguration.java--统一注册BEAN

├─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

└─util

│ MapUtil.java--Map自用工具类,用于切分Map支持多线程

核心功能点

缓存(Redis和Caffeine)

关联类SpringDataRedisConfig,CaffeineConfig,MyRedissonConfig

<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客户端。

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序列化是为了压缩数据同时也是官方推荐。

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的封装。

@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

@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工具

基于漏斗思想的限流器

关联类LimitMethod,LimitHandler,LuaTool

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不重复和易读的目标。

/**
     * 限流器-漏斗算法思想
     *
     * @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,这样流量就不准了。所以我这重置了下超时时间,确保方法在超时时间内运行完毕能顺利归还,保证流量数更新正确。

幂等性校验器

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加密,对一些特殊的入参也要单独做处理。

发号器

/**
 * 单号按照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用于注解在方法上。此加密方式由切面方式实现,使用时请务必注意切面使用禁忌。

使用案例

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

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());
        }
    }
}

线程池

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.6 2021/6/21 14:13 初始化组件,增加Hr信息查询、消息通知、Redis、Spring工具
0.7 2021/6/21 18:39 增加Redisson配置类
0.8 2021/6/22 14:15 优化包结构,迁移maven仓库坐标
0.9 2021/6/22 15:09 增加说明文档
1.0 2021/7/2 11:51 增加Redis配置类,配置Spring Data Redis
1.2 2021/7/15 11:25 Hr信息查询增加新方法
1.2.5 2021/8/3 18:36 1.增加加密解密切面2.增加启动校验参数类
1.3 2021/8/4 10:31 加密解密切面BUG FIXED
1.4.0 2021/8/10 10:14 Redisson配置类增加Redis-Cluster集群支持
1.4.5 2021/9/14 16:03 增加Excel模块相关类
1.5.0 2021/9/14 16:51 增加@Valid快速失败机制
1.6.0 2021/9/15 15:04 1.加密解密切面支持更多入参,BUG FIXED2.增加脱敏用Mybatis扩展
1.6.8 2021/9/17 11:29 增加主站用待办模块相关类
1.6.9 2021/10/27 13:19 脱敏用Mybatis扩展BUG FIXED
1.7.0 2021/10/28 20:43 更新邮件发送人判断,优化消息通知工具
1.7.1 2021/11/15 10:07 待办参数移除强制校验
1.7.2 2021/11/23 14:08 邮件发送增加附件支持
1.7.5 2021/12/9 11:08 1.待办及Excel模块迁移至组件Business-Common2.增加spring-cache配置redis3.ToolException继承AbstractRJBusinessException,能被全局异常监听
2.0.0 2022/1/7 11:22 完全去除业务部分,迁移至组件Business-Common
2.0.2 2022/1/13 15:44 增加统一注册类ToolAutoConfiguration
2.0.5 2022/3/14 15:11 消息通知工具使用resttemplate默认编码格式不支持中文问题解决
2.0.6 2022/3/24 23:49 Redisson编码更换String,方便图形可视化
2.0.7 2022/3/30 14:22 Redisson及Mybatis依赖版本升级
2.0.8 2022/4/12 11:57 增加线程池配置
2.0.9 2022/4/15 18:25 增加漏桶算法限流器
2.1.0 2022/4/18 14:29 漏桶算法限流器优化,切面顺序调整
2.1.1 2022/4/26 9:56 新增幂等性校验工具
2.1.2 2022/4/26 16:13 幂等性校验支持文件、IO流等特殊参数
2.1.3 2022/4/29 14:23 1.移除redisTool,推荐使用Redisson2.修改单号生成器BUG
2.1.4 2022/5/18 11:29 1.修复了自2.1.0版本以来的限流器BUG2.优化了缓存配置类的过时代码
2.1.6 2022/5/24 17:44 配合架构组升级新网关
2.1.7 2022/6/8 14:01 增加Caffeine配置
2.1.8 2022/7/12 10:19 1.回归fastjson1,避免fastjson2版本兼容性BUG2.forkjoinpool临时参数
2.1.9 2022/7/27 13:59 优化消息通知工具,增加发送人参数
2.2.0 2022/8/25 9:24 1.增加ForkJoinPool类型的线程池默认配置2.线程池参数增加配置化支持
2.2.2 2022/9/19 17:08 修改Redisson编码为默认编码,String编码不支持RBucket的泛型(Redisson3.18.0已修复该问题)
2.2.3 2022/9/21 19:06 调大Redisson命令及连接超时参数
2.2.4 2022/9/27 11:52 消息通知工具BUG FIXED,避免空指针
2.2.5 2022/12/16 18:46 增加工具类Map切分
2.2.8 2022/12/18 13:19 增加Mybatis扩展,日期转换处理器
2.2.9 2023/2/10 22:30 Redisson及Lombok依赖版本升级
2.3.0 2023/5/6 10:26 重写Redis配置类,增加SpringDataRedisConfig
2.3.1 2023/5/7 19:05 1.线程池参数调整2.优化注释

Business-Common(业务包)

包结构简析

├─annotation

│ ExcelFieldValid.java--数据输入校验注解

├─config

│ BusinessAutoConfiguration.java--统一注册BEAN

│ BusinessBeanConfig.java--删除公司框架包中的全局异常监听类

│ ExcelFieldValidator.java--Excel参数校验-Validator扩展

│ MybatisPlusConfig.java--支持Mybatis-Plus分页方言配置

│ MyBatisPlusObjectHandler.java--MyBatisPlus用填充字段规则

│ ValidatorConfig.java--Valid配置快速失败模式

├─constant

│ ExcelFieldConstant.java--Excel模块用常量

│ TaskConstant.java--待办模块用常量

├─excel

│ ExcelListener.java--通用Easy Excel监听,在原版基础上魔改强化

│ ExcelTool.java--超级威力无敌全能Excel工具,整合主子站、文件服务器、Easy Excel

│ NoModelExcelListener.java--无模板Excel监听类

├─exception

│ PtmException.java--提供项目统一的自定义异常

│ PtmExceptionHandler.java--全局异常监听

├─pojo

│ ├─dto

│ │ CommonProperties.java--业务包可配置参数

│ ├─excel

│ │ ExcelAddDTO.java--主站文件列表新增接口入参

│ │ ExcelAnalyzeResDTO.java--Excel解析结果类

│ │ ExcelUpdateDTO.java--主站文件列表更新接口入参

│ │ ExcelUploadResDTO.java--文件服务器返回结果类

│ └─task

│ ForwardTaskDTO.java--主站转办接口入参

│ RecallTaskDTO.java--主站撤回待办接口入参

│ TaskApproveDTO.java--主站审批待办接口入参

│ TaskReceiveDTO.java--主站生成待办接口入参

├─properties

│ RewriteOriginTrackedPropertiesLoader.java--修改Spring配置文件读取默认编码

│ RewritePropertiesPropertySourceLoader.java--修改Spring配置文件读取默认编码

├─remote

│ ExternalApi.java--对外调用

└─util

│ JacksonJsonUtil.java--Jackson工具类

│ ModelConverterUtils.java--模型转换工具

│ UploadFileServerUtil.java--文件服务器交互工具类

核心功能

Excel模块

后端思想-如何设计一个操作和管理Excel的业务模块,详细情况参考以上文章,六千字精解,不再赘述。

1.0.4重大版本更新

解决BUG---获取文件流失败 java.io.FileNotFoundException: /data/ptm/tmp/upload_0e7e1e62_8df3_4d2a_ae2f_86be3a0c08c6_00000000.tmp (No such file or directory) at java.io.FileInputStream.open0(Native Method)

该BUG原因是Spring上传文件时,异步操作时主线程关闭IO流,Tomcat删除缓存的上传文件,导致子线程操作文件实例时找不到。当前已修复该问题,并做了新的优化,包括使用缓冲流加速文件读取、删除本地临时文件释放空间。

异常体系

异常体系主要是为了提供友好提示、根据不同错误码转向不同处理场景、优化Controller层。

优化后如上,需要有一个类似于RemoteResult的类,包含状态码,消息,返回值,如果你有更多的内容需要输出那就扩展这个类。异常主要是用到三个类。

  • 业务异常类PtmException,提供项目统一的自定义异常,包含错误码,错误信息,默认错误码是10001。
  • 抽象异常类AbstractException,这个类的主要作用是提供一个异常的父类,方便扩展,所有业务异常类PtmException比如强制继承该类
  • 全局异常监听类PtmExceptionHandler,在这个类里面去监听不同的错误,根据不同的错误来进行对应的处理
import com.xx.framework.common.RemoteResult;
import com.xx.framework.exception.exception.AbstractRJBusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @Author WangZY
 * @Date 2020/12/24 10:40
 * @Description 全局异常监听
 **/
@RestControllerAdvice
@Slf4j
public class PtmExceptionHandler {
    @ExceptionHandler(Exception.class)
    public RemoteResult<String> handleException(HttpServletRequest request, Exception e) {
        log.error("全局监听异常捕获,方法={}", request.getRequestURI(), e);
        return new RemoteResult<>("10001", "内部错误,请联系管理员处理");
    }

    @ExceptionHandler(AbstractRJBusinessException.class)
    public RemoteResult<String> handleBusinessException(HttpServletRequest request, AbstractRJBusinessException e) {
        log.error("全局监听业务异常捕获,方法={}", request.getRequestURI(), e);
        String errCode = e.getErrCode();
        if ("10003".equals(errCode)) {
            return new RemoteResult<>(errCode, "用户未授权,即将跳转登录地址", e.getErrMsg());
        } else {
            return new RemoteResult<>(errCode, e.getErrMsg());
        }
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public RemoteResult<String> methodArgumentNotValidExceptionHandler(HttpServletRequest request,
                                                                       MethodArgumentNotValidException e) {
        log.error("全局监听Spring-Valid异常捕获,方法={}", request.getRequestURI(), e);
        // 从异常对象中拿到ObjectError对象
        List<ObjectError> allErrors = e.getBindingResult().getAllErrors();
        String err = allErrors.stream().map(DefaultMessageSourceResolvable::getDefaultMessage)
                .collect(Collectors.joining(","));
        // 然后提取错误提示信息进行返回
        return new RemoteResult<>("10001", err);
    }
}

强制Spring读取配置文件使用UTF-8

重写配置类RewritePropertiesPropertySourceLoader,固定UTF-8编码,避免中文读取乱码。spring.factories里为org.springframework.boot.env.PropertySourceLoader接口提供一个新的实现类,并且使用@Order调高优先级。

移除三方包中指定Bean

该方法不可移除配置类,也就是@Configuran注解的类。

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.stereotype.Component;

/**
 * @Classname RegistryBeanFactoryConfig
 * @Date 2021/12/6 18:39
 * @Author WangZY
 * @Description 删除base包部分数据
 */
@Component
public class BusinessBeanConfig implements BeanDefinitionRegistryPostProcessor {
    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
        if (registry.containsBeanDefinition("rJGlobalDefaultExceptionHandler")) {
            registry.removeBeanDefinition("rJGlobalDefaultExceptionHandler");
        }
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {

    }
}

更新记录

版本号 发布时间 更新记录
0.1.0 2021/12/7 10:53 初始化组件包,增加统一注册类BusinessAutoConfiguration
0.2.0 2021/12/7 16:21 迁移Tool-Box中的业务部分
0.3.0 2021/12/9 11:05 初版 该版可用1.整合依赖-部门框架中Base包及Http包,Easy Excel,文件服务器,Mybatis-Plus2.迁移Tool-Box的Excel模块,开发Excel Tool,该工具更新中,目前完整的功能已有异步导出3.Mybatis-Plus分页插件配置4.禁用框架全局异常监听类,整合为PtmExceptionHandler,所有异常统一为PtmException,自定义异常必须继承AbstractRJBusinessException5.禁用部门框架日志切面,使用自定义请求返回框架,增加注解IgnoreLog可不输出入参和出参日志,增加日志切面类RequestLogAspect
0.5.0 2021/12/15 17:25 1.部门框架更新2.7.12.新增配置文件参数指引3.ExcelTool增加通用导入方法commonImportExcel,整合主子站交互以及文件服务器交互逻辑,支持对File以及MultipartFile类型的解析
0.5.5 2021/12/22 9:44 引入RestTemplate
0.5.6 2021/12/23 15:14 RestTemplate整合部门网关校验参数
0.6.0 2022/1/7 11:31 新增文件及待办相关业务组件
0.6.4 2022/1/13 16:40 RequestLogAspect及ExcelTool的BUG FIXED
0.6.5 2022/1/27 16:45 RequestLogAspect日志切面优化
0.6.6 2022/2/9 17:16 移除日志切面模块,迁移至Log-Transfer组件
0.6.7 2022/2/14 17:21 1.resttemplate配置完善,增加部门要求header2.无法传递Date字段的格式化问题解决
0.7.0 2022/3/1 10:13 ExcelTool优化文件名生成逻辑,更具可读性且更方便
0.8.0 2022/3/30 14:28 升级Mybatis-Plus大版本,高版本存在不兼容问题,需各业务系统选择性更新
0.8.2 2022/5/18 11:51 1.ExcelTool新增同步导出方法2.MP分页插件方言类型支持Spring配置文件参数配置
0.8.4 2022/6/8 14:59 魔改强化Easy Excel默认读取类,并增加解析异常信息的收集
0.8.6 2022/6/20 9:40 1.ExcelTool创建文件默认导出BUG FIXED2.ExcelTool新增创建人信息入参
0.8.8 2022/6/20 16:34 ExcelTool收集堆栈信息需要截取,优化日志输出
0.8.9 2022/6/21 19:57 业务支持-待办新增字段
0.9.0 2022/6/23 16:56 ExcelTool增加新方法,支持动态导入及导出场景
0.9.1 2022/6/24 14:44 1.增加读取表头及收集功能2.提供表头校验参数,校验是否与预期一致-支持业务
0.9.3 2022/7/5 15:05 ExcelTool优化错误展示,提供多种途径的错误信息输出
0.9.4 2022/7/21 10:50 ExcelTool的BUG FIXED
0.9.6 2022/7/27 14:06 1.截取异常信息BUG FIXED2.资源释放优化,try-with-resources3.FastJson版本序列化兼容问题解决 boolean isXXX
0.9.8 2022/8/1 17:17 加载Spring配置文件强制使用UTF-8,解决中文乱码问题
0.9.9 2022/8/19 16:41 下调Excel解析失败日志等级为warn
1.0.1 2022/9/14 15:50 增加Mybatis-Plus自动填充配置类
1.0.2 2022/10/21 15:27 ExcelListener监听类BUG FIXED
1.0.3 2022/11/18 13:58 增加模型转换工具类
1.0.4 2022/12/16 17:04 1.异步读取文件,文件丢失BUG修复2.使用缓冲流优化文件读取速度3.优化通用导入方法,修改返回结构4.删除导入和导出时的本地文件,释放空间
1.0.5 2023/2/9 15:49 更新Mybatis-Plus和Easy Excel依赖版本至最新版

SSO-ZERO(单点登录)

包结构简析

├─api

│ ScmApi.java--门户网站SCM远程调用

├─common

│ CheckUrlConstant.java--各个环境的URL

│ LoginConstant.java--SSO统一常量,用于主子站共享变量

├─config

│ RJSsoConfigurerAdapter.java--SSO拦截器注入,提供路径排除

│ SSOProperties.java--SSO-ZERO用参数

├─exception

│ SsoAppCode.java--部门原始SSO遗留

│ SSOException.java--异常

├─hanlder

│ SsoProcessHandler.java--SSO核心处理类

├─model

│ LocalUserInfo.java--留待扩展,参数受部门大框架限制

├─pojo

│ └─dto

│ MenuVO.java--菜单信息

│ RoleCacheDTO.java--角色缓存信息

│ UserGroupCacheDTO.java--用户组缓存信息

├─spi

│ RuiJieSsoVerifyProcessor.java--SPI扩展接口,留待放出,目前仅有本人开发设计维护

└─utils

│ CookieUtils.java--Cookie工具类

│ CurrentUserUtil.java--提供给开发同事的SSO信息简易获取工具类

组件简述

后端思想-单点登录组件的设计与思考,同样是一个我设计并开发的,缺了认证的单点登录模块,很遗憾受限于公司架构,不是认证授权鉴权三位一体的完整版。在已有认证的情况下,做了一个主站-组件构成的授权鉴权模块,由于是内网,安全方面做的比较粗糙。在功能上我是按照shiro去设计的,比如注解控制权限。文章是好文章,记录了六次迭代的变更点和我的思考,最后总结的时候还列举我对这个单点登录组件的一些感想,但是组件没有做到很完善,还是有点遗憾。

更新记录

版本号 发布时间 更新记录
1.0.0 2021/5/10 21:15 初始化组件,不可用
1.1.0 2021/5/11 10:48 兼容公司OA登录,优化冗余代码
1.1.2 2021/5/11 16:53 去除校验模拟登录模块,格式化代码
1.1.3 2021/5/11 18:50 去除无用缓存模块
1.1.6 2021/5/13 14:49 兼容E平台登录
1.1.9 2021/5/26 15:15 1.对接主站权限模块2.优化日志输出
1.2.0 2021/5/27 11:44 整合主站优化授权模块
1.2.2 2021/6/3 11:36 增加缓存,提升鉴权及授权速度
1.2.4 2021/6/21 17:39 判断唯一逻辑从部门原有的userid变为整合主站后的userid+uid,避免多个认证源出现userid一致的情况
1.2.5 2021/8/3 11:09 业务支持-为子系统增加扩展信息
1.2.7 2021/9/9 15:36 增加系统链接配置,用以本地调试
1.2.8 2021/10/27 14:04 代码优化,老版SSO封版
1.4.0 2021/10/28 13:58 配合公司战略,认证方式替换为其他部门自研认证系统SID,第一次整合完毕
1.4.1 2021/11/8 10:10 1.升级SID版本后,移除无用登录校验2.整合用户信息及前端菜单、权限、组等信息接口,合二为一,减少远程调用次数,加速鉴权3.增加SSO日志,打印详细鉴权及授权过程和异常信息定位日志4.删除冗余代码,精简代码
1.4.2 2021/11/12 17:44 1.所有用户鉴权操作融合成一个接口,再次减少校验次数2.新增SID版本下测试环境的Cookie,并做好正式测试的隔离3.优化包结构,简化代码
1.4.4 2021/12/7 10:48 1.新增渠道用户登录校验2.日志BUG,API部分优化
1.4.6 2022/1/4 15:15 E平台账号中间空格数据导致URL解析失败的BUG FIXED
1.4.7 2022/1/7 13:25 新增Refresh Token续约机制
1.4.8 2022/1/13 16:34 错误码定制化,与前端合作完善SID版本单点登录模块
1.4.9 2022/1/18 11:42 迁移部门新网关
1.5.0 2022/2/17 16:14 新网关存在兼容问题,紧急回撤老网关并加入白名单
1.5.1 2022/3/30 22:42 新网关已稳定,重新迁回
1.5.3 2022/5/23 17:27 新增获取用户信息工具类
1.5.4 2022/6/20 17:15 1.继续优化代码2.增加对新老网关的兼容
1.5.9 2022/8/22 14:57 兼容部门通用鉴权授权体系
1.6.1 2022/8/23 16:18 兼容部门鉴权授权体系引发的BUG FIXED
1.6.3 2022/8/23 19:18 优化ThreadLocal使用部分的代码
1.6.4 2022/8/25 20:34 SID版本增加测试版本已隔离版本环境
1.6.5 2022/10/8 10:48 增加详细错误日志,方便问题定位
1.6.9 2023/5/6 17:36 老网关容易出现异常,迁移所有接口转为新网关

Log-Transfer(日志传输)

包结构简述

├─annotation

│ IgnoreLog.java--切面排除该日志

│ LogCollector.java--历史遗留,第一版日志收集系统用注解

├─aop

│ LogTransferHandler.java--优化后日志切面,仍保留第一版日志收集系统用注解,为后续行为日志收集埋下伏笔

├─config

│ KafkaConsumerConfig.java--多种消费者配置

│ KafkaProducerConfig.java--多种生产者配置

│ LogApplicationContextInitializer.java--启动后检查参数

│ LogAutoConfiguration.java--统一注册BEAN

│ LogBeanConfig.java--删除部门框架中默认日志切面

│ Snowflake.java-- hutool雪花ID单机版,自用魔改简化版

├─constants

│ LogConstants.java--日志常量

├─exception

│ TransferException.java--日志异常

├─pojo

│ LogProviderDTO.java--日志收集系统用信息收集类

├─properties

│ TransferProperties.java--Spring配置文件可配置参数

└─util

│ AddressUtils.java--获取IP归属地

│ IpUtils.java--获取IP方法

组件简述

Filebeat+Kafka+数据处理服务+Elasticsearch+Kibana+Skywalking日志收集系统,该组件服务于我自己设计并开发的完整日志收集系统,并提供Kafka生产者和消费者的模板配置,后面是本文介绍。

一个由我独立设计并开发的,完整的日志收集系统,到今天成功运行了一年半了,接入了团队的三四十个大小项目,成功抢了架构组的活,装了个大大的逼。文章详细描述了三次完整的迭代过程,为什么需要迭代?我做了什么优化?这一阶段我是怎么想的?以上大家最关心的问题,我都做出了解答。毫无疑问,这是我做过最疯狂的操作,难度系数拉满。后续更新的时候追加了一些扩充日志,以及部分配置的优化。对我来说,真的是一次很有挑战,也很长知识的经历,我至今难以想象我是如何用下班和周末时间,自己捣鼓出来这么一套庞大的东西,真TM离谱。

消息积压问题难?思路代码优化细节全公开--1550阅读37赞42收藏,同时组件为本文的Kafka配置提供了代码支持,后面是该文介绍。

我很奇怪,这篇纯纯的实战文真的是榨干了我,花了大量的时间来测试和佐证我的结论。有消息积压问题的详细处理思路和伪代码,还对Kafka的生产者消费者配置的优化给出了解释。我在整个过程中遇到的问题也有详细的记录和解决方案。数据算是一般般吧,不过我会继续努力的,带来更好的文章。

更新记录

版本号 发布时间 更新记录
1.0.0 2022/1/19 18:35 初始化包结构
1.0.2 2022/1/20 17:37 开发日志切面LogTransferHandler
1.0.6 2022/2/9 17:14 1.排除部门框架中的日志切面2.完善日志切面投入使用
1.0.8 2022/2/16 18:42 1.提供日志收集注解2.增加不主动收集日志的选择
1.0.9 2022/2/18 10:12 优化日志切面,放过健康检测接口,MDC增加自定义参数
1.1.1 2022/2/25 17:32 统一注册Bean类
1.1.2 2022/4/11 11:20 提供Kafka生产者和消费者的多种模板配置,高并发低时延顺序性等等
1.1.3 2022/4/13 15:03 减少日志组件的强制配置,提供默认配置
1.1.4 2022/4/14 22:27 Kafka参数提供Spring配置文件参数配置,并增加@Primary,避免引入时未指定Bean导致的报错
1.1.5 2022/4/24 14:10 根据业务情况优化Kafka配置的参数
1.1.6 2022/6/20 16:56 优化日志切面,解决日志打印延后的问题
1.1.7 2022/7/14 14:24 应实际业务情况增加手动提交消费者的配置
1.1.8 2023/2/28 22:29 适配项目调整消费者参数
1.1.9 2023/3/9 22:57 增加订单交付计算项目专用测试消费者,并调整参数适配项目
1.2.6 2023/5/8 17:40 1.Kafka生产者配置最大消息大小和请求超时时间调大,避免大消息发送失败2.Kafka消费者配置增加单通道消费

Feign-Seata(Seata包)

包结构简析

├─config

│ FeignInterceptor.java--Open Feign植入Seata用XID

│ SeataAutoConfiguration.java--统一注册BEAN

├─filter

│ SeataFilter.java--Seata过滤器--也可以用拦截器实现

└─interceptor

│ SeataHandlerInterceptor.java--Seata官方包捞出来的拦截器实现版本

│ SeataHandlerInterceptorConfiguration.java--拦截器注册类

组件简析

分布式事务Seata-1.5.2使用全路线指北,三千字文章全面介绍了部署、server端配置、client端配置、组件封装,还记录了我在使用Seata时遇到的问题,最后聊了聊我对分布式事务的看法,欢迎来看嗷!版更记录就没必要发了,写完之后就没什么变动了,就是一个普通的基于Seata做本地化封装的组件。即使更新也只是适配新版本Seata,按照官方文档进行修改。

Timer-Common(Elastic-Job包)

包结构简析

├─config

│ ElasticJobConfiguration.java--定时调度配置类

│ TimerAutoConfiguration.java--统一注册BEAN

└─properties

│ TimerProperties.java--定时调度可配置参数

组件简析

import com.xx.framework.base.config.BaseEnvironmentConfigration;
import com.xx.timer.properties.TimerProperties;
import org.apache.commons.lang3.StringUtils;
import org.apache.shardingsphere.elasticjob.reg.base.CoordinatorRegistryCenter;
import org.apache.shardingsphere.elasticjob.reg.zookeeper.ZookeeperConfiguration;
import org.apache.shardingsphere.elasticjob.reg.zookeeper.ZookeeperRegistryCenter;
import org.apache.shardingsphere.elasticjob.tracing.api.TracingConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

/**
 * 定时调度配置列
 */
@EnableConfigurationProperties({TimerProperties.class})
@Configuration
public class ElasticJobConfiguration {

    @Autowired
    private TimerProperties prop;
    @Autowired
    private BaseEnvironmentConfigration env;

    /**
     * 初始化配置
     */

    /**
     * 当ruijie.timer.start为true时初始化bean,如果没有该项属性则默认值为true
     */
    @Bean(initMethod = "init")
    @ConditionalOnProperty(value = "ruijie.timer.start", havingValue = "true", matchIfMissing = true)
    public CoordinatorRegistryCenter zkRegCenter() {
        String zkServerList = prop.getZkServerList();
        String zkNamespace = prop.getZkNamespace();
        String currentEnv = env.getCurrentEnv();
        if (StringUtils.isBlank(zkServerList)) {
            if ("pro".equalsIgnoreCase(currentEnv)) {
                zkServerList = "";
            } else {
                zkServerList = "";
            }
        }
        if (StringUtils.isBlank(zkNamespace)) {
            if ("pro".equalsIgnoreCase(currentEnv)) {
                zkNamespace = "elastic-job-pro";
            } else {
                zkNamespace = "elastic-job-uat";
            }
        }
        ZookeeperConfiguration zkConfig = new ZookeeperConfiguration(zkServerList, zkNamespace);
        zkConfig.setConnectionTimeoutMilliseconds(100000);
        zkConfig.setSessionTimeoutMilliseconds(100000);
        zkConfig.setMaxRetries(3);
        zkConfig.setMaxSleepTimeMilliseconds(60000);
        zkConfig.setBaseSleepTimeMilliseconds(30000);
        return new ZookeeperRegistryCenter(zkConfig);
    }

    @Bean
    @ConditionalOnProperty(value = "ruijie.timer.start", havingValue = "true", matchIfMissing = true)
    public TracingConfiguration<DataSource> tracingConfiguration() {
        String dbUrl = prop.getDbUrl();
        String dbDriverClassName = prop.getDbDriverClassName();
        String dbUserName = prop.getDbUserName();
        String dbPassword = prop.getDbPassword();
        String currentEnv = env.getCurrentEnv();
        if (StringUtils.isBlank(dbUrl)) {
            if ("pro".equalsIgnoreCase(currentEnv)) {
                dbUrl = "";
                dbDriverClassName = "org.postgresql.Driver";
                dbUserName = "";
                dbPassword = "";
            } else {
                dbUrl = "";
                dbDriverClassName = "org.postgresql.Driver";
                dbUserName = "";
                dbPassword = "";
            }
        }
        DataSource source = DataSourceBuilder.create()
                .url(dbUrl).driverClassName(dbDriverClassName)
                .username(dbUserName).password(dbPassword).build();
        return new TracingConfiguration<>("RDB", source);
    }
}

组件核心就是上面这个配置类,简单封装了一下Elastic-Job必要的参数,为下面这个项目中使用的定时类,提供必要的配置。

import org.apache.shardingsphere.elasticjob.api.JobConfiguration;
import org.apache.shardingsphere.elasticjob.lite.api.bootstrap.impl.ScheduleJobBootstrap;
import org.apache.shardingsphere.elasticjob.reg.base.CoordinatorRegistryCenter;
import org.apache.shardingsphere.elasticjob.tracing.api.TracingConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

/**
 * @Classname JobConfig
 * @Date 2022/3/29 17:52
 * @Author WangZY
 * @Description 定时任务配置类
 */
@Component
public class JobConfig {

    public static final String time_zone = "GMT+08:00";
    //注入TracingConfiguration和CoordinatorRegistryCenter两个必要的配置类,固定配置  
    @Autowired
    private TracingConfiguration tracingConfiguration;
    @Autowired
    private CoordinatorRegistryCenter zkRegCenter;
    @Autowired
    private ErpBudgetSchedule erpBudgetSchedule;

    //定时任务详情配置,一般只用改newBuilder里,这里是任务唯一ID,分片给1就行,如果需要多个分片共同参与运算则给多个。
    //cron表达式自己写,描述改一下,其他不用动了
    private JobConfiguration createErpBudgetSchedule() {
        JobConfiguration job = JobConfiguration
                .newBuilder("Dashboard-ErpBudgetSchedule", 1)
                .cron("0 0 1 ? * *").description("资金管理")
                .overwrite(true).jobErrorHandlerType("LOG").timeZone(time_zone).build();
        job.getExtraConfigurations().add(tracingConfiguration);
        return job;
    }
    private JobConfiguration createErpBudgetSchedule() {
        JobConfiguration job = JobConfiguration
                .newBuilder("Dashboard-ErpBudgetSchedule", 1)
                .cron("0 0 1 ? * *").description("资金管理")
                .overwrite(true).jobErrorHandlerType("LOG").timeZone(time_zone).build();
        job.getExtraConfigurations().add(tracingConfiguration);
        return job;
    }
    @PostConstruct
    public void runSchedule() {
        //这里固定写法,只用改第二和第三个参数即可
        new ScheduleJobBootstrap(zkRegCenter, erpBudgetSchedule, createErpBudgetSchedule()).schedule();
        new ScheduleJobBootstrap(zkRegCenter, analisisReportSchedule, analysisReportScheduleTask()).schedule();
        .......
    }
}

写在最后

就在昨天也就是周天的时候呢,发生了一件对我来说特别有意义的好事,哈哈,所以,我很高兴!在长时间的激动和喜悦之后呢,决定临时加更一篇文章,来平复我的心情,当然我并不是有什么大病,非得写文章来冷静。和朋友打了两把游戏,聊了一会儿,最后还是亢奋,没办法,得写点东西。写完日记之后,复盘了下这件好事,觉得正好要写东西吧,那就写篇文章。正好领导让我整理我做的轮子,毕竟就我一人开发,还开发了这么多轮子,没人知道怎么玩了,我一请假出问题就全白给。所以这篇文章应运而出,家人们,有好事发生,这文后语不写了,我先溜了!

好事告一段落,心情美滋滋,有点小紧张,哈哈,不过还算顺利。接着随便写写,但好像也没啥写的,那就重申一遍我的人生信条,我要让这痛苦压抑的世界绽放幸福快乐之花,向美好的世界献上祝福!!!

2023/5/12追加更新

  1. Caffeine增加遗漏的配置文件
  2. 追加封面图,云吸猫

读者朋友给我发了他拿我文档中的代码去问ChatGpt的截图,我懵了,真的泰裤辣!早知道ChatGpt能干这个,我自己写啥注释啊!牛逼的,真是长见识了。

image.png FjDNbMfBT-L_IuTguS7GsyEe1CwQ.jpg

最近有读者和朋友跟我提到过焦虑,我也焦虑,但是焦虑也没用啊,是吧,如果还想干这行,就多学习。不是说有多卷,工作还是不少的,吃饭是没问题的,保持学习的劲头。同时呢,有一些解压的爱好那是最好,我自己的话就是游戏和写博客,还会偶尔记记日记。

我不会劝你放下焦虑,更期待你有勇气面对可能到来的困境,诸君共勉!

最后小小的给自己推荐一波文章,因为本文目前是后端热榜第一,综合榜第二,阅读量来到了4K,收藏数来到了160,理论上这样的数据在掘金后端这个板块暂时是没有增长趋势了,那么我小推一下自己的文章应该不算引流吧,哈哈。算是给偶然点进来的读者们一个小小的惊喜!

写技术博客的这一年,有个人的成长也有与他人思想的碰撞,博主的个人介绍,一些对人生、工作、情感的思考和内省,希望给焦虑的你带来一丝慰藉。

如何挖掘项目中的亮点(多方向带案例),目前我最强也是全网独一份的文章,完全原创,帮我冲一下收藏吧,马上就可以上收藏榜了,谢谢大家!

最后的最后,还是忍不住想提起之前半场开香槟的蠢事,哈哈,这里给自己简单记一笔,下次别这么上头了。

猜你喜欢

转载自juejin.im/post/7230838101077540901