🎆

Cloudflare WorkersとHono🔥を学ぶ

2024/09/23に公開

はじめに

Cloudflare Workersをキャッチアップします。Cloudflare Workers上で動くHonoというフレームワークの評判がとても良いです。
せっかくなのでクラウドフレアのドキュメントを読みつつ、Honoのソースコードを読みつつの、Honoっぽいフレームワークを作成してキャッチアップしていきたいと思います。

Cloudflareとは

CDNの提供から始まり、強力なWAFを提供している企業というイメージでした。最近はCloudflare Workersというエッジコンピューティングが人気を集めており、それ以外にもドメインレジストラ、オブジェクトストレージ、サーバーレスDB、KVストア、キューイングなどのサービスも提供しています。
ニューヨーク証券取引所に上場しています。

Cloudflare Workersとは

エッジコンピューティングのサーバーレスプラットフォームです。世界各地のデータセンターにWebサーバーを設置することで、リクエスト元のロケーション近いサーバーでそのリクエストを処理し、レスポンスを返すことができます。(地理的な優位性)
また、似たサービスにAWS LambdaやGCP Cloud Runなどがあります。これらはコンテナを使用したリソース管理をしているため、コールドスタートなどのオーバーヘッドが生じてきます。*1
反面、Cloudflare WorkersはV8のIsolateと呼ばれる機能でサンドボックス環境を作成しコンテキスト管理をしているため、コールドスタートそのものが発生しません。*2
また、Node.jsではなくV8をそのままJavaScriptの実行環境として使用しているのでNode.jsより高速なようです。(Node.jsも内部ではV8を使用している)

*1 AWS Lambda@Edge vs. Cloudflare Workers
*2 How Workers works#Isolates

Cloudflare Workers考察

120か国以上にデータセンターを構えているようで、日本でも東京、大阪、福岡、那覇の4都市にデータセンターを持っています。
クラウドフレアが提供するエッジロケーション上で動くDBやストレージ、キャッシュサービスなどと連携もしやすいはずで、パフォーマンスを気にした薄いバックエンド(BFFやWebサーバーなど)を配置するには、最適解の1つになりそうです。

(Next.jsなど使用するとき、それ用に最適化されたVercelなど利用した場合はまた違ってくるかもです…)

Honoとは

ブラウザなどで使用されているWeb標準の技術を使用して作られた軽量でシンプルなWebアプリケーションフレームワーク。Web標準に則っておりCloudflare Workersを始め、多くのプラットフォームで動作します。また、公式のNode.jsアダプターを使用することでNode.js上でも動かすことができるそうです。

Hono感想

Honoは「シンプルで使いやすいフレームワーク」という噂は聞いていましたが、その通りだと思いました。私自身も重厚なフレームワークはそれ自体に振り回されてしまうので、薄いフレームワークを好みます。実装もExpress.jsを使ったことがある人ならすぐにキャッチアップできそうです。

また、両面TypeScriptで開発している場合、得たい恩恵の1つに「フロントエンドとバックエンドの型の共有」があります。これを実現するためtRPCを使用することがありました。zodを使用した型の共有の恩恵に比べればtRPCの癖は許容範囲だと思っていました。
しかしHonoでは同じようなことをとてもシンプルに行えます。これには「あれ、だったらtRPCじゃなくて良きかな?」と感じました。*1

Node.js上でのパフォーマンスはExpress以上、Fastify未満*2 とのことなので、今後はNode.js上でも選択肢に入ってくるかもしれません。

また、どの言語でBFFやWebサーバーを作っていると「認証、セキュリティヘッダー、ログ、リクエストID、包括的なエラー処理」あたりの実装は必要になってくると思います。
ここあたりが使いやすいインターフェースで元から提供されているのも嬉しかったです。

そして何より、ドキュメントがシンプルで最高でした。

*1 見よ、これがHonoのRPCだ
*2 Honoの今の状況

Honoのソースコードを読んでみた。

https://github.com/honojs/hono

噂通りとてもシンプルです。1時間ほどあればアウトラインを掴むことができると思います。
クラウドフレアのキャッチアップ目的でしたが、クラウドフレアと言うよりもTypeScriptによるシンプルで使いやすいフレームワークの設計を学ばせていただきました。

