feature image

2021年5月19日 | 活動紹介

CPCTF2021を実現させたスコアサーバー

こんにちは。スコアサーバー開発チームです。
CPCTF2021では、xxpoxxoribesappi_redの3人でスコアサーバーの開発を行いました。
サーバーサイドをxxpoxx、oribeが、クライアントサイドをsappi_redが担当しました。
この記事では、スコアサーバーの機能紹介、サーバーサイドとクライアントサイドの紹介をしていきます。

機能紹介(xxpoxx)

スコアサーバーの機能について紹介します。以下のようなサイトを用いて参加者は問題を解いたりランキングを確認したりしていました。

一般ユーザー用機能

トップページにアクセスすると以下のような画面が表示されます。
upload_59d9cc44489a52d509a22cc761329e95-1

登録をクリックし、ユーザー名とパスワードを設定して、CPCTFにサインアップすることができます。
ログインすると、問題一覧や質問やアナウンスやランキングやビジュアライザ、さらにはマイページを見ることができます。
upload_160282c44a25a37a0733f14d73343775

問題一覧を開くと、以下のように問題の一覧を見ることができます。ジャンルフィルタや表示順の変更、正答済みの問題を非表示等の機能も実装されています。
upload_ecd726a8bd5b1bed6ad1d6632f0724a4

それぞれの問題をクリックすると、問題を閲覧、ヒントを解放、またFLAGの提出ができるようになっています。
upload_9c0b3750009fa2f4d7e09186eea31e72

質問では、コンテスト中に生じた問題に関する質問をすることができます。
upload_3b92fb6b35eb41ba56bc387db245f6b7

アナウンスでは、コンテスト中の運営からのアナウンスが表示されます。
upload_81e7c00adad8d170103d7e613b143fe0

ランキングでは、CPCTF参加者の点数推移やランキング、またその中でも新入生のみの点数推移やランキングを確認することができます。

マイページからは、自身の獲得点数の推移や得点、提出した問題の確認やTwitter連携、さらにはWebShellの認証情報の確認やメールアドレスの変更などができるようになっています。
upload_01ec39566cbeccfc59c6d32e30b3c42d

以上が、一般ユーザーが利用できる機能でした。

アドミンユーザー用機能

ここからは、アドミンが利用できる機能を紹介していきます。

†アドミン昇格†を行うと、以下のようにアドミンモードでサイトを閲覧することができます。
upload_5b6b5a8576f9b8e9ad11ebbc8ce1ed21
アドミンモードでは、以下のような問題の登録や質問の回答、新規アナウンスの追加を行うことができます。
upload_09f202641b606c0d3f2e4a0a5cc1978b
upload_d4f9469bdaecaff28ba44f2dcacd85be

また、アドミンに昇格したユーザーが問題に解答を行っても、動的配点に影響が出ないようになっていたり、ランキングに含まれないようになっているなど、コンテストに配慮した工夫が施されています。

さらに、このサイトはダークモードでも閲覧でき、右上のボタンで切り替えることができます。
upload_a36756bc0bb7ae7b9043cbff69770a68

サーバーサイド

技術スタックについて(xxpoxx)

言語としてはWebアプリ開発勢の母語のような存在になりつつあるGoを採用しました。開発する2人がメインで書ける言語なので特に異論もなく、自然と採用されました。
採用したライブラリは以下のものです。

この他にもテストのためのmockライブラリやuuidのライブラリなども利用しています。実装を進めていくにあたって、様々な機能が必要となりました。それらを実現してるライブラリを採用していくような形で、技術スタックが選択されていきました。
使い慣れた言語、ライブラリを用いたことにより、たった2人、1か月の開発でCPCTF2021の開催にこぎ着けることができました。

性能について(oribe)

競技中には多くのトラフィックが発生しました。
インフラチームが用意してくれた監視基盤とデータベース上の情報からその様子の一部を紹介します。

ユーザー数の推移

upload_c2bb31577ab239efe7eed06f4a1a9f7e

