Launching a URL Shortener in Rust using Rocket

Matthias Endler

使用 Rocket 在 Rust 中发布一个 URL 缩短器

面试中一个常见的系统设计任务,是勾勒出 URL 缩短器的软件架构(如果你愿意,也可以把它看作一个 bit.ly 克隆版)。既然我最近正在摆弄 Rocket——一个用于 Rust 的 Web 框架——那为什么不试试看呢?

一枚穿越太空的火箭
一枚穿越太空的火箭

需求

URL 缩短器有两个主要职责:

  • 为较长的 URL 创建一个短 URL(这不是显而易见的吗!)。
  • 请求短链接时,重定向到较长的链接。

我们把这个服务称作 rust.ly提示一下:在本文写作时,这个域名仍然可用……)。

首先,创建一个新的 Rust 项目:

cargo new --bin rustly

接着,将 Rocket 添加到我们的 Cargo.toml 中:

[dependencies]
rocket = "0.2.4"
rocket_codegen = "0.2.4"

警告:你很可能需要获取最新的 Rocket 版本。否则,可能会遇到一些……相当有“娱乐性”的错误消息。请在 crates.io 上查找最新版本。

由于 Rocket 需要 Rust 的前沿特性,我们需要使用较新的 nightly 构建版本。Rustup 提供了在 stable 和 nightly 之间切换的简单方式。

🤔 现在也许已经不再需要 nightly Rust 了。有人试过不用它吗?可以反馈一下吗?

rustup update && rustup override set nightly

第一个原型

现在可以开始编写这个小服务了。首先,写一个简单的“hello world”骨架来启动。将下面的内容放入 src/main.rs

#![feature(plugin)]
#![plugin(rocket_codegen)]

extern crate rocket;

#[get("/<id>")]
fn lookup(id: &str) -> String {
    format!("⏩ You requested {}. Wonderful!", id)
}

#[get("/<url>")]
fn shorten(url: &str) -> String {
    format!("💾 You shortened {}. Magnificent!", url)
}

fn main() {
    rocket::ignite().mount("/", routes![lookup])
                    .mount("/shorten", routes![shorten])
                    .launch();
}

在底层,Rocket 做了一些魔法,让我们能够使用这种简洁的语法。更具体地说,这要归功于 rocket_codegen crate。

为了让 rocket 库进入当前作用域,我们写下 extern crate rocket;

我们为服务定义了两个 路由routes)。两个路由都会响应 GET 请求。
具体做法是给函数添加一个名为 get属性attribute)。这个属性可以接受额外参数。在我们的例子中,我们为 lookup 端点定义了一个 id 变量,为 shorten 端点定义了一个 url 变量。两个变量都是 Unicode 字符串切片。由于 Rust 对 Unicode 的支持非常出色,我们特意用一个漂亮的表情符号来响应,以便炫技。🕶

最后,我们需要一个 main 函数,用来启动 Rocket 并挂载我们的两个路由。这样一来,它们就会公开可用。如果你想进一步了解其中的细节,可以参考 Rocket 官方文档

运行应用程序,检查一下方向是否正确。

cargo run

经过一番编译后,你应该会看到 Rocket 输出的漂亮启动信息:

🔧  Configured for development.
    => address: localhost
    => port: 8000
    => log: normal
    => workers: 8
🛰  Mounting '/':
    => GET /<hash>
🛰  Mounting '/shorten':
    => GET /shorten/<url>
🚀  Rocket has launched from https://localhost:8000...

太棒了!调用一下我们的服务。

> curl localhost:8000/shorten/www.endler.dev
💾 You shortened www.endler.dev. Magnificent!

> curl localhost:8000/www.endler.dev
⏩ You requested www.endler.dev. Wonderful!

到目前为止一切顺利。

数据存储与查找

我们需要让缩短后的 URL 在多次请求之间持续保存……但该怎么做呢?在生产环境中,我们可以使用 Redis 之类的 NoSQL 数据存储。由于我们的目标是玩玩 Rocket 并学习一些 Rust,所以这里只使用内存存储。

Rocket 有一个名为 managed state(托管状态) 的功能。在我们的例子中,我们想要管理一个 URL 存储库repository)。

首先,创建一个名为 src/repository.rs 的文件:

use std::collections::HashMap;
use shortener::Shortener;

