短信短链接项目中的实践

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

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值