Launching a URL Shortener in Rust using Rocket

Matthias Endler

Rocketを使ってRustでURL短縮サービスを作る

面接でよく出るシステムデザイン課題のひとつに、URL短縮サービス(いわゆる bit.ly のクローン)のソフトウェアアーキテクチャを設計するというものがあります。ちょうど Rust のウェブフレームワークである Rocket で遊んでいたところだったので、試しに作ってみることにしました。

宇宙を旅するロケット
宇宙を旅するロケット

要件

URL短縮サービスには、主に2つの役割があります。

  • 長いURLから短いURLを生成すること(まあ、当たり前ですが)。
  • 短いリンクがリクエストされたときに、元の長いリンクへリダイレクトすること。

今回作るサービスは 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 クレートを使っています。

rocket ライブラリをスコープに取り込むために、extern crate rocket; と記述します。

サービス用に2つのルートを定義しました。どちらのルートも GET リクエストに応答します。
これは関数に get というアトリビュートを付与することで実現します。このアトリビュートは追加の引数を取ることができ、今回は lookup エンドポイント用に id 変数を、shorten エンドポイント用に url 変数を定義しています。どちらの変数も Unicode 文字列スライスです。Rust は素晴らしい Unicode サポートを備えているので、自慢も兼ねて絵文字でレスポンスを返してみます。🕶

最後に、Rocket を起動して2つのルートをマウントする main 関数が必要です。こうすることでルートが公開されます。さらに詳しい内容を知りたい方は、公式 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 の実装をインポートします。さらに、次のステップで URL の短縮に役立つ shortener::Shortener; も取り込みます。今はあまり気にしなくて大丈夫です。慣例に従い、空の HashMap と新しい Shortener を持つ Repository 構造体を生成する new() メソッドを実装します。さらに、storelookup という2つのメソッドを用意します。

store は URL を受け取り、インメモリの HashMap ストレージに書き込みます。まだ定義していない shortener を使って一意な ID を生成し、その短縮 ID を返します。lookup はストレージから指定された ID を取得し、Option として返します。ID が見つかれば戻り値は Some(url) に、見つからなければ None になります。

なお、文字列スライス(&str)を to_string() メソッドで String に変換していることに注目してください。こうすることでライフタイムについて考える必要がなくなります。初心者のうちは、あまり深く考えなくても大丈夫です。

補足(読み飛ばしても構いません)

経験豊富な(Rust)開発者なら、ここは少し違った書き方をするかもしれません。リポジトリと shortener が密結合になっていることにお気づきでしょうか。本番システムであれば、RepositoryShortener は単にトレイト(他言語のインターフェースに少し似ていますが、より強力です)の具体的な実装として切り出すかもしれません。たとえば、RepositoryCache トレイトを実装するといった形です。

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 についても同様です。

さらに、store の引数として &strString の両方をサポートするために、Into トレイトを使いたい場合もあるでしょう。

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 氏によるこちらの記事を読んでみてください。今回はシンプルに進めましょう。

実際に URL を短縮する

いよいよ URL 短縮の本体を実装します。ウェブ上では URL 短縮について驚くほど多くのことが書かれていることに気づくかもしれません。一般的な方法のひとつに、base 62 変換を使って短い URL を生成する方法があります。

いろいろ調べてみたところ、harsh という小さくて素敵なクレートを見つけました。まさに求めていたものです。入力文字列からハッシュ ID を生成してくれます。

harsh を使うために、Cargo.toml の依存関係のセクションに追加します。

harsh = "0.1.2"

次に、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}; で必要な構造体をスコープに取り込みます。続いて Harsh をラップする独自の Shortener 構造体を定義します。この構造体は2つのフィールドを持ちます。id は次に短縮する際の ID を保持します(負の ID は存在しないため、そのために符号なし整数を使います)。もう一方は生成器そのもので、Harsh を使います。HarshBuilder を使えば、ID 用のカスタムアルファベットを設定するなど、いろいろと凝ったことができます。今回はこのままで十分ですが、詳しくは公式ドキュメントをご覧ください。next_id によって、URL 用の新しい String 型の ID を取得します。

ご覧のとおり、next_id には URL を渡していません。つまり、実際には何も短縮していないのです。単に短く一意な ID を生成しているだけです。というのも、多くのハッシュアルゴリズムはかなり長い URLを生成してしまい、短い URL にすること自体がそもそもの目的なのです。

つなぎ合わせる

これで shortener とリポジトリの準備ができました。両者を活用するために、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

今思えば、当然のことです。もし2つのリクエストが同時にリポジトリを変更しようとしたらどうなるでしょうか。Rust がここで競合状態を防いでくれたのです!ヒヤッとしますね。とはいえ、エラーメッセージはもう少し親切でもよかったかもしれませんが。

幸い、Sergio Benitez 氏(Rocket の作者)が 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 を使っています。このタイプのロックは、任意の数の読み手、あるいは同時に1人の書き手だけを許可します。リポジトリにアクセスするたびに readwrite メソッドを呼び出す必要があるため、コードは少し読みにくくなります。

lookup メソッドでは、Result 型を返すようになっていることがわかります。2つのケースがあります。リポジトリ内で ID が見つかれば Ok(Redirect::permanent(url)) を返し、リダイレクトを処理します。ID が見つからなければ Error を返します。

shorten メソッドでは、get から post リクエストに切り替えました。これにより URL エンコーディングを気にする必要がなくなります。Url 構造体を作成し、FromForm を derive するだけで、デシリアライズを自動で処理してくれます。便利ですね!

これで完成です。サービスを再起動して試してみましょう!

cargo run

別のウィンドウで、最初の URL を保存してみます。

curl --data "url=https://www.endler.dev" https://localhost:8000/

何らかの ID が返ってくるので、それを使って URL を再取得できます。私の場合は gY でした。ブラウザで https://localhost:8000/gY にアクセスしてみてください。私のホームページへリダイレクトされるはずです。

まとめ

Rocket は素晴らしいドキュメントと素敵なコミュニティを提供しています。本当に Rust らしいウェブフレームワークだと感じます。

Rocket での遊びを楽しんでいただけたなら幸いです。
完全なサンプルコードは Github で公開しています

原文は Matthias Endler により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。