Remix+Drizzle on Cloudflare Workersで自分用の筋トレアプリを作り直した話+α

Page content

1~2年ほど前にNext.js+Prisma on Vercelで作った自分用の筋トレアプリを、Remix+Drizzle on Cloudflare Workersで作り直した。2025年11月13日の投稿分から移植先の新システムで生成した画像と文面で運用中。その開発の裏話を綴っていく。

はじめに

作り直したというか、正確に言うと、最近開発した毎日バンT管理機能英単語フラッシュカードの管理機能が搭載されているWebアプリの機能群の一つとして移植した。引用した日記の中でも語っているが、歴史的な経緯(笑)から、「筋トレ管理アプリ」だけはもともと独立したアプリとして開発・運用していたが、Tシャツやら英語の勉強やら「毎日実施」するタイプのアクティビティが他にも増えてきたことで、その辺の管理を1つのアプリで統合したいと思うようになり、それを実現させたことになる。

運用以外の側面でいうと、これも前の日記でも書いてるが、Vercelから卒業したかった。というのも今回の作り直しに拍車をかけている。Vercel上で稼働しているアプリは実はまだ1つだけ残っているのだがそれほど重要なアプリでもなく、実質的にこの「筋トレ管理」アプリのためだけに使っているような状況だった。今回Cloudflare Workersに乗り換えられて、Vercelから足を洗う算段がつき、肩の荷が下りた感じでほっとしている。ちなみに一応言っておくと(これも前から言ってるが)Vercelが嫌いになったわけではない。ただ自分用の主要なアプリは大体全部Cloudflare Workersで動いており、別のプラットフォームにあっちゃこっちゃと手を出したくない(管理の手間が増えるから)というのと、DNSやWAFやR2などで結局Cloudflare使うことになるのであれば、最初からCloudflareでいいじゃんというのが本音で、昨今自分の中でCloudflareへの移植が加速している。Vercelはアカウントは残すつもりだがしばらく休眠させることになると思う。

加えて、出来る限りNext.jsから離れたかった(Next.jsで作ってた諸々をRemixで作り直したかった)。フレームワークでは、昨今Remixにドハマリしており、逆にNext.jsには興味が失せてきているので、今回の移植に際してフレームワークの入れ替えも実施した。ただNext.jsは現在絶賛稼働中のアプリがこれとは別に存在しており、完全に一掃するには少し時間がかかる、のと、Next.jsそのものの動向もある程度追跡はしておきたいとは思っており、Vercelからの脱却程強い思いがあるわけではない。タイミングなどが一致して余力が出あるようであればRemixに置き換えて作り直すという動きは今後も実施していくつもりだが、今はNext.jsとRemixは佩用して開発していくつもりである。

同じ脱却の観点でもう一つあって、ORMのPrismaからも足を洗いたかった。最近Prismaは(個人的には)開発体験が若干落ちてきていて、あまり積極的に使いたいと思わなくなってきており、一方でDrizzleの開発体験がすごく良いことに気づき、今後の開発ではPrismaではなくDrizzleを積極的に使っていきたいと思うようになっている。この辺をもろもろ踏まえて、今回機能統合した既存のTシャツや英単語フラッシュカードの稼働するアプリではRemix+Drizzleを採用しており、今回の移植に際してそれに合流させた形である。

アプリ機能の移植

現行Next.js+Prismaで移行先がRemix+Drizzleなので、特にバックエンド側の処理は大々的に作りなおすことになった。どうせ作り直すことになるんだし、せっかくなら現行でイケてない部分を改修しちまおう、と、これにかこつけて手を加えた処理もいくつかある。ただ基本的なテーブル構造はそのまま流用したし、いうて割とマスメン的な機能も多く、作り直しもそこまで苦労があったわけではない。ポイントを抜粋していくつかここでご紹介する。

基本方針