pub struct Repository {
    urls: HashMap<String, String>,
    shortener: Shortener,
}

impl Repository {
    pub fn new() -> Repository {
        Repository {
            urls: HashMap::new(),
            shortener: Shortener::new(),
        }
    }

    pub fn store(&mut self, url: &str) -> String {
        let id = self.shortener.next_id();
        self.urls.insert(id.to_string(), url.to_string());
        id
    }

    pub fn lookup(&self, id: &str) -> Option<&String> {
        self.urls.get(id)
    }
}

在这个模块中,我们首先从标准库导入 HashMap 的实现。还要包含 shortener::Shortener;,它会在下一步帮助我们缩短 URL。现在不用太在意这一点。按照惯例,我们实现一个 new() 方法,用来创建一个带有空 HashMap 和新 ShortenerRepository 结构体。此外,我们还有两个方法:storelookup

store 接受一个 URL,并将其写入内存中的 HashMap 存储。它使用尚未定义的缩短器来创建唯一 ID,并返回该条目的缩短 ID。lookup 从存储中获取给定 ID,并将其作为 Option 返回。如果找到该 ID,返回值就是 Some(url);如果没有匹配项,则返回 None

注意,我们使用 to_string() 方法将字符串切片(&str)转换为 String。这样就不必处理 lifetimes(生命周期)。作为初学者,不用对它们想得太复杂。

补充说明(可以放心跳过)

一位经验丰富的(Rust)开发者™可能会在这里采取一些不同做法。你注意到存储库与缩短器之间的紧密耦合了吗?在生产系统中,RepositoryShortener 可能只是 trait 的具体实现(trait 有点像其他语言中的接口,但功能更强大)。例如,Repository 可以实现一个 Cache trait:

trait Cache {
    // Store an entry and return an ID
    fn store(&mut self, data: &str) -> String;
    // Look up a previously stored entry
    fn lookup(&self, id: &str) -> Option<&String>;
}

这样我们就实现了清晰的关注点分离,也可以轻松切换到其他实现(例如 RedisCache)。此外,还可以使用 MockRepository 来简化测试。Shortener 也是一样。

除此之外,你可能还想使用 Into trait,让 store 的参数同时支持 &strString

pub fn store<T: Into<String>>(&mut self, url: T) -> String {
		let id = self.shortener.shorten(url);
		self.urls.insert(id.to_owned(), url.into());
		id
}

如果你对此感到好奇,可以阅读 Herman J. Radtke III(赫尔曼·J·拉德克三世)的这篇文章。现在,还是让我们保持简单。

真正缩短 URL

接下来实现 URL 缩短器本身。你可能会惊讶于整个 Web 上关于 URL 缩短的相关文章竟然这么多。一种常见方法是使用 base 62 转换创建短 URL

继续查找后,我发现了一个叫作 harsh 的小巧 crate,正好满足需求。它可以根据输入字符串创建哈希 ID。

要使用 harsh,我们把它添加到 Cargo.toml 的依赖部分:

harsh = "0.1.2"

接着,将 crate 添加到 main.rs 的顶部:

extern crate harsh;

创建一个名为 src/shortener.rs 的新文件,并写入以下内容:

use harsh::{Harsh, HarshBuilder};

pub struct Shortener {
    id: u64,
    generator: Harsh,
}

impl Shortener {
    pub fn new() -> Shortener {
        let harsh = HarshBuilder::new().init().unwrap();
        Shortener {
            id: 0,
            generator: harsh,
        }
    }

    pub fn next_id(&mut self) -> String {
        let hashed = self.generator.encode(&[self.id]).unwrap();
        self.id += 1;
        hashed
    }
}

通过 use harsh::{Harsh, HarshBuilder};,我们将所需的结构体引入当前作用域。然后定义自己的 Shortener 结构体,将 Harsh 封装起来。它有两个字段:id 存储下一个用于缩短的 ID。(由于不会有负数 ID,所以这里使用无符号整数。)另一个字段是生成器本身,我们使用 Harsh 表示它。借助 HarshBuilder,你可以进行许多灵活的设置,例如为 ID 设置自定义字母表。现在这样就够了;如需更多信息,请查看官方文档。通过 next_id,我们可以为 URL 获取一个新的 String ID。

