不必复杂,静态博客也可以做短链

立泉

为什么需要短链?

https://mudan.me/post/original/2022/05/17/不必复杂-静态博客也可以做短链.html

这是我博客里一篇文章的完整链接,除去协议和域名,后面一长串包含文章类型“original”、写作时间“20220517”和文章标题“不必复杂-静态博客也可以做短链”,它们共同构成一个指向此HTML文档的URL,很详细也很长。

我的博客站点是用Jekyll生成的,它在转换Markdown文章到静态网页时支持设定输出的URL结构,确实能做到很短。只需给每个Markdown文章都指定一个自定义Front Matter,然后用它做输出文件名,可以实现类似https://mudan.me/lxzlysh.html的短链。

# Jekyll配置文件 _config.yml

# 指定HTML页面的输出目录格式,也就是最终文章的URL格式
# https://mudan.me/post/original/2022/05/17/不必复杂-静态博客也可以做短链.html
permalink: /post/:categories/:year/:month/:day/:title:output_ext
# 可以很短,比如 https://mudan.me/bfjbyzd.html
permalink: /:custom_front_matter:output_ext

但这样的短接不包含任何文章相关信息,只有一串手动指定的字符,会不会碰撞是一个问题,另一个问题是permalink实际上是Jekyll文章页面的输出目录。我的长链是把文章输出到/post/original/2022/05/17/目录下,按类型、日期分类存储,而上面那种短链显然把所有文章都输出到了根目录,堆成一团。如果硬要做「短结构」也可以是/p/o/22/5/17/,但显然不如长链清晰,而且这也不是通常意义上的「短链」。

理解为什么链接会这么长之后,再回到一开始的问题,为什么需要短链?

URLUniversal Resource Locator,统一资源定位符,是一个能在茫茫网络中准确指向一个指定资源的字符串。当把一篇文章的URL分享出去,任何人都可以通过它访问到文章页面,就是这么“神奇”,而我则是遇到了几个不太“神奇”的问题。

问题

不友好的中文解析

我的博文URL包含中文(文章标题),而执行网页“跳转”的HTTP协议并不允许URL中含有ASCII码表以外字符,所以浏览器在访问中文URL时会先用URL Encoder将其转换为合法ASCII字符,然后才能执行HTTP请求。另一方面,中文URL的分享传播并不需要转码,可以直接发在微博、朋友圈的帖子里,等点击的时候再转码跳转。

问题是,很多平台在解析链接时会以非ASCII字符作为URL结束的标志,它们解析出的可点击链接只是中文URL的前一部分,并不完整,比如微博:

# 原始URL
https://mudan.me/post/original/2022/05/17/不必复杂-静态博客也可以做短链.html
# 解析出可以点击跳转的只是`https://mudan.me/post/original/2022/05/17/`这部分
# 自然不能跳转到正确页面
`https://mudan.me/post/original/2022/05/17/`不必复杂-静态博客也可以做短链.html

如果使用URL Encoder把中文部分编码后再分享倒是能正确解析,但又太长了,每一个汉字都被编码成十几个ASCII字符,很容易触及帖子的字数限制。

# 原始URL
https://mudan.me/post/original/2022/05/17/不必复杂-静态博客也可以做短链.html
# 编码后的URL,明显变长
https://mudan.me/post/original/2021/06/10/%E4%B8%8D%E5%BF%85%E5%A4%8D%E6
%9D%82-%E9%9D%99%E6%80%81%E5%8D%9A%E5%AE%A2%E4%B9%9F%E5%8F%AF%E4%BB%A5%
E5%81%9A%E7%9F%AD%E9%93%BE.html

URL变化需要重定向

使用短链的另一个原因也与博文URL里的文章标题有关,我有时候修订文章可能会修改标题,导致其URL也随之变化,之前分享出去的链接也会因此失效。虽然Jekyll提供的重定向插件jekyll-redirect-from可以在原位置生成一个指向文章最新地址的HTML网页进行跳转,但毕竟会创建多余目录和多余文件,不如直接维护一个短链与原始链的映射表简单直观。无论文章URL怎么变化,只需要保证映射表里用于分享的短链始终指向最新地址即可。

<!-- jekyll-redirect-from 生成的重定向网页 -->
<!DOCTYPE html>
<html lang="en-US">
  <meta charset="utf-8">
  <title>Redirecting&hellip;</title>
  <link rel="canonical" href="http://mudan.me/target.html">
  <script>location="http://mudan.me/target.html"</script>
  <meta http-equiv="refresh" content="0; url=http://mudan.me/target.html">
  <meta name="robots" content="noindex">
  <h1>Redirecting&hellip;</h1>
  <a href="http://mudan.me/target.html">Click here if you are not redirected.</a>
</html>

映射

短链服务的实现本质就是查询映射表,从中找到短链对应的长链然后跳转,这要求从长链生成短链的算法必须保证短链的唯一性,不能出现多个长链生成同一个短链的情况。

首先哈希算法是不合适的,虽然概率很低,但哈希碰撞不可避免。一种流行实现是多位62进制自增,比如定义短链为5个字符,每个字符都是62进制,那么1位就可以对应62个长链,5位对应62^5=916,132,832个长链。短短5位的指数级增长就已经接近10亿,足够应对大量短链映射。

为什么是62进制?因为URL必须是ASCII码表里的字符,常用的数字[0-9]和大小写字母[a-z,A-Z]加起来就是62。当然如果把+-这类合法字符也算上,64进制也可以。

一个生成短链的自增函数示例:

val table = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
    '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')
/**
 * 把传入的字符串按[table]数组的62个字符顺序自增,比如0000Z,自增后为00010
 */
fun selfIncreaseBase62(baseStr: String): String {
    val sb = StringBuilder()
    // 进位记录
    var carry = 1
    for (i in (baseStr.length - 1) downTo  0) {
        var index = table.indexOf(baseStr[i])
        if (index == -1) throw InvalidParameterException("invalid character")
        index += carry
        if (index >= table.size) {
            // 进位
            carry = 1
            // index归零
            index = 0
        } else {
            // 不进位
            carry = 0
        }
        sb.insert(0, table[index])
    }
    // 跳出循环依然有进位,则已经超出了所能表示的范围
    if (carry == 1) {
        throw IndexOutOfBoundsException()
    }
    return sb.toString()
}

需要数据库吗

已经有生成映射表的方法,要如何存储这个映射表并快速查询呢?数据库当然是能想到的首选方案,但对一个托管在GitHub Pages上区区数十网页的静态博客来说,有些太“重”了,而且还要单独租用服务器,并不合适。

我不会对外提供短链服务,仅仅为博客里的一部分文章生成短链,所以更现实的方法是在api目录下创建一个保存映射表的url-map.json文件,手动添加映射,我估摸着以自己的文章产出大概是不会超过百条的🙄️。

// api/url-map.json

// 短链我只用2位36进制就足够,36^2=1296,千条容量,如果有幸真的不够,后期还可以扩充
// 这里的op、og、rp是我的文章分类标志
// 最终的短链格式 https://mudan.me/op00
{
    "map": [
        {
            "id": "op00",
            "target": {
                "path": "/post/opera/essay_00.html",
                "title": "title_00"
            }
        },
        {
            "id": "op01",
            "target": {
                "path": "/post/opera/essay_01.html",
                "title": "title_01"
            }
        },
        {
            "id": "og00",
            "target": {
                "path": "/post/original/essay_00.html",
                "title": "title_00"
            }
        },
        {
            "id": "og01",
            "target": {
                "path": "/post/original/essay_01.html",
                "title": "title_01"
            }
        },
        {
            "id": "rp00",
            "target": {
                "path": "/post/repost/essay_00.html",
                "title": "title_00"
            }
        }
    ]
}

跳转

GitHub Pages静态博客并不能像自建Nginx服务器那样可以处理每一个请求并灵活返回要展示的网页,所谓“静态”是指每个网页都是已经存在的HTML文件,不能动态生成。Pages只会返回请求URL的对应页面,但允许用户创建域名下URL不存在时的404页面,它本质也是一个HTML文件,是实现短链跳转的关键。

对于短链https://mudan.me/op00,它对应的HTML文件不存在,所以Pages会重定向到自定义的404页面,在这个页面里就可以通过JavaScript获取URL中的短链ID,随后下载含有映射表的JSON文件,从中查找原链跳转。这就是静态博客实现短链的机制,没那么复杂,也不需要额外花费。

适当添加一些好看的跳转动画,一切都由开发者随意控制。

short link jumping

arrow_upward