こんにちは。株式会社ゼストでバックエンドを担当している正原です。
最近は懲りずにまたフロントエンドに手を出してしまい、自分の古い知識に絶望していますが、強く生きていこうと思います。
さて、すでに本ブログで紹介されているように、弊社ではtRPCを用いてWEBアプリケーションを開発しています。
開発に利用し始めてから約半年ほどになりますが、型チェックによってうっかりフロントエンドでエラーが出てしまうような変更を何度も未然に防げていて、非常に助かっています。
またフロントエンドとサーバーサイドをTypeScriptという言語に統一しつつモノレポで開発してるので、そのような破壊的変更を行う場合においても、フロントエンドのことを詳しく分かっていないサーバーサイドエンジニアでも簡単な修正が行えるのも大きな利点です。
そんなゼストでのtRPCを用いたWEBアプリケーション開発ですが、今回はファイルアップロードで困ったこと・それをどうやって解決したのかを紹介したいと思います。
tRPCでのファイルアップロード
要件としてファイルアップロードが必要と聞いたとき、ぱっと思いついたのは以下の手法でした。
- BASE64でエンコードしてリクエストに含める
- tRPCではなくREST APIなどの別のエンドポイントを用意する
- 署名付きURLで直接クラウドのストレージへアップロードする
- tRPCで
multipart/form-data
形式のファイルアップロードを頑張る
まずBASE64でエンコードしてリクエストに含めるのは、大きな画像や動画などのバイナリファイルを想定すると少々不安なので見送りました。
次に、別のエンドポイントを用意する方法についてですが、tRPCで実装してきたミドルウェアによる認証機構などをそのままでは再利用できないので、なるべくなら避けたいと思いました。
署名付きURLについては、もしかしたら今後開発を進めていく中で変更するかもしれませんが、今回は検証として tRPC のエンドポイントで multipart/form-data
形式でのファイルアップロードが可能かどうかを試してみました。
サンプルを試す
tRPCでの multipart/form-data
形式でのファイルアップロードについてですが、現時点(2024/01)ではまだ experimental ステータスではありますが、公式によってサポートされておりサンプルコードもあります。
GitHubに記載されている通りですが、以下のコマンドで実際に動かすことができます。
$ npx create-next-app --example https://github.com/trpc/trpc --example-path examples/.experimental/next-formdata trpc-formdata $ cd trpc-formdata $ npm i $ npm run dev
これでWEBアプリケーションが立ち上がるので、ブラウザからアクセスすることによって、以下のようにそれぞれのサンプルフォームからファイルをアップロードすることができます。
同様にcurlを用いてファイルアップロードが可能なことも確認できます。
$ curl -s -XPOST -F 'name=test' -F 'image=@./zest-techblog.png' 'http://localhost:3000/api/trpc/room.sendMessage'|jq . { "result": { "data": { "image": { "url": "/uploads/1702573308588/zest-techblog.png", "name": "zest-techblog.png" } } } }
プロダクトに導入
サンプルが動くのを確認できたので、大勝利を確信して試しに現在開発しているプロダクトに導入してみました。
$ curl -s -XPOST -F 'file=@./zest-techblog.png' -F 'message=test' 'http://localhost:3000/trpc/upload' ... Cannot destructure property 'json' of 'payload' as it is null. ...
結果は予想とは大きく異なり、無惨にも大敗北となりました。
原因調査
残念ながらサンプルのように簡単に動かすことはできませんでしたが、サンプルとプロダクトの差分を考慮して、サンプルを徐々にプロダクトと同じ状態に近づけていき、何が異なるかを調査していきました。
superjson
最初に疑ったのは superjson
です。エラーの内容が json
というプロパティがない(null)ということだったので、superjson
のデシリアライズで失敗しているのではないか?と思い、サンプル実装にトランスフォーマーとして superjson
を用いるよう実装を変更してみました。
import { initTRPC } from '@trpc/server'; import { Context } from './context'; import superjson from 'superjson'; const t = initTRPC.context<Context>().create({ transformer: superjson, });
結果は問題なく動いたので、この検証から superjson
が直接的な原因ではないということが分かりました。
(疑ってごめんなさい😭)
fastify
次は何も候補が思いつかなかったのですが、他に差分らしい差分が fastify
しかなかったので、node
ではなく fastify
を用いるようにプラグインを導入しました。
fastify
プラグインの利用方法は公式ドキュメントに記載されているので、それに従って実装していきます。
export type AppRouter = typeof appRouter; const server = fastify({ maxParamLength: 5000, }); server.register(multipart); server.register(fastifyTRPCPlugin, { trpcOptions: { router: appRouter, createContext, experimental_contentTypeHandlers: [ nodeHTTPFormDataContentTypeHandler(), nodeHTTPJSONContentTypeHandler(), ], } // satisfies FastifyTRPCPluginOptions<AppRouter>['trpcOptions'], });
怪しいコメントアウトをしていますが、まさにここが原因で動かなかったようです。
実際、ソースコードを追っていくと fastify
プラグインの方には experimental_contentTypeHandlers
というハンドラを利用するような実装はありませんでした。
このハンドラがないことは satifies
さえ記述していれば気付けたのに・・・と悔しく思いましたが、公式ドキュメントにある satisfies
をわざわざ消すメリットはないので新しく追記されたのかもしれないと思ったところ、tRPC
レポジトリにあるドキュメントの該当行の最終更新履歴が2024/01/03となっていたので、もしかしたら私と同じような勘違い予防のために、公式ドキュメントに最近追記されたのかもしれません。
ワークアラウンド
では fastify
プラグインが原因であることは分かりましたが、まだ試験運用中の機能のために fastify
から node
に変更するかどうかは悩みどころだと思います。
また、今回の検証で出たエラーが「json
というプロパティがnull」という内容だったので、もしかしたらトランスフォームのタイミングで一工夫したら回避できないかと思って試行錯誤してみました。
const transformer: DataTransformerOptions = { serialize: superjson.serialize, deserialize: (data: never) => { const payload: SuperJSONResult = data ?? { json: null }; return superjson.deserialize(payload); }, }; const t = initTRPC.context<Context>().create({ transformer });
結果として、こちらの修正を加えることで他の実装に大きな影響を与えず fastify
プラグインおよび superjson
をトランスフォーマーとして利用しても、
multipart/form-data
でのファイルアップロードが可能であることが分かりました。
$ curl -s -XPOST -F 'file=@./assets/1.png' -F 'message=test' localhost:3000/upload|jq . { "result": { "data": { "json": { "message": "OK" } } } }
正常なリクエストのペイロードとして data
が undefined
であるリクエストが全て { json: null }
になってしまうのが少し気になるところです。
ですが、そのようなリクエストは本来であればエラーになっていましたし、実際のエンドポイントでは tRPC
の入力の型をスキーマ等によって定義するので、パースのタイミングで同様に失敗すると思われるため、あまり大きな問題にはならないかもしれません。
最後に
今回はファイルアップロードの検証を通して、tRPCのサーバー導入部分をより深く理解できてよかったたと思います。
せっかくいくつか検証してみたので、今回記事として取り上げてみました。
今回検証に用いた実装内容はサンプル実装として GitHub にアップロードしてありますので、よろしければ参考にしていただければと思います。