質問箱クローンをRustで作った話

1年ぐらい前に質問箱(peing.net)を真似て匿名質問サービスを作成しました. これに関して技術的な話と技術的でない話をします.技術的な話というのはRustでWebサービスを作る知見で,技術的でない話というのは質問箱を自分で運用するとどういう感じになるかという知見です.

作ったもの

質問お待ちしてます!!!

ソースは公開しており,気持ち程度にREADMEも書いてあるので,あなたも自分のインスタンスを立てることができます.

背景

当初は "本家質問箱" を使っていたんですが,2018年の5月ぐらいにクッソ動作が重くなってストレスがすごく,また当時はRustでなんか作りたいという気持ちがあり,自前で作ったら快適になりそうだと思って作りました.

技術的な話

仕様策定

私が作った質問箱には以下のような機能を実装しました.

  • 匿名で質問をPOSTする機能
  • 質問投稿の通知をメールで私に投げる機能
  • 回答者(=私)のログイン機能
  • 回答する機能
  • 回答されたときにTwitterにツイートする機能

この質問箱は,1つのサーバーに複数人の回答者を登録するということができないように作っています.つまり,1つのサーバーには回答者は1人(=管理者)しかいません. この方針により,例えばユーザー登録機能を作らなくて良かったりするなど,機能の複雑度が大幅に下がっています.

実装

Rustでサーバーサイドを作りました.RustでWebアプリを作るといいことがあるのかという話はもちろんあるが,そこについてはあまり考えずにやる.俺はRustが書きたかったんだよ!!! まあ真面目な話をすると,速いアプリケーションにしたかったが,Goは宗教上の理由で使えないのでRustになったという側面もあります.

WebフレームワークとしてはRocket,ORMとしてはDieselを使用しました.また,質問文の画像を生成する部分は特に頑張って自作しました.

Webフレームワーク "Rocket" に関する所感

RocketはfastでsecureなWebフレームワークですが,flexibility, usability, type safetyも犠牲にせんぞということだそうです.

rocket.rs

大体こういう感じでエンドポイントが生やせます:

#[get("/hello/<name>/<age>")]
fn hello(name: String, age: u8) -> String {
    format!("Hello, {} year old named {}!", age, name)
}

fn main() {
    rocket::ignite().mount("/", routes![hello]).launch();
}

URLのパラメーターをシュッと関数の引数に入れてくれるのが面白いですね. ここには書いてないですけど,POSTリクエストのbodyをいい感じにパースして構造体に入れてくれたりもします. どちらの場合も,型が合致しないときはマッチする他のエンドポイントを見に行くようになっています.

テンプレートエンジンのサポートがあるので,古典的にHTMLをサーバーサイドで合成して返すこともできるし,もちろんJSONを返してAPIとして使うこともできるようになってます. テンプレートエンジンはTeraというのを使うことができて,こういう感じで書けます*1

<ul>
{% for user in users %}
  <li><a href="{{ user.url }}">{{ user.username }}</a></li>
{% endfor %}
</ul>

このときコントローラはこういう感じになっているとします.

#[derive(Serialize)]
struct User {
    pub url: String,
    pub username: String
}

#[derive(Serialize)]
struct Index {
    pub users: Vec<User>
}

#[get("/")]
fn index() -> Template {
    let users = fetch_users();
    let context = Index { users: users };
    Template::render("index", &context)
}

完全にJinja2リスペクトだというのがわかります*2Template::render 関数には Serialize traitを実装しているものなら何でも context として渡せるようです. 今回は構造体を定義してSerializeをderiveしたものを渡していますが,普通にHashMapとかも渡せます.

このフレームワークは速いのかということに関しては普通に高速だと思います.そもそもドメインロジックが少ないしDB呼び出しも少ないから遅くなりようがないんですが.... ただ,RocketはRustのnightlyコンパイラを前提にしているので,そこがちょっとつらいです. 2ヶ月ぶりに機能を追加しようと思ったらコンパイラのインストールから始まるみたいなのつらくないですか?僕はつらいです.

ORM "Diesel" に関する所感

DieselはRustのORMです.今回は主にmigrationが欲しくて使いました.

diesel.rs

DBアクセスをこんな感じで書ける*3

