Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

taramanji.com フルリプレイス解説

0
Posted at

Next.js 1 つで動いていたサイトを、Go のマイクロサービス + React(Vite)+ 自宅 Mac mini の Kubernetes に置き換えた。
このドキュメントでは、何をどう作ったかを図を使って順番に説明する。

図は Mermaid という書き方で描いてあり、GitHub や VS Code のプレビューで絵として表示される。

目次

回 分野 テーマ
0 全体 何から何に置き換えたか
1 バックエンド 通信の約束事:Protocol Buffers・Connect・gRPC
2 バックエンド 層の分け方:DDD+クリーンアーキテクチャ
3 バックエンド 自作のコード生成・エラー・ログイン・Redis
4 バックエンド サービス同士の通信・外部 API・テスト
5 インフラ Docker と Kubernetes の基本、kind
6 インフラ 入口:Cloudflare・Envoy Gateway・HTTPRoute
7 インフラ Redis・Secret・NetworkPolicy
8 インフラ CI/CD と GitOps(dev01〜03 を含む)
9 インフラ カナリアリリース:Argo Rollouts+Datadog
10 フロント Next.js から React+Vite へ、FSD
11 フロント データの取り方と状態
12 フロント 見た目と動き、配信
付録 – 用語集・ドキュメントと実装の食い違い

第0回 全体像:何から何に置き換えたか

置き換え前:Next.js 1 つ

Next.js は「画面(React)」と「サーバーの処理(API)」を 1 つのプロジェクトにまとめて書ける。手軽だが、全部がくっついているので、一部を直しても全体を作り直して入れ替えることになる。

置き換え後:役割ごとに分割

部分 前 後
画面 Next.js React + Vite(frontend/)
サーバー処理 Next.js の API routes(TypeScript) Go のマイクロサービス 8 つ(backend/)
通信の形 普通の JSON API Connect / gRPC(.proto で型を決める)
動かす場所 自宅の Kubernetes 自宅 Mac mini の kind を、クラウド(GKE)に近い構成で
リリース 手作業寄り GitHub Actions → Argo CD(GitOps)→ Argo Rollouts(カナリア)
環境 本番 + dev dev01〜03(PR ごと)→ stg → 本番
監視 Datadog(一部) Datadog で全部を見張り、悪ければ自動で元に戻す

8 つの Go サービス

1 回のアクセスを追う:「勤務場所」ページ

