Analysis App
Next.js + Go + Pythonで構築したデータ分析アプリケーション。
背景
物理学実験の授業では単元ごとに複数のグラフを作成、編集を行う必要があった。この作業は単純で時間のかかるものであり、レポート作成のモチベーションを低下させる原因になっていた。グラフ作成を効率化し、よりといレポートを制作するため、本アプリを作成した。
工夫したこと
グラフを直感的に操作できるようにしたため、実験ごとの要件にあったグラフを作成できるようにした。また、キャッシュやDBの利用により、データの保持を可能にした。
振り返り
DBやキャッシュ、ログの扱いに苦労した。どこまでデータを保持するか、いつまでデータを残すかなど、今後の変更も考えながら設計することは難しい と感じたが、いい経験になった。
技術詳細開く閉じる
全体構成
3つのサービスを1つのリポジトリに置いている。
flowchart TB
B["ブラウザ"] --> F["Next.js / App Router"]
F -->|"JWT を載せて REST"| A["Go API"]
A --> P[("Supabase Postgres")]
A -.->|"best-effort"| R[("Redis")]
A -->|"内部 HTTP"| W["Python worker / FastAPI"]
外から到達できるのは Next.js だけで、ブラウザから Go API を直接叩く経路は持たせていない。Go API への呼び出しはすべてサーバーコンポーネント・サーバーアクション・ルートハンドラーのいずれかを起点にし、そこで Supabase のセッションから JWT を載せる。Python のワーカーはさらに内側で、認証もDBも持たず、Go API からしか呼ばれない。
役割は、Go が入口と認可と永続化、Python が数値解析、Next.js が描画、と分けている。ワーカーは数値だけを返してグラフは描かない。同じ解析結果を別の見せ方で使い回せるようにするためで、対数軸への切り替えのようにクライアント側の都合で変わるものをサーバーに持ち込まずに済んでいる。
解析リクエストとキャッシュ
sequenceDiagram
participant F as Next.js
participant A as Go API
participant R as Redis
participant W as worker
F->>A: POST 解析リクエスト
A->>R: キャッシュを引く
alt ヒット
R-->>A: 保存済みの結果
else ミス
A->>W: 内部HTTPで解析
W-->>A: 数値のみ
A->>R: 24時間で保存
end
A-->>F: data / error / meta
キャッシュのキーは analysis:実験ID:解析タイプ:パラメータのハッシュ の形にした。パラメータは順序の定まらないマップなので、そのまま埋めずに JSON にしてから SHA-256 を取っている。Go のマップは JSON 化のときにキーが整列されるため、これで反復順に左右されないキーになる。
キャッシュは best-effort の扱いで、Redis がエラーを返してもミスと同じ扱いにして処理を続ける。落ちたときに壊れるのが速度だけで、正しさには波及しないようにしたかったため。逆に実験データそのものが編集されたときは、キーにデータのハッシュを含めていない都合で古い結果が最大24時間残ってしまうので、その実験のキーをプレフィックスでまとめて消している。
テストのための境界
ハンドラーには具体型ではなくインターフェースを渡している。切ってあるのは4つ。
| インターフェース | 本番の実装 |
|---|---|
experiments.Store |
pgx のリポジトリ |
projects.Store |
pgx のリポジトリ |
cache.Cache |
Redis 実装 |
worker.Client |
HTTP クライアント |
この4つが継ぎ目になっていて、ハンドラーのテストは Postgres も Redis もワーカーも立てずに回る。I/O を足すときは先にインターフェースを切ってから渡す、という順序を守らないと、その時点でテストに実インフラが必要になってしまう。
解析の種類は Python 側のレジストリで引いている。新しい解析を足すときはファイルを1つ作って @register("タイプ名") を付けるだけで、Go 側にもフロント側にも手を入れずに済む。
フロントエンドの依存の向き
app/ → components/ → lib/ の一方向に固定し、lib/ から components/ を import しない。
lib/ に置くのは入力から出力が決まるだけの純粋関数(API 呼び出しと Supabase クライアントの生成だけが例外)で、対数目盛りの計算、軸レンジ、回帰の評価、±1σ 帯、有効数字、単位換算、CSV のパースなどが入っている。コンポーネントはそれを呼ぶだけにした。描画せずにロジックを検証できるのはこの分離のおかげで、特に useMemo の中に書いた計算は描画しないと触れず見落としやすいので、見つけ次第 lib/ に出すようにしている。
認可
/api/v1 配下に認証ミドルウェアを一括で掛け、ここを通ったリクエストだけが user_id を持つ。認可の判断をする場所をここ1か所に限定している。
リポジトリの SQL は必ず user_id で絞り、「存在しない」と「他人のもの」を意図的に同じ Not Found として返している。区別すると、他人のデータが存在すること自体が応答から漏れるため。