現行はかっこつけて(?)API呼び出して処理する方式をとってる画面が大多数だったが、Remixではその方式はやめて、素直に各画面のloader()action()で全部のサーバー処理をするように変更した。この方がシンプルでいいよ。。このシンプルさがRemixの魅力でもあると思っている。開発してる途中、全画面の再読み込みではなく、クライアントからAPIをfetchしてコンポーネントの部分的な再描画をしたくなる場面もいくつかあったが、最初の方針に則り、必要なモンは全部loader()(とaction())で済ますよう徹底した。画面によってはサーバー処理がゴチャゴチャしちゃった部分はあるし、クライアント側の処理が全くないわけではないが、描画に必要なデータの取得・処理箇所と、描画処理部分は、すべての画面において同じルールで境界線がひけるようになっており、Next.jsの頃にくらべて格段に分かりやすくなったと実際感じている。

もう一つ気を付けたのが、サーバー処理も可能な限り軽めにするように心がけたことだ。もう少し具体的にいうと、サーバー以外でできることは、仕様上問題なければ原則全部そっちでやってもらうようにした。そっちというのは、主にクライアントやDBなどだ。これはCloudflare WorkersのCPU時間の制限によるものである。Workers上で実行できるサーバー処理の実行時間には限度があるが、クライアント側でやる分にはその範囲外だし、またfetchなどで外にコールしてレスポンスを待っている時間はCPU時間に計上されない(らしい、ドキュメントによると)ので、DB側で出来ることは僅かだとしても全部DB側に寄せた。例えば日付の加減算や画面表示用の文言加工など、DBやクライアント側で出来ることは全部そっちでやるようにしている。そんなに重い処理があるわけでもないので、正直それほど効果が出ているものとも思ってないが、心がけとしては良かったのではないかと思っている。

連続日数の管理

現行アプリは、「筋トレ連続実施日数」と「メニュー別筋トレ連続実施日数」を、それぞれ独立したテーブルにしていた。「何月何日の時点で何日間連続している」というような情報を保持しているテーブルである。個々の筋トレの実施記録のたびに、当日の筋トレ記録の最も細かい明細単位のトランザクションを集計して、毎回この辺のテーブルを作り直すという、割と真面目で面倒くさいことをやっていたのである。

この辺のテーブルを用意するのが面倒くさかった(正確に言うと用意したうえでテーブルのCRUD処理をコーディングするのが面倒くさかった)のと、そもそも大本になるトランザクションデータさえあれば、別テーブルで保持しなくてもやろうと思えばSQLだけで同じことできるんじゃね?という思考になり、それで実現することにしてテーブルを廃止した。実際、同じことはテーブルなしでもSQLだけで実装できた。ROW_NUMBER()で番号採番し、元日付からその番号引くと連続している分は同じ日付になるので、それをまたROW_NUMBER()で連続日数採番する、という流れ。文章にするとシンプルだがSQLは結構キモイことになっている(副問い合わせが4~5つ、60~70行くらい)。しかし、WINDOW関数って便利ですね。これあまり使ったことなかったけど今回駆使して便利さに気づいた。なお、こういう「特定区間の連続性をグループ化する」という仕組みは、 「ギャップ&アイランド問題」 (参考記事)といって、データ管理のユースケースとして比較的有名なものらしい。(この実装してて初めて知った)

ただ、上述の通りSQLが巨大でキモイことになっており、今後長期的にこのSQLをちゃんと保守できる自信がない(俺が自分で書いたのに…)。それと、このSQLは、過去分含めてすべてのデータが存在していることが前提になっていて、連続日数をカウントする場面で毎回全データをなめる必要がある。俺しか使ってないから大した件数はないが(15,000件ちょっと)、普通に考えると将来にわたって同じパフォーマンスを維持する保証はないし、むしろ劣化していくことになるだろう。なので本来論でいえば当初の仕様通り別テーブルにデータを保持しておくのが筋であるはずなのだ。正直、「SQLを書いてみたい」というプログラマーとしての欲求に負けただけである。そんなわけでそのうち別テーブル化しなおすかもしれない。幸いにしてこのSQLで「特定日時点での連続日数」は一括で取得できるので、別テーブルにした場合でも移行は比較的容易のはずだ。筋トレの記録タイミングでこの辺のテーブルつくるトランザクション処理が面倒くさいなあ~~~って感じだが、まあそこまで難しくもならないだろう。多分。

