💬

Remix の App Tutorial を魔改造してちょっとワカル気持ちになった

2024/06/08に公開

はじめに

流行ってるものを触ってちょっとワカル気持ちになろうのコーナーです。
今回はフロントエンドで話題っぽい Remix を触ってみました。
ちなみに普段からフロントエンドエンジニアという訳ではなく、今回のチュートリアルの延長アプリもせいぜい 5,000 行程度しか変更を加えていません。
そのため、その程度の解像度のお話にはなります。
Remix を本採用検討している方にはそれ自体の紹介やメリ/デメなどは良記事がたくさんあるので、そちらを参考にしてください。
本記事は、あくまでも少しチュートリアルの延長を行って僕個人が感じたことをメモするに留めてみましたので、ちょっと興味あるんだけど ...... ぐらいの温度感の人向けの記事です。

TL;DR

結論としては面白いフレームワークではあるなという感想です。

  • モダンで扱いやすいフレームワークで Web 初学者が Web アプリケーションを知るのに筋が良さそう
  • チュートリアルの完成度が高いのでとてもおすすめ
  • コレを使ってなにか作ってみたいと思える程度には自身は興味を持てた

App Tutorial の紹介

Remix 公式に用意してくれているチュートリアルは二つあります。
両方ともに英語のみですが、昨今翻訳ツールも充実しているので困ることはないでしょう。

  1. Remix Tutorial
  2. Jokes App Tutorial

1 は 30min 程度で最低限の Remix の機能が体験できます。
今回は触りませんでしたが、 template のコードやチュートリアルページをさっと眺める限り、オンメモリなデータに対する CRUD でフロントエンド側を中心と体験できるようになっていて、Remix の根幹となる機能を味わえるようになっていました。
LPやシンプルなポートフォリオなどバックエンドに依存しないものやバックエンド処理を完全に切り分けて考えフロントエンド機能のみの活用を検討する場合はこちらを参照するだけで必要十分そうな感じに見えます。

2 は少し長いですが、一連の Web アプリケーションに必要な機能が体験できます。
本記事における App Tutoail はこちらを指します。

App Tutorial は Outline を参考いただくと体験できる機能が一覧できるようになっていますが、かなり豊富です。
1 のフロントエンドの体験に加えて個人的にうれしかったポイントは以下です。

  • 簡易の ID/Password 認証についてライブラリを使わないサンプルがあり
  • エラーハンドリングに対する説明 ( ErrorBoundary の組み込み ) があって実戦的
  • データベースやサーバーサイドとのやり取り含めて、手順毎に差分のみでなくファイル全体をコピーできるようになってるのでとりあえず動かす状態にするのが簡単

全体を通してチュートリアルとして質が高く Web の基本は網羅されていそうなので Web 初心者にも Web アプリケーションとは?みたいな課題として与えるのにもちょうどよさそうに感じました。

魔改造アプリの紹介

そんなチュートリアルを触ってみてもう少し 「 Remix に触れてみたい! 」 と感じたので基礎的な構成はチュートリアルを参考にしつつ魔改造してみました。
今回テーマとしたのは、Google Maps です。
ちょうどいいタイミング(2024.05.21 公開)に Google Maps Platform が React インテグレーション ライブラリ 1.0 を発表 という発表がありました。
Google Map ライブラリと言えば、TypeScript との組み込みが大変とか hook が直感的でなく泥臭すぎる実装が必要になったりとかなりの手間がありました。
それが解消されているということでついでに触ってみました。

出来上がったのもはこちらです。

https://youtu.be/ccd7zvhnA8U

どこ.や.net

※ 2024.06.08 時点では公開してますが、本格サービスインを考えていないのでしばらくしたら閉じます。

機能

テーマは「お気に入りの場所を共有し合うサービス」というていです。
ユースケースとして以下を想定しました。

  • 〜旅行に行ったときの場所の紹介
  • いきつけのお店を共有したい
  • 生活に役立つ場所を教えたい
  • 特定テーマの場所にまつわる逸話を地図にまとめたい
  • ...... etc

