個人開発アプリをRemix + Cloudflare D1に移行してみた
この記事は『blessing software 夏のブログリレー企画』の5日目の記事です。
昨日はasukaさん(@a_skua)の「Flutterを用いたWeb開発の今後について考える」が公開されました。
次回はKanonさん(@samurai_se)の「私がエンジニア勉強会を作り、ブログリレーを主催するまで」です!
はじめに
以前、Type Challenges Judgeという、type-challengesのオンラインジャッジを作りました。
Type Challenges Judgeは、type-challengesの問題の回答の正誤判定を行ったり、自分がどれくらい正解したかや、他の人の回答が確認できるアプリです。
このアプリをRemix + Cloudflare(Pages、D1)に移行してみた[1]ので、やったことについて書こうと思います。
技術スタックについて
Type Challenges Judgeは、React + React Router + Firebase(Authentication、Firestore、Hosting、Functions)という技術スタックで開発しました。
今回はこのアプリをRemix + Cloudflare(Pages、D1、Workers)でリニューアルしてみました。なぜこの構成にしたかというと、単純にRemixやCloudflare(特にD1)を使ってみたかったというのが一番の理由です。
Type Challenges Judgeはアプリの規模があまり大きくなく、新しい技術を試すのにちょうどいい規模でした。また、ルーティングにReact Routerを使っているため、Remixへの移行のハードルも小さいと予想できました。
移行の手順
次の3つの手順で行いました。
- React RouterからRemixに移行して、Cloudflare Pagesにデプロイする
- FirestoreからCloudflare D1に移行する
- (進行中)判定処理をCloud FunctionsからCloudflare Workersに移行する
D1にアクセスするためには、Remixのloader/actionを使う必要がありそうだったので、最初にRemixへの移行を行うことにしました。
React RouterからRemixに移行する
公式のマイグレーションガイドを参考にして移行しました。途中でつまることもありましたが、移行の差分自体は小さく1日程度で完了しました。
Migrating from React Router | Remix
Cloudflareにデプロイする場合は、必要なパッケージやファイルの中身にマイグレーションガイドと異なる点があります。そのため、create-remixでCloudflare Pagesを選んで作ったプロジェクトを参考にしました。
既存のプロジェクトにRemixを追加して動かす
まずは必要なパッケージをインストールしました。
yarn add @remix-run/react @remix-run/cloudflare @remix-run/cloudflare-pages
yarn add -D @remix-run/dev wrangler @cloudflare/workers-types
それから、Remixを動かすのに必要なファイルを追加しました。ファイルの中身は、create-remixで作ったプロジェクトからコピーしました。詳細はPull Requestを参照していただければと思います。
server.ts
app/entry.client.tsx
app/entry.server.tsx
app/root.tsx
remix.config.js
マイグレーションガイドではroot.tsx
に<Script />
が書かれていなかったのですが、これがないとReactが動かないので注意が必要です。
そして、package.jsonのスクリプトを変更して、yarn run dev
でRemixが動作することを確認しました。
{
- "dev": "vite",
- "build": "tsc && vite build",
+ "dev": "remix dev --manual -c \"npm run start\"",
+ "start": "wrangler pages dev --compatibility-date=2023-06-21 ./public",
+ "build": "remix build",
}
既存のコードを移植する
次に、既存のコードをRemixで動くように変更しました。Remixのコードはapp/
ディレクトリに入れるので、まずはsrc/
ディレクトリの中身をapp/
ディレクトリに移動しました。
それから、ページコンポーネントのファイル名をRemixの規約に合わせて変更しました。Remixは、Next.jsなどのフレームワークと同じように、ファイルシステムベースのルーティングを採用しています。URLの階層を、ディレクトリではなくファイル名の.
区切りで表すのが特徴です。
Route File Naming (v2) | Remix
その後、主に次の2つの変更をすると、ローカル環境でアプリケーションが動作するようになりました。
- コンポーネントをデフォルトエクスポートに変更する
-
react-router-dom
を@remix-run/react
に変更する
Cloudflare Pagesにデプロイする
Cloudflare Pagesへのデプロイは、CloudflareのコンソールのWorkers & Pagesからアプリを作成して、GitHubのリポジトリと連携するとできました。
Deploy a Remix site · Cloudflare Pages docs
FirestoreからCloudflare D1に移行する
移行のPull Requestはこちらです。
データモデルの設計
まず、既存のデータモデルがどうなっているかを把握してから、SQLite用のデータモデルを作成しました。
Type Challenges Judgeには、主にユーザー・問題・提出という3つのエンティティがあります。問題一覧で解いた問題の色を変えたり、難易度ごとにどれくらい解いたかを取得したいため、問題の挑戦結果(正解したかどうか)も保存するようにしています。
シードの作成
次に、初期データを登録するスクリプトを書きました。少し詰まったのは、スクリプトからD1へアクセスする方法です。D1にはWorkerからアクセスする必要があるので、スクリプトからWorkerを呼ぶ必要がありました。
cloudflare/wildebeestを見てみると、wrangler
のunstable_dev
を使っていたので、その方法でやってみると出来ました。
データベースアクセスを書き換える
Firestoreからデータを取得する処理を、D1から取得する処理に書き換えました。また、データを取得する場所をRemixのローダーに変更しました。
データベースアクセスには、sqlc(sqlc-gen-ts-d1)を使いました。
sqlcは、テーブル定義のDDLとSQLから、データベースアクセス用のコードや型を生成するライブラリです。sqlc-gen-ts-d1は、sqlcのCloudflare D1用のプラグインです。導入は次の記事を参考にしました。
普段はPrismaを使うことが多いのですが、Cloudflare Workersで動かすことはできない[2]ようなので、sqlcを使ってみることにしました。
sqlcではカラムをスネークケースで定義する必要がある
sqlcを使う場合の注意点は、データベースのカラムをスネークケースで定義する必要があることです。なぜかというと、クエリの実行結果は定義したカラム名で返ってくるのに対して、sqlc側はカラムを小文字に統一して扱うためです。
つまり、select * from user
の実際の結果の型が{ userId: string }[]
だとしても、sqlc側でそれに合わせた型を生成することができません。
スネークケースからキャメルケースへの変換はsqlc-gen-ts-d1
が行ってくれるため、アプリケーション側ではキャメルケースで扱えます。
データの移行
最後に、FirestoreのデータをD1に移行しました。次の手順で行いました。
-
firebase-admin
でFirestoreからデータを取得して、JSONファイルに書き出す - シードと同じ方法でローカルのD1に書き込む
- ローカルのDBをダンプして、
wrangler d1 execute
で本番DBに書き込む
データの件数は少なかったため(< 1000行)、問題なく移行できました。
exported 34 users # ユーザー
exported 343 submissions # 提出
exported 232 problem results # 挑戦結果
(進行中)判定処理をCloud FunctionsからCloudflare Workersに移行する
回答の判定処理では、TypeScript Compiler APIを使ってコードをコンパイルして、エラーメッセージを取得します。
既存の判定処理はCloud Functions for Firestoreで実行しており、ファイルシステムに依存していました。Cloudflare Workersではfsが使えないため、インメモリで処理するように書き換える必要があります。
インメモリでのコンパイルは、次の記事を参考にCompiler Hostを書き換えて行いました。
これでローカルでは動作するようになったものの、Cloudflare Pagesにデプロイするときにエラーになってしまいました。TypeScriptをバンドルに含んだためサイズが大きくなり、Pages Functionsの制限に引っかかってしまったようです。
そのため、判定処理のワーカー(とキュー)を別で作成して実行しようと思っています。
これからすること
ランキング機能があると面白そうなので、判定処理の実装が終わったら作ってみる予定です。
また、テストを全く書いていなかったり、パッケージが1年前から塩漬けになっていたりするので、その辺りも少しずつ改善できればと思っています。
感想
Remix + Cloudflare D1は、可能性を感じる技術スタックでした。規模が小さい場合はリレーショナルデータベースを無料で運用できるので、個人開発の1つの選択肢になりそうです。
Node.jsのライブラリで動かないものがあることと、現時点ではD1のトランザクションがサポートされていない点は注意が必要です。
sqlcは、SQLを直接書くことがパフォーマンスに自覚的になることにつながって良さそうでした。SQLを書いてコード生成をする手間があるので、複雑なRead以外はKyselyなどを使ってみるのもいいのかなと思いました。
参考
- Migrating from React Router | Remix
- Route File Naming (v2) | Remix
- Deploy a Remix site · Cloudflare Pages docs
-
まだ判定の処理の移行が完了していません。 ↩︎
Discussion