Honoを参考にフレームワークっぽいものを作ってみた

https://github.com/ishiyama0530/Hiba

練習で作ったので実用性0です。Honoと似たインターフェースを持つフレームワークを目指しました。

/src/hibahonojs/honoにあたるディレクトリ。
/src/middlewareshonojs/middlewareにあたるディレクトリ。
それぞれをindex.tsから使用しています。

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L10-L14

Hiba

なんとなく響きがHonoっぽかったので火花のHibaにしました(笑)
以下がほぼ全実装です。

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/hiba.ts

ルーティング、コンテキスト、エラー処理、ミドルウェア機能のみを提供しています。

ルーティング

Honoに習って以下のインターフェースで抽象化しています。

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/types.ts#L12-L16

src/hiba/router/poor-router.tsに実態はありますが、Httpメソッドとパスの完全一致でルートを返すだけの貧相なルーターです。/:idなどのパラメーターは使用できません(泣)
こういったリッチなルーティングを使用する場合はitty-routerなどを使用してルーターの実態を作成するのが手軽でしょうか。

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/hiba.ts#L47-L49
パスの登録は上記の用に内部で保持しているルーターにaddしているだけです。

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L20-L22
こんな感じでパスとハンドラを登録できます。

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L10-L11
ルーターの実態はHibaのコンストラクタで指定できます。

検証

GET/POST

※ GETとPOSTにしか対応していません。

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L20-L22

$ curl http://localhost:8787/hello -i
HTTP/1.1 200 OK
Content-Length: 34
Content-Type: application/json
x-hello: hello world

{"hello":"world from development"}%

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L24-L31

$ curl http://localhost:8787/hello -i -X POST -s | grep 'HTTP/1.1'  
HTTP/1.1 201 Created

コンテキスト

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L20-L22

Honoと同じように、コンテキストに使い勝手のよいフィールドやメソッドを持たせます。ルーターに登録されているハンドラに、都合の良いコンテキストを渡たしています。

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/hiba.ts#L69-L72

検証

c.json/c.env

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L20-L22

$ curl http://localhost:8787/hello -i
HTTP/1.1 200 OK
Content-Length: 34
Content-Type: application/json
x-hello: hello world

{"hello":"world from development"}%

Cloudflare Workersの環境変数

環境変数の管理

Cloudflare Workersの設定値はwrangler.tomlに記載し、worker-configuration.d.tsでEnvの型を拡張します。
シークレットに関してはnpx wrangler secret put <KEY>で登録し、環境変数同様にworker-configuration.d.tsでEnvの型を拡張して使用できます。
なを、ローカル環境では.dev.varsに記載することで環境変数を上書きできます。

$ npx wrangler secret put ENVIRONMENT

 ⛅️ wrangler 3.78.7
-------------------

✔ Enter a secret value: … **********
🌀 Creating the secret for the Worker "hiba" 
✨ Success! Uploaded secret ENVIRONMENT

$ npx wrangler secret list           
[
  {
    "name": "ENVIRONMENT",
    "type": "secret_text"
  }
]

エラー処理

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L47-L53

上記の用に設定することで包括的なエラー処理が設定できます。

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/hiba.ts#L70-L74

仕組みとしてはハンドラーの実行のTryCatchで設定したエラー処理を呼んでいます。

検証

404/500

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L39-L45

$ curl http://localhost:8787/notfound -i
HTTP/1.1 404 Not Found
Content-Length: 9
Content-Type: text/plain;charset=UTF-8

Not Found%                                                                                                    
$ curl http://localhost:8787/error -i   
HTTP/1.1 500 Internal Server Error
Content-Length: 21
Content-Type: text/plain;charset=UTF-8

Internal Server Error%   

個人的な考え

サーバーサイドでのエラーの扱い方
.
├── server
│   ├── api
│   ├── usecase
│   ├── domain
│   └── infra

私は上記のような構成を取ることが多いです。
そのさい、ExpressやHono(つまりAPI)の関心事は/apiに閉じ込めたいです。
ですが、エラーはどこのレイヤーからでもスローすることを許可し、上位のレイヤーでそのレイヤー都合で扱ってくれると良いと思っています。

なので、今回Hiba内ではなく呼び出し元でエラーを定義し、呼び出し元のエラーハンドラーで良きに処理しています。