ユーザー登録は前日の夜から可能でしたが、当日の10:00の時点では登録数は61人でした
うち17人は運営側のadminユーザーなので、実際に参加する人の登録数は44人ということですね。
競技開始時刻前後で一気に登録数が増え、14:30には140人(adminを除くと123人)になりました。
その後も途中参加の登録があり、最終的には158人(adminを除くと141人)になりました。

HTTPリクエスト数

upload_040a85966a5e184c1d8a8360c0f1ad86

これは5分単位でのHTTPリクエスト数のグラフです。
競技時間中は常にそれなりの量のHTTPリクエストが来ました。
リクエスト数のピークは競技開始から20分後の13:50頃で、この時間には5分間で10kものリクエストが飛んでいました。
また素晴らしいことに、サーバー側でトラブルが発生したことを意味するステータスコード500のレスポンスの件数は0件でした。

この膨大なリクエストの中にはBrute Force的な提出によるものも存在しました。
対象となった問題はこの記事で解説されている「OSINT/Student ID Card」です。
学籍番号を構成する要素の制約はTwitterで適切な検索を行うと得られるのですが、その制約情報にたどり着けなかった何人かの参加者が総当たりで提出したようです。
それによりこの問題は提出数が2085に対し正答者が44人、正答率が2.11%という大変「高難易度な」問題となりました。

upload_146442b1f6a3f993d5935419b7a1146f
POSTリクエストのピークは14:50前後

upload_6c67724764490733acefa40be8fe8b91
その頃に行われた同一ユーザーからのフラグ提出

どうやらスクリプトを用いた提出が行われたようです。
このような集中的なリクエストに対してもアプリケーション負荷、データ整合性の両者共に問題なく捌き切ることができました。

WebSocketのコネクション数

upload_85dc3b3ea80181e4a323c364de6235fa
後述するクライアントサイドやビジュアライザとの連携にWebSocketを使用していました。
競技時間中は200~350程度のコネクションが貼られていました。

動的配点を見据えてのテーブル設計(oribe)

今回のCPCTFは動的配点を採用していました。
問題に正答した際に得られる点数は、他の参加者の解答状況と自身のヒントの開封状況によって決まります。

動的配点の詳細 イベント詳細ページより

動的配点について

大雑把に言うと

  • 難易度で基礎点が決まり、解いた人数が多いほど得点が下がります
  • ヒントを開くと自分の得る得点が下がります
  • 解いた時点での得点を得るので、早解きをすると高得点が狙えます

レベル別には以下の通りです。

難易度 ヒントなし ヒント1個 ヒント2個 ヒント3個
レベル0 10 10 10 10
レベル1 100~80 90~60 60~40 30~10
レベル2 200~160 180~150 120~80 60~20
レベル3 300~240 270~180 180~120 90~30
レベル4 400~320 360~240 240~160 120~40
レベル5 500~400 450~300 300~200 150~50

詳しい計算式
=レベル
満点 :

ヒント0個 : ~
ヒント1個 : ~
ヒント2個 : ~
ヒント3個 : ~

各問題について、

(人 個ヒントを開けている)
としたとき

個ヒントを開いたときの点数は

なお、小数点以下第三位を四捨五入します

動的配点の計算を行いやすいよう、下記のようなテーブル構成にしました。
upload_6856e39d8be97447fd9829a80f51d307

正答して得る点数は、以下の3つの情報を取得することで算出できます。

この3つのテーブルを参照すれば、各ユーザーの獲得点数、そして現在のスコアはいつでも正しく得ることができます。

しかし、ここで1つ問題があります。
スコアサーバーにはランキングページがあり、そこでは各ユーザーの現在のスコアだけでなくスコアの変動のグラフも表示しています。
このランキングの描画に必要な情報は上述の3つのテーブルを参照すれば得ることができます。
が、そのための処理は大変重いものになってしまいます。

ユーザーAの現在のスコアを得るための処理はこのようになります。

  1. ユーザーAが正答したときの解答全てをanswersから取得
  2. 解答1つずつの点数を計算する
    2-1. 解答した問題のlevelを取得
    2-2. その解答よりも前の同じ問題への正答全てをanswersから取得
    2-3. ユーザーAの解答時のヒントの開封状況を取得
    2-4. 取得した情報を点数計算式に代入
  3. すべての解答の点数の総和を求める