ちなみに余談なのだが、これ当時別テーブルで作る実装書いてて思ったのだが、色々考え出すとこの辺の仕様は非常に面倒くさい。特に 「過去日の登録を削除した場合」に連続日数をそこで途切れさせるための仕組み の実装が非常に厄介だったというか今でも正解がわかっていない。「連続日数を管理するアプリ」は市場にも色々出回っていて、例えばスタディサプリとかもそうなんだが、当時その辺のアプリや機能の開発記録を探ったり、実際にアプリを触って仕様を確かめたりしたが、この辺の機能の仕様の実装例や、ベストプラクティスなどが見当たらなかった。「過去日の登録を削除」してその日が歯抜けになったら、その翌日から再度1~で採番しなおす必要があるが、画面操作で削除が手軽にできる一方、裏のデータの手入れは手間がかかりそうで、しかもそれが連続日数がかさめばかさむほど、それを画面機能で同期的に実施するのは無茶な仕様だと思われ、必然的に非同期バッチ処理とかにすることにしないとなーと思っていた。が、そもそもそんな操作そんなに多頻度で実施するか?そのためだけにバッチなんか使って大掛かりな仕組み作らなきゃいけないのか?と自問すると、所詮俺専用のアプリ、それほどでもないなと落ち着いて、結局この仕様は断念して運用していた。このため、特定の日のトレーニングを後日全部削除しても、その日は「連続で実施していた」とカウントされたままになる(連続日数がそこで途切れない)。実際それで困ってないし、現状毎日筋トレを絶やすつもりはないので、そもそもこの仕様が発動する機会は運用上訪れることはないはずなのでこれで全然問題ないのだ。だが単純に一人のエンジニアとしてこの仕様の実現には未だに興味があって、それが実現できていない現状はもどかしく感じてもいる。ちなみにSQLだとこの点がクリアできて、毎回テーブルの全レコードを過去分含めて全部なめて採番するから、過去の特定日が消されても動的にその場で採番しなおしが可能になっている。だからSQLがいいってわけではないが、SQLにしたことでこの懸念は一時的にクリアできていることにはなっている。こういうのって各社・各アプリはどうやって実装している・あるいは仕様に制限かけるなどして妥協・折り合いをつけているのだろうか?純粋に気になる。実際の事例が知りたいところである。

validationの変更

現行はvalidationをyup+Formikでやってたが、これをzod+Remix Validated Formに変更した。ただ、別に変更したかったわけじゃなく、たまたま最初に調べた例がzodだったので流れでzodになっちゃったという感じで、yupに不満があったわけでもzodに興味があったわけでもない。そもそも俺専用アプリにvalidation自体必要か?っていう疑問が湧くレベルでもあるんだが、自由入力を可にしちゃうとどうしても意図しない文字種が入り込んでしまうことはあり、全面的ではないが必要な箇所に関してはvalidationは導入している。

yupもzodもそんなに使い込んでいるわけじゃないが、一応両方経験した身として言うと、使い勝手的には個人的にはわずかながらyupが勝る(zodは若干使いづらいと感じる)。特にチェックボックスの扱いがzodは特殊だと感じる。yupだとyup.boolean()で直感的に処理できたが、zodで同じこと(zod.boolean())するとvalidtionが落ちる。どうも未チェックだと空文字('')が来て(というか要するに"何も来なくて")、チェックしたときは"on"という文字列が来るようだ。(参考記事)この空文字のほう(未チェックの時)が厄介で、まとめてz.string()だと受け止めてもらえずvalidationで意図せずエラー扱いになってしまうので、チェック時はz.string().transform(value=>value==='on')で、未チェック時はz.literal('')で、それぞれ判定するようにして、2つをz.union()でくっつけるという、なんとも切ないvalidation定義が必要になった。本当にこんなことせにゃならんのか?(なんか間違ってるんじゃないか?)と未だに疑問である。これはzodというよりValidatedFormのせいなのかも?良く調べていない。。

