🤝

Next.js + Hono + AWS でイベント駆動なプロフィール交換アプリを開発!

2024/02/02に公開

TL;DR

  • スマホでQRコードを読み込むプロフィール交換アプリを開発した。
  • イベントソーシングだったり Hono だったり、気になる技術を試せた。
  • アプリのレスポンスが速く、懇親会も盛り上がったのでよかった!

はじめに

レバテック開発部で基盤システムグループに所属している瀬尾です。
普段はマイクロサービスのつらみを味わったりしています。

昨年末に弊社で大規模な懇親会があり、そこでの交流を増やす目的として、互いの QR コードを読み取りプロフィールを交換するアプリを作りました。

この記事では、そのアプリをどんな技術でどのように開発していったかをご紹介しようと思います!

技術スタック

  • 言語
    • Typescript
    • Go(Lambdaで使用)
  • フロントエンド
    • Node.js
    • Next.js, TailwindCSS
  • バックエンド
    • Bun
    • Hono, Prisma
    • イベントソーシング
  • インフラ
    • AWS ECS, Lambda, DynamoDB, RDS, S3
    • Terraform
  • その他
    • MySQL
    • Docker
    • Figma

バックエンド概要

バックエンドは、今激アツの Bun と Hono を使って実装しました。
どちらも気になっていた技術です。

Bun は実務でも取り入れられる範囲(パッケージマネージャーとして)で取り入れていましたが、ランタイムとして使うのは初めてでした。
Hono は、標準の WebAPI に沿って実装されたどんなランタイムでも動く Web フレームワークです。便利なミドルウェアもいろいろ用意されているので、簡単な API サーバーを実装する用途では特にうってつけだと思います。
ISUCON13 の node 実装でも使われてましたね〜!

今回は honojs/middleware@hono/zod-openapi + @hono/swagger-ui を導入し、型安全な REST API を実装しました。使い心地よかったです!

フロントエンド概要

