使用 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 和新 Shortener 的 Repository 结构体。此外,我们还有两个方法:store 和 lookup。
store 接受一个 URL,并将其写入内存中的 HashMap 存储。它使用尚未定义的缩短器来创建唯一 ID,并返回该条目的缩短 ID。lookup 从存储中获取给定 ID,并将其作为 Option 返回。如果找到该 ID,返回值就是 Some(url);如果没有匹配项,则返回 None。
注意,我们使用 to_string() 方法将字符串切片(&str)转换为 String。这样就不必处理 lifetimes(生命周期)。作为初学者,不用对它们想得太复杂。
补充说明(可以放心跳过)
一位经验丰富的(Rust)开发者™可能会在这里采取一些不同做法。你注意到存储库与缩短器之间的紧密耦合了吗?在生产系统中,Repository 和 Shortener 可能只是 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 的参数同时支持 &str 和 String:
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(读写锁),以保护我们的存储库免受共享可变访问的影响。这种锁允许任意数量的读取者同时访问,或者同时最多允许一个写入者。它让代码变得有些难读,因为每次想访问存储库时,都必须先调用 read 和 write 方法。
在 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 上找到完整的示例代码。
随机一篇博客