Launching a URL Shortener in Rust using Rocket

Matthias Endler

使用 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 與全新 ShortenerRepository 結構。另外,我們有兩個方法:storelookup

store 接收一個 URL 並將其寫入記憶體中的 HashMap 儲存區。它使用我們尚未定義的 shortener 來產生唯一的 id,並回傳該筆資料的縮短 ID。lookup 則從儲存區中取得指定的 ID,並以 Option 的形式回傳。如果找到 ID,回傳值會是 Some(url);若無相符項目,則會回傳 None

請注意,我們使用 to_string() 方法將字串切片(&str)轉換為 String。這樣我們就不需要處理生命週期。作為初學者,先不用對它們想得太深入。

補充說明(可安心跳過)

經驗老道的(Rust)開發者™ 可能會在這裡採取一些不同的做法。你有注意到 repository 與 shortener 之間的高度耦合嗎?在正式的系統中,RepositoryShortener 可能只是 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 的參數同時支援 &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(赫曼·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 時,都必須先呼叫 readwrite 方法。

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

原文由 Matthias Endler 發布

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