如你所见,我们没有把 URL 传给 next_id。这意味着我们实际上什么也没有缩短。我们只是创建了一个短而唯一的 ID。这是因为大多数哈希算法生成的 URL 都相当,而拥有短 URL 才是这一切的核心目的。

组装起来

这样,我们的缩短器和存储库就完成了。还需要再次调整 src/main.rs,以便使用它们。

事情从这里开始变得有点棘手。

我得承认,这里让我费了一番功夫。主要是因为我以前不习惯多线程请求处理。在 Python 或 PHP 中,你不需要考虑共享可变访问。

起初,我的 main.rs 中有下面这段代码:

#[get("/<url>")]
fn store(repo: State<Repository>, url: &str) {
    repo.store(url);
}

fn main() {
    rocket::ignite().manage(Repository::new())
                    .mount("/store", routes![store])
                    .launch();
}

State 是 Rocket 内置的跨请求保存数据方式。只要通过 manage() 告诉它哪些内容属于应用状态,Rocket 就会自动将其注入路由。

但编译器不答应:

error: cannot borrow immutable borrowed content as mutable
  --> src/main.rs
   |
   |     repo.store(url);
   |     ^^^^ cannot borrow as mutable

现在回头看,一切都很合理:如果两个请求同时想要修改我们的存储库,会发生什么?Rust 在这里阻止了竞态条件!哎呀。不过话说回来,这条错误消息本可以对用户更友好一些。

幸运的是,Rocket 的创建者 Sergio Benitez(塞尔吉奥·贝尼特斯)Rocket IRC 频道 上帮助了我(再次感谢!)。解决办法是将存储库放在 Mutex(互斥锁) 后面。

下面是完整的 src/main.rs

#![feature(plugin, custom_derive)]
#![plugin(rocket_codegen)]

extern crate rocket;
extern crate harsh;

use std::sync::RwLock;
use rocket::State;
use rocket::request::Form;
use rocket::response::Redirect;

mod repository;
mod shortener;
use repository::Repository;

#[derive(FromForm)]
struct Url {
    url: String,
}

#[get("/<id>")]
fn lookup(repo: State<RwLock<Repository>>, id: &str) -> Result<Redirect, &'static str> {
    match repo.read().unwrap().lookup(id) {
        Some(url) => Ok(Redirect::permanent(url)),
        _ => Err("Requested ID was not found.")
    }
}

#[post("/", data = "<url_form>")]
fn shorten(repo: State<RwLock<Repository>>, url_form: Form<Url>) -> Result<String, String> {
    let ref url = url_form.get().url;
    let mut repo = repo.write().unwrap();
    let id = repo.store(&url);
    Ok(id.to_string())
}

fn main() {
    rocket::ignite().manage(RwLock::new(Repository::new()))
                    .mount("/", routes![lookup, shorten])
                    .launch();
}

如你所见,这里使用了 std::sync::RwLock(读写锁),以保护我们的存储库免受共享可变访问的影响。这种锁允许任意数量的读取者同时访问,或者同时最多允许一个写入者。它让代码变得有些难读,因为每次想访问存储库时,都必须先调用 readwrite 方法。

lookup 方法中可以看到,我们现在返回的是 Result 类型。它有两种情况:如果在存储库中找到 ID,就返回 Ok(Redirect::permanent(url)),由它负责重定向。如果找不到 ID,就返回一个 Error

shorten 方法中,我们将请求从 get 切换成了 post。这样做的好处是,不需要处理 URL 编码。我们只需创建一个 Url 结构体,并为其派生 FromForm,它会替我们处理反序列化。真方便!

完成了。再次启动服务,试用一下!

cargo run

现在,在新窗口中可以存储第一个 URL:

curl --data "url=https://www.endler.dev" https://localhost:8000/

我们会得到一个 ID,可以用它再次获取该 URL。我的例子中得到的是 gY。在浏览器中打开 https://localhost:8000/gY,你应该会被重定向到我的主页。

总结

Rocket 提供了出色的文档和优秀的社区。它确实让人感觉像是一个符合 Rustlang 惯用风格的 Web 框架。

希望你在摆弄 Rocket 的过程中玩得开心。
你可以在 Github 上找到完整的示例代码

原文由 Matthias Endler 发布

本文章由 openai/gpt-5.6-luna 进行翻译