機能としては以下があります。

  • ID/Password によるユーザー登録, ログイン (チュートリアルより
  • マップの登録/削除
  • マップに紐付く場所の登録
  • 新規登録されたマップの RSS フィード (チュートリアルより

やったこと

実際に上記を実現させるためにチュートリアルから手を加えた点は以下です。

  1. tailwindcss の導入
  2. 使いまわしの効くものをコンポーネント化したりルートを tailwind で書くようにしたり
  3. 必要なルートを追加
  4. 全体的なレイアウトをざっと決める
  5. prisma model 定義
  6. seed の用意
  7. 既存のコンポーネントや App Tutorial を参考にデータ読み取り系処理を実装
  8. Google Map 系処理実装
  9. Map, Location の追加処理など

tailwindcss の導入

App Tutorial は分かりやすさを重視して素の CSS を HTML タグに当てる方式で実装されています。
CSS に対しては以下のような説明があります。

One quick note about CSS. A lot of you folks may be used to using runtime libraries for CSS (like Styled-Components). While you can use those with Remix, we'd like to encourage you to look into more traditional approaches to CSS. Many of the problems that led to the creation of these styling solutions aren't really problems in Remix, so you can often go with a simpler styling approach.

That said, many Remix users are very happy with Tailwind CSS, and we recommend this approach. Basically, if it can give you a URL (or a CSS file which you can import to get a URL), then it's generally a good approach because Remix can then leverage the browser platform for caching and loading/unloading.

基本的に Remix は Web 標準を推していて従来の CSS の利用を推奨するような思想のようです。
とはいえ tailwind の利用も多くは問題にならないとあります。
個人的には CSS の理解度はほぼゼロに近しいので今回は個人的な分かりやすさ重視で tailwind を採用しました。

使いまわしの効くものをコンポーネント化したりルートを tailwind で書くようにしたり

Tailwind CSS はコンポーネント指向です。(と言い切っていいほどの理解度ではないですが。。
ともかくチュートリアルは必要最低限のコンポーネント化を行っていて、かつ form や h1 などグローバルは HTML タグに対するスタイルなども多くありました。
その点は一旦雑多にコンポーネント化するなどしました。
結果としては過度な抽象化になった気がして中途半端な実装になったなという気がします。

古くからDRY原則を勘違いしないこと(コードの重複≠ユースケースの重複)は訴えられていましたが、「コードに早まってDRY原則を適用しないこと」とGoogleが呼びかけ、こういった話もあるようにこの辺りは改めて一連作り終えてからの抽象化を心がけたいですね。

必要なルートを追加

既存の CSS から解放された後は用意しようとしている機能に準じたパスを想定してルートを追加しました。
結果としては以下のような構成になりました。

dokoya/app/routes on  main on ☁️
❯ tree
.
├── _index.tsx
├── locations.new.tsx
├── locations.tsx
├── login.tsx
├── logout.tsx
├── maps.$mapId.tsx
├── maps._index.tsx
├── maps.new.tsx
├── maps.tsx
└── maps[.]rss.tsx

本当は /maps/${mapId}/locations/new のようなパスを表現したかったのですが勘違いかもですが、 maps.$mapId.locations.new.tsx みたいなものを用意してもうまいこと動いてくれなかったので locations.new.tsx になってたりします。
この辺りはディレクトリを使ったりすると上手く言ったりするのかシンプルにファイル名やパス名をタイポしてたりしてたのか。。

全体的なレイアウトをざっと決める

flex を最大限有効活用してがんばりました。
できる手は尽くしたんです。

prisma model 定義

各機能で利用できる model の方を定義しました。
最終的には以下のような model です。
今回は必要最低限の実装のため User は Map を複数持っていて Map は Location を複数持てるというシンプルな構成です。
prisma は直感的な model 定義から対応したドライバ( db.provider )の sql をリレーションありで作成してくれるのが便利ですね。
今回は試してませんが、PostgreSQLとかだとマイグレーション毎のマイグレーションファイルを作ってくれるのも大きく、本番運用におけるマイグレーションの自由度が高いですね。
CreatedAt や  UpdatedAt について ORM でサポートしてくれているのも大きいです。
本来は db 側がマネージドに提供してくれればいいですがそうではなくテンプレート拡張が必要だったりアプリ側実装が必要なケースが多いと思うのでORMが用意してくれるからとりあえずそれを使っておけばいいの世界観は楽ちんです。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "debian-openssl-3.0.x"]
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id           String   @id @default(uuid())
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
  username     String   @unique
  passwordHash String
  maps         Map[]
}

model Map {
  id        String @id @default(uuid())
  mapsterId String
  mapster   User @relation(fields: [mapsterId], references: [id], onDelete: Cascade)
  name      String
  locations Location[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Location {
  id        String @id @default(uuid())
  mapId     String
  map       Map @relation(fields: [mapId], references: [id], onDelete: Cascade)
  title     String
  memo      String
  lat       Float
  lng       Float
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

seed の用意

ここどうしようかな?と思いましたが、今の時代に感謝ですね。
Chat GPT に上記 model を見せた上で、少し prompt を調整しただけである程度の数の seed を用意してくれました。
単純なモックデータの用意なんかはもう AI にまかせっきりでいいですね。

...
...
          {
            name: "京都旅行",
            locations: {
              create: [
                { title: "清水寺", memo: "有名な観光地", lat: 34.994856, lng: 135.785046 },
                { title: "祇園", memo: "舞妓さんが見られる地域", lat: 35.003674, lng: 135.778877 },
                { title: "烏丸御池", memo: "中心街", lat: 35.010399, lng: 135.762828 },
              ],
            },
          },
          {
            name: "東京観光",
            locations: {
              create: [
                { title: "東京タワー", memo: "ランドマークタワー", lat: 35.6585805, lng: 139.7454329 },
                { title: "浅草寺", memo: "歴史的な寺院", lat: 35.714765, lng: 139.796655 },
                { title: "上野動物園", memo: "家族向けの動物園", lat: 35.715298, lng: 139.773036 },
              ],
            },
...
...

既存のコンポーネントや App Tutorial を参考にデータ読み取り系処理を実装

データ既に投入されてさえしまえば読み取り部分で困るポイントは特になかったです。

Google Map 系処理実装

とりあえず雑多に maps.$mapId.tsx 辺りにGoogle Map ライブラリの公式ドキュメントに書かれているサンプルを置いてみたりしたんですが、めちゃめちゃワーニングが出て困ってました。
エラー文言としては以下のようなものです。

Warning: useLayoutEffect does nothing on the server ......

*.server.tsx をサーバー処理として区別する」といった記述があったのでチュートリアルをやった時点では勘違いしていた(多分読み飛ばしていた)のですが、Remix は基本的にはサーバーサイドでレンダリングします。
そこが起因としていたようです。
そのため、クライアントコンポーネントであることを明示するなりする必要がありました。
やり方としては色々とあるようですが、remix-utils/client-only を利用するのがてっとり早かったです。

上記は実装を眺めるとめちゃめちゃシンプルな実装になっていてハイドレート(クライアント読み込み)が終わるまで fallback (デフォルトは何もなし) を表示して遅延ローディングしてるような感じみたいですね。

Map, Location の追加処理など

Google Map ライブラリの使い方はざっとこんな感じでした。全体を通して直感的でわかりやすかったです。
公式のサンプルも豊富でやりたいことは大抵出来そうな印象を受けました。

  • <GoogleMap /> コンポーネントで地図表示
  • コントロールパネル(検索入力欄など)は別コンポーネントとして実装する
  • 地図の位置を動的に変更するパターンなどは useMap をラップした MapHandler を用意することで実現できる
  • 地図上のマーカーは GoogleMap コンポーネントの children として置く

追加の post や削除の delete はほぼチュートリアル通りで詰まりポイントはなかったです。

寄り道

ローカルで期待通り動くようになったので次はデプロイを考えてみました。
App Tutorial の最後には Fly.io を使ったものが提示されていました。
確かにこの手のホスティングサービスは利用も簡単なんですが、個人的には汎用性に欠いていてマネージドである反面システムがサービスに依存しすぎるきらいがあるように感じています。
ので、シンプルに Docker 化してパブリッククラウドなりに上げる方が好みです。
これが必ずしもいいのかというとそんなことはなく、spin-up の問題であったり実装工数の問題であったりがあるので適切に考える必要はあるように思います。
あくまでも今回の範疇では好みであるパブリッククラウドの中でもGCPへのデプロイを検討してみました。

候補としては以下です。

  • LB + GCE
  • Cloud Run + GCS

LB + GCE は言うまでもなく定番ですね。
難しいことを考える必要はなく仕上がった GCE に物理的にソースコードを持っていってドンと serve すれば完了です。
GCE 立ち上げっぱなしであることや LB の利用は個人のお試しの範疇では料金面や保守面双方でコスト的に不利なので今回は Cloud Run + GCS にしました。

Cloud Runサービスの Cloud Storage ボリュームのマウント を利用して GCS bucket をマウントしてそこに配置した sqlite のファイルを読み込む形です。

色々と試してみて Dockerfile はマルチステージングビルドを行い最終的にはこうなりました。

FROM --platform=linux/x86_64 node:20.14.0-slim as builder

WORKDIR /app

COPY . .
RUN npm ci
RUN npm run generate
RUN npm run build

FROM --platform=linux/x86_64 node:20.14.0-slim as production-installer

ENV NODE_ENV production
WORKDIR /app

COPY . .
RUN npm ci --omit=dev
RUN npm run generate

FROM --platform=linux/x86_64 node:20.14.0-slim as runner

ENV NODE_ENV production
WORKDIR /app

RUN apt-get update -y && apt-get install -y openssl

COPY --from=production-installer /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/build ./build

CMD [ "npm", "start" ]

--platform=linux/x86_64 は手元の mac が amd64 なので手元からビルドする際に Cloud Run 上で動作しなくなるため追加してます。
openssl を入れているのは prisma に要求されたからです。
model にもありますが、openssl いれる + debian-openssl-3.0.x の指定が必須でした。

Cloud Run + GCS のボリュームマウントで sqlite が動くかどうかは試した事がなかったので半分かけでしたが、思っていたよりもすんなりと動いて拍子抜けしました。
コスト面を鑑みてもプロトタイプ実装にはもってこいな構成だなと思います。

おわりに

Remix はいいぞ!
チュートリアルは面白かったですし、最近のフロントエンドフレームワークがちゃんとフレームワークしてるなというのを感じてよかったです。
ノリでやってみた魔改造もやりきれる程度には扱いやすいフレームワークでした。(基本的には大体機能使って挫折します。
本資料には書ききれませんでしたが、ErrorBoundary が組み込まれていたり随所に使い手に配慮のある仕組みが用意されていて、個人的には好きでした。
なにかしら小さなサービスを作る機会があれば使ってみたいですね。

一方で大規模なアプリケーションや本番活用するために考慮する点は多々あると思います。
例えばデータベースは sqlite より psql など利用してマネージドな db にした方がよさそうです。
その上でマイグレーションの仕組みなど考える必要がありそうです。
大規模なアプリケーションにする場合はそもそもバックエンドを切り離すなど検討が必要そうです。

技術的な体験を得るという面では今回必要十分な体験が出来たんですが、個人的な課題も多く感じました。
これは実力的な問題ですが、今回魔改造で作ったサンプルアプリを本番サービスインするためには足りないものが多すぎますね。。
なんとか auth を使って OAuth できるようにするとか、グループ単位で利用できるようにするとか、もう少しターゲットを限定したシナリオを想定したサービスにするとか、、、何よりデザイン(CSS)が難しすぎる!

デザイン勉強するべきですかね ...... 。
一人でサービスを立ち上げるとなると、人に何をどう見せるか?を考えれるようになるべきで、デザインは覚えたいとは思います。
相当な熱意が必要そうです。
どなたかデザイナーの方いっしょになにか作ったりしませんか?
デザイナーとエンジニアを繋げるサービスとかサービスになりませんか?

...... 本筋からそれましたが、(本来の絵もきっとそうだとは思いますが) CSS なんかで表現されるアプリケーションはまだロジカルに感じる部分もあるのでうまい落とし所を見つけて一つぐらいちゃんとしたものは作りたいですね。

最後はポエミーになってしまいましたが総じて Remix 楽しかったです!

Discussion