Launching a URL Shortener in Rust using Rocket

Matthias Endler

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

原文は Matthias Endler により に公開されました。 このブログを購読する

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

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

要件

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は存在しないため、ここでは符号なし整数を使います)。もう一つのフィールドはgeneratorそのもので、ここではHarshを使います。HarshBuilderを使えば、ID用のカスタムアルファベットを設定するなど、いろいろと凝ったことができます。今はこれで十分ですが、詳しくは公式ドキュメントをチェックしてみてください。next_idでURL用の新しいString型のIDを取得します。

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

つなぎ合わせる

これでshortenerとリポジトリが完成しました。この2つを使えるように、再度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で公開しています

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

コメント