Webxxpoxxsappi_redoribeこんにちは。スコアサーバー開発チームです。CPCTF2021では、xxpoxx、oribe、sappi_redの3人でスコアサーバーの開発を行いました。サーバーサイドをxxpoxx、oribeが、クライアントサイドをsappi_redが担当しました。この記事では、スコアサーバーの機能紹介、サーバーサイドとクライアントサイドの紹介をしていきます。機能紹介(xxpoxx)スコアサーバーの機能について紹介します。以下のようなサイトを用いて参加者は問題を解いたりランキングを確認したりしていました。一般ユーザー用機能トップページにアクセスすると以下のような画面が表示されます。登録をクリックし、ユーザー名とパスワードを設定して、CPCTFにサインアップすることができます。ログインすると、問題一覧や質問やアナウンスやランキングやビジュアライザ、さらにはマイページを見ることができます。問題一覧を開くと、以下のように問題の一覧を見ることができます。ジャンルフィルタや表示順の変更、正答済みの問題を非表示等の機能も実装されています。それぞれの問題をクリックすると、問題を閲覧、ヒントを解放、またFLAGの提出ができるようになっています。質問では、コンテスト中に生じた問題に関する質問をすることができます。アナウンスでは、コンテスト中の運営からのアナウンスが表示されます。ランキングでは、CPCTF参加者の点数推移やランキング、またその中でも新入生のみの点数推移やランキングを確認することができます。マイページからは、自身の獲得点数の推移や得点、提出した問題の確認やTwitter連携、さらにはWebShellの認証情報の確認やメールアドレスの変更などができるようになっています。以上が、一般ユーザーが利用できる機能でした。アドミンユーザー用機能ここからは、アドミンが利用できる機能を紹介していきます。†アドミン昇格†を行うと、以下のようにアドミンモードでサイトを閲覧することができます。アドミンモードでは、以下のような問題の登録や質問の回答、新規アナウンスの追加を行うことができます。アドミンに昇格したユーザーが問題に解答を行っても、動的配点に影響が出ないようになっていたり、ランキングに含まれないようになっているなど、コンテストに配慮した工夫が施されています。このサイトはダークモードでも閲覧でき、右上のボタンで切り替えることができます。サーバーサイド技術スタックについて(xxpoxx)言語としてはWebアプリ開発勢の母語のような存在になりつつあるGoを採用しました。開発する2人がメインで書ける言語なので特に異論もなく、自然と採用されました。採用したライブラリは以下のものです。EchoGoの軽量Webフレームワーク。traQで使われているので採用。GORMGoのORMライブラリ。traQで使われているので採用。gcacheGoのキャッシュライブラリ。「動的配点にまつわるキャッシュ」で解説。go-qidenticonGoの、アイコンを生成するライブラリ。「初期アイコン生成」で解説。sendgrid-goメール配信サービスであるSendGridをGoから操作するためのライブラリ。部内で用いられているSendGridを扱うために採用。sessions}GoのWebツールキット。visualizerやクライアントサイドにWebSocketを用いて情報を送るため、セッションの管理をするために採用。「他サービスとの連携」を参照。Tokensを実装しているライブラリ。アカウントのアクティベートのために採用。AnacondaGoからTwitterAPIにアクセスするためのライブラリ。Twitter連携のために採用。この他にもテストのためのmockライブラリやuuidのライブラリなども利用しています。実装を進めていくにあたって、様々な機能が必要となりました。それらを実現してるライブラリを採用していくような形で、技術スタックが選択されていきました。使い慣れた言語、ライブラリを用いたことにより、たった2人、1か月の開発でCPCTF2021の開催にこぎ着けることができました。性能について(oribe)競技中には多くのトラフィックが発生しました。インフラチームが用意してくれた監視基盤とデータベース上の情報からその様子の一部を紹介します。一気に登録数が増え、14:30には140人(adminを除くと123人)になりました。その後も途中参加の登録があり、最終的には158人(adminを除くと141人)になりました。HTTPリクエスト数これは5分単位でのHTTPリクエスト数のグラフです。競技時間中は常にそれなりの量のHTTPリクエストが来ました。リクエスト数のピークは競技開始から20分後の13:50頃で、この時間には5分間で10kものリクエストが飛んでいました。素晴らしいことに、サーバー側でトラブルが発生したことを意味するステータスコード500のレスポンスの件数は0件でした。Force的な提出によるものも存在しました。学籍番号を構成する要素の制約はTwitterで適切な検索を行うと得られるのですが、その制約情報にたどり着けなかった何人かの参加者が総当たりで提出したようです。それによりこの問題は提出数が2085に対し正答者が44人、正答率が2.11%という大変「高難易度な」問題となりました。POSTリクエストのピークは14:50前後その頃に行われた同一ユーザーからのフラグ提出どうやらスクリプトを用いた提出が行われたようです。このような集中的なリクエストに対してもアプリケーション負荷、データ整合性の両者共に問題なく捌き切ることができました。後述するクライアントサイドやビジュアライザとの連携にWebSocketを使用していました。競技時間中は200~350程度のコネクションが貼られていました。動的配点を見据えてのテーブル設計(oribe)今回のCPCTFは動的配点を採用していました。問題に正答した際に得られる点数は、他の参加者の解答状況と自身のヒントの開封状況によって決まります。イベント詳細ページより動的配点について大雑把に言うと難易度で基礎点が決まり、解いた人数が多いほど得点が下がりますヒントを開くと自分の得る得点が下がります解いた時点での得点を得るので、早解きをすると高得点が狙えますレベル別には以下の通りです。四捨五入します動的配点の計算を行いやすいよう、下記のようなテーブル構成にしました。正答して得る点数は、以下の3つの情報を取得することで算出できます。その問題の難易度を示すlevelの値(challengesから)すでにその問題を正答した人達のhint_level(answersから)解答時の自身のヒントの開封状況(hintsから)この3つのテーブルを参照すれば、各ユーザーの獲得点数、そして現在のスコアはいつでも正しく得ることができます。ここで1つ問題があります。スコアサーバーにはランキングページがあり、そこでは各ユーザーの現在のスコアだけでなくスコアの変動のグラフも表示しています。このランキングの描画に必要な情報は上述の3つのテーブルを参照すれば得ることができます。そのための処理は大変重いものになってしまいます。ユーザーAの現在のスコアを得るための処理はこのようになります。取得した情報を点数計算式に代入すべての解答の点数の総和を求めるユーザーが正答した問題が多ければ多いほど処理が重くなる、典型的なN+1ですね。全ユーザーのスコアをまとめて取ろうとしたら、ユーザーの数をm、提出済みの解答の数をnとするとO(mn2)になってしまいます。この問題を解決するためにscore_logsというテーブルを追加することにしました。score_logsにはユーザーが正答する度に、新たに得た点数と現在のスコアを保存します。score_logsを追加したことにより、ユーザーの現在のスコアを得る処理は「指定したuser_idかつ直近のcreated_at」のデータを得ると言う大変シンプルなものとなりました。全ユーザーのスコアをまとめて取得するのも、created_atによるフィルターを行うだけですね。スコアの変動の情報もcreated_atでソートするだけで得られます。データベースを用いて値のキャッシュを行うというのは少々邪道かもしれませんが、このテーブルが安定したアプリケーション動作に大きく貢献したことは間違いありません。解答提出からscore_logsに獲得点数を保存するまでの処理はMutexにより排他制御を行い、獲得点数に不整合が発生しないようにしました。ランキングにまつわるキャッシュ(xxpoxx)ランキングページは、score_logテーブルを見て必要な情報をとり、ランキング形式に処理して返すという非常に重い処理がある上、アクセスが集中するという予見がかねてよりありました。これに対して今回のスコアサーバーでは、ランキングにキャッシュを挟むという方法で対策をとっていました。後述されるservice層に存在するGetRanking()メソッドでは、以下のようにしてインメモリキャッシュを挟んでいました。nilキャッシュライブラリとしてgcacheを採用しました。safeであるという特徴などを踏まえて採用しました。この結果、以下のように実際にランキングの取得処理にキャッシュを挟むことで、レスポンスの時間が非常に小さくなっていることが確認できます。CPCTF2021の参加人数は約150人でした。ランキングページに150人がそれぞれ30秒に1回リクエストを送ってくると考えると、1秒につき5回の処理が走ることになります。今回の実装では、正答が送られてきたときにキャッシュを破棄するようにしています。正答の送られてきた間隔はビジュアライザの記録を確認したところ、約5秒に1回でした。キャッシュを破棄したあとに1度だけ生成処理を行うので、5秒につき1回の処理が走っていると考えてよいでしょう。1秒に5回の処理だったものが1秒につき0.2回となり、単純計算で25倍の性能改善ができたことがわかります。コンテスト終了直後など、ランキングページに150人が同時にアクセスした際も、本来ならば1秒につき150回の処理が走ることになりますが、キャッシュのおかげで生成処理を行わずに、キャッシュされている結果を返すだけで捌けるようになりました。このキャッシュのおかげかはわかりませんが、本番の膨大なリクエストでも、サーバーが落ちることなくコンテストを実施できました。他サービスとの連携(oribe)サーバーサイドはクライアントサイドの他に、ビジュアライザとWebShellのサーバーと連携を行っていました。クライアントサイドクライアントサイドからサーバーサイドへのAPIリクエストが行われる他に、サーバーサイドからイベントを配信するためにWebSocketを繋いでいました。配信されるイベントはアナウンスや質問の投稿、問題の点数変動やランキングの更新などで、クライアントはそれらを受けてリアルタイムに描画を行いました。ビジュアライザ誰がどれほどのスコアを獲得したのかをかっこいい演出とともに伝え、参加者だけでなく観戦者をも盛り上げてくれたのがビジュアライザです。ビジュアライザは初期化時にAPIリクエストで現在の情報を取得し、それ以降はサーバーサイドから配信されるWebSocketイベントをトリガーにして描画を行っていました。配信されるイベントはユーザーの登録やスコアの獲得、ランキングの変動と競技残り時間の同期のための情報です。WebShellCPCTFでは自由に使えるLinux環境としてWebShellを用意しました。参加者はマイページでWebShellのアカウントを発行でき、以後はそれを用いてWebShellを利用することができます。WebShellアカウントの乱造や不適切な用途で使用されることをふせぐため、WebShellのアカウントとその認証を管理するアプリケーションへのリクエストはスコアサーバーのサーバーサイドを介してのみ行えるようにしました。マイページで「WebShellを有効化する」や「WebShellの環境のリセット」のボタンを押すとスコアサーバーのサーバーサイドにHTTPリクエストが飛びます。keyと共にWebShellのアプリケーションにリクエストを行い、その結果を元にクライアントへのレスポンスを返します。初期アイコン生成(xxpoxx)今回のスコアサーバーでは、アイコンとして初期アイコン、またはTwitter連携で取得したTwitterのアイコンを用いることができました。このうちの初期アイコンは以下のようなものですこの初期アイコンは、ユーザーの登録時に自動生成されるものとなっています。この初期アイコンの生成には、部内SNSサービスtraQでも用いられているgo-qidenticonを用いています。以下のようなコードで初期アイコンを生成し、保存したURLを返すようにしていました。nil}初期アイコンが並んだランキングを見て、開発者としてはニコニコしていました。初期アイコンが必要となるサービスで、是非利用してみてください。ミニマムなアーキテクチャ(oribe)今回のスコアサーバーの開発では開発期間が短かったこと、来年以降も使えるような保守性の高いものにしたかったことなどから、なるべくシンプルでかつユニットテストを書きやすいアーキテクチャにしようと考えました。最終的には以下のような構成になりました。repositoryはデータベース操作を行う層です。基本的には引数で受け取ったパラメータを用いてのシンプルなCRUD処理のみを行います。serviceはアプリケーションロジックを実装した層です。前述のランキング計算やそのキャッシュといった複雑な実装が必要になる処理はこの層に集約しました。handlerはAPIハンドラを実装した層です。APIリクエストに基づいてrepository、またはserviceのメソッドを呼び出し、最終的なレスポンスを生成します。複雑な処理を要する一部のAPIはserviceのメソッドにロジックの実装を移譲していますが、そうでないAPIの場合はこの層にロジックを書きました。このrepositoryとserviceはinterfaceを挟んで実装するようにしたことで、mockを用いた他の層の実装に依存しないテストを書くことができました。mockの生成にはgomockを使用しました。statusCode)})}このアーキテクチャにしたことによる恩恵が最も大きかったのはserviceの実装でした。前述の通りserviceでは複雑なロジックを担うメソッドを実装しているので、そのメソッド単位のテストで確認できるのは実装作業やデバックに大いに役立ちました。serviceにあるメソッドのいくつかは開発中に要求仕様が変わりましたが、他の層の実装やテストがその影響を受けずに済むというメリットがありました。serviceには正答時の獲得点数を算出するメソッドがありますが、開発期間中にルール上の点数の計算式が何度か変更されたことを受けてロジックの変更をする必要がありました。そのメソッドを呼び出すhandlerメソッドは複数ありますが、適切に層同士の依存関係が分離されているためhandlerメソッドやそのテストを変更する必要は一切ありません。対象である獲得点数算出メソッドとそのテストを変更するだけで、新たな仕様への対応を完了できました。クライアントサイド技術スタックについて(sappi_red)ここ最近はずっとViteを使っているので今回もビルドツールにはViteを使いました。ビルド速いと快適ですね。クライアントは自分(@sappi_red)が一人で書くので、使い慣れていることとできる限りコードを減らしつつ自由度を高めることを念頭に置いて以下の選択をしました。Prismでモックサーバーを立てて利用したり、APIを叩くコードはOpenAPIGenaratorで生成したりしました。他に利用したライブラリはこんな感じです。これは正確にはライブラリではないけれど...。アイコンの表示に使ったこのようにかなりいろいろなライブラリに頼りました。APIの汎用関数のライブラリです。useDarkと言うダークテーマ切り替え用の関数だったり、useNowと言う現在の日時を利用できる関数だったりがあります。使ってみようと思いつつも触れていなかったので、この機会に利用することに決めました。useNowは以下のように開始状態や終了状態かどうかの判定に利用していました。}}実装していて汎用性が高いと感じた二つの関数は、VueUseへのプルリクエストを出しました。}>のようなものにも使えるようにした関数です。/>のように利用できます。}}</script>これは、問題の編集画面をコンポーネントとして切り出すのに使ってました。二つ目はautoResetRefで、以下のようなことができます。set'}これはエラーメッセージ表示に利用してました。利用した関数だと、useVModelやuseLocalStorageが便利でした。CSS互換のオンデマンドCSSフレームワークです。開発中は必要になったスタイルを必要なったタイミングで生成して挿入し、ビルド時は必要になったスタイルだけを生成してバンドルするという挙動をします。開発サーバーの起動時間を短縮することとバンドルサイズを小さくすることができます。0.001remのクラス)のように事前に定義されていない数値を含んだスタイルを利用することもできます。CSSのリポジトリのディスカッションを見ると書いてあります。CSSを利用するためにvite-plugin-windicssも使っています。CSSに関しても使ってみようと思っていて、ちょうどよかったので利用しました。あと細かいバグ修正のPRを出したりしました。CSSも利用したことがなかったので、クラス名が長くなるのがちょっと気になってたんですが、これはある意味しっくりくるなと感じました。Qiitaという記事で納得しました。]">これは書いてたコードの一部ですが、すごいわかりにくくなってます。(本当はcomputedとしてくくりだしたほうがよいと思いますが、一旦そのことは忘れておいて...)条件分岐が一か所にまとまってるというのはメリットですが、どのプロパティが条件によって変化するのかわかりにくいというのが微妙なところでした。属性でかけるとよさそうな感じするなと思いつつ時間の余裕がなかったので、そのときはそのまま書き進めてました。betaで実装されました。(現在はv3のstableもリリースされてます、ただvite-plugin-windicssのv3対応版はまだbetaです)コンポーネントのpropとややこしいというデメリットはありますが、先述のわかりにくい点はこれを利用すると解消されそうです。今のところ触ってませんが今度触ってみたいと思ってます。UXについて(sappi_red)問題を解くことに集中できるようにすること、問題を解くのを楽しく感じられるようにすることにフォーカスをあてて、仕様を考えてました。いくつか紹介していきます。問題一覧での複数のフィルタと並び替え新しいアナウンスの表示いろいろ実装したのが功を奏して、開催後のアンケートでも使いやすかったとの声が多かったのでよかったです。終わりにCPCTF2021のスコアサーバーはこのブログで紹介した様々な技術・工夫を取り入れて開発しました。有志で集まった開発メンバーでしたが、完成度の高いものを作ることができたと感じています。参加者の皆さんにも満足していただけていたら光栄です。この記事を書いた人xxpoxx雰囲気でプログラミングをやっているこの記事を書いた人sappi_red19B/22M。