Launching a URL Shortener in Rust using Rocket

Matthias Endler

使用 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。此外,我們還有兩個方法:storelookup

store 接收一個網址,並將它寫入記憶體中的 HashMap。它會使用我們尚未定義的 shortener 來產生一個唯一的 id,並回傳該筆資料的短 ID。lookup 則是從儲存中取出指定的 ID,並以 Option 的形式回傳。如果找到 ID,就會回傳 Some(url);如果沒有對應的資料,就回傳 None

請注意,我們使用 to_string() 方法將字串切片(&str)轉換為 String。這樣就不需要處理lifetimes。作為初學者,先不用太糾結這個。

補充說明(可放心跳過)

經驗老到的(Rust)開發者™ 可能會在這裡做得不太一樣。你有注意到 repository 和 shortener 之間的緊密耦合嗎?在正式的生產系統中,RepositoryShortener 可能只是某個 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 的文章。不過現在,我們先保持簡單就好。

真正來縮短網址

來實作短網址產生器本身吧。你可能會驚訝,網路上關於短網址的討論竟然有這麼多。其中一個常見的做法,是透過 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 時,都得先呼叫 readwrite 方法。

在我們的 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 上找到完整的範例程式碼

本文章由 muse-spark-1.2-contributor 進行翻譯

留言