個人開発してるサービスをExpressからNext.jsにしたり、BusBoyを使った話しなど

2017年から個人で開発しているTrickleというサービスがある。最近、これのバックエンド構成を変えたり、新機能追加などをした。技術的に目新しいものや凄いものはないけど、頑張ったのでその時の話を残しておく。

バックエンド

Express → Next.js

これまではExpressでモバイルアプリ向けのWeb APIを作っていたが、今回Web版も作るにあたり、Next.jsに移行した。

まずはこれまでのモバイルアプリ向けAPIをNext.jsのAPI Routeで作成。Expressと非常に似た感じだったので、ハンドラー部分だけ新規実装して、残りはほぼそのまま動いた。

APIを移行するにあたって、少しだけ実験実装を入れてみた。通常、Next.jsのAPI Routeは一つのパスでHTTPメソッドをすべて受け取って、ハンドラーの中で分岐するようになってる。今回はその方式ではなく、パス1つにつき1つのHTTPメソッドに限定するようにしてみた。

例えばこんな感じで、GET, POST...ごとに個別のパスを作った。こうすることで各ファイルの中は非常にスッキリした。

/page/api/items/
             - index.ts
             - [itemId].ts
             - update.ts
             - delete.ts

もうRESTではないし、いっそのことすべてPOSTで受け取るようにするか?みたいにも考えた。けど、そこまでやるのはGraphQLへ移行するときがよさそう。来年とかにGraphQL移行したい。

あとExpressではMulterを使って画像(マルチパート)をうけとっていたが、Next.jsではBusBoyを使ってストリームとして処理してみた。これは後述する。

Web版実装はSSRを使うことにした。これはOGPを使えるようにしたかったため。特にハマることなくサクサク実装できた。

過去にNext.jsの小さなアプリを1, 2個作ったことはあるが、がっつり使うのは今回がはじめて。その時も思ったが、やはりNext.jsはすごく開発しやすい。最高。

Multer → BusBoy

ExpressのmiddlewareとしてMulterを使って画像などのマルチパートを処理していたが、Next.jsでそのまま使える感じではなかった(?)ので、BusBoyを使うことにした。

クライアントから画像などの大きなファイルを受け取ってGCSなどのクラウドストレージに保存する場合、ストリームで受け取ったものをそのままストリームとして書き出すのがメモリ使用量を抑えられる。

しかしMulterはすべての画像を一度すべて受け取ってから処理することになっているので、メモリ効率があまりよくない(と思う)。今回BusBoyを使うことにしたので、ストリームとして処理できるようにしてみた。

なんだけど、運営してるサービスでは画像の受け取り時にリサイズ処理をサーバ上で行っているため、すべてをストリームパイプラインで実施することはできない。

そこで以下のようにストリームからの画像読み出しのタイミングを調整して、すべての処理を直列で実施することにした。

ストリームから画像1つ読み出し → 画像リサイズ処理 → GCSに保存 → ストリームから画像1つ読み出し → 画像リサイズ処理 → ・・・

これによって、1リクエストの中では画像リサイズ処理が同時に複数実行されないので、メモリのピーク使用量は抑えられていると思う。一方で、トータルの処理時間は長引くので、結局良いのか悪いのかは微妙なところ。

ちなみにMulterも内部的にはBusBoyを使ってるらしい。マルチパートなデータを処理するライブラリの比較については以下のサイトがよかった。

https://bytearcher.com/articles/formidable-vs-busboy-vs-multer-vs-multiparty/

Web版

Web版を作るにあたり、URL設計を当初は愚直に/items/[itemId]にしようかと思っていた。しかし、itemIdが外に露出するのは少し嫌な感じがする。露出すると、以下のような弊害がある。

  • コンテンツ数が予測される
  • クローリングされやすい

この2つ、実害としてはそんなにないし、僕の個人サービスは全然有名ではないので心配し過ぎではある。とはいえ露出させるメリットもなさそう。でも、こういうところを検討することは勉強にもなるので何かやってみることに。

単純に思いつくのはitemId + saltをMD5にしたり、UUIDを使う方法だけど、それだとURLとして長い。では先頭N文字を使えば良いかといえば、それは想定されていない使い方だし、文字種が少ないので衝突性が高くなったりと微妙である。

そこでZennのURL設計を見てみたところ、/articles/a873bbbe25b15bというようになってる。 記事ごとに独自IDを発行して、それをURLとしてるようだ。

そういう実装が簡単にできるライブラリをさがしてみたら、Nano IDというのをみつけた。これは文字種や長さを自由に調整しつつランダムな文字を作れるというもの。衝突確率のシミュレータなども公開されている。今回はこれを使って[0-9a-f]{14}でIDを生成した。最終的には以下のようなURLとなった。

https://trickle.day/topics/7d9cfed9e3e143

GAE → Cloud Run

これまでGoogle App Engineのflexible環境を使っていた。これは前述したサーバサイドで画像リサイズをしているので、ピークメモリ使用量が結構必要になるからである(メモリ1GBにしてる)。しかし、これが結構お高い。月間で8500円ほどかかっている。

これは高すぎて辛いので、サーバレスにして安くしたいと思いCloud Runに移行してみた。ミニマムインスタンス1、リクエストの処理中にのみCPUを割り当てる、という条件で月間1500円ぐらいにおさまりそう。安い。

Next.jsをCloud Runで動かすのは簡単だった。Next.jsの公式どおりにDockerfileを書いて(yarnをnpmにかえた)、Cloud Runのチュートリアル通りにデプロイすればよい。