#[derive(Insertable)]
#[table_name="users"]
struct NewUser<'a> {
    name: &'a str,
    hair_color: Option<&'a str>,
}

let new_users = vec![
    NewUser { name: "Sean", hair_color: Some("Black") },
    NewUser { name: "Gordon", hair_color: None },
];

insert_into(users)
    .values(&new_users)
    .execute(&connection);

あとmigrationがあったり,複雑なクエリが書けるDSLがあったり,型安全だったり,データベースからテーブルの定義を導出してくれたりという感じで結構便利です.

一方で,DSLコンパイル時に生成されるので,ドキュメントが検索しづらいという問題があります.あと導入が若干面倒です. また,Rocketと組み合わせて使うためにはコネクションプールをいい感じに取り扱ってやる必要があって,そこはかなり面倒でした. 具体的には,r2d2-dieselというライブラリがあるので,これとRocketのstateを組みわせることで,Rocketでdieselを使うことができます.

画像の生成

質問文を画像に埋め込んでポストするというのを実現したいというのがあって,これはTwitterの140字制限を回避しつつ質問文を全てツイートに入れたいからですね. つまり,こういう画像を生成したいわけです:

f:id:threetea0407:20190313154853j:plain

この機能はRustのimageというクレートで画像を生成することで実現しました. 素朴に質問文を一文字一文字画像に埋め込んでいます. 文字の折返しもちゃんとやるようになっていて*4,これが一番面倒だった.

また,この部分だけは別のレポジトリに切り出していて,コマンドラインから文字画像を生成できるようになっています.

github.com

こうすると

$ cargo run -- '5000兆円欲しい!!' --brand "ぼくがかんがえたさいきょうの質問サービス" --rgb 00,00,00

こうなる

f:id:threetea0407:20190313161632j:plain

こういう感じで,テキスト,枠色,ロゴなどを自由に変えられるので,自分で質問箱を作りたいという方は使って下さい.

デプロイ

もともとHerokuでやってたんですが,高速化を試みていくとHerokuへのアクセス時間のせいで主に遅くなってるっぽかったので,途中からさくらのVPSに載せ替えました.

Heroku

HerokuにRustのアプリケーションをデプロイするには,これを使っていました.

github.com

Herokuへのアクセス時間が長いのは,FreeプランだとUSにあるサーバーを使うことになるからですね. 金を払えばTokyoリージョンのHerokuも使えるらしいが...

さくらのVPS

フツーに80番ポートでnginxでリクエストを受けて,アプリケーションが待ち受ける別のポートにリダイレクトしています. Dockerとかもほとんど使ってない*5.古き良きWebアプリケーションという感じ.

さくらのVPSは(日本国内からの)アクセス速度が速いです.具体的な数字は忘れたんですが,Herokuからさくらに移行しただけでめっちゃ速くなったので感動した記憶がある.

速度について

速いの?→速いぞ!!!

まずこれがpeing.netのPageSpeed Insightsのスコアです.

f:id:threetea0407:20190313170310p:plain
peing.netのPageSpeed Insightsの結果

そしてこれが私が作った質問サービスのスコアです.

f:id:threetea0407:20190313170246p:plain
私が作った質問箱のPageSpeed Insightsの結果

まずRustで書いてるので速いというのと,あとpeing.netの方は質問の画像を表示しているのですが,こっちは質問文は文章だけを表示しており,画像はTwitterに投稿するときだけ使うようにしています.これらが主に速度に寄与していると思います.

とはいえ,広告なし,利用者1人でやってたら早くなるのは当たり前ですね.

オマケ:技術的でない話

質問してくるユーザーのIPアドレスを保存するようにしているんですが,それによっていくつかわかったことがあります

質問してくるユーザーの数は非常に少ない

このサービスを作成してから大体2900件ぐらいの質問が来てるんですが,これは色んな人からまんべんなく質問が来てるわけではなくて,少数のユーザーからの質問が主であるということがわかっています*6

例えば,質問してきた数が多い順にIPアドレスを並べて質問数の累積和を取ると,上から10個目ぐらいで質問数が75%に達し,35個目ぐらいで90%を超えます. つまり,よく質問する35人で90%以上の質問をしているということになります*7