フロントエンドは、主に Next.js と TailwindCSS を使って開発しました。
Bun を使わなかった理由は、おそらくですが Next.js 内のミドルウェアの動作に必要な API が未実装だったからです(参考:error: Attempt to export a nullable value for "TextDecoderStream"

OpenAPI と併せて型安全に通信するために zod と aspida を組み合わせて利用(参考)したり、QR 生成のための next-qrcode や読み込みのための ZXing を利用したりしました。
カメラの起動や QR の読み取りはライブラリを用いましたが、それらは WebAPI をラップしたものであったためテスト時に PC でもカメラ起動を試せたりと、WebAPI の便利さを感じました。

また、UIデザインは Figma から TailwindCSS のコンポーネントとしてエクスポートして実装しました。この部分を担当してくれた方いわく、1発でいい感じにエクスポートできるわけじゃないため手作業でいい感じにする必要があり、ちょっとかゆさがあったな〜とのことでした。


どんなアプリを作ったんだい

基本の画面は3つで、左からマイページQR交換ページともだち一覧ページです。
ログインページもありましたがここでは割愛。

UI/UX デザインは、レバテック全体を担当されている方にしていただきました。即席なのにクオリティ高すぎだろ!
デザイナーさんってすごいですねぇ…

開発経過

発端

懇親会の企画メンバーから、レバテック開発部メンバーに相談がきました。
普段の業務内容とは違って一発ネタのアプリなので、技術的に好きなことできそうだしやってみるか!と引き受けることにしました。
(なお、開発期間は短かったので大変ではありましたね…)

要件はざっくりこんな感じ:

  • 相手のアプリに表示された QR コードを読み込むとその人のプロフィールに画面遷移する
  • プロフィールには話すきっかけになるような項目が記載されているとよい
  • 読み込んだプロフィールに応じて自分に得点が入る
    • 今年度入社の方: 5点
    • 自分と違う部署: 3点
    • 自分と同じ部署: 1点

スコア計算のルールにも現れていますが、部署を超えた交流や新しく入社した方との交流を目的としています。
また、2時間ほどある懇親会中のレクリエーションとして、約500人が一斉にこのプロフィール交換ゲームを行うということで、アクセス数のスパイクにぶっつけ本番で耐えられるシステムを作る必要がありました。

設計

というわけでこれを実現するシステムの設計に着手しました。

これは一番初めに話し合ったときのホワイトボード!
最終的に下のようなシステムになりました(雑な図ですみません)
ほとんど最初に話し合った形と同じですが、ちょっとだけ具体的になってます。

DynamoDB を活用したイベントソーシングをしてます。
イベント駆動はいいぞ!みたいな言説は散見されますが実際に触れることはなかったので、この機会にやってみました。

相手の QR を読み込んでプロフィールに遷移することを「訪問イベント」と見做し、これにより更新処理が非同期に行われるようになっています。
登場人物は Visitor (訪問者)と Destination (訪問先)です。
訪問イベントが発生するとこれらの AccountID の組が DynamoDB に記録され、各アカウントの得点計算やともだち一覧(訪問履歴)の更新が発火します。

実装

データの用意

このアプリで用意する必要があるのは社員情報とそのプロフィール、あとはともだち一覧となる訪問履歴でした。
事前に元となる社員名簿をいただき、DB の設計をしました。

まず、不変な情報である社員自体の情報(Account, Department)と、可変と想定されるプロフィール(Profile)を分けました。Account テーブルにいろいろ紐づくようになっています。

Department は基本となる所属部署の一覧となっており、要件にあった訪問スコアの計算はこれに基づいて行っています。
なぜプロフィールにある実所属部署(Profile.displayDepartment)と別にこれを用意したかというと、スコアを競うルール上、ゲームの公平性をいい感じにするためです。
組織構造的に部署やチームのネストみたいなものがバラバラだったので、実所属部署をそのままに使うとちょっと微妙な感じになりそうだったので…

訪問履歴(ともだち一覧)はアプリの画像にあるように1訪問ごとに点数をつけるので、訪問スコア(VisitScore)というテーブルに記録していきました。
取得する際に Visitor 側の AccountId で引っ張ってくることで、ともだち一覧を作ることができます。

HandScore というテーブルは、違う部署3人や同じ部署3人への訪問で"役"による加点を行おうとしていた開発者たちの夢の跡です。未使用です。時間がなくて間に合いませんでした。

よかったこと

可変なデータを不変な Account テーブルに紐づくように設計することで、HandScore のような機能を拡張しやすいテーブル構造になっていました。未使用だったけど!
まあでも次の懇親会でまた使うかもしれないからね!そのときとかにね!

あとは、事前にアイコンも Slack から取得して、全て S3 に置いておいたことがよかったです。
AccountId をキーに署名付きURLで取得するようにしていたので、アカウント作成漏れがあって当日急遽作成したアカウントでログインしても、ちゃんとアイコンが表示されました。

ログイン

ログイン画面で受け取ったアカウント情報を検証した後、それをもとに JWT トークンを発行して、これによってセッションを持ちます。
ログイン以降の画面は、このトークンがなければアクセスできないようになっています。

また、ログインのときに ID/PASS と同時に「ひとこと」を入力してもらい、それによってプロフィール完成させました。ひとことは、テーブルにある myHeart というふざけた名前のカラムに格納されます。
その他情報を事前に埋めてあったからこそ出来たことではありますが、懇親会のボリュームにちょうどいい簡単さだったなと思いました。

QRコードによるプロフィール交換

これがメインの機能です。
PayPayみたいに QR を表示して、それを相手が読み込むことでプロフィール交換ができます。

ところで、QR コードを読み取ってスコアを稼ぐゲームと聞くと、「QR コードをスクショして共有すれば不正できそうだな〜」なんて思いますよね。これの対策として、10 秒ごとに切り替わる QR コードを実装しました。力技だったかもしれない!

QR コード自体はフロントで生成するのですが、その元となるハッシュをバックエンドで生成しました。ハッシュは、バックエンドのインメモリキャッシュに AccountId と紐づけて期限 10 秒で保存します。
QR は AccountID とハッシュを結合した文字列から生成しました。

Backend QR表示のためのHash生成
const createdHash = Math.random().toString(32).substring(2)
cache.set(accountId, createdHash, 10)

// frontendに返す
return c.json({
  accountId: accountId,
  name: profile.name,
  nameKana: profile.nameKana,
  hash: createdHash,
  expire: 10,
})
Frontend QR文字列の生成
export type QrString = Opaque<string, 'QrString'>

export const buildQrString = (accountId: string, hash: string): QrString => {
  return `${accountId}:${hash}` as QrString
}

QR が読み込まれて訪問イベントとして PUT される前に、訪問先アカウントの ID に紐づくハッシュが期限内のものかを確認して、そうでなければ 403 で返すといった処理になっていました。

Backend QR読み取り後
// RequestのBodyにあるHashが有効か確認
const expectedHash = await cache.get<string>(body.destinationId)
if (expectedHash !== body.hash) {
  return c.json(
    {
      message: 'hash not match',
      code: '403',
    },
    403,
  )
}

// 訪問イベント作成
const visitorResult = await putVisitEvents(
  visitorId,
  body.destinationId,
  dynamoClient,
)

この寿命の短い QR コードの生成によって不正を防ぐことが出来たと思います。
ハッシュをインメモリキャッシュに入れておいたおかげか、レスポンスも速かったです!

訪問イベントでトリガーする更新処理

QR 読み込みが成功すると、DynamoDB に「訪問イベント」が PUT されます。
DynamoDB には VisitorID と DestinationID の組が格納されており、これに Insert や Update があることを訪問イベントとしていました。

DynamoDB にイベントが発生すると、DynamoDB Streams をソースとした Lambda をトリガーすることができます。この Lambda でバックエンドの更新エンドポイントを叩き、訪問した/されたアカウントの訪問履歴を更新するといった構成です。

Lambda の実装では Go を使いました。書いてみたかったからというのもありますが、イベントソーシングの要となるため、少しでも速くしたかったからというのが(尤もらしい)理由です。
業務では Typescript しか使わないので Go を書いたのは初めてだったのですが、ChatGPT と協力することで難なく実装できました。やっぱり ChatGPT はすごい。

でも実装していて思ったのは、Lambda ってテストしづらいからあんましロジックを寄せたくないな〜ということです。この気持ちがあったので Lambda はバックエンドの更新処理である /update エンドポイントを叩くだけのものにしました。

Lambdaの実装(長いので折りたたみ)
ChatGPTと一緒に書いたLambda実装
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

type PostData struct {
	VisitorID     string   `json:"visitorId"`
	DestinationID []string `json:"destinationIds"`
}

func handler(ctx context.Context, event events.DynamoDBEvent) {
	// 環境変数からbackendのエンドポイントを取得。
	// 存在しない場合はlocalhostをデフォルト値とする
	serverURL := os.Getenv("ServerURL")
	if serverURL == "" {
		serverURL = "http://localhost:3002"
	}
	serverURL += "/update" // パスを追加

	for _, record := range event.Records {
		fmt.Printf("EventName: %s, DynamoDB Record: %s\n", record.EventName, record.Change.NewImage)

		// NewImageからVisitorIDとDestinationIDを取り出す
		visitorID, err := extractVisitorID(record.Change.NewImage)
		if err != nil {
			fmt.Println("Error extracting VisitorID:", err)
			continue
		}

		destinationID, err := extractDestinationID(record.Change.NewImage)
		if err != nil {
			fmt.Println("Error extracting DestinationID:", err)
			continue
		}

		// PostData構造体にデータをセット
		postData := PostData{
			VisitorID:     visitorID,
			DestinationID: destinationID,
		}

		// 構造体をJSONに変換
		jsonData, err := json.Marshal(postData)
		if err != nil {
			fmt.Println("Error marshalling JSON:", err)
			continue
		}

		// POSTリクエストの作成
		req, err := http.NewRequest("POST", serverURL, bytes.NewBuffer(jsonData))
		if err != nil {
			fmt.Println("Error creating request:", err)
			continue
		}

		// ヘッダーの設定
		req.Header.Set("Content-Type", "application/json")

		// HTTPクライアントの作成
		client := http.Client{}

		// リクエストの送信
		res, err := client.Do(req)
		if err != nil {
			fmt.Println("Error sending request:", err)
			continue
		}
		defer res.Body.Close()

		// レスポンスの読み取り
		fmt.Println("Response Status:", res.Status)
	}
	return
}

// extractAttributeValueはAttributeValueMapから指定した属性名の値を取り出すヘルパー関数です。
func extractVisitorID(attributeMap map[string]events.DynamoDBAttributeValue) (string, error) {
	attributeValue, found := attributeMap["VisitorID"]
	if !found {
		return "", fmt.Errorf("Attribute VisitorID not found")
	}
	// AttributeValueから値を取り出す
	value := attributeValue.String()
	return value, nil
}

// extractAttributeValueはAttributeValueMapから指定した属性名の値を取り出すヘルパー関数です。
func extractDestinationID(attributeMap map[string]events.DynamoDBAttributeValue) ([]string, error) {
	attributeValue, found := attributeMap["DestinationID"]
	if !found {
		return []string{}, fmt.Errorf("Attribute DestinationID not found")
	}
	// AttributeValueから値を取り出す
	value := attributeValue.StringSet()
	return value, nil
}

func main() {
	lambda.Start(handler)
}

インフラ

インフラは Terraform を使ってレバテックでも使っている AWS に構築しました。ざっくり図にするとこんな感じです。

おいおい1回しか使わないのに IaC なんて必要なんか?と思いますよね。僕も思いました。
でも Terraform で書いといてよかったなと思った場面はちゃんとありました。

例えば、今回のような DynamoDB を使ったイベント駆動アーキテクチャの実装は、僕らにとって初めての経験でした。初めてのものを実装するときって、トライアンドエラーを繰り返しながら進めていきたい場面が多いと思います(多いですよね?)
かくいう僕らも、インフラ含めて開発環境でたくさんのトライアンドエラーを繰り返しながら実装を進めました。そして何を隠そう、このアプリが完成したのは懇親会前日の22時(あほすぎる)だったので、その時間までトライアンドエラーの繰り返しだったわけです。

そんな前日の夜に開発環境でやっとすべての動作確認ができた後、いざ本番環境へ!となったときに、Terraform は大活躍でした。
本番用のコードを用意して terraform apply。これだけです!

懇親会終了後にインフラリソースを削除するのも簡単でした。Terraform いつもありがとう!


懇親会当日!

当日は大盛況と言っていいくらいこのアプリを楽しんでもらうことが出来ました。ほんとは大盛況っぽい写真を載せたいんですが、僕みたいな陰キャは写真を取らないのでなかったです;;
代わりに、運営側で得点を確認するためのランキング画面を載せておきます。

冒頭のスクショのとおり僕は 80pt ほどしか取れなかったのですが、上位には賞品があったのもあり、トップはなんと 694pt でした。陽キャめ!

参加者アンケートでは、このアプリのおかげで会話のきっかけが得られてよかったなど、励みになるコメントをたくさんいただけました。嬉しい〜

アプリの動作はどうだったか

動作はというと、基本的には良好でした。
そもそもコンテンツが少なかったこともありますが、イベントソーシングをしていたからか、Next.js のキャッシュがいい感じに働いたからか、QR 読込みも含めて画面の遷移が爆速でとても評判が良かったです。開発部としても鼻が高い。

"基本的に"といった理由は、メモリが逼迫して一度バックエンドが落ちてしまったからです。おそらくコネクション数が足りておらずリクエストをさばききれなかったことが原因だったかと思います。落ちた瞬間は別の催しをステージでやっていたので、あまり気づかれてないと思いたい…

インメモリキャッシュを使ってハッシュを保存していたので、スケールアウトが出来なかったことも要因のひとつですね。手軽さを取ってしまいましたが、Redis など使っていれば ECS タスク数を増やして対応できたかも。

おわりに

今度はイベントソーシングを業務のどこかで導入してみたいな〜などと画策しております。レバテックの裏側で起こっている色々をイベントとリソースで分けて解釈してみたい!

今回は業務から外れた内容となりましたが、技術的な挑戦もさせてもらえるいい環境であることが伝われば幸いです。
誰か一緒に働きましょう!!!!

https://hrmos.co/pages/leverages/jobs/A_c_00057

レバテック開発部

Discussion