Fastifyで非同期ハンドラ関数を使おうとしたらだいぶハマった
はじめに
Fastifyで書かれたソースコードをリファクタリングしていたとき、何らかの手違いによってクライアントへレスポンスを送信できない事態を引き起こしてしまいました。調べてみても対処法がわからず、結局いろいろと検証をする羽目になったのでここにその検証結果を記事として残します。
問題のコード
import Fastify from "fastify";
(async () => {
const fastify = Fastify();
fastify.get("/", async (_request, reply) => {
await reply.header("Content-Type", "text/plain");
return "Hello, world!";
});
const address = await fastify.listen({ port: 3000 });
console.log(`Server is now listening on ${address}`);
})();
私がリファクタリングしていたコードはもっと複雑で、この最小構成にたどり着くまでにも時間がかかりましたが……。
このようなコードを動かそうとすると、サーバの起動こそうまくいきますが、/
へアクセスしても一向にレスポンスが送信されません。何かの計算に時間をかけているような様子でもなく、ただいつまでも待たされます。
どこがいけなかったのか
サーバの起動はできていることからも、ハンドラ関数でのレスポンス生成になにか問題があると考えられます。
fastify.get("/", async (_request, reply) => {
await reply.header("Content-Type", "text/plain");
return "Hello, world!";
});
注目してほしいのが2行目、await
しています。これはESLint(正確にはtypescript-eslint)が、no-floating-promises
ルールに基づいて「Promiseは適切にawait
しなよ!」とエラーを表示してきたために書いたものです。
Promiseならばawait
する、当たり前です。FastifyのドキュメントなどではPromiseが返されるとは書かれていませんでしたが、ESLintが言うのだからそうなのでしょう……。しかしこれがよくありませんでした。
reply.header()
によって返される値は、正確にはPromiseではなくThenableな値です。Thenableとはその名の通り.then()
できる値のことで、Promiseもそうですし、自作クラスもthen
メソッドを実装していればThenableです。
class Thenable {
then(resolve: () => void, _reject?: () => void) {
resolve();
}
}
new Thenable(); // ERROR: no-floating-promises
typescript-eslintによるエラーには次のように書かれています。
Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the
void
operator.
提示された選択肢は4つ。
-
await
する(rejectがエラーとしてthrowされるようにする) -
.catch
する(rejectを適切に処理する) - 第2引数付きで
.then
する(rejectを適切に処理する) -
void
する(無視することを明示する)
この中ではawait
が今回の需要と噛み合っていそうだったため、私は深く考えることなくawait
してしまいました。
というわけで、状況的にこれは「いつまでも履行されることのないThenableの履行を待ってしまった」のが原因であると考えられます。
ハンドラ関数とはそもそもなんなのか
ここで私は、そもそものFastifyのハンドラ関数がどういった挙動をするものなのか整理しようとしました。同期的な関数も非同期的な関数もハンドラ関数として妥当です。返り値はvoidでもそれ以外でも構いませんが、それがクライアントへのレスポンスとして使われるかどうかは場合によります。そもそもレスポンスはどのように組み立てられ、どのタイミングで送信されているのでしょうか?
検証の結果、(真偽はさておき)次のような自然な解釈を得ました。
おそらくFastifyの設計思想として、
- リクエストに対して
FastifyReply
クラスのインスタンスが自動で作成される- レスポンス関連の処理は
FastifyReply
がすべて行う- ハンドラ関数は
FastifyReply
を操作する- ハンドラ関数は
void
を返すというのがあった上で、
- ハンドラ関数内で
await
を使えるようにするため、ハンドラ関数が非同期関数であることも許容する(返り値がPromise<void>
であることを許容する)- ハンドラ関数を実行しても
FastifyReply.sent
がfalse
ならハンドラ関数の返り値をレスポンスとして使う(返り値がunknown
やPromise<unknown>
であることを許容する)となっているのだろう。
ではここで気になるのが、FastifyReplyがレスポンスを送信するタイミングです。検証のために次のようなコードを書きました。
import { setTimeout } from "node:timers/promises";
import Fastify from "fastify";
void (async () => {
const fastify = Fastify();
fastify.get("/", async (_request, reply) => {
// voidでESLintエラーを無視する
void reply.header("Content-Type", "text/plain").then(
() => {
console.log("resolved");
},
() => {} // (第2引数もなぜか必須)
);
await setTimeout(100); // 0.1秒だけ待ってみる
console.log("before send");
void reply.send("これはレスポンスです");
console.log("after send");
await setTimeout(100); // さらに0.1秒だけ待ってみる
console.log("before return");
return "これはレスポンスとして使われません";
});
const address = await fastify.listen({ port: 3000 });
console.log(`Server is now listening on ${address}`);
})();
reply.header()
の返り値が履行されたタイミング、reply.send()
の前後、ハンドラ関数のreturn
直前の各部分にconsole.log()
を仕込んであります。実行結果は次の通り。
before send
after send
resolved
before return
というわけで、どうやらreply.send()
によって履行されるようになっているようです。ここで最初の問題のあるコードを振り返ると……。
fastify.get("/", async (_request, reply) => {
await reply.header("Content-Type", "text/plain");
return "Hello, world!";
});
「ハンドラ関数が実行され値が返されることで初めてレスポンスは送信されるのに、ハンドラ関数内の処理ではレスポンスの送信を待っている」というデッドロックが起きてしまっています。
どうすればよかったのか
-
await
しないよう気をつける -
void
するよう気をつける
おわりに
リファクタリングのついでにFastifyやめたい……。
おまけ:そもそもFastifyのこの挙動はなんなの?(考察)
Fastifyのソースコードを読んで理解できたら良かったのですが、
- TypeScriptではないため型がわからない
- JSDocも書かれていないため型がまったくわからない
- 古典的なCommonJSで書かれており開発ツールの静的解析がうまくいかない
- そのため、実際の使用方法から型を推測するのも重労働になっている
- 関係ないけどclassじゃなくprototype拡張なんですね……
といった感じでやる気が尽きて読めなくなってしまったのでわかりません。
FastifyReply
を操作すればいいのかハンドラ関数が値を返せばいいのか、ハンドラ関数が値を返すとしてレスポンスステータスコードの設定やレスポンスヘッダの設定などは依然としてFastifyReply
を操作しなければならないのか、そもそもなぜFastifyReply
は問答無用でThenableなのか(しかもawait
するとデッドロックすることがあるのか)、などなど。わからないことが多いです。
これは完全な想像ですが、Fastifyが生まれた当時の状況として、Promiseがまだ普及しておらず非同期的な処理のほとんどをコールバック関数によって表現していたために、関数の返り値によってレスポンスを変えるような仕組みよりも、FastifyReply
をハンドラ関数に渡してあげてその操作によってレスポンスが送られるような仕組みにしておいたほうがよかったのかもしれません。そして時代の流れに応えようと非同期ハンドラ関数に対応したりしたことで、このようなものになってしまったのではないかと。
Fastifyのドキュメントには、「非同期ハンドラ関数ではreply.send()
は使わないでね」とも取れるような記述があります。
また、FastifyReply
をThenableにするにあたってされた議論はなかなか白熱しています。
私のリファクタリング作業は一体どうしたものでしょうか。できるならFastifyをやめたいですが、Fastifyの資源を活用しまくった既存のコードを他のライブラリを使うように書き換えるのはだいぶ骨が折れそうです。
Discussion