一方、相関チェックの書き方(zod.refine()でまとめて定義できるところ;参考)は、zodのほうが個人的には分かりやすくて好き。yupだと同じようなチェックをフィールドごとに書かなければならなかった記憶があり、冗長な感じがしてあまり好きではなかった。この部分はzodが個人的には勝る。

あと、これはValidatedFormのほうの不満だが、validationのタイミングがボタンクリック以外に存在していないのが個人的にちょっと気になる。Formikだと項目移動やフォーカスした瞬間などを割と事細かに設定できたが、試した感じではValidatedFormのほうはそういったオプションが存在せず、ボタンクリック時(Formのsubmit時)にしかvalidationが走らない。まあやろうと思えばonFocusChangeとかで意図してvalidate実行すればいくらでもなんとでもなるが、そうだとしてもFormikと比べると使い勝手が悪い。

ただまあ不満点は上記の部分くらいで、他は大体yup+Formikと同じような使い勝手で実現できており、全般的に強い不満があるわけではない。フロント・サーバーで定義流用できる部分も同じだし、大まかな使い勝手は(少なくとも個人利用の範囲で言えば)ほぼ同じだと思う。最初にかいた通りどっちに強い思い入れがあるわけでもなく、ぶっちゃけるとどっちでも良いのだが、まあいい経験になったかなと思っている。その程度の感想である。

classの使用

これは今回の移植の時にたまたま発覚した話で、もとをただすとこのアプリそのものに(つまりTシャツ管理や英単語のフラッシュカード管理機能を作った段階で)潜在的に潜んでいたものだった。なので筋トレ管理機能の移植が問題だったわけではない。意図せず問題を露呈させる引き金にはなってしまったので、タイミング的にちょうどいいのでここで語ることにする。

Cloudflare Workersのようなサーバーレス環境では、リクエストごとにTCP接続の使いまわしができない。したがってDBコネクションはリクエストのたびに生成する必要がある。これを最初 「処理のたびにDBコネクションの生成が必要」 だと勘違いして、DB系の処理の前に毎回必ずDBコネクション生成していた。そしたらとある画面で"A stalled HTTP response was canceled to prevent deadlock. This can happen when a Worker calls fetch() or cache.match() several times without reading the bodies of the returned Response objects. There is a limit on the number of concurrent HTTP requests that can be in-flight at one time. Normally, additional requests made beyond that limit are delayed until previous responses complete. However, because the Worker did not read the responses, they would never complete. Therefore, to prevent deadlock, the oldest response was canceled. To avoid this warning, make sure to either read the body of every HTTP Response or call response.body.cancel() to cancel a response that you don't plan to read from."という長ったらしいエラーが出た。 あるリクエストの中でDBに合計して6~7個のSQLを発行する処理があって、それがCloudflare Workersの外部接続数の制限を超えてエラーになった。直接的にはこれが原因だが、根本的な原因はDBコネクションの生成タイミングを誤解していたことにある。このことはこのQiitaの記事で語っている。

で、これを改善するべく、ある業務(?)処理の単位で、DB処理をそれぞれ別のclassに分離することにした。例えば「Tシャツ管理」とか「英単語フラッシュカード管理」とか「筋トレ管理」とかそういった単位である。それぞれの処理単位が1つのclassになり、classはコンストラクタとしてDBコネクションを受け取り、そのclass内の処理ではコンストラクタで初期化されたDBコネクションを使いまわす。特定のリクエスト(loader() or action()の処理)の中でDBコネクションを生成してそれをこのクラスのコンストラクタに渡してインスタンス化して使用すれば、少なくともそのリクエストにおけるその業務処理の中ではDBコネクションは使いまわしになり、新たなDBコネクションを必要としない。こんな感じである↓

import { NodePgDatabase } from "drizzle-orm/node-postgres";

