短链接平台搭建详细分析及代码实现

本文最后更新于:2021年8月12日 中午

作者:Mintimate

博客:https://www.mintimate.cn

Mintimate’s Blog,只为与你分享

短链接

什么是短链接

短链接,又称缩略网址服务、缩址、短址、短网址、缩略网址、网址缩短、缩短网址、URL缩短等,指的是一种互联网上的技术与服务。此服务可以提供短URL以代替原来可能较长的URL,将长的URL地址缩短。

用户访问缩短后的URL时,通常将会重定向到原来的URL。

为什么用短链接

使用短链接,主要的场景有:

  • Twitter、微博等平台,消息字数限制,使用短链接对原有链接缩短。
  • 隐藏Get、PATH参数。
  • ……

实例演示

有些小伙伴可能还是没有概念,这里举个腾讯云自带的短链接。比如腾讯云服务器限时秒杀活动的链接是:

1
https://cloud.tencent.com/act/cps/redirect?redirect=1077&cps_key=&from=console

而腾讯云给的短链接:

1
https://curl.qcloud.com/XnaFxKqr

可以看到,链接有效地缩短了。同时,已经看不到PATHGet参数。用户访问短链接,会自动301/302跳转到原链接:
腾讯重定向

实现思路

其实实现的思路很简单,我们生成一个短链接,大概的思路是传入原链接,在后台进行处理后,得到一个唯一识别码,一同存入数据库,最后再把这个唯一识别码回显给用户
生成短链接
得到短链接后,用户发给其他用户进行访问时,后台根据这个识别码,再进行数据库查询,最后重定向到原链接即可
解析短链接

所以,其实实现很简单,要点:

  • 生成唯一识别码,对应链接,且识别码要短。
  • 后台301/302重定向跳转。

使用Java作为后台API服务,处理上面两点很简单:

  • 雪花ID转换为六十二进制,得到短的识别码。
  • 使用RedirectView设置响应头,并重定向链接。

本文以Java(Springboot)为例,其他编程语言可以按图索骥。

唯一识别码

每次后台接收前台的响应,则生成一个识别码存储到数据库,已备后续调取重定向。

这个识别码最好与时间戳有关,同时,如果有多个服务器同时组网,这个识别码最好还要加上机械识别码。

综上,我们可以使用雪花ID,但是雪花ID最为一个Long类型,转换为int类型有19位,肯定是太长了,所以,我们还需要转码为六十二进制。

雪花ID

雪花算法(Snowflake)是一种生成分布式全局唯一ID的算法,生成的ID称为Snowflake IDs或snowflakes。这种算法由Twitter创建,并用于推文的ID。Discord和Instagram等其他公司采用了修改后的版本。
一个雪花ID:

  • 前41位是时间戳
  • 之后10位代表计算机ID
  • 其余12位代表每台机器上生成ID的序列号

雪花ID组成

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/**
* Twitter的SnowFlake算法,使用SnowFlake算法生成一个整数,然后转化为62进制变成一个短地址URL
*
* https://github.com/beyondfengyu/SnowFlake
*/
public class SnowFlakeShortUrl {

/**
* 起始的时间戳
*/
private final static long START_TIMESTAMP = 1480166465631L;

/**
* 每一部分占用的位数
*/
private final static long SEQUENCE_BIT = 12; //序列号占用的位数
private final static long MACHINE_BIT = 5; //机器标识占用的位数
private final static long DATA_CENTER_BIT = 5; //数据中心占用的位数

/**
* 每一部分的最大值
*/
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
private final static long MAX_DATA_CENTER_NUM = -1L ^ (-1L << DATA_CENTER_BIT);

/**
* 每一部分向左的位移
*/
private final static long MACHINE_LEFT = SEQUENCE_BIT;
private final static long DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
private final static long TIMESTAMP_LEFT = DATA_CENTER_LEFT + DATA_CENTER_BIT;

private long dataCenterId; //数据中心
private long machineId; //机器标识
private long sequence = 0L; //序列号
private long lastTimeStamp = -1L; //上一次时间戳

private long getNextMill() {
long mill = getNewTimeStamp();
while (mill <= lastTimeStamp) {
mill = getNewTimeStamp();
}
return mill;
}

private long getNewTimeStamp() {
return System.currentTimeMillis();
}

/**
* 根据指定的数据中心ID和机器标志ID生成指定的序列号
*
* @param dataCenterId 数据中心ID
* @param machineId 机器标志ID
*/
public SnowFlakeShortUrl(long dataCenterId, long machineId) {
if (dataCenterId > MAX_DATA_CENTER_NUM || dataCenterId < 0) {
throw new IllegalArgumentException("DtaCenterId can't be greater than MAX_DATA_CENTER_NUM or less than 0!");
}
if (machineId > MAX_MACHINE_NUM || machineId < 0) {
throw new IllegalArgumentException("MachineId can't be greater than MAX_MACHINE_NUM or less than 0!");
}
this.dataCenterId = dataCenterId;
this.machineId = machineId;
}

/**
* 产生下一个ID
*
* @return
*/
public synchronized long nextId() {
long currTimeStamp = getNewTimeStamp();
if (currTimeStamp < lastTimeStamp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}

if (currTimeStamp == lastTimeStamp) {
//相同毫秒内,序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE;
//同一毫秒的序列数已经达到最大
if (sequence == 0L) {
currTimeStamp = getNextMill();
}
} else {
//不同毫秒内,序列号置为0
sequence = 0L;
}

lastTimeStamp = currTimeStamp;

return (currTimeStamp - START_TIMESTAMP) << TIMESTAMP_LEFT //时间戳部分
| dataCenterId << DATA_CENTER_LEFT //数据中心部分
| machineId << MACHINE_LEFT //机器标识部分
| sequence; //序列号部分
}

public static void main(String[] args) {
SnowFlakeShortUrl snowFlake = new SnowFlakeShortUrl(2, 3);

for (int i = 0; i < (1 << 4); i++) {
//10进制
System.out.println(snowFlake.nextId());
}
}
}

当然,如果你用使用Mybatis Plus,可以引用Mybatis Plus的IdWorker.getId方法,生成雪花ID。生成后的Long类型,我们使用十进制展开,应该是一个17-19位的数字。

六十二进制

因为雪花ID通过十进制展开是一个17-19位的数字,如果直接用来当作短链接,太长了点,我们需要对其缩短。

为了保证唯一,且可对照。我们转换为六十二进制。原因很简单:六十二进制使用A-Z、a-z和0-9组成。
把十进制,转换为六十二进制,能有效减短长度。
根据Wiki-Base62,六十二进制中0-61对应0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz。所以,我们编写编码和解码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* 初始化 62 进制数据,索引位置代表转换字符的数值 0-61,比如 A代表1,z代表61
*/
private static String CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

/**
* 进制转换比率
*/
private static int SCALE = 62;

/**
* 匹配字符串只包含数字和大小写字母
*/
private static String REGEX = "^[0-9a-zA-Z]+$";

/**
* 十进制数字转为62进制字符串
*
* @param val 十进制数字
* @return 62进制字符串
*/
public static String encode10To62(long val)
{
if (val < 0)
{
throw new IllegalArgumentException("this is an Invalid parameter:" + val);
}
StringBuilder sb = new StringBuilder();
int remainder;
while (Math.abs(val) > SCALE - 1)
{
//从最后一位开始进制转换,取转换后的值,最后反转字符串
remainder = Long.valueOf(val % SCALE).intValue();
sb.append(CHARS.charAt(remainder));
val = val / SCALE;
}
//获取最高位
sb.append(CHARS.charAt(Long.valueOf(val).intValue()));
return sb.reverse().toString();
}

/**
* 十进制数字转为62进制字符串
*
* @param val 62进制字符串
* @return 十进制数字
*/
public static long decode62To10(String val)
{
if (val == null)
{
throw new NumberFormatException("null");
}
if (!val.matches(REGEX))
{
throw new IllegalArgumentException("this is an Invalid parameter:" + val);
}
String tmp = val.replace("^0*", "");

long result = 0;
int index = 0;
int length = tmp.length();
for (int i = 0; i < length; i++)
{
index = CHARS.indexOf(tmp.charAt(i));
result += (long)(index * Math.pow(SCALE, length - i - 1));
}
return result;
}

再测试一下:

1
2
3
4
5
6
7
8
9
//Test
public static void main(String[] args) {
Long snow = IdWorker.getId();
System.out.println(snow);
String str = encode10To62(snow);
System.out.println(str);
Long g = decode62To10(str);
System.out.println(g);
}

输出:

1
2
3
1425664925648310274
1hJYkVByV0M
1425664925648310274

响应头

重定向链接,响应头很重要。Nginx内可以使用配置直接跳转301/302,比如强制HTTPS:

1
2
3
if ($server_port !~ 443){
rewrite ^(/.*)$ https://$host$1 permanent;
}

而我们搭建短链接平台,也利用301或者302进行重定向:
雪花ID组成

301/302

301和302都是重定向,那它们的区别是什么呢?

  • 301:永久重定向,在请求的URL已被移除时使用,响应的location首部中应包含资源现在所处的URL
  • 302:临时重定向,和永久重定向类似,客户端应用location给出URL临时定位资源,将来的请求仍为原来的URL。

实际场景里,301在跳转后,浏览器会记住这个跳转,后续请求,不再请求原地址,而是直接请求新地址;所以301一般用于网站域名的迁移,强制网站https等,而302一般是网站维护,需要临时跳转到非维护页面等情况。

那我们搭建短链接平台,需要什么重定向呢?我认为是都可以。使用301重定向,可以减少服务器负载,而使用302重定向,可以方便我们统计链接实际调取次数。

Java内,进行301/302的跳转,其实很简单,使用类RedirectView,其中的HttpStatus即可:

1
2
3
4
5
6
# RedirectView类
RedirectView redirectView = new RedirectView(fullURL);
# 301跳转
redirectView.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
# 302跳转
redirectView.setStatusCode(HttpStatus.MOVED_TEMPORARILY);

实际上,看HttpStatus的源码,可以看到这里枚举了很多HTTP的响应头:
HttpStatus枚举

Maven部署(代码实现)

最后,我们看看实际部署和代码实现。只是随便提供思路,代码可能有逻辑不严谨地方嗷。

本次使用MariaDB作为数据库,使用Mybatis Plus对数据库进行操作,Springboot提供框架并方便打包。

依赖包

首先,我们创建一个工程,其中Lombok是为了方便实体类生成Set/Get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<dependencies>
<!-- Springboot-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MariaDB驱动-->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MybatisPlus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<!-- lombok插件-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

实体类

我们看看短链接实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@NoArgsConstructor
public class ShortUrl {
@TableId
private Long id;
private String baseUrl;
private String suffixUrl;
private String fullUrl;
private String shortCode;
@TableField(fill = FieldFill.INSERT)
private Integer totalClickCount;
@TableField(fill = FieldFill.INSERT)
private Date expirationDate;

public ShortUrl(String baseUrl, String suffixUrl, String fullUrl) {
this.baseUrl = baseUrl;
this.suffixUrl = suffixUrl;
this.fullUrl = fullUrl;
}
}

其中:

  • baseUrl:用户提供的原链接域名,如:tool.mintimate.cn
  • suffixUrl:用户提供链接的参数,如:/user?login=yes
  • fullUrl:用户提供的原链接,如:https://tool.mintimate.cn/curl
  • shortCode:生成的短链接。
  • totalClickCount:统计点击次数(Hander自动设置默认值)
  • expirationDate:失效时间(Hander自动设置默认值)

短链接处理

首先,做一个控制器,用来接收用户请求:

1
2
3
4
5
6
7
8
9
10
// 接收原链接,返回短链接
@ResponseBody
@PostMapping(value = "/add")
public ShortUrl encodeURL(@RequestParam(value = "UserURL") String UserURL){
String Domain = DomainUntil.getDomain(UserURL);
if (Domain==null){
return null;
}
return shortUrlService.saveURL(UserURL);
}

之后,看看业务层,我们需要对域名进行加工,先得到一个雪花ID,再对其转至六十二进制,并回显:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Resource
ShortUrlMapper shortUrlMapper;
@Override
public ShortUrl saveURL(String UserURL) {
// 创建新对象
ShortUrl shortUrl=new ShortUrl(DomainUntil.getTopDomain(UserURL),DomainUntil.getFile(UserURL),UserURL);
// 使用Mybatis Plus,提前拿到对象的雪花ID
Long ID= IdWorker.getId(shortUrl);
// 转六十二进制
String Short_URL_CODE = DecimalAndSixtyBinary.encode10To62(ID);
shortUrl.setShortCode(Short_URL_CODE);
int code=shortUrlMapper.insert(shortUrl);
return shortUrl;
}

这个时候,我们使用Postman来测试一下:
测试成功
可以看到,测试成功。

短链接重定向

短链接重定向,就很简单了。我们写一个请求即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ResponseBody
@RequestMapping(value = "/{UrlID}")
public RedirectView reverseURL(@PathVariable(value = "UrlID") String UrlID) {
// 接收请求参数为String
String fullURL=shortUrlService.findURL(UrlID);
if (fullURL==null){
// 定义的404页面
RedirectView redirectView = new RedirectView("/error/404");
redirectView.setStatusCode(HttpStatus.NOT_FOUND);
return redirectView;
}
else {
RedirectView redirectView = new RedirectView(fullURL);
redirectView.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
return redirectView;
}
}

其中,shortUrlServicefindURL就是简单的JDBC查询,不具体实现。

Demo

我根据上述思路,初略搭建一个Demo,并部署在了腾讯云轻量应用服务器(不得不说,个人开发者,使用轻量应用服务器实在是太方便了,而且性价比极高):

  • 前端:基于Vue,使用element ui和Bootstrap
  • 后端:Springboot

在线演示
我们可以在Linux/macOS上使用curl测试一下,比如直接用腾讯云轻量应用服务器的Linux远程终端:

1
curl -I "https://curl.mintimate.ml/1Hjsg8wDe8i"

在线演示

完善思路

可以看到,文章实现的有些粗糙,提供以下完善思路:

  • 限制单IP一段时间的请求频率:目前我是使用前端Vue进行控制,但是最好后端也进行控制。
  • 数据库优化:目前使用的是MariaDB,如果要更好的体验,或者响应数据量大,使用Redis会更好。
  • Cron定时任务:使用雪花ID转六十二进制,在链接长度上,还是有点长,但是安全性应该是很高的;如果降低安全性,并进一步缩短长度,可以创建Cron定时线程,无效旧的短链接。

若对文章很感兴趣,可以B站关注我ヾ(≧▽≦*)o

点击图片跳转“关于”页面(○` 3′○)