はじめに
まだギリギリ新卒1年目の中井改めstatiolakeです。エンジニアブログではRust関連の投稿をさせていただいたりしていますが、改めましてRust大好きマンです。よろしくお願いします。
さて、実は私もこのたびのエンジニア定例合宿に参加させていただき、チーム†skyscraper†としてオリンピックを盛り上げるサービスを作るなどしました。チーム内では私はバックエンド開発グループ(総勢1名)に所属していました。せっかくなので、記憶が新しいうちに(といっても一ヶ月は経ってしまったのですが)どういう感じだったのかをつらつらと書き残しておこうかと思います。
エンジニア定例合宿ってなんぞ? †skyscraper†って誰? という方は、以下の投稿を先にご覧ください!
- エンジニア定例とは: https://engineering.nifty.co.jp/blog/25125
- †skyscraper†の制作物: https://engineering.nifty.co.jp/blog/25226
チームの開発体制について
改めて、私たちのチームの開発物の構成はこのようになっていました。
フロントエンドにSvelteを採用し、バックエンドにはRustを採用しています。Rust側がAPIを提供し、フロントからはそれを呼び出してJSONでレスポンスを受けるという構造です。
ふたを開けてみると、他のチームではこのように明確にフロントエンドとバックエンドを分離しているところはなかったように思います。確かにツールや言語も増えますし、APIのインターフェースなど、短期的には考えることが少し増えますよね。そういう意味では、この構成でハッカソンを走りきれたことには達成感もあります。ただ、開発中も完成しない危機感を感じることは全くありませんでした。それはフロントエンドチームの技術力の高さと、実際に走りきるための色々な工夫があったからこそだと思っています。
フロントエンドチームの技術力の高さ
まず最初に断っておきたいのは、これから書き残す諸々の工夫がしっかり効果を発揮したのはフロントエンドチームの技術力の高さゆえです。バックエンド側として私が決めたAPIの仕様をいい感じにエスパーして勝手に使ってくれましたし、APIの新しい実装が出来上がったときもすぐチェックしてもらえました。とにかく余裕をもって作業を進めてくれましたおかげで、バックエンド側も次へ次へとスムーズに進んでいきました。これめちゃくちゃやりやすかったです。ありがとうございます。
走り切るための工夫
さて、チーム側の参加記では、次の点を開発における工夫に挙げさせてもらいました。
- フロントエンドとバックエンドを効率よく完全に分業する。
- フロントエンド側のUXにかなりの比重を置き、粗の少ないユーザー体験を作る。
- 「最低限の機能」を十分小さく定義し、動作する状態をキープしながら拡張を続ける。
この記事では、1つ目の「効率よく完全に分業する」点、そして3つ目の「動作する状態をキープしながら拡張を続ける」点について、自分が考えたことだったり貢献した部分だったりを書き残そうと思います。2つ目の部分は走り切るための工夫というよりはクオリティのための工夫で、少しでも理想形に近い完成度でゴールするためという意味合いが強いためです。
ちなみに、ここでいう最低限の機能というのは「デモで動作が破綻しない程度にとりあえず動く状態のもの」という意味合いです。例えばデモでちゃんとしたものに見えるとか、はたまたお客様へのリリースに耐える品質であるとか、そういう「担保したい品質のボーダーライン」的な意味では考えていませんでした。むしろ完全に開発の都合側の概念で、短期間の開発で品質を最大化するために、1ステップ1ステップのギャップを最小化しようとした、という意味で捉えていただけると幸いです。
工夫1: 完成像の雰囲気と開発の流れを全員が完全に理解しておく
これはまあ合宿が始まる前の話ですが、事前のアイデア出しの段階で3人の中の完成像を完全に一致させるように話し合いを重ねました。合わせて全体の開発の流れも全員で共有していました。以下はその話し合い中のメモです。
求める機能全体をいい感じの小さい機能に分割し、実装優先順位を決めて番号を振りました。当日は番号順にできるところまで進めるという方針を取りました。実際に進んだのは4まで、5がベータ版として実装されていたくらいです。でも、仮に最初から4まで一気に完成させようとしていたら多分うまくいかなかったと思います。見せられるものがなくて焦ってしまったり、進捗が合わなくてつなぎこみに失敗したりするからです。
そして、機能を分割した後に、各機能の具体的な内容をすり合わせていきました。遷移はこんな感じ、画面構成はこんな感じで、こういう機能は存在しないので考えなくて良くて、… などの内容です。そのおかげで、完全に分かれて作業してもAPIの機能に関する認識が一致し、つなぎこみに失敗することはありませんでした。
工夫2 重たい仕組みを必須要件から削除する
我々が普段見るWebサービスのほとんどは、だいたいログイン機能があり、何かしらデータを送信する機能があって、それを閲覧表示する機能があります。これらの機能のためにはデータを保存する仕組み、データベースが必ず必要になります。そしてログイン機能を実装するためには、ログイン画面やユーザー登録画面を実装する必要があるでしょう。
データベース、新しい画面、認証、ログイン処理、これらはサービスの実装の中でかなりのウェイトを占めてしまいます。私たちのように経験が浅いとなおさらです。これらを必須機能に要求してしまうと、いつまでたっても最初の機能するバージョンが完成しないということになりかねません。
今回私たちが取った戦略は、これらの重たい要素を極力排除することです。
私たちの画面にはログイン画面のような画面があります。しかし、これは厳密にはログイン画面ではありません。ユーザー登録がないからです。
入力された名前と秘密の言葉は、適当な文字列に加工され、これをシード値としてビンゴカードを生成します。生成されたビンゴカードを保存することすらしません。毎回生成し直します。このようにすると、利用者についてサーバー側で保存しておくべき情報は何もありません。
当初は、ビンゴカードのマス目をクリックして自分で開ける操作ができるようにするとか、ビンゴカードのマス目を自分で変更して自分好みのカードを作るなどの機能も想定されていました。これらはデータベースが必要になるという理由でコア機能としては見送りました。それくらい軽量化にはこだわったつもりです。
ここまで仕様を簡略化すると、フロントエンド側はこのログイン風画面とビンゴカードそのものの画面だけを作成すれば完成します。画面の遷移も「ボタンを押したらビンゴカードに移動する」の一言で済んでしまいます。残った時間をすべて、いちばん大切なビンゴカードの画面に費やすことができるようになります。
一方のバックエンド側も、データベースのテーブル設計・モデル設計をスキップし、接続周りの様々な面倒からも開放され、ビンゴカード生成ロジックに集中することができます。
ちなみに、最終的には一応データベースも利用しています。ある時点での試合結果の情報がないと、どのマスが空いているかを決定できないからです。一応ここでもデータベースを回避するために、デモ用に適当なAPIを生やしてインメモリでごまかすという逃げ道を考えてはいました。デモだけであればこれで十分で、小さく作る次の一手としてはこの歩幅でも良かったと思います。ただおかげさまで十分な時間的余裕があったので、順当にデータベースをセットアップすることができました。まあ案の定、色々と時間を溶かしてしまいましたね。でも焦りはありませんでした。常に見せられるものができている状況というのは本当に開発しやすいです。
工夫3: APIの仕様を何よりも一番最初に決め、モックを作る
さて、いい感じに成果物の要件について思いを巡らせることができたら、次は実装です。スムーズな開発のため、APIのインターフェースとモックをなるべく早く決定しなくてはなりません。事前にある程度認識を固めてあるとはいえ、プロパティ名まで含めた具体的なデータ形式があったほうがフロントエンド側が気にすることが減ります。ここはスピードが重要です。
注意することは、後々の変更にも耐える、なるべく破壊的変更を起こさないインターフェースにすることです。そもそも最初から完璧なインターフェースが設計できればそれに越したことはないのかもしれませんが、短期の開発、アジャイル開発では、仕様を素早く確定してしまうことのほうがはるかに重要です。足りない情報はインクリメンタルに修正していけばよいからです。しかしだからといって、APIを修正するたびに既存のコードを書き換えなければいけなくなってしまっては大問題です。
例えば、私が最初に考えていたビンゴのマスのレスポンスの構造はこのようなものでした。
1 2 3 4 5 |
{ "sport": "Archery", "event": "Mixed Team", "team": "JPN" } |
競技、種目、チーム名、この3つでひとマスが表せるので、最初の機能としてはこれで十分です。しかし、もしかしたらそれぞれのプロパティに後々追加の情報を足したくなるかもしれません。そのようなとき、今まで文字列だったものがオブジェクトになってしまうと困ったことになります。
少し考えて、代わりに下のようなインターフェースで決定しました。
1 2 3 4 5 6 7 8 9 10 11 |
{ "sport": { "name": "Archery" }, "event": { "name": "Mixed Team" }, "team": { "ioc": "JPN" } } |
一見冗長なようですが、これで例えばスポーツ名の日本語名を足したくなっても name_ja
のような項目を別に足すだけで済み、その時に既存のコードには影響を与えません。
実際、こうしておいてよかったです。開発中、チームの詳細情報を横に補足として表示するというアイデアが出て、最終的には次のようなレスポンスになったからです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "sport": { "name": "Archery" }, "event": { "name": "Mixed Team" }, "team": { "ioc": "JPN", "country_ja": "日本", "country_en": "Japan", "first_olympics": "1912", "remarks": "1956年冬・1960年夏 : GIA / 1960年冬 : JAP" } } |
アジャイルな開発では、次の機能に取り掛かるたびに必ずAPIの改修が発生します。その時に一度も破壊的変更を起こさないで済んだことも、私たちの開発がスムーズに進んだ理由だと思います。
工夫4: Rustで実装する
あー待ってください。閉じないで。Rust大好きマンとして少しだけ語らせてください。
今回バックエンドにRustを選択したのにはいくつか理由があります。まず自分が好きだからというのが9割。そしてビンゴカードの生成処理のような重たい処理をリクエストごとに走らせる必要があったことが0.5割。バグを起こしづらいことが0.5割です。すなわち、上述の工夫を可能にし、スムーズな開発を裏で支えてくれたのがRustなわけです。
今回、データベースをなるべく排除した以上、ビンゴカードの生成処理を毎回走らせることが必要になってきます。いや、仮にデータベースに保存していたとしても、初回は必ずこの生成処理が走ってしまいます。そのビンゴカードに対してリーチやビンゴの判定も毎回行うことになります。そこをRustがネイティブバイナリの力を生かして最速で実行してくれました。本当に助かります。
また、Rustがバグを起こしづらいというのは、そこに非常にしっかりと型がついているからです。
たとえば、私が愛してやまないRustの機能の一つにenumがあります。Rustのenumは多くの他の言語のenumとは異なり、構造体的なものを一緒に持つことができます。tagged unionや代数データ型と呼ばれるものですね。今回のビンゴのセルもこのenumの力を多分に発揮しました。具体的には、セルの型は以下のように定義されています。
1 2 3 4 5 6 7 8 9 |
pub enum BingoCell { Free, Normal { sport: SportName, event: EventName, team: TeamIoc, opened_at: Option<i32>, }, } |
これの意味するところは、ビンゴのセルは中央の「Free」マスであるか通常のマスであるかの二通りがあるということです。これにより、Freeマスなのにスポーツやイベント名が設定されているとか、通常マスなのにそれらが設定されていないということはなくなります。
ここで嬉しいのは、コードの他の部分でビンゴのセルを扱うときは必ず条件分岐でどちらの値が入っているかを確認しなければならないことです。FreeかNormalかを確認することなく勝手にsportやeventにアクセスすることはコンパイル時エラーになります。
他にも細かい場所として、opened_atは、そのマスが何ターン目に開いたかを持ちます。もちろんまだ空いていない場合もありますから、これをOption<i32>型とすることで「空いていたらターン数、空いていなければNoneという特別な値」で表現できるようになります。そしてこれも、Noneでないかを確認せずに数字にアクセスすることはできません。プログラマーは、使う箇所で必ずNoneかもしれないことを意識させられることになります。
ちなみに、バグりにくいもう一つの理由として、テストが好きなところにかけるからというのもあります。Rustには組み込まれたテストシステムがあって、書いているファイルにそのままテストを書くことができます。今回もちょこっとだけですがテストを書いたりしました。普通ハッカソンでテストをかくのもなかなか難しいと思うのですが、強みが出ましたね。
リーチやビンゴの判定はビット演算を使ったちょっと複雑なロジックになっているので、以下のように、ちゃんとリーチやビンゴが判定できているかどうかをとりあえずチェックしています。全く網羅性はありませんが…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
// : // : // ここまで普通にBingoCard型の実装とかが書いてある // 唐突にこうやって追加すればその場でテストになってくれる #[cfg(test)] mod tests { use super::*; #[test] fn test_bingo_new() { let mut rng = rand::thread_rng(); let bingo = BingoCard::generate(&mut rng); println!("{:?}", bingo); } #[test] fn test_bingo_check() { let mut rng = rand::thread_rng(); let mut check = |order: &[(usize, usize)]| { let mut card = BingoCard::generate(&mut rng); assert!(!card.is_bingo()); let rest = &order[..order.len() - 2]; let reach_last = order[order.len() - 2]; let bingo_last = order[order.len() - 1]; for &(i, j) in rest { card.open(i, j, 0); assert!(!card.is_reach()); assert!(!card.is_bingo()); } card.open(reach_last.0, reach_last.1, 0); assert!(card.is_reach()); assert!(card.reach_last_cells().get(bingo_last.0, bingo_last.1)); assert!(!card.is_bingo()); card.open(bingo_last.0, bingo_last.1, 0); assert!(!card.is_reach()); assert!(card.is_bingo()); }; check(&[(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]); check(&[(0, 3), (1, 3), (2, 3), (3, 3), (4, 3)]); check(&[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]); check(&[(0, 4), (1, 3), (2, 2), (3, 1), (4, 0)]); } } |
おわりに
この記事では、今回のハッカソンでフロントエンドとバックエンドを完全に分離して作業を進めるために考えた工夫について書き残しました。全員が完全に成果物のイメージを持っていたことで、フロントとバックのすり合わせのための会話はほとんどなかったです。きれいに分割したことでお互いにコンフリクトを起こすことなく自分の責任範囲を実装できました。また、重たい仕組みを排除してできた時間で、フロントエンド側は一生懸命デザインに凝ってくれました。自分もそれに横から口出ししたりして、本当にクリエイティブな時間だったと思います。とてつもなく楽しく、貴重な時間でした。
ところで、振り返ってみると、これらの工夫はこの一年間のOJTを通じて培った知識でもあります。例えば、常に完成品を持っておいて小さな改善を続けるという開発は、ニフティで広く取り入れられているスクラム開発(アジャイル開発)の思想です。何としてもモックを先に作るぞという気概は、ニフティニュースの開発チームにお邪魔していたときに先輩方から学んだことです。サービスやAPIの設計も、色々なチームで設計に携わらせてもらった成果です。改めてこの一年間で多くのことを学んだなと思いました。
さて、もう1ヶ月もしないうちに2年目になるらしいです。これからもまだまだ新人として、たくさんのことを吸収していきたいと思います。一方、自分も先輩になるという側面もあります。来年度の新人に少しでもなにか良い影響を与えられるよう、精進していきます!