卒業研究での技術スタック 〜フロントエンド編〜
みんなの趣味部屋というサービスを卒業研究で作成したのでそのフロントエンド部分の技術構成を紹介します。
前提
メンバ
フロントエンドのメンバは自分含めて3人でした。自分以外、授業でJava, C#などのプログラミングの経験はあるもののJSはほぼ初めてでした。
作成したもの
みんなの趣味部屋というWebサービスを作成しました。
「好きな作品をアピールして他のユーザに届ける」ことをコンセプトとしたWebサービスになります。
ソースコードは全てGithubで公開しています。
技術スタック
- Webフレームワーク: Nextjs 14
- DBホスティング: CockroachDB Serverless
- ORM: Prisma
- 認証: Authjs
- UI: Mantine、pandacss
- デプロイ先: Cloud Run
- Cloudflare Workers
Nextjs 14 (App Router)
始めはデータベースアクセスなどの部分は別にAPIサーバを用意しようと思っていましたが、一部機能をのぞき「わざわざ別のサーバに分ける必要なくね?」と思ったので Server Actions などを利用してほぼ全てをNextjsで完結させました。
App Router
個人的にApp Routerを採用した初めてのプロジェクトだったのでディレクトリ構造・アーキテクチャなど模索しながら実装したところが多かったです。
ディレクトリ構造
ルーティングのためのapp
ディレクトリに加え、コロケーションを意識してsrc配下にドメインごとにディレクトリを切ってその中にmodel関数(後述、DBにアクセスするなどするサーバ側のコード)などを入れる構成としました。
src/
app/
art/ → 作品に関するコード
good/ → 作品のいいねに関するコード
...
styled-system/ → pandacssで生成したコード
tools/ → 各種開発用のツール
...
データの取得・更新ルールの定義
Nextjsを学習したてのメンバの混乱がなるべく少なくなるように、初めにルールを決めて基本的にはそれに従って実装するようにしました。
ルール1 サーバでのデータの取得はサーバコンポーネントから
データの取得は サーバコンポーネントからmodel関数を直接叩くようにすることとしました。model関数とはデータベースへのアクセスなどを含むコードのことです(MVCで言うModelの役割に似た関数)。model関数はチームメンバには実装させず、あらかじめ用意されているものを使ってねと言うふうに伝えておきました。
const SomeServerComponent = async ()=>{
const arts = await getArts() // getArts() がmodel関数
return (
// artsを描画
)
}
ルール自体がシンプルであるために すんなり受け入れられたと感じています。
チームメンバにこのルールに従って実装してもらったことで、RSCのメリットを享受できたと思っています。
また後の改修で、Suspense
や Promise.all
を利用してパフォーマンスチューニング も実施しました。
ルール2 データの更新はクライアントコンポーネントからServer Actionsを呼び出す
データの更新は "use client" をつけたクライアントコンポーネントから Server Actionsを呼び出してその中でmodel関数を呼ぶ といった流れで実装してもらいました。
"use client"
import { handleGoodArt } from "./actions"
const SomeClientComponennt = ()=>{
const goodArt = async ()=>{
await handleGoodArt(artId)
}
return (
<button onClick={goodArt}>
いいね
</button>
)
}
"use server"
export const handleGoodArt = async (artId: string)=>{
await goodArt()
}
事前に実施した面談ではメンバの理解度・好み的に、Web標準のフォームの実装よりもC#のWindows Formのような onClick={handler}
といったイベントハンドリングの方が理解できていそうだった ためパフォーマンス的な問題の懸念もありつつ、基本的にはクライアントコンポーネントからServer Actionsを呼び出すといった形をとりました。
またこのあたりは事前に用意したミューテーションユーティリティとも組み合わせて、ローディング表示などの実装の負荷を軽減するようにも努めました。
ミューテーションユーティリティについては別記事でもまとめたいと思います。
バージョン
初めver14.0.4 を使用していましたが、ファイルアップロードの実装の最中、バージョンを14.0.3に落とすと動作するようになる謎のバグがあったので最終的に14.0.3を使用しました。(現在のバージョンだと治ってたりするのかも...?)
CockroachDB Serverless
CockroachDBはNewSQLと呼ばれるPostgreSQL互換の分散DBです。
以下の理由から採用しました。
- NoSQLと違いSQLが利用できること
- 無料枠が充実していること
- 無料枠を超過しても料金設定が親切(執筆現在 1GiBで$0.50, 1M request Unitsで$0.20)
実際レコメンドエンジンの実装をし出したあたりから大量の作品データがDBに必要になり、大量のリクエストをしても¥2000も行かない程度で済んだので安めのサービスで本当に助かりました🙇。
Prisma
本当はDrizzleORMにチャレンジしようとしていましたがCockroachDBに対応していなかったので泣く泣く対応しているORMのなかで一番有名なPrismaを選択しました。
あまり使ったことはなかったのですが想像以上に使い勝手が良かったので悪い選択ではなかったと思います。
不満としてはschema.prisma
のファイル分割できるようにして欲しいな...と感じたくらいです。
Authjs
ClerkやLogto などの選択肢もありましたが、認証の部分は今回の研究の目的ではないためサクッとできたらそれでいいかと思い、使い慣れたNextAuthを利用することにしました。
また他のチームではログイン機能を実装しているところはあってもOAuthにしているところは少なく、変に実装の負荷が変に高かった的な話も先生からお聞きしました。自分としては認証機能IDaaSやOAuthに任せるのは当たり前な認識だったので意外だったので、先生から言われた時はびっくりしました。
Mantine
メンバがCSSを書けない関係上、UIライブラリに頼る必要がありました。メリットとしては以下が挙げられるでしょう。
- ドキュメントが充実しているため、初心者のメンバでもなんとかなった
- CSS Modulesで書かれるようになったためApp Routerでも事故らず使える。
デメリットとしては細かなスタイリングが難しかった点が挙げられます。
公式ドキュメントではCSS Modulesを使えばいいんじゃない?的な内容を見かけましたが、用意しているCSS変数を覚えたりレスポンシブデザインもしにくかったりするなどの理由から、後述するpandacssを後に導入しました。
pandacss
Mantineで実装しにくい細かなスタイリングを実装するために導入しました。
classNameにemotionチックにCSSをかけるので書き心地が非常に良かったです。
<div
className={css({
p: "md",
border: "solid 1px red",
})}
>
...
</div>
ただ使ってみた感想としては、Windowsだけで発生する「スタイリングが効かなくなるバグ」があったり、Mantineと競合して !important
をつけないとスタイルが適用されないなどの問題点があったため、Mantine との組み合わせは最悪といった感じです。
Cloud Run
デプロイ先は最終的にCloud Runになりました。当初Vercelでデプロイする予定でしたが、GithubのOrganizationにレポジトリを作成していたため自動的にProプラン(有料)に登録するよう言われてしまったので泣く泣く断念。
とりあえずNetlifyに切り替えてはみたものの、Suspense周りでうまく動作しないなど不具合が多かったので、Cloud Runに移行することにしました。
VercelやNetlifyとは違い、Cloud RunでNextjsをデプロイするにはDockerfileを用意したり、Preview環境は自分で用意する必要がある(結局面倒でやってない🤛)など、自前で準備しないといけない面が多く大変でした。
またドメインのマッピングはパフォーマンスの都合上、Cloudflare Workersをリバースプロキシすることで実現しました(後述)。
catnoseさんのこちらの記事が大変参考になったので感謝です!
Cloudflare Workers
カスタムドメインでアクセスできるようにするために、前述の通りCloud Runのカスタムドメインマッピングは利用せずCloudflare Workersを利用しました。
export interface Env {
ORIGIN_SERVER_URL: string
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const reqUrl = new URL(request.url)
const originReq = new Request(request)
originReq.headers.set("X-Forwarded-Host", reqUrl.host)
const originRes = await fetch(`${env.ORIGIN_SERVER_URL}${reqUrl.pathname}${reqUrl.search}`, originReq)
return originRes
},
}
ただWorkersからオリジンであるCloud Runのアプリにfetchしただけでは X-Forwarded-Host
ヘッダー (リバースプロキシ関連のHTTPヘッダ)が送信されないため、Server Actions周りで不正なリクエストとして認識されてしまうといった問題がありました。
もっといいやり方があるのかもしれませんが、余り時間をかけれなかったため雑な実装になってしまっていたり、キャッシュAPIなどが活用しきれていないなど課題が山積みなのでこのあたりは要リファクタかなといった印象です。
Nextjsの学習
メンバのスキルが足りていないと把握していたので事前学習として以下を学習してもらいました。
資料 | |
---|---|
Nextjs LearnのReact Foundations | Nextjsが用意しているReactのチュートリアルです。Nextjsでの開発を行う上で最低限必要になるReactの知識がまとまっていた印象でした。 |
Nextjs Learn | 公式のチュートリアル。全てではなく、ところどころピックアップしながら学習してもらいました。 |
また学習ログをZennのスクラップにまとめてもらって都度フィードバックしました。
繰り返しフィードバックすることでスキル向上に寄与するだけでなく、僕自身がメンバのスキルレベルの理解ができたのも良かったと思います。
あきらめたこと
React Hook Formのようなフォームライブラリの導入
- 学習コストが高めなので学習している暇がなかったため導入しませんでした。
- フォームのコードで以下のような共通化したくなるコードが散見されてモヤモヤが止まらないこと以外は特に困ってないのでまあいっかといった結論に落ち着きました。(複雑なデータを扱うフォームがいくつかありこれに時間を取られてしまうとよくないと思いました。複雑なフォームがないサービスかつメンバのスキルがそれなりにあるといった状況であれば採用の価値ありだと思ってますが今回はそういうわけではなかったので採用しませんでした)
const [title, setTitle] = useState("")
const isValidTitle = title.length.trim() >= 1
const [description, setDescription] = useState("")
const isValidDescription = description.length.trim() >= 1
const isValidForm = isValidTitle && isValidDescription
- 結果論ではありますがパフォーマンスで困ることは概ねなかったので採用しなくても大丈夫だったのかと思います。
バンドルサイズの軽減
- チームメンバが初学者である以上、パフォーマンスチューニングなどの高度なことは意識してもらうことは不可能と踏んで、バンドルサイズの軽減を諦めました。
- UIライブラリ(Mantineの導入)やZodの採択などがこれに現れているでしょう。
Discussion