ユーザーが正答した問題が多ければ多いほど処理が重くなる、典型的なですね。
全ユーザーのスコアをまとめて取ろうとしたら、ユーザーの数を、提出済みの解答の数をとするとになってしまいます。

この問題を解決するためにscore_logsというテーブルを追加することにしました。
score_logsにはユーザーが正答する度に、新たに得た点数と現在のスコアを保存します。
upload_42c7ecbb2ffdc71b448bbf5377aca2f3

score_logsを追加したことにより、ユーザーの現在のスコアを得る処理は「指定したuser_idかつ直近のcreated_at」のデータを得ると言う大変シンプルなものとなりました。
全ユーザーのスコアをまとめて取得するのも、created_atによるフィルターを行うだけですね。
スコアの変動の情報もcreated_atでソートするだけで得られます。

データベースを用いて値のキャッシュを行うというのは少々邪道かもしれませんが、このテーブルが安定したアプリケーション動作に大きく貢献したことは間違いありません。

また、解答提出からscore_logsに獲得点数を保存するまでの処理はMutexにより排他制御を行い、獲得点数に不整合が発生しないようにしました。

ランキングにまつわるキャッシュ(xxpoxx)

ランキングページは、score_logテーブルを見て必要な情報をとり、ランキング形式に処理して返すという非常に重い処理がある上、アクセスが集中するという予見がかねてよりありました。これに対して今回のスコアサーバーでは、ランキングにキャッシュを挟むという方法で対策をとっていました。後述されるservice層に存在するGetRanking()メソッドでは、以下のようにしてインメモリキャッシュを挟んでいました。

if s.gc.Has(CacheRanking) {
    cachedRanking, err := s.gc.Get(CacheRanking)
    /*** convert process ***/
    return ranking, nil
}
/*** get ranking process ***/
s.gc.Set(CacheRanking, ranking)
return ranking, nil

キャッシュライブラリとしてgcacheを採用しました。Go言語のキャッシュライブラリでは、go-cacheなども存在しますが、gcacheは部内SNSサービスtraQでも用いており、goroutine safeであるという特徴などを踏まえて採用しました。
この結果、以下のように実際にランキングの取得処理にキャッシュを挟むことで、レスポンスの時間が非常に小さくなっていることが確認できます。
upload_3a895a42b7826f3033dc01337d3fb632
CPCTF2021の参加人数は約150人でした。ざっくりランキングページに150人がそれぞれ30秒に1回リクエストを送ってくると考えると、1秒につき5回の処理が走ることになります。今回の実装では、正答が送られてきたときにキャッシュを破棄するようにしています。正答の送られてきた間隔はビジュアライザの記録を確認したところ、約5秒に1回でした。キャッシュを破棄したあとに1度だけ生成処理を行うので、5秒につき1回の処理が走っていると考えてよいでしょう。つまり、1秒に5回の処理だったものが1秒につき0.2回となり、単純計算で25倍の性能改善ができたことがわかります。
さらに、コンテスト終了直後など、ランキングページに150人が同時にアクセスした際も、本来ならば1秒につき150回の処理が走ることになりますが、キャッシュのおかげで生成処理を行わずに、キャッシュされている結果を返すだけで捌けるようになりました。
このキャッシュのおかげかはわかりませんが、本番の膨大なリクエストでも、サーバーが落ちることなくコンテストを実施できました。

他サービスとの連携(oribe)

サーバーサイドはクライアントサイドの他に、ビジュアライザとWebShellのサーバーと連携を行っていました。

upload_9c9316e65c4b353567bf3c7aa03a8ca0

クライアントサイド

クライアントサイドからサーバーサイドへのAPIリクエストが行われる他に、サーバーサイドからイベントを配信するためにWebSocketを繋いでいました。
配信されるイベントはアナウンスや質問の投稿、問題の点数変動やランキングの更新などで、クライアントはそれらを受けてリアルタイムに描画を行いました。

upload_a22dd6cfb630dd38a385cca0836876bf

