使用 Rocket 以 Rust 打造 URL 縮短服務
在面試中,常見的系統設計題目之一,就是描繪 URL 縮短服務(可以想成 bit.ly 的複刻版)的軟體架構。既然我最近正在把玩 Rocket——一個 Rust 的網頁框架——何不來試試看呢?
需求
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 了。有人試過不用 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 支援,我們就用一個漂亮的表情符號來炫耀一下。🕶
最後,我們需要一個 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 的儲存庫。
首先,來建立一個名為 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 儲存區。它使用我們尚未定義的 shortener 來產生唯一的 id,並回傳該筆資料的縮短 ID。lookup 則從儲存區中取得指定的 ID,並以 Option 的形式回傳。如果找到 ID,回傳值會是 Some(url);若無相符項目,則會回傳 None。
請注意,我們使用 to_string() 方法將字串切片(&str)轉換為 String。這樣我們就不需要處理生命週期。作為初學者,先不用對它們想得太深入。
補充說明(可安心跳過)
經驗老道的(Rust)開發者™ 可能會在這裡採取一些不同的做法。你有注意到 repository 與 shortener 之間的高度耦合嗎?在正式的系統中,Repository 與 Shortener 可能只是 traits(特徵)(有點像其他語言中的介面,但更強大)的具體實作。舉例來說,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 縮短器本身吧。你可能會驚訝,網路上已經有大量關於 URL 縮短的討論。其中一種常見的做法是使用 base 62 轉換來產生短網址。
再多找了一下之後,我發現了一個名為 harsh 的小巧 crate,正好符合需求。它能從輸入字串產生雜湊 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,我們為此使用無號整數。)另一個欄位是 generator 本身,我們使用 Harsh 作為其型別。透過 HarshBuilder 你可以做很多花俏的事,例如為 id 設定自訂字母表。目前這樣就夠了,若想了解更多,請查看官方文件。透過 next_id,我們為 URL 取得一個新的 String id。
如你所見,我們並沒有將 URL 傳給 next_id。這表示我們實際上並沒有縮短任何東西。我們只是產生一個短小且唯一的 ID。這是因為多數雜湊演算法產生的 URL 都相當長,而擁有短網址正是整個構想的核心。
整合串接
至此,我們已經完成了 shortener 與 repository。我們需要再次調整 src/main.rs 來運用這兩者。
這裡開始就有點棘手了。
我得承認我在這裡卡了一下。主要是因為我還不習慣多執行緒的請求處理。在 Python 或 PHP 中,你不需要考慮 shared-mutable access(共享可變存取)的問題。
#[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在新的視窗中,我們現在可以儲存第一個 URL:
curl --data "url=https://www.endler.dev" https://localhost:8000/我們會收到一個 ID,之後可以用它來取回 URL。在我的例子中,這個 ID 是 gY。將瀏覽器指向 https://localhost:8000/gY,你應該就會被重新導向到我的首頁。
總結
Rocket 提供了非常棒的文件與優秀的社群。它真的讓人感覺是一個符合 Rust 語言慣例的網頁框架。
希望你在把玩 Rocket 的過程中玩得愉快。
你可以在 Github 上找到完整的範例程式碼。
隨機一篇部落格