export default class Hogehoge(){
  db:NodePgDatabase
  constructor(db:NodePgDatabase) {
    this.db = db;
  }
  public async getHogehoge(params:{id:string}) {
    const r = await this.db.select().from(hogehoge).where(eq(hogehoge.id,params.id));
    return r;
  }
}

で、このクラスを、loader()とかで以下のように使う↓

import Hogehoge from './services/db/hogehoge';
import {getConnection} from './services/db';
export async function loader({request}:LoaderFunctionArgs) {
  const hogehoge = new Hogehoge(getConnection());
  const hugahuga = await hogehoge.getHogehoge(id);
  // ...
}

ちなみにgetConnection()はDrizzle使ってるだけである↓

export function getConnection() {
    db = drizzle(process.env.DATABASE_URL! , );
    return db;
}

「リクエストのたびにDBコネクションを生成するが、そのリクエスト内ではそのDBコネクションを使いまわす」というのを実現するための実装で、一番直感的でわかりやすくかつ手軽にできそうなやり方が個人的にこのclass作戦だったので、これをとったのだが、他にもう少しスマートなやり方がありそうな気はする。単純なWorkerのfetchエンドポイントのようなものとは違って、RemixのようなWebアプリ(正確に言うとRemix starter template)の場合は、これがうまい具合にラップされてて、実態としてどこがリクエストのスタートで、どうやってloader()とかaction()まで来ているのかがよくわからず、追跡するのも面倒だった(し、それを突き止めたかったわけでもない)ので、出来る範囲で取れそうな案のこれに乗っかることにしたのだった。

これで言うなら、究極的にいうと「DB操作クラス」みたいなのを一つ用意して、その中にすべてのDB処理を定義して、リクエストの最初に1回DBコネクション生成してあとはそれを(リクエストの中ではずっと)使いまわす、っていうのが出来れば理想的だったんだが、親のルーティングのloader()が子のルーティングのloader()の時にも連鎖的に呼ばれるという(多分Remixの)仕様上、完全にこの部分を制御することができそうになかったし、何より異なる業務処理を一つのクラスでまとめて管理するのでは、コードの保守性も下がるし1つのソースファイルが巨大になりすぎる。業務処理の単位で分割しておくのがわかりやすくていいだろうと踏んだ。これは正解だったと感じている。

一方、個人的にTypescriptの、というかjavascriptの「class」って使ったことがほぼなくて、これが初めてに近い体験だった。ただ、感想としては、新鮮な気持ちというより「違和感」のようなもの、つまり負の側面のほうが強い。TypescriptではなくてJavaを書いてるような気分にさせられて、正直あまり楽しいっていう感覚はなかった。「こういうの書きたかったんだっけ??」と自問するとどうにも首を縦に振りづらいというか。。言語化が難しい。(Javaが嫌いなわけではありません、悪しからず)

