六脉神剑-我在公司造了六个轮子
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的源码中也有大量参考案例。
- 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延。使用lua脚本执行以上操作时,比redis普通操作快80%左右
- 原子操作。Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件,无需使用事务。
- 复用。客户端发送的脚本会永久存在redis中,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑。
说一下我写的脚本逻辑,首先获取当前key对应的值count,如果count不为null的情况下,再判断是否大于limit,如果大于说明超过漏斗最大值,将count设置为-1,标记为超过限制。如果小于limit,则将count值自增1.如果count为null,说明第一次进入,设置count为1。最后再刷新key的有效期并返回count值,用于切面逻辑判断。归还逻辑和进入逻辑相同,反向思考即可。
总结一下,限流器基于Lua+AOP,切点是@LimitMethod,注解参数是同时运行次数,使用场景是前后端的接口。@Around运行实际方法前进行限流(使用次数自增),@After后返还使用次数。作用是限制同时运行线程数,只有限流没有降级处理,超过的抛出异常中断方法。
读者提问:脚本最后一行失效时间重置的意图是啥?
换个相反的角度来看,如果去掉了重置失效时间的代码,是不是会存在一点问题?比如刚好进入限流后,此时流量为N,方法还没有运行完毕,这个key失效了。那么按照代码逻辑来看,生成一个新的key就从0开始,但是明明之前我还有N个流量没有执行完毕,也就是表面上看key的结果是最新的1,但实际上是1+N,这样流量就不准了。所以我这重置了下超时时间,确保方法在超时时间内运行完毕能顺利归还,保证流量数更新正确。
幂等性校验器
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追加更新
- Caffeine增加遗漏的配置文件
- 追加封面图,云吸猫
读者朋友给我发了他拿我文档中的代码去问ChatGpt的截图,我懵了,真的泰裤辣!早知道ChatGpt能干这个,我自己写啥注释啊!牛逼的,真是长见识了。
最近有读者和朋友跟我提到过焦虑,我也焦虑,但是焦虑也没用啊,是吧,如果还想干这行,就多学习。不是说有多卷,工作还是不少的,吃饭是没问题的,保持学习的劲头。同时呢,有一些解压的爱好那是最好,我自己的话就是游戏和写博客,还会偶尔记记日记。
我不会劝你放下焦虑,更期待你有勇气面对可能到来的困境,诸君共勉!
最后小小的给自己推荐一波文章,因为本文目前是后端热榜第一,综合榜第二,阅读量来到了4K,收藏数来到了160,理论上这样的数据在掘金后端这个板块暂时是没有增长趋势了,那么我小推一下自己的文章应该不算引流吧,哈哈。算是给偶然点进来的读者们一个小小的惊喜!
写技术博客的这一年,有个人的成长也有与他人思想的碰撞,博主的个人介绍,一些对人生、工作、情感的思考和内省,希望给焦虑的你带来一丝慰藉。
如何挖掘项目中的亮点(多方向带案例),目前我最强也是全网独一份的文章,完全原创,帮我冲一下收藏吧,马上就可以上收藏榜了,谢谢大家!
最后的最后,还是忍不住想提起之前半场开香槟的蠢事,哈哈,这里给自己简单记一笔,下次别这么上头了。
转载自:https://juejin.cn/post/7230838101077540901