f:id:threetea0407:20190313174720j:plain
質問数の集計結果(IPアドレスは一応モザイク)

つまり,質問してくるユーザーの数は少なく,一部の熱心なユーザーが大量の質問をポストしてくるという構造がありそうということがわかります.

自演するやつがいる

匿名インターネットで自演は当たり前のことなんですが,綺麗に自演が判明したことがあったので共有します.

一時期女叩きするやつにめっちゃ粘着されてた時期がありました.

この5倍ぐらい質問がPOSTされてたんですが多すぎるので割愛します. 僕は飛んできた質問はなるべく返すようにしているんですが流石ウンザリしてきたタイミングでした. ちなみにこれ全部同じIPアドレスから飛んできた質問です.

このような質問に紛れて,以下のような質問が飛んできました*8

当方女ですが、女の方が悪いです。 当然です。 男の人が優しい人が多い理由として、 男の人は 『他人から良い評価を得られる事に生き甲斐を感じている』 からでしょう。 男の人は周りからの目線をよく気にします。 どう、思われてるかな? モテたい、出世したいなど だから、人に対して優しく出来たり、 だから、義理人情に熱い人が多い →よって、自ずと性格の優しい人が増えるのだと思います。 対して女が性格悪いと言われる理由に 『いかに自分がお姫様扱いされるか』 に生き甲斐を感じれる人が多いからでしょう。 だから大して自分から動きもせずに 楽がしたい、 甘やかされてるから我が儘も言い放題だから その証拠に、 彼氏にしたい人=会話が面白い人でぇ~ 結婚したい人=年収500万以上でぇ~ なんていう私利私欲の極みと言えるゲス、ばっかりですよね。 だから男の人は性格が優しい だから女は全員性格が悪い そう叩かれるのは当然の結果だと思います。 女も良く思われたいなら努力しろ!! 女だからって甘ったれんな!! これを見てる人で自信を持って 『私はそんな事無い!!』 と言い切れるなら、 聞き流してくれて良いですよ。

突然女性を名乗る匿名の人からの質問が飛んできましたね. 「当方女ですが」の破壊力が高いのはさておいて,こんなこと言う女性おるんかいなという気持ちに当然なります*9. そこで質問者のIPアドレスを見ると,この質問は上に貼った女叩きの質問のIPアドレスと同じIPアドレスから投稿されているということがわかりました. 女叩きの中で「自分は男である」と言っているので,これは明らかな自演です.

そのことを回答で指摘したところ,女叩きの質問はピタッと止まりました*10

みなさんも自演するときはプロキシなどを通して身元がばれないようにしましょうね.

追記(2019/03/19)

このブログを公開した後,以下のような質問が来た.

https://observatory.mozilla.org/analyze/reing.kuminecraft.xyzは,ウェブサイトをチェックしてどこを直せばセキュリティが向上するか教えてくれるというサービスらしい. この質問が投げられたときは安全性は F というクッソ低いスコアだったんですが,nginxの設定をいじったりした結果,A+まで改善することができました.わいわい!

f:id:threetea0407:20190319221215p:plain

*1:teraのREADMEから引用

*2:Teraという名前もJinja(神社)に対するTera(寺)ということなのだろう.そういえばTemplateってTemple(寺院)っぽい綴りですね.なんという偶然!

*3:http://diesel.rs/ より引用

*4:禁則処理は実装してないです

*5:このサーバーはArchLinuxなんですが,ArchのパッケージマネージャでPostgreSQLを入れると勝手にバージョンアップされて悲惨なことになるので,PostgreSQLのバージョンを固定するためだけにDockerを使っている.

*6:人にもよると思うが

*7:IPアドレスの数自体は218ぐらいあるので,割と綺麗にパレートの法則を満たしている気がします

*8:もはや質問でもない

*9:これは女性は悪口言わないだろうみたいな話ではなく,同性のことをここまで悪し様に描写して異性を持ち上げるやつおるか?という気持ちでした.最近ではそのような男性・女性はしばしばいるということがわかってきたのでなんやという感じですが...

*10:ちなみにこのIPアドレスはその後もいくつか質問をしているのだが,「明日手術を受けるのですが、怖くてたまりません。何か勇気の出る言葉をください」という質問を最後に質問をしなくなっています.