ビジュアライザ

誰がどれほどのスコアを獲得したのかをかっこいい演出とともに伝え、参加者だけでなく観戦者をも盛り上げてくれたのがビジュアライザです。
ビジュアライザは初期化時にAPIリクエストで現在の情報を取得し、それ以降はサーバーサイドから配信されるWebSocketイベントをトリガーにして描画を行っていました。
配信されるイベントはユーザーの登録やスコアの獲得、ランキングの変動と競技残り時間の同期のための情報です。

upload_cb1abe4f48fba96ee8bcc317e22c4178

WebShell

CPCTFでは自由に使えるLinux環境としてWebShellを用意しました。
参加者はマイページでWebShellのアカウントを発行でき、以後はそれを用いてWebShellを利用することができます。

WebShellアカウントの乱造や不適切な用途で使用されることをふせぐため、WebShellのアカウントとその認証を管理するアプリケーションへのリクエストはスコアサーバーのサーバーサイドを介してのみ行えるようにしました。
マイページで「WebShellを有効化する」や「WebShellの環境のリセット」のボタンを押すとスコアサーバーのサーバーサイドにHTTPリクエストが飛びます。
それを受けてスコアサーバーのサーバーサイドはAPI keyと共にWebShellのアプリケーションにリクエストを行い、その結果を元にクライアントへのレスポンスを返します。

upload_6502f0b8e0bf2f3edd1ccd82fae80a87

upload_0c7c6eadb8af5db2c57f7c4afc4cc3a9

初期アイコン生成(xxpoxx)

今回のスコアサーバーでは、アイコンとして初期アイコン、またはTwitter連携で取得したTwitterのアイコンを用いることができました。このうちの初期アイコンは以下のようなものです
upload_8c902edc87cfb06692c432f7af97616a
この初期アイコンは、ユーザーの登録時に自動生成されるものとなっています。この初期アイコンの生成には、部内SNSサービスtraQでも用いられているgo-qidenticonを用いています。以下のようなコードで初期アイコンを生成し、保存したURLを返すようにしていました。

// GenerateIcon アイコンを生成し、ローカルに保存、配信のURLを返す(ex. http://localhost:3000/images/78636449-169b-4dcd-a157-c12cd219cb53.png)
func (s *Services) GenerateIcon(salt string, hostURL string) (iconURL string, err error) {
	icon := qidenticon.Render(qidenticon.Code(salt), iconSize, iconSettings)

	iconURL = uuid.New().String()
	if dir, err := os.Stat(os.Getenv("ICON_DIR")); os.IsNotExist(err) || !dir.IsDir() {
		if err := os.Mkdir(os.Getenv("ICON_DIR"), 0777); err != nil {
			return "", err
		}
	}
	iconPath := filepath.Join(os.Getenv("ICON_DIR"), iconURL+".png")
	f, err := os.Create(iconPath)
	if err != nil {
		return "", err
	}
	err = png.Encode(f, icon)
	if err != nil {
		return "", err
	}
	return hostURL + "/" + "images" + "/" + iconURL + ".png", nil
}

初期アイコンが並んだランキングを見て、開発者としてはニコニコしていました。初期アイコンが必要となるサービスで、是非利用してみてください。

ミニマムなアーキテクチャ(oribe)

今回のスコアサーバーの開発では開発期間が短かったこと、来年以降も使えるような保守性の高いものにしたかったことなどから、なるべくシンプルでかつユニットテストを書きやすいアーキテクチャにしようと考えました。
最終的には以下のような構成になりました。
upload_9e4fe34b8737b66c2b00cafe7c338e52

repositoryはデータベース操作を行う層です。
基本的には引数で受け取ったパラメータを用いてのシンプルなCRUD処理のみを行います。

serviceはアプリケーションロジックを実装した層です。
前述のランキング計算やそのキャッシュといった複雑な実装が必要になる処理はこの層に集約しました。

handlerはAPIハンドラを実装した層です。
APIリクエストに基づいてrepository、またはserviceのメソッドを呼び出し、最終的なレスポンスを生成します。
複雑な処理を要する一部のAPIはserviceのメソッドにロジックの実装を移譲していますが、そうでないAPIの場合はこの層にロジックを書きました。