https://nextjs.org/docs/deployment#docker-image

クライアントアプリ

React Nativeのアップグレード

React Nativeは個人開発のように開発リソースが限られてる状況でiOS, Androidのアプリを作るのにすごく役立ってる。

しかし、React Native自体のアップグレードはまじで辛い。公式からアップグレードツールは提供されているが、それでうまくいったことが一度もない。

今回のv0.63からv0.68へのアップグレードもやはりうまくいかなかった。なので、僕は毎回新規にアプリを作成して、そこに既存のソースコードやライブラリ、設定を移植するという方法をとっている。今回もその方法で実施した。

昔はReact Nativeのネイティブモジュールを入れるのもハマることが多かったけど、最近それはすごく簡単になってるのでそこにはストレスはない。

細々としたプロジェクトの設定や、プッシュ通知の設定、アイコンの設定などは面倒ではある。なので設定した場所をドキュメントとして残していってる。

順調にいけば半日ほどで完了するので、アップグレードツールでハマってしまうことを考えたらこっちのほうが現時点では良いと思ってる。

けど、辛いのは辛い。

react-native-image-crop-picker → react-native-image-picker

React Nativeでギャラリーから画像を選択するにはネイティブモジュールを使う必要がある。これまではreact-native-image-crop-pickerを使っていたが、これが結構不安定で辛い。

  • Androidでqualityを1未満でpngをリサイズすると、jpegに変換されてしまい、透過が消える
  • Androidのレスポンスにfilenameがない
  • AndroidでPhotos経由で写真を選択するとレスポンスがとれない
  • iOSでpngをリサイズすると、mimeがjpegになる

そこで今回react-native-image-pickerも評価してみた。

  • iOS14以降ではないと画像選択の最大枚数の制御ができない
    • 今はiOS15がメインなので、そんなに問題ではない
  • 取得した画像パスが選択した画像順にならない
    • 内部でリサイズされた順番になってそう
  • Androidでは返ってくるデータのwidth,heightがexif rotationを考慮した値になっていない
    • なので、画像を取得したらあらためて、React NativeのImage.getSize()でサイズを取得して回避している

というわけでreact-native-image-pickerのほうがよさそうなので移行した。ワークアラウンドは多少必要だけど、全体的にはcrop-pickerに比べて全然良い。

どうしてreact-native-image-pickerを最初から使ってなかったのかは忘れた(当時も評価したような気がするんだけどな)。

ソーシャルログイン

Firebase Authを使ったソーシャルログイン(Googleログイン、Appleログイン)をサポートした。実装自体はReact Native Firebaseのドキュメント通りにすれば、簡単にできた。細かい注意点としては以下のようなものがある。

  • iOSシミュレータではAppleログインができない
    • できるようにする方法もあるみたいだが、実機インストールして試せばよいので深追いせず
  • Googleログインのみにしたかったが、Appleの規約上ソーシャルログインを実装する場合、Appleログインを入れる必要がある
    • Android版にAppleログインは実装しなかった
  • Androidの場合、Firebaseプロジェクトにアプリ署名鍵のSHA 証明書フィンガープリントを追加する必要がある
    • アプリ署名をGoogle Playでしている場合手元のアップロード鍵のフィンガープリントではなく、アプリ署名の方を入れる必要がある
  • サービス内のアカウントと紐付けるためにサーバ上ではFirebase Adminを使ってクライアントからもらったidTokenを検証する
    • クライアントから直接Firebase UIDを送ってそれを使うと、UIDによるなりすましができてしまうため

そして、ソーシャルログインを実装したので、ID/Passwordによる新規のアカウント発行は停止した。サービスリリース当初はReact Nativeによるソーシャルログインモジュールがまだ整ってなくて、仕方なくID/Password認証にした記憶がある。今からサービス作るなら、ソーシャルログインのみにするのがよさそう。

それと、これまでID/Passwordでログインしていた人をソーシャルログインへマイグレーションする機能(アカウントマージ)は今回は未対応。結構大変なので先送りで。

アイコン変更

今回サービスのアイコンを変更した。ただ自分でアイコンデザインはできないので、99designでデザインコンペを実施してみた。

料金グレード(参加できるデザイナーのレベルと連動)はいくつかあるが、最高グレードでやってみた。99designsの最高レベルのデザイナーはどんな感じなのか体験したかったのと、下位グレードにして後悔するのは避けたいなと考えたため。

結果としては満足行くものができた。しかし、レベル感としては全員が全員たかいわけではないようだ。応募してくれたのは5人ほどでそのなかでいいなと思ったのは2名。みんな複数案件掛け持ちでやってるのだろうから仕方はなさそう。

でもデザイナーと細かくやり取りしながらすすめていけるのはよかった。またアイコン作成するときは使ってみようと思う。

ちなみにアイコンを変更した理由は、以前のアイコンは「記録していく」「ためていく」という意味合いが強かったが、「書くこと自体」にもっとフォーカスしたアイコンにしたかったため。


これ以外にも細かい実装をめちゃくちゃたくさんした。だけどまだまだやりたいことがある。

  • デスクトップ版のソーシャルログイン対応
  • SequelizeからPrismaに移行
  • MySQLからPostgresqlに移行
  • RESTからGraphQLに移行
  • React hookに移行

ぼちぼちやっていこうと思う。

ちなみに今回話題にした個人開発してるサービス自体については以下の記事を書いた。興味を持ってくれた方は読んでみてほしい。

見るより気兼ねなく書く、Trickleというサービス

https://blog.h13i32maru.jp/entry/2022/08/12/154716

丸山@h13i32maru