本项目是基于DDD实现的短链服务,通过它我们可以轻松地将原始链接映射为短链,使它可以被投放在一些限制消息长度或者可提高阅读性的场景中。关于项目源码可通过链接github.com/kc-co9/shor…查看。
概述
短链服务(URL Shortener)是一种可以将统一资源定位符(URL)缩短且仍可指向原统一资源定位符(URL
)的技术。一般,这项技术会用在一些限制消息中字符长度、提高消息内容阅读性、或者追踪点击率等场景中,典型的有:短信消息(SMS)、社交媒体消息(微博、小红书等)。
理论
单从原理的角度看,短链服务主要是通过URL重定向来实现,在访问短链时会被重定向到对应的长链上。也就是说,当我们通过短链去请求时,服务器会根据短链所对应的长链对请求进行重定向,并最终将其链接到长链上,具体流程如下:
┌───────────┐ 1.request to A ┌───────────┐
│ ├────────────────────►│ │
│ client │ │ serverA │
│ │◄────────────────────┤ │
└───▲───┬───┘ 2.redirect to B └───────────┘
│ │
│ │
4.response OK │ │ 3.request to B
│ │
│ │
┌───┴───▼───┐
│ │
│ serverB │
│ │
└───────────┘
短链服务的原理很简单,但实现时仍然需要考虑以下3个关键的问题:
- 短链的创建问题。
- 短链的访问问题。
- 短链的安全问题。
短链的创建
对于短链的创建,本质上是完成从"原始链接"到"短链"的一一映射,使得用户在访问"短链"时服务器可以在映射中找到与之对应的"原始链接",并完成最终的重定向。在实现上,首先我们会生成一个"标识符"与"原始链接"进行绑定,然后再将已绑定的" 标识符"拼接到"短链"所在的顶级域名后完成"短链"的生成。最后在对"短链"进行访问时我们会通过"短链"上"标识符"找到与之对应的"原始链接",并完成最终的重定向。
例如,在将"原始链接"与
m3q2xt进行绑定后将其添加到"短链"所在的顶级域名https://tinyurl.com后完成"短链"的生成,即https://tinyurl.com/m3q2xt。
为了能最大效率地为"原始链接"生成其对应的"短链",对"标识符"的生成策略十分关键。
"标识符"的策略
对于"标识符"生成策略的设计,我们应该至少需要考虑到以下三点:
- 无重复。若"标识符"的生成出现了重复的键会导致将"短链"错误地重定向到无符合预期的"原始链接"上。
- 可控的。若"标识符"的生成长度太长就违背了"短链"设计的初衷,造成生成的"短链"非短链的尴尬局面。
- 高效的。若"标识符"的生成效率太低就会导致使用体验大大的降低,无法最大程度的发挥其作用。
因此,要是我们想实现一款用在"短链"上的"标识符"生成器,最基本是要保证其生成的"标识符"是无重复、可控的和高效的。
1. HASH算法
对于将"原始链接"映射到一个"标识符"最直接最优雅的方式当然是通过一个HASH算法来实现,通过这种方式在每次创建时我们只需要简单地通过HASH算法计算出"原始链接"对应的"标识符"
即可。但是,要将一个长链接通过算法来计算出一个简短的"标识符"且要一一映射显然是不可能的。也就是说,在通过HASH算法将一个长链接计算出的"标识符"是会发生碰撞的,而对发生碰撞的"标识符"我们可以通过对"原始链接"
附加一些干扰符号再执行HASH算法得出下一个"标识符"来绕开碰撞的发生(若再次发生碰撞则继续添加干扰符号,直到生成出一个没有发生碰撞的"标识符")。
举个例子,假设我们现在需要将原始链接 https://longurl.com/abc 转换为短链。那我们首先会通过HASH算法对 https://longurl.com/abc 链接计算得出"标识符",即:
HASH(https://longurl.com/abc) => m3q2xt
接着,我们会查询"标识符"m3q2xt是否已经存在,如果存在则对原始链接 https://longurl.com/abc 添加一些干扰符号再通过HASH算法进行计算,在这里采用的是在原始链接前后添加[和]符号,即:
HASH([https://longurl.com/abc]) => j3df2f
以此类推,直至生成一个不发生碰撞的"标识符"。
然而,虽然干扰符号的加入可以解决"标识符"碰撞的问题,但是对于固定长度的"标识符"生成数量确是不能无限地增长(取决于HASH算法),在短链不断生成的同时"标识符"
的可用数量也在不断地减少,而且越到后面碰撞发生的概率就会越大。总的来说,如果HASH算法计算出HASH值的取值范围符合业务需要,我们可以使用这种方式来实现短链的生成,但如果业务需要的取值范围大于HASH
值的取值范围,那还是考虑采用其他方案更好。
2. 自增ID算法
"标识符"的另一种天生自带无重复和可控特性的方案是自增ID算法。与HASH算法不同的是,使用自增ID来生成"标识符"并不会在内容上与"原始链接"建立联系,而是需要我们自己存储"原始链接"与"标识符"
之间的映射。典型地,我们在短链服务中维护一个自增ID发号器,每当需要为"原始链接"生成"短链"时就向发号器申请一个唯一的自增ID,在获得自增ID后将其与"原始链接"的关系进行绑定,然后将自增ID拼接到"短链"域名上返回。
不过,自增ID也有自身的一些问题,比如自增ID连续性会比较强,容易让人发现"标识符"的生成规律。在一些特定场景下,业务可能不想将这些规律暴露给接入方或者用户,让其可以很轻易地就完成对整个"短链"
库的扫描。而对于这个问题,我们也可以使用自增ID的一些变种方案来代替,比如使用雪花ID(Snowflake)、UUID
等,当然这些替代方案本身也有各自的问题(时间回拨、索引效率等问题),这需要结合各自的业务场景进行取舍和选择了。
总的来说,自增ID算法也很好地符合"短链"中对"标识符"生成的基本要求,不过在选择前需要考虑清楚是否在意它的随机性或其他性能上的问题。
3. 线性同余算法
除此之外,我们还可以使用线性同余生成器(LCG)算法来生成"标识符",通过这种方式可以在无重复和可控性的基础上附加比较好的随机性。其中,线性同余策略(LCG
)是一个通过不连续的分段线性方程来生成伪随机数的策略,该方程属于最古老、最著名的伪随机数生成算法之一,即:
X(n+1) = (a*X(n) + c) mod m
其中,X(n)表示伪随机数的值,a、c和m为生成器设定的整形常量,具体含义如下:
a,0 < a < m,表示倍率c,0 <= c < m,表示递增量m,0 < m,表示模数
在计算第一个伪随机数时方程需要一个起始值
X(0),又称之为种子值或者开始值,取值范围为0 <= X(0) < m。
为了能完全发挥线性同余生成器(LCG),我们需要正确的选择合适的参数a、参数c和参数m,使得它可以连续生成m无重复的随机的"标识符"。也就是说,要高效地使用线性同余算法,不但需要我们额外去存储Xn
,还需要正确的选择LCG的参数,使得它的单次生成周期能达到m。
总的来说,线性同余算法在无重复、可控性、随机性上都能得到比较好的效果,但是为了达到这种效果我们需要进行额外的存储和配置,这显然会增加业务开发和维护的整体复杂度。
"标识符"的缩简
为了保证"标识符"的唯一性,可能某些"标识符"的生成策略会使得生成字符长度过长,对这种场景我们可以使用Base36或者Base62进行编码(即将10进制数转换为36进制数或者62
进制数)。例如,当我们通过某种生成策略生成"标识符"18989844622434后觉得生成长度过长,可以通过Base36编码使得"标识符"变为6qbtb6noi,或者可以通过Base62编码使得"标识符"
变为5okgsMqS,上述可见经过编码后它们的长度都有明显地缩简。
短链的访问
在完成短链的创建和映射后,我们就可以将短链暴露出去。终端在对短链进行访问时,服务器会根据短链上的"标识符"找到与之对应的"原始链接",并根据不同业务需要进行不同类型的重定向。而对于重定向,我们可以将status设置为301
、302、303、307和308来实现,具体差异如下所示:
| 状态码 | 状态码定义 | 描述 |
|---|---|---|
301 | Moved Permanently | 永久重定向。301表示目标资源已被永久地指派到新到URI上,后续对目标资源的引用都应该使用新的URI。因为历史的原因,在重定向的请求中可以将POST请求转变为GET请求。 |
302 | Found | 临时重定向。302表示目标资源临时存放在不同的URI上,后续对目标资源的引用应该还是使用原本的有效URI(后续重定向的URI可能会发生变化)。因为历史的原因,在重定向的请求中可以将POST请求转变为GET请求。 |
303 | See Other | 临时重定向。303表示服务器要将终端重定向到不同的URI上,以实现向原始请求提供一个间接的响应(终端可以通过GET方法或者HEAD方法请求原始响应上的不同的URI(可能会发生重定向)并以此作为原始请求的结果),例如,在通过POST方法上传一些文件后,服务器发回一个303响应将终端重定向到一个“上传成功”的页面。 |
307 | Temporary Redirect | 临时重定向,307表示目标资源临时存放在不同的URI上,后续对目标资源的引用应该还是使用原本的有效URI(后续重定向的URI可能会发生变化)。相比于302,307不允许在重定向的请求中将POST请求转变为GET请求。 |
308 | Permanent Redirect | 永久重定向。308表示目标资源已被永久地指派到新到URI上,后续对目标资源的引用都应该使用新的URI。相比于301,308不允许在重定向的请求中将POST请求转变为GET请求。 |
短链的安全
短链技术在某种程度说会遮盖了真实请求的目标地址,在访问时用户可能会被重定向到一个预期之外的网站,例如一些诈骗网站、含恶意软件或者带XSS攻击的网站等。而因为短链技术,将这些网站加入到blocklists
来阻止访问的方式也完全地绕开了,这样的安全威胁对某些服务商来说显然是不可接收的,因此这些服务商也会限制某些短链的访问。
当然,一些短链服务商也为此想出了一些方式来防止或者提醒用户进入到这些危险的网站,比如通过提供一个选项去让终端或者用户预览短链最终指向的目的地址。除此之外,终端或者用户还可以通过bad-site
筛选服务(例如Google Safe Browsing)来预防短链重定向时进入到危险网站。
典型地,可在短链地址加上前缀路由
preview来实现目的地址的预览。举个例子,在短链地址https://tinyurl.com/8kmfp加上前缀路由preview构造出https://preview.tinyurl.com/8kmfp来实现目的地址的预览。
另外,对于短链的存活周期也会有一些安全的问题。往往一个永久的短链并不是一个好事,因为这可能会导致一些指向了已失效的"原始链接" 的永久“短链”仍然被不断的传播。而且一些只被用作临时分享的敏感资源通过永久的“短链”分享出去,若被恶意利用就可能导致敏感信息的泄漏。因此,出于安全性的考虑对“短链”设置一定的有效期还是很有必要的。
设计
考虑到"标识符"的利用率和复杂度,项目采取了自增ID的方式来生成"短链"。其中,对于自增ID的实现,项目中是通过数据库分段发号和AtomicLong工具类来实现,具体生成流程如下:
- 检查当前"原始链接"是否存在相对应的短链,如果存在则直接返回。
- 检查当前实例是否持有
ID号段,如果没有持有则向数据库申请一个ID号段(插入一条数据)。 - 检查当前实例持有的
ID号段是否已经使用完,如果使用完了则再次向数据库申请一个ID号段(插入一条数据)。 - 借助
JDK的AtomicLong工具类实现ID号段内的自增ID分配。 - 将每个分配到的自增
ID与"原始链接"及其Hash值绑定且存储到数据库中。
- 通过数据库分段发号和
AtomicLong工具类来实现自增ID,一方面是因为主键实现的自增ID在高并发下容易成为系统瓶颈,另一方面是额外中间件会增加项目的复杂度和维护成本,因此项目采取了这种方式来实现自增ID。- 另外,为了避免同一"原始链接"重复地创建出多条不同的"短链",在"短链"创建前会先查询数据库中是否存在对应的"原始链接",如果不存在才会去创建。而为了更优的查询效率,在处理这种反查时可以先通过对"原始链接"的
Hash值进行判断,如果存在才继续往下查询"原始链接"是否存在,否则直接可判断为不存在。再进一步优化,我们还可以加上时间范围的过滤,比如只查询过去一个月内的数据而不是全量查询;又或者可以通过对Hash值进行分表来减少单次查询的数据量。
同时,考虑到自增ID长度在递增的过程中逐步地增长,在绑定或者输出前使用Base62进行编码(即将10进制数转换为62进制数),而且也考虑到自增ID
的规律很容易就让用户识别出来,在绑定或者输出前需要再经过一次简单的字符转换进行干扰,即:
CHAR_CONVERT(BASE_62(AutoIncrementID))
而在对"短链"进行访问时,为了方便数据统计项目使用的是状态307来实现重定向的。另外,考虑到"原始链接"到"短链"
的映射理论上是不会发生变化的,因此我们完全可以对其加上缓存,而无需每次都从数据库中查询出他们的映射关系。对“短链”的缓存策略如下所示:
- 缓存预热:在“短链”创建时会提前将它加入到缓存中,而非等到用户访问时再缓存。
- 二级缓存:在应用中使用了二级缓存(即本地缓存+分布式缓存)来减少分布式缓存访问的压力。
LRU淘汰策略:为避免缓存空间资源的浪费,通过LRU实现对冷数据的淘汰。
另一方面,考虑到"短链"的安全性问题,在每个"短链"的创建时都需要设置相应的有效期,而且在访问时也可以通过在路径上加上preview来实现“原始地址”的预览。同时,在配置上也添加了对"原始链接"的黑名单处理,如果"原始链接"
处于黑名单中,则无法创建出"短链"(未创建的情况)或者无法通过"短链"进行访问(已创建的情况)。
建模
本项目是基于DDD来做短链服务的建模的。DDD是领域驱动设计(Domain-Driven Design)
,是一种以业务领域为核心的软件设计方法。它强调与领域专家密切合作,通过建模真实世界中的业务概念,将业务逻辑清晰地映射到代码中。也就是说,在DDD建模下,将业务知识系统化地映射为代码结构,使系统具备高度的可读性、可演化性与业务一致性。
划分子域
首先,根据业务场景将整个短链领域划分为不同的子域与对应的限界上下文。其中,子域类型包括核心域、支撑子域和通用子域,即:
| 子域 | 子域类型 | 限界上下文 |
|---|---|---|
| 短链子域 | 核心域 | 短链上下文 |
| 黑名单子域 | 支撑子域 | 黑名单上下文 |
| 用户子域 | 通用子域 | 用户上下文 |
统一语言
然后,为确保业务与技术在建模过程中的一致性,我们在每个限界上下文中定义统一语言(Ubiquitous Language),并使用聚合根、实体和值对象等建模手段进行表达。
短链上下文
| 业务术语 | DDD建模对象类型 | 建模名称 |
|---|---|---|
| 短链 | 聚合根 | Shorturl |
| 短链ID | 值对象 | ShortId |
| 短链码 | 值对象 | ShortCode |
| 短链状态 | 值对象 | ShorturlStatus |
| 短链有效时间 | 值对象 | ValidTimeInterval |
黑名单上下文
| 业务术语 | DDD建模对象类型 | 建模名称 |
|---|---|---|
| 黑名单 | 聚合根 | Blocklist |
| 黑名单ID | 值对象 | BlockId |
| 黑名单备注 | 值对象 | BlockRemark |
| 黑名单状态 | 值对象 | BlockStatus |
用户上下文
| 业务术语 | DDD建模对象类型 | 建模名称 |
|---|---|---|
| 用户 | 聚合根 | User |
| 用户ID | 值对象 | UserId |
| 用户邮箱 | 值对象 | UserEmail |
| 用户名称 | 值对象 | UserName |
| 用户原始密码 | 值对象 | UserRawPassword |
| 用户加密密码 | 值对象 | UserPassword |
| 角色 | 聚合根 | Role |
| 角色ID | 值对象 | RoleId |
| 角色名称 | 值对象 | RoleName |
| 权限 | 聚合根 | Permission |
| 权限ID | 值对象 | PermissionId |
| 权限名称 | 值对象 | PermissionName |
| 权限描述 | 值对象 | PermissionDesc |
| 权限值 | 值对象 | PermissionValue |
领域建模
最终完成短链领域的建模,即:
| 子域 | 子域类型 | 限界上下文 | 统一语言 |
|---|---|---|---|
| 短链子域 | 核心域 | 短链上下文 | Shorturl(聚合根)、ShortId、ShortCode、ShorturlStatus、ValidTimeInterval |
| 黑名单子域 | 支撑子域 | 黑名单上下文 | Blocklist(聚合根)、BlockId、BlockRemark、BlockStatus |
| 用户子域 | 通用子域 | 用户上下文 | User(聚合根)、UserId、UserEmail、UserName、UserPassword、UserRawPassword、Role(聚合根)、RoleId、RoleName、Permission(聚合根)、PermissionId、PermissionName、PermissionDesc、PermissionValue |
架构
在架构上,本项目采用了分层架构,每个层级将负责不同职责的工作,其中主要包括:
| 层级 | 职责描述 |
|---|---|
| 基础设施层 | 提供技术实现支持(如数据库访问、消息队列、缓存、文件系统等),实现领域层和应用层的技术需求。 |
| 表示层 | 处理用户交互与接口协议转换,不应该包含领域或业务逻辑。 |
| 应用层 | 协调领域层完成用例流程(定义用例流程),处理事务边界和权限控制,不包含业务规则或数据访问逻辑。 |
| 领域层 | 包含领域模型(聚合根、实体、值对象)、领域服务和领域事件,封装核心业务逻辑,不涉及技术实现。 |
然而,本项目并没有直接采用传统的分层结构,即用户接口层位于上层、应用层和领域层位于中层、基础设施层位于下层:
┌──────────────────────────────┐ ┌───────────────────────────┐
│ Frontend Starter │ │ Admin Starter │
│ ┌──────────────────────────┐ │ │ ┌───────────────────────┐ │
│ │ Frontend Interface Layer │ │ │ │ Admin Interface Layer │ │
│ └──────────────────────────┘ │ │ └───────────────────────┘ │
└──────────────┬───────────────┘ └──────────────┬────────────┘
└─────────────────┬────────────────┘
┌──────────▼───────────┐
│ Application Layer │
│ ┌──────────────┐ │
│ │ Domain Layer │ │
│ └──────────────┘ │
└───────────┬──────────┘
┌───────────▼──────────┐
│ Infrastructure Layer │
└──────────────────────┘
而是通过依赖倒置原则,对传统分层结构进行了改进,将用户接口层和基层设施层放在上层、应用层和领域层放在下层:
┌──────────────────┐ ┌───────────────┐
│ Frontend Starter │ │ Admin Starter │
└────────┬─────────┘ └───────┬───────┘
┌─────────────┴─────────────┬──────────┴───────────────┐
┌────────────▼─────────────┐ ┌──────────▼───────────┐ ┌───────────▼───────────┐
│ Frontend Interface Layer │ │ Infrastructure Layer │ │ Admin Interface Layer │
└────────────┬─────────────┘ └──────────┬───────────┘ └───────────┬───────────┘
└───────────────────────────┼──────────────────────────┘
┌──────────▼───────────┐
│ Application Layer │
│ ┌──────────────┐ │
│ │ Domain Layer │ │
│ └──────────────┘ │
└──────────────────────┘
在传统分层架构中,基础设施层位于最底层,包含持久化、消息机制等具体实现细节,导致领域模型容易直接依赖这些技术细节,从而产生较高的耦合度。改进后的分层架构则将基础设施层置于最上层,让其依赖于领域层定义的抽象接口,反转了传统的依赖关系。这样,核心领域模型无需直接依赖基础设施,实现了更好的解耦,保证领域模型的纯粹性,避免被技术细节污染,提升了系统的可维护性和可演进性。