SaaS短链接系统-新手从零学习 5 -短链接管理(上

eve2333 发布于 26 天前 24 次阅读


第01节:短链接模块功能分析

功能分析

  • 短链接跳转原理
  • 创建短链接表
  • 新增短链接
  • Host添加域名映射
  • 分页查询短链接集合
  • 编辑短链接
  • 将短链接删除(回收站)

第02节:短链接跳转原始链接原理  

比如说你在某宝或者某东上面去访问他们的商家,比如说要做活动了,他会给你发这种营销短信对吧?上面有个什么“戳一下”那种链接对吧?这种的话我们点完这个链接,他们会跳转到对应的一个原始网址。大家可能会问,我们为什么要用这种短链接呢?我直接把原来的网站链接复制过去不就行了?

大家可能不知道,类似于我们这种短信或者微博,它的内容长度是有限制的。如果说你把这种原始链接直接复制上去,它会非常长,这样的话你本来想打一些字,你就得缩短,远不如这种短链的效果要好。然后其次的话,你像这种短信,它的长度和它的付费是产生直接关系的。一般来说,70个字以内是按一条短信来算的。假如你一条短信超过了70个字,每超过69个字就会按两条、三条一直往上叠加。相当于超过70字之后,每69个字就算作一条。大家知道了吧?如果说你用那种很长的链接,你这个短信的费用不就超了吗?所以我们用短链接转换一下,你的费用以及你能打的字肯定就更多了。

我们点一下短链接,它就会跳转到我们的原始链接,它对应的底层原理是什么呢?我们复制一下,然后按 F12 访问一下。跳过来了,这个时候这边是我们第一个访问的原始链接,“leafefxe”对吧?这个是我们的短链接。然后看到没有?HTTP 状态码是 302,一般三开头的都是重定向的状态码。

302 你看它做了什么,它又紧接着访问了我们对应的原始链接,然后状态码变成了 200 了,对吧?这样的话我们可以得出一个结论:首先他访问了一下短链接地址,然后通过这个短链接跳转到了目标地址。

我们来总结一下这个原理,我们画一张图。我们创建一个新的绘图:短链接管理,创建。我们先把这个网络去掉,然后首先用户——就不说“不用户了”,直接是用户访问浏览器,对不对?

然后通过浏览器去访问什么呢?等几个词对吧?有些情况下是直接用 Nginx 再搭配一个对应的管理平台直接就做了,但是我们这边不是这么做的,我们是直接对接到我们的微服务里面去,然后跳转到我们的短链接服务集群。然后这里把集群给拉起来,然后移到最后。然后通过短链接服务,跳转到目标网站。在跳转目标网站之前,我们要做一些什么事情呢?监控和采集相关信息。

首先这些数据是很重要的,如果我们做这些跳转但没有监控,只是一个简单的跳转,那其实没有什么特别大的价值。所以说我们这里面要做监控,采集相关的信息。不过这个用户基本上是看不到的,用户对这个是没有感知的,所以我们在流程上加一个虚线,表示采集一些相关信息。OK,那问题来了,在跳转目标网站之前,我们是不是要先获取到对应的原始链接呢?你怎么获取?你总不能直接调数据库对不对?它一般的做法是什么呢?我们会再加一层。用户访问,通过 Redis 获取目标地址。访问 Redis,获取目标地址,然后获取完目标地址之后,干什么?拿到之后去跳转到目标网站。

这样的话其实就是一个比较合理的系统设计了。

然后其实这边还有一些情况,我们不可能把所有的短链接都统一放到 Redis 里面去,因为你有些链接访问之后其实很长时间就不会再访问了。比如今年是六一儿童节做个活动,可能也就六一高峰期用,到七月份它就不用了,后续也不用了。

这就涉及到一个问题,就是数据的过期以及与数据库之间的同步问题。不过这不是我们现在讲的重点,等到我们讲到 Redis 的跳转那一块的时候,我会跟大家详细说明。

大家现在只需要知道我们现在这个跳转的原理就可以了。然后我们通过 302 跳转

一般以 3xx 开头的代表重定向,表示网页发生了转移,需要重定向到对应的地址中去,两者区别是:

  • 301:表示永久性转移(Permanently Moved)
    什么是永久性重定向呢?举个例子,在浏览器上访问某个 URL 的时候,如果是 301 跳转,它只会第一次去调用你的后端短链接服务。调用之后,浏览器就会把这个跳转缓存下来。
    也就是说,当你第一次访问的时候,它会去请求你的短链接服务集群,获取对应的目标地址。但是一旦缓存生效,第二次访问的时候,它就不会再请求你的后端服务了,因为它已经知道这是永久性的跳转了。
  • 302:表示临时性转移(Temporarily Moved)
    就是不管用户访问多少次这个短链接,每次都会向后端发起请求,去获取对应的目标地址。
    301只访问短链接一次,以后都直接跳转到原链接。不能采集用户信息,所以使用302

对于互联网应用来说,从数据质量和用户行为分析的角度来看,全量的数据是非常重要的。虽然 302 带来的服务器压力会比 301 大一些,但我们并不怕这种压力。我们可以通过加缓存、加机器、提升配置来解决这些问题。但用户的行为数据一定要全部拿到,这才是我们短链接跳转系统的核心价值所在。 

第03节:创建短链接数据库表

短链接表结构

CREATE TABLE `t_link` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `domain` varchar(128) DEFAULT NULL COMMENT '域名',
  `short_uri` varchar(8) DEFAULT NULL COMMENT '短链接',
  `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',
  `origin_url` varchar(1024) DEFAULT NULL COMMENT '原始链接',
	`click_num` int(11) DEFAULT 0 COMMENT '点击量',
	`gid` varchar(32) DEFAULT NULL COMMENT '分组标识',
	`enable_status` tinyint(1) DEFAULT NULL COMMENT '启用标识 1:未启用 0:已启用',
	`created_type` tinyint(1) DEFAULT NULL COMMENT '创建类型 0:控制台 1:接口',
	`valid_date_type` tinyint(1) DEFAULT NULL COMMENT '有效期类型 0:永久有效 1:用户自定义',
	`valid_date` datetime DEFAULT NULL COMMENT '有效期',
	`describe` varchar(1024) DEFAULT NULL COMMENT '描述',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '修改时间',
  `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_unique_full_short_url` (`full_short_url`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我们作为一个SaaS的一种短链接平台 ,那肯定是支持在控制台上创建,其次我们也要支持别人通过接口的形式去创建,因此create_type 

短链接唯一?

  • 全局唯一:单一短链接在所有域名下唯一,全平台唯一。
  • 域名下唯一:单一短链接仅保证域名下唯一。

URI、URL和URN区别 URI 指的是一个资源 URL 用地址定位一个资源; URN 用名称定位一个资源。 举个例子: 去寻找一个具体的人(URI);如果用地址:XX省XX市XX区...XX单元XX室的主人 就是URL;如果用身份证号+名字去找就是URN(身份证号+名字 无法确认资源的地址) 。 在Java类库中,URI类不包含任何访问资源的方法,只能标识资源。URL类可以访问资源,可以获取指定资源的流信息。

原文:URI、 URL 和 URN 的区别 - 简书

短链接:/abcdef

a.com/abcdef

b.com/abcdef

在所有域名下唯一,全平台唯一,那也就相当于不管你是哪个域名,比如说你是 a.com 下面,你和 b.com 下面,它们不能有相同的短链接。如果全局唯一的话,这两个域名下就不能同时存在相同的短链接。

但如果只是“域名下唯一”的话,就表示这个短链接只在当前域名下是唯一的,不同域名之间可以重复使用。比如 a.com 下有一个短链接是 abcdef,跳转到某个目标页面。如果是全局唯一,那么只要 a.com 下已经有了这个短链接,b.com 就不能再使用 abcdef 了;但如果是“域名下唯一”,那么 b.com 是可以用这个 abcdef 的。

相比较来说,“域名下唯一”其实是更好的选择,对吧?因为你这个 abcdef 在不同的语义下,可能代表不同的含义,在不同的域名下也不会冲突。

所以我们要保证的是“域名下唯一”。那这样的话我们该怎么办呢?就不能再拿短链接本身作为唯一的索引了。我们需要干什么?要用完整的短链接地址来当作唯一索引。

完整的短链接地址应该包括:域名 + 短链路径。也就是说,这两个组合在一起才是唯一的。例如我们加上一个 x-queue-1.0,然后是 four-shot-1.0,接着是 bc,然后是一个 four-shot-1.0。这样,我们的短链接表结构就创建完成了。

我们再来梳理一下:一条正常的短链接记录应该是长这样的。

版本字段我们可以设置为 HTTP 或 HTTPS,例如我们之前买的一个域名可能是 nurl.ink

好,比如我们访问 /123456,那完整的短链接地址就是 https://nurl.ink/123456 。这个时候它就会跳转到原始链接对应的地址。

点击次数这个字段是比较灵活的,它是会变化的,不是一成不变的。我们在设计的时候也要考虑到它的实时更新问题。

然后我们说一下 Giddy(应为 Redis),这里对接的部分也有讲究。比如说,如果你是默认分组,因为我们进入这个 SaaS 平台的时候,并不强制用户一定要去创建分组,他可以选择默认分组。

我们想一下,默认分组的单词怎么拼来着?是 default 对吧?我们在系统里加一个 default 字段,如果不填,系统也会自动分配到默认分组里。

安全策略、CSP(内容安全策略)这些是我们手动创建时才会有的配置项。有效期类型我们也可以设为“永久有效”,这部分暂时先空着。

测试短链接我们也可以先创建一个看看效果。创建时间我们可以直接赋默认值,为什么没设置成功?default 这里怎么没生效?好奇怪,算了,这个问题不重要,回头我自己再检查一下。

这样的话其实就可以了。使用的原理就是当我们访问 /123456 的时候,它就会跳转到我们预先设定好的那个原始链接地址,这就是我们期望的效果。

第04节:新增短链接(上)

一种独特的hash算法,

假设我们使用的是 26 个字母的大小写,加上 10 个数字,那么对于短链接可以表示的最大组合数量为:

  • N = 4,组合数为 62 ^ 4 = 14_776_336,1477 万左右
  • N = 5,组合数为 62 ^ 5 = 916_132_832,9.16 亿左右
  • N = 6,组合数为 62 ^ 6 = 56_800_235_584,568 亿左右

生成短链接的时候,只需要生成一个唯一的 10 进制数,然后再基于此 10 进制数转换为 62 进制数即可。 

 它不只是能在控制台上去创建,它可以给一个给你一个API的一个TOKEN,你可以通过接口去生成,应对这种海量的并发场景不能写到后端admin了,在project中新建toolkit软件包,下面文件

package com.nageoffer.shortlink.project.toolkit;

import cn.hutool.core.lang.hash.MurmurHash;

/**
 * HASH 工具类
 */
public class HashUtil {

    private static final char[] CHARS = new char[]{
            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
            'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
    };

    private static final int SIZE = CHARS.length;

    private static String convertDecToBase62(long num) {
        StringBuilder sb = new StringBuilder();
        while (num > 0) {
            int i = (int) (num % SIZE);
            sb.append(CHARS[i]);
            num /= SIZE;
        }
        return sb.reverse().toString();
    }

    public static String hashToBase62(String str) {
        int i = MurmurHash.hash32(str);
        long num = i < 0 ? Integer.MAX_VALUE - (long) i : i;
        return convertDecToBase62(num);
    }
}

记得在pom中注入hutool ,把admin.pom的全过来算力,基本上都有用到

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
</dependency>

 接下来创建对应短链接的新增方法,在dao中创建LinkDO

package com.nageoffer.shortlink.project.dao.entiry;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.util.Date;

@TableName("t_link")
@Data
public class LinkDO {

    /**
     * id
     */
    private Long id;

    /**
     * 域名
     */
    private String domain;

    /**
     * 短链接
     */
    private String shortUri;

    /**
     * 完整短链接
     */
    private String fullShortUrl;

    /**
     * 原始链接
     */
    private String originUrl;

    /**
     * 点击量
     */
    private Integer clickNum;

    /**
     * 分组标识
     */
    private String gid;

    /**
     * 启用标识 0:未启用 1:已启用
     */
    private int enableStatus;

    /**
     * 创建类型 0:控制台 1:接口
     */
    private int createdType;

    /**
     * 有效期类型 0:永久有效 1:用户自定义
     */
    private int validDateType;

    /**
     * 有效期
     */
    private Date validDate;

    /**
     * 描述
     */
    private String describe;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 修改时间
     */
    private Date updateTime;

    /**
     * 删除标识 0:未删除 1:已删除
     */
    private Integer delFlag;
}

 接着创建一系列的controller,mapper,service和impl

package com.nageoffer.shortlink.project.dao.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;

public interface ShortLinkMapper extends BaseMapper<ShortLinkDO> {
}
package com.nageoffer.shortlink.project.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;
import com.nageoffer.shortlink.project.dao.mapper.ShortLinkMapper;
import com.nageoffer.shortlink.project.service.ShortLinkService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
 * 短链接接口实现层
 */
@Slf4j
@Service
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {
}
package com.nageoffer.shortlink.project.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;

/**
 * 短链接接口层
 */
public interface ShortLinkService extends IService<ShortLinkDO> {
}

 创建必要的启动项,如在com.nageoffer.shortlink.project创建启动项

package com.nageoffer.shortlink.project;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.nageoffer.shortlink.project.dao.mapper")
public class ShortLinkApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShortLinkApplication.class, args);
    }
}

 暂时不分库分表了,application.yaml如下

server:
  port: 8001
spring:
    datasource:
      password: root
      username: root
      url: jdbc:mysql://127.0.0.1:3306/link?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
      driver-class-name: com.mysql.cj.jdbc.Driver
      hikari:
        connection-test-query: select 1
        connection-timeout: 20000
        idle-timeout: 300000
        maximum-pool-size: 5
        minimum-idle: 5
    data:
      redis:
        host: 192.168.111.130
        password: 123321
        port: 6379

 这里为了适配controller,把admin的controller都加了一个admin/,这也是你apifox接口为什么是那样的

 将convention和database导入其中,其实你做抽象的话其实没有太大的必要,直接复制过就行了,记得调下import

很多参数,因此再次定义返回体,在dto下定义req和resp两个文件夹,req下ShortLinkCreateReqDTO文件如下

package com.nageoffer.shortlink.project.dto.req;

import lombok.Data;
import org.apache.shardingsphere.sharding.exception.syntax.UnsupportedUpdatingShardingValueException;

import java.util.Date;

/**
 * 短链接创建请求对象
 */
@Data
public class ShortLinkCreateReqDTO {
    /**
     * 域名
     */
    private String domain;
    /**
     * 原始链接
     */
    private String originUrl;
    /**
     * 分组标识
     */
    private String gid;
    /**
     * 创建类型:0接口创建,1控制台创建
     */
    private Integer createType;
    /**
     * 有效期类型:0永久有效,1自定义
     */
    private Integer validDateType;
    /**
     * 有效期
     */
    private Date validDate;
    /**
     * 描述
     */
    private String describe;
}

 controller里面的方法返回什么呢?是分组信息,因此在resp中创建ShortLinkCreateRespDTO

package com.nageoffer.shortlink.project.dto.resp;

import lombok.Data;

@Data
public class ShortLinkCreateRespDTO {
    /**
     * 分组信息
     */
    private String gid;
    /**
     * 原始链接
     */
    private String originUrl;
    /**
     * 短链接
     */
    private String fullShortUrl;

}

 controller层如下

package com.nageoffer.shortlink.project.controller;

import com.nageoffer.shortlink.project.common.convention.result.Result;
import com.nageoffer.shortlink.project.common.convention.result.Results;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.project.service.ShortLinkService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * 短链接控制层
 */
@RestController
@RequiredArgsConstructor
public class ShortLinkController {
    private final ShortLinkService shortLinkService;

    /**
     * 创建短链接
     */
    @PostMapping("/api/short-link/v1/create")
    public Result<ShortLinkCreateRespDTO> createShort(@RequestBody ShortLinkCreateReqDTO requestParam) {
        return Results.success( shortLinkService.createShort(requestParam));
    }
}

接着完善service和impl

package com.nageoffer.shortlink.project.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;

/**
 * 短链接接口层
 */
public interface ShortLinkService extends IService<ShortLinkDO> {
    /**
     *  创建短链接
     * @param requestParam 创建短链接请求参数
     * @return 短链接创建消息
     */
    ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam);
}
package com.nageoffer.shortlink.project.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;
import com.nageoffer.shortlink.project.dao.mapper.ShortLinkMapper;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.project.service.ShortLinkService;
import com.nageoffer.shortlink.project.toolkit.HashUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
 * 短链接接口实现层
 */
@Slf4j
@Service
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {

    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
        ShortLinkDO shortLinkDO= BeanUtil.toBean(requestParam, ShortLinkDO.class);
        String shortLinkSuffix =generateSuffix(requestParam);
        shortLinkDO.setFullShortUrl(requestParam.getDomain() +"/"+ shortLinkSuffix);
        baseMapper.insert(shortLinkDO);
        return ShortLinkCreateRespDTO.builder()
                .fullShortUrl(shortLinkDO.getFullShortUrl())
                .gid(requestParam.getGid())
                .build();
    }
    private String generateSuffix(ShortLinkCreateReqDTO requestParam) {
        String originUrl=requestParam.getOriginUrl();
        return HashUtil.hashToBase62(originUrl);
    }
}

给ShortLinkCreateRespDTO添加这个@Data @Builder @NoArgsConstructor @AllArgsConstructor

 describe是关键字,你需要将ShortLinkDO修改如下

package com.nageoffer.shortlink.project.dao.entiry;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.nageoffer.shortlink.project.common.database.BaseDO;
import lombok.Data;

import java.util.Date;

/**
 * 短链接实体
 */
@TableName("t_link")
@Data
public class ShortLinkDO extends BaseDO {

    /**
     * id
     */
    private Long id;

    /**
     * 域名
     */
    private String domain;

    /**
     * 短链接
     */
    private String shortUri;

    /**
     * 完整短链接
     */
    private String fullShortUrl;

    /**
     * 原始链接
     */
    private String originUrl;

    /**
     * 点击量
     */
    private Integer clickNum;

    /**
     * 分组标识
     */
    private String gid;

    /**
     * 启用标识 0:未启用 1:已启用
     */
    private int enableStatus;

    /**
     * 创建类型 0:控制台 1:接口
     */
    private int createdType;

    /**
     * 有效期类型 0:永久有效 1:用户自定义
     */
    private int validDateType;

    /**
     * 有效期
     */
    private Date validDate;

    /**
     * 描述
     */
    @TableField("`describe`")
    private String describe;
}

同样的在project下新建一个包config,里面

package com.nageoffer.shortlink.project.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        strictInsertFill(metaObject, "createTime", Date::new, Date.class);
        strictInsertFill(metaObject, "updateTime", Date::new, Date.class);
        strictInsertFill(metaObject, "delFlag", () -> 0, Integer.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        strictInsertFill(metaObject, "updateTime", Date::new, Date.class);
    }
}

测试如下,说明success了

{
    "domain": "http://baidu.com",
    "originUrl": "https://likely-battle.net/",
    "gid": "93",
    "createdType": 1,
    "validDateType": 1,
    "validDate": "",
    "describe": "啊米浴说的道理"
}

{
    "code": "0",
    "message": null,
    "data": {
        "gid": "93",
        "originUrl": null,
        "fullShortUrl": "http://baidu.com/bLbGX"
    },
    "requestId": null,
    "success": true
}

1.这里转换成62进制一定能保证生成的短链接是6位的吗?

答:我试了,不一定,可能是5位,但是影响不大,这个没影响,就是[0,62^6]区间内

  • 代码逻辑 convertDecToBase62 方法将哈希值转换为62进制字符串,但未强制补位(如补前导零)。
  • 数值范围
    • 62^5 ≈ 9.16亿,62^6 ≈ 568亿。
    • MurmurHash32 生成的32位整数范围为 [0, 4,294,967,295](即0到约43亿),因此:
      • 当哈希值 < 62^5(9.16亿)时,生成的字符串长度为5位或更短。
      • 当哈希值 ≥ 62^5 时,生成的字符串长度为6位。
  • 结论 :生成的短链接长度不固定(5位或6位),但总组合空间仍覆盖 [0, 568亿],不影响功能。

2.好像这个MurmurHash.hash32是把字符串取hash变成32位的数字,也就是8位16进制,总共42亿,变成62进之后也只能是这42亿,感觉到不了500多亿,除非用hash64算法,而且能够保证一定生成6位吗?如果取hash之后的十进制数小于9亿的话应该就只有5位或者更少了,不知道这个算法的具体细节可能需要再看看?

答:确实。那可以多一位,如果不到6位就补充?比如/acsqx、///cax这种?42亿里面只有9亿可能达不到6位,就这样补充一下?

  • MurmurHash32的限制
    • 输出为32位整数(范围 [0, 43亿]),远小于62^6(568亿)。
    • 因此,生成的短链接组合数受限于哈希值的输出空间(仅约43亿),无法覆盖所有62^6的可能值。
  • 冲突风险
    • 根据鸽巢原理 ,当生成的短链接数量超过43亿时,必然发生哈希冲突。
  • 解决方案
    • 使用64位哈希算法(如 MurmurHash64)扩展输出空间至 [0, 1.8×10^19],显著降低冲突概率。
    • 或对短链接不足6位的部分补前导字符(如 0A)以统一长度。

3.这里使用生成32位的MurmurHash算法,其实完成达不到6^62这么多种情况。数据量一大,很容易冲突?

  • 冲突概率
    • 根据生日悖论 ,当生成约77,000个短链接时,冲突概率已高达50%(计算公式:√(2^32) ≈ 65536)。
  • 实际场景影响
    • 若系统需支持海量短链接(如10亿+),MurmurHash32的冲突概率将不可接受。
  • 改进建议
    • 使用强哈希算法(如 SHA-256)或64位MurmurHash,并结合盐值(Salt)增强唯一性。

4.生成6位短链接的逻辑:先将原始链接通过MurmurHash.hash32()方法生成一个32位的整形(最大40多亿,最小10亿),而62的5次方为9亿多,所以可以生成6位短链接,而每一位有0到9、a到z、A到Z,62中情况,所以可以生成的短链接有62^6约500多亿万

答:最小不应该是0吗?为什么最小是10亿,没有最小10亿这个说法啊,最小就是0;

❌ 错误

  • 哈希值范围 MurmurHash32 的输出范围为 [0, 43亿],最小值为 0,而非10亿。
  • 用户误解
    • 用户可能混淆了“最大值43亿”与“最小值非零”,但哈希值可以取到 0(例如输入空字符串时)。
int hash = MurmurHash.hash32(""); // 返回0

5.关于短链接位数的理解:生成的短链接中,不少于3/4的为6位数,因为2^30 > 62^5;接近于100%的短链接大于等于5位数,因为2^24 > 62^4,至于能不能生成7位数,因为2^32 < 62^6,所以是肯定不行的。 关于能生成多少不重复的短链接的理解:num的取值决定了短链接不重复的数量,num只有2^32个值,远没有达到62^6,所以数据量一旦超过了2^32必然会出现重复的短链接。

  • 位数分布
    • 62^4 ≈ 13,367,494(约1300万),2^24 ≈ 16,777,216 → 约1600万哈希值会生成5位短链接。
    • 62^5 ≈ 9.16亿,2^32 ≈ 43亿 → 约43亿哈希值中,仅有约34亿会生成6位短链接。
  • 冲突上限
    • 哈希空间为2^32,因此最多生成约43亿个唯一短链接,超过后必然冲突。

6.哈希算法HashUtil里的MurmurHash.hash32生成的32位int数,数量级十亿级别,还是满有可能产生哈希冲突,可以考虑拓展为MurmurHash.hash64吧。

  • MurmurHash64的优势
    • 输出为64位整数(范围 [0, 1.8×10^19]),远超62^6(568亿),可覆盖所有6位62进制组合。
    • 冲突概率降至极低水平(需生成约300亿个短链接时,冲突概率才达50%)。
long num = Math.abs(MurmurHash.hash64(str)); // 取绝对值避免负数

7.这里生成HashUtil的方法是错的,convertDecToBase62参数取值范围才20多亿对应的62进制数也只有这么多,500多亿的总数是建立在6位随机的62进制数

  • 核心问题
    • MurmurHash32 的输出空间(43亿) < 62^6(568亿),因此无法生成所有可能的6位62进制短链接。
  • 后果
    • 短链接的理论上限为43亿,而非568亿。
    • 某些6位短链接永远无法生成(如超过43亿的哈希值不存在)。
  • 解决方案
    • 改用64位哈希算法,或使用雪花算法(Snowflake)生成唯一ID。

第05节:新增短链接(中)

第一点就是短链接的不可重复以及短链接的数据库中的唯一,还有关于我们短链接的防缓存穿透, 我们能在sql中查出来对不对?那如果说我给它设置成大写,理论上他应该查不出来,但是他查出来了,这是因为mysql它的这种utf-8mb4的这种字符编码集它是忽略大小写的,但是你想我们能在正常的短链接使用过程当中,我们肯定是要区分大小写的如果说,大小写不区分就直接损了我们很大一部分的这种就是可使用的这种概率对不对?

这样的话其实很简单,我们直接干什么?一个给它改一下字符编码机就好了,utf-8,utf-8_bin我们查不到了,这样就解决了奇奇怪怪的知识,又加了一点可以直接改,或者

alter table `t_link`
modify column `short_uri` varchar(8) character set utf8 collate utf8_bin;

我们看到impl的代码,这里是存在冲突的啊,因为你用hash 去模创建短链接,一定会存在冲突的可能性,

package com.nageoffer.shortlink.project.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.project.common.convention.exception.ServiceException;
import com.nageoffer.shortlink.project.config.RBloomFilterConfiguration;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;
import com.nageoffer.shortlink.project.dao.mapper.ShortLinkMapper;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.project.service.ShortLinkService;
import com.nageoffer.shortlink.project.toolkit.HashUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBloomFilter;
import org.springframework.stereotype.Service;

/**
 * 短链接接口实现层
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {

    private final RBloomFilter<String> shortUriCreateCachePenetrationBloomFilter;

    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
        ShortLinkDO shortLinkDO = BeanUtil.toBean(requestParam, ShortLinkDO.class);
        String shortLinkSuffix = generateSuffix(requestParam);
        shortLinkDO.setShortUri(shortLinkSuffix);
        shortLinkDO.setEnableStatus(0);
        shortLinkDO.setFullShortUrl(requestParam.getDomain() + "/" + shortLinkSuffix);
        baseMapper.insert(shortLinkDO);
        return ShortLinkCreateRespDTO.builder()
                .fullShortUrl(shortLinkDO.getFullShortUrl())
                .gid(requestParam.getGid())
                .build();
    }

    private String generateSuffix(ShortLinkCreateReqDTO requestParam) {

        /*去重试,先给它get一个原始的一个链接,然后我们要定义一个自定义生成次数*/
        int customGenerateCount = 0;
        String shortUri;
        while (true) {
            /*核心理念是如果冲突的就一直生成,但是如果说他一直生成非常非常小的概率会造成死循环
             * 对我们数据库的压力会比较大,因此要给他设置一个大的一个重试次数*/
            if (customGenerateCount > 10) {
                throw new ServiceException("短链接频繁生成,请稍后再试");
            }
            String originUrl = requestParam.getOriginUrl();
            shortUri = HashUtil.hashToBase62(originUrl);
            /* 第一种方案就是去查询*/
            LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
                    .eq(ShortLinkDO::getFullShortUrl, requestParam.getDomain() + "/" + shortUri);
            /*我频繁地去访问你的这个数据库显然扛不住,so要加一层分布式锁,但是它肯定会有性能损耗
             * 而且你都查询数据库了,怎么可能会支持海量并发呢,因此缓存和sql隔离开,那么用什么缓存架构支持去做这种判空或者判重呢?
             * string和hash,list都有问题,要么就是你一个hash里面存的数据量过大产生大key问题
             * 字符串又容量过大,于是是布隆过滤器判断一下*/
            ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
            if (shortLinkDO == null) {
                break;
            }
            customGenerateCount++;

        }
        return shortUri;
    }
}

从config复制RBloomFilterConfiguration到这里

package com.nageoffer.shortlink.project.config;

import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 布隆过滤器配置
 */
@Configuration
public class RBloomFilterConfiguration {

    /**
     * 防止短链接创建查询数据库的布隆过滤器
     */
    @Bean
    public RBloomFilter<String> shortUriCreateCachePenetrationBloomFilter(RedissonClient redissonClient) {
        RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("userRegisterCachePenetrationBloomFilter");
        cachePenetrationBloomFilter.tryInit(100000000L, 0.001);
        return cachePenetrationBloomFilter;
    }
}

如果是布隆过滤器已经非常大了,假如我布隆过滤器设置了1亿的元素,已经有接近1亿的这个数据了就会经常误判,怎么解决?暂未回答,下面是布隆过滤器是实现方案

private String generateSuffix(ShortLinkCreateReqDTO requestParam) {

    /*去重试,先给它get一个原始的一个链接,然后我们要定义一个自定义生成次数*/
    int customGenerateCount = 0;
    String shortUri;
    while (true) {
        if (customGenerateCount > 10) {
            throw new ServiceException("短链接频繁生成,请稍后再试");
        }
        String originUrl = requestParam.getOriginUrl();
        shortUri = HashUtil.hashToBase62(originUrl);
        if (!shortUriCreateCachePenetrationBloomFilter.contains(requestParam.getDomain() + "/"+ shortUri)) {
            break;
        }
        customGenerateCount++;

    }
    return shortUri;
}

误判后在createShortLink中的insert中 一定会报错,我们承接这个即可

package com.nageoffer.shortlink.project.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nageoffer.shortlink.project.common.convention.exception.ServiceException;
import com.nageoffer.shortlink.project.config.RBloomFilterConfiguration;
import com.nageoffer.shortlink.project.dao.entiry.ShortLinkDO;
import com.nageoffer.shortlink.project.dao.mapper.ShortLinkMapper;
import com.nageoffer.shortlink.project.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.project.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.project.service.ShortLinkService;
import com.nageoffer.shortlink.project.toolkit.HashUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBloomFilter;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;

/**
 * 短链接接口实现层
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ShortLinkServiceImpl extends ServiceImpl<ShortLinkMapper, ShortLinkDO> implements ShortLinkService {

    private final RBloomFilter<String> shortUriCreateCachePenetrationBloomFilter;

    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
        ShortLinkDO shortLinkDO = BeanUtil.toBean(requestParam, ShortLinkDO.class);
        String shortLinkSuffix = generateSuffix(requestParam);
        shortLinkDO.setShortUri(shortLinkSuffix);
        String fullShortUrl = requestParam.getDomain() + "/" + shortLinkSuffix;
        shortLinkDO.setEnableStatus(0);
        shortLinkDO.setFullShortUrl(requestParam.getDomain() + "/" + shortLinkSuffix);
        try {
            baseMapper.insert(shortLinkDO);
        } catch (DuplicateKeyException ex) {
            log.warn("短链接:{} 重复入库", fullShortUrl);
            //TODO已经误判的短链接如何处理
            //第一种,短链接确实真实存在缓存
            //第二种,短链接不一定存在缓存中
            throw new ServiceException("短链接生成重复");
        }
        shortUriCreateCachePenetrationBloomFilter.add(shortLinkSuffix);
        return ShortLinkCreateRespDTO.builder()
                .fullShortUrl(shortLinkDO.getFullShortUrl())
                .gid(requestParam.getGid())
                .build();
    }

    private String generateSuffix(ShortLinkCreateReqDTO requestParam) {

        /*去重试,先给它get一个原始的一个链接,然后我们要定义一个自定义生成次数*/
        int customGenerateCount = 0;
        String shortUri;
        while (true) {
            if (customGenerateCount > 10) {
                throw new ServiceException("短链接频繁生成,请稍后再试");
            }
            String originUrl = requestParam.getOriginUrl();
            shortUri = HashUtil.hashToBase62(originUrl);
            if (!shortUriCreateCachePenetrationBloomFilter.contains(requestParam.getDomain() + "/" + shortUri)) {
                break;
            }
            customGenerateCount++;

        }
        return shortUri;
    }
}

复制添加下admin的web包,apifox测试运行

 1.判断布隆过滤器是否存在用的域名和 shortUri,新增时仅添加 shortUri,是一个逻辑错误,后续视频已修复。或者大家新增布隆过滤器时可以直接新增域名和 shortUri 都是可以的

2.嗯嗯。:生成shortUri方法不是已经保证shortUri唯一了吗?为什么创建方法里面还要去查数据库?

生成shortUri方法里面的是基于工具类的哈希算法生成的短链,那么通过哈希就会有可能出现哈希冲突,所以生成的就会有重复

生成可能有重复没错,但是不是加了一层布隆过滤器判断吗?经过布隆过滤器判断不存在数据库的,那么在数据库就一定不会重复啊

然后布隆过滤器存在误判呢,如果没有误判,那就正常进行,如果误判了,那么误判不存在,其实是存在的,那么就去数据库查一下,就会被重复字段拦截异常,如果没有误判,就正常通过

布隆过滤器判断不存在就肯定不存在,布隆过滤器判断存在才会误判吧

可能是做一个查数据的保险动作吧,比如前端已经做了用户名密码非空判断,后端也一般需要判空感觉是一个道理,个人理解

存在一种情况,短链接入库成功,但是并没有添加到布隆过滤器中(可能因为进程挂掉等等原因,由于没加事务,短链接入库不会回滚)。也就是说实际上入库了,但布隆过滤器显示短链不存在,此时再次插入该短链不就越过布隆过滤器,然后被唯一索引给拦截了。 因为这种情况出现的概率极低,所以把唯一索引称为兜底策略。 前面章节的用户名设置为唯一索引也是同样的道理。

总结一下前几位的回复: 1.布隆过滤器判断不存在,就一定不存在,这个不会误判;判断存在,实际有可能不存在,这个会误判。 2.generateSuffix若正常返回shortUri,一定是不存在缓存里的(如果重复10次就抛异常了)。然而shortUri有可能在数据库里(出了数据库、缓存不一致bug),因此数据库里有必要引入唯一索引,这个是兜底机制。所以createShortLink里面try, catch其实就是加个保险

那把添加进布隆过滤器的语句放在写入数据库之前,是不是就能避免这个问题?

也可能是在高并发的场景下,生成重复fullShortUrl的,但布隆过滤器没有及时更新,导致数据打到了数据库,感觉是并发问题

同一个原始链接通过Hash之后也会产生不同的短链接吧,这一点怎么办呢?

3.这里根据原始链接生成的shortUri是确定的,不会因为重试而改变,这里要重试的话感觉需要加盐,不然好像没意义呀

若原始链接固定,HashUtil.hashToBase62(originUrl) 生成的 shortUri 永远相同,重试无法解决冲突。在哈希计算中加入随机盐值(如时间戳或UUID),确保每次生成的 shortUri 不同:

private String generateSuffix(ShortLinkCreateReqDTO requestParam) {
    int customGenerateCount = 0;
    String shortUri;
    while (true) {
        if (customGenerateCount > 10) {
            throw new ServiceException("短链接频繁生成,请稍后再试");
        }
        String salt = UUID.randomUUID().toString(); // 加盐
        String input = requestParam.getOriginUrl() + salt;
        shortUri = HashUtil.hashToBase62(input);
        String fullShortUrl = requestParam.getDomain() + "/" + shortUri;
        if (!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
            break;
        }
        customGenerateCount++;
    }
    return shortUri;
}

4.布隆过滤器的误判不是指,由于哈希冲突,将不存在于过滤器中的元素,误判为已存在吗?不应该有将已存在的当成不存在的这种情况啊,为什么还要在create()中的插入处,进行校验呢?

存在一种情况,短链接入库成功,但是并没有添加到布隆过滤器中(可能因为进程挂掉等等原因,由于没加事务,短链接入库不会回滚)。也就是说实际上入库了,但布隆过滤器显示短链不存在,此时再次插入该短链不就越过布隆过滤器,然后被唯一索引给拦截了。 因为这种情况出现的概率极低,所以把唯一索引称为兜底策略。 前面章节的用户名设置为唯一索引也是同样的道理。

  • 布隆过滤器特性
    • 无假阴性 :若布隆过滤器返回 false(不存在),则短链接一定不在数据库中。
    • 可能假阳性 :若返回 true(存在),可能实际不存在(哈希冲突导致)。
  • 数据库兜底逻辑
    • 即使布隆过滤器误判为存在,仍需通过数据库唯一索引拦截重复插入。
    • 若短链接已存在但未添加到布隆过滤器(如进程崩溃),数据库唯一索引可防止重复生成。

5.布隆过滤器那里RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("shortUriCreateCachePenetrationBloomFilter"); 这段代码没改,与用户注册共用了

  • 用户注册和短链接生成共用同一个布隆过滤器(userRegisterCachePenetrationBloomFilter),可能导致数据混淆(如短链接误判为用户已注册)。
// 修改 RBloomFilterConfiguration
@Bean
public RBloomFilter<String> shortUriCreateCachePenetrationBloomFilter(RedissonClient redissonClient) {
    RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("shortUriCreateCachePenetrationBloomFilter");
    cachePenetrationBloomFilter.tryInit(100000000L, 0.001);
    return cachePenetrationBloomFilter;
}

@Bean
public RBloomFilter<String> userRegisterCachePenetrationBloomFilter(RedissonClient redissonClient) {
    RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("userRegisterCachePenetrationBloomFilter");
    cachePenetrationBloomFilter.tryInit(100000000L, 0.001);
    return cachePenetrationBloomFilter;
}

6.刚查了一下:数据库里字符集决定了字符如何存储在数据库中,而校对集则定义了字符比较和排序的规则,包括大小写敏感性等。utf8mb4是一个字符集,它支持存储所有的Unicode字符,包括emoji等特殊字符。而校对集如utf8mb4_general_ci和utf8mb4_bin等,其中的"_ci"后缀表示不区分大小写,而"_bin"表示使用二进制比较,区分大小写。因此,选择utf8mb4字符集本身并不会导致忽略大小写,忽略大小写是由所选的校对集决定的。

7.总结一下: 首先短链接是要保证唯一的,短链接在数据库中是唯一的(不同网址生成的短链接不能相同),我们不能发生让同一个短链接访问不同的目标网址这种问题,所以要查询数据库。 此时发生两个个问题,1.发生了哈希冲突,不同网址生成了相同的短链接,这里采用生成十次还重复就抛出2.如果短链接已经生成了还一直恶意生成,每次都会访问数据库,所以这里会造成数据库压力。采用缓存不合适,会占用大量内存,所以使用布隆过滤器。布隆过滤器为空则说明没有重复,正常执行,如果不为空,那么就抛出异常,以防恶意请求。 但这里又会出现一个问题,布隆过滤器会误判,客户想要生成,过滤器将不存在判为已存在,发生异常,所以我们要解决这个问题。

第06节:新增短链接(下)

新增当前系统的毫秒数,给originurl加一下,然后hashtobase62

originUrl+=System.currentTimeMillis();

 如果说是误判该怎么办?去数据库查一下,虽然说从数据库查有一定的风险,高并发去查可能就挂了,但是

try {
    baseMapper.insert(shortLinkDO);
} catch (DuplicateKeyException ex) {
    LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
            .eq(ShortLinkDO::getFullShortUrl, fullShortUrl);
    ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
    if (hasShortLinkDO != null) {
        log.warn("短链接:{} 重复入库", fullShortUrl);
        throw new ServiceException("短链接生成重复");
    }
}

如果说我一直去攻击你的这个数据库 如何防?他误判的前提是我们布隆过滤器容量已经基本上接近满了,这样的话它的误判几率是很大的,如果说它不是接近满了,它的误判几率非常小。然后他偶尔请求一次数据库是没有问题的。懂我意思吧?然后在那基于这种场景下,我们的流程到底就变成什么样子了呢?我给大家去画一个图,然后去用户去创建短链接。首先我们要先给他干什么?生成生成短链接,然后生成短链接判断什么?判断是否存在?通过什么?布隆过滤器,然后我们布隆过滤器的话单独用一个DB去存储,是8月26。然后生成的案件如果说存在,如果说是存在的话,那么外部循环10次,直到不冲突为止。

如果超过10次。跑一场,如果判断存在,如果说不存在,那就正常返回对不对?做一个双向的箭头,然后这里面的话我们用双向的箭头ok,然后它就生成短链接,然后生成之后它干什么?入库,保存入买C口。这里的话我们还要再创建出来一个DB。咋看都有点丑。算了。我们这样好吧?然后保存入my circle,它最终的话这里是请求到请求到我们不容易请求到我们买C口tb的。然后这里的话其实是什么?瑞迪斯。然后基于这种场景下,我们的短链接的创建其实才算是稍微完成了80%。稍等我继续跟大家去,这里面还有个流程没说清楚如果说冲突了。在这里如果说保存唯就是唯一索引冲突,我们就该干什么?去查询数据库,对吧?查询数据库是否存在,如果说存在,存在的话为什么我们这里直接就这种去简单的去表达了。

存在跑一场不存在执行控制逻辑是什么?那就是把我们的一个那就是把这个数据加到波动过滤器里面。Ok大概就是这个样子。然后可能大家会问一点,如果说我这么做了之后对吧?你不能过滤器,如果说这个容量超过了它接近于它的阀值了怎么办?大家可以先不要着急,这个肯定是有解决方案的,解决方案我先把思路跟大家说一下,那就是你要有个定时任务,不断的去请求你当前的步骤过滤器去判断它的容量是否有多大,对吧?它有它已经存储多大的 URL了,如果说存储的很大了之后,接近我们的设置的阀值之后,那么就再给它创建出来一个新的过程过滤器,然后把现有的不同过滤器里面的数据给它放到我们新的里面去,然后再把引用指向新的,后续的会在后续的逻辑里面跟大家讲,我们现在只需要知道有这种场景,然后这种场景是怎么解决的就可以了,然后我们再重启一下,我们再试一下功能。

这个长链接,我这个原始链接你给我加上毫秒数会有问题吗?听我们这边要明确一个问题啊,短链接的原始链接会保存到数据库中,保存的不是http://qrrxiba/qqqzr121698068473014这种一大坨的,这个只是让你去生成它对应短链接。他最终不会保存到库里面,只是为了防止你生成的一个hash概率冲突的

认为代码不美观,用build会好一点

给ShortLinkDO添加

@Builder
@NoArgsConstructor
@AllArgsConstructor

 代码修改如下

    @Override
    public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {

        String shortLinkSuffix = generateSuffix(requestParam);
        //ShortLinkDO shortLinkDO = BeanUtil.toBean(requestParam, ShortLinkDO.class);
        //shortLinkDO.setShortUri(shortLinkSuffix);
        //String fullShortUrl = requestParam.getDomain() + "/" + shortLinkSuffix;
        String fullShortUrl = StrBuilder.create(requestParam.getDomain())
                .append("/")
                .append(shortLinkSuffix)
                .toString();

        //shortLinkDO.setEnableStatus(0);
        //shortLinkDO.setFullShortUrl(requestParam.getDomain() + "/" + shortLinkSuffix);
        ShortLinkDO shortLinkDO = ShortLinkDO.builder()
                .domain(requestParam.getDomain())
                .originUrl(requestParam.getOriginUrl())
                .gid(requestParam.getGid())
                .createdType(requestParam.getCreateType())
                .validDateType(requestParam.getValidDateType())
                .validDate(requestParam.getValidDate())
                .describe(requestParam.getDescribe())
                .shortUri(shortLinkSuffix)
                .enableStatus(0)
                .fullShortUrl(fullShortUrl)
                .build();
        try {
            baseMapper.insert(shortLinkDO);
        } catch (DuplicateKeyException ex) {
            LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
                    .eq(ShortLinkDO::getFullShortUrl, fullShortUrl);
            ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
            if (hasShortLinkDO != null) {
                log.warn("短链接:{} 重复入库", fullShortUrl);
                throw new ServiceException("短链接生成重复");
            }
        }
        shortUriCreateCachePenetrationBloomFilter.add(shortLinkSuffix);
        return ShortLinkCreateRespDTO.builder()
                .fullShortUrl(shortLinkDO.getFullShortUrl())
                .gid(requestParam.getGid())
                .originUrl(requestParam.getOriginUrl())
                .build();
    }

 为什么在唯一索引冲突异常捕获中还要再查一次?

https://t.zsxq.com/18eJBXoWS

一.有个疑问,布隆过滤器对于判断元素不存在时,是没有误判的。即GenerateSuffix生成的后缀是唯一的,那为什么插入的时候还会出现重复key的情况。

答:主要是为了防止多线程并发情况下的错误,有多个线程可能会拿到相同的不存在的URI然后返回,接着去插入数据库。为啥不用分布式锁呢?用分布式锁性能不就低了么

二.2个布隆过滤器数据迁移时,如果redis宕机了怎么办?在公司实际业务场景下,会选择什么样的方案来做故障恢复呢?比如持久化/集群还是什么别的方案呢? 

答:这个感觉就是redis集群和自带的持久化机制就解决了

三.唯一索引存在不就是数据库里有这个短链接吗?咋还再查一次数据库啊?

答:异常里面查询数据库是多余的,都已经报唯一索引异常肯定就存在,那么还是相当于执行if里面的代码,这里应该是多余的操作,反正我是不明白的。

四. 为啥放进布隆过滤器里的是后缀,从布隆过滤器取出来判断的是整个短链接的url

答:放进的就是整个full短链接啊

不是吧,放进去的是在下面方法中生成的后缀啊

应该要放进去生成后完整的的短链接吧,布隆过滤器判断的时候判断的fullShortUrl

五. 插入数据库那里已经做了唯一索引的限制,所以如果生成的短链接已经在库中存在不是应该再次调用generate方法么,如果接下来的十次都还是重复的,那就抛出错误,感觉这样会不会更好一些

六. 这里加盐处理之后同一个originUrl能生成多个短链接吧,不会有问题吗,然后就是布隆过滤器添加和检查的字段不一致,最后就是不明白为什么数据库抛异常还要检查一遍,异常不就说明键重复了吗

答:问题一不会有问题吧,万一多个不同用户用同一个原链接也应该可以正常生成才对;

同一个原始链接生成多个短链接不会有问题,业务是需要的,比如多个渠道(小红书、微博等)跳转同一短链接统计

七. 发生误判只有一种情况,就是本来不存在误判为了存在,如果存在那么一定会重试,不会走到添加数据库的步骤,那么能走到添加数据库步骤的一定是不存在的短链接,此时添加只有多线程情况下会出现问题吧,如果多个线程同时添加相同的短链接,只有一个能添加成功,其他的会因为唯一索引约束添加失败,所以既然添加失败了就说明这个短链接已经被使用了,为什么还要查询数据库再判断一次是否存在呢?

答:我的猜测是,插入数据库的短链接数据,因为执行业务过程中失败,又回归了😒这里确实没讲清楚

 八.布隆过滤器存在的误判三种情况 1、如果当前的短链接是没用过的,但是被误判用过了,那也没事,直接会被跳过,继续生成 2、如果当前的短链接是用过的,但是被误判说没有用过,那么在插入到数据库的时候,就会被数据库的唯一字段,短链接拦截,出现异常 3、如果在数据库没有拦截,说明没有误判, 获取到的就是没用过的,正常进行,不抛异常

答:我的想法就是兜底一下,比如我们平时写登录的时候要判断空,那么前端其实已经检验好了,一般不会传入空过来,我们后端还是要检判空,我就是这么想的,如果不对,等马哥解释哈哈,布隆过滤器说不存在就是不存

九.马哥说对布隆过滤器中误判后,数据库还要复查一遍说是有说法的。在这里合理催更一手。

应该是重试十次后,仍然可能是误判,可以再查一次数据库检查,直接抛异常也可以,毕竟都重试十次还是失败了

十. 这里重复插入异常出现说明布隆过滤器里面是没有这条fullShortLink的 所以我觉得应该不用再去查一次表直接把这个fullShortLink放到布隆过滤器中然后再抛异常

答:这个想法应该是对的,查数据库是多余的

11. 在generateSuffix()方法中 originUrl += System.currentTimeMillis(), 然后当执行 HashUtil.hashToBase62(originUrl), 每次生成的短链接都不一样, 因为是原始链接+毫秒值,这个值是动态改变的,生成的短链接也是动态改变, 当执行baseMapper.insert时,它就不会进入catch代码块中, 会直接插入.理论上一个链接可以生成无限个不同的短链, 这样直接把数据库填满了

答:用户要生成短链接,应该是有条数限制的,不会让他插满的 这代码有逻辑问题,用户每次生成的短链接都是不同的,只能用一次

我觉得这种业务场景是需要的,就是给同一个原始链接,生成多个不同的短链接,比如说做一些商家推广,给不同的主播不同的短链接去推广,比如b站这种就很多,方便统计不同主播的流量情况,当然我说的也可能不对,这是我的想法

加毫秒值目的就是生成不同的短链接,在多线程情况下有可能进入到catch块里,因为这时还没加到布隆过滤器里面,就误判为不存在,但是数据库中其实已经插入进去了,所以在异常块里直接把完整的短链接放到布隆过滤器里面就好了

12. 1.这里当向数据库中插入短链接时,可能发生唯一索引冲突,即布隆过滤器判断不存在,但数据库中已经存在,这种情况可能是多线程多个用户一起生成相同短链接导致的;也可能因为进程挂掉等等原因,由于没加事务,短链接入库了,但布隆过滤器中没有保存,同时没有回滚,第二种情况是可以通过加事务来解决的。 2.捕获到唯一索引冲突之后,马哥再次判断数据库中是否存在,这里我也不太看得懂了,已经唯一索引冲突,还可能数据库不存在吗?我能想到的情况只有别人的业务回滚了,本来保存进数据库的数据又删除了,那他数据库不存在了,我还把它加到布隆过滤器里,这是为了什么?难道不应该数据库查到存在时,加到布隆过滤器,防止1里面我说的事务问题导致的吗?

答:确实,问题2我也没看懂,另外为什么判断不存在就可以直接执行下面的步骤,插入失败了直接返回给用户短链接而数据库并没有记录,但是因为没有报错用户仍然使用这条记录进行操作就会有问题。我感觉判断不存在之后需要再重新插入一遍,或者直接报错,而不是正常返回

同意小明的说法,通过保证数据库和布隆过滤器的事务,可以防止布隆过滤器丢失部分短链接的信息。具体做法是?消息队列重试?订阅MySQL binlog,再操作布隆过滤器?

多线程多个用户一起生成相同短链接导致的,这个只是在布隆过滤器上会重复,数据库插入是串行执行不会重复插入,为了解决这个问题,上锁就行,注意粒度,第二个问题那确实需要事务来处理,保证redis和数据库同时成功失败,使用spring的编程事务,注意不是声明事务,就可以,这里写的代码的确比较多问题

对于第2个问题的场景,应该是不存在的吧。因为插入短链接这个方法是没有加事务的,所以插入是不存在业务回滚的情况的,只要出现了Dup Key,说明数据库中必然已经存在。 有了这个前提,那么当出现了Dup Key,此时只需要手动在异常catch块中补充一次布隆过滤器的add操作,然后抛出异常即可,不用再加事务了。

13. 发现重复入库之后,是不是应该重新生成短链接,而不是直接报错?答:一种优化思路,是可以的

14. 这种生成短链接的逻辑不就是意味着,一个原始url可能会对应多个短链接吗,每次调用这个接口都会创建一个新的短链接?答:是的,这种情况是允许的

15. 有个疑问:在generateSuffix方法中,给originUrl字符串添加了时间导致每个originUrl都不会相同,包括同一个originUrl,那么同一个originUrl可以创建多次短链接,生成的shortUri都不相同,一会就会把布隆过滤器撑爆。

答:这个我觉得合理啊,难道一个百度url只能给一个用户创建短链接吗?合理的,例如多个短链接对应一个 originUrl, 不同的短链接对应给不同的平台做推广, 就可以通过这些短链接的点击率分析到在不同平台的推广质量;这里的问题不是说一个原始链接不可以拥有多个短链接,而是会出现同一个用户同一个原始链接都会出现多个短链接,我感觉这种应该是不允许出现的吧?我觉得你说得对,同一用户就应该只能对同一短链接生成理论上有限的短链接,mading这里直接用系统毫秒数加盐我觉得很扯,这样基本上一个用户对任意原始连接生成的短链接都不同了,高并发直接把布隆过滤器撑爆了

16. 看到这有疑问的同学,可以先把代码这么敲着,后续马哥会针对这一块在改代码,因为布隆过滤的判断逻辑是:布隆过滤器判断不存在的,那么一定不存在,布隆过滤器判断存在的数据,真实情况可能不存在也就是出现误判的情况,所以后边马哥就把查询数据库这一块的代码删了。

17.添加了System.currentTimeMillis()不是会导致相同的originUrl与多个短链接绑定吗?那这样不就可以不断地请求,不会有风险吗? 

第07节:短链接海量数据分片分表

package com.nageoffer.shortlink.admin.test;

/**
 *  做一个分片的处理,我们先分十六个片啊
 */
public class UserTableShardingTest {

    public static final String SQL = "CREATE TABLE `t_link_%d` (\n" +
            "  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',\n" +
            "  `domain` varchar(128) DEFAULT NULL COMMENT '域名',\n" +
            "  `short_uri` varchar(8) DEFAULT NULL COMMENT '短链接',\n" +
            "  `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',\n" +
            "  `origin_url` varchar(1024) DEFAULT NULL COMMENT '原始链接',\n" +
            "\t`click_num` int(11) DEFAULT 0 COMMENT '点击量',\n" +
            "\t`gid` varchar(32) DEFAULT NULL COMMENT '分组标识',\n" +
            "\t`enable_status` tinyint(1) DEFAULT NULL COMMENT '启用标识 1:未启用 0:已启用',\n" +
            "\t`created_type` tinyint(1) DEFAULT NULL COMMENT '创建类型 0:控制台 1:接口',\n" +
            "\t`valid_date_type` tinyint(1) DEFAULT NULL COMMENT '有效期类型 0:永久有效 1:用户自定义',\n" +
            "\t`valid_date` datetime DEFAULT NULL COMMENT '有效期',\n" +
            "\t`describe` varchar(1024) DEFAULT NULL COMMENT '描述',\n" +
            "  `create_time` datetime DEFAULT NULL COMMENT '创建时间',\n" +
            "  `update_time` datetime DEFAULT NULL COMMENT '修改时间',\n" +
            "  `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',\n" +
            "  PRIMARY KEY (`id`),\n" +
            "  UNIQUE KEY `idx_unique_full_short_url` (`full_short_url`) USING BTREE\n" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";

    public static void main(String[] args) {
        for (int i = 0; i < 16; i++) {
            System.out.printf((SQL) + "%n", i);
        }
    }
}

 生成了16个表,把原来那个删了

 现在配置shardingsphere,project的两个配置文件如下

server:
  port: 8001
spring:
  datasource:
    driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
    url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
  data:
      redis:
        host: 192.168.111.130
        password: 123321
        port: 6379

 分片件该怎么选择了?参考一下那个小码短链接。它难道是根据短链接去分组的吗?列表里面查询条件里面一定有短链接?不一定;有一个他们的这个分组ID,相当于你举例:我们如果说用短链接分库分表,我们用短链接去做那种hashmode,用hash的方式去进行分表,根据他对应的短链接去查一下长链接,当然可以。但是你像这种后管里面的分页,他想根据某个分组去查询对应的这个分组下的短链接,用短链接去分表就不行:他只传给你一个这个分组,肯定是查所有的那个表

那我们可以按照分组ID,也就是我们的gid去给它做一个分表,但是你groupid分库分表,那你如果说这种短链接跳转长链接,它是没有分组ID的,怎么办?下节课说

我们就按那个GID去给它做分表

# 数据源集合
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_link:
        actualDataNodes: ds_0.t_link_${0..15}
        tableStrategy:
          standard:
            shardingColumn: gid
            shardingAlgorithmName: link_table_hash_mod
    shardingAlgorithms:
      link_table_hash_mod:
        type: HASH_MOD
        props:
          sharding-count: 16
props:
  sql-show: true

我们去传他对应的一个GlobeIid查询即可

你可能会报错,记得 ShortLinkServicelmpl.java,ShortLinkDO.java ,ShortLinkCreateReqDTO.java这几个的createdType或者getCreatedType;都要加个d,不是createType;

第08节:拦截器封装用户上下文(补充篇)

 我在跟前端联调过程当中遇到的一个问题,首先我们这里面是已经重构过了 ADB接口的,然后但是我们这里面还是没加/admin,所以说它会有一点问题;然后其次我们前端不需要去登录的接口不止一个login,它会有三个,一个注册,一个是用户登录,用户注册和一个检查用户名是否可用,大家可能会有问题,就是说检查用户名是否可用,为什么不需要token?这个接口是我们在注册的时候去判断这个用户名有没有被其他用户使用,相当于用户还没有去注册,更何况他哪来的token对不对? 

package com.nageoffer.shortlink.admin.common.biz.user;

import com.alibaba.fastjson2.JSON;

import com.google.common.collect.Lists;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.net.URLDecoder;
import java.util.List;
import java.util.Objects;

import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * 用户信息传输过滤器
 */
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {
    private final StringRedisTemplate stringRedisTemplate;

    private static final List<String> IGNORE_URI = Lists.newArrayList(
            "/api/short-Link/admin/v1/user/Login",
            "/api/short-Link/admin/admin/v1/actual/user/has-user-name"
            /*
            "/api/short-Link/admin/v1/user"
            */
    );

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpServletRequest.getRequestURI();
        //这个URI是否存在于我们的忽略机壳里,忽略取反说明它那个不存在于我们忽略的名单里面
        //但是这里有个问题,"/api/short-Link/admin/v1/user"是因为我们是用的Restful的这种形式,它不止一个地方
        //注册和修改是一模一样的路径,那他就不能再通过这种方式去验证是否存在了
        if (!IGNORE_URI.contains(requestURI)) {
            String username = httpServletRequest.getHeader("username");
            String token = httpServletRequest.getHeader("token");
            Object userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_" + username, token);
            if (userInfoJsonStr != null) {
                UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
                UserContext.setUser(userInfoDTO);
            }
        }
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            UserContext.removeUser();
        }
    }
}

那就是通过它的这个HTTP的method 

如果一致之后

客户端的异常是A开头的,UserErrorCodeEnum.java加一句

USER_TOKEN_FAIL("A000200","用户token验证失败"),

 然后这个时候我会跟前端做成达成一个标识呢, 如果说我返回到result里面是这个状态码,那你让用户跳到登录页面去

package com.nageoffer.shortlink.admin.common.biz.user;

import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;

import com.google.common.collect.Lists;
import com.nageoffer.shortlink.admin.common.convention.exception.ClientException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.net.URLDecoder;
import java.util.List;
import java.util.Objects;

import static com.nageoffer.shortlink.admin.common.enums.UserErrorCodeEnum.USER_TOKEN_FAIL;
import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * 用户信息传输过滤器
 */
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {
    private final StringRedisTemplate stringRedisTemplate;

    private static final List<String> IGNORE_URI = Lists.newArrayList(
            "/api/short-Link/admin/v1/user/Login",
            "/api/short-Link/admin/admin/v1/actual/user/has-user-name"
            /*
            "/api/short-Link/admin/v1/user"
            */
    );

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpServletRequest.getRequestURI();
        if (!IGNORE_URI.contains(requestURI)) {
            String method = httpServletRequest.getMethod();
            if (!(Objects.equals(requestURI, "/api/short-Link/admin/v1/user") && Objects.equals(method, "POST"))) {
                String username = httpServletRequest.getHeader("username");
                String token = httpServletRequest.getHeader("token");
                if (!StrUtil.isAllNotBlank(username,token)) {
                    throw new ClientException(USER_TOKEN_FAIL);
                }
                Object userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_" + username, token);
                if (userInfoJsonStr != null) {
                    UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
                    UserContext.setUser(userInfoDTO);
                }
            }//POST和地址这两个都不满足证明,它需要去验证token,如果说你验证TOKEN不通过对吧
        }
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            UserContext.removeUser();
        }
    }
}

 只是判断它俩是否有值还不行,hai得去判断一下它是否存在在Redis里面,即token存储器里面是否存在?为了避免redis报错,做一次try catch

Object userInfoJsonStr = null;
try {
    userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_" + username, token);
}catch (Exception ex){
    throw new ClientException(USER_TOKEN_FAIL);
}

然后如果说我们这里判断它是否等于null, 直接下面那些就不会等于null了

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpServletRequest.getRequestURI();
        if (!IGNORE_URI.contains(requestURI)) {
            String method = httpServletRequest.getMethod();
            if (!(Objects.equals(requestURI, "/api/short-link/admin/v1/user") && Objects.equals(method, "POST"))) {
                String username = httpServletRequest.getHeader("username");
                String token = httpServletRequest.getHeader("token");
                if (!StrUtil.isAllNotBlank(username, token)) {
                    throw new ClientException(USER_TOKEN_FAIL);
                }
                Object userInfoJsonStr = null;
                try {
                    userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_" + username, token);
                    if (userInfoJsonStr == null) {
                        throw new ClientException(USER_TOKEN_FAIL);
                    }
                } catch (Exception ex) {
                    throw new ClientException(USER_TOKEN_FAIL);
                }

                UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
                UserContext.setUser(userInfoDTO);
            }
        }
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            UserContext.removeUser();
        }
    }
}

 在apifox中测试输入一个错误的token,老师发现全局异常处理捕获拦截器拦不到啊

后续拆解到网关了就不会有这种问题了,这是临时代码

package com.nageoffer.shortlink.admin.common.biz.user;

import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSON;

import com.google.common.collect.Lists;
import com.nageoffer.shortlink.admin.common.convention.exception.ClientException;
import com.nageoffer.shortlink.admin.common.convention.result.Results;
import com.nageoffer.shortlink.admin.common.enums.UserErrorCodeEnum;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.io.PrintWriter;
import java.net.URLDecoder;
import java.util.List;
import java.util.Objects;

import static com.nageoffer.shortlink.admin.common.enums.UserErrorCodeEnum.USER_TOKEN_FAIL;
import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * 用户信息传输过滤器
 */
@RequiredArgsConstructor
public class UserTransmitFilter implements Filter {
    private final StringRedisTemplate stringRedisTemplate;

    private static final List<String> IGNORE_URI = Lists.newArrayList(
            "/api/short-link/admin/v1/user/login",
            "/api/short-link/admin/v1/actual/user/has-user-name"

    );

    @SneakyThrows
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpServletRequest.getRequestURI();
        if (!IGNORE_URI.contains(requestURI)) {
            String method = httpServletRequest.getMethod();
            if (!(Objects.equals(requestURI, "/api/short-link/admin/v1/user") && Objects.equals(method, "POST"))) {
                String username = httpServletRequest.getHeader("username");
                String token = httpServletRequest.getHeader("token");
                if (!StrUtil.isAllNotBlank(username, token)) {
                    returnJson((HttpServletResponse) servletResponse, JSON.toJSONString(Results.failure(new ClientException(USER_TOKEN_FAIL))));
                    return;
                }
                Object userInfoJsonStr;
                try {
                    userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_" + username, token);
                    if (userInfoJsonStr == null) {
                        throw new ClientException(USER_TOKEN_FAIL);
                    }
                } catch (Exception ex) {
                    returnJson((HttpServletResponse) servletResponse, JSON.toJSONString(Results.failure(new ClientException(USER_TOKEN_FAIL))));
                    return;
                }

                UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
                UserContext.setUser(userInfoDTO);
            }
        }
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            UserContext.removeUser();
        }
    }

    private void returnJson(HttpServletResponse response, String json) throws Exception {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(json);
        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }
}

试一下,之前我们都是在网关层面去验让 ,自前先以功能为导向;为了给应用打包,在admin,project的pom中添加一点东西,没有repackage的话,它打出来的包只是你的原码包,里面不会包含可直接启动的springboot

    </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>

针对于全局异常处理捕获不到filter异常的问题,网上的另一种解决方案是定义一个新的filter,捕获出异常的filter,然后手动指定处理该请求的处理器,让他到达controller层,这样就能让@ControllerAdvice捕获到。

刚开始到拦截这一块就有点疑惑,代码到这一步整个系统还是单体项目那为什么要用javaweb里的Filter呢,直接用SpringMVC里的HandlerInterceptor 不就行了,还与GlobalExceptionHandler 适配 

在单体项目中使用 **Java Web Filter** 而非 **Spring MVC HandlerInterceptor** 是否合理,需结合具体场景分析:

---

### **1. Filter 与 HandlerInterceptor 的核心区别**
| **特性**                | **Filter(Servlet 规范)**                     | **HandlerInterceptor(Spring MVC)**       |
|-------------------------|-----------------------------------------------|-------------------------------------------|
| **作用层级**            | 更底层,作用于所有请求(包括静态资源、WebSocket等) | Spring MVC 控制器级别,仅作用于 Controller 方法 |
| **依赖注入**            | 需通过构造函数或 `@RequiredArgsConstructor` 注入 Spring Bean(需配置 FilterRegistrationBean) | 支持 `@Autowired` 直接注入 Spring Bean |
| **异常处理**            | 异常无法被 Spring `@ControllerAdvice` 捕获,需手动处理 | 异常可被 Spring 全局异常处理器捕获 |
| **执行时机**            | 在 Spring MVC 初始化前执行(如 DispatcherServlet 之前) | 在 Controller 方法前后执行 |
| **路径匹配灵活性**      | 需硬编码 URI 列表(如 `IGNORE_URI.contains(requestURI)`) | 支持 `AntPathMatcher` 等动态匹配规则 |

---

### **2. 当前代码中使用 Filter 的合理性分析**
#### **当前实现逻辑**:
- **Filter 功能**:  
  - 提取请求头中的 `username` 和 `token`,从 Redis 获取用户信息并存入 `ThreadLocal`(用户上下文)。  
  - 放行特定接口(如登录、注册、用户名检查)。  
  - 拦截未登录请求并返回错误码。  
- **异常处理**:  
  - 捕获 Redis 异常并手动返回 JSON 错误响应(`returnJson(...)`)。  

#### **合理性判断**:
- **优点**:  
  1. **全局性**:Filter 可拦截所有请求(包括非 Controller 的资源),适合处理全局上下文(如用户身份、日志记录)。  
  2. **与网关兼容**:若未来拆分为微服务架构,Filter 的设计可直接迁移至网关层,保持逻辑一致性。  
- **缺点**:  
  1. **异常处理复杂**:Filter 中的异常无法被 Spring 的 `@ControllerAdvice` 捕获,需手动返回响应(如 `returnJson(...)`)。  
  2. **路径匹配繁琐**:硬编码 `IGNORE_URI` 列表,维护成本高(如 RESTful 接口 `/api/short-Link/admin/v1/user` 的 POST 需特殊处理)。  
  3. **与 Spring 集成度低**:需显式通过构造函数注入 Bean(如 `StringRedisTemplate`),不如 HandlerInterceptor 直接使用 `@Autowired` 简洁。  

---

### **3. 改用 HandlerInterceptor 的可行性**
#### **改进步骤**:
1. **定义 Interceptor**:  
   ```java
   @Component
   public class UserContextInterceptor implements HandlerInterceptor {
       @Autowired
       private StringRedisTemplate stringRedisTemplate;

       private static final List<String> IGNORE_PATHS = Arrays.asList(
           "/api/short-link/admin/v1/user/login",
           "/api/short-link/admin/v1/user/has-username"
       );

       @Override
       public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
           String requestURI = request.getRequestURI();
           if (isIgnorePath(requestURI, request.getMethod())) {
               return true; // 放行指定路径
           }
           String username = request.getHeader("username");
           String token = request.getHeader("token");
           if (!StringUtils.hasText(username) || !StringUtils.hasText(token)) {
               throw new ClientException(USER_TOKEN_FAIL);
           }
           Object userInfoJsonStr = stringRedisTemplate.opsForHash().get("login_" + username, token);
           if (userInfoJsonStr == null) {
               throw new ClientException(USER_TOKEN_FAIL);
           }
           UserInfoDTO userInfoDTO = JSON.parseObject(userInfoJsonStr.toString(), UserInfoDTO.class);
           UserContext.setUser(userInfoDTO);
           return true;
       }

       @Override
       public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
           UserContext.removeUser();
       }

       private boolean isIgnorePath(String uri, String method) {
           // 使用 AntPathMatcher 优化路径匹配
           AntPathMatcher pathMatcher = new AntPathMatcher();
           return IGNORE_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, uri));
       }
   }
   ```

2. **注册 Interceptor**:  
   ```java
   @Configuration
   public class WebConfig implements WebMvcConfigurer {
       @Autowired
       private UserContextInterceptor userContextInterceptor;

       @Override
       public void addInterceptors(InterceptorRegistry registry) {
           registry.addInterceptor(userContextInterceptor)
                   .addPathPatterns("/**")
                   .excludePathPatterns("/api/short-link/admin/v1/user/login", "/api/short-link/admin/v1/user/has-username");
       }
   }
   ```

3. **异常处理优化**:  
   ```java
   @ControllerAdvice
   public class GlobalExceptionHandler {
       @ExceptionHandler(ClientException.class)
       public Result<?> handleClientException(ClientException ex) {
           return Results.failure(ex);
       }
   }
   ```

#### **优势**:
- **与 Spring 深度集成**:  
  - 可直接使用 `@Autowired` 注入 Bean,无需构造函数传参。  
  - 异常可被 `@ControllerAdvice` 统一捕获,避免手动返回 JSON 响应。  
- **路径匹配更灵活**:  
  - 支持 `AntPathMatcher`,无需硬编码路径(如 RESTful 接口匹配 `/api/short-link/admin/v1/user/**`)。  
- **代码简洁性**:  
  - 无需显式处理 `try-catch` 和 `finally`,由 Spring 管理生命周期。  

---

### **4. 为何原代码使用 Filter?**
可能原因:  
1. **历史遗留或网关迁移准备**:  
   - 若未来计划将拦截逻辑迁移至网关(如 Spring Cloud Gateway),Filter 设计可无缝迁移。  
2. **全局性需求**:  
   - 若需拦截非 Spring MVC 的资源(如 WebSocket、静态文件),Filter 是唯一选择。  
3. **团队习惯**:  
   - 开发者可能更熟悉 Filter 的传统模式,但实际在单体项目中并非最佳实践。

---

### **5. 结论与建议**
#### **判断正确性**:
- **用户质疑合理**:在单体项目中,**优先推荐使用 HandlerInterceptor**,因其与 Spring MVC 集成更紧密,异常处理更优雅。  
- **Filter 的适用场景**:  
  - 需拦截非 Spring MVC 的资源(如 `/ws/**` WebSocket)。  
  - 未来计划拆分网关时,提前统一设计。  

#### **改进建议**:
1. **替换为 HandlerInterceptor**:  
   - 使用 `@Component` 和 `WebMvcConfigurer` 注册拦截器。  
   - 利用 `AntPathMatcher` 动态匹配路径(如排除 `/api/short-link/admin/v1/user` 的 POST 请求)。  
2. **统一异常处理**:  
   - 移除 Filter 中的 `try-catch` 和 `returnJson(...)`,通过 `@ControllerAdvice` 统一处理 `ClientException`。  
3. **优化路径匹配**:  
   - 避免硬编码 `IGNORE_URI`,改用 `excludePathPatterns(...)` 或 `AntPathMatcher` 动态匹配。  
4. **依赖注入简化**:  
   - 通过 `@RequiredArgsConstructor` 或 `@Autowired` 注入 `StringRedisTemplate`,无需构造函数传递。  

#### **最终结论**:
- **单体项目中使用 HandlerInterceptor 更优**:  
  - 与 Spring MVC 集成度高,异常处理更优雅。  
  - 路径匹配更灵活,维护成本更低。  
- **Filter 适用于全局性或网关场景**:  
  - 若需拦截非 Controller 请求或计划拆分网关,Filter 是合理选择。  

**建议**:当前单体项目应优先使用 HandlerInterceptor,减少与 Spring 的耦合,提升代码可维护性。

第09节:分页查询短链接列表

 分表的数量一般是多少?是要和要根据业务去评估的,主要是分两个点:那就是你业务的一个现有数据量以及你业务的一个增长量
比如说你预估现有表假如说有一个亿的数据,但是你每月对吧,它的数据增长大概是在500万,你就要充分考虑到这一个亿的历史的数据,以及你每月500万的增长数据,然后这个时候你要去评估出一个就是你每张表大概最大的数据量,比如说我预估T-link-0,它的整体的就是单张表2,000万数据性能绰绰有余,对吧?我就可以按照这种方式去评估。然后具体的话我会写一张文档里面去给大家去讲,明到时候大家去看我们短链接的文档里面就可以了,然后讲的话我们还是按照默认16张表这样创建形成一种规范,这里面有个非常重要的点,它这个表可以多,每张表的数据可以少,但是千万不要一开始设计的很少,因为等你设计的等你的表多了之后,你要再给他进行扩容,那就是在高速上换车胎,非常的不推荐。

在数据库中创建新字段的favicon ,在gid后面添加`favicon` varchar(256) DEFAULT NULL COMMENT '网站图标',

右键navicat的表,设计表,即可修改,然后重新弄16个表

package com.nageoffer.shortlink.admin.test;

/**
 *  做一个分片的处理,我们先分十六个片啊
 */
public class UserTableShardingTest {

    public static final String SQL = "CREATE TABLE `t_link_%d` (\n" +
            "  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',\n" +
            "  `domain` varchar(128) DEFAULT NULL COMMENT '域名',\n" +
            "  `short_uri` varchar(8) DEFAULT NULL COMMENT '短链接',\n" +
            "  `full_short_url` varchar(128) DEFAULT NULL COMMENT '完整短链接',\n" +
            "  `origin_url` varchar(1024) DEFAULT NULL COMMENT '原始链接',\n" +
            "  `click_num` int DEFAULT '0' COMMENT '点击量',\n" +
            "  `gid` varchar(32) DEFAULT NULL COMMENT '分组标识',\n" +
            "  `favicon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '网站图标',\n" +
            "  `enable_status` tinyint(1) DEFAULT NULL COMMENT '启用标识 1:未启用 0:已启用',\n" +
            "  `created_type` tinyint(1) DEFAULT NULL COMMENT '创建类型 0:控制台 1:接口',\n" +
            "  `valid_date_type` tinyint(1) DEFAULT NULL COMMENT '有效期类型 0:永久有效 1:用户自定义',\n" +
            "  `valid_date` datetime DEFAULT NULL COMMENT '有效期',\n" +
            "  `describe` varchar(1024) DEFAULT NULL COMMENT '描述',\n" +
            "  `create_time` datetime DEFAULT NULL COMMENT '创建时间',\n" +
            "  `update_time` datetime DEFAULT NULL COMMENT '修改时间',\n" +
            "  `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',\n" +
            "  PRIMARY KEY (`id`),\n" +
            "  UNIQUE KEY `idx_unique_full_short_url` (`full_short_url`) USING BTREE\n" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;";

    public static void main(String[] args) {
        for (int i = 0; i < 16; i++) {
            System.out.printf((SQL) + "%n", i);
        }
    }
}

开发我们的短链接这个分页功能 ,现在是MybatisPlus了

ShortLinkDO加一个字段

/**
 * 网站标识
 */
private String favicon;

面先预留出来一下 ,相当于我们有个那种监控字段,我们先我们先不给他赋值

package com.nageoffer.shortlink.project.dto.req;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.Data;

/**
 * 短链接分页请求参数
 */
@Data
public class ShortLinkPageReqDTO extends Page<ShortLinkDO> {
    /**
     * 分组标识
     */
    private String gid;
}

 dto里面的req和resp中分别写

package com.nageoffer.shortlink.project.dto.resp;

import lombok.Data;

import java.util.Date;

/**
 * 短链接分页返回参数
 */
@Data
public class ShortLinkPageRespDTO {
    /**
     * id
     */
    private Long id;
    /**
     * 域名
     */
    private String domain;
    /**
     * 短链接
     */
    private String shortUri;
    /**
     * 完整短链接
     */
    private String fullShortUrl;
    /**
      *原始链接
     */
    private String originUrl;
    /**
     * 分组标识
     */
    private String gid;
    /**
     * 有效期
     */
    private Date validDate;
    /**
     * 描述
     */
    private String describe;
    /**
     * 网站标识
     */
    private String favicon;

}

 在controller和service中创建相关代码

/**
 * 分页查询短链接
 */
@GetMapping("/api/short-link/v1/page")
public Result<IPage<ShortLinkPageRespDTO>> pageShortLink(ShortLinkPageReqDTO requestParam) {
       return Results.success(shortLinkService.pageShortLink(requestParam));
}
/**
 * 分页查询短链接
 * @param requestParam 请求参数
 * @return 分类返回结果
 */
IPage<ShortLinkPageRespDTO> pageShortLink(ShortLinkPageReqDTO requestParam);

 impl如下

@Override
public IPage<ShortLinkPageRespDTO> pageShortLink(ShortLinkPageReqDTO requestParam) {
    LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
            .eq(ShortLinkDO::getGid, requestParam.getGid())
            .eq(ShortLinkDO::getEnableStatus, 0)
            .eq(ShortLinkDO::getDelFlag, 0)
            .orderByDesc(ShortLinkDO::getCreateTime);
    IPage<ShortLinkDO> resultPage = baseMapper.selectPage(requestParam, queryWrapper);
    return resultPage.convert(each -> BeanUtil.toBean(each, ShortLinkPageRespDTO.class));
}

在config中创建 DataBaseConfiguration

package com.nageoffer.shortlink.project.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataBaseConfiguration {
    /**
     * 分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

同样的在admin中创建上面那个文件

{
    "code": "0",
    "message": null,
    "data": {
        "records": [
            {
                "id": 1926975245760077826,
                "domain": "http://eve2333.com",
                "shortUri": "3TajjG",
                "fullShortUrl": "http://eve2333.com/3TajjG",
                "originalUrl": null,
                "gid": "14",
                "validDate": null,
                "describe": "啊米浴说的道理ccb",
                "favicon": null
            },
            {
                "id": 1926972819892105218,
                "domain": "http://eve2333.com",
                "shortUri": "QqQ0b",
                "fullShortUrl": "http://eve2333.com/QqQ0b",
                "originalUrl": null,
                "gid": "14",
                "validDate": null,
                "describe": "啊米浴说的道理ccb",
                "favicon": null
            }
        ],
        "total": 2,
        "size": 10,
        "current": 1,
        "orders": [],
        "optimizeCountSql": true,
        "searchCount": true,
        "maxLimit": null,
        "countId": null,
        "gid": "14",
        "pages": 1
    },
    "requestId": null,
    "success": true
}

多余的参数 是和后面网关用的,现在先这么用着 

 之前在短链接分组表中,gid和username一起作为唯一索引,而这里短链接表里只有gid,没有username,因此可能查到其他用户的短链接,建议把gid设置为全局唯一,因为6位的gid,每位62种字符,580多亿,完全足够了

其实不会的,因为前面新增分组的判断逻辑是查询group表中是否有重复gid。 应该增加校验,验证用户身份,防止用户篡改请求参数中的gid直接查到其他用户的分组下的链接。link表应该也可以加上username字段,查的时候需要这两个条件就行。

第10节:后管联调中台短链接接口

 

请求到我们的前端控制台,然后前端控制台现在是访问什么?访问前端控制台里面访问的是后管。我们先来看一下我们的代码是写在project里面,这边货款在这里。如果说这样的话就变成演变成了什么?前端还给他分出来一个去请求我们的短链接中心,其实这种事能不能实现效果是可以的,但是有一点不太一样是什么?首先我们后果也不能讲完全的后管,这个后管是提供给我们这些SARS用户,通过我们的什么控制台访问的,相当于有一点是什么?它是需要什么?它是需要用户信息的,对不对?它得验证用户信息,也就是我们的token。 然后其次它这里面访问后管,访问后管的都不是那种什么,都不是那种并发量较低。然后这种是访问的是后管带图形化界面,然后短链接中心的话,短链接中心一个是作为 SARS提供API调用,然而这个API调用是我们要颁发给客户端一个token,这个不能说token就是一个密钥,我们要颁发给客户端一个密钥,然后你来请求我的时候,你在里面把这个密钥带上,然后我才能验证你的身份注意,密钥它不是用户的,它是个别颁发的。

然后第二它的流量并发量非常高,你想我们作为一款SasS应用,它不只是对个人,它也对企业如果说,假如说阿里巴巴买了我的 SARS的短链接系统当然不太现实,但是如果他买了他要生成的量是非常大的,当然我会收他钱很多,但是收他钱多就意味着人家对吧调研调你的接口人家肆无忌惮,所以说它这里面主要应对的是什么?就是个人以及企业的SARS化的短链接接口调用,所以说它一般它是不会对到控制台的,一般我们会干什么?Ok我们现在理清楚这个关系了,那理清楚这个关系之后我们现在要做什么?我们是不是要通过后管去调我们的短链接中心实现这种调用,两个项目之间怎么调用?大家想一想是不是就是比较常见的是不是就是HTTP网络协议调用?正就是如果说在企业当中肯定是类似于spring cloud或者 double这种IPC,类似于这两种去调用,但是目前我们的主要流程还没讲完,我们现在先以HTTP的方式去调,然后等我们都讲完后,我们再用这种比如说spring cloud我们就用spring cloud去改造,现在先怎么简单怎么来好吧?

给ShortLinkPageRespDTO添加和补充

package com.nageoffer.shortlink.admin.remote.dto.resp;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.util.Date;

/**
 * 短链接分页返回参数
 */
@Data
public class ShortLinkPageRespDTO {

    /**
     * id
     */
    private Long id;

    /**
     * 域名
     */
    private String domain;

    /**
     * 短链接
     */
    private String shortUri;

    /**
     * 完整短链接
     */
    private String fullShortUrl;

    /**
     * 原始链接
     */
    private String originUrl;

    /**
     * 分组标识
     */
    private String gid;

    /**
     * 有效期类型 0:永久有效 1:自定义
     */
    private Integer validDateType;

    /**
     * 有效期
     */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date validDate;

    /**
     * 创建时间
     */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;

    /**
     * 描述
     */
    private String describe;

    /**
     * 网站标识
     */
    private String favicon;
}

 HTTP的话就比较简单了嘛,创建短链接和这个分页查询短链接我们都是需要的,那么就要将project 的req,resp都复制到admin的remote dto里面去(ShortLinkPageReqDTO extends Page;没有shortlinkDO),后面用fin的话是interface,如果说你用接口去调的话。有一个java8的default方法,在remote.dto下创建ShortLinkRemoteService

package com.nageoffer.shortlink.admin.remote.dto;

import cn.hutool.core.lang.TypeReference;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkPageReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkPageRespDTO;

import java.util.HashMap;
import java.util.Map;

public interface ShortLinkRemoteService {
    default Result<IPage<ShortLinkPageRespDTO>> pageShortLink(ShortLinkPageReqDTO requestParam) {
        Map<String,Object>requestMap=new HashMap<>();
        requestMap.put("gid",requestParam.getGid());
        requestMap.put("current",requestParam.getCurrent());
        requestMap.put("size",requestParam.getSize());
        String resultPageStr = HttpUtil.get("http://127.0.0.1:8001/api/short-link/v1/page",requestMap);
        return JSON.parseObject(resultPageStr, new TypeReference<>() {
        });
    }
}

controller新建ShortLinkController

package com.nageoffer.shortlink.admin.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import com.nageoffer.shortlink.admin.remote.dto.ShortLinkRemoteService;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkPageReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkPageRespDTO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ShortLinkController {
    
    @GetMapping("/api/short-link/admin/v1/page")
    public Result<IPage<ShortLinkPageRespDTO>> pageShortLink(ShortLinkPageReqDTO requestParam) {
        ShortLinkRemoteService shortLinkRemoteService = new ShortLinkRemoteService(){};
        return shortLinkRemoteService.pageShortLink(requestParam);
    }
}

在apifox中原来提供的两个创建短链接,上一个用的应该是短链接管理中的,怪不得是多了参数

 admin的ShortLinkController和ShortLinkRemoteService如下 短链接后管控制层

package com.nageoffer.shortlink.admin.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import com.nageoffer.shortlink.admin.common.convention.result.Results;
import com.nageoffer.shortlink.admin.remote.dto.ShortLinkRemoteService;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkPageReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkPageRespDTO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * 短链接后管控制层
 */
@RestController
public class ShortLinkController {
    ShortLinkRemoteService shortLinkRemoteService = new ShortLinkRemoteService(){};
    /**
     * 创建短链接
     */
    @PostMapping("/api/short-link/admin/v1/create")
    public Result<ShortLinkCreateRespDTO> createShortLink(@RequestBody ShortLinkCreateReqDTO requestParam) {
        return shortLinkRemoteService.createShortLink(requestParam);
    }

    @GetMapping("/api/short-link/admin/v1/page")
    public Result<IPage<ShortLinkPageRespDTO>> pageShortLink(ShortLinkPageReqDTO requestParam) {
       
        return shortLinkRemoteService.pageShortLink(requestParam);
    }
}
package com.nageoffer.shortlink.admin.remote.dto;

import cn.hutool.core.lang.TypeReference;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.nageoffer.shortlink.admin.common.convention.result.Result;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkCreateReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.req.ShortLinkPageReqDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkCreateRespDTO;
import com.nageoffer.shortlink.admin.remote.dto.resp.ShortLinkPageRespDTO;
import org.springframework.web.bind.annotation.RequestBody;

import java.util.HashMap;
import java.util.Map;

public interface ShortLinkRemoteService {
    /**
     * 创建短链接
     *
     * @param requestParam 创建短链接请求参数
     * @return 短链接创建响应
     */
    default Result<ShortLinkCreateRespDTO> createShortLink(ShortLinkCreateReqDTO requestParam) {
        String resultBodyStr = HttpUtil.post("http://127.0.0.1:8001/api/short-link/v1/create", JSON.toJSONString(requestParam));
        return JSON.parseObject(resultBodyStr, new TypeReference<>() {
        });
    }

    /**
     * 分页查询短链接
     * @param requestParam 分页短链接请求参数
     * @return 查询短链接响应
     */
    default Result<IPage<ShortLinkPageRespDTO>> pageShortLink(ShortLinkPageReqDTO requestParam) {
        Map<String, Object> requestMap = new HashMap<>();
        requestMap.put("gid", requestParam.getGid());
        requestMap.put("current", requestParam.getCurrent());
        requestMap.put("size", requestParam.getSize());
        String resultPageStr = HttpUtil.get("http://127.0.0.1:8001/api/short-link/v1/page", requestMap);
        return JSON.parseObject(resultPageStr, new TypeReference<>() {
        });
    }


}

 点击运行ShortLinkAdminAppllication,两个服务都要启动

有一个错误,但是我找不到,事实上这个在上期就可能出现了

tm的,原来是这一句short-link写成short-Link了

if (!(Objects.equals(requestURI, "/api/short-link/admin/v1/user") && Objects.equals(method, "POST")))

TODO 后续重构为SpringCloud Feign调用

 这是短链接后管系统调用中台请求相关接口

 我们的服务器上的,但是部署到服务器里面它会有一个问题,那就是shardingsphere-config-dev这个东西,它这里面的参数没有办法通过vm参数以及spring那种注入的方式给它替换起来,那也就相当于它这里面写什么,你在服务器里面就是什么,但是我生产的服务器它的密码不叫root,因为你在生产部署如果说,你设置一个弱密码的话,可能被别人直接用那种表给你就炸了,所以说我密码设计得非常复杂,这种情况下怎么办?application如下

url: jdbc:shardingsphere:classpath:shardingsphere-config-${database.env:dev}.yaml

只能说我们这边给他改一下。我跟他给你找到了,大家演示一下该怎么说该怎么做。首先我们的改成默认的DEV,然后在这里给它加一个参数。

Java启动的话,都知道能在Java启动过程当中传递一些那种系统参数,然后我们在这里通过上下文去拿到 database.dev,然后判断如果说它不等于空,那么就是如果说它这个参数等于空,那么它默认就是DV,所以说我们默认读DV的参数,然后这里的话我们给它改成prod的,然后密码

shardingsphere-config-dev版就是这样

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

prod是 密码复杂的线上版本,project一样的道理,一样的东西给project也来一波

vm参数这是“开发服务端部署短链接项目数据库配置变更”

 旺旺掀被:我的中台远程调用服务,在Service那里的return会报类型转换异常,你们有同情况解决了,t我一下

你从树林深处走出雾 回复 旺旺掀被:我的也在return报类型转换异常了,后来发现是中台远调后的接收结果类型不对,api那里返回的已经是Result<IPage<>>了,结果我又搞了个IPage反序列化

wdnmd 回复 你从树林深处走出雾:为啥这里显示我没携带token,报了我自己的异常

你从树林深处走出雾 回复 wdnmd:之前后管系统不是设置了登录检测嘛,请求头里得有username和token,你看看你的请求里有没有加

空想家 回复 旺旺掀被:如果你说的日期转换异常的话,需要在请求参数的日期类型上加上JsonFormat

旺旺掀被 回复 你从树林深处走出雾:我也忘记什么错了,但不是你的这个,反正我后面是用的Post请求。

旺旺掀被 回复 空想家:这个我还是有注意到的,不是这个哦

Faded 回复 旺旺掀被:我的报错原因是没启动ShortLinkApplication,两个启动类都要启动

清晨 回复 旺旺掀被:我的是TypeReference的包导错了,应该导入fastjson2的包

旺旺掀被 回复 Faded:如果服务没启动应该不会是类型转换异常吧

旺旺掀被 回复 清晨:这个我也错过,我开了那个自动导包有时候还是会有点烦人。

想毕业哩 回复 Faded:我也是这样,请问你解决了吗

Faded 回复 想毕业哩:两个服务都要启动

一只鱼 回复 Faded:我也是

Drano 🌝 回复 wdnmd:看看是不是 HttpUtil.get里面的url写错了,我端口号8001写成了8002就会报这个错误

 HttpUtil怎么设置charSet和contentType阿?我远程调用报的这个错?

我设置了HttpUtil的charSet和contentType,但又有新问题,我后来用RestTemplate了

王璞:现在随便一个用户登录就可以查看所有的短链接信息吗?还没有用户与短链接关联?不太懂

2024-03-14 09:19

阿白 回复 王璞:同问

2024-04-13 02:00

马丁 回复 王璞:Get,修复思路:判断当前登录用户和 gid 的记录是否存在,会在最新代码中修复

2024-04-15 10:48

不想取名 回复 马丁:请问这个地方还没修复吗?

2024-06-19 20:22

马丁 回复 不想取名:已修复,通过 gid 全局唯一手段解决

2024-06-19 20:23

不想取名 回复 马丁:噢噢好的谢谢马哥,我还有个问题发在短链接问答里的,可以请你解答一下吗

2024-06-19 20:29

不想取名 回复 马丁:马哥,请问如果这个短链接中心针对的是个人和企业的api调用,那么这个后管系统针对的是谁呢?

2024-06-20 17:50

TheTurtle 回复 不想取名:个人啊 也就是通过控制台

2024-07-11 22:45

TheTurtle 回复 不想取名:对企业就相当于某项目内查电话号码的归属地需要访问运营商那边提供的API,换到这里就是直接请求短链接中心的 API 。这种并发量取决于企业(请求方)那边的并发量。

2024-07-11 22:47

不想取名 回复 TheTurtle:这样设计的意义是什么呢,为什么不都直接通过后管系统访问

2024-07-13 23:03

MayDay 回复 不想取名:我的理解是,用户都是统一通过登录后管来获取服务,登录后管的用户,一种方式是直接在后管点点点操作生成短链接;还有一种通过api调用生成,就是生成一个属于该用户的密钥,用户可以自己再开发一个程序去调用这个api,配置好正确的密钥就使用生成短链的服务。 所以后管只是给用户或企业一个统一入口,既可以在上面生成短链(一般是少量的),也可在上面申请密钥然后调用api去生成短链(一般是大量的)。申请到密钥后,后续不需要用户认证也可以调用短链服务。

2024-07-20 14:07

TheTurtle 回复 不想取名:比如你需要在项目中获取高德地图的 IP 定位服务 那不可能去控制台手动点击获取吧 而是通过访问高德的接口 然后你把短链接项目想成这样就行了 别人的一些项目可以调用短链接中台的提供 API 服务

2024-07-24 17:38

微辣 回复 MayDay:就是自己再开发一个sdk给别人用,别人拿到sdk,然后配置一下从我们这儿获得的密钥,就可以调用我们的接口了

 Required request body is missing: public com.nageoffer.shortlink.project.convention.result.Result<com.baomidou.mybatisplus.

core.metadata.IPage<com.nageoffer.shortlink.project.dto.resp.ShortLinkPageRespDTO>> ,请问遇到这个错误吗?在admin里面的分页查询功能报这个错误,调用project里面的还能正常运行

视频里面请求时的validDate是空字符串,在后管中插入短链接会报错,是 JSON.toJSONString 解析的日期格式不对,添加红框中的几行即可,但仍然存在 bug,"validDate"为"2002-10-07T16:11:46",在后管中发起请求写入数据库是 2002-10-08 08:11:46,同样的 validDate 直接通过中台发起请求写入数据库是 2002-10-08 00:11:46, 想问下,为什么 2002-10-07T16:11:46、2002-10-08 08:11:46、2002-10-08 00:11:46这三个时间不一致,如果想要一致,该如何处理?

在application.yml 中配置时区好像就好了 

为什么不直接使用openfeign进行调用?这样搞的好麻烦啊,我记得使用openfeign进行本地调用也是可以的啊,地址改成本地的,之前看黑马的科的时候有个老师这样搞过,,,,马丁哥这样搞感觉实在是太臃肿了 

 使用JSON.toJSONString对requestParam对象序列化为JSON字符串后,调用HttpUtil.post()后出现validDate格式不正确的问题。 解决方法:在com.nageoffer.shortlink.project.dto.req的ShortLinkCreateReqDTO类中为validDate添加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")即可解决

报错提示gid==null,接收不到参数,原因是在Apifox中是以Body中json的方式提交数据,然后把Apifox中的请求改为Post,并且将admin中的ShortLinkController相应的请求改为Post+@RequestBody,测试通过了;后面又将Apifox中的请求改为Get,将admin中的ShortLinkController相应的请求改为Get并且去掉@RequestBody,也通过了测试。所以就是请求与请求格式不匹配的问题,这里会出错是因为@GetMapping("/api/short-link/admin/v1/page") public Result<IPage<LinkPageRespDTO>> pageLink(LinkPageReqDTO requestParam)是请求了一个对象,所以想当然就用上body+@RequestBody 但这是在Spring Boot中,当在Controller方法参数中使用一个自定义对象(如LinkPageReqDTO)并且没有添加任何注解(如@RequestParam或@RequestBody)时,Spring会默认使用**参数绑定(Data Binding)机制来自动将HTTP请求参数映射到该对象的字段上。这是Spring MVC的一个特性,称为命令对象(Command Object)**绑定。 

修正错误

从 ShortLinkPageRespDTO.java的     private Integer validDateType;还有 private String originUrl;不要写错(不要过于相信Tab啊)

ShortLinkDO里面是Integer不是int啊

第11节:创建用户后默认添加短链接分组(上)

短链接分组的一个事情。当我们用户注册完成之后,我们肯定是要有一个默认的短连接分组的,因为如果说你没有短链接分组,它肯定就是说它没办法进行相应的短链接的创建,因为我们短链接分组在创建短链接的时候是必填的也就是说你在登录过程之后,它要有一个默认的一个叫做默认分组的一个就是分组的这条记录,所以说我们在用户创建完,我们就要去执行这个操作。

这样的话我们首先对吧,我们需要考虑的第一个事情用户都分表了,你如果说短链接分组,你如果要做的话,他肯定也是要分表的,所以说我们首先要对短链接进行分组,用户对吧?你如果用户都已经分了16张表,如果说短时间分表,只能短链接分组,如果分表只能比他多不能比他少,因为你一个用户至少有一个短链接分组,1个用户多的话,我们默认它设置它可以最多有10个,所以说我们尽量去把分组设置的分真实去分表的话要设置的大一些,但是因为我们现在是在测试,所以说也是分16个保持一致,因为你如果说在我们实战里面去写对应的分表数太多的话,你数据库展示的就比较麻烦一点,所以说我们就先分16个,分表的话,我们这里还是老样子,我们去创建一下先创建一下我们的分表

为t_group分表 ,

package com.nageoffer.shortlink.admin.test;

public class UserTableShardingTest {

    public static final String SQL = "CREATE TABLE `t_group_%d` (\n" +
            "  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',\n" +
            "  `gid` varchar(32) DEFAULT NULL COMMENT '分组标识',\n" +
            "  `name` varchar(64) DEFAULT NULL COMMENT '分组名称',\n" +
            "  `username` varchar(256) DEFAULT NULL COMMENT '创建分组用户名',\n" +
            "  `sort_order` int DEFAULT NULL COMMENT '分组排序',\n" +
            "  `create_time` datetime DEFAULT NULL COMMENT '创建时间',\n" +
            "  `update_time` datetime DEFAULT NULL COMMENT '修改时间',\n" +
            "  `del_flag` tinyint(1) DEFAULT NULL COMMENT '删除标识 0:未删除 1:已删除',\n" +
            "  PRIMARY KEY (`id`),\n" +
            "  UNIQUE KEY `idx_unique_username_gid` (`gid`,`username`) USING BTREE\n" +
            ") ENGINE=InnoDB AUTO_INCREMENT=1924109143078203394 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;";

    public static void main(String[] args) {
        for (int i = 0; i < 16; i++) {
            System.out.printf((SQL) + "%n", i);
        }
    }
}

用什么来分键呢?肯定是不能用gid的,我查当前用户下的所有的短链接分组没有gid只有username,所以说是usernname。修改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
            # 分片算法,对应 rules[0].shardingAlgorithms
            shardingAlgorithmName: user_table_hash_mod
      t_group:
        actualDataNodes: ds_0.t_group_${0..15}
        tableStrategy:
          standard:
            shardingColumn: username
            shardingAlgorithmName: group_table_hash_mod
    # 分片算法
    shardingAlgorithms:
      # 数据表分片算法
      user_table_hash_mod:
        # 根据分片键 Hash 分片
        type: HASH_MOD
        # 分片数量
        props:
          sharding-count: 16
      group_table_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 加密密钥
          aes-key-value: d6oadClrrb9A3GWo

props:
  sql-show: true

由于那个很麻烦啊,我把redis清空了,我们重新注册一个用户

优化用户新增重复提示报错:UserServiceImpl修改如下

try {
    if (lock.tryLock()) {
        try {
            int inserted = baseMapper.insert(BeanUtil.toBean(requestParam, UserDO.class));
            if (inserted < 1) {
                throw new ClientException(USER_SAVE_ERROR);
            }
        } catch (DuplicateKeyException ex) {
            throw new ClientException(USER_EXIST);
        }
        userRegisterCachePenetrationBloomFilter.add(requestParam.getUsername());
        return;
    }
    throw new ClientException(USER_NAME_EXIST);
} finally {
    lock.unlock();
}

 信息如下:请求头也要配

{
    "username":"test529",
    "password":"test",
    "realName":"测试",
    "phone":"15198765432",
    "mail":"114514@qq.com"
}
"token": "6de85325-110a-4c3a-9687-2c3867778024"

header只有11个,接口也要输对啊 

 插入到6表里面了,我们看数据库地区成功了;我们在新建一个“测试分组2”,查询分组集合,出现了

username是不必要的在 ShortLinkGroupRespDTO.java删除他,添加一个分组下短链接数量private String shortLinkCount;            优化短链接分组查询返回实体

在project中dto的resp创建如下

package com.nageoffer.shortlink.project.dto.resp;

import lombok.Data;

@Data
public class ShortLinkGroupCountQueryRespDTO {
    /**
     * 分组标识
     */
    private String gid;
    /**
     * 当前分组短链接数量
     */
    private Integer shortLinkCount;
}  

 ShortLinkController,service,impl修改如下

/**
 * 查询短链接分组内链接数量
 */
@GetMapping("/api/short-link/v1/count")
public Result<List<ShortLinkGroupCountQueryRespDTO>> listGroupShortLinkCount(List<String>requestParam) {
    return Results.success(shortLinkService.listGroupShortLinkCount(requestParam));
}
/**
 * 查询短链接分组内数量
 * @param requestParam 查询短链接分组内数量请求参数
 * @return 查询短链接分组内数量响应
 */
List<ShortLinkGroupCountQueryRespDTO> listGroupShortLinkCount(List<String> requestParam);

 mp不好写;gid 是不允许变更的

    private final ShortLinkMapper shortLinkMapper;



    @Override
    public List<ShortLinkGroupCountQueryRespDTO> listGroupShortLinkCount(List<String> requestParam) {
        QueryWrapper<ShortLinkDO> queryWrapper = Wrappers.query(new ShortLinkDO())
                .select("gid as gid, count(*) as shortLinkCount")
                .in("gid", requestParam)
                .eq("enable_status", 0)
                .groupBy("gid");
        List<Map<String, Object>> shortLinkDOList = baseMapper.selectMaps(queryWrapper);
        return BeanUtil.copyToList(shortLinkDOList, ShortLinkGroupCountQueryRespDTO.class);
    }

 优化短链接后管远程调用包目录

 最后进行计数查询的时候实际sql显示查询了create_type和valid_date_type导致最后查询不出来结果,但我并没有传这两个字段,有同样遇到这个问题的么

把ShortLinkDO的enable_status、create_type和valid_date_type对应属性的类型改成Integer就行了,原来这三个属性都是int:在MyBatisPlus中,当实体类的属性是基本数据类型(如int)而不是其包装类型(如Integer)时,可能会遇到一些特殊行为,尤其是在动态SQL生成的场景中。这是因为基本数据类型不能被赋予null值,而包装类型可以。 当MyBatisPlus使用AbstractWrapper(如QueryWrapper)来构建动态SQL的where条件时,它会检查实体类属性的值来决定是否将该条件加入到SQL语句中。如果属性是Integer类型且值为null,MyBatisPlus会理解为你不想根据这个字段过滤数据,因此不会在生成的SQL中包含这个条件。但如果是int类型,由于它不能为null,在实体类实例化时,默认值为0,这可能导致以下情况: 如果你本意是想根据某个条件动态地决定是否添加到查询中,而这个条件对应的实体类属性是int类型,默认值0会被误解为有效的查询条件,即where子句中会包含这个字段等于0的条件。 即使你不打算基于这个字段进行过滤,因为没有null的概念,该字段的默认值(通常是0)会被视为有效值并影响查询结果。 因此,为了更灵活地处理可选的查询条件,推荐在实体类中使用包装类型(如Integer、Long等),这样可以利用null值来明确表示“不考虑此条件”的意图。

qwer:这里把username作为t_group的分片键,在创建group的时候,随机生成gid并查找数据库是否存在的操作会导致读扩散扫描所有表吧

start 回复 qwer:在创建group的时候,随机生成gid并查找数据库的时候,不是把username也传进去了吗

。 回复 qwer:是的,逻辑SQL和实际SQL一样

花开富贵 回复 qwer:把username作为t_group的分片键,在创建group的时候,随机生成gid并查找数据库并不会存在的操作会导致读扩散扫描所有表,因为我们在创建短链接组saveGroup的时候,从我们的gid是随机生成的,但是我们判断gid是否存在是通过username和gid一块查hasGid的,而我们此时使用username作为分片键,因此就不存在扫描所有的表判断gid是否可用。 可以创建新的短链接分组验证一下,首先执行hasGid判断随机生成的gid是否可用,可以看到执行的logic sql和actual sql不一样,logic sql是from t_group,而actual sql是from t_group_x,因此进一步验证了没有扫描全表。

 我记得之前gid设置的不是全局唯一,而是用户下唯一,那么如果这里两个用户巧合gid相同,是怎么区分的呢?不会查出错误的数量吗?Get,最新代码会优化该问题

第12节:创建用户后默认添加短链接分组(下)

 ShortLinkRemoteService.java新增如下函数

/**
 * 查询分组短链接总量
 * @param requestParam 分组短链接总量请求参数
 * @return 查询分组短链接总量响应
 */
default Result<List<ShortLinkGroupCountQueryRespDTO>> listGroupShortLinkCount(List<String> requestParam) {
    Map<String, Object> requestMap = new HashMap<>();
    requestMap.put("requestParam", requestParam);
    String resultPageStr =HttpUtil.get("http://127.0.0.1:8001/api/short-link/v1/count", requestMap);
    return JSON.parseObject(resultPageStr, new TypeReference<>() {
    });
}

GroupServicelmpl.java如下 


    ShortLinkRemoteService shortLinkRemoteService =new ShortLinkRemoteService() {
    };



    @Override
    public List<ShortLinkGroupRespDTO> listGroup(){

        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getDelFlag, 0)
                .eq(GroupDO::getUsername,UserContext.getUsername())
                .orderByDesc(GroupDO::getSortOrder, GroupDO::getUpdateTime);
        List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);
        Result<List<ShortLinkGroupCountQueryRespDTO>> listResult=shortLinkRemoteService.(groupDOList.stream().map(GroupDO::getGid).toList());
        return BeanUtil.copyToList(groupDOList, ShortLinkGroupRespDTO.class);
    }

 我们的GID是不会重复的,如果不会重复的话我们不如让它返回一个map,ShortLinkGroupCountQueryRespDTO如下

package com.nageoffer.shortlink.admin.remote.dto.resp;
@Data
public class ShortLinkGroupCountQueryRespDTO {
    private String gid;
    private Integer shortLinkCount;
}
    @Override
    public List<ShortLinkGroupRespDTO> listGroup() {

        LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
                .eq(GroupDO::getDelFlag, 0)
                .eq(GroupDO::getUsername, UserContext.getUsername())
                .orderByDesc(GroupDO::getSortOrder, GroupDO::getUpdateTime);
        List<GroupDO> groupDOList = baseMapper.selectList(queryWrapper);
        Result<List<ShortLinkGroupCountQueryRespDTO>> listResult = shortLinkRemoteService
                .listGroupShortLinkCount(groupDOList.stream().map(GroupDO::getGid).toList());
        List<ShortLinkGroupRespDTO> shortLinkGroupRespDTOList = BeanUtil.copyToList(groupDOList, ShortLinkGroupRespDTO.class);
        shortLinkGroupRespDTOList.forEach(each -> {
            Optional<ShortLinkGroupCountQueryRespDTO> first = listResult.getData().stream()
                    .filter(item -> Objects.equals(item.getGid(), each.getGid()))
                    .findFirst();
            first.ifPresent(item -> each.setShortLinkCount(first.get().getShortLinkCount()));
        });
        return shortLinkGroupRespDTOList;
    }

 里面ShortLinkGroupRespDTO是           private Integer shortLinkCount;不是string

短链接分组列表查询返回短链接数量

 接下来创建完用户之后给对应的分组进行创建,UserServiceImpl修改这个函数

    @Override
    public void Register(UserRegisterReqDTO requestParam) {
        if (!hasUsername(requestParam.getUsername())) {
            throw new ClientException(USER_NAME_EXIST);
        }
        RLock lock = redissonClient.getLock(LOCK_USER_REGISTER_KEY + requestParam.getUsername());
        try {
            if (lock.tryLock()) {
                try {
                    int inserted = baseMapper.insert(BeanUtil.toBean(requestParam, UserDO.class));
                    if (inserted < 1) {
                        throw new ClientException(USER_SAVE_ERROR);
                    }
                } catch (DuplicateKeyException ex) {
                    throw new ClientException(USER_EXIST);
                }
                userRegisterCachePenetrationBloomFilter.add(requestParam.getUsername());
                groupService.saveGroup(requestParam.getUsername(),"默认分组");
                return;
            }
            throw new ClientException(USER_NAME_EXIST);
        } finally {
            lock.unlock();
        }
    }

feature:注册用户后默认创建对应分组

报错了,GroupServiceImpl如下

@Override
public void saveGroup(String groupName) {
    saveGroup(UserContext.getUsername(), groupName);
}
@Override
public void saveGroup(String username, String groupName) {
    String gid;
    do {
        gid = RandomGenerator.generateRandom();
    } while (!hasGid(username,gid));
    GroupDO groupDO = GroupDO.builder()
            .gid(gid)
            .sortOrder(0)
            .username(username)
            .name(groupName)
            .build();
    baseMapper.insert(groupDO);
}

private boolean hasGid(String username,String gid) {
    LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class)
            .eq(GroupDO::getGid, gid)
            .eq(GroupDO::getUsername, Optional.ofNullable(username).orElse(UserContext.getUsername()));
    GroupDO hasGroupFlag = baseMapper.selectOne(queryWrapper);
    return hasGroupFlag == null;
}

  GroupService.java如下:

/**
 * 新增短链接分组
 * @param groupName 短链接分组名
 */
void saveGroup(String groupName);

/**
 * 新增短链接分组
 * @param username 用户名
 * @param groupName 短链接分组名
 */
void saveGroup(String username,String groupName);

运行注册用户

    "username":"test531",
    "password":"test",
    "realName":"测试",
    "phone":"15198765432",
    "mail":"114514@qq.com"

 数据库中test531的gid是 1k91Uw,名字是默认分组,token  78687be5-2914-4f82-b813-6c76582fdc77

 Ahci:8.45:比较优雅的写法 ```java public List<GroupRespDTO> listGroup() { LambdaQueryWrapper<GroupDO> queryWrapper = Wrappers.lambdaQuery(GroupDO.class) .eq(GroupDO::getUsername, UserContext.getUsername()) .orderByAsc(GroupDO::getSort) .orderByAsc(GroupDO::getUpdateTime); List<GroupDO> groups = baseMapper.selectList(queryWrapper); List<LinkCountRespDTO> gids = shortLInkRemoteService.count(groups.stream().map(GroupDO::getGid).toList()).getData(); List<GroupRespDTO> results = BeanUtil.copyToList(groups, GroupRespDTO.class); Map<String, Integer> counts = gids.stream().collect(Collectors.toMap(LinkCountRespDTO::getGid, LinkCountRespDTO::getCount)); return results.stream().peek(result -> result.setCount(counts.get(result.getGid()))).toList(); } ```

Zzz 回复 Ahci:优雅

鲸 回复 Ahci:queryWrapper 少了.eq(GroupDO::getDelFlag, 0)

Ahci 回复 鲸:这个我配置了逻辑删除,这里就不用写了。

Pandakai 回复 Ahci:感觉有bug,如果用户新增分组,但是没有新增链接数据,会得到空对象

初弦 回复 Pandakai:counts.getOrDefault(result.getGid(), 0)就可以了

热心市民a 回复 Ahci:这里用Integer::sum会不会好一点,毕竟如果冲突好像会报错 Map<String, Integer> gidCountMap = list.stream() .collect(Collectors.toMap(ShortLinkGroupCountQueryRespDTO::getGid, ShortLinkGroupCountQueryRespDTO::getShortLinkCount, Integer::sum, () -> Maps.newHashMapWithExpectedSize(list.size())));

橘喂侬糖boice 回复 Ahci:@Override public List<ShortLinkGroupRespDTO> listGroup() { LambdaQueryWrapper<GroupDO> listGroupWrapper = Wrappers.lambdaQuery(GroupDO.class) .eq(GroupDO::getDelFlag, 0) .eq(GroupDO::getUsername, UserContext.getUsername()) .orderByDesc(GroupDO::getSortOrder, GroupDO::getUpdateTime); List<GroupDO> groups = baseMapper.selectList(listGroupWrapper); Result<Map<String, ShortLinkGroupCountQueryRespDTO>> listResult = shortLinkRemoteService.groupCount(groups.stream().map(GroupDO::getGid).toList()); List<ShortLinkGroupRespDTO> shortLinkGroupResp = BeanUtil.copyToList(groups, ShortLinkGroupRespDTO.class); shortLinkGroupResp.forEach(each -> each.setShortLinkCount(listResult.getData().get(each.getGid()).getShortLinkCount())); return shortLinkGroupResp; }

橘喂侬糖boice 回复 Ahci:@Override public Map<String, ShortLinkGroupCountQueryRespDTO> groupCount(List<String> requestParam) { QueryWrapper<ShortLinkDO> shortLinkQueryWrapper = Wrappers.query(new ShortLinkDO()) .select("gid, count(*) as shortLinkCount") .in("gid", requestParam) .eq("enable_status", 0) .groupBy("gid"); List<Map<String, Object>> maps = baseMapper.selectMaps(shortLinkQueryWrapper); Map<String, ShortLinkGroupCountQueryRespDTO> result = new HashMap<>(16); maps.forEach(each -> result.put((String) each.get("gid"), BeanUtil.toBean(each, ShortLinkGroupCountQueryRespDTO.class))); requestParam.forEach(each -> result.putIfAbsent(each, ShortLinkGroupCountQueryRespDTO.builder() .shortLinkCount(0) .gid(each) .build())); return result; }

MayDay 回复 Ahci:有一说一,lambda写法可读性太差了

皮蛋瘦肉粥。 回复 MayDay:没点功底,还真的看挺久。但是这种写法方便太多了

 如果两个用户 有相同的 gid 那查询分组下短链接数量 不就有问题了吗

后面好像会基于布隆过滤器实现gid全局唯一就可以了

 操作两张表了,如何保证数据一致性?

添加事务注解就好,会在最新代码提交

这里分布式锁在事务提交前释放了不会有问题吗?

这里gid和username都在提交事务前注册了布隆过滤器,所以即使没有提交事务,也会被挡住

 fix:修复用户创建分组时用户上下文获取不到用户信息 ​