使用 Rust 與 Rocket 打造 URL 短網址服務
原文由 Matthias Endler 于 發布,訂閱此部落格
在面試中,一個常見的系統設計題目就是畫出短網址服務的軟體架構(可以想成是做一個 bit.ly 的分身)。剛好我最近在把玩 Rocket——一個 Rust 的網頁框架——何不就來試試看呢?
需求
短網址服務主要有兩個職責:
- 為較長的網址產生一個短網址(廢話!)。
- 當短網址被請求時,重新導向至原本的長網址。
我們就把這個服務叫做 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,並掛載(mount)我們這兩個路由。這樣它們才會對外開放。如果你想了解更深入的細節,可以參考官方 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 有一個叫做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;,它會在下一步幫我們縮短網址。先不用太在意它。按照慣例,我們實作了一個 new() 方法來建立 Repository 結構,裡面包含一個空的 HashMap 和一個新的 Shortener。此外,我們還有兩個方法:store 和 lookup。
store 接收一個網址,並將它寫入記憶體中的 HashMap。它會使用我們尚未定義的 shortener 來產生一個唯一的 id,並回傳該筆資料的短 ID。lookup 則是從儲存中取出指定的 ID,並以 Option 的形式回傳。如果找到 ID,就會回傳 Some(url);如果沒有對應的資料,就回傳 None。
請注意,我們使用 to_string() 方法將字串切片(&str)轉換為 String。這樣就不需要處理lifetimes。作為初學者,先不用太糾結這個。
補充說明(可放心跳過)
經驗老到的(Rust)開發者™ 可能會在這裡做得不太一樣。你有注意到 repository 和 shortener 之間的緊密耦合嗎?在正式的生產系統中,Repository 和 Shortener 可能只是某個 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 的文章。不過現在,我們先保持簡單就好。
真正來縮短網址
來實作短網址產生器本身吧。你可能會驚訝,網路上關於短網址的討論竟然有這麼多。其中一個常見的做法,是透過 base 62 轉換來產生短網址。
再多找了一下之後,我發現了一個很棒的小 crate,叫做 harsh,它完全符合我們的需求。它可以從輸入的字串產生雜湊 id。
要使用 harsh,我們把它加到 Cargo.toml 的 dependencies 區段:
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,所以我們使用無號整數。)另一個欄位是 generator 本身,我們使用 Harsh。透過 HarshBuilder 你可以做很多花俏的設定,例如為 id 設定自訂的字母表。現在這樣就夠用了,如果想了解更多,可以查看官方文件。透過 next_id,我們就能為網址取得一個新的 String id。
如你所見,我們並沒有把網址傳給 next_id。也就是說,我們其實根本沒有縮短任何東西。我們只是產生一個短小且唯一的 ID。這是因為大多數雜湊演算法產生的網址其實都相當長,而短網址的重點本來就是要短嘛。
串起來
短網址產生器和儲存庫都完成了。接下來我們要再次修改 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事後想想,這其實很有道理:如果有兩個請求同時想要修改我們的 repository,會發生什麼事?Rust 在這裡幫我們避免了一次競態條件!天啊。話說回來,錯誤訊息要是能再友善一點就好了。
幸好,Sergio Benitez(Rocket 的作者)在 Rocket IRC 頻道上幫了我一把(再次感謝!)。解決辦法就是把 repository 放到 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 來保護我們的 repository,避免共享可變存取的問題。這種鎖允許同時有多個讀取者,或是同一時間只有一個寫入者。這會讓程式碼變得稍微難讀一點,因為每當我們想存取 repository 時,都得先呼叫 read 和 write 方法。
在我們的 lookup 方法中,你可以看到我們現在回傳的是 Result 型別。它有兩種情況:如果在 repository 中找到 id,我們就回傳 Ok(Redirect::permanent(url)),它會幫我們處理重新導向。如果找不到 id,我們就回傳一個 Error。
在我們的 shorten 方法中,我們從 get 改成了 post 請求。這樣的好處是,我們就不用處理 URL 編碼了。我們只需要建立一個 Url 結構,並為它衍生 FromForm,它就會幫我們處理反序列化。真方便!
大功告成。讓我們再次啟動服務來試試看吧!
cargo run開一個新視窗,我們現在可以來儲存第一個網址了:
curl --data "url=https://www.endler.dev" https://localhost:8000/我們會拿回一個 ID,之後就可以用它來取回網址。以我的情況來說,回傳的是 gY。把瀏覽器導向 https://localhost:8000/gY,你應該就會被重新導向到我的首頁。
總結
Rocket 提供了非常棒的文件和很棒的社群。它真的讓人感覺是一個符合 Rust 慣用風格的網頁框架。
希望你在把玩 Rocket 的過程中玩得愉快。
你可以在 Github 上找到完整的範例程式碼。
隨機一篇部落格
留言
登入後參與討論