Launching a URL Shortener in Rust using Rocket

Matthias Endler

使用 Rust 和 Rocket 实现一个 URL 短链服务

原文由 Matthias Endler 发布,订阅该博客

面试中常见的系统设计题之一,就是勾勒一个 URL 短链服务的软件架构(你可以把它理解为一个 bit.ly 克隆版)。正好最近在把玩Rocket——一个 Rust 的 Web 框架——何不拿它来试试?

一枚在太空中飞行的火箭
一枚在太空中飞行的火箭

需求

URL 短链服务主要有两个职责:

  • 为长链接创建一个短链接(这还用说!)。
  • 当请求短链接时,重定向到对应的长链接。

我们把这个服务叫做 rust.ly悄悄提示:写这篇文章时这个域名还能注册……)。

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

cargo new --bin rustly

接着,在 Cargo.toml 中添加 Rocket:

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

注意:你很可能需要获取最新版本的 Rocket。否则,你可能会看到一些……颇为“精彩”的报错信息。在 crates.io 上可以找到最新版本。

由于 Rocket 需要用到 Rust 最前沿的特性,我们得使用较新的 nightly 版本。Rustup 提供了在 stable 和 nightly 之间轻松切换的方法。

🤔 也许现在已经不再需要 Nightly 版 Rust 了。有人在非 Nightly 下试过吗?欢迎反馈。

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;

我们为服务定义了两个路由。它们都会响应 GET 请求。这是通过给函数添加一个名为 get属性来实现的。该属性还可以接受额外的参数。在我们的例子里,我们为 lookup 接口定义了一个 id 变量,为 shorten 接口定义了一个 url 变量。这两个变量都是 Unicode 字符串切片。由于 Rust 对 Unicode 有着出色的支持,我们特意用一个漂亮的 emoji 来回应,炫耀一下。🕶

最后,我们需要一个 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!

目前为止一切顺利。

数据存储与查询

我们需要在多次请求之间保存这些短链……但该怎么做呢?在生产环境中,我们可以使用像 Redis 这样的 NoSQL 存储。而既然我们的目标是把玩 Rocket、学习 Rust,这里就简单地使用内存存储好了。

Rocket 有一项名为托管状态的功能。在这里,我们想要管理一个 URL 的仓库。

首先,创建一个名为 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 存储。它会调用我们尚未定义的 shortener 来生成一个唯一的 id,并返回该条目的短 ID。lookup 则根据给定的 ID 从存储中查找,并以 Option 的形式返回结果。如果找到对应的 ID,就返回 Some(url);如果没有匹配项,则返回 None

注意,我们使用 to_string() 方法将字符串切片(&str)转换为 String。这样就不用去处理生命周期了。作为初学者,暂时不必在这上面钻得太深。

补充说明(可放心跳过)

经验丰富的(Rust)开发者™可能会在这里做一些不同的处理。注意到 repository 和 shortener 之间紧密的耦合了吗?在生产系统中,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 的这篇文章。现在,我们还是保持简单。

真正实现短链接生成

接下来来实现 URL 缩短器本身。你可能会惊讶,网上关于 URL 缩短已经有大量的讨论。一种常见的做法是使用 base 62 转换来创建短链接

四处搜寻之后,我发现了一个小巧好用的 crate,叫 harsh,正好符合需求。它可以根据输入字符串生成一个哈希 ID。

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

harsh = "0.1.2"

接着,在 main.rs 的顶部引入这个 crate:

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 往往相当,而短链接的核心恰恰就是要短。

串联起来

短链接生成器和仓库都已完成。接下来我们需要再次修改 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 在这里阻止了一次竞态条件!好险。不过也得承认,报错信息本可以更友好一些。

好在,Sergio Benitez(Rocket 的作者)在 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)),它会负责处理重定向;如果找不到,就返回一个 Error

shorten 方法中,我们从 get 请求改为了 post 请求。这样做的好处是我们不必再处理 URL 编码。我们只需创建一个 Url 结构体并为其派生FromForm,它就会帮我们处理反序列化。真不错!

大功告成。再来启动服务试试看!

cargo run

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

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

我们会得到一个 ID,之后可以用它来取回对应的 URL。在我的例子中,这个 ID 是 gY。用浏览器访问 https://localhost:8000/gY,你就会被重定向到我的主页。

总结

Rocket 提供了出色的文档和活跃的社区。它确实给人一种符合 Rust 习惯的 Web 框架的感觉。

希望你在把玩 Rocket 的过程中也收获了乐趣。
你可以在 Github 上找到完整的示例代码

本文章由 muse-spark-1.2-contributor 进行翻译

评论