自分用の筋トレアプリをNext.js+Prisma(+Tailwind css)で作り替えた…際の苦労話

Page content

毎日やってる筋トレを記録する自分用のWebアプリをNext.js+Prisma(+Tailwind css)で作り変えた。もともと2023年中には完了させるつもりだったんだが、自作漫画RESIGN THREATのほうが面白くてそっちに注力してしまい、こっちのほうは優先度が下がってしまった。2023年の終わりくらいになってようやく心機一転(?)短期集中型で一気に仕上げて、2月上旬ごろに移行が完了、以後1か月とちょっと使ってみて、それなりに運用できる形にはなったという確信を得たので、この記事でその振り返りをする。基本的にはその開発中に発生した苦労話がメインである。

概要

ことの発端は2022年8月末頃の日記でボヤいてたこれで、もともとExpress+SequelizeというORMで作ってた割と雑なNode.jsのWebアプリだったので、内外含めてもっとモダンな形に作り直したいという欲があって、その開発を実施したという流れである。基本的な設計思想は当時から変わっていないが、当時からイケてないなと思っていた部分の改善や、運用でカバーしていた部分の機能化、それらに伴うテーブル構造の変更、Next.jsの採用による内部構成の大幅な変更などを含むため、完全移植できたロジックはほぼ0で、基本的には新規開発に近かった。まあそうやって新しく作る(作り直す)のが好きなんだろう。ひなっちのおはっちをリメイクしたときもそんな感じだったし。

そんなわけで1つのWebアプリを1から作り上げた感じになったんだが、Web+DBという構成のアプリを、Next.js+Prismaという今をトキめくキーワードで作るにあたっては、実際のところ結構いろいろな苦労があった。その辺の話をざっくばらんに挙げて供養する。この記事に書かれている内容は、この開発に着手し始めた2023年初旬くらい?から、気づいたときにちょいちょい書き足して修正して…を繰り返して育ててきたものなので、最古のものだと恐らく1年以上前のネタが含まれている。その間にブログを移行したりPC買い替えたりしており、個人的な視点ではいろいろな出来事を潜り抜けて公開に至ったものになるので、「ようやく解放してあげることができた…」みたいな、 封印を守るという役割のみを与えられて生きながらえていたゴーレムが役目を終えて天に召されるような、 少し感慨深い気持ちである。まあとはいっても基本的には「開発困りごとメモ集」なので、これが同じことに困ってる誰かの目に留まればよいなと思う。

構成

ざっくりとは以下のような構成になっている。

  • ホスティング:Vercel
  • DB:Supabase
  • 認証:Auth0
  • DNS、WAF:Cloutflare
  • その他:Lambda、S3

この構成の基本的なスタンスとしては、Vercel+Supabaseを体験してみたかったという思いが強い。セ●クスに憧れる童貞の中学生みたいな、そんな純粋な思いが始まりだった(?)Vercel自体は他の個人開発でも使ってるんだが、Supabaseを実際に運用で使ったことがなかったので、これを機会にちゃんと使ってみたい、と思うに至った。

Supabaseは完全にDBとしてのみ使用している。無料のPostgresqlとしては基本はこれで満足している。ただ、1週間だかなんだか無操作だとinactiveになるという仕様があり、前にも書いたがこの「無操作」の定義があいまいでよくわからないという問題がある。筋トレは毎日やってる=つまり必ず1日に1回はCRUDしているので、おそらく問題はないはずで、実際2週間ほど、当時の現行システムと並行運用してinactivate云々について警告受けなかったので、まぁ大丈夫じゃないかと思っている。もしこれでもどこかでinactivateになった場合はAivenあたりにでもいくか…とかぼんやり考えていたりはする。なのでそういう意味ではこの部分の構成は若干グレーではある(そのうち乗り換えするかもしれない)。