https://github.com/ishiyama0530/Hiba/blob/main/src/errors.ts

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L47-L50

ミドルウェア

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/hiba.ts#L55-L57
ルーター同様に内部で保持している配列にaddしています。

https://github.com/ishiyama0530/Hiba/blob/main/src/hiba/hiba.ts#L16-L30
ハンドラ実行時に再帰的に登録されているミドルウェアを実行していきます。

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L13-L18
こんな感じでミドルウェアが登録できます。全ルートに適用させることしかできません笑

https://github.com/ishiyama0530/Hiba/blob/main/src/middlewares/auth.ts
認証用のミドルウェアです。

$ curl http://localhost:8787/hello -i -H "Authorization: Bearer 123"

このようなリクエストを想定しています。
declareでContextにフィールドを記載することでContextの型を拡張しています。

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L33-L37

Cloudflare Workersでも利用可能なAsyncLocalStorageとDIコンテナを合わせるともっと面白いことができそうです 🤭

検証

middleare123/auth

middleare123

// app.use(auth);
app.use((c, next) => {
  console.log("middleware 1 start");
  const resp = next();
  console.log("middleware 1 end");
  return resp;
});

app.use((c, next) => {
  console.log("middleware 2 start");
  const resp = next();
  console.log("middleware 2 end");
  return resp;
});

app.use((c, next) => {
  console.log("middleware 3 start");
  const resp = next();
  console.log("middleware 3 end");
  return resp;
});
$ curl http://localhost:8787/hello -i

# コンソールで…

middleware 1 start
middleware 2 start
middleware 3 start
/hello
middleware 3 end
middleware 2 end
middleware 1 end

auth

$ curl http://localhost:8787/hello -i
HTTP/1.1 401 Unauthorized
Content-Length: 12
Content-Type: text/plain;charset=UTF-8

Unauthorized%                                                                                                 
$ curl http://localhost:8787/hello -i -H "Authorization: Bearer 123"
HTTP/1.1 200 OK
Content-Length: 34
Content-Type: application/json
x-hello: hello world

{"hello":"world from development"}%

デプロイ

https://github.com/ishiyama0530/Hiba/blob/main/src/index.ts#L55

エントリーポイントでExportedHandler<T>["fetch"]を満たすフィールドを設置することで、Cloudflare Workers上に公開できます。
Hibaがsatisfies ExportedHandler<Env>;に合格しているのでHibaをexportすることで上記の仕様を満たすことができます。

// https://developers.cloudflare.com/workers/get-started/guide/#3-write-code
export default {
  async fetch(request, env, ctx) {
    return new Response("Hello World!");
  },
};

ちょっと分かりづらかったと思いますが、公式では上記の用に至ってシンプルです。

npm run deploy #(wrangler deploy)

これはクラウドフレアが提供しているCLIの機能ですが、難しい設定を入れずともdeployコマンドだけで済むの良いですね。
実際にはCIに組み込むことになると思いますが、それでいても簡単にできそうです。

検証

本番環境でhello
$ curl https://hiba.1728394857362134.workers.dev/hello -i -H "Authorization: Bearer 123"
HTTP/2 200 
date: Sun, 22 Sep 2024 08:53:02 GMT
content-type: application/json
content-length: 33
x-hello: hello world
report-to: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=v9Zftm2cvro99Bj5yxjTIvarybZrUDE1Qrq%2BbVVBi7Us%2BQ523lhEqBvCuI4EsIdmNCO3MLp%2BhtGW3v0NSdHo75WcUjInxaDBEqX4IRz9QP1o7%2FRqEYcZgizhTMe0OrZNzyWD7WuA02gks93ffvS43MBRt98%3D"}],"group":"cf-nel","max_age":604800}
nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
server: cloudflare
cf-ray: 8c710ff2b943d497-NRT

{"hello":"world from production"}%

最後に

  • R2(S3互換オブジェクトストレージ)
  • D1(リレーショナルデータベース)
  • KV(KeyValueストア)

ここあたりもWebアプリケーションを作っていると定番な構成要素だと思います。時間あるときにこれらの可用性やレプリケーションの仕様なども見てみたいですね 🙆‍♂️

Discussion