Liumai Shenjian - I built six wheels in the company

2023/5/12 additional update, article filling, private goods sharing

foreword

I believe that many developers will have the idea of ​​​​making their own wheels. After all, only by improving efficiency can we create more room for fishing. As for me, I happen to be an efficient player, and I am fortunate enough to design and develop a lot of wheels by myself, and successfully promote them to the whole team. If there are readers and friends who have read my previous article, they must know the working situation of the blogger. My job is a front-line business brick-and-mortar party, so the wheels are slowly accumulated by myself in my spare time and weekends. Later, it was really easy to use, so I started to promote it, everyone improved efficiency, and everyone was like a dragon. But this thing, everything is good, there is one thing that is not good, since everyone's development efficiency has improved, the development man-hours given by the leaders have been shortened, huh. Not to mention these sad things, in fact, as far as I am concerned, components are also witnesses in the process of my learning and growth. From the stumbling at the beginning to the current ease of use, when I look back at the submission records of the year, I still find it very interesting.

This article not only has a brief analysis of the package structure of all components, but also an in-depth explanation of the core functions, as well as a version update record that I specially compiled, and I also specially fished out the submission time. The update record is pulled out, mainly to let readers understand the problems I encountered in the process of building the wheel, some struggles and the selection process from the process of change. Of course, there are some components mentioned before. I am lazy and put the link of the previous article, otherwise the article with more than 10,000 words will not fit. After writing the complete article, I found that there is no link to my small warehouse. Go to gitee.com/cloudswzy/g ... for readers who need it, which contains some function extractions of the following components.

Tool-Box (toolbox)

Brief analysis of package structure

├─annotation-annotation

│ IdempotencyCheck.java-idempotency check, with parameters

│ JasyptField.java-encrypted field, used for marking field

│ JasyptMethod.java- mark method encryption or decryption

│ LimitMethod.java-Limiter

├─aop

│ IdempotencyCheckHandler.java- idempotency check aspect

│ JasyptHandler.java- data encryption interface

│ LimitHandler.java-A current limiter based on the funnel idea

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

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

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

Finally, I would like to recommend a wave of articles to myself, because this article is currently No. 1 on the back-end hot list and No. 2 on the comprehensive list. The reading volume has reached 4K, and the number of favorites has reached 160. There is no growth trend in the sector for the time being, so if I push my own article, it should not be considered drainage, haha. It is a small surprise for the readers who click in by chance!

In this year of writing technical blogs, there are personal growth and collisions with other people’s thoughts . The blogger’s personal introduction, some thinking and introspection on life, work, and emotions, hope to bring a little comfort to you who are anxious.

How to dig out the highlights in the project (multiple directions with cases) , currently my strongest article is also the only one in the whole network, it is completely original, please help me to collect it, it will be on the list of favorites soon, thank you everyone!

In the end, I still can't help but want to mention the stupid thing about opening champagne in the first half, haha, just make a quick note for myself here, don't be so obsessed next time.

Guess you like

Origin juejin.im/post/7230838101077540901