来整一个自己的短链接服务吧!

5,756 阅读8分钟

什么是短链接?

所谓短链接,就是把普通网址转换成一个比较短的网址,而访问得到的内容不变。

比如说对于一个这样的链接 juejin.cn/post/684490… ,使用短链接服务的话就可以将它转换成类似这种 http://xxx/abc (由于某不可描述的原因与某不可抗力的影响 这个URL其实是我编的)。是不是感觉简洁了许多 (。-`ω´-)

短链接是如何实现的?

基本原理

简单来说,当我们输入 http://xxx/abc 后,会经过如下过程 :

  1. DNS 首先解析获得 http://xxx 的 IP 地址
  2. 当 DNS 获得 IP 地址后,会向这个地址发送 GET 请求,查询短码 abc
  3. http://xxx 服务器上运行的服务会通过短码 abc 获取其原本的 URL
  4. 请求通过 Http 重定向跳转到对应的长 URL,即可以正常访问啦

其中,重定向又分 301(永久重定向)和 302(临时重定向)。由于短地址一经生成就不会变化的性质,使用永久重定向可以对服务器压力又一定减少,但也因此无法统计到经由短地址来访问该页面的次数。所以,当对这种数据存在分析需求的时候,可以使用 302 进行跳转,以增加一点服务器压力的代价来实现对更多数据的收集。

常用的算法实现

内容压缩算法(MD5压缩算法)

使用算法直接对长链内容进行压缩,例如获取 hash 算法,或是采用 MD5 算法,将长链接生成一个 128 位的特征码,然后将特征码截取成 4 到 8 位用作短链码。

优点 :生成简单,不需要建立对应关系就可以支持长链接重复查询。

缺点 :由于 MD5 为有损压缩算法,不可避免会出现重复的问题。

发号器算法(id自增算法)

给每个请求过来的长链接分配一个 id 号,对该索引进行加密,并用其作为短码生成需要的短链接。由于 id 是自增的,所以理论上生成的短链接永远不会重复,因此也叫永不重复算法。

优点 :避免了短链接重复问题。

缺点 :自增 id 暴露在外,会有很大的安全风险,造成链接信息泄露;需要建立对应关系才可以支持长链接重复查询。

整一个自己的短链接服务

这里我们选用id自增算法进行实现。

首先创建一个 springboot 项目,技术栈 spring boot + spring-data-jpa + mysql,项目结构如下 :

然后就要开始理需求啦 :我们实际需要的其实只有两个功能

  1. 实现根据 URL 获取对应的短 URL
  2. 实现通过短 URL 访问原 URL,即跳转到原 URL

实现URL的映射算法

首先要解决的是 长链接重复查询问题。上文提到需要建立对应关系,这里我们就简单用一张表来存储。与其对应的实体类 TranslationEntity 如下 :

package com.demo.demosurl.demosurl.model.entity;

import lombok.Data;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

/**
 * @author yuanyiwen
 * @create 2019-12-06 21:04
 * @description
 */
@Entity
@Data
public class TranslationEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    /**
     * 真实url(长链)
     */
    private String url;

    /**
     * 转换url(短链)
     */
    private String shortUrl;

}

然后是 直接暴露自增主键造成的链接信息泄露问题。简单来想的话,如果 id 变化的规律为 1、2、3、4,...,就很好推测下一个;但如果 id 变化的规律是 1、32、18、97,...,呢?是不是就猜不到下一个是啥啦!

隐藏递增规律的核心就是使用 Feistel 密码结构,引用大佬的话 :

Feistel 加密算法的输入是长为 2w 的明文和一个密钥 K=(K1,K2...,Kn)。将明文分组分成左右两半 L 和 R ,然后进行 n 轮迭代,迭代完成后,再将左右两半合并到一起以产生密文分组。

Feistel 加密算法能够产生一个非常好用的特点,那就是,在一定的数字范围内(2 的 n 次方),每一个数字都能根据密钥找到唯一的一个匹配对象,这就达到了我们隐藏递增规律的目的。

例如,我们给定数字范围为 64(2 的 6 次方),其中,每个数字都能找到唯一一个随机对应的数字对。这里的随机通过密钥来产生。

那么我们将 1 和 2 使用算法进行计算,会发现对应到的数字就是 17 和 25。这就完美解决了我们的数字递增问题,外部用户无法从数字表面看出是递增的。而且每个数字的匹配模式都不一样。

借助这种思想,我们添加一个 id 混淆工具类,来将有序的 id 映射为无序 :

package com.demo.demosurl.demosurl.util.encrypt;

/**
 * @author yuanyiwen
 * @create 2019-12-07 22:02
 * @description id混淆工具类
 */
public abstract class NumberEncodeUtil {

    /**
     * 对id进行混淆
     * @param id
     * @return
     */
    public static Long encryptId(Long id) {
        Long sid = (id & 0xff000000);
        sid += (id & 0x0000ff00) << 8;
        sid += (id & 0x00ff0000) >> 8;
        sid += (id & 0x0000000f) << 4;
        sid += (id & 0x000000f0) >> 4;
        sid ^= 11184810;
        return sid;
    }

    /**
     * 对混淆的id进行还原
     * @param sid
     * @return
     */
    public static Long decodeId(Long sid) {
        sid ^= 11184810;
        Long id = (sid & 0xff000000);
        id += (sid & 0x00ff0000) >> 8;
        id += (sid & 0x0000ff00) << 8;
        id += (sid & 0x000000f0) >> 4;
        id += (sid & 0x0000000f) << 4;
        return id;
    }
}

解决了数字递增问题后,接下来要做的是 数字压缩与加密。对一个数字长度进行压缩而不改变其含义,最简单的方式就是将它变为更高的进制。对于一个符号位来说,如果我们采用不带符号的简单字符(0-9a-zA-Z),加起来正好 62 个常用字符。所以我们可以选择将混淆后的十进制 id 转换为 62 进制 :

package com.demo.demosurl.demosurl.util.encrypt;

import java.util.Stack;

/**
 * @author yuanyiwen
 * @create 2019-12-07 22:00
 * @description 进制转换工具类
 */
public abstract class ScaleConvertUtil {

    /**
     * 将10进制数字转换为62进制
     * @param number 数字
     * @param length 转换成的62进制长度,不足length长度的高位补0
     * @return
     */
    public static String convert(long number, int length){

        char[] charSet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();

        Long rest=number;
        Stack<Character> stack=new Stack<Character>();
        StringBuilder result=new StringBuilder(0);
        while(rest!=0){
            stack.add(charSet[new Long((rest-(rest/62)*62)).intValue()]);
            rest=rest/62;
        }
        for(;!stack.isEmpty();){
            result.append(stack.pop());
        }
        int result_length = result.length();
        StringBuilder temp0 = new StringBuilder();
        for(int i = 0; i < length - result_length; i++){
            temp0.append('0');
        }

        return temp0.toString() + result.toString();
    }
}

然后就可以开始进行具体的实现了!ServiceImpl 包含两个方法 :

  • 根据真实url获取短url时,返回转换实体
  • 根据短url获取真实url时,返回转换实体

具体的实现如下,关键思路标在注释里了 :

package com.demo.demosurl.demosurl.service.impl;

import com.demo.demosurl.demosurl.common.CommonConstant;
import com.demo.demosurl.demosurl.dao.TranslationDto;
import com.demo.demosurl.demosurl.model.entity.TranslationEntity;
import com.demo.demosurl.demosurl.model.vo.TranlationVo;
import com.demo.demosurl.demosurl.service.TranslationService;
import com.demo.demosurl.demosurl.util.convertion.EntityVoUtil;
import com.demo.demosurl.demosurl.util.encrypt.NumberEncodeUtil;
import com.demo.demosurl.demosurl.util.encrypt.ScaleConvertUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * @author yuanyiwen
 * @create 2019-12-06 21:12
 * @description
 */
@Service
public class TranslationServiceImpl implements TranslationService {

    @Autowired
    private TranslationDto translationDto;

    @Override
    public TranlationVo getShortUrlByUrl(String url) {

        // 若不是URL格式,直接返回空
        if(!isHttpUrl(url)) {
            return null;
        }

        TranslationEntity translationEntity = translationDto.findByUrl(url);

        // 如果该实体不为空,直接返回对应的短url
        if(translationEntity != null) {
            return EntityVoUtil.convertEntityToVo(translationEntity);
        }

        // 否则,重新生成转换实体并存入数据库 todo 存入缓存
        translationEntity = new TranslationEntity();

        // 获取当前id并生成短url尾缀
        Long currentId = translationDto.count()+1;
        String shortUrlSuffix = ScaleConvertUtil.convert(NumberEncodeUtil.encryptId(currentId), CommonConstant.LENGTH_OF_SHORT_URL);

        // 短链接拼接
        String shortUrl = CommonConstant.PRIFIX_OF_SHORT_URL + shortUrlSuffix;

        translationEntity.setUrl(url);
        translationEntity.setShortUrl(shortUrl);
        translationDto.save(translationEntity);

        return EntityVoUtil.convertEntityToVo(translationEntity);
    }

    @Override
    public TranlationVo getUrlByShortUrl(String shortUrl) {
        TranslationEntity translationEntity = translationDto.findByShortUrl(shortUrl);
        if(translationEntity != null) {
            return EntityVoUtil.convertEntityToVo(translationEntity);
        }
        return null;
    }

    /**
     * 判断一个字符串是否为URL格式
     * @param url
     * @return
     */
    private boolean isHttpUrl(String url) {
        boolean isUrl = false;
        if(url.startsWith("http://") || url.startsWith("https://")) {
            isUrl = true;
        }
        return isUrl;
    }
}

对外暴露一个“根据URL获取对应短链接”的接口方法 :

@PostMapping("/surl")
public ResponseVo<TranlationVo> getShortUrlByUrl(String url) {

    TranlationVo tranlationVo = translationService.getShortUrlByUrl(url);

    if(tranlationVo == null) {
        return ResponseUtil.toFailResponseVo("请检查上传的url格式");
    }

    return ResponseUtil.toSuccessResponseVo(tranlationVo);
}

最后是一些可以集中配置的常量参数,这里单独抽出来方便进行一些自定义调配 :

package com.demo.demosurl.demosurl.common;

/**
 * @author yuanyiwen
 * @create 2019-12-07 22:13
 * @description 保存项目中用到的常量
 */
public interface CommonConstant {

    /**
     * 默认生成的短链接后缀长度
     */
    Integer LENGTH_OF_SHORT_URL = 4;


    /**
     * 默认生成的短链接前缀
     */
    String PRIFIX_OF_SHORT_URL = "http://localhost:8090/";


    /**
     * 若输入的短链接不存在,默认跳转的页面
     */
    String DEFAULT_URL = "http://localhost:8090/default";
}

实现URL的跳转

这个就非常简单了,只需要通过短链获取到长链,然后使用 ModelAndView 的重定向进行跳转 :

@GetMapping("{shortUrl}")
public ModelAndView redirect(@PathVariable String shortUrl, ModelAndView mav){

    // 获取对应的长链接(若短链接不存在,则跳转到默认页面)
    TranlationVo tranlationVo = translationService.getUrlByShortUrl(CommonConstant.PRIFIX_OF_SHORT_URL + shortUrl);
    String url = (tranlationVo == null) ? CommonConstant.DEFAULT_URL : tranlationVo.getUrl();

    // 跳转
    mav.setViewName("redirect:" + url);

    return mav;
}

最后来检测一下成果

启动项目,打开 postman ,输入我们的URL :

可以看到,服务已经将长链接 juejin.cn/post/684490… 转成对应的短链接 http://localhost:8090/kvgQ 返了回来。让我们访问一下试试 :

跳转成功!这样一个简单的短链接服务就完成啦。

项目改进与源代码

因为是一个自定义的非常简单的短链接服务,所以还是有非常多地方可以继续改进的,比如说

  • 使用缓存建立链接的对应关系,及时清理不再需要的短链接,以避免数据的无限膨胀
  • 做一些数据统计,比如访问量、IP地址段、用户使用的设备等等
  • 使用多个发号器,段性错开进行号码的发放,以解决改造为分布式后的同步问题
  • 给人家加一个前端页面 TuT

最后悄悄放一个github地址 _(:3 」∠)_ : github.com/yuanLier/su…


参考文章 :
www.cnblogs.com/yuanjiangw/… my.oschina.net/u/2485991/b…