发布于 2026-01-06 5 阅读
0

我创建了自己的 TinyURL。以下是我的创建过程。

我创建了自己的 TinyURL。以下是我的创建过程。

设计类似TinyURLBitly 的网址缩短服务是软件工程中最常见的系统设计面试题之一。

在摆弄 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
Enter fullscreen mode Exit fullscreen mode
  • 我们的 UUID 长度应 ≤ 8 个字符,因为 62⁸ 将给我们带来大约 218 万亿种可能性。
  • 生成的短链接永不过期。

非功能性

  • 低延迟
  • 高可用性

预算、能力和限制规划

目标很简单——我希望能够免费托管这项服务。因此,我们的限制很大程度上取决于Cloudflare Worker 的定价平台限制

截至撰写本文时,每个账户免费托管我们服务的限制条件如下:

  • 每天 10 万次请求,每分钟 1 千次请求。
  • CPU运行时间不超过10毫秒

与大多数网址缩短服务一样,我们的应用预计会面临较高的读取量和相对较低的写入量。为了存储数据,我们将使用Cloudflare KV,这是一种键值数据存储,支持高读取量和低延迟——非常适合我们的应用场景。

突破之前的限制,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
Enter fullscreen mode Exit fullscreen mode

要创建这些 KV 命名空间,我们还需要更新wrangler.toml文件以包含相应的命名空间绑定。您可以通过访问以下网址查看 KV 的仪表板https://dash.cloudflare.com/<your_cloudflare_account_id>/workers/kv/namespaces


短链接 UUID 生成逻辑

这可能是我们整个申请过程中最重要的部分。

根据我们的要求,目标是为每个 URL 生成一个字母数字 UUID,其中密钥的长度不应超过 8 个字符。

理想情况下,生成的短链接的 UUID 不应冲突。另一个需要考虑的重要问题是:如果多个用户缩短了同一个 URL 该怎么办?理想情况下,我们也应该检查是否存在重复项。

我们来考虑以下几种解决方案:

1. 使用 UUID 生成器

UUID生成器流程

这个方案实现起来相对简单。对于遇到的每个新 URL,我们只需调用 UUID 生成器生成一个新的 UUID。然后,我们将生成的 UUID 作为键分配给新的 URL。

如果 UUID 已存在于我们的键值表中(发生冲突),我们可以继续重试。但是,我们需要注意重试的开销可能相对较大。

此外,使用 UUID 生成器并不能帮助我们处理键值表中的重复项。在键值表中查找长 URL 值会比较慢。

2. 对 URL 进行哈希处理

对 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 碰撞计算器

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;
};
Enter fullscreen mode Exit fullscreen mode

API

在本节中,我们将定义要支持的 API 端点。本项目使用itty-routerworker模板进行初始化——它能帮助我们处理所有路由逻辑:

wrangler generate <project-name> https://github.com/cloudflare/worker-template-router
Enter fullscreen mode Exit fullscreen mode

我们项目的入口点位于 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));
});
Enter fullscreen mode Exit fullscreen mode

为了提供更好的用户体验,我创建了一个简单的 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 });
    }
};
Enter fullscreen mode Exit fullscreen mode

要在本地进行测试(您可以使用以下命令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/"
}'
Enter fullscreen mode Exit fullscreen mode

重定向短链接

作为网址缩短服务提供商,我们希望用户在访问短网址时能够重定向到其原始网址:

// 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 });
};
Enter fullscreen mode Exit fullscreen mode

删除功能如何实现?由于用户无需任何授权即可缩短任何 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;
    }
};
Enter fullscreen mode Exit fullscreen mode

要使用此缓存中间件,只需index.js相应地更新我们的代码即可:

// index.js
...
router.post('/api/url', shortUrlCacheMiddleware, createShortUrl)
...
Enter fullscreen mode Exit fullscreen mode

最后,我们需要确保在缩短 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 });
    }
};
Enter fullscreen mode Exit fullscreen mode

在我的测试过程中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