その他の話

  • 実施した分の筋トレ記録を集計・表示する画面があって、現行ではこの画面をhtml2canvasで画像にしてダウンロードする機能を実装していた(その画像をTwitterのリプに添付画像としてつけている)。同じ機能を作り、見た目の装飾(Tailwindcssのクラス指定)も現行と全く同じにしたんだが、どうにも同じスタイルで画像出力されなかった。(ほぼ同じなんだが微妙に違う)まあ画面全体のデザインがそもそも少し違うのと、Tailwindcssのバージョンも少し違うので、多少の誤差は出てもしょうがないかと思っていたし、実際見た目で分かれば別にいいんじゃねという感じだったので(そもそも完全同じ見た目で出力することを求めてなかった)、少し微調整して終わりにしたが、Tailwindcssのクラス指定が全く同じなのに同じ結果にならないというのに、なんとなく一抹の疑問はある。
  • テーブルの構造は(ほぼ)現行と同じなので、原則そのまま横流しで移行した。一点、PrismaだとUUIDはtext型になる一方、Drizzleだとしっかりuuid型でテーブル生成されるので、::uuidでキャストが必要にはなった。あと、PrismaもDrizzleも、「マスタとトランの間の紐づけ」みたいなことを定義すると、両テーブル間で対象となる紐づけ項目に外部キー制約が張られるので、先にマスタを移行しなければいけなくなる(トラン側だけ移行すると外部キー制約違反(マスタなし)でエラーになる)。まあ些細な話だが。。
  • 正確に言うと「筋トレ」ではないのだが、これまた歴史的な経緯(笑)から、現行システムには「体重記録」「ランニング実施記録」の簡単な機能もくっついていたので、今回それも一緒に移植している。ただしどちらもシンプルなCRUDしか持たない機能で、筋トレ管理機能の本体ほど複雑な仕様もないので、移植はそれほど苦ではなかった。以後これらの記録管理も移植先のCloudflare Workersのアプリの方で実施していく形としている。
  • 自画面遷移したときに前画面で入力した値が意図せず残存する問題に少し悩まされた。自画面遷移する機能は現行の(Next.jsの)画面にもあったが、サーバー処理をAPIで実行している関係で、画面遷移はクライアント側でlocation.href=...とかで実行していた。一方、Remixアプリの場合はaction()の最後でredirectで自画面のパス指定して遷移しており、要するにサーバーサイドで画面遷移を実行していたので、よくよく考えると両者で遷移の仕方が違っており、この違いで露見した問題なのだろうと思われる。逆に言えばNext.jsのアプリでも同じような組み方したら再現した可能性はある。これは要するにReactがコンポーネントの再マウントをするかどうかという話であり、厳密に言えば恐らくRemixは関係ない。対象のFormにkeyパラメータを付けることで解決した。こちらのQiitaの記事に簡単にまとめている。

Remixアプリとしての残課題

筋トレ管理機能の話というか、この機能が搭載されているRemixアプリ(Tシャツ管理機能、英単語フラッシュカード管理機能含む)全般の話なんだけど、どうも稀に変な挙動が見られて困っている。

  • react-selectのCSS読み込みができなくて画面が崩れることがある。これは画面側でTailwindcssで指定したclassではなくてreact-select自身のスタイルのこと。大体再読み込みすれば直るのでキャッシュ周りに何かある気がしているが、明確な原因がわかってない。不思議なのは、他のスタイル(画面側で明示的にTailwindcssのクラス指定しているところ)では、同じ現象が起きないことだ。なぜかreact-selectでだけ起きる。これはローカルでも何回か再現してるので、Remixアプリとの相性の問題(あるいはRemixアプリでreact-selectを使う場合の考慮漏れがある)な気がしているが、「再読み込みで直る」のであまり重要視していない。だが気になる。
  • Tシャツ画像のファイルアップロードは、前面分と背面分で2枚分の画像をアップロードできるが(前面は必須、背面はオプション)、前面・背面両方指定したときに、前面指定分が無視されて、背面指定分と同じ画像が勝手に使われることがある。つまり、前面・背面それぞれ指定したのに、アップロードすると両方とも背面画像になってしまう。ここだけ見るとコードのバグを疑いたくなるが、厄介なことに起きるときと起きないときがあり、再現性がない。ただ、ローカルでデバッグしてる分には(30回くらいやったんだが)起きないので、こっちはRemixアプリというよりCloudflare Workes特有の事情がありそうな気もする。また、どうもスマホからやったときにだけこの現象が起きる傾向が強く、スマホの場合の考慮漏れがある可能性もある。これも機能上後から修正可能にしているのでクリティカルな問題にはなってないが、それはそれで手間だしなにより現象が謎なので、できるならどうにかしたい。

他にもいくつか問題視している課題はあるが、とはいえ結局俺しか使わないので所詮大した話ではない。時間みつけて適当に調査してみようと思う。

今回の筋トレ管理機能の移植で、この「俺専用管理アプリ」に搭載したかった最低限の機能は組み込めたので、基本的には以後運用保守モードに入る。いくつか追加したい機能や仕様はあるので、それはそのうち、作り込んだらまた日記にするかもしれない。では。