我创建了自己的 TinyURL。以下是我的创建过程。
设计类似TinyURL和Bitly 的网址缩短服务是软件工程中最常见的系统设计面试题之一。
在摆弄 Cloudflare Worker 将每日 LeetCode 挑战同步到我的 Todoist 时,我萌生了一个想法,那就是构建一个任何人都可以使用的真正的 URL 缩短器。
接下来,我将通过代码示例,阐述如何使用Cloudflare Worker创建 URL 缩短服务。如果您想尝试一下,需要一个 Cloudflare 帐户并使用Wrangler CLI。
太长不看
- 使用 Cloudflare Worker 和 KV 免费构建 URL 缩短服务
- 项目需求和限制规划
- 短链接 UUID 生成逻辑
- 在线演示请访问s.jerrynsh.com
- GitHub 仓库
在开始之前,请不要抱有过高的期望。这并非一份关于以下方面的指南:
- 如何应对实际的系统设计面试
- 构建类似 TinyURL 或 Bitly 的商业级网址缩短服务
但这更像是一个概念验证(POC),展示了如何使用无服务器计算构建一个真正的URL缩短服务。所以,先把“可扩展性”、“分区”、“副本”等等这些概念抛到脑后,准备好迎接挑战吧。
希望您觉得这篇文章既有见地又有趣!
要求
就像任何系统设计面试一样,让我们先来定义一些功能性和非功能性需求。
功能
- 给定一个 URL,我们的服务应该返回一个唯一且简短的 URL。例如
https://jerrynsh.com/how-to-write-clean-code-in-python/→s.jerrynsh.com/UcFDnviQ - 每当用户尝试访问时
s.jerrynsh.com/UcFDnviQ,用户都会被重定向回原始 URL。 - UUID(我有时称之为 URL 键,因为它是我们存储对象的键)应遵循Base62 编码方案(26 + 26 + 10):
1. A lower case alphabet 'a' to 'z', a total of 26 characters
2. An upper case alphabet 'A' to 'Z', a total of 26 characters
3. A digit '0' to '9', a total of 10 characters
4. In this POC, we will not be supporting custom short links
- 我们的 UUID 长度应 ≤ 8 个字符,因为 62⁸ 将给我们带来大约 218 万亿种可能性。
- 生成的短链接永不过期。
非功能性
- 低延迟
- 高可用性
预算、能力和限制规划
目标很简单——我希望能够免费托管这项服务。因此,我们的限制很大程度上取决于Cloudflare Worker 的定价和平台限制。
截至撰写本文时,每个账户免费托管我们服务的限制条件如下:
- 每天 10 万次请求,每分钟 1 千次请求。
- CPU运行时间不超过10毫秒
与大多数网址缩短服务一样,我们的应用预计会面临较高的读取量和相对较低的写入量。为了存储数据,我们将使用Cloudflare KV,这是一种键值数据存储,支持高读取量和低延迟——非常适合我们的应用场景。
- 每日阅读量10万
- 每天写作1000篇
- 存储数据 1 GB(键大小为 512 字节;值大小为 25 MiB)
我们可以存储多少个短链接
考虑到免费存储空间上限为 1 GB,我们来估算一下最多可以存储多少个 URL。这里,我使用这个工具来估算 URL 的字节大小:
- 1 个字符等于 1 个字节
- 由于我们的 UUID 最多只能是 8 个字符,所以我们肯定不会遇到密钥长度限制的问题。
- 另一方面,关于值大小限制——我根据计算估计,URL 的最大长度平均约为 200 个字符。因此,我认为可以合理假设每个存储对象的平均大小应≤400 字节,这远低于 25 MiB。
- 最后,凭借 1 GB 的可用空间,我们的 URL 缩短器最多可以支持 2,500,000 个短链接(1 GB 除以 400 字节)。
- 我知道,我知道。250万个网址并不算多。
回想起来,我们本可以将 UUID 的长度设置为 ≥ 4 而不是 8,因为 62⁴ 种可能性远远超过 250 万。话虽如此,我们还是继续使用长度为 8 的 UUID。
总的来说,我认为 Cloudflare Worker 和 KV 的免费套餐相当慷慨,完全能够满足我们的概念验证需求。需要注意的是,这些限制是按账户计算的。
存储和数据库
正如我之前提到的,我们将使用 Cloudflare KV 作为数据库来存储我们的缩短 URL,因为我们预计读取操作会比写入操作多。
最终一致性
:需要特别注意的是,虽然 KV 能够支持极高的全局读取速度,但它是一种最终一致性存储解决方案。换句话说,任何写入操作(例如创建短链接)可能需要长达 60 秒才能在全球范围内生效——我们认为这是可以接受的缺点。
在我的实验中,我还没有遇到过超过几秒钟的情况。
原子弹操作
阅读有关键值对工作原理的文章后发现,键值对并不适用于需要原子操作的场景(例如两个账户余额之间的银行交易)。幸运的是,这完全与我们无关。
对于我们的 POC,KV 的键将是域名后面的 UUID(例如s.jerrynsh.com/UcFDnviQ),而值将由用户提供的长 URL 组成。
创建KV
要创建 KV,只需使用 Wrangler CLI 运行以下命令即可。
# Production namespace:
wrangler kv:namespace create "URL_DB"
# This namespace is used for `wrangler dev` local testing:
wrangler kv:namespace create "URL_DB" --preview
要创建这些 KV 命名空间,我们还需要更新wrangler.toml文件以包含相应的命名空间绑定。您可以通过访问以下网址查看 KV 的仪表板https://dash.cloudflare.com/<your_cloudflare_account_id>/workers/kv/namespaces:
短链接 UUID 生成逻辑
这可能是我们整个申请过程中最重要的部分。
根据我们的要求,目标是为每个 URL 生成一个字母数字 UUID,其中密钥的长度不应超过 8 个字符。
理想情况下,生成的短链接的 UUID 不应冲突。另一个需要考虑的重要问题是:如果多个用户缩短了同一个 URL 该怎么办?理想情况下,我们也应该检查是否存在重复项。
我们来考虑以下几种解决方案:
1. 使用 UUID 生成器
这个方案实现起来相对简单。对于遇到的每个新 URL,我们只需调用 UUID 生成器生成一个新的 UUID。然后,我们将生成的 UUID 作为键分配给新的 URL。
如果 UUID 已存在于我们的键值表中(发生冲突),我们可以继续重试。但是,我们需要注意重试的开销可能相对较大。
此外,使用 UUID 生成器并不能帮助我们处理键值表中的重复项。在键值表中查找长 URL 值会比较慢。
2. 对 URL 进行哈希处理
另一方面,对 URL 进行哈希处理可以让我们检查重复的 URL,因为将字符串(URL)传递给哈希函数总是会产生相同的结果。然后,我们可以使用结果(键)在键值表中查找,以检查是否存在重复项。
假设我们使用MD5 算法,最终得到的密钥长度至少为 8 个字符。那么,如果我们只取生成的 MD5 哈希值的前 8 个字节呢?问题不就解决了吗?
不完全是这样。哈希函数总是会产生碰撞。为了降低碰撞概率,我们可以生成更长的哈希值。但是,这样做对用户不太友好。此外,我们希望 UUID 的长度不超过 8 个字符。
3. 使用增量计数器
在我看来,这可能是最简单且最具扩展性的解决方案。使用此方案,我们将不会遇到冲突问题。每当我们遍历完整个集合(从 00000000 到 99999999)时,只需将 UUID 的字符数加一即可。
然而,我不希望用户能够通过简单地访问某个网址来随机猜测短链接s.jerrynsh.com/12345678。因此,这个方案行不通。
选择哪一个
还有许多其他解决方案(例如预先生成一个键列表,并在收到新请求时分配一个未使用的键),这些方案各有优缺点。
对于我们的概念验证,我们选择方案一,因为它易于实现,而且我可以接受重复请求。为了处理重复请求,我们可以缓存用户的请求以缩短URL。
Nano ID
为了生成 UUID,我们使用该nanoid软件包。为了估算碰撞率,我们可以使用Nano ID 碰撞计算器:
KV免费套餐每天只能处理1000次写入,因此速度约为每小时42个ID(1000次/24小时)。
好了,话不多说,让我们开始写代码吧!
为了应对可能发生的碰撞,我们只需不断重试:
// utils/urlKey.js
import { customAlphabet } from "nanoid";
const ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/*
Generate a unique `urlKey` using `nanoid` package.
Keep retrying until a unique urlKey which does not exist in the URL_DB.
*/
export const generateUniqueUrlKey = async () => {
const nanoId = customAlphabet(ALPHABET, 8);
let urlKey = nanoId();
while ((await URL_DB.get(urlKey)) !== null) {
urlKey = nanoId();
}
return urlKey;
};
API
在本节中,我们将定义要支持的 API 端点。本项目使用itty-routerworker模板进行初始化——它能帮助我们处理所有路由逻辑:
wrangler generate <project-name> https://github.com/cloudflare/worker-template-router
我们项目的入口点位于 index.js 文件中:
// index.js
import { Router } from "itty-router";
import { createShortUrl } from "./src/handlers/createShortUrl";
import { redirectShortUrl } from "./src/handlers/redirectShortUrl";
import { LANDING_PAGE_HTML } from "./src/utils/constants";
const router = Router();
// GET landing page html
router.get("/", () => {
return new Response(LANDING_PAGE_HTML, {
headers: {
"content-type": "text/html;charset=UTF-8",
},
});
});
// GET redirects short URL to its original URL.
router.get("/:text", redirectShortUrl);
// POST creates a short URL that is associated with its an original URL.
router.post("/api/url", createShortUrl);
// 404 for everything else.
router.all("*", () => new Response("Not Found", { status: 404 }));
// All incoming requests are passed to the router where your routes are called and the response is sent.
addEventListener("fetch", (e) => {
e.respondWith(router.handle(e.request));
});
为了提供更好的用户体验,我创建了一个简单的 HTML 登录页面,任何人都可以使用;您可以在这里获取登录页面的 HTML 代码。
创建短链接
首先,我们需要一个 POST 端点(/api/url),该端点createShortUrl会调用解析请求originalUrl体的内容并从中生成一个短 URL。
以下是代码示例:
// handlers/createShortUrl.js
import { generateUniqueUrlKey } from "../utils/urlKey";
export const createShortUrl = async (request, event) => {
try {
const urlKey = await generateUniqueUrlKey();
const { host } = new URL(request.url);
const shortUrl = `https://${host}/${urlKey}`;
const { originalUrl } = await request.json();
const response = new Response(
JSON.stringify({
urlKey,
shortUrl,
originalUrl,
}),
{ headers: { "Content-Type": "application/json" } },
);
event.waitUntil(URL_DB.put(urlKey, originalUrl));
return response;
} catch (error) {
console.error(error, error.stack);
return new Response("Unexpected Error", { status: 500 });
}
};
要在本地进行测试(您可以使用以下命令wrangler dev在本地启动服务器) :curl
curl --request POST \\
--url http://127.0.0.1:8787/api/url \\
--header 'Content-Type: application/json' \\
--data '{
"originalUrl": "https://www.google.com/"
}'
重定向短链接
作为网址缩短服务提供商,我们希望用户在访问短网址时能够重定向到其原始网址:
// handlers/redirectShortUrl.js
export const redirectShortUrl = async ({ params }) => {
const urlKey = decodeURIComponent(params.text);
const originalUrl = await URL_DB.get(urlKey);
if (originalUrl) {
return Response.redirect(originalUrl, 301);
}
return new Response("Invalid Short URL", { status: 404 });
};
删除功能如何实现?由于用户无需任何授权即可缩短任何 URL,因此决定不提供删除 API,因为任何用户都可以随意删除其他用户的短 URL 是没有意义的。
要在本地试用我们的网址缩短服务,只需运行 wrangler dev 即可。
额外内容:利用缓存处理重复数据
注意:目前,此功能仅适用于自定义域名。
如果用户反复缩短同一个URL会发生什么?我们肯定不希望我们的键值对最终出现分配了唯一UUID的重复URL,对吧?
为了缓解这个问题,我们可以使用缓存中间件,该中间件缓存用户使用缓存 API提交的原始 URL :
import { URL_CACHE } from "../utils/constants";
export const shortUrlCacheMiddleware = async (request) => {
const { originalUrl } = await request.clone().json();
if (!originalUrl) {
return new Response("Invalid Request Body", {
status: 400,
});
}
const cache = await caches.open(URL_CACHE);
const response = await cache.match(originalUrl);
if (response) {
console.log("Serving response from cache.");
return response;
}
};
要使用此缓存中间件,只需index.js相应地更新我们的代码即可:
// index.js
...
router.post('/api/url', shortUrlCacheMiddleware, createShortUrl)
...
最后,我们需要确保在缩短 URL 后,将缓存实例更新为原始 URL:
// handlers/createShortUrl.js
import { URL_CACHE } from "../utils/constants";
import { generateUniqueUrlKey } from "../utils/urlKey";
export const createShortUrl = async (request, event) => {
try {
const urlKey = await generateUniqueUrlKey();
const { host } = new URL(request.url);
const shortUrl = `https://${host}/${urlKey}`;
const { originalUrl } = await request.json();
const response = new Response(
JSON.stringify({
urlKey,
shortUrl,
originalUrl,
}),
{ headers: { "Content-Type": "application/json" } },
);
const cache = await caches.open(URL_CACHE); // Access our API cache instance
event.waitUntil(URL_DB.put(urlKey, originalUrl));
event.waitUntil(cache.put(originalUrl, response.clone())); // Update our cache here
return response;
} catch (error) {
console.error(error, error.stack);
return new Response("Unexpected Error", { status: 500 });
}
};
在我的测试过程中wrangler dev,Worker 缓存似乎在本地或任何 worker.dev 域上都无法工作。
测试此功能的变通方法是运行命令将应用程序发布到自定义域。您可以通过向端点发送请求并同时观察日志来wrangler publish验证更改。/api/urlwrangler tail
部署
任何副业项目都离不开服务器托管,对吧?
发布代码之前,您需要编辑wrangler.toml文件并添加 Cloudflare 配置account_id。有关配置和发布代码的更多信息,请参阅官方文档。
要部署和发布对 Cloudflare Worker 的任何新更改,只需运行命令即可wrangler publish。要将应用程序部署到自定义域,请观看此短片。
如果你在阅读过程中遇到任何问题,可以随时点击这里查看GitHub代码库。就这样!
最后想说的话
说实话,这是我最近一段时间以来最开心的一次经历——同时进行研究、撰写和构建这个概念验证项目。关于我们的网址缩短服务,我还有很多想法,这里只列举几个:
大多数网址缩短服务都面临着一个问题,那就是短链接经常被滥用,将用户引导至恶意网站。我认为这是一个非常值得深入研究的话题。
今天就到这里啦!感谢阅读,祝您一切顺利!
本文最初发表于jerrynsh.com 网站。
文章来源:https://dev.to/jerrynsh/i-built-my-own-tinyurl-heres-how-i-did-it-11ah