CloudflareはWAFを使いたいために採用している。逆に言うとこのアプリでそれ以外の使用目的(CDNとしての用途等)は何もない。旧版時代から、我が家のグローバルIP以外からのアクセスを遮断するようにWAFを設定していたので、それをそのまま引き続き使用している形である。これにより俺専用になっている。旅行とかいったときは一時的にWAFを解除して外からアクセスできるように変える。ちなみに、こういうキツ目のWAF設定を施す場合、Vercelが案内している通り、CloduflareのPage Ruleに/.well-known/acme-challenge/*(Let’s EncryptのACME Challengeのパス)だけは通すように許可しておかないと、Vercel上でドメインの設定不正で警告が出るので注意だ。っていうか実際注意が出たので注意だ。(?)そんなわけでこのPage RuleだけはIP制限のルールよりも上に位置しており、優先して通すように設定している。

認証にはAuth0を採用。もともとは、passport-local使ってDBのユーザーマスタを参照しセッションをRedisに格納する、という超簡素な自前認証機能を使っていたが、それをAuth0に全部任せる形にした。これによりRedisの必要性がなくなった。Auth0は(ケチなので)カスタムドメインを使用していないため、URLさえ知っていれば誰でもアクセス可能な状態になっているが、現実的には誰も来やしないだろうし、コールバック先が上記の通りCloudflareのWAFで弾かれる構造なので、実際のところAuth0の画面にだけ来られたところで特に問題はない。かつ、Disable Signupsを設定してユーザーの新規登録をできなくしているので、万が一Auth0の画面に来られても基本的に何もできない。もちろん、本当はカスタムドメイン設定して完全隠蔽できるのがいいのだが、それはお金かかっちゃうし、上記の通りで現状別に大きな運用上の問題も起きないだろうし、一旦はこれでいいかと思うに至った。

その他のECSとS3はデータバックアップ用の運用機能として使用している。旧版アプリでは、psqlCOPYコマンドでCSV形式でデータ抜いてきて、tar.gzに固めてS3にアップ、ってな流れで(要するにマネージド機能じゃなくて手動のスクリプトにより)バックアップ取ってたので(自分専用なのでその程度の処理で事足りるのだ)、今回に関してもこれをそのまま流用することにした。しかし、Vercelはサーバーに入ってスクリプト流すみたいなチョメチョメができそうにないので(※)、「shellスクリプトを流すマネージド環境」が欲しかった。現行のスクリプトがあるので、できればそれをそのまま流せる環境が欲しい。…ってなるとコンテナになっちゃうんだよね。。個人的にあんまりコンテナ使いたくなかったんだが(なにかと手当が面倒になる気がする)、かといって今更Node.jsでnode-pgとか使ってバックアップの処理書くか??と言われるとそれはそれですげー億劫だし、shellスクリプトで実行できるならそっちのほうが手軽でいいしな…ということでコンテナにした。後述するが、最初はLambdaを試したんだが、すっげー面倒くさくなってやめてECSにした経緯がある。まあ、興味があれば…

一応断っておくと、現行のHerokuから別のプラットフォーム(群)に移行したのは、別にHerokuが嫌になったからとかそういう理由ではまったくない。むしろHeroku大好きなほうだ。特に上に書いているバックアップのスクリプトを頑張って運用に乗せようとしてるところとかでHerokuの素晴らしさを知った。HerokuはいいPaaSですよ。。ただ、上に書いた通りでVercel+Supabaseの運用をしてみたいという思いがあり、かつSupabaseの「1週間無操作だと非activeになる」という制限に対しては、「毎日筋トレしする」という運用上の特徴が結構うまくハマりそうだった。一方で、これとは別にまた自分専用のアプリを作って運用に乗せたい思いがあって、そのプラットフォームをどこにするか決めかねていたところで、筋トレアプリの変更によりHerokuに「空き」が出るため、ちょうどいいから引っ越すか、という形になった。なのでHerokuはHerokuで継続して使ってるのだ。

苦労話

Prisma関連

ORMとして今回初めてPrismaを使った。基本的にはスムーズな開発体験であり、大きなストレスはなかったが(少なくとも前使ってたsequelizeより断然いい)、いくつか詰まった部分があったので備忘録として残す。

relationの設定

テーブル間に関係性を持たせてINNER JOINとかで結合してデータ持ってこれるようにしたい、という場合のprisma.schemaの記述方法。 一応基礎的な部分は公式サイトに解説はされているんだが。 親側(結合元)、子側(結合先)のテーブルに何をどう書くか、というのがイマイチよくわからなかった。 要するに、親テーブル(例としてここではoyaとする)と子テーブル(例としてここではkoとする)があったとき、

  • oya側の定義にはko側のテーブルの名前を配列で記述
  • ko側の定義には@relation(fields: [子側で結合につかう項目名], references: [親側に結合するときに使う項目名])の形で関係性を記述

とする。例を書くと

model oya {
	id String @id @unique @default(uuid())
	name String
	ko ko[] 
}
model ko {
	id String @id @unique @default(uuid())
	ko_name String
	oya_id String
	oya_data oya @relation(fields: [oya_id], references: [id])
}

って感じになる。 oya側にあるko ko[]は親基準で子を結合して子テーブルの項目を取得してきたときの子側の項目情報が入れるための定義。 ko側にあるoya_idoyaidと結合するための項目。 oya_dataは、「親への参照」を指す抽象的な項目名で(このため型が親のテーブル名であるoyaになっている)、実際にこう言う名前の項目がテーブルに作られるわけではない。 @relation(fields: [oya_id], references: [id])fields: [oya_id]自分の(この場合koの) のうち「結合に使う項目」を指定し、references: [id]結合先となる相手の(この場合oyaの) のうち「結合に使われる項目」を指定する。 つまりこの部分で疑似的に... FROM OYA INNER JOIN KO ON KO.OYA_ID = OYA.IDという結合条件を指定しているわけだ。

特にこの@relationの部分が、fieldsとreferencesでどっちにどっちを書くのかが公式の例読んだだけだとわからなくて、困った。(よく読んだらわかったが) あと、fields: [oya_id]みたいに、結合対象項目を配列的な記述で表記する以上、複数項目で結合するようにもできるんじゃないかと思ってたんだが(実際fieldsもreferencesも複数形だしさ)、試した感じできなかった。 まだ対応してないとか?? それとも記述の仕方が悪かっただけ?? まあ基本全てのテーブルのKEYをUUIDにしてるので、UUIDで結合できるからここは個人的に問題にはならなかったが、単純に疑問ではある。 複数項目のrelationの定義ってできるのかな??

relationの設定・続(外部キー制約による弊害)

↑の続きなんだけど。 このような形でテーブル間にrelationを持たせると、少なくともPostgresqlにおいては、親側・子側の結合対象項目に対して外部キー(FOREIGN KEY)が設定される。 このため、例えば「ユーザーマスタ」と「(ユーザー別)取引テーブル」みたいのを作って両者で「ユーザーID」をrelationとして設定した場合、

  • 「(ユーザー別)取引テーブル」だけを単独で作成できない。(先に「ユーザーマスタ」に該当の「ユーザーID」をもつレコードを作っておく必要がある)
  • 逆に「ユーザーマスタ」だけを単独で削除できない。(先に「(ユーザー別)取引テーブル」を削除できない)

という、(考えてみれば当たり前なんだが)制約ができる。 これが正直ちょっと個人的に鬱陶しくて、特にjestで単体テストしてる際に、テストデータを作ったり消したりするときに若干面倒くさかった。 まあ種がわかっちゃえばなんてことはないので、開発作業に支障をきたすレベルのものではなかったし、 逆に「そういや外部キー制約貼ってるとそうなるよな…」っていう、自分への気づきが出来たことは、開発者視点からするとむしろ良い発見もあったんだろうが。 個人的に今まで「外部キー」を積極的に使ってこなかったし(あるにはあるが、あまりいい思い出がないのでできるなら個人的には付けたくない)、 実際Prismaに使われるまでは使おうという発想自体頭になかったので、若干手間取ってしまった。 これオプションで外部キーつけないみたいなこと出来たらいいのになあ。

あとこれ例えばMySQLとか他のDBだと違ったりするのかな?Postgresqlでしか試してないので、それが気になる。だからといってDBを変えるつもりはないんだが、単純にPostgresqlのときだけの挙動なのかどうか?という好奇心がある。 調べれば出てくるかな…

計算しながら集計する方法

いわゆるselect id, sum(num1+num2) as num from input group by idみたいなこと。これをPrismaを使って実装できるか?というのが気になっていた。 これはよくわからなかったので、GithubでDiscussionあげて聞いてみた。 結論からいうとこれは現時点ではできない(サポートしていない)ようだ。残念。

実は今回俺がやりたかったのは、このDiscussionに書いてる例のように、単純な数値項目の総和ではなく、Interval項目の総和だった。 つまり2つのtimestamp型の項目、仮にそれをstart_datetimeend_datetimeとして、その2項目のIntervalの総和をとること、すなわちsum(end_datetime - start_datetime)がやりたかった。 この人の回答くれた例だと、一度それぞれの項目を足し込んで、あとでmapすればいいという話だが、 実際はstart_datetimeend_datetimeもいずれもHH24:MI:SSフォーマットの文字列で定義していて、 集計するときにto_timestampで動的にtimestampに変換しつつinterval取るようにしていたので、 start_datetimeend_datetimeの間で日を跨ぐ場合、このやり方だと正しく日が判定できない。 よって、仕方ないので生SQL書くことにした。

$rawQueryって使ったことなくてよく知らなかったんだが、あと出来れば使いたくなかったんだが-というのもPrismaの強力な型サポートが得られなくなりそうで嫌だったので-、 どこかで使う場面も出てきそうだし、経験のために使ってみるか、ということで手を出した。 そして感動した。 これジェネリクス指定してあげればちゃんと戻り値の型も定義できるんだね。 感動だ。 よく作られている! SQLの中身と指定したジェネリクスが極端に違反してるとどーなるんだ?みたいのは少し気になるんだが、Datebigintの違いくらいなら全然吸収してくれた。 まあ個人的にはここは、SQLのreturnと戻り値の型定義を、実装者の責任でちゃんと合わせろという気はするが。。

一つ困ったのは、$rawQueryintervalをサポートしていないらしいこと。 上の例で言うと(start_datetimeend_datetimeが両方ともtimestamp型である前提で) sum(end_datetime - start_datetime) を実行すると、以下のようなエラーが返ってくる。

Raw query failed. Code: `N/A`. Message: `Failed to deserialize column of type 'interval'. If you're using $queryRaw and this column is explicitly marked as `Unsupported` in your Prisma schema, try casting this column to any supported Prisma type such as `String`.`

ってなわけでどうするかというと、Intervalからエポック秒を取り出して乗り切ることにした。以下のような感じ↓

sum(extract(EPOCH from (end_datetime - start_datetime)))

これだと「秒」の数値が返ってくることになるので、HH:MM:SS形式で取り扱うなら、この後アプリ側で加工が必要になるんだが、別に大した話じゃないので、ここまで出来ていれば個人的には問題なし。 勿論直接Intervalが扱えるほうが楽ではあったので、これは今後に期待したいところだ。

Transactionを別のメソッドに引き渡す

要するにprisma.$transaction((tx)=>{...txを他のメソッドに渡したかったのだが、このtxがどんなオブジェクト(型)かイマイチわからず、どうやって渡せばいいのかなーと思っていた。 いわゆるPrismaClientとは違うらしくて(というか実際違くて)、渡される側をfunction hogehoge(prisma:PrimsaClient)で定義してtxを渡すとコンパイルエラーになった。 色々調べたところ同じようなこと悩んでるissueがあった。
https://github.com/prisma/prisma/issues/10880
https://github.com/prisma/prisma/issues/11511
10880の人は結構物凄くて、自前でTransaction用のClientをexportしているようだが、このissueを追跡してみると、最終的にPrisma.TransactionClientなるものがあることを知った。 で、渡される側をfunction hogehoge(prisma:Primsa.TransactionClient)で定義したらtxをそのまま渡せた。これで終了。はい。

$queryRawの戻り値のbigint型をそのままAPIに渡したら死亡

まあタイトルの通りなんだが、$queryRawで集計系のクエリを投げると、対応する結果カラムの戻り値がbigint型になるんだが(参考)、 bigintはserializableではないので、JSON.stringify()とかに無邪気に渡すとTypeError: Do not know how to serialize a BigIntというエラーが返ってきて死亡する。 意図的にJSON.stringifyを呼ばないようにすれば回避可能なんだが、APIの戻り値としてこれをres.json()にそのまま渡したところ、同じエラーが出て死亡した。 どうやらres.jsonは内部的にJSON.stringifyを呼び出しているらしい(言われてみれば、まあそりゃそうかって感じではある)。 bigintをそのままの型では扱えないので、このQiitaで紹介されている内容に則り、number型に変換して取り扱うことにした。 基本numberの範囲の値しか扱わないのでこれで個人的には満足しているが、ただ「暫定対応」の感は否めず、もうちょっとどうにかならんかったか~という気はしないでもない。 bigintについて詳しくないのが悪いんだが…… できるならそのうち直してみたいところだ(っていう感じだと間違いなく数年塩漬けにされるんだがw)

$queryRawで動的な条件をつける方法

画面の検索条件にAとBがあり、Aは必須だがBは任意とする。で、Bが入力されたときだけ、Bの条件を動的に加えたい。という場合。findManyとか使ってる分には、手前でwhereの条件を動的に組み立てるとかなんとかして渡せばそれでよかったんだが、$queryRawだと生のSQLをべた書きするので、逆にこの分がうまくいかなかった。昔のJavaのStringBuilderappendしまくって動的にSQL組み立てるのと同じ発想で

await prismaClient.$queryRaw<AType[]>
`select
 from test
 where aaa = ${aaa}
   and ${bbb != null ? bbb : ''}
`

みたいな書き方を最初はしてたんだが、これだと${bbb != null ? bbb : ''}の部分全体がバインド変数と取られるのでSQLエラーになる。まぁ考えてみればそりゃそうかという感じではあるが。調べてみると似たようなことに悩んでる人は他にもいて、例えばこのissueとかこのredditとか。いずれのケースでも$queryRawUnsafeが回避策で与えられているが、せっかくPrisma使ってるのに$queryRawUnsafeを使うのもなあ。。という感じ。かといって1行で矛盾しないようにバインド変数を組み立てる方法がわからない…と思ってちょっと考えたら以下のような書き方でいけた。

await prismaClient.$queryRaw<AType[]>
`select
 from test
 where aaa = ${aaa}
   and bbb = case when ${bbb} != '' then ${bbb} else bbb end
`

case when ${bbb} != '' then ${bbb} else bbb endは、変数${bbb}が空白でない(何かしら画面から条件が指定されている)ならそれを使い、そうでなければ自分自身の項目を条件として使え、という指示。つまり、画面から条件が指定されていない場合、条件句をbbb = bbbにすることで、100%すべての行でtrueにさせる(ことで事実上この条件をないものとして動かす)。可能ならこの行自体を${bbb}の有無で動的に追加するように制御したかった(そっちのほうが可読性が高い気がする)ので、そういう意味では個人的にも完全にイケてるソリューションだとは思ってないんだけど、まあ動いたからこれでいいか、ということにした。他に何かいい感じの案があったら教えて下さい。

項目設計関連

かっこつけてnumeric(PrismaのModelだとDecimal)でテーブル項目つくったら、画面から「71.50」で入力した値が「71.4999999999998」とかになって乾いた笑いが出たので、全般的にDecimalの採用はやめた。こういった項目は全部textで保持するようにした。もはやそっちのほうが扱いやすい。

関連として、システムカラム系以外ではtimestampなど日付系の型を使用しないようにした。アプリで発生するデータに関しては、日付も時間も全部textで保持する。調べたらPrismaは日付や時間の型を扱う場合の考慮事項が色々と多くて大変そうだったので、いっそtextで保持しちゃったほうが楽だという結論に至った。

というわけで、アプリのデータは全部textintegerのいずれかで保持する形になったので、結果的に結構シンプルになっている。面倒くさいことを避けて安定的な方に舵を切った結果であり、これ自体は個人的に良い決断だったと思う。ただ、シンプルといえば聞こえはいいが、Prismaの機能をうまい具合に使いきれなかったという話でもあるので、ここは(別アプリつくるとかそういう話とは別に)もう少し個別に踏み入って整理したいところだ。

React Hook Form関連

最終的にformik使う形でフォームの入力項目を全部Fieldタグで書き直したので、ここ↓に書いてある課題のほとんどは考える必要はなくなった。まあ、備忘録という事で。。

handleSubmitの勘違い

handleSubmitの使い方を勘違いしていた。 「こいつのせいで処理が止まって延々submitされねーなぁ~」とか、今にして思うとあほなところに詰まっていた。 onSubmitで定義した関数内で最後にreturnしてみたりとか、大分見当違いというか、無駄なことをやっていた。 これ、「submitの手前でvalidationが通った内容を確認する」目的のためだけに存在してるんだな。。(つまり、本番運用においては実装不要。デバッグのために存在しているという理解) どおりで公式サイトとかブログの記事見てもみんな処理の中身が console.log だけになってるわけだ。。 これからsubmitするのになんでわざわざconsole.logとか使わないといけないのかなーとしばらく疑問だった。あほ。

inputタグへの文字入力が反映されない

inputタグに画面上で入力した値が反映されないという問題があった。 打っても打っても未入力のまま、テキストボックスの中身が一切変わらない。 原因ははっきりとよくわからないが、どうも各inputタグにvalue属性つけててバリデーションと絡めるとこういう問題が起きるらしい。 こちらのブログが参考になった。 要するに、各inputタグに対応する値をuseStateで定義して、それぞれのonChangeイベントでvalueをsetする のではなく、 すべてのinputタグに対して同じonChangeイベントの関数を指定して、中で値を動的に各inputタグに値をセットするように変える。 んで、各inoutタグからはvalue属性をはずす。 つまり

  const [test, setTest] = useState('');
  const onChangeTest = (e:React.ChangeEvent<HTMLInputElement>) => {setTest(e.target.value)};

  const [hoge, setHoge] = useState('');
  const onChangeHoge = (e:React.ChangeEvent<HTMLInputElement>) => {setHoge(e.target.value)};

  ...
  <input type="text" name="test" value={test} onChange={onChangeTest} />
  <input type="text" name="hoge" value={test} onChange={onChangeHoge} />

じゃなくて

    const [form, setForm] = useState({});
    const onChangeFormValue = (event:React.ChangeEvent<HTMLInputElement>) => {
        setForm({
            ...form,
            ...{[event.target.name]: event.target.value }
        })
    };
    ...
  <input type="text" name="test" onChange={onChangeFormValue} />
  <input type="text" name="hoge" onChange={onChangeFormValue} />

みたいにする。これで乗り切れた。

謎のコンパイルエラー

以下のような記述をしていてコンパイルエラーが出た。

                    <input type="text"
                            name="menu_name"
                            placeholder="menu name"
                            required
                            onChange={onChangeFormValue} 
                            {...register('menu_name')}
                     />

コンパイルエラーのメッセージは

'name' is specified more than once, so this usage will be overwritten.ts(2783)
index.tsx(192, 29): This spread always overwrites this property.

ってな感じ。 要するにname属性が二重で書かれてるから駄目だよってことらしいんだが。(ちなみにonChangeにも同じエラーが起きている) 見ての通りそんな定義はしてないし、別の環境だと同じ事象は起きなくて、なにが原因だか最初はよくわからなかった。

これはどうも register の記述位置が悪いみたいで、以下のように

                    <input type="text"
                            {...register('menu_name')}
                            name="menu_name"
                            placeholder="menu name"
                            required
                            onChange={onChangeFormValue} 
                     />

と、registerの位置をnameonChangeより前に持ってくると解消した。 なんだかよくわからないがこのStack Overflowにそんな感じのことが書いてあったのでその通りにしたら実際解消した実例。 なんだそりゃ!?

Formik関連

yupでバリデーションするフォーム」とかでググるとほとんどの例でformik使ってて、「よくわかんねーけどformik使ってる方が幸せになりそうだな」と思いはじめ、 最初はReact Hook Formで<form>タグ と <input>タグで組んでた入力画面を途中から全てFieldタグ等Formikのライブラリで書き換えた。 結果、確かに恩恵にあずかれた部分はある。…のだが、逆に別の課題が発生したのも事実で、例えば自前で<input>タグやuseState使えるほうが融通が利いた部分もあった。 以下にその辺のメモを残す。

FormikでFieldに定義した以外の項目値をsubmitする方法

が、わからなかった。 Formikタグで囲った範囲内なら、<input type="hidden" value="aaa" name="aaa">とか書いていても、何も考えずにsubmitしてくれるもんだと思っていたが、 そういうところは融通が効かず、formikはFieldで定義した値しかsubmitしてくれないらしい。 逆にいうとsubmitしたいんなら是が非でもFieldで定義しておかないといけない。 この場合、initialValuesに渡す値も、yupのスキーマ定義も連動して全部追加が必要になる。 仕方ないのでinitialValuesとか諸々関連する範囲を修正しつつ、画面上は<Field name="aaa" className="hidden">で隠して描画して、 一緒にsubmitさせるという方式をとったが、なんかあまりにも力技というか古典的な回避方法な気がしてならない。 「入力も変更もできないのに、わざわざinitialValuesyupを指定しなきゃいけない(他の入力項目と内部的に同じ扱いになる)」というのが個人的に気に食わない。 全くスマートではない気がする。 が、この辺どうやって回避するのかがわからなかった。 と、修正範囲が大したことない(いうて所詮initialValuesyupちょっと足すだけで済む)ので、一旦これで乗り切ってしまった。 formikが簡単にフォームを実装できるが故の弊害って感じかなア。 他に何かやり方がありそうな気がしてならない。 ありますか??

Formikの入力フォームを共通化(コンポーネント化)しようとして断念した話

このシステムで必要になる入力フォームの動作は、基本的にどの画面でも似たようなもんなので、共通化できるんじゃねーのと思ってコンポーネント化を目論んだ。 が、筋トレの実施画面で、「筋トレの開始・終了時刻をボタン押下によって動的に入力する」という動作を実現したいと思ったところ、仕掛けたコンポーネントでは実現できず、結局この作戦はお蔵入りになった。 技術的なポイントだけはQiitaにまとめたので、興味があったら読んでみてください。 振り返ってみると、結局のところ個々の画面で個別の仕様がちょいちょい出てきたので、共通化しなくて(できなくて)良かったかもしれないなあ、と思う部分はある。言い訳っぽいが。。

submitボタンを押してないのに自動的にsubmitされちゃう話

作成したとあるフォームで、submitボタンを押してないにも関わらず自動でsubmitされちゃう現象が起きた。しかもvalidationをクリアしてるのでそのままDBへの登録まで走ってしまう。なんのためにsubmitボタンあるのみたいなことになった。。デフォルトのvalidationのタイミングが悪いのかと思い、onBlur等におけるvalidationの発動タイミングを全部falseにしたりと悪あがきしてみたが、だめ。色々調べたんだが同じ現象で困ってる人も見つけられず、頭を抱えた。

というので3日程頭を抱えていたら唐突に原因が思いついて、というのもこのフォームではformik.setFieldValueを使ってる箇所があって、そのメソッドで値をセットしたときに自動submitがかかることに気づいた。どうやらFormikのsetFieldValueは値をセットすると同時にそのフィールドに対して「入力完了」を示すEnterキーの入力に相当することをやってるようだ。Formikのフォームは、フォーム内のフィールドにフォーカスしてるときにEnterを押すとそれがそのままsubmitの扱いになる。setFieldValueでも同じことが起きているようで、これが原因のようだった。(多分Formikで"touch"と呼んでいる事象のことをそういうのだと思われる) というか、もっと根本的な話をすると、これは別にFormikに限った話じゃなくて、<form>タグで囲われた範囲のどこかの<input>タグにフォーカスしてEnter押すとformが自動submitされる動きによるものだ。 Formikの場合、それがsetFieldValueで(個人的には意図せず)引き起こされていたというだけの話で、別にFormik特有の問題でもなかった。ただ強いて言うなら、setFieldValueには、shouldValidateのオプションはあるが、shouldTouchのオプションがないので、実行によりtouch=Enterキー押下が運命づけられており、これを回避する術がないという点はFormik特有ではあるかもしれない。よく対比させられるライブラリReact Hook FormのsetValueにはそのオプション(shouldTouch)があるので、この点に限って言えばReact Hook Formのほうが柔軟性で勝る。実際、この問題のせいで一瞬「React Hook Formに戻そうかな…」と考えたりもしたのだ。結局Formikにしたけど。

で、どうやって乗り切ったかというと、非常に単純で、要するに<form>タグがいなければいい話なので、この入力フォームからFormikのformタグにあたる<Form>を取っ払った。これで自動送信されるformが消滅して、いくらsetFieldValueされようともsubmitされることがなくなった。いろんな事例見る限り、必ずと言っていいほど<Form>タグがついてたので、これをつけることが前提だと思ってたし、「これなくすとvalidationとか動かなくなるのかなー」と不安だったが、別になくても普通に動いた。値の送信も問題なく可能。そう考えると、<Form>があるせいで、「Enterキー押下による速攻submit」の危険性(Userabilityの悪化の可能性)がうまれる気すらしており、最早書かないほうが色々自由度上がってやりやすい気が個人的にはするんだが、実際のところ<Form>を書かないというやり方が正攻法なのかどうかはわからない。<Formik>onSubmit属性の指定が必須なことから考えると、そもそもFormikの設計思想は、「サーバーにformをリクエストする」という目的に基づいて作られている気がしており、そう考えると(普通は)<Form>タグ書くもんなんじゃないのという気はしないでもない。この対応により、もはやonSubmitは使うことがなくなったので、なんのためにFormik使ってるのという気がしたのも事実である。後述するが、今回のアプリは、いわゆる「SSR」を1つも作成しておらず、CRUD系の処理はすべてAPIにしたので、仮にFormikの設計思想がそうなんだとすると、そもそもこの点でFormikとの相性が異常に悪かった(使い方を間違えてる)ということになる。CRUD系の処理を書くことを考えた時、そのエラーハンドリングなどを踏まえて、サーバー側のどこに書くかという点を先に決めたが、そのフロント側に使うライブラリのことまでは全く想像していなかった。まあそうやって悩むのも開発の課題の1つか。とりあえず動いたからヨシとするが、これは課題の解決の仕方自体はシンプルだが、その辺の「設計思想」みたいなところにまで立ち入って考え直すことがあり、個人的に結構深い話だった。

Tailwindcss関連

aタグを加工してつくった疑似ボタンのborderが表示されない

こういうクラスを定義して

.custom-button {
      @apply  font-bold text-base inline-block text-black px-4 py-2 leading-none border rounded border-black hover:text-slate-500 hover:bg-slate-300 mt-4
}

これをフロント側からLinkタグで呼び出す形で使っていたんだが(つまり見た目ボタンっぽいだけの実質ただのaタグ)

                  <Link href="/xxx" className="custom-button">
                      go!
                  </Link>

なぜかこの疑似ボタンの囲い線=つまりborderが表示されなくてちょっと詰まった。Tailwind playgroundに同じ記述のクラス定義書いてaタグ書いたらちゃんとborderが表示されたので、なんで同じ定義で出るのと出ないのがあるんだ??と、困惑。見た目の問題なので別にそこまで困らなく、後回しにしていたが、メインとなる開発部分がある程度落ち着いたのでちょっと調べてみるかと思って調べたら、わかった。要するにborder-styleを指定してないだけだった。蓋をあけてみると超初歩的なミスだった。。。というわけでTailwindcssのborder-solidを足して解決した。

.custom-button {
      @apply border-solid font-bold text-base inline-block text-black px-4 py-2 leading-none border rounded border-black hover:text-slate-500 hover:bg-slate-300 mt-4
}

逆に言うとTailwind playgroundはおそらくaタグにもとからborder-solidがついてるんだな。globalのCSSかなんかにそういう定義があるんだと推測する。調べてないけど、状況からみるとそういうことになる。ま、どうでもいい話だが…

yup関連

validationにyupを使った。初めてだったのだが、先人たちの知恵がいろいろそこら中に落ちてるので、基本的にそこまで困ることはなかった。validationの実装が簡素に、かつ一元化でき、そのうえフロント・サーバー両方で使えるという点で、個人的にはとても気に入っている。ただ一部にはやはり課題があったので、その辺の記録を残す。

小数点を含む項目のvalidation

yup().number()は「整数」のvalidtionには使えるが、小数点含む項目のvalidationには使えないらしい。(使えないっていうか、validationしても必ずinvalid扱いになってしまう)じゃあどうするか?っていうと、どうやら文字列yup().string()扱いにして、正規表現で頑張るしかなさそう、なんだそうだ。このStack overflowとかこのissueとかでもその方向性の回答が出ている。まじかよこのご時世に小数点のvalidaitonに正規表現使うしかねーのか、yup().decimal()みたいないい感じのスキーマねえのかよって感じだが、まあ先人たちがそういうんだからそうなんだろう。仕方ないので正規表現を書くことにした。小数点の項目は、体重を記録する画面で必要になった。さすがに体重や筋肉量、体脂肪率なんかは整数で記録するわけにはいかないわけで小数点の管理が必須なのだ。

ちょっと詰まったのは、画面上これらの項目を<Field type="number">で定義していたら、validation用の正規表現がちゃんと動作しない(と誤認していた)ことだった。type="number"はただの整数項目なので、例えば画面上"12.0"と入力されると内部的には"12"になっている。このため、例えば/^[1-9][0-9]\.[0-9]$/みたいな正規表現だとvalidation errorになってしまう。(この正規表現は、“12.0"ならOKなんだが"12"が渡されてるのでNGになる)自分が体重の記録に3年近くずっと使っている「タニタの体重計」では、例えば体重は「61.50」のように、小数点第二位まで、かつそこに0を含んで計測結果を表示することを、経験的に知っていたので、/^[1-9][0-9]\.[0-9]$/のような正規表現を組んだのだが、フォーム側がtype="number"だったせいで「61.50」と入力してもvalidationに使われるのは「61.5」になってしまうので、意図したようにvalidationされなかった。この正規表現のvalidationだけをjestでtestしても問題なく動作したので、なんで画面経由だとだめなのか最初は原因がわからず、ちょっと悩んだ。

ちなみに、旧版は<input type="number">で項目定義していたので、スマホで入力したときに初回の入力キーが自動で数値になったのだ。今回のはtype="text"なので初回の入力キーが文字になるため、このままだと入力するためにいちいち数値キーに切り替える必要があり、些細なことだが、この部分だけ取り上げると単純にユーザビリティが悪化している。で、調べたらinputmodeという属性があることを知った。これをdecimalにすることで、type="text"でありながらもスマホの初期入力キーを数値キーにしておくことが可能になり、ユーザビリティの悪化を防げた。今はこういう便利な属性があるんですなあ。とにかく、解決してよかった。

jest関連

普段から単体テストにはjest使ってるので、今回もjest使っていくつかテストコード書いた。 基本的な使い方はもう知ってるので、技術的な部分での悩みはそこまで出てこなかったんだが(あるにはある)、特にPrismaとの絡みで色々悩みが出てきて、モヤモヤしながらテストコード書いてた。 おそらく、「テストを開発に織り込む方法」について、経験や知識が浅すぎるんだと思う。 テスト戦略というか、テスト開発に関しての知見が必要だと思った。

Prismaでマスタとrelation貼ったトラン作成処理の単体テストについて

上で書いたPrismaのrelationの話の関連というか続きになるんだが、Prismaでrelation設定すると外部キーが貼られるので、マスタートラン関でrelationがあって(例えばユーザーマスタとか)、トランテーブルの作成処理を単体テストしようと思った場合、 事前にマスタ側のデータを作っておかないと、「トランテーブルの作成処理」の単体テストがそもそも成立しない。 「トランテーブルの作成処理」を単体で呼び出して実行しても、登録しようとするトランのデータに紐づくはずのマスタ側のデータが存在しないと、外部キー制約に違反してるってことで落ちちゃうからだ。

これ自体はもうしょうがない(Prismaの「仕様」だろうから…)んだろうけど、こういう場合のテストデータってどこにどう定義するのが正しいんだろうな??っていうのが少し気になる。 ここでやりたいのは、つまりjestで実行するtestコードのファイルに記述したいのは、 あくまで「トランテーブルの作成処理」の単体テストであり、そのための関連マスタデータの作成処理を、このtestコードに書くのはなんかちょっと違和感がある。 jestみたいなtestライブラリを使わずに、手動で画面とかバッチ動かして、エクセルにエビデンスペタペタ貼ってた頃の、SIer時代のレガシーなテスト実施の考え方でいえば、 テストシナリオに沿ってテストデータ用意するのが基本的には筋だったから、その考え方でいえば、「トラン作成処理」のtestコード内で、必要なマスタデータは全部自分で用意しろ、って話になるんだろうな。 別にそれならそれでいいんだけど、そうなると「トラン作成処理」のtestコード内に、実際には関係ない(あくまで「トラン作成処理」を動かすのに必要になるだけの)testデータを用意する処理を書かないといかず、 実際の単体テストに至るまで手間や時間がかかるのが個人的にいただけない。 そういうもんなのかなあ?? 時と場合によって変わりそうだけど、こういう場合の基本的なtestデータ用意の「考え方」を知りたい。 ここについては未だに自分なりの答えが出ておらず、どうするのが正解なんだろうな~~と思ってモヤモヤしている。

で、とりあえず今回自分がとった行動を書いておく。 「トラン作成処理」で必要になるマスタデータは、それぞれを作成する処理の単体テストのtestコードで用意したものを使う。 要するに「トラン作成処理」内で自分から作ることはせず、既に別のtestコードで使ったものを利用させてもらうという考え方だ。 実際、利用者の立場でシステムを使用する場合も、関連する(外部キー制約が貼ってある)マスタを事前に作ってから/用意してから、そこで作ったマスタを使ってトランデータの作成に至るんだろうし、 マスタとトランは視点としては切り離してtestしていいんじゃないか、と思うに至った。 とりあえずこれで単体テストは乗り切った。 これで、各testコードファイルにはその処理の単体テストのみが記述されて、少なくともtestコードとしてはスッキリした。(気がする)

だが、そうなるとちょっと問題があって、というのも、

  • 各テーブルのKEYは、マスタかトランかに限らず自動採番されるUUID値である
  • マスタとトランのrelationをこのUUID値で構成している(↑のprismaの項で書いた通り)

なので、一度マスタつくってUUIDを採番しないと、トランの作成処理に流すテストデータを作れない。 トラン側のtestに流すテストデータに、関連マスタのUUIDをべた書きする必要があるからだ。 まあ本来そう言う流れだから当然っちゃ当然なんだけど、 とあるテストを完了しない限り次のテストに移れない(テストコードを完成できない) というのが正直少し気になる。 トラン作成処理のtestコードに、必要になるマスタを全部用意するようにすれば、多分この点は(多少)解消するんだが、そうなるとわざわざマスタ側のテストコード書く必要なくなるしなあ。 何が正解なのかよくわからなくなってくる。 こういうのにも「ベストプラクティス」みたいのがあれば知りたいものだ。 どうするのがいいんだろうね??アプリの規模とかに色々依存しそうだけども。。

テストデータの後処理

テストデータって、テストし終わった後はどういう扱いにするもんなんだろうか。 そのまま(本番環境であっても)残すもの?? まあCI環境で流すもんだろうから、残そうが消そうが関係ないって感じなのかな。。(テストが済んだらCI環境ごと消え去るんだろうし、ってかんじで)

ただ開発中にローカルでtestしてると、何回かtest実行したくなることがある。 特に、upsertじゃなくてcreateの処理をテストしようとした場合、テストするたびに毎回テストデータが作られて蓄積していき、正直邪魔になる。 個人的に、つくるテストデータのcreated_byを全部"test"とかにして、あとでまとめて消す(PrismaのdeleteManyをつかう)ようなコードは書いたんだが、 テスト以外の場面で使用されることのないコード書くのには若干抵抗があった。 まぁPrismaだとこの辺簡単に書けるから、悩んでるくらいならコード書いた方が早いんだけど。 本来的にはどうするんだろうねこういうの??

@ import(絶対パスインポート)できない

テストするコードのimportを@で指定してimportできない。(つまりプロジェクトルートを@でエイリアスしての絶対パスimportができない) テストコード以外の、つまり普通のアプリケーションコードは@ importするように設定しているのだが、何故かjestのテストコードだけは駄目だった。 import先が見れない(そんなもんない)といって文句言ってくる。

検索したら同じ問題に遭遇してる人が見つかって、こちらの記事の通り実施したんだが、なぜかエラーが解消されないまま。 なんだかよくわからんが、ここで長時間悩んでるのもあほらしいので、仕方ないけど相対パス表記にしてテストコードを書いた。 これは解決しておらず、未だに気持ち悪い。 まあtestの観点からしてみればそれほど重要なポイントでもないし、次のなんかの開発時に見直すか、って思ってる感じ。

$queryRawの結果が空配列になる

なぜかわからないが、jestで$queryRawを実行すると、メソッドの戻り値が空配列になってしまうという現象に悩まされた。 ts-bodeとかnext devからの実行では正常なのに、jestだと必ず空配列が戻ってくる。 バインド変数部分を固定値にしてみたり、超簡単なSQLにしてみたり、色々試行錯誤したんだが、確実に結果が返ってくるはずのクエリ(実際、ts-nodeとかでは結果を取れる)でも空配列になる。 色々探ったんだけど、少なくとも画面経由で動作してるんなら(jestが動かないっていうだけなら)、まぁいいかという結論に落ち着くことにした。 結局原因は謎のままで、なんでこんなことになってるんだかは不明。 軽く検索したけど類似事例も引っかからない。 なんとなくjestかprismaのバグな気がするが…見なかったことにする。ちなみに以下Versionにて発生確認。参考までに。

    "jest": "^29.5.0",
    "ts-jest": "^29.1.0",
    "prisma": "^4.12.0",
                                                               version                                                               
-------------------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 13.13 (Ubuntu 13.13-1.pgdg20.04+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0, 64-bit

その他全般

POST処理の作り方

↑のyupの話とも少し絡むんだが、いわゆるCRUDにおける、R以外-つまりC・U・Dの処理を実装するにあたって、どこにどう処理を実装するか?というのに結構長い間悩んだ。 例えばyupによるvalidationを、サーバー側に実装する場合、それはgetServerSidePropsが適切なのか、それともAPI Routeが適切なのか?といった点だ。 yupのvalidationはサーバー側で実行する処理の一例であり、処理内容次第では、例えば「外部サイトへのAPIコール」とかそういうのもあるはずだ。(今回の開発範囲にはなかったけど) 言葉を選ばずに大きく書いちゃうと、いわゆる 「ビジネスロジックってNext.jsだとどこに書くの??」 って話に帰着する、のかな。 全般的に、こうしたサーバー側の処理を、Next.jsではどこにどう書くのが適切なんだろうか?? そういう「ベストプラクティス」みたいのが存在するのだろうか?

ちょっと探したんだが、別にここに関して「ベストプラクティス」っぽいものはないようだった。 逆に言うと個々人が好きに作ればいいんじゃない、っていう領域であって、あんまり悩むようなものでもないかもしれないが。 先人の素敵な知恵みたいのがあればそれが知りたかった。 Next.jsでCRUDのサンプル作ってみた!みたいなのでこういうのとかチラホラ見かけたんだけど、みんな基本正常系だけなんだよな。。 (まあサンプルだから当たり前なのかもしれないけど) 異常系の考慮ってみんなどうしてるの??というのが気になる。 yupのvalidationを組み入れたサンプルだとこういうのも見つけたが、APIでの実装例だけで、フロント側との絡みがない。 yup+POST APIのNext.jsのコードサンプルが欲しい。。
究極的にはyupに限った話じゃなく、ビジネスロジックをどこに配置してNext.jsでどう取り扱うか?の「考え方の指針」みたいのが欲しい。 そういう本とかないかなあ。

正常系の場合は別にあまり考える必要はなくて、getServerSidePropsの中にゴチャゴチャ書いても、同じ処理をAPI Routeに書いても、なんとでもなりそうなんだが、 異常系(それこそvalidationErrorが発生するケース)になると、いくつか試した感じでは、発生した場所に応じてハンドリングが結構面倒くさくなるように思う。 特にgetServerSidePropsでエラーが発生したのを元画面に戻すのが色々試したけど結構難しそうだった。 ここのdisucussionでも「API Routeにvalidation入れるのはアリだぜ!」って言ってるので、今回は全般的にPOST処理に関してはAPIに寄せることにした。 つまり呼び出し元画面のformからonSubmit={(e)=>callAPI(e)}みたいにしてAPI呼び出して、validationとかDB処理とかでエラー起きたら200以外でレスポンスし、それをフロント側で表示する仕組み。(こうやって書くと実にシンプルで陳腐な感じだな…たどり着くまで結構試行錯誤はしたんだが) よってgetServerSidePropsでvalidationしたりとかそういうのはしない=SSRのページを作らない、ということにした。

余談だが、もともとはgetServerSidePropsでサーバー側処理を実装しようとしていて、「POSTパラメータが取れねぇな~」と悩んでいた時期があった。 結果作ったのがこのQiitaの記事である。 結局APIに処理を移したのでこの記事に書いてあるようなことは不要な悩みになったわけだが。 まあナレッジとして一つ得るものではあったので無駄にはなっていないと前向きに考えておきたい。(誰か同じ悩みに遭遇する人いるかもしれないしね)

デジタルクロック

作り変える前からつけてるんだけど、全ページのヘッダ部分に共通でデジタルクロックをつけている。(最終的に「必要なのトレーニングするときだけだな」と気づいて筋トレ入力する画面以外からは取っ払ったが) これは時間系のメニュー(例えばプランクなど)をやるときに、「◯分経過した」というのが見た目ですぐわかるようにするためだ。 最低単位は秒で、つまり1秒単位に表示が切り替わる。まさに「時計」である。 これ、生のJavascriptなら、適当なHTML要素をフロント側に用意して、jQueryつかってsetTimeout で1分間隔でそれの中身を動的に書き換えればそれで済んだんだが、Next.jsだとそういうわけにはいかん。 実装自体はそんなに難しくなくて、useEffectと中にsetTimeout使ったコード書いて、それ使うだけ。 なんだけど、最初の表示だけはいけるんだが、1秒後に表示が変わった瞬間にHydrationのエラーが出る。 「ビルド時から表示が変わってる」というのが理由だ。まあよく考えれば当たり前ではあるが。 これがなかなか解決できなくてちょっと困った。

で、色々調べたら 動的インポート(next/dynamicっていうのがあって、 読み込む際の第二引数に {ssr:false} を与えることで、そのコンポーネントだけ部分的にクライアントサイドで動作するように読み込むことができる。 デジタルクロックの実装分だけをコンポーネントとして切り出し、動的インポートで {ssr:false} にして読み込むことで、解決した。 出来てみちゃうとあっけない話だったが。 生Javascriptで書いてたときには全然苦労する必要のないポイントで躓くことになり、Next.jsみたいに「うまく出来てしまっている」フレームワークは、それはそれで(こういう場面では)使いづらいんだな、と感じた一例だった。

なお、デジタルクロック部分の実装は、もともとは useEffect とか使って自分で書いてたが、 「よく考えたらもう誰かそれっぽいの作ってんじゃねーの」と思って探したら実際やはり既にreact-live-clockってのがあって、 自分で変なガリついたコード書くよりもうこういうの使ったほうがいいやってことで、早い段階でこっちに切り替えた。 これ、サンプルコード使うと日時の書式が12時間表記になるんだが、個人的に24時間表記にしたくて、どうやってやるんだろうなと思って探してたら、どうも内部的に moment.js 使ってるっぽくて(npmのページに書いてある)、そのフォーマットに従うようだ。 というわけで moment.js の書式に倣って24時間表記に変更。

react-selectの初期選択

筋トレメニューの選択部位にreact-selectというライブラリを使っている。ただの<select><option>...じゃなんとなく面白くないし、メニュー増えたときに名前で検索できたり利便性があるのでね。

このライブラリを使って、リストの中からある値を初期表示段階で選択しておく実装をやりたかったんだが、これがうまくいかなくて長らく苦労した。具体的にこのライブラリでいとdefaultValueってプロパティの設定だ。ここで解説されている。ただ、これはこのライブラリに問題があったんじゃなく、根本的にはReact Componentの書き方をよくわかっていないことが原因である。

筋トレを実施する画面(いわゆる「登録画面」)では、どのメニューを選択するかはこれから決めるので、defaultValueは未設定でいい(内部的には{value:'',label:''}みたいなオブジェクトを設定してるけど)。しかし、実施済みの筋トレを更新する画面(いわゆる「更新画面」)では、実施したメニューを初期選択しておかないといけない。で、このためにdefaultValueを使うんだが、どう頑張ってもlabelのほうが埋まらずに苦労した。この画面は関数型のReact Componentにしていて、最初うまくいかなかったときはそのreturn前の処理でuseStateの初期値に設定する形でこの挙動を実現しようと色々頑張っていた(が、想定通りには動かなかった)。簡単に言うと以下のような実装だった:

// (1-1)筋トレメニューリスト取得処理
const getMenus = () => {
  (async()={
    // API呼んで結果を取得し値をセットしてreturn
  })():
};

// (1-2)筋トレメニュー名取得処理
const getMenuName = (menu_id) => {
  (async()={
    // API呼んで結果を取得し値をセットしてreturn
  })():
};
// (2) 筋トレ記録フォームのReact Component
export const TrainingForm = (defaultVaues:TrainingData|null) => { // `defaultVaues`が筋トレの実施記録を示すオブジェクト、登録画面のときはnullで更新画面のときは実施済の筋トレの実施記録が詰まったオブジェクトをもらう
  // (2-1) 筋トレメニューリスト生成
  const [menus,setMenus] = useState<{value:string, label:string}[]>(getMenus());
  // (2-2) 筋トレメニューの初期選択
  const [defaultMenu, setDefaultMenu] = useState<{value:string, label:string}>({
    value: defaultVaues != null ? defaultVaues.menu_id : '',
    label: defaultVaues != null ? getMenuName(getMenuName.menu_id) : ''
  });
  ...
  return (
    <div>
      ...
      {/* (3) react-select のレンダリング*/}
      <Select
        options={menus}
        defaultValue={defaultMenu}
      >

      </Select>
      ...
    </div>
  );
};
  • このReact Componentは引数に「筋トレ実施記録のオブジェクト」をもらう。上の例ではこれをdefaultVauesという名前の変数で定義している。オブジェクトがnullのときは登録画面、nullじゃないときは更新画面としてふるまう。
  • (1-1)はreact-selectのoptionsプロパティに渡す、筋トレメニューの配列({value:string, label:string}[]型)を取得して返却する処理。筋トレメニューはそれ用のテーブルに入っているので、それに対してselectするAPIを用意してあり、(1-1)はそれを呼び出す処理だ。内部的にはAPIのコールにaxios使ってるのだが、React Component関数自体をasyncにできないので、(1-1)自体も同期型にして、関数の中でasyncブロック使ってそこでだけ非同期処理にしている。まあ(1-1)をasyncにしてReact Component内にasyncブロック作っても同じだし実際それも試したんだが、それだと何故かうまくいかなかったのでしばらくこういう形に落ち着いていた。
  • (1-2)はreact-selectのdefaultValueプロパティ({value:string, label:string}型)の中の、特にlabelにあたる値を取得して返却する処理。筋トレの実施記録のオブジェクトには、「どのメニューを実施したか」についてメニューのIDしか持ってないので、それに紐づくメニュー名を補完してやる必要がある。実際、react-selectの仕様上、valueだけ埋めてもdefaultValueは動作しない(参考
  • (2-1)は筋トレメニューのリストを管理するstate。初期値に(1-1)の戻り値をそのままセットしている。=useState<{value:string, label:string}[]>(getMenus());ここは登録画面と更新画面で挙動に変わりはないのでシンプル。
  • (2-2)は筋トレメニューのリスト内から初期選択する要素を指定するstate。React Componentに渡された筋トレ実施記録のオブジェクトがnullか(登録画面)そうでないか(更新画面)で設定する値が分かれる。登録画面のときは、メニューのID(value)および名前(value)ともに空文字でいいが、更新画面のときは、メニューIDに筋トレの実施記録のオブジェクトにもっている筋トレメニューのIDを、メニュー名は(1-2)の処理で取得して設定しておく必要がある。
  • で、(1-1)~(2-2)までにかき集めた情報でreact-selectをレンダリングするのが(3)。具体的にはoptionsプロパティに(2-1)の配列を、defaultValueに(2-2)のオブジェクトを、それぞれ指定する。

これだと想定通りに動かなかった。どんなに頑張っても、更新画面での筋トレメニューの初期選択が空白になる。ただし空白になっているのはlabelのみで、裏にあるvalueはちゃんとセットできていた。つまり、初期選択のロジック部分は想定通りに起動しているが、(1-2)の筋トレメニュー名の取得処理がおかしい。と思ってconsole.logを(1-2)に限らず色々なところに仕掛けまくって追跡したんだが、(1-2)で戻り値を返却する直前に値(筋トレメニュー名)が取得できているし、(2-2)でuseStateで値を設定する直前にもその値は確認できているのに、(3)に来るとなぜかlabelだけ空文字になってることがわかった(<span>{JSON.stringify(defaultMenu)}</span>とかして画面に出した)。これが意味不明で、つまり「名前を取得する処理」と「それを設定する処理」は無事なのに、レンダリングの直前に勝手に空白にされている。誰がどう悪いのか全くわからなくて、しばらくの間頭を抱えた。

実際のところ、バリデーションやらDBの更新に使ってるのは裏にある筋トレのメニューIDで、そっちはちゃんとセットできていることを確認済だったので、更新画面で筋トレメニューの初期選択が空白になってても、究極的にいうとビジネスロジック自体は成り立ってしまう。俺しか使わない想定だし、まあ業務が成立するなら最悪それでもいいかなと思って妥協してしまいたくなったくらいだ。それくらい意味不明であきらめたくなった。

もしくは、もういっそreact-selectみたいなライブラリ使うのはやめて、<select><option>...で書いちゃおうかと思ったりもした。react-selectと<select>タグでは「初期選択」の実装方法が異なり、前者はdefaultValueのように「初期選択のオブジェクト」を指定するのに対し、後者は単に対象の<option>要素にselected=trueをつけるだけだ。で、後者のほうが絶対簡単なのである(配列をmapで回しながらID一致したらselected=trueつけるだけだし)。これは確実な勝算があったんだが、何か自分に負けた気がしてこっちの方向にはいきたくなかった(実際react-selectを使ったままで解決したのでこの判断は正解だったと思う)。

これに頭を悩ませた原因の一つに、「筋トレメニューのリストの設定」はうまくいっていた、というのがあると思う。要するに、「画面にレンダリングするデータの準備」を「useStateの初期値に設定することで実現する」という身近な成功例を、自分で実際に見つけてしまっており、これに固執していた(これこそ正攻法だと信じて疑わない姿勢があった)というのが、解決まで時間がかかった理由の一つになっていると分析する。かつ、後述するが、一時的にuseEffectを使ったこともあったんだが、これがうまく動作しなかったことがあったのも、それに拍車をかけた(俺がやりたいことはuseEffectではできないんだ、と思い込むに至った…単に書き方が正しくなかっただけなんだが)。(1-1)+(2-1)と、(1-2)+(2-2)は、呼んでる関数や設定するuseStateの型こそ違えど、基本的な処理構造は同じなのに、なぜ(1-2)+(2-2)だけうまく動作しないのか、というところを解決しようとパワーを費やしてしまった。正直いって今でも原因不明のままなんだが、そもそものこととして、 (1-2)+(2-2)がうまく動作したことのほうがが恐らくレアというか想定外なんじゃないかと予想する。 useStateの初期値に設定する値に、React Componentのレンダリング部(上でいう(3))から見て外部にある別のfunction等を使って生成される値を使うと、多分なにかがおかしくなるんだと思う。React Componentがそれに対応していないというか、対応してるとしても少なくとも上の例では実装の仕方がそれに倣っていないんじゃないか、と予想する。つまり(1-2)+(2-2)がうまくいったのは単なる偶然であり、基本的にはうまくいかないもんなのだ、と思うに至った。この辺は少し調べたんだが、ピンポイントでこれに該当する問題を扱っている情報は見つからなかった。ただ、この方の悩みは少し近い気はする。本質的には、React Componentに関する理解が不足していることが原因なんだと思われる。基本的に勢いとノリでコーディングしており、場当たり的な対処ばかりで乗り切ってきていて、体系的な知識や理解が伴っていないのだ。こんな風に表面的な理解だけで進めてきた弊害なんだろう。。

で、解決した実装例がこちら:


// (2) 筋トレ記録フォームのReact Component
export const TrainingForm = (defaultVaues:TrainingData|null) => { // `defaultVaues`が筋トレの実施記録を示すオブジェクト、登録画面のときはnullで更新画面のときは実施済の筋トレの実施記録が詰まったオブジェクトをもらう
  // (2-1) 筋トレメニューリスト生成
  const [menus,setMenus] = useState<{value:string, label:string}[]>();
  // (2-2) 筋トレメニューの初期選択
  const [defaultMenu, setDefaultMenu] = useState<{value:string, label:string}>();

  // (2-3) useEffect
  useEffect(()=>{
    // (1-1)' 筋トレメニューリスト取得処理
    const getMenus = async () => {
      // API呼んで結果を取得しuseSateのset関数で値をセット
    };
    
    setMenus(getMenus());
    setDefaultMenu(...);
  },[])
  ...
  return (
    <div>
      ...
      {/* (3) react-select のレンダリング*/}
      <Select
        options={menus}
        defaultValue={defaultMenu}
      >

      </Select>
      ...
    </div>
  );
};
  • (1-1)や(1-2)といった、React Componentの外に定義していた関数を廃止した。いろいろ追跡していたらReact Componentの初期化処理でReact Componentの外にあるfunctionを呼び出すことに対する不信感が募ったので。代わりに(1-1)はuseEfefctの中にもってきて(関数内関数として定義)、かつasync付きに変えた。で、useEffect内では(2-1)と(2-2)のsueStateのsetterを呼び出し、設定する値にその関数内関数を呼び出す形に変えた。ちなみに余談だが、もともと(1-1)と(1-2)で2つAPIコールがあったのを、(1-1)(リストの取得)だけにして、(1-2)にあたる処理は、(1-1)で取得した配列に対してfindかけるというように実装を変えた。いちいちIDユニークでAPIもう1回コールするのもなんかちょっとな、、、と思ったので、やめた。(これはもとからそうか)
  • これ今でも個人的に不思議なのが、useEffectのブロック自体は同期である(=useEffect(()=>{...)一方、中で定義している関数は非同期(=const getMenus = async () => {...)で、最終的にuseEffectブロック内で実行しているuseStateのsetter内でも特にawaitつけてないのに、何故これだとうまく動作するのかがわかっていない。ただこの実装例は調べるといろんなところで出てきて、例えばこのQiitaの記事とか、useEffectで非同期関数を扱う場合の書き方が確認できる。この問題に関しても一時期useEffectを使って対応することを考えたのだが、この記事にも書いてある「間違いの例」のように、useEffect自体をasyncつけて中の処理を書いていたので、うまくいかなかった(=useEfefct(async ()=>{...}))。この失敗体験のせいで「useEFfectだとうまくいかない」と思い込むに至って、かつuseStateの初期値に設定する方法でうまくいったこともあって、useEFfectを利用する実装に立ち返るのがだいぶ後回しになった。

だいぶ回り道して戻ってきたが、蓋をあけてみたら結構すっきりしたので、まあ結果オーライかなと思う。これは開発初期段階から顕在していた問題で、かつ開発の最終期まで残存したので、期間だけでいえば相当時間をかけて解決に至ったことになる。見る人によっては大した話でもなさそうだが、色々なやんで様々なパターンの実装を試し、Try&Errorを繰り返して解決に至ったので、自分的には少し感慨深い。色々学びがあった。

Container on AWS

冒頭に書いたバックアップ用の運用スクリプトを実行する機能、もともと本当にただのshellスクリプトだったので、これをなるべく流用するべく、今回はそれをコンテナで仕上げてAWS上で動かすこととした。最初はLambdaを使おうと考え実際いろいろやったんだが、これやってみたらわかったが、すっげー面倒くさい。。

  • まず、Lambdaでコンテナ動かすためには、それ用の「お作法」があり、bootstrapとかの周辺スクリプトを用意してそれをコンテナに同梱してあげないと、そもそも起動すらできない。こちらのQiitaの記事とかにやり方が載っている。探せば似たような記事はいろいろ出てくるが。。
  • かつ、ローカルで試しに「起動」してみたいと思ったら、docker runでポートフォワーディングして実行中状態にして、別ターミナルからcurl叩いて実行するという、なんかトリッキーなやり方が要求される。こちらの記事でやり方学んだ。ありがとうございます。
  • また、Lambdaの実行にはそれ用に用意されたDockerイメージがあり、これを使うことが推奨されてるが、entrypointがLambda専用になってるので、これを上書きしないとそもそも/bin/shとかで中入って作業、みたいなことすらできない。地味に面倒。
  • そのうえパッケージマネージャーはdnfなので個人的に使い慣れてなく、欲しいもんがわからない。psqlクライアントが欲しいだけなんだが。。ここに一覧が載ってるんだけどaptにはあるpostgresql-clientが見当たらない。前述の通りいちいちentrypoint上書きしないと中に入ってチョメチョメもできないので探すのも一苦労。。(ちなみにpostgresql15postgresql15-server でいいらしいです)
  • やっとの思いでローカル実行できてさぁいざLambdaで起動だ!と思ったら、処理自体は正常終了するのに"Runtime.ExitError"というエラーで異常終了する。S3にモノ吐くスクリプトで、この実行後にS3に実際アウトプットが生成されているのは確認しているので、処理全体としては何も問題なく、Lambdaの終わり方が異常なだけ、という状況。shell scriptのexitのコードとかを全部取っ払ったんだが解消せず。こちらの記事を見ると、Github Actionsで使ってるUbuntuのバージョンやらなんやらで影響を受けることがあるらしく、心が折れた。こちらの記事にある「俺はLambda動かしたいだけだが」には共感しかない。。。

という感じだ。最終的に処理は一通り動いて、あとはLambdaが異常終了する問題が片づけばそれで万事解決、というところまできたんだが、その問題が手ごわくて、というか解決しようと努力するのが馬鹿らしくなってやめた。俺はただ単にshell scriptを実行したいだけなんだよ。。 Lambdaが謎に死んで終わる原因なんかどうでもいいし、探ったところで何も面白くないし、そういうことやりたかったわけでもない。正直、S3にちゃんとモノ吐かれてて(処理の目的は達成できていて)、Lambdaの死に方が異常なだけなら、もうシカトでこのまま運用してもいいかなと思ったくらいだが、さすがになんか気持ち悪いのでやめた。そんなわけでContainer on Lambdaは断念。

で、次に目を付けたのがECS。これ今回初めて使ったんだが、最初はクラスターだのタスクだのスケジュールだの出てきて色々面倒くさいな~~って思ってたんだが、ちょいちょい手を付けたら割と速攻で終わったし、想定通りに動作した。Lambdaのように変なお作法もいらず、直感的にコンテナ動かせた。そうです、こういうの求めてました。。最初から素直にこっち使っておけばよかったね。はい。

なお、このスクリプトのソースコードは、DockerfileをふくめてGithubのPrivate Repositryで管理しており、Github Actionsでdocker buildからのECRへのpushまでも済ませる。これはこちらの記事が参考になった。もともとこの記事にある通りでLambdaへの更新までやってたんだが、上述の通りLambdaを断念したので、ECRへのpushだけで終わる形になった。ECS的にはそれで最新のイメージとってきてタスク実行してくれるのも地味にありがたい。(Lambdaより一手間少ないので)

今回学んだのは、Lambdaでコンテナは鬼門だということ。というかもう今後二度とこの方式を検討することはないだろう。面倒くさすぎる。Lambdaの良さをかき消してなお地獄の底までおとしめるほどの負のパワーを持っている。Lambdaは素直に用意されたランタイムで動かしたほうがいい。。。

VercelとSupabaseとPrisma

画面操作していると、ある瞬間から突然APIが500エラーを返すようになった。しばらくすると復活するが、そのあとしばらく画面操作してるとまた同じエラーになって死ぬ。その繰り返し。Vercelのログを見るとDBに接続できない云々のエラーが出力されている。最初はVercelかSupabaseが障害で死んだのか(or 死にかけですごい不安定なのか)と思ってたが、色々調べたところ原因はそうではなく、どうもそもそもからしてVercelからPrismaを使ってSupabaseに接続する場合の考慮漏れがあったようだ。

Vercel(のようなサーバーレス環境)からSupabaseに接続する場合、接続先はSupabaseが用意しているPGBouncer用のURL(「Transaction Mode」と呼ばれてるほうの、6543ポートのURL)を指定して、かつpgbouncer=true&connection_limit=1パラメータを付与するのが正解なんだそうだ。また、PGBouncer相手ではPrismaのmigrationが動作しない(エラーになる)ため、schema.prismaurlとは別に directUrl というパラメータを用意して、そちらに非PGBouncer用のURL(「Session Mode」と呼ばれてるほうの、5432ポートのURL)を指定する。そうしないと、すぐにコネクションが枯渇して死亡、という流れが起きる。へぇ~~。このことは以下の記事に詳しい。ありがとうございます。

PGBouncer経由だとPrismaのmigrationが動かないのは知ってたので、schema.prismaurlをPGBouncer用にしちゃったらmigrationコケちゃうんじゃないの?どうするんだ??npx prisma migrateでオプションにURL指定できたりするのか??と思ってたけど、Prismaはurlに指定された接続先がPGBouncerなどのコネクションプールの接続だった場合、directUrlのほうのURLを使ってmigrationしにいくという仕様があるらしい。ここに書いてあった。へ~初めて知った。(ドキュメント先に読めよという話でもあるが。。。)というわけでこの対応でmigrationの問題も解決。言い換えると、Vercel(のようなサーバーレス環境)とSupabase(のDB)をバックエンドにする場合、「画面から接続するURL」と「migrationを行うURL」の2つの環境変数の準備が必要になるということである。まあ大した話ではないんだが、運用の手間が増えるのも事実で、面倒に思う人もいそうだな、と思った。ローカルで動作確認しているときはPGBouncerなんか勿論使ってないので気づかなかった。

解せんのは、なぜ突然このエラーが起きるようになったかが不明なことだ。それまで2週間近く運用していてこの現象に遭遇したことがなかったので、なぜ突然発生するようになったのかがよくわからない。その間に細かい修正は実施してはいるものの、DB周りでの修正は実施してないし、DBの接続URLの見直しはもちろんやってない。一応、この現象は、(多分Supabaseが不要なIdleセッションを定期的に殺してくれているおかげで)「時間が経てば復活する(コネクションに空きが出る)」という事情があり、例えば1回の実施に時間のかかるメニューをやってると、その間に溜まってたセッションが死んでいるので、結果的に事象の発生に気づかなかったという背景はあるかもしれない。例えばスクワットは1回やり始めると10分はやり続けるので、その間画面は無操作になるし(useStateuseEffect等により、画面無操作でも自動的にAPIリクエストがある画面もあるが、筋トレを実施する画面はそういう作りでは基本的にはない)、仮にこの問題が起きていたとしても、その間に事象が自然解消していた可能性はある。ただ、様子見で運用していた2週間の間に、比較的短い期間(インターバル1分程度)で連続して筋トレ実施してる日もあり、そこで問題が発生しなかったので、問題の発生に気づかなかった。まぁ元々おかしな設定で動いてたんでたまたま運よく生きてただけじゃねーのという話はあるかもしれないが。なにか少しモヤモヤする。

これはまあサーバーレスとSupabaseという2つのプラットフォームを利用する場合に関する知識が単純に不足していたことで起きた問題なわけだが、しかしながらスタンドアロン環境で動かす分には考慮しなくていい問題であることも事実であり、簡単に動かせるようになったように見えて実は考慮しなければいけない部分が増えて、なんというかどっちもどっちなんだなあと思った問題だった。

残課題

割と長い間並行運用期間を設けて、かつ気づいたissueは比較的すぐに改修に着手したこともあって、運用に支障をきたすような致命的な残課題は今の所ほとんどない。逆に言うと残ってるのはチマチマしていてかつ面倒くさいやつであり、正直腰が重い。そのうちやろう…と思ってると延々手つかずで終わりそうなので、少なくとも年内にはなんらかの形で完了させたい所存。

  • あまり意識してなかったせいか、スマホのChromeで使うと一部の画面項目の幅が狭くて若干使いづらい。レスポンシブのスタイルはほとんどすべての箇所に未設定なのが原因だろう。運用に耐え切れないってほどではないので優先度は低めにしてるが、どこかでちゃんと整えたいなあと思っている。
  • 旧システムの残課題4.「「日」の考え方が機械的に24:00で切り替わるようになっている。」に関しては、対応はしたのだが、24時をまわって登録した分が前日のその時刻にやったことになってしまうので、あまりイケてない。(3/22 00:10に開始した筋トレを3/21分として登録できるが、記録上3/21 00:10にやったことになってしまう)ちょっとデータの持ち方を変えないといけなくて面倒くさいので手を付けてないが、そのうちやりたいなあと思っているところ。
  • 旧システムで残課題5.「複数件のトレーニング記録をあとでまとめて更新」機能がない。今のところそこまで(個人的にも)需要がないので作ってないのだが、まあそのうち作ろうかなあ…くらいに思っている。

おわりに

困ったところ・詰まったところをただひたすらに書き溜めていった記事なのでだいぶ長くなってしまったが、その分学びもあり、また総合的には楽しい開発体験だったと思う。今後このWebアプリをより良い形に育てていきたいと思います。