功能扩展
用户创建分组限制最大数量
使用redissonClient(分布式锁实现)
短链接验证布隆过滤器域名冲突
使用的是默认的域名,createShortLinkDefaultDomain。
公网环境部署系统如何做流量风控
短链接后管:
根据登录用户做出控制,比如 x 秒请求后管系统的频率最多 x 次。
实现原理也比较简单,通过 Redis increment
命令对一个数据进行递增,如果超过 x 次就会返回失败。这里有个细节就是我们的这个周期是 x 秒,需要对 Redis 的 Key 设置 x 秒有效期。
但是 Redis 中对于 increment
命令是没有提供过期命令的,这就需要两步操作,进而出现原子性问题。
为此,我们需要通过 LUA 脚本来保证原子性。
-- 设置用户访问频率限制的参数
local username = KEYS[1]
local timeWindow = tonumber(ARGV[1]) -- 时间窗口,单位:秒
-- 构造 Redis 中存储用户访问次数的键名
local accessKey = "short-link:user-flow-risk-control:" .. username
-- 原子递增访问次数,并获取递增后的值
local currentAccessCount = redis.call("INCR", accessKey)
-- 设置键的过期时间
redis.call("EXPIRE", accessKey, timeWindow)
-- 返回当前访问次数
return currentAccessCount
yml
short-link:
flow-limit:
enable: true
time-window: 1
max-access-count: 20
cofig
@Data
@Component
@ConfigurationProperties(prefix = "short-link.flow-limit")
public class UserFlowRiskControlConfiguration {
/**
* 是否开启用户流量风控验证
*/
private Boolean enable;
/**
* 流量风控时间窗口,单位:秒
*/
private String timeWindow;
/**
* 流量风控时间窗口内可访问次数
*/
private Long maxAccessCount;
}
common.biz.user
@Slf4j
@RequiredArgsConstructor
public class UserFlowRiskControlFilter implements Filter {
private final StringRedisTemplate stringRedisTemplate;
private final UserFlowRiskControlConfiguration userFlowRiskControlConfiguration;
private static final String USER_FLOW_RISK_CONTROL_LUA_SCRIPT_PATH = "lua/user_flow_risk_control.lua";
@SneakyThrows
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(USER_FLOW_RISK_CONTROL_LUA_SCRIPT_PATH)));
redisScript.setResultType(Long.class);
String username = Optional.ofNullable(UserContext.getUsername()).orElse("other");
Long result;
try {
result = stringRedisTemplate.execute(redisScript, Lists.newArrayList(username), userFlowRiskControlConfiguration.getTimeWindow());
} catch (Throwable ex) {
log.error("执行用户请求流量限制LUA脚本出错", ex);
returnJson((HttpServletResponse) response, JSON.toJSONString(Results.failure(new ClientException(FLOW_LIMIT_ERROR))));
return;
}
if (result == null || result > userFlowRiskControlConfiguration.getMaxAccessCount()) {
returnJson((HttpServletResponse) response, JSON.toJSONString(Results.failure(new ClientException(FLOW_LIMIT_ERROR))));
return;
}
filterChain.doFilter(request, response);
}
private void returnJson(HttpServletResponse response, String json) throws Exception {
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html; charset=utf-8");
try (PrintWriter writer = response.getWriter()) {
writer.print(json);
}
}
}
短链接中台:
根据接口进行流控,比如同一接口最大接受 20 QPS。
1. 引入 Sentinel
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-annotation-aspectj</artifactId>
</dependency>
2. 定义接口规则
定义需要风控接口的规则。
package com.nageoffer.shortlink.project.config;
import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 初始化限流配置
*/
@Component
public class SentinelRuleConfig implements InitializingBean {
@Override
public void afterPropertiesSet() throws Exception {
List<FlowRule> rules = new ArrayList<>();
FlowRule createOrderRule = new FlowRule();
createOrderRule.setResource("create_short-link");
createOrderRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
createOrderRule.setCount(1);
rules.add(createOrderRule);
FlowRuleManager.loadRules(rules);
}
}
如果触发风控,设置降级策略。
package com.nageoffer.shortlink.project.handler;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.nageoffer.shortlink.project.common.convention.result.Result;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;
/**
* 自定义流控策略
*/
public class CustomBlockHandler {
public static Result<ShortLinkCreateRespDTO> createShortLinkBlockHandlerMethod(ShortLinkCreateReqDTO requestParam, BlockException exception) {
return new Result<ShortLinkCreateRespDTO>().setCode("B100000").setMessage("当前访问网站人数过多,请稍后再试...");
}
}
在代码中引入 Sentinel 注解控制流控规则。
/**
* 创建短链接
*/
@PostMapping("/api/short-link/v1/create")
@SentinelResource(
value = "create_short-link",
blockHandler = "createShortLinkBlockHandlerMethod",
blockHandlerClass = CustomBlockHandler.class
)
public Result<ShortLinkCreateRespDTO> createShortLink(@RequestBody ShortLinkCreateReqDTO requestParam) {
return Results.success(shortLinkService.createShortLink(requestParam));
}
3. 微服务版本 Sentinel 如何接入?
启动 Sentinel 控制台,删除 Sentinel 定义的相关规则代码,加入以下配置即可。
删除的规则配置,在 Sentinel 中进行配置。
spring:
sentinel:
transport:
dashboard: localhost:8686
port: 8719
4. 压测脚本
jmx
消息队列重构短链接监控功能
海量访问短链接,直接访问数据库,会导致数据库负载变高,甚至数据库宕机。为此,需要引入消息队列削峰。
消息队列使用场景:从零到一学习RocketMQ | 拿个offer - 开源&项目实战
1. 为什么使用 Redis 充当消息队列?
轻量级(这里已经使用了redis作为缓存,为了减少组件的引入,所以这里用redis作消息队列)
2. Redis 实现消息队列的几种方式?
List、PubSub、Stream
使用 Redis 充当消息队列参考文章:Redis消息队列发展历程
3. 使用 Redis 消息队列后逻辑
未使用时:
使用后:
创建 Redis Stream Key 相关配置
在Redis Desktop Manager中写入命令
1. 创建 Stream Key
XADD "short_link:stats-stream" * "New key" "New value"
2. 创建消费者组
xgroup create short_link:stats-stream short_link:stats-stream:only-group 0
使用消息队列后的一些问题?
数据延迟、幂等
代码:
config
/**
* Redis Stream 消息队列配置
*/
@Configuration
@RequiredArgsConstructor
public class RedisStreamConfiguration {
private final RedisConnectionFactory redisConnectionFactory;
private final ShortLinkStatsSaveConsumer shortLinkStatsSaveConsumer;
@Bean
public ExecutorService asyncStreamConsumer() {
AtomicInteger index = new AtomicInteger();
int processors = Runtime.getRuntime().availableProcessors();
return new ThreadPoolExecutor(processors,
processors + processors >> 1,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("stream_consumer_short-link_stats_" + index.incrementAndGet());
thread.setDaemon(true);
return thread;
}
);
}
@Bean(initMethod = "start", destroyMethod = "stop")
public StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamMessageListenerContainer(ExecutorService asyncStreamConsumer) {
StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options =
StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder()
// 一次最多获取多少条消息
.batchSize(10)
// 执行从 Stream 拉取到消息的任务流程
.executor(asyncStreamConsumer)
// 如果没有拉取到消息,需要阻塞的时间。不能大于 ${spring.data.redis.timeout},否则会超时
.pollTimeout(Duration.ofSeconds(3))
.build();
StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamMessageListenerContainer =
StreamMessageListenerContainer.create(redisConnectionFactory, options);
streamMessageListenerContainer.receiveAutoAck(Consumer.from(SHORT_LINK_STATS_STREAM_GROUP_KEY, "stats-consumer"),
StreamOffset.create(SHORT_LINK_STATS_STREAM_TOPIC_KEY, ReadOffset.lastConsumed()), shortLinkStatsSaveConsumer);
return streamMessageListenerContainer;
}
}
yml
更改impl中的代码
发送流程:
mq/producer
/**
* 短链接监控状态保存消息队列生产者
*/
@Component
@RequiredArgsConstructor
public class ShortLinkStatsSaveProducer {
private final StringRedisTemplate stringRedisTemplate;
/**
* 发送延迟消费短链接统计
*/
public void send(Map<String, String> producerMap) {
stringRedisTemplate.opsForStream().add(SHORT_LINK_STATS_STREAM_TOPIC_KEY, producerMap);
}
}
mq/consumer
/**
* 短链接监控状态保存消息队列消费者
* 公众号:马丁玩编程,回复:加群,添加马哥微信(备注:link)获取项目资料
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShortLinkStatsSaveConsumer implements StreamListener<String, MapRecord<String, String, String>> {
private final ShortLinkMapper shortLinkMapper;
private final ShortLinkGotoMapper shortLinkGotoMapper;
private final RedissonClient redissonClient;
private final LinkAccessStatsMapper linkAccessStatsMapper;
private final LinkLocaleStatsMapper linkLocaleStatsMapper;
private final LinkOsStatsMapper linkOsStatsMapper;
private final LinkBrowserStatsMapper linkBrowserStatsMapper;
private final LinkAccessLogsMapper linkAccessLogsMapper;
private final LinkDeviceStatsMapper linkDeviceStatsMapper;
private final LinkNetworkStatsMapper linkNetworkStatsMapper;
private final LinkStatsTodayMapper linkStatsTodayMapper;
private final StringRedisTemplate stringRedisTemplate;
private final MessageQueueIdempotentHandler messageQueueIdempotentHandler;
@Value("${short-link.stats.locale.amap-key}")
private String statsLocaleAmapKey;
@Override
public void onMessage(MapRecord<String, String, String> message) {
String stream = message.getStream();
RecordId id = message.getId();
if (messageQueueIdempotentHandler.isMessageBeingConsumed(id.toString())) {
// 判断当前的这个消息流程是否执行完成
if (messageQueueIdempotentHandler.isAccomplish(id.toString())) {
return;
}
throw new ServiceException("消息未完成流程,需要消息队列重试");
}
try {
Map<String, String> producerMap = message.getValue();
ShortLinkStatsRecordDTO statsRecord = JSON.parseObject(producerMap.get("statsRecord"), ShortLinkStatsRecordDTO.class);
actualSaveShortLinkStats(statsRecord);
stringRedisTemplate.opsForStream().delete(Objects.requireNonNull(stream), id.getValue());
} catch (Throwable ex) {
// 某某某情况宕机了
messageQueueIdempotentHandler.delMessageProcessed(id.toString());
log.error("记录短链接监控消费异常", ex);
throw ex;
}
messageQueueIdempotentHandler.setAccomplish(id.toString());
}
public void actualSaveShortLinkStats(ShortLinkStatsRecordDTO statsRecord) {
String fullShortUrl = statsRecord.getFullShortUrl();
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(String.format(LOCK_GID_UPDATE_KEY, fullShortUrl));
RLock rLock = readWriteLock.readLock();
rLock.lock();
try {
LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
String gid = shortLinkGotoDO.getGid();
Date currentDate = statsRecord.getCurrentDate();
int hour = DateUtil.hour(currentDate, true);
Week week = DateUtil.dayOfWeekEnum(currentDate);
int weekValue = week.getIso8601Value();
LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
.pv(1)
.uv(statsRecord.getUvFirstFlag() ? 1 : 0)
.uip(statsRecord.getUipFirstFlag() ? 1 : 0)
.hour(hour)
.weekday(weekValue)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);
Map<String, Object> localeParamMap = new HashMap<>();
localeParamMap.put("key", statsLocaleAmapKey);
localeParamMap.put("ip", statsRecord.getRemoteAddr());
String localeResultStr = HttpUtil.get(AMAP_REMOTE_URL, localeParamMap);
JSONObject localeResultObj = JSON.parseObject(localeResultStr);
String infoCode = localeResultObj.getString("infocode");
String actualProvince = "未知";
String actualCity = "未知";
if (StrUtil.isNotBlank(infoCode) && StrUtil.equals(infoCode, "10000")) {
String province = localeResultObj.getString("province");
boolean unknownFlag = StrUtil.equals(province, "[]");
LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
.province(actualProvince = unknownFlag ? actualProvince : province)
.city(actualCity = unknownFlag ? actualCity : localeResultObj.getString("city"))
.adcode(unknownFlag ? "未知" : localeResultObj.getString("adcode"))
.cnt(1)
.fullShortUrl(fullShortUrl)
.country("中国")
.date(currentDate)
.build();
linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO);
}
LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
.os(statsRecord.getOs())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);
LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
.browser(statsRecord.getBrowser())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);
LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
.device(statsRecord.getDevice())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO);
LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder()
.network(statsRecord.getNetwork())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO);
LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
.user(statsRecord.getUv())
.ip(statsRecord.getRemoteAddr())
.browser(statsRecord.getBrowser())
.os(statsRecord.getOs())
.network(statsRecord.getNetwork())
.device(statsRecord.getDevice())
.locale(StrUtil.join("-", "中国", actualProvince, actualCity))
.fullShortUrl(fullShortUrl)
.build();
linkAccessLogsMapper.insert(linkAccessLogsDO);
shortLinkMapper.incrementStats(gid, fullShortUrl, 1, statsRecord.getUvFirstFlag() ? 1 : 0, statsRecord.getUipFirstFlag() ? 1 : 0);
LinkStatsTodayDO linkStatsTodayDO = LinkStatsTodayDO.builder()
.todayPv(1)
.todayUv(statsRecord.getUvFirstFlag() ? 1 : 0)
.todayUip(statsRecord.getUipFirstFlag() ? 1 : 0)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkStatsTodayMapper.shortLinkTodayState(linkStatsTodayDO);
} catch (Throwable ex) {
log.error("短链接访问量统计异常", ex);
} finally {
rLock.unlock();
}
}
}
消息队列重复消费问题如何解决?
当消息队列出现重复消费问题情况下,应该如何保障数据的准确性?
- 网络问题
- 生产重试
如何解决?
幂等。
代码:
mq/idempotent
/**
* 消息队列幂等处理器
*/
@Component
@RequiredArgsConstructor
public class MessageQueueIdempotentHandler {
private final StringRedisTemplate stringRedisTemplate;
private static final String IDEMPOTENT_KEY_PREFIX = "short-link:idempotent:";
/**
* 判断当前消息是否消费过
*
* @param messageId 消息唯一标识
* @return 消息是否消费过
*/
public boolean isMessageBeingConsumed(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
return Boolean.FALSE.equals(stringRedisTemplate.opsForValue().setIfAbsent(key, "0", 2, TimeUnit.MINUTES));
}
/**
* 判断消息消费流程是否执行完成
*
* @param messageId 消息唯一标识
* @return 消息是否执行完成
*/
public boolean isAccomplish(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
return Objects.equals(stringRedisTemplate.opsForValue().get(key), "1");
}
/**
* 设置消息流程执行完成
*
* @param messageId 消息唯一标识
*/
public void setAccomplish(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
stringRedisTemplate.opsForValue().set(key, "1", 2, TimeUnit.MINUTES);
}
/**
* 如果消息处理遇到异常情况,删除幂等标识
*
* @param messageId 消息唯一标识
*/
public void delMessageProcessed(String messageId) {
String key = IDEMPOTENT_KEY_PREFIX + messageId;
stringRedisTemplate.delete(key);
}
}
mq/consumer中ShortLinkStatsSaveConsumer中:
常见问题
1. 如果消费者消费失败了但没有执行到删除标识,该怎么办?
(此时因为已经在isMessageBeingConsumed方法中设置了redis,所以消息失败后,应该删除redis的缓存,防止其他消息无法消费)
比如网络宕机了,messageQueueIdempotentHandler.delMessageProcessed(id.toString())未执行,因为ack没有得到,所以mq会进行重试,会检查messageId有没有,如果有的话就会返回失败。所以在判断标识是否存在时,当已经存在标识时,还需要判断消费流程是否执行完成,防止未执行完成时,直接失败。完成等于1.
2. 为什么仅设置 2分钟的过期时间?
当生产者一直重发消息时,因为异常 redis中还有key,程序一直无法向下进行。而设计这两分钟后过期就恰好合理的解决了这个问题。
因为这个key其实是存在redis中的,如果时间过长,redis中缓存的key数据量就会大,占用内存多,所以设置时间短一些,可以减少key存储的数量。
3. 如何应对海量幂等 Key 所消耗的内存?
(因为存在redis中占用内存,所以是得不偿失的)
- MySQL 或其它大数据量存储。(不带自动删除(自动过期),所以需要通过其它方式进行设置)
- 想办法改造数据。(比如可以用布隆过滤器)
延迟队列:
/**
* 延迟记录短链接统计组件
*/
@Deprecated
@Slf4j
@Component
@RequiredArgsConstructor
public class DelayShortLinkStatsConsumer implements InitializingBean {
private final RedissonClient redissonClient;
private final ShortLinkService shortLinkService;
private final MessageQueueIdempotentHandler messageQueueIdempotentHandler;
public void onMessage() {
Executors.newSingleThreadExecutor(
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("delay_short-link_stats_consumer");
thread.setDaemon(Boolean.TRUE);
return thread;
})
.execute(() -> {
RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque = redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
for (; ; ) {
try {
ShortLinkStatsRecordDTO statsRecord = delayedQueue.poll();
if (statsRecord != null) {
if (messageQueueIdempotentHandler.isMessageBeingConsumed(statsRecord.getKeys())) {
// 判断当前的这个消息流程是否执行完成
if (messageQueueIdempotentHandler.isAccomplish(statsRecord.getKeys())) {
return;
}
throw new ServiceException("消息未完成流程,需要消息队列重试");
}
try {
shortLinkService.shortLinkStats(statsRecord);
} catch (Throwable ex) {
messageQueueIdempotentHandler.delMessageProcessed(statsRecord.getKeys());
log.error("延迟记录短链接监控消费异常", ex);
}
messageQueueIdempotentHandler.setAccomplish(statsRecord.getKeys());
continue;
}
LockSupport.parkUntil(500);
} catch (Throwable ignored) {
}
}
});
}
@Override
public void afterPropertiesSet() throws Exception {
// onMessage();
}
}
/**
* 延迟消费短链接统计发送者
*/
@Component
@Deprecated
@RequiredArgsConstructor
public class DelayShortLinkStatsProducer {
private final RedissonClient redissonClient;
/**
* 发送延迟消费短链接统计
*
* @param statsRecord 短链接统计实体参数
*/
public void send(ShortLinkStatsRecordDTO statsRecord) {
statsRecord.setKeys(UUID.fastUUID().toString());
RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque = redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
delayedQueue.offer(statsRecord, 5, TimeUnit.SECONDS);
}
}
短链接Redis缓存命名重构
project:
/**
* Redis Key 常量类
*/
public class RedisKeyConstant {
/**
* 短链接跳转前缀 Key
*/
public static final String GOTO_SHORT_LINK_KEY = "short-link:goto:%s";
/**
* 短链接空值跳转前缀 Key
*/
public static final String GOTO_IS_NULL_SHORT_LINK_KEY = "short-link:is-null:goto_%s";
/**
* 短链接跳转锁前缀 Key
*/
public static final String LOCK_GOTO_SHORT_LINK_KEY = "short-link:lock:goto:%s";
/**
* 短链接修改分组 ID 锁前缀 Key
*/
public static final String LOCK_GID_UPDATE_KEY = "short-link:lock:update-gid:%s";
/**
* 短链接延迟队列消费统计 Key
*/
public static final String DELAY_QUEUE_STATS_KEY = "short-link:delay-queue:stats";
/**
* 短链接统计判断是否新用户缓存标识
*/
public static final String SHORT_LINK_STATS_UV_KEY = "short-link:stats:uv:";
/**
* 短链接统计判断是否新 IP 缓存标识
*/
public static final String SHORT_LINK_STATS_UIP_KEY = "short-link:stats:uip:";
/**
* 短链接监控消息保存队列 Topic 缓存标识
*/
public static final String SHORT_LINK_STATS_STREAM_TOPIC_KEY = "short-link:stats-stream";
/**
* 短链接监控消息保存队列 Group 缓存标识
*/
public static final String SHORT_LINK_STATS_STREAM_GROUP_KEY = "short-link:stats-stream:only-group";
/**
* 创建短链接锁标识
*/
public static final String SHORT_LINK_CREATE_LOCK_KEY = "short-link:lock:create";
}
admin:
/**
* 短链接后管 Redis 缓存常量类
*/
public class RedisCacheConstant {
/**
* 用户注册分布式锁
*/
public static final String LOCK_USER_REGISTER_KEY = "short-link:lock_user-register:";
/**
* 分组创建分布式锁
*/
public static final String LOCK_GROUP_CREATE_KEY = "short-link:lock_group-create:%s";
/**
* 用户登录缓存标识
*/
public static final String USER_LOGIN_KEY = "short-link:login:";
}
推荐代码优雅的书:
《重构既有代码设计》、《代码整洁之道》
短链接生成重复为什么要再查询数据库?
补充:初始化Redis Stream Topic和消费组。
package com.nageoffer.shortlink.project.initialize;
/**
* 初始化短链接监控消息队列消费者组
*/
@Component
@RequiredArgsConstructor
public class ShortLinkStatsStreamInitializeTask implements InitializingBean {
private final StringRedisTemplate stringRedisTemplate;
@Override
public void afterPropertiesSet() throws Exception {
Boolean hasKey = stringRedisTemplate.hasKey(SHORT_LINK_STATS_STREAM_TOPIC_KEY);
if (hasKey == null || !hasKey) {
stringRedisTemplate.opsForStream().createGroup(SHORT_LINK_STATS_STREAM_TOPIC_KEY, SHORT_LINK_STATS_STREAM_GROUP_KEY);
}
}
}
正式:
private String generateSuffix(ShortLinkCreateReqDTO requestParam) {
int customGenerateCount = 0;
String shorUri;
while (true) {
if (customGenerateCount > 10) {
throw new ServiceException("短链接频繁生成,请稍后再试");
}
String originUrl = requestParam.getOriginUrl();
originUrl += UUID.randomUUID().toString();
// 短链接哈希算法生成冲突问题如何解决?详情查看:https://nageoffer.com/shortlink/question
shorUri = HashUtil.hashToBase62(originUrl);
// 判断短链接是否存在为什么不使用Set结构?详情查看:https://nageoffer.com/shortlink/question
// 如果布隆过滤器挂了,里边存的数据全丢失了,怎么恢复呢?详情查看:https://nageoffer.com/shortlink/question
if (!shortUriCreateCachePenetrationBloomFilter.contains(createShortLinkDefaultDomain + "/" + shorUri)) {
break;
}
customGenerateCount++;
}
return shorUri;
}
异常里为什么还查询数据库?
靠什么判断短链接是否存在?布隆过滤器。
有什么特点?
- 查询是否存在,如果返回存在,可能数据是不存在的。
- 查询是否存在,如果返回不存在,数据一定不存在。
并发场景下会出现短链接生成重复
同一毫秒下,大量请求相同的原始链接会生成重复短链接,并判断不存在,通过该方式访问数据库。为此,我们使用 UUID 替换了当前时间戳,来一定程度减少重复的短链接生成报错。
微服务改造
如何改造为微服务架构?
为什么要用微服务:
1. 模块化和独立性
- 微服务:微服务架构通过将应用拆分为小型、独立的服务,每个服务专注于特定的业务功能。这种模块化的设计使得每个服务都可以独立开发、部署、扩展和维护。
- 单体服务:在单体服务中,应用是一个大而臃肿的单一单元,修改一个功能可能会影响整个应用的部署。
2. 技术异构性
- 微服务:允许使用不同的技术栈和编程语言来构建不同的服务,以适应不同的需求。每个微服务可以选择最适合其特定任务的技术。
- 单体服务:通常需要在同一技术栈下构建整个应用。
3. 独立部署和扩展
- 微服务:允许独立部署和扩展每个服务,这样可以更灵活地应对流量变化和需求变更。
- 单体服务:需要整体部署和扩展,可能会导致资源浪费或性能瓶颈。
4. 团队自治
- 微服务:每个微服务通常由一个小团队负责,团队可以根据其服务的需求进行独立的决策,提高了开发团队的自治性。
- 单体服务:整个应用的变更需要协调整个团队,可能导致开发速度较慢和沟通成本较高。
5. 弹性和容错性
- 微服务:由于每个服务都是独立的,可以更容易实现服务的弹性和容错。一个服务的故障不会影响整个应用。
- 单体服务:一个组件的故障可能导致整个应用的崩溃。
6. 可维护性和可测试性
- 微服务:每个微服务的小规模和清晰的职责范围使得代码更容易理解、维护和测试。
- 单体服务:单体应用的复杂性可能导致代码难以理解,难以维护和测试。
短链接如何改造微服务:
1. 下载 Nacos
Nacos 部署:
2. 服务中引入 Nacos 进行服务注册
同理,在admin中一样映入pom文件等。
引入 Pom 文件:
服务自主发起注册。
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
如果是调用方,需要引入 OpenFeign 组件。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- openfeign 已不再提供默认负载均衡器 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
启动类添加 Nacos 注册中心注解。
@EnableDiscoveryClient
调用方,启动类还要加入注解:
@EnableFeignClients("com.nageoffer.shortlink.admin.remote")
配置 yaml :
spring:
application:
name: short-link-project
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
3. 改造现有代码通过 OpenFeign 调用
创建 OpenFeign 远程调用服务
@FeignClient("short-link-project")
public interface ShortLinkActualRemoteService {
// 调用接口
}
feign中get请求默认是不能传对象的,如果要传对象,需要做一些配置。
还有没办法接收一个接口当泛型,要用实体。所以分页那块把IPage改成了Page
/**
* 短链接中台远程调用服务
*/
@FeignClient(
value = "short-link-project",
url = "${aggregation.remote-url:}",
configuration = OpenFeignConfiguration.class
)
public interface ShortLinkActualRemoteService {
/**
* 创建短链接
*
* @param requestParam 创建短链接请求参数
* @return 短链接创建响应
*/
@PostMapping("/api/short-link/v1/create")
Result<ShortLinkCreateRespDTO> createShortLink(@RequestBody ShortLinkCreateReqDTO requestParam);
/**
* 查询分组短链接总量
*
* @param requestParam 分组短链接总量请求参数
* @return 查询分组短链接总量响应
*/
@GetMapping("/api/short-link/v1/count")
Result<List<ShortLinkGroupCountQueryRespDTO>> listGroupShortLinkCount(@RequestParam("requestParam") List<String> requestParam);
...
}
业务代码中引用
private final ShortLinkActualRemoteService shortLinkActualRemoteService;
shortLinkActualRemoteService.xxx();
一般业务的 类在应用时放上面,像redisson这种中间件引用时放下面。
引入网关架构SpringCloud-Gateway
为什么需要网关:
没有网关存在的一些问题:
- 路由管理&服务发现困难。
- 安全性难以管理:https 访问、黑白名单、用户登录和数据请求加密放篡改等。
- 负载均衡问题。
- 监控和日志难以集中管理。
- 缺乏统一的 API 管理。
没有网关:
有网关:
引入软件网关组件
更复杂的网关架构
流量网关和业务网关等。
引入 SpringCloud Gateway:
1. 引入 Pom 文件
引入 SpringCloud Gateway 相关的 Pom 组件。视频讲解中漏掉一个 build 标签,正常不会影响运行,但是打包的 Jar 文件不能运行。
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
<!-- Unable to load io.netty.resolver.dns.macos.MacOSDnsServerAddressStreamProvider xxx -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
2. 创建网关启动类
/**
* 网关服务应用启动器
*/
@SpringBootApplication
public class GatewayServiceApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayServiceApplication.class, args);
}
}
3. 添加网关配置文件
server:
port: 8000
spring:
application:
name: short-link-gateway
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
gateway:
routes:
- id: short-link-admin
uri: lb://short-link-admin/api/short-link/admin/**
predicates:
- Path=/api/short-link/admin/**
filters:
- name: TokenValidate
args:
whitePathList:
- /api/short-link/admin/v1/user/login
- /api/short-link/admin/v1/user/has-username
- id: short-link-project
uri: lb://short-link-project/api/short-link/**
predicates:
- Path=/api/short-link/**
filters:
- name: TokenValidate
4. 添加用户登录拦截器
添加白名单配置类:
package com.nageoffer.shortlink.gateway.config;
/**
* 过滤器配置
*/
@Data
public class Config {
/**
* 白名单前置路径
*/
private List<String> whitePathList;
}
添加网关错误返回信息。
package com.nageoffer.shortlink.gateway.dto;
/**
* 网关错误返回信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GatewayErrorResult {
/**
* HTTP 状态码
*/
private Integer status;
/**
* 返回信息
*/
private String message;
}
添加用户登录拦截器。
package com.nageoffer.shortlink.gateway.filter;
/**
* SpringCloud Gateway Token 拦截器
*/
@Component
public class TokenValidateGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {
private final StringRedisTemplate stringRedisTemplate;
public TokenValidateGatewayFilterFactory(StringRedisTemplate stringRedisTemplate) {
super(Config.class);
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String requestPath = request.getPath().toString();
String requestMethod = request.getMethod().name();
if (!isPathInWhiteList(requestPath, requestMethod, config.getWhitePathList())) {
String username = request.getHeaders().getFirst("username");
String token = request.getHeaders().getFirst("token");
Object userInfo;
if (StringUtils.hasText(username) && StringUtils.hasText(token) && (userInfo = stringRedisTemplate.opsForHash().get("short-link:login:" + username, token)) != null) {
JSONObject userInfoJsonObject = JSON.parseObject(userInfo.toString());
ServerHttpRequest.Builder builder = exchange.getRequest().mutate().headers(httpHeaders -> {
httpHeaders.set("userId", userInfoJsonObject.getString("id"));
httpHeaders.set("realName", URLEncoder.encode(userInfoJsonObject.getString("realName"), StandardCharsets.UTF_8));
});
return chain.filter(exchange.mutate().request(builder.build()).build());
}
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.writeWith(Mono.fromSupplier(() -> {
DataBufferFactory bufferFactory = response.bufferFactory();
GatewayErrorResult resultMessage = GatewayErrorResult.builder()
.status(HttpStatus.UNAUTHORIZED.value())
.message("Token validation error")
.build();
return bufferFactory.wrap(JSON.toJSONString(resultMessage).getBytes());
}));
}
return chain.filter(exchange);
};
}
private boolean isPathInWhiteList(String requestPath, String requestMethod, List<String> whitePathList) {
return (!CollectionUtils.isEmpty(whitePathList) && whitePathList.stream().anyMatch(requestPath::startsWith)) || (Objects.equals(requestPath, "/api/short-link/admin/v1/user") && Objects.equals(requestMethod, "POST"));
}
}
5. 后管系统改造事项
5.1. 删除用户未登录错误码
因为通过 HTTP status 401 来标识用户未登录,所以需要删除后管中的自定义错误码。
package com.nageoffer.shortlink.admin.common.enums;
import com.nageoffer.shortlink.admin.common.convention.errorcode.IErrorCode;
/**
* 用户错误码
*/
public enum UserErrorCodeEnum implements IErrorCode {
// 需要删除
USER_TOKEN_FAIL("A000200", "用户Token验证失败"),
USER_NULL("B000200", "用户记录不存在"),
USER_NAME_EXIST("B000201", "用户名已存在"),
USER_EXIST("B000202", "用户记录已存在"),
USER_SAVE_ERROR("B000203", "用户记录新增失败");
private final String code;
private final String message;
UserErrorCodeEnum(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
5.2. 修改用户拦截器
将之前的操作已经迁移至网关识别,为此,该拦截器只需要保留用户上下文代码即可。
package com.nageoffer.shortlink.admin.common.biz.user;
/**
* 用户信息传输过滤器
*/
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {
@SneakyThrows
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String username = httpServletRequest.getHeader("username");
if (StrUtil.isNotBlank(username)) {
String userId = httpServletRequest.getHeader("userId");
String realName = httpServletRequest.getHeader("realName");
UserInfoDTO userInfoDTO = new UserInfoDTO(userId, username, realName);
UserContext.setUser(userInfoDTO);
}
try {
filterChain.doFilter(servletRequest, servletResponse);
} finally {
UserContext.removeUser();
}
}
}
因为之前 Redis 操作通过构造函数创建,所以同时需要改造创建方式。
@Configuration
public class UserConfiguration {
/**
* 用户信息传递过滤器
*/
@Bean
public FilterRegistrationBean<UserTransmitFilter> globalUserTransmitFilter() {
FilterRegistrationBean<UserTransmitFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new UserTransmitFilter());
registration.addUrlPatterns("/*");
registration.setOrder(0);
return registration;
}
}
5.3. 修改前端代码
调整 vite.config.js 文件调用后端的端口需要从 8002 改为 8000 网关端口。
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, '') // 不可以省略rewrit
}
}
}
})
调整 axios.js 文件的用户未登录跳转方式,之前通过 res.data.code === 'A000200'
判断,现在通过 err.response.status === 401
判断。
import axios from 'axios'
import { getToken, getUsername } from '@/core/auth.js'
// import Router from '../router'
import { ElMessage } from 'element-plus'
import { isNotEmpty } from '@/utils/plugins.js'
import { useRouter } from 'vue-router'
const router = useRouter()
// const baseURL = '/resourcesharing/organizational'
const baseURL = '/api/short-link/admin/v1'
// 创建实例
const http = axios.create({
// api 代理为服务器请求地址
baseURL: '/api' + baseURL,
timeout: 15000
})
// 请求拦截 -->在请求发送之前做一些事情
http.interceptors.request.use(
(config) => {
config.headers.Token = isNotEmpty(getToken()) ? getToken() : ''
config.headers.Username = isNotEmpty(getUsername()) ? getUsername() : ''
// console.log('获取到的token和username', getToken(), getUsername())
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截 -->在返回结果之前做一些事情
http.interceptors.response.use(
(res) => {
if (res.status == 0 || res.status == 200) {
// 请求成功对响应数据做处理,此处返回的数据是axios.then(res)中接收的数据
// code值为 0 或 200 时视为成功
return Promise.resolve(res)
}
return Promise.reject(res)
},
(err) => {
// 在请求错误时要做的事儿
// 此处返回的数据是axios.catch(err)中接收的数据
if (err.response.status === 401) {
localStorage.removeItem('token')
router.push('/login')
}
return Promise.reject(err)
}
)
export default http
补充:@SneakyThrows的作用
@SneakyThrows是Lombok库提供的一个注解,其作用主要用于处理Java中的受检异常。
- 自动转换异常:在方法上使用@SneakyThrows注解后,该方法中抛出的所有检查型异常(checked exceptions)会被自动转换为非检查型异常(unchecked exceptions),即
java.lang.RuntimeException
或其子类。这样,开发者就无需在方法签名中声明这些检查型异常,也无需在方法体内显式地编写try-catch语句来处理它们。 - 简化代码:通过自动转换异常,@SneakyThrows注解可以显著减少代码量,使代码更加简洁。它避免了在方法签名中声明大量检查型异常,并减少了方法体内的异常处理代码。
引入网关架构后如何访问中台?
后管作为可视化界面方式操作短链接系统,中台作为提供后管接口调用以及 API 等多种调用方式。
此时,中台应用就需要进行独立的用户登录验证逻辑。
密钥方式
在用户记录生成时创建唯一的密钥进行保存,每次访问时都携带该密钥访问即可。
和后管沿用一套方案
和当前后管服务沿用一套登录机制,每次都带上用户的登录 Token 访问中台接口即可。
Q:如果用户在后管中操作了退出登录如何解决?
A:应该在客户端应用调用后,发现请求返回的 401,重新调用登录接口,再发起一次调用即可。
Q:如果用户登录状态失效,会请求 401 如何解决?
A:改造登录接口,如果用户已登录情况,那么重新刷新有效期。
开发短链接聚合服务
微服务中的聚合服务,顾名思义,是指将多个相关的微服务或服务功能聚集在一起,形成一个更高级别、更综合的服务单元。这种服务模式在微服务架构中尤为重要,因为它有助于优化服务间的协同工作,减少服务间的通信成本,提升系统的整体性能和响应速度。
创建聚合服务
创建聚合服务 Modules(aggregation),并创建对应的启动类和 Pom.xml。
1. 聚合服务启动类
package com.nageoffer.shortlink.aggregation;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
/**
* 短链接聚合应用
*/
@SpringBootApplication(scanBasePackages = {
"com.nageoffer.shortlink.admin",
"com.nageoffer.shortlink.project"
})
@EnableDiscoveryClient
@MapperScan(value = {
"com.nageoffer.shortlink.project.dao.mapper",
"com.nageoffer.shortlink.admin.dao.mapper"
})
public class AggregationServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AggregationServiceApplication.class, args);
}
}
2. 聚合服务 Pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.nageoffer.shortlink</groupId>
<artifactId>shortlink-all</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>shortlink-aggregation</artifactId>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>shortlink-admin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>shortlink-project</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
创建聚合服务应用配置
1. application.yaml
server:
port: 8003
spring:
application:
name: short-link-aggregation
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-config-${database.env:dev}.yaml
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
mvc:
view:
prefix: /templates/
suffix: .html
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
short-link:
group:
max-num: 20
flow-limit:
enable: true
time-window: 1
max-access-count: 20
domain:
default: nurl.ink:8003
stats:
locale:
amap-key: 824c511f0997586ea016f979fdb23087
goto-domain:
white-list:
enable: true
names: '拿个offer,知乎,掘金,博客园'
details:
- nageoffer.com
- zhihu.com
- juejin.cn
- cnblogs.com
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath:mapper/*.xml
2. shardingsphere-config-dev.yaml
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/link?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
- !SHARDING
tables:
t_user:
actualDataNodes: ds_0.t_user_${0..15}
tableStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: user_table_hash_mod
t_group:
actualDataNodes: ds_0.t_group_${0..15}
tableStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: group_table_hash_mod
t_link:
actualDataNodes: ds_0.t_link_${0..15}
tableStrategy:
standard:
shardingColumn: gid
shardingAlgorithmName: link_table_hash_mod
t_link_goto:
actualDataNodes: ds_0.t_link_goto_${0..15}
tableStrategy:
standard:
shardingColumn: full_short_url
shardingAlgorithmName: link_goto_table_hash_mod
t_link_stats_today:
actualDataNodes: ds_0.t_link_stats_today_${0..15}
tableStrategy:
standard:
shardingColumn: gid
shardingAlgorithmName: link_stats_today_hash_mod
bindingTables:
- t_link, t_link_stats_today
shardingAlgorithms:
user_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
group_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
link_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
link_goto_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
link_stats_today_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
- !ENCRYPT
tables:
t_user:
columns:
phone:
cipherColumn: phone
encryptorName: common_encryptor
mail:
cipherColumn: mail
encryptorName: common_encryptor
queryWithCipherColumn: true
encryptors:
common_encryptor:
type: AES
props:
aes-key-value: d6oadClrrb9A3GWo
props:
sql-show: true
3. shardingsphere-config-prod.yaml
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/link?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: PHRmUcd6ZpM0506N1wldC9EKsix77VA8HwMHloLJPZtxSkMnRfEKSn8SYpvcaI5
rules:
- !SHARDING
tables:
t_user:
actualDataNodes: ds_0.t_user_${0..15}
tableStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: user_table_hash_mod
t_group:
actualDataNodes: ds_0.t_group_${0..15}
tableStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: group_table_hash_mod
t_link:
actualDataNodes: ds_0.t_link_${0..15}
tableStrategy:
standard:
shardingColumn: gid
shardingAlgorithmName: link_table_hash_mod
t_link_goto:
actualDataNodes: ds_0.t_link_goto_${0..15}
tableStrategy:
standard:
shardingColumn: full_short_url
shardingAlgorithmName: link_goto_table_hash_mod
t_link_stats_today:
actualDataNodes: ds_0.t_link_stats_today_${0..15}
tableStrategy:
standard:
shardingColumn: gid
shardingAlgorithmName: link_stats_today_hash_mod
bindingTables:
- t_link, t_link_stats_today
shardingAlgorithms:
user_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
group_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
link_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
link_goto_table_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
link_stats_today_hash_mod:
type: HASH_MOD
props:
sharding-count: 16
- !ENCRYPT
tables:
t_user:
columns:
phone:
cipherColumn: phone
encryptorName: common_encryptor
mail:
cipherColumn: mail
encryptorName: common_encryptor
queryWithCipherColumn: true
encryptors:
common_encryptor:
type: AES
props:
aes-key-value: d6oadClrrb9A3GWo
props:
sql-show: true
改造后管服务调用中台的方式
1. 添加配置
aggregation:
remote-url: http://127.0.0.1:${server.port}
2. FeignClient 改造
@FeignClient(value = "short-link-project", url = "${aggregation.remote-url:}")
配置网关访问聚合服务
1. application.yaml
server:
port: 8000
spring:
application:
name: short-link-gateway
profiles:
active: aggregation
# active: dev
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
2. application-dev.yaml
spring:
cloud:
gateway:
routes:
- id: short-link-admin
uri: lb://short-link-admin/api/short-link/admin/**
predicates:
- Path=/api/short-link/admin/**
filters:
- name: TokenValidate
args:
whitePathList:
- /api/short-link/admin/v1/user/login
- /api/short-link/admin/v1/user/has-username
- id: short-link-project
uri: lb://short-link-project/api/short-link/**
predicates:
- Path=/api/short-link/**
filters:
- name: TokenValidate
3. application-aggregation.yaml
spring:
cloud:
gateway:
routes:
- id: short-link-admin-aggregation
uri: lb://short-link-aggregation/api/short-link/admin/**
predicates:
- Path=/api/short-link/admin/**
filters:
- name: TokenValidate
args:
whitePathList:
- /api/short-link/admin/v1/user/login
- /api/short-link/admin/v1/user/has-username
- id: short-link-project-aggregation
uri: lb://short-link-aggregation/api/short-link/**
predicates:
- Path=/api/short-link/**
filters:
- name: TokenValidate
聚合服务中:
由于project和admin有很多相同的bean,所以需要在project和admin中对bean加名字。
@ConditionalOnMissingBean条件注解,检查ioc里面有没有这个bean,有的话就不注入了。
@Primary注解的作用:
标记首选Bean:@Primary
注解用于标记一个Bean作为在多个同类型的Bean候选中进行自动装配时的首选Bean。当一个接口有多个实现类,或者多个Bean属于同一类型时,使用@Primary
注解可以明确指定哪一个Bean应该被优先考虑。这样,在注入该类型的Bean时,Spring容器会优先选择带有@Primary
注解的Bean进行注入。
解决自动装配冲突:在Spring容器中,如果存在多个相同类型的Bean,而自动装配时又未明确指定具体哪一个Bean,那么就会出现自动装配冲突的问题。使用@Primary
注解可以明确指定哪一个Bean应该被优先考虑,从而避免这种冲突,确保注入过程的顺利进行。
简化配置:通过@Primary
注解,开发人员可以避免在每个注入点使用@Qualifier
注解来指定具体的Bean名称,从而简化了代码和配置。这不仅使得代码更加简洁,也提高了代码的可读性和可维护性。
使用场景
- 当一个接口有多个实现类时,可以使用
@Primary
注解来指定其中一个实现类作为默认的候选项。 - 在配置和自动装配复杂的Spring应用程序时,特别是当有多个Bean实现相同的接口或继承相同的类时,
@Primary
注解非常有用。
线上环境部署短链接服务(聚合服务)
(使用java -jar这种形式启动项目,和idea启动是不一样的,一个是用的tomcat的类加载器,一个是用的idea的类加载器。
使用聚合模式时,需要把admin和project中的builder删除,只留聚合服务中的builder。)
云服务器Linux安装Docker、
云服务器部署短链接项目
如何通过域名访问线上服务
流程解析
- 创建个人信息实名模板;
- 购买轻量级云服务器;
- 购买中意的域名,建议有实际意义的;
- 申请服务码;
- 域名备案;
- 备案完成后,进行控制台访问 DNS 解析;
- 设置短链接跳转 DNS 和 Nginx 解析。