このrepositoryとserviceはinterfaceを挟んで実装するようにしたことで、mockを用いた他の層の実装に依存しないテストを書くことができました。
mockの生成にはgomockを使用しました。

// repository層でのinterface定義

type QuestionRepository interface {
	CreateQuestion(args CreateQuestionArgs) (*Question, error)
	UpdateQuestion(id uuid.UUID, args UpdateQuestionArgs) (*Question, error)
	GetQuestion(id uuid.UUID) (*Question, error)
	GetQuestionsByQuestionerID(questionerID uuid.UUID) ([]*Question, error)
	GetOpenQuestions() ([]*Question, error)
	GetClosedQuestions() ([]*Question, error)
	QuestionExists(id uuid.UUID) (bool, error)
}
// mockメソッドを用いたhandler層のメソッドのテスト

func TestHandlers_GetQuestions(t *testing.T) {
	t.Parallel()

	t.Run("Success", func(t *testing.T) {
		t.Parallel()
		ctrl := gomock.NewController(t)
		th := setupH(t, ctrl)

		accessUser := mustMakeUser(t, false, false)
		question1 := &model.Question{
			ID:           uuid.New(),
			QuestionerID: accessUser.ID,
			AnswererID:   nil,
			Text:         random.AlphaNumeric(t, 30),
			Answer:       "",
		}
		answererID := uuid.New()
		question2 := &model.Question{
			ID:           uuid.New(),
			QuestionerID: accessUser.ID,
			AnswererID:   &answererID,
			Text:         random.AlphaNumeric(t, 30),
			Answer:       random.AlphaNumeric(t, 30),
		}

		th.Repo.MockQuestionRepository.
			EXPECT().
			GetQuestionsByQuestionerID(accessUser.ID).
			Return([]*model.Question{question1, question2}, nil)

		var resBody []PublicQuestion
		statusCode, _ := th.doRequestWithLogin(t, accessUser, echo.GET, "/api/questions", nil, &resBody)

		assert.Equal(t, http.StatusOK, statusCode)
		if assert.Len(t, resBody, 2) {
			pq := resBody[0]
			assert.Equal(t, question1.ID, pq.ID)
			assert.Equal(t, question1.QuestionerID, pq.QuestionerID)
			assert.Equal(t, question1.Text, pq.Text)
			assert.Equal(t, question1.Answer, pq.Answer)
			assert.False(t, pq.Closed)

			pq = resBody[1]
			assert.Equal(t, question2.ID, pq.ID)
			assert.Equal(t, question2.QuestionerID, pq.QuestionerID)
			assert.Equal(t, question2.Text, pq.Text)
			assert.Equal(t, question2.Answer, pq.Answer)
			assert.True(t, pq.Closed)
		}
	})

	t.Run("FailedToGetQuestionsByQuestionerID", func(t *testing.T) {
		t.Parallel()
		ctrl := gomock.NewController(t)
		th := setupH(t, ctrl)

		accessUser := mustMakeUser(t, false, false)
		th.Repo.MockQuestionRepository.
			EXPECT().
			GetQuestionsByQuestionerID(accessUser.ID).
			Return(nil, errors.New("failed to GetQuestionsByQuestionerID"))

		statusCode, _ := th.doRequestWithLogin(t, accessUser, echo.GET, "/api/questions", nil, nil)

		assert.Equal(t, http.StatusInternalServerError, statusCode)
	})

	t.Run("WithoutLogin", func(t *testing.T) {
		t.Parallel()
		ctrl := gomock.NewController(t)
		th := setupH(t, ctrl)

		statusCode, _ := th.doRequest(t, echo.GET, "/api/questions", nil, nil)

		assert.Equal(t, http.StatusUnauthorized, statusCode)
	})
}

このアーキテクチャにしたことによる恩恵が最も大きかったのはserviceの実装でした。
前述の通りserviceでは複雑なロジックを担うメソッドを実装しているので、そのメソッド単位のテストで確認できるのは実装作業やデバックに大いに役立ちました。
またserviceにあるメソッドのいくつかは開発中に要求仕様が変わりましたが、他の層の実装やテストがその影響を受けずに済むというメリットがありました。

例えばserviceには正答時の獲得点数を算出するメソッドがありますが、開発期間中にルール上の点数の計算式が何度か変更されたことを受けてロジックの変更をする必要がありました。
そのメソッドを呼び出すhandlerメソッドは複数ありますが、適切に層同士の依存関係が分離されているためhandlerメソッドやそのテストを変更する必要は一切ありません。
ただ対象である獲得点数算出メソッドとそのテストを変更するだけで、新たな仕様への対応を完了できました。

クライアントサイド

技術スタックについて(sappi_red)

ここ最近はずっとViteを使っているので今回もビルドツールにはViteを使いました。やっぱりビルド速いと快適ですね。
クライアントは自分(@sappi_red)が一人で書くので、使い慣れていることとできる限りコードを減らしつつ自由度を高めることを念頭に置いて以下の選択をしました。

OpenAPIでAPI仕様を書いてもらってたので、開発初期はStoplight Prismでモックサーバーを立てて利用したり、APIを叩くコードはOpenAPIGenaratorで生成したりしました。

他に利用したライブラリはこんな感じです。

このようにかなりいろいろなライブラリに頼りました。

VueUseについて(sappi_red)

VueUseはVueのComposition APIの汎用関数のライブラリです。
useDarkと言うダークテーマ切り替え用の関数だったり、useNowと言う現在の日時を利用できる関数だったりがあります。
使ってみようと思いつつも触れていなかったので、この機会に利用することに決めました。

useNowは以下のように開始状態や終了状態かどうかの判定に利用していました。

import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { useMainStore } from '/@/store'

export const useContestSchedule = () => {
  const main = useMainStore()
  const { now } = useNow({ interval: 1000 })

  const secondsUntilStart = computed(() =>
    main.schedule === null
      ? NaN
      : Math.ceil(
          (Date.parse(main.schedule.startTime) - now.value.getTime()) / 1000
        )
  )
  const isContestStarted = computed(() =>
    Number.isNaN(secondsUntilStart.value) ? null : secondsUntilStart.value <= 0
  )

  const secondsUntilEnd = computed(() =>
    main.schedule === null
      ? NaN
      : Math.ceil(
          (Date.parse(main.schedule.endTime) - now.value.getTime()) / 1000
        )
  )
  const isContestEnded = computed(() =>
    Number.isNaN(secondsUntilEnd.value) ? null : secondsUntilEnd.value <= 0
  )

  return {
    secondsUntilStart,
    isContestStarted,
    secondsUntilEnd,
    isContestEnded
  }
}

実装していて汎用性が高いと感じた二つの関数は、VueUseへのプルリクエストを出しました。
一つ目はtoRefsで、Vueにある既存のtoRefsRef<{ a: string }>のようなものにも使えるようにした関数です。以下のようなコンポーネントを書くと、const data = ref({ a: 'a', b: 'b' })があったときに<comp v-model:data="data" />のように利用できます。

<template>
  <div>
    <input v-model="a" type="text" />
    <input v-model="b" type="text" />
  </div>
</template>

<script lang="ts">
import { toRefs, useVModel } from '@vueuse/core'

export default {
  setup(props) {
    const refs = toRefs(useVModel(props, 'data'))

    console.log(refs.a.value) // props.data.a
    refs.a.value = 'a' // emit('update:data', { ...props.data, a: 'a' })

    return { ...refs }
  }
}
</script>
これは、問題の編集画面をコンポーネントとして切り出すのに使ってました。

二つ目はautoResetRefで、以下のようなことができます。

import { autoResetRef } from '@vueuse/core'

const message = autoResetRef('default message', 1000)

const setMessage = () => {
  /* 'message has set'に変更する、1秒後には勝手に'default message'に戻る */
  message.value = 'message has set'
}

これはエラーメッセージ表示に利用してました。

利用した関数だと、useVModeluseLocalStorageが便利でした。

Windi CSSについて(sappi_red)

Windi CSSはTailwind CSS互換のオンデマンドCSSフレームワークです。
開発中は必要になったスタイルを必要なったタイミングで生成して挿入し、ビルド時は必要になったスタイルだけを生成してバンドルするという挙動をします。
なので開発サーバーの起動時間を短縮することとバンドルサイズを小さくすることができます。さらには事前生成ではないので、m-0.004(margin: 0.001remのクラス)のように事前に定義されていない数値を含んだスタイルを利用することもできます。
Tailwind CSSのほうにあるTailwind JITとの違いはWindi CSSのリポジトリのディスカッションを見ると書いてあります。
実際にはViteでWindi CSSを利用するためにvite-plugin-windicssも使っています。
Windi CSSに関しても使ってみようと思っていて、ちょうどよかったので利用しました。
あと細かいバグ修正のPRを出したりしました。

Tailwind CSSも利用したことがなかったので、クラス名が長くなるのがちょっと気になってたんですが、これはある意味しっくりくるなと感じました。
使ってる間はその理由がいまいち言語化できなかったんですが、ユーティリティーファーストとTailwind CSSのススメ - Qiitaという記事で納得しました。

<button
  class="rounded whitespace-nowrap border-2 border-transparent focus:border-white focus:border-dotted focus:border-opacity-50 focus:outline-none"
  :class="[
    type === 'primary'
      ? 'bg-emerald-600 dark:bg-emerald-400 text-white hover:bg-emerald-500 dark:hover:bg-emerald-500'
      : type === 'secondary'
      ? 'bg-warm-gray-200 dark:bg-warm-gray-700 text-emerald-600 dark:text-emerald-400 hover:bg-warm-gray-300 dark:hover:bg-warm-gray-600'
      : '',
    size === 'large' ? 'text-xl font-bold py-3 px-8' : 'py-2 px-6'
  ]"
>

これは書いてたコードの一部ですが、すごいわかりにくくなってます。(本当はcomputedとしてくくりだしたほうがよいと思いますが、一旦そのことは忘れておいて…)
条件分岐が一か所にまとまってるというのはメリットですが、どのプロパティが条件によって変化するのかわかりにくいというのが微妙なところでした。
属性でかけるとよさそうな感じするなと思いつつ時間の余裕がなかったので、そのときはそのまま書き進めてました。
そう思ってたら4月下旬にAttributify Modeというまさにそういう機能がv3 betaで実装されました。(現在はv3のstableもリリースされてます、ただvite-plugin-windicssのv3対応版はまだbetaです)
コンポーネントのpropとややこしいというデメリットはありますが、先述のわかりにくい点はこれを利用すると解消されそうです。
今のところ触ってませんが今度触ってみたいと思ってます。

UXについて(sappi_red)

問題を解くことに集中できるようにすること、問題を解くのを楽しく感じられるようにすることにフォーカスをあてて、仕様を考えてました。
いくつか紹介していきます。

いろいろ実装したのが功を奏して、開催後のアンケートでも使いやすかったとの声が多かったのでよかったです。

終わりに

CPCTF2021のスコアサーバーはこのブログで紹介した様々な技術・工夫を取り入れて開発しました。
有志で集まった開発メンバーでしたが、完成度の高いものを作ることができたと感じています。
参加者の皆さんにも満足していただけていたら光栄です。

xxpoxx icon
この記事を書いた人
xxpoxx

雰囲気でプログラミングをやっている

sappi_red icon
この記事を書いた人
sappi_red

19B/22M。SysAd班。 JavaScript書いたりTypeScript書いたりGo書いたりRust書いたり…

oribe icon
この記事を書いた人
oribe

お絵かきする時間が欲しい

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2023年12月11日
DIGI-CON HACKATHON 2023『Mikage』
toshi00 icon toshi00
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】 feature image
2018年11月3日
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】
Azon icon Azon
2022年4月7日
traPグラフィック班の活動紹介
annin icon annin
2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年3月19日
traPグラフィック班の活動紹介
NABE icon NABE
2023年4月27日
Vulkanのデバイスドライバを自作してみた
kegra icon kegra
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記