1、什么是短 URL?
短网址(Short URL),顾名思义就是比较短的 URL 网络地址, 在如今 Web 2.0 的时代,短网址十分得流行,在业界已经有许多短网址生成的服务,使我们可以用各位简短的网址来替代原来十分冗长的网址。让分享的网页链接不会因为太长而引起用户反感,影响体验,使使用者更容易分享哈。
事实上,短网址,也就是短链接在我们生活中随处可见,如微博分享、外卖订单信息、或者如上面的快递短信,短信中就含有一条短网址 http://tb.cn/vvDezXw 。
2、短网址的意义
- 短信和许多平台(微博)有字数限制:用户每次能接收和发送短信的字符数,是160个英文或数字字符,或者70个中文字符。
- 简介美观(用户友好)。节省网址长度,便于社交化传播。短链接URL更短小,传播更方便。尤其是URL中有中文和特殊字符时,短网址解决了长链接URL难以记忆、不利于传播的问题。
- 统计需要(网页流量统计、点击统计等)
- 安全。 不暴露访问参数。规避关键词、域名屏蔽手段、隐藏真实地址,适合做付费推广链接。
3、使用短链接的一些典型场景
3.1 字数限制场景
1) 微博内容
我们在新浪微博上发布的内容包含长链接网址的时候,微博服务会自动判别出长链接网址,并将其转换为短链接网址。
这是因为新浪微博限制字数为140字一条。如果我们需要发布的内容包含一些链接,但是这些链接非常长,以至于要占用我们内容的一半篇幅、甚至更多,这肯定是不能被允许的,或者说用户体验很差的。此时,就需要将内容里的长链接地址替换为短链接地址。
2)用户短信
一般短信发文有长度限度。如果使用长链接地址,那么一条短信很可能要拆分成两三条发,本来一条一毛的短信费变成了两三毛,直接提升了几倍的花费。另外,使用短链接在内容排版上也更为美观。
3.2 短链接二维码
二维码核心解决的是跨平台、跨现实的数据传输问题,我们经常需要将链接转成二维码的形式分享给他人。使用长链接网址生成的二维码,码点密集复杂,难以识别。使用短链接生成的二维码,码点稀疏清晰,就不存在这个问题了。
3.3 无法识别长链接场景
在有些平台上,长链接地址无法自动识别为完整的超链接,只能识别一部分url地址,甚至根本无法识别。譬如,在钉钉、企微上,长链接地址通常只能被识别一部分,而不是完整的链接地址。
4、短网址的原理
4.1 短网址是如何生成的呢?
短网址服务是如何将那么多的长网址对应到相应的短网址呢?
短网址通常结构如下:域名/短网址id。
短网址 id 其通常由 26 个大写字母 + 26 小写字母 +10 个数字 即 62 种字符组成,随机生成 6 到 7 个,然后组成对应一个 短网址 id,并存入相应的数据存储服务中。
当短网址被访问的时候,短网址的解析服务,会根据 id 查询到对应页面从而实现相应的跳转。
原理:打开短链接的时候,通过 302 的方式,即临时重定向的方式进行跳转
为什么要用62进制转换?
- 62进制转换是因为62进制转换后只含数字+小写+大写字母。而64进制转换会含有/,+这样的符号(不符合正常URL的字符)
- 10进制转62进制可以缩短字符,如果我们要6位字符的话,已经有560亿个组合了。
示例:转换如下的url为对应的短链接
http://localhost:8080/rabbitmq/delayMsg?msg=sendLongMsg&delayTime=10000
地址对应的主键id:假设为100000
通过十进制转换为62进制:q0U
转换地址:https://www.iamwawa.cn/jinzhi.html
ID自增后,转成62进制,在DB保存映射关系,生成短链接。
http://localhost:8080/q0U,通过q0U 找到对应的长链接地址,重定向跳转。
4.2 如何保证短网址 id 不重复
事实上,假如短网址 id 为 6 位,那就是共有 2^62 个短网址。超过这个数目的网页可能性并不大。但在生成即发放短网址的时候,需要保证能够发送不重复的短网址 id。
为了保证不冲突和重复,大多数短网址服务都会采用自增的方式来分发 id,如第一个使用这个服务的人得到的短地址是 http://xxx/0 ,第11个是 http://xxx/a 等依次生成。
对于大多数小型的短网址服务,直接使用 mysql 的自增索引就可以保证不冲突,但这种方式不太适合大型的应用。因为每次操作都需要涉及数据库的增删的资源损耗。因此对于一些大型应用,我们可以通过一些分布式 key-value 系统做短网址的分发。同时不停的自增就可以来。
4.3 如何分布式生成不重复的短网址?
如果生成短网址的服务是分布式的(用户量很大,只有一台生成一台不够用,如天猫、新浪微博),那么每个服务节点要保持同步自增,而不起冲突。是怎么做的呢?
事实上我们可以这样做。加入我们要实现有 5 台分布的短网址服务,此时我们让:
服务 1,从 1 开始发放,然后每次自增 5 即 1、6、11、16…
服务 2,从 2 开始发放,然后每次自增 5 即 2、7、12、17…
服务 3,从 3 开始发放,然后每次自增 5 即 3、8、13、18…
服务 4,从 4 开始发放,然后每次自增 5 即 4、9、14、19…
服务 5,从 5 开始发放,然后每次自增 5 即 5、10、15、20…
这样每个分布的服务都能够独立工作,从而互不干扰。从而实现分布式发放。
5、短链接服务实现
5.1 实现步骤
- 将长链接通过一定的手段生成一个短链接
- 生成短路径PATH:利用放号器,初始值为0,对于每一个短链接生成请求,都递增放号器的值,再将此值转换为62进制(a-zA-Z0-9),这个62进制值即为短URI。比如第一次请求时放号器的值为0,对应62进制为a,第二次请求时放号器的值为1,对应62进制为b,第10001次请求时放号器的值为10000,对应62进制为sBc。
- 短链接服务域名与短URL PATH拼接:将短链接服务器域名与短路径PATH进行字符串连接,即为短链接的URL,比如:t.cn/sBc。
- 保存短链接与长链接的关系到数据库
- 访问短链接时实际访问的是短链接服务器,然后根据短链接的参数找回对应的长链接
- 短链接跳转
- 短链接服务器返回302状态码,将响应头中的Location设置为长链接
- 浏览器访问长链接
- 业务服务器响应
5.2 数据库设计
CREATE TABLE `short_url` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键ID',
`short_url_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '短链接编码',
`long_url` varchar(256) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '长链接url',
`long_url_md5` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '长链接url md5值',
`delete_flag` tinyint(2) NOT NULL DEFAULT '0' COMMENT '删除标记,0未删除 1已删除',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT NULL COMMENT '更新时间',
`create_user_code` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '创建人',
`update_user_code` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '更新人',
`version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号',
`remark` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '备注',
`click_count` bigint(20) NOT NULL DEFAULT '0' COMMENT '链接点击数',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
5.3 进制转换工具和主键id的生成
进制转换工具:
package com.sflx.shortmessage.util;
/**
* 进制转换工具,最大支持十进制和62进制的转换
* 1、将十进制的数字转换为指定进制的字符串
* 2、将其它进制的数字(字符串形式)转换为十进制的数字
*/
public class NumericConvertUtils {
/**
* 在进制表示中的字符集合,0-Z分别用于表示最大为62进制的符号表示
*/
private static final char[] digits = {
'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',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'};
/**
* 将十进制的数字转换为指定进制的字符串
* @param number 十进制的数字
* @param seed 指定的进制
* @return 指定进制的字符串
*/
public static String toOtherNumberSystem(long number, int seed) {
if (number < 0) {
number = ((long) 2 * 0x7fffffff) + number + 2;
}
char[] buf = new char[32];
int charPos = 32;
while ((number / seed) > 0) {
buf[--charPos] = digits[(int) (number % seed)];
number /= seed;
}
buf[--charPos] = digits[(int) (number % seed)];
return new String(buf, charPos, (32 - charPos));
}
/**
* 将其它进制的数字(字符串形式)转换为十进制的数字
* @param number 其它进制的数字(字符串形式)
* @param seed 指定的进制,也就是参数str的原始进制
* @return 十进制的数字
*/
public static long toDecimalNumber(String number, int seed) {
char[] charBuf = number.toCharArray();
if (seed == 10) {
return Long.parseLong(number);
}
long result = 0, base = 1;
for (int i = charBuf.length - 1; i >= 0; i--) {
int index = 0;
for (int j = 0, length = digits.length; j < length; j++) {
//找到对应字符的下标,对应的下标才是具体的数值
if (digits[j] == charBuf[i]) {
index = j;
}
}
result += index * base;
base *= seed;
}
return result;
}
public static void main(String[] args) {
/**
* 10进制:100000 62进制:Aa4
* 62进制:Aa4 10进制:100000
*
* 10进制:100001 62进制:Aa5
* 62进制:Aa5 10进制:100001
*
* 10进制:100002 62进制:Aa6
* 62进制:Aa6 10进制:100002
*
* 10进制:100003 62进制:Aa7
* 62进制:Aa7 10进制:100003
*/
for (int i = 100000; i <100010 ; i++) {
//10进制
//62进制
String convertedNumStr = NumericConvertUtils.toOtherNumberSystem(i, 62);
//10进制转化为62进制
System.out.println("10进制:" + i + " 62进制:" + convertedNumStr);
//TODO 执行具体的存储操作,可以存放在Redis等中
//62进制转化为10进制
System.out.println("62进制:" + convertedNumStr + " 10进制:" + NumericConvertUtils.toDecimalNumber(convertedNumStr, 62));
System.out.println();
}
}
}
雪花算法生成id:
package com.sflx.shortmessage.util;
import org.springframework.stereotype.Component;
/**
* 使用SnowFlake算法生成一个整数,然后转化为62进制,变成一个短地址URL的PATH
*/
public class SnowFlake {
/**
* 起始的时间戳
*/
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; //上一次时间戳
/**
* 根据指定的数据中心ID和机器标志ID生成指定的序列号
* @param dataCenterId 数据中心ID
* @param machineId 机器标志ID
*/
public SnowFlake(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; //序列号部分
}
private long getNextMill() {
long mill = getNewTimeStamp();
while (mill <= lastTimeStamp) {
mill = getNewTimeStamp();
}
return mill;
}
private long getNewTimeStamp() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
/**
* 10进制:771450362589884416 62进制:49pIpAvQfk
* 62进制:49pIpAvQfk 10进制:771450362589884416
*
* 10进制:771450362594078720 62进制:49pIpANrno
* 62进制:49pIpANrno 10进制:771450362594078720
*
* 10进制:771450362594078721 62进制:49pIpANrnp
* 62进制:49pIpANrnp 10进制:771450362594078721
*
* 10进制:771450362594078722 62进制:49pIpANrnq
* 62进制:49pIpANrnq 10进制:771450362594078722
*/
SnowFlake snowFlake = new SnowFlake(2, 3);
for (int i = 0; i < (1 << 4); i++) {
//10进制
Long id = snowFlake.nextId();
//62进制
String convertedNumStr = NumericConvertUtils.toOtherNumberSystem(id, 62);
//10进制转化为62进制
System.out.println("10进制:" + id + " 62进制:" + convertedNumStr);
//TODO 执行具体的存储操作,可以存放在Redis等中
//62进制转化为10进制
System.out.println("62进制:" + convertedNumStr + " 10进制:" + NumericConvertUtils.toDecimalNumber(convertedNumStr, 62));
System.out.println();
}
}
}
配置:
@Bean
public SnowFlake snowFlake() {
return new SnowFlake(1, 1);
}
5.4 短链接生成
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sflx.shortmessage.entity.ShortUrl;
import com.sflx.shortmessage.service.ShortUrlService;
import com.sflx.shortmessage.util.NumericConvertUtils;
import com.sflx.shortmessage.util.SnowFlake;
import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.security.MD5Encoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.util.List;
import java.util.UUID;
@RestController
@Slf4j
public class ShortUrlController {
@Resource
private ShortUrlService shortUrlService;
@Resource
private SnowFlake snowFlake;
/**
* 根据ID获取
*
* @param id
* @return
*/
@GetMapping("/shortUrl/getById")
public ShortUrl getById(@RequestParam("id") Long id) {
ShortUrl shortUrl = shortUrlService.getById(id);
return shortUrl;
}
/**
* 模拟的长链接地址
*
* @param shortUrl
* @return
*/
@GetMapping("/shortUrl/list")
public List<ShortUrl> list(ShortUrl shortUrl) {
List<ShortUrl> list = shortUrlService.list(new LambdaQueryWrapper<ShortUrl>()
.eq(ShortUrl::getId, shortUrl.getId()));
return list;
}
private static final String url = "localhost:8081/a/";
@PostMapping("/shortUrl/save")
public String save() {
ShortUrl shortUrl = new ShortUrl();
//生成对应的主键
long id = snowFlake.nextId();
//转换为对应的短链接地址 10进制转换为62进制
String code = NumericConvertUtils.toOtherNumberSystem(id, 62);
shortUrl.setId(id);
shortUrl.setShortUrlCode(code);
String longUrl = generateLongUrl();
shortUrl.setLongUrl(longUrl);
MessageDigest md5 = null;
try {
md5 = MessageDigest.getInstance("md5");
byte[] digest = md5.digest(longUrl.getBytes("utf-8"));
//16是表示转换为16进制数
String md5Str = new BigInteger(1, digest).toString(16);
shortUrl.setLongUrlMd5(md5Str);
} catch (Exception e) {
log.error("md5加密异常:{}", e.getMessage());
}
shortUrlService.save(shortUrl);
//发送短信:包含短链接
String linkUrl = url + code;
log.info("短信短链接:{}", linkUrl);
return linkUrl;
}
/**
* http://localhost:8081/shortUrl/list?id=1&shortUrlCode=1&longUrlMd5=xxxx&remark=111322&createUserCode=xxx
*
* @return
*/
private static final String listUrl = "http://localhost:8081/shortUrl/list?id=1&";
/**
* 随机生成对应的长链接地址
*
* @return
*/
public String generateLongUrl() {
StringBuilder builder = new StringBuilder(listUrl);
String uuid = UUID.randomUUID().toString().substring(16);
builder.append("&shortUrlCode=" + uuid);
builder.append("&longUrlMd5=" + uuid);
builder.append("&remark=" + uuid);
builder.append("&createUserCode=" + uuid);
return builder.toString();
}
}
5.5 短链接访问
@Controller
@Slf4j
public class ShortUrlRedirectController {
@Resource
private ShortUrlService shortUrlService;
/**
* 访问短链接重定向到长链接
* 接口需要开通白名单
*
* @param shortUrlCode
* @return
*/
@GetMapping("/a/{shortUrlCode}")
public void redirectLongURL(@PathVariable("shortUrlCode") String shortUrlCode, HttpServletResponse response) {
ShortUrl shortUrl = shortUrlService.getOne(new LambdaQueryWrapper<ShortUrl>()
.eq(ShortUrl::getShortUrlCode, shortUrlCode)
.last("limit 1"));
if (shortUrl == null) {
throw new RuntimeException("链接不存在");
}
// 增加短链点击次数
shortUrl.setClickCount(shortUrl.getClickCount() + 1);
shortUrlService.updateById(shortUrl);
try {
//重定向到长链接
response.sendRedirect(shortUrl.getLongUrl());
} catch (IOException e) {
log.error("重定向异常:{}", e.getMessage());
}
}
}
5.6 验证测试
save方法:com.sflx.shortmessage.controller.ShortUrlController#save
http://localhost:8081/shortUrl/save
返回:localhost:8081/a/49CZd5BPvU
访问:http://localhost:8081/a/49CZd5BPvU,重定向到长链接地址
6、短链接服务提供平台
目前,国内有很多提供短链接服务的平台,例如:
- 新浪:http://sina.lt/
- 一定时间内,同样的网址生成的短网址都是一样的。且支持短网址后缀选择。
- 百度:http://dwz.cn/
- 0x3:http://0x3.me/
- MRW:http://mrw.so/
需要注意的是,如果使用某一个平台的短地址服务,一定要保证长期可靠的服务,不然一段时间失效了,我们以前转换得到的短链接地址就没法访问了!
附代码地址:
https://gitee.com/zengqingfa/springboot-examples/tree/master/business-biz-demo/short-message-demo