リプレイス全体の方針

  • 見た目と作りは変えるが、内容は 1 文字も変えない:文言・URL・メール文面・localStorage のキーは旧版のまま
  • URL を変えない:Google に登録済みのログイン用 URL(/api/auth/*・/api/calendar-sync/*)は旧版と同じ
  • 旧版と同じ答えを出すことをテストで守る:旧 TypeScript を Node で動かして作った正解データ(golden)と照合する
  • いきなり全員に出さない:dev → stg → 本番の順。本番は 10% の利用者から少しずつ切り替える

第1回 通信の約束事:Protocol Buffers・Connect・gRPC

なぜ必要か

画面(TypeScript)とサーバー(Go)は別の言語で書かれている。「関数の名前」「渡すもの」「返ってくるもの」の約束がずれると動かない。
そこで約束を 1 つのファイル(.proto) に書き、両方のコードをそこから自動で作る。

用語

用語 ひとことで
Protocol Buffers(protobuf) Google が作った「データの形」と「関数の一覧」を書く言語。.proto ファイル
gRPC protobuf で決めた関数を、ネットワーク越しに呼ぶ仕組み。速いがブラウザから直接は使いにくい
Connect gRPC と同じ約束で、ブラウザからも普通の HTTP + JSON で呼べるようにした仕組み(Buf 社製)
buf .proto の書き方チェック(lint)とコード生成をまとめて行う道具
h2c 暗号化なしの HTTP/2。クラスターの中のサービス同士の gRPC で使う

約束事の例(backend/proto/taramanji/worklocation/v1/worklocation.proto)

service WorkLocationService {
  rpc ListWorkLocations(ListWorkLocationsRequest) returns (ListWorkLocationsResponse) {} // 公開
  rpc SetWorkLocations(SetWorkLocationsRequest) returns (SetWorkLocationsResponse) {}   // 管理者
  rpc DeleteWorkLocation(DeleteWorkLocationRequest) returns (DeleteWorkLocationResponse) {}
  rpc GetWorkLocation(GetWorkLocationRequest) returns (GetWorkLocationResponse) {}      // 内部(reservation から)
}

// @entity
message WorkLocation {
  // @pk
  string date = 1;      // YYYY-MM-DD(JST)
  // @required
  string location = 2;
}

// @entity などのコメントは自作の生成器(第3回)への指示。

URL の形

RPC 1 つが URL 1 つになる:/<package>.<Service>/<RPC名>

POST /taramanji.worklocation.v1.WorkLocationService/ListWorkLocations
content-type: application/json

{}

手元なら curl でも呼べる(backend/README.md)。

1 つのサーバーで 3 種類の呼ばれ方を受ける

backend/pkg/util/server/server.go で、1 つのポート(:8080)が HTTP/1 と h2c の両方を受けるように設定している。

protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)

設定ファイル

  • backend/buf.yaml:lint は STANDARD(空のレスポンスを許すため 1 ルールだけ除外)
  • backend/buf.gen.yaml:3 つの生成器(protoc-gen-go・protoc-gen-connect-go・mss-protoc-gen)
  • frontend/buf.gen.yaml:protoc-gen-es。notification は内部専用なので画面側には生成しない

第2回 層の分け方:DDD + クリーンアーキテクチャ

考え方

  • DDD(ドメイン駆動設計):業務の言葉(勤務場所・予約・お問い合わせ)で区切って作る。区切りを「コンテキスト」と呼び、ここでは 1 コンテキスト = 1 サービス
  • クリーンアーキテクチャ:コードを層に分け、依存(import)は外側から内側への一方通行にする。業務ルールが Redis や HTTP の都合に引きずられない

ポイントは、内側は interface(「こういう関数があるはず」という約束)だけを知っていること。本物の Redis かテスト用の偽物かは、起動時(cmd/<service>/main.go)に差し込む。これを DI(依存性の注入) という。

worklocation のファイル一覧

ファイル 書き方 役割
domain/entity/work_location.gen.go 生成 WorkLocation 型と検証(@required)
domain/repository/work_location_repository.gen.go 生成 「読む・書く・消す」の interface
domain/repository/mock/...gen.go 生成 テスト用の偽物
domain/service/work_location_service.go 手書き 業務ルール(過去を消す・未定で埋める)
usecase/work_location_usecase_interface.gen.go 生成 Usecase の interface と入出力の型
usecase/work_location_usecase.go 手書き 入力チェックとエラーの種類決め
handler/work_location_handler.gen.go 生成 Connect の入口
infra/repository/work_location_redis_repository.gen.go 生成 Redis の実装
dto/work_location.gen.go 生成 外に返すための型
di/handlers.gen.go 生成 ハンドラを URL に登録

人が書くのは業務ルールと usecase だけで、残りは proto から生成される。

1 リクエストが層を通る順番

手書き部分の中身

業務ルール(domain/service/work_location_service.go)。旧 Next.js の src/app/api/location/route.ts と同じ動きを Go で書き直したもの。

const Undecided = "未定(お問い合わせください)"
// ListPublished は過去の日を消し、今日から 2 ヶ月先まで未登録の日を Undecided で埋めて返す。
today := jst.Date(now)
// ... 過去の日は BulkDelete
// JS の setMonth(+2) と同じく、月末の繰り越しも Go の AddDate と一致する
last := jst.Date(now.In(jst.Location).AddDate(0, publishMonths, 0))

usecase(usecase/work_location_usecase.go):

if len(input.Dates) == 0 || input.Location == "" {
    return nil, errs.NewCodedError(errs.ErrorTypeBadRequest, "missing_fields", "日付と勤務場所を指定してください")
}

起動(cmd/worklocation/main.go):部品を組み立てるだけ。

rdb, _ := redisx.New(serviceName)
svc := service.NewWorkLocationService(infrarepo.NewRedisWorkLocationRepository(rdb), nil)
handlers := di.NewHandlers(usecase.NewWorkLocationUsecase(svc))
adminOnly := auth.AdminInterceptor([]string{ /* Set と Delete */ }, nil, "")
server.Run(serviceName, func(mux *http.ServeMux) {
    handlers.Register(mux, connect.WithInterceptors(adminOnly))
})

データの持ち方の変化


第3回 自作のコード生成・エラー・ログイン・Redis

mss-protoc-gen(backend/cmd/mss-protoc-gen/)

protoc の プラグイン(生成器)を自作したもの。proto のコメント(アノテーション)を読んで、層ごとの雛形を作る。

アノテーション 作られるもの
// @entity Entity・Repository interface・Mock・Redis 実装・DTO
// @pk 主キー(SelectByPK / Delete / BulkDelete)
// @unique SelectBy<項目>(Redis では索引キー ...:idx:<項目>:<値>)
// @required / // @email 検証
// @timestamp int64 を time.Time に
// @paging ページ送り

buf.gen.yaml で渡しているオプション:contexts=true(internal/<context>/ に出し分け)、infra=redis、handler=connect。

cd backend
make install-tools   # buf と生成器を入れる
make proto-gen       # proto を変えたら必ず実行

*.gen.go と gen/ は手で直さない。直したいときは proto を変えて生成し直す(CI でも「生成し直して差分が出ないか」を見ている)。

エラーの扱い(backend/pkg/util/errs/)

種類 HTTP 相当 Connect のコード
Validation / BadRequest 422 / 400 InvalidArgument
NotFound 404 NotFound
Duplicate 409 AlreadyExists
Unauthorized 401 Unauthenticated
Forbidden 403 PermissionDenied
Upstream(外部 API の失敗)/ Concurrency 502 / 503 Unavailable
Internal 500 Internal(中身は返さずログだけ)

code(invalid_email など)には旧 API のエラー文字列をそのまま使う。フロントはそれを見て旧版と同じメッセージを出す。

ログイン(backend/pkg/util/auth/)

  • JWT:中身(メールアドレスなど)に署名を付けた文字列。署名の鍵 AUTH_SECRET は全サービスで共通なので、identity に問い合わせなくても各サービスが自分で確かめられる
  • Interceptor:全 RPC の手前に挟まる関所。管理者用の RPC だけを止める
  • 5 分ごとの同期(CronJob)は人ではないので、Cookie の代わりに Authorization: Bearer <トークン> で通す

Redis の分け方

  • 1 つの Redis を、DB 番号と**ユーザー(ACL)**で区切って使う
    • DB 0 = worklocation、1 = content(キャッシュ)、2 = calendarsync
    • 各サービスのユーザーは自分のキー(例 workLocations:*)しか触れず、危険なコマンドも使えない
  • 実際の区切りは REDIS_URL(redis://<ユーザー>:<パスワード>@<ホスト>:6379/<DB番号>)で決まり、その値は deploy/k8s/scripts/make-secrets.sh が作る

その他の共通部品(backend/pkg/util/)

部品 役割
server 起動、/healthz・/readyz、終了時に 5 秒待ってから止める(通信を切らないため)
observability Datadog(トレース・メトリクス)
logger slog。本番・stg は JSON、ログに trace_id を付ける
env 環境変数。必須のものが無ければ起動しない
jst 日付は必ず日本時間で判定する
httpx 外部 HTTP 用のクライアント(Datadog で計測済み)

第4回 サービス同士の通信・外部 API・テスト

サービス同士:interface を挟んで gRPC

// backend/internal/inquiry/infra/gateway/notification_client.go
notificationv1connect.NewNotificationServiceClient(
    server.InternalHTTPClient(), baseURL, server.InternalClientOptions()...) // WithGRPC()

reservation → worklocation(その日の勤務場所を聞く)も同じ形。

外部 API の担当

サービス つなぐ先
identity Google OAuth(PKCE)、ユーザー情報
notification Amazon SES(鍵が無いときは送らずに成功扱い=開発用)
reservation GAS のウェブフック、Google Calendar、iCal、Google マップの短縮 URL
content Qiita API とページ、connpass、ORCID、OGP(キャッシュは Redis)
calendarsync Google Calendar(複数アカウント)、OAuth
analytics Datadog(web.pageviews を数える)

外部の URL を勝手に取りに行かないよう、Qiita とマップは許可したホストだけを通す(IsQiitaURL・IsMapsURL)。

旧 API との対応

旧(Next.js) 新
/api/location・/api/admin/location worklocation ListWorkLocations / SetWorkLocations / DeleteWorkLocation
/api/reserve・/api/ical/busy・/api/maps/resolve reservation CreateReservation / GetBusy / ResolveMapsUrl など
/api/contact・/api/questionnaire・/api/admin/send-email inquiry → notification
/api/qiita*・/api/connpass・/api/orcid・/api/ogp content
/api/calendar-sync/* calendarsync(connect・callback は同じ URL の HTTP)
/api/auth/[...nextauth] identity(/api/auth/* は同じ URL の HTTP)
/api/metrics/pageview analytics(同じ URL の HTTP)

テスト

種類 何を守るか 例
ユニットテスト(Mock) 業務ルール。時刻も注入して固定 internal/worklocation/domain/service/work_location_service_test.go(12/31 + 2 か月 = 3/3 を JS と合わせる)
golden テスト メール文面・同期予定の ID が旧版と同じ internal/inquiry/.../golden_test.go、internal/calendarsync/.../golden_test.go
miniredis 本物の Redis 無しで Redis を使う処理を試す internal/calendarsync/usecase/calendar_sync_usecase_test.go
live テスト(-tags live) 本番の旧 API と結果を比べる internal/reservation/usecase/live_parity_test.go

コンテナイメージ(backend/Dockerfile)

1 つの Dockerfile で、--build-arg SERVICE=reservation のように 8 サービスを作り分ける。


第5回 Docker と Kubernetes の基本、kind

コンテナと Kubernetes

用語 ひとことで
コンテナ / イメージ プログラムと必要なものを詰めた箱 / その箱の設計図
Pod Kubernetes で動かす最小単位
Deployment 「この Pod を N 個保つ」。落ちたら作り直す、新しい版へ少しずつ入れ替える
Service Pod が入れ替わっても変わらない宛先(名前)
namespace クラスターの中の区画(taramanji・taramanji-dev01 など)
マニフェスト 「こうなっていてほしい」を書いた YAML

kind で Mac mini の中にクラスターを作る

kind = Kubernetes in Docker。Docker のコンテナ 1 つを 1 台のマシンに見立てる。

  • deploy/kind/cluster.yaml:1 + 2 台。GKE と同じ「ゾーン」のラベルを付け、Pod を 2 台に散らす練習ができる
  • データ置き場を Mac のフォルダに出しているので、クラスターを作り直してもデータが残る(StorageClass standard-rwo、Retain)
  • deploy/kind/up.sh:起動(Mac の起動時に launchd が実行)。cloud-provider-kind で LoadBalancer に IP を付ける
  • deploy/kind/platform.sh:GKE なら最初からあるもの(metrics-server・Envoy Gateway・StorageClass)を入れる

Go サービス 1 つ分のマニフェスト(deploy/k8s/base/apps/worklocation.yaml)

設定 意味
replicas: 2 + topologySpread 2 個をノード・ゾーンに散らす。1 台止まっても動く
RollingUpdate maxUnavailable: 0 入れ替え中も減らさない
startup / readiness / liveness probe 起動した?通信を受けてよい?生きている?を /readyz・/healthz で確かめる
resources CPU 10m・メモリ 24Mi を予約、上限 64Mi
securityContext root で動かさない、ファイルを書き換えられない、権限を全部外す
PDB minAvailable: 1 メンテナンス中も最低 1 個は残す
HPA(web・reservation・content) CPU 70% を超えたら 2 → 最大 4 個に増やす

第6回 入口:Cloudflare・Envoy Gateway・HTTPRoute

ルーターのポートを開けずに公開する

  • Cloudflare Tunnel:家の中の cloudflared が外へつなぎに行く。外から家へ入る穴(ポート開放)が要らない
  • トンネルは 2 本
トンネル 通すホスト Access(ログイン必須)
prod(taramanji-onprem) taramanji.com・www・gws なし(公開)
staging(taramanji-kind) dev01〜03・dev01〜03-gws・stg・stg-gws・rollouts あり(deploy/cloudflare/access.py)
  • ホスト名が dev01-gws.taramanji.com のようにハイフンなのは、Cloudflare の無料証明書 *.taramanji.com が 1 段しか効かないから

Envoy Gateway と HTTPRoute

Gateway API は Kubernetes の「入口」の標準の書き方。Envoy Gateway はその実装。

  • notification には HTTPRoute が無い → 外からは絶対に届かない
  • 各ルールは <svc>(重み 100)と <svc>-canary(重み 0)の 2 つに向いている。カナリアのとき Argo Rollouts がこの重みを書き換える(第9回)
  • ホスト名は base では仮の値で、環境ごと(overlay)に差し替える

第7回 Redis・Secret・NetworkPolicy

Redis(deploy/k8s/base/data/redis.yaml)

  • StatefulSet:Pod に固定の名前(redis-0)と専用のディスクを付ける。データを持つものに使う
  • AOF(appendonly yes、1 秒ごとに書き出し)で、再起動してもデータが残る
  • maxmemory 200mb・noeviction:あふれたら勝手に消さずにエラーにする

Secret:公開リポジトリに鍵を置く方法

  • 暗号を解く鍵はクラスターの中にしかないので、暗号文は public リポジトリに置いてよい
  • dev01〜03 は同じ secrets/dev/ を、namespace だけ変えてそれぞれ暗号化する(SealedSecret は namespace ごとに別の暗号になるため)
deploy/k8s/scripts/make-secrets.sh dev
for e in dev01 dev02 dev03; do deploy/k8s/scripts/seal.sh $e; done
  • CI(lint)は、平文の kind: Secret がコミットされていないかも確かめる

NetworkPolicy:Pod 同士の通信も「許可したものだけ」

まず全部を拒否(default-deny-ingress)し、必要な道だけを開ける。


第8回 CI/CD と GitOps(dev01〜03 を含む)

用語

用語 ひとことで
CI push のたびに自動で lint・テスト・ビルドする
CD できたものを自動で環境に出す
GHCR GitHub のコンテナイメージ置き場
GitOps 「クラスターのあるべき姿」を Git に書き、Git を正とする。人がクラスターを直接いじらない
Argo CD Git を 3 分ごとに見て、クラスターをそれに合わせる道具(Pull 型なので家への入口が要らない)
Kustomize 共通の YAML(base)に、環境ごとの差分(overlay)を重ねる仕組み

環境は 5 つ

環境 きっかけ URL namespace
dev01〜03 main への PR/手動 devNN.taramanji.com・devNN-gws.taramanji.com taramanji-devNN
stg main への push(マージ)/手動 stg.taramanji.com・stg-gws.taramanji.com taramanji-stg
prod タグ v* の push taramanji.com・gws.taramanji.com taramanji

全体の流れ

変わったサービスだけ作り直す(deploy/k8s/scripts/changed-services.py)

dev01〜03:PR ごとに枠を割り当てる(今回の変更)

以前の dev は 1 つで、PR が来るたびに上書きされていた。今は 3 つの枠があり、deploy/k8s/scripts/pick-dev.sh が出す先を決める。

例:PR が A・B・C・D の順に来た場合

dev01 dev02 dev03
A を出す A – –
B を出す A B –
C を出す A B C
D を出す(一番前に使われたのは dev01) D B C
A をもう一度 push D B C → A(一番前に使われた dev03 へ)
  • 「まず stg にそろえる」のは、前にその枠を使っていた PR の版が残らないようにするため
  • 全部の反映ジョブは同じ concurrency グループ(deploy-manifests)なので、2 つの PR が同じ枠を同時に取り合うことはない
  • 今どこに何が出ているか:PR のコメント、git log --grep '^deploy(dev0'、Argo CD の画面(taramanji-dev01〜03)

dev・stg の共通部分(deploy/k8s/components/nonprod/)

  • dev・stg も Rollout(カナリアの手順なし)にしたので、rollouts.taramanji.com で namespace を taramanji-devNN / taramanji-stg に切り替えれば入れ替わりの様子が見える
  • Rollouts の画面は、開いたときに taramanji の一覧が出るよう --namespace=taramanji を付けた(deploy/gitops/rollouts-values.yaml)
  • dev・stg の Redis はそれぞれの namespace に 1 つずつ。予約・お問い合わせは本物の GAS・SES に届くので、試すときは自分宛てに

Argo CD の構成

  • ApplicationSet:1 つの雛形から複数の Application を作る仕組み。ここでは {env} に dev01〜03 を入れて 3 つ作る
  • 自動同期(prune:Git から消したら消す、selfHeal:手でいじったら戻す)
  • 家の回線は遅いので、git の待ち時間を 300 秒に伸ばしている
  • deploy/gitops/bootstrap.sh の順番:Sealed Secrets → Argo Rollouts → Argo CD → リポジトリの鍵 → root

今回の修正:deploy/gitops/argocd-values.yaml に applicationSet: enabled: false と書いてあったが、使っているチャート(argo-cd 10.9.6)にはこの項目が無く、ApplicationSet コントローラーは最初から動いていた(だから dev01〜03 が作られている)。書いてあることと実際が逆で紛らわしいので、replicas: 1 と説明コメントに直した。helm template で比べて、クラスターに入る中身は変わらないことを確認済み。

本番リリース

# stg で確かめたコミットにタグを付ける
git tag v1.2.3 <コミット> && git push origin v1.2.3

戻すとき(Git が正なので、Git を戻す):

deploy/k8s/scripts/set-version.sh prod v1.2.2 reservation
git commit -am "deploy(prod): rollback reservation to v1.2.2" && git push

第9回 カナリアリリース:Argo Rollouts + Datadog

カナリアとは

新しい版を一部の利用者にだけ先に出し、問題がなければ広げる。炭鉱のカナリアが由来。

仕組み

  • 新版だけを判定できるのは、各 Pod に version タグ(=イメージのタグ)が付いているから。Kustomize の replacements で、イメージのタグを Datadog のラベルと判定の引数に自動でコピーしている
  • 判定に入れないもの
    • ヘルスチェック:数秒ごとに来るので、混ぜると数字が実態から離れる(server.go でトレースしない)
    • 外部 API への呼び出し:自分の失敗ではないので、サーバー側の span だけを見る(DD_TRACE_SPAN_ATTRIBUTE_SCHEMA=v1)
    • RunSync の遅さ:カレンダー同期はもともと長いので p95 の判定から外す
  • web は一度に切り替える:旧版の HTML から新版の JS を取りに行くと 404 になるため、混ぜない

操作

kubectl argo rollouts --context kind-taramanji -n taramanji get rollout reservation --watch  # 様子
kubectl argo rollouts --context kind-taramanji -n taramanji promote reservation              # 10% から先へ
kubectl argo rollouts --context kind-taramanji -n taramanji abort reservation                # 中止して戻す

画面なら https://rollouts.taramanji.com(Cloudflare Access で管理者だけ)。

監視(deploy/k8s/observability/)

  • monitors.py:5xx 率・p95・再起動・トンネルの接続数・Redis のメモリ・カレンダー同期の失敗・予約やメールの失敗・証明書の期限・Argo の異常など
  • dashboard.py:入口から Redis まで、上から順に見られるダッシュボード
  • dev・stg のログは取らない(お金がかかるため)。トレースは届く

第10回 Next.js から React + Vite へ、FSD

何が変わったか

用語 ひとことで
SPA 最初に 1 枚の HTML と JS を読み込み、あとはブラウザの中で画面を切り替える
Vite 開発サーバーとビルドの道具。保存するとすぐ画面に反映される
React Router URL ごとにどの画面を出すか決める
  • サーバー側の処理(API・ログイン)は全部 Go へ移ったので、フロントは画面だけになった
  • 内容は 1 文字も変えないのがルール。文言・リンク・検証メッセージ・localStorage のキーは旧版のまま。/privacy と /calendar-sync は Google の審査に登録済みなので特に厳密

主な設定ファイル

  • frontend/vite.config.ts:@ を src の別名に。開発時は API(/taramanji.* と /api)を kind の Gateway へ転送し、Host を dev01.taramanji.com にする(=手元の開発は dev01 のデータを使う)。GATEWAY_URL で変更可
  • frontend/src/shared/config/site.ts:今いるホスト名から環境を判断(dev01〜03・stg・prod)。dev・stg ではタブのタイトルに [DEV01]〜[DEV03]・[STG] が付く
  • frontend/src/app/routes/router.tsx:ページは必要になってから読み込む(lazy)。gws のホストでは /admin だけを出す(旧 middleware.ts の役目)

FSD(Feature-Sliced Design):フォルダの分け方

ルール(npm run lint:fsd = scripts/check-fsd.mjs が CI で検査):

  1. import は上から下へだけ
  2. 同じ層の別のスライスは import しない(例:features/contact から features/reserve は ✕)
  3. 他のスライスは index.ts で公開したものだけを使う

例:src/entities/work-location/index.ts

export * from "./model/locations";
export { useWorkLocations } from "./api/use-work-locations";

第11回 データの取り方と状態

API の呼び出し:connect-es

関数のように呼べて、引数や戻り値の型を間違えるとビルドで止まる。エラーは toApiError(err)(src/shared/api/errors.ts)で { code, message, detail, fields } にし、code を見て旧版と同じ文言を出す。

状態の置き場所

サーバーのデータ(src/entities/work-location/api/use-work-locations.ts):

export function useWorkLocations() {
  return useQuery({
    queryKey: ["work-locations"],
    queryFn: async () => (await workLocationApi.listWorkLocations({})).locations,
  });
}

フォームの入力(src/features/contact/model/state.ts):

const ttlString = createTTLStorage<string>(10 * 60 * 1000);
export const contactEmailAtom = atomWithStorage<string>("contact_email", "", ttlString, { getOnInit: true });

入力チェック:zod

補足:package.json には react-hook-form も入っているが、実際のコードでは使っていない。フォームの値は jotai に置き、送信時に zod の safeParse で確かめている。useMutation も使わず、API を直接呼んで try/catch している。


第12回 見た目と動き、配信

見た目

道具 役割
Tailwind CSS v4 class="text-body-regular ..." のようにクラス名で見た目を書く。v4 は設定ファイル不要で、CSS の中で設定する
BoardUI ボタン・入力欄などの部品集。ソースを自分のリポジトリにコピーして使う型(src/components/)。中身は react-aria-components なので、キーボード操作や読み上げに対応
src/app/theme.css 色と文字の大きさを、暗い背景の日本語向けに上書き
IBM Plex Sans JP / Mono 本文と、日付・メタ情報用の等幅

決めごと(frontend/CLAUDE.md):

  • ダークテーマ固定。文字はすべてコントラスト 4.5:1 以上(npm run check:contrast が Playwright で全ページを測る)
  • 飾りのアイコン・絵文字・グラデーションを付けない。何でも箱で囲まない
  • 残すもの:背景に流れる CLI コマンド、送信中のターミナル風ダイアログ、Qiitan / Gopher、所属とスキルの色分け

動き(motion ライブラリ、src/shared/ui/motion/)

部品 動き
DecodeText 見出しの文字が記号から 1 文字ずつ定まる
DecodeReveal 中身の文字も記号から定まって出る
Reveal 画面に入るとぼかしから浮かぶ
Rule 区切り線が左から伸びる
ScrollProgress 画面上端の線で読んだ位置を出す
ページ遷移 前のページは上へ消え、次はぼかしから浮かぶ(約 280ms)

すべて prefers-reduced-motion(OS の「動きを減らす」設定)で止まる。

配信と計測

  • リリース直後に古いタブが消えた JS を取りに行って失敗したら、1 回だけ自動で再読み込みする(src/shared/lib/stale-chunk.ts)
  • Datadog RUM(src/app/providers/rum.ts):ブラウザでの体験を計測。traceparent を付けて、ブラウザ → Gateway → Go まで 1 本のトレースでつながる
  • ページビューは navigator.sendBeacon("/api/metrics/pageview") → analytics → Datadog の web.pageviews

付録

用語集

用語 説明
マイクロサービス 役割ごとの小さなアプリに分ける作り方
API 画面からサーバーに頼みごとをする窓口
RPC 遠くの関数を呼ぶこと(Remote Procedure Call)
interface 「こういう関数があるはず」という約束。中身は別に書く
DI 部品を外から差し込むこと。テストで偽物に差し替えられる
JWT 署名付きの身分証明の文字列
CronJob 決まった時刻に動く Job(ここでは 5 分ごとの同期)
HPA 負荷に応じて Pod の数を自動で増減する
PDB メンテナンス中も最低何個残すかの約束
トレース 1 つのリクエストがどこをどれだけの時間で通ったかの記録
p95 遅い方から 5% を除いた中での最大の時間。「ほとんどの人はこれ以内」

ドキュメントと実装の食い違い(調べて分かったこと)

書いてあること 実際
deploy/k8s/README.md:kind に「ローカルレジストリ」 作っていない。イメージはすべて GHCR から
backend/CLAUDE.md:予約の件名・説明文を golden.json で守る golden.json があるのは inquiry と calendarsync だけ
backend/CLAUDE.md:httpx に許可リスト 許可リストは content と reservation がそれぞれ持っている
backend/CLAUDE.md:ログは JSON JSON は stg・本番だけ。local・dev はテキスト
frontend/CLAUDE.md:フォームは react-hook-form + zod react-hook-form は未使用(jotai + zod)
cloudflared のマニフェスト:「setup.sh が作る」 setup.sh は無い。トンネルの鍵は seal.sh prod で作る
deploy/gitops/argocd-values.yaml:applicationSet.enabled: false 今回修正。この項目はチャートに無く、コントローラーは動いていた
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?