从零实现一个短链接项目
什么是短链接?
短链接就是短网址,是在长度上比较短的网址。简单来说就是帮您把冗长的URL地址缩短成8个字符以内的短网址。
当我们在腾讯、新浪发微博时,有时发很长的网址连接,但由于微博限制字数,所以微博就自动把您发的长网址给转换成短网址了。在微博 和手机短信提醒等限制字数的地方来使用短网址,的确是一个不错的方案。
使用短链接的好处:
- 缩短地址长度,留足更多空间的给 有意义的内容
URL是没有意义的,有的原始URL很长,占用有效的屏幕空间。
微博限制字数为140字一条,那么如果这个连接非常的长,以至于将近要占用我们内容的一半篇幅,这肯定是不能被允许的,链接变短,对于有长度限制的平台发文,可编辑的文字就变多了, 所以短网址应运而生了。 - 可以很好的对原始URL内容管控。
有一部分网址可以会涵盖XX,暴力,广告等信息,这样我们可以通过用户的举报,完全管理这个连接将不出现在我们的应用中,应为同样的URL通过加密算法之后,得到的地址是一样的。 - 可以很好的对原始URL进行行为分析
我们可以对一系列的网址进行流量,点击等统计,挖掘出大多数用户的关注点,这样有利于我们对项目的后续工作更好的作出决策。 - 短网址和短ID相当于间接提高了带宽的利用率、节约成本
- 链接太长在有些平台上无法自动识别为超链接
- 短链接更加简洁好看且安全,不暴露访问参数。而且,能规避关键词、域名屏蔽等手段
例如:
这是原始链接:
https://blog.csdn.net/qq_68883928/article/details/144195187?spm=1001.2014.3001.5501
这是短链接:
好处无用置疑
短链接系统的原理
短URL系统的核心: 将长的 URL 转化成短的 URL。
客户端在访问系统时,短URL的工作流程如下:
- 先使用短地址A访问 短链Java 服务
- 短链Java 服务 进行 地址转换和映射,将 短URL系统映射到对应的长地址URL
- 短链Java 服务 返回302 重定向 给客户端
- 然后客户端再重定向到原始服务
长URL如何变短?
原始URL如何变短呢?简单来说, 可以将原始的地址,使用编号进行替代
编号如何进一步变短呢? 可以使用更大的进制来表示
六十二进制表示法
顾名思义短网址就是非常短的网址,比如http://xxx.cn/ETOFYE,其中核心的部分 ETOFYE 只有6位长度。
62进制 最大竟能生成 62 ^ 6 - 1 =56800235583个 基本上够了。
即使6位长度62^6也能达到568亿的范围,
这样的话只要算法得当,可以覆盖很大的数据范围。
在编码的过程中,可以按照自己的需求来调整62进制各位代表的含义。
一个典型的场景是, 在编码的过程中,如果不想让人明确知道转换前是什么,可以进行弱加密,
比如A站点将字母c表示32、B站点将字母c表示60,就相当于密码本了。
短URL系统的功能分析
假设短地址长度为6位,62的6次方足够一般系统使用了
系统核心实现,包含三个大的功能:
- 发号
- 存储
- 映射
可以分为两个模块:发号与存储模块、映射模块
发号与存储模块
- 发号:使用发号器发号 , 为每个长地址分配一个号码ID,并且需要防止地址二义,也就是防止同一个长址多次请求得到的短址不一样
- 存储:将号码与长地址存放在DB中,将号码转化成62进制,用于表示最终的短地址,并返回给用户
映射模块
用户使用62进制的短地址请求服务 ,
- 转换:将62进制的数转化成10进制,因为咱们系统内部是long 类型的10进制的数字ID
- 映射:在DB中寻找对应的长地址
- 通过302重定向,将用户请求重定向到对应的地址上
发号器的高并发架构
回顾一下发号器的功能:
- 为每个长地址分配一个号码ID
- 并且需要防止地址歧义
以下仅对本文使用的分布式ID方案做简单介绍
snowflake算法(雪花算法)生成ID
snowflake ID 严格来说,属于 本地生产 ID,这点和 Redis ID、MongoDB ID不同, 后者属于远程生产的ID。
本地生产ID性能高,远程生产的ID性能低。
snowflake ID原理是使用Long类型(64位),按照一定的规则进行分段填充:时间(毫秒级)+集群ID+机器ID+序列号,每段占用的位数可以根据实际需要分配,其中集群ID和机器ID这两部分,在实际应用场景中要依赖外部参数配置或数据库记录。
总结一下,snowflake ID 的优缺点和使用场景:
- 优点:
高性能、低延迟、去中心化、按时间总体有序 - 缺点:
要求机器时钟同步(到秒级即可),需要解决 时钟回拨问题
如果某台机器的系统时钟回拨,有可能造成 ID 冲突,或者 ID 乱序。 - 适用场景:
分布式应用环境的数据主键
二义性检查的高并发架构
所谓的地址二义性,就行同一个长址多次请求得到的短址不一样。
在生产地址的时候,需要进行二义性检查,防止每次都会重新为该长址生成一个短址,一个个长址多次请求得到的短址是不一样。
通过二义性检查,实现长短链接真正意义上的一对一。
怎么进行 二义性检查?
最简单,最为粗暴的方案是:直接去数据库中检查。
但是,这就需要付出很大的性能代价。
要知道:
数据库主键不是 原始url,而是 短链url 。
如果根据 原始url 去进行存在性检查,还需要额外建立索引。
问题的关键是,数据库性能特低,没有办法支撑超高并发 二义性检查
所以,这里肯定不能每次用数据库去检查。
这里很多同学可能会想到另一种方案,就是 redis 的布隆过滤, 把已经生成过了的 原始url,
大致的方案是,可以把已经生成过的 原始url ,在 redis 布隆过滤器中进行记录。
布隆过滤器
布隆过滤器就是bitset+多次hash的架构,宏观上是空间换时间,不对所有的 surl (原始url)进行内容存储,只对surl进行存在性存储,这样就节省大家大量的内存空间。
在数据量比较大的情况下,既满足时间要求,又满足空间的要求。
但是布隆过滤器有个缺点:就是存在不一定存在,不存在一定不存在。
Bloom Filter 相当于是一个不太精确的 set 集合,我们可以利用它里边的 contains 方法去判断某一个对象是否存在,但是需要注意,这个判断不是特别精确。
一般来说,通过 contains 判断某个值不存在,那就一定不存在,但是判断某个值存在的话,则他可能不存在。
使用缓存架构
具体来说,可以使用 Redis 缓存进行 热门url的缓存,实现部分地址的一对一缓存。
在进行生成短链接时,对长URL和短URL进行映射,防止重复的生成请求,在使用布隆过滤器对长URL进行二次判断,从而减少mysql崩溃概率。
进行短链接跳转时,将短链接与长链接进行映射,提高系统的响应速度。
具体实现
pojos实体类
java
/**
* url实体类
*/
@Data
@TableName("url_mapping")
public class UrlMapping implements Serializable {
private Long id; // 主键
private String shortUrl; // 短链key
private String userId ;//所属用户id
private String longUrl; // 原始url
private Date expireTime; // key失效日期
private Date creatTime; // 创建日期
private Date updateTime; // 修改日期
}
controller层
java
/**
* @author
* @deprecated url转化对应接口
* @date
*/
@RestController
@RequestMapping("/api/v1/url")
public class UrlController {
@Autowired
UrlService urlIService;
/**
* 将长链接变成短链接的接口
* @param dto
* @return
*/
@PostMapping("/getUrl")
public Result getUrl(@NotNull @RequestBody UrlRequestDto dto) {
return urlIService.getUrl(dto);
}
/**
* 通过短连接访问源链接
* @param url
* @return
*/
@GetMapping("/{url}")
public Result toUrl(@PathVariable("url") String url, HttpServletResponse response){
return urlIService.toUrl(url,response);
}
}
service层
java
public interface UrlService extends IService<UrlMapping> {
Result getUrl(UrlRequestDto dto);
Result toUrl(String url, HttpServletResponse response);
}
实现类
java
@Service
@Transactional
@Slf4j
public class UrlServiceImpl extends ServiceImpl<UrlMapper, UrlMapping> implements UrlService {
@Autowired
RBloomFilter rbloomFilter;
@Autowired
UrlMapper urlMapper;
@Autowired
UserService userService;
@Autowired
RedisTemplate redisTemplate;
/**
* 根据长链接获取短链接
* @param dto
* @return
*/
@Cacheable(cacheNames = RedisConstants.LONGURL_TO_SHORTURL ,key = "#dto.url",sync = true)
public Result getUrl(UrlRequestDto dto){
//TODO:此处为本项目核心
//TODO:1、获取appkey额度是否存在和超额
//2、使用布隆过滤器判断生成的短URl是否存在
try {
if (!rbloomFilter.contains(dto.getUrl())) {
//不存在
log.info("请求参数为:"+dto);
String longUrl = urlMapper.selectByLongUrl(dto.getUrl());
if(StringUtils.isNotBlank(longUrl)){
return Result.success(longUrl);
}
String response = NoExistUrl(dto);
if(response.equals("-1")){
return Result.error(ResponseStatusMessage.User_IS_NOT_LOGIN);
}
return Result.success(response);
} else {
//存在
//数据库兜底
String shortUrl = urlMapper.selectByLongUrl(dto.getUrl());
if (StringUtils.isEmpty(shortUrl)) {
String response = NoExistUrl(dto);
if(response.equals("-1")){
return Result.error(ResponseStatusMessage.User_IS_NOT_LOGIN);
}
return Result.success(response);
}
return Result.success(shortUrl);
}
} catch (Exception e) {
log.error("长链接生成短连接失败");
throw new RuntimeException(e);
}
}
/**
* 通过短连接跳转链接
* @param url
* @param response
* @return
*/
public Result toUrl(String url, HttpServletResponse response) {
if (StringUtils.isBlank(url)) {
return Result.error(ResponseStatusMessage.Url_IS_NOT_EMPTY);
}
String longurl = (String)redisTemplate.opsForValue().get(RedisConstants.SHORTURL_TO_LONGRUL + url);
try {
if(StringUtils.isNotBlank(longurl)){
userService.ShortUrlCount(url);
response.sendRedirect(longurl);
return Result.success();
}
longurl = urlMapper.selectByShortUrl(url);
redisTemplate.opsForValue().set(RedisConstants.SHORTURL_TO_LONGRUL + url,longurl);
if (StringUtils.isBlank(longurl)) {
log.info("通过短连接跳转链接中,查询数据库异常:无该短连接对应的长链接:"+longurl);
return Result.error(ResponseStatusMessage.SHORTURL_TO_LONGURL_ENPTY);
}
userService.ShortUrlCount(url);
response.sendRedirect(longurl);
return Result.success();
} catch (Exception e) {
return Result.error(ResponseStatusMessage.UNKNOW_ANOMALY);
}
}
@NotNull
private String NoExistUrl(UrlRequestDto dto) {
UserAppKey user = DbContextHolder.getUser();
if(ObjectUtils.isEmpty(user)){
return new StringBuffer().append("-1").toString();
}
String shortUrl = UrlToShortHash(dto.getUrl());
rbloomFilter.add(shortUrl);
UrlMapping urlMapping = new UrlMapping();
urlMapping.setLongUrl(dto.getUrl());
urlMapping.setUserId(user.getUserId());
urlMapping.setShortUrl(shortUrl);
urlMapping.setCreatTime(Calendar.getInstance().getTime());
urlMapper.insert(urlMapping);
return shortUrl;
}
}
最少6位的短码
java
/**
* url变短的hash生成短url
* @return
*/
private static final String ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final int BASE = 62;
public static final String UrlToShortHash(String url){
// 生成 32 位的 Murmur3 哈希值
long hash = Hashing.murmur3_32().hashUnencodedChars(url).padToLong();
// 将 32 位哈希值转换为 62 进制字符串
StringBuilder sb = new StringBuilder();
while (hash > 0) {
sb.append(ALPHABET.charAt((int) (hash % BASE)));
hash /= BASE;
}
// 确保生成的短 URL 至少有 6 个字符
while (sb.length() < 6) {
sb.append(ALPHABET.charAt(0));
}
// 反转字符串,使其更美观
return sb.reverse().toString();
}
布隆过滤器
java
/**
* 短链接布隆过滤器,用于减少数据库的压力
*/
@Component
@Slf4j
public class UrlBloomFilter {
@Autowired
private RedissonClient redissonClient;
/**
* 编码
*/
@Value("${Bloom.charset}")
private String CharSet;
/**
* 最大处理的数量
*/
@Value("${Bloom.maxnums}")
private int MaxNums;
/**
* 需要控制的错误概率
*/
@Value("${Bloom.errpre}")
private Double ErrPre;
/**
* 创建布隆过滤器
* @return
*/
@Bean("RBloomFilter")
@Primary
public RBloomFilter InitR( ) {
RBloomFilter bloomFilter = redissonClient.getBloomFilter("UrlBloomFilter");
bloomFilter.tryInit(MaxNums, ErrPre);
return bloomFilter;
}
}
缓存实现
java
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Value("${spring.redis.password}")
private String password;
@Bean
public RedissonClient InitRedisson(){
Config config = new Config();
if (StringUtils.isNotBlank(password)) {
config.useSingleServer().setAddress("redis://" + host + ":" + port).setPassword(password);
} else {
config.useSingleServer().setAddress("redis://" + host + ":" + port);
}
//添加主从配置
// config.useMasterSlaveServers().setMasterAddress("").setPassword("").addSlaveAddress(new String[]{"",""});
return Redisson.create(config);
}
}
配置文件
yaml
Bloom:
charset: utf-8
maxnums: 100000
errpre: 0.000001
sping:
datasource:
druid:
type: com.alibaba.druid.pool.DruidDataSource
cs1-db:
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/short-chain?useUnicode=true&characterEncoding=UTF-8
username: root
password:
initialSize: 10
minIdle: 20
maxActive: 100
maxWait: 60000
redis:
host: 127.0.0.1
port: 6379
password:
cache:
type: redis # 指定使用的缓存类型
启动类上添加注解
java
@SpringBootApplication
@EnableCaching
public class application {
public static void main(String[] args) {
SpringApplication.run(application.class, args);
System.out.println(" _ \n" +
" _ooOoo_ \n" +
" o8888888o \n" +
" 88\" . \"88 \n" +
" (| -_- |) \n" +
" O\\ = /O \n" +
" ____/`---'\\____ \n" +
" .' \\\\| |// `. \n" +
" / \\\\||| : |||// \\ \n" +
" / _||||| -:- |||||_ \\ \n" +
" | | \\\\\\ - /'| | | \n" +
" | \\_| `\\`---'// |_/ | \n" +
" \\ .-\\__ `-. -'__/-. / \n" +
" ___`. .' /--.--\\ `. .'___ \n" +
" .\"\" '< `.___\\_<|>_/___.' _> \\\"\". \n" +
" | | : `- \\`. ;`. _/; .'/ / .' ; | Buddha \n" +
" \\ \\ `-. \\_\\_`. _.'_/_/ -' _.' / \n" +
" ================-.`___`-.__\\ \\___ /__.-'_.'_.-'================ \n" +
" `=--=-' 佛祖保佑无bug---王五周八 ");
}
}
依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<!-- 布隆过滤器的依赖-->
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.0.1-jre</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.9</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.22</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
<scope>runtime</scope>
</dependency>
<!-- 其他依赖项 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.0</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.4</version>
</dependency>
结尾
由上述可以实现一个基于布隆过滤器和redis实现的一个简易的短链接系统,这只实现了短链接的核心功能,有兴趣的小伙伴还可以基于以上进行扩展,从而达到商用的目的。