philan.netという寄付の予算を決めて寄付した記録をつけるウェブサービスを作ったので、この記事では技術的な部分の解説をします。 philan.net自体については、次の記事で解説しています。

この記事では、Next.js + Vercel + Cloudflare Workers KV + Googleスプレットシートを使って動いているphilan.netについて解説します。

あと検証中にCloudflare Workersを色々いじったのでそれについても書いていきます。

Idea

philan.netを作った理由でも書いていましたが、予算を決めておくことで寄付するときの気持ちを楽にするものを作ろうとしたのがスタートです。 この寄付の公開家計簿的なサービスは次のようなアイデアからスタートしました。

  • 家計簿のように自分の寄付を管理する
  • budget(予算)の設定
  • 寄付の履歴の登録
  • 年末調整用の便利な何か
  • publicに公開するためのポートフォリオ的なページ
  • なぜ寄付したのかをメモできるもの

これを機能的に見ていくと、つぎのような要素が必要でした。

  • 予算を記録できるデータベース
  • 寄付先、金額、メモなどを登録できるデータベース
  • データを取得してタイムライン的に一覧を表示できるページ

シンプルにデータに対するUI的なサービスになりそうです。 こういったデータベース的なサイトは、データをどこに置くかがコストに直結するので、まずはコストの面から考えていきました。

コストを最小化することは、サービスの継続性に直結するので、コストゼロを目指して設計しています。

実際に現時点のphilan.netはドメイン費用以外は、特に費用はかかっていません。

コスト

コストは次のようなメモ書きをしながら考えていました。


メモ書き

  • APIはServerlessベース
    • Cloud Run
    • Cloudflare Workers
    • Lambda
  • Databaseがやっぱり課題?
  • データ特性
    • そこまで書き込みは発生しない
    • 同時編集は実際には起きない(各自のデータを記録するだけなので)
    • 読み込みが多い
  • Google Spreadsheetを用意してもらって、それに読み書きする仕組みとか?
    • GASを仕込んだシートを用意してそれを使う
    • Airtable的な感じ?
    • SpreadSheetを手書きするのは体験的に厳しい(表示が重たい)ので、それを補助する入力UIや仕組みが必要
  • → Google spreadsheet + cloudflare worker kv + lambda??

実データはGoogleスプレットシートに書き込んで、 それを扱う仕組みがあれば十分そうだなと思って、 Googleスプレットシート + Cloudflare Workers + Cloudflare Workers KVで検証してみることにしました。

Googleスプレットシート + Cloudflare Workers + Cloudflare Workers KV

Cloudflare WorkersはCDN Edgeで動くLambdaみたいなサービスです。 CPU runtimeの同期処理はFreeだと10ms(有料は50ms、この時間は同期的な処理だけなのでFetchや非同期処理は計算外となる)、Worker Memoryが128MBといったLimitsがありますが、非同期処理がメインなら十分に使えてものすごく早いです。

English Notesという、GitHub Issuesをデータベースにしたブログを作ったりしましたが、30~50msで返ってくるサイトが作れます。(初回はCDN上にキャッシュがないため、GraphQLでGitHubのAPIを叩きますが、それでも1秒以内にレスポンスを返せます)

Cloudflare Workers KVは、Cloudflare Workersで使える結果整合性のKVSです。

Cloudflare Workersからしか使えないように見えますが、実際にはAPIからも触れます。 そのため、CloudflareのCDN上にあるKVSみたいなものとして使えます。

Cloudflare WorkersはLambdaみたいに任意のレスポンスを返せます。サイトを作るには画像などの静的なアセットなどもそのまま扱えるほうが楽ですが、Workers Sitesを使うとそれができます。 Workers Sitesの実態は、Cloudflare Workers + Cloudflare Workers KVです。

@cloudflare/kv-asset-handlerというモジュールを使うと、Cloudflare Workers KVに対して静的なアセットを保存して、Cloudflare Workersのルーティングを設定してくれるという形になっています。

これらを組み合わせれば次のような感じで作れそうな気がしました。

  • 静的なアセット: Workers Sites(Workers + Workers KV)
  • ロジック: Workers
  • KVS: Workers KV
  • データベース: Google spreadsheet

この構成で作ろうとした残骸がphilan.net/workerにありますが、実際にはいくつかの問題があってこの構成は難しかったのでやめました。

問題点

Googleスプレットシート + Cloudflare Workers + Cloudflare Workers KVだと、いくつかの問題がありました。

開発環境

Cloudflare Workersはwranglerを使うとローカルで開発ができます。 wrangler dev を使うとローカルのファイル変更を監視して開発できますが、このWorkersは実際にローカルで動いているのではなく、変更するたびにデプロイしていて、ローカルっぽく表示しているだけです。

デプロイしていること自体はそれ自体が早い(大体数秒で反映される)ので問題ないのですが、リモートで動いているためWorkersをローカルでデバックしているときにlocalhostへのリクエストが飛ばせないという問題があります。

これはCloudflarer Workersのスクリプトから localhoost へリクエストを送っても、実際にはCloudflare上で動いているWorkersなのでlocalhostはCloudflareになります。Cloudflare上でlocalhostへリクエストすると503が返ってきます。

そのため、ローカルでデバッグ中にlocalhostを参照したい場合は、localtunnelなどのlocalhostを公開する仕組みが必要になってしまいます。

エコシステムが弱い

Cloudflare Workersは、Service WorkersライクなAPIですが、まだエコシステムが殆どありません。

ExpressっぽいものならSunderJS/sunderがまずまずですが、Express middlewareなどNode.jsのAPIに依存したライブラリは動かないので、そこから作る必要があります。 (Node.jsのAPIを使ってないライブラリならwebpackでbundleされるため動く)

具体的には、セッション管理の仕組み(Cookie処理)、OAuth周りの処理などを全部自分で書く必要があります。

型が微妙

公式にcloudflare/workers-types: TypeScript type definitions for authoring Cloudflare Workers.があるので型はあります。

ただし、globalに型を追加するイメージ(Cloudflare Workersのruntimeの型なのでそうなる)なので、この型定義をライブラリで使うのが難しいです。 cloudflare/workers-typesから型がexportされてないので、Cloudflare Workers向けのライブラリが作りにくい感じでした。


このような問題があり、自分ですべて書けばできなくないけど時間的に難しいので諦めました。 ロジックはNode.jsを動かせるもので書いたほうがコスパが良さそうなので、別の構成を考えることにしました。

Googleスプレットシート + Cloudflare Workers + Cloudflare Workers KV + Next.js

静的なアセット: Workers Sites(Workers + Workers KV)
- ロジック: Workers
+ ロジック: Workers、Next.js API
KVS: Workers KV
データベース: Google spreadsheet

先ほどの構成にNode.jsでAPIサーバ(serverless)を使えるNext.jsを追加してみました。 Google APIを叩くためのOAuthなどのNode.jsを使ってロジックの一部をCloudflare WorkersからNext.jsのAPIに移すという作戦です。

Cloudflare Worker → Proxy → Next.js's API

Cloudflare Worker → Proxy → Next.js's API

しかし、この構成だとCloudflare Workersからローカルで起動しているNext.js's APIを叩くのが難しいです。 先ほど書いたように、Cloudflare Workersはローカルであってもリモートで動いているためです。 localtunnelを通せばなんとかなりますが、開発体験は良くありません。(レイテンシが入るので遅い)

📝 LambdaとかじゃなくてNext.jsのAPIを使っているのは、Next.jsのapi/自体が普通によくできていて便利だったため。 ローカルで変更がすぐ試せる、Vercelを使えばデプロイはpushするだけですむ。 AWS lambdaの場合はCDKを使ったserverlessuiserverless-stackなどが出てきていますが、やっぱりまだひと手間ある感触です。

Googleスプレットシート + Cloudflare Workers KV + Next.js

- 静的なアセット: Workers Sites(Workers + Workers KV)
+ 静的なアセット: Next.js
- ロジック: Workers、Next.js API
+ ロジック: Next.js API
KVS: Workers KV
データベース: Google spreadsheet

最終的なリリース時の構成はこうなりました。

Next.js(Vercel) + Cloudflare Workers KV + Googleスプレットシートなので普通な構成になりました。

Next.js → SpreadSheet + Workers KV

静的なアセット(サイト)

Clouldflare Workersがエントリポイントではないなら、静的なアセットも単純にNext.jsアプリとしてVercelにデプロイすればいいだけになります。 CloudflareとVercelのVercel Edgeを比べるとClouddflareの方が早くできるケースは多いですが、Cloudflare Workersはまだちょっと難しかったのでそこは諦めました。 (VercelもCDNにキャッシュが載ってるなら30ms~100msぐらいでレスポンスを返せます)

UIフレームワークには、Next.jsなのでReactとChakra UIを使っています。 Chakra UIは程よいバランスのCSS in JS(内部的にはemotionを使う)なUIフレームワークです。

xstyledReact Spectrumのようにローレベルでもなく、Fluent UIMATERIAL-UIみたいなハイレベルでもないバランスのフレームワークです。 サイトとリポジトリを検索すれば、やりたいことはだいたい見つかったので悪くはなかったかなと思います。

スタイルをプロパティで指定します(CSS-in-JSなので、それがクラスとスタイルに展開される)。 marginをmとかそういう短縮するのは好きじゃないので、margin propsで指定したりしています。 baseでブレークポイントに対応したスタイルをかけるので、そこまで深く意識しないでもレスポンシブなサイトになりました。

アイコンはReact Iconsというコンポーネントを使っています。 いろいろなアイコンの集合体で、探せばだいたいあるのでとても便利です。(アイコン個別のコンポーネントとしてimportするのでサイズも安心できる)

背景パターンの生成にはBGJarを使っています。 適当なパターンをランダムに作ったりできるので便利です。 似たものだとPattern Generatorがあります。

KVS

最初に書いていたようにCloudflare Workers KVはWeb APIとして呼び出せます。

cloudflare-kv-storage-restというライブラリを使うと、Cloudflare WorkersのKV Storageと同じインタフェースで、KVのAPIを叩いてKVSとして使えます。

philan.netでは、このcloudflare-kv-storage-restを使って本番ではCloudflare Workers KVを使って、ローカルではファイルシステムベースにしたラッパーを用意して使っています。 (KV StorageのAPIはCloudflareのAPI Keyが必要なので、他の人が開発することを考えてローカルではKV Storageに依存しないようにした)

セッション管理はとりあえず今はnext-iron-sessionを使っています。 (Pros, Consあるのでなんかいい方法を見つけたい。Serverless RedisのUpstashを使うなど)

📝 ネタ的な話ですが、このcloudflare-kv-storage-restを使ってexpressのsessionをKV Storageで管理するexpress-session-cloudflare-kvというsession storeの実装を書いてみました。 ただし、Cloudflare Workers KVは結果整合性で、こういったセッション管理には向いていないのでproductionとかでは使えるものではありません。(実際に一度readがあってから反映されてるような挙動が見えた)

こういうリアルタイム性が必要なものはDurable Objectsの方が適しています。(そもそもWorkers内でセッション管理向いてないとは思うけど)

Durable Objectsのユースケースは、最近Cloudflareが買収したLincのブログが良く書かれています。

ロジック

Next.jsのapi/ディレクトリに置いたファイルはLambda的なServerlessなアプリケーションとして動作します。 (実際VercelのバックエンドにはAWS Lambdaがいると思います。)

philan.netのロジックはこのNext.jsのAPIに書いています。

next-connectというライブラリを使うと、express middlewareも動かせるので、 APIの権限管理などはexpress的なmiddlewareとして実装しています。

たとえば、次のコードはSpreadSheetにデータを追加するAPIの実装です。

セッション管理するwithSessionとログインしているかをチェックするrequireLoginをmiddlewareとしてハンドラに置いています。

const handler = nextConnect<NextApiRequestWithUserSession, NextApiResponse>()
    .use(withSession())
    .use(requireLogin())
    .post(async (req, res) => {
        const { isoDate, amount, memo, to, url, currency, meta } = validateAddRequestBody(req.body);
        const user = req.user;
        const date = isoDate ?? new Date().toISOString();
        await addItem(...);
        res.json({
            ok: true
        });
    });

export default handler;

APIのリクエストのバリデーションはcreate-validator-tsを書いて使っています。

create-validator-tsは、TypeScriptの型定義からJSON Schemaベースのバリデーション関数を自動生成するツールです。(バリデーションコードはカスタムできますが、今回はデフォルトでOK)

先程のvalidateAddRequestBodyというバリデーション関数は、このapi-types.tsの型定義から自動的に生成されています。 このリクエスト/レスポンスの型定義は、フロントからも参照できるので、クライアントとサーバのAPIのやり取りがコンパイル時にチェックできます。

api-types.tsに定義したリクエストBodyの型をフロントではリクエストのbodyとなるオブジェクトで参照しています。

export type AddRequestBody = Omit<RecordItem, "date"> & {
    isoDate: string; // iso date
    currency: string;
};

TypeScriptを書いているだけで、APIのバリデーションもRuntime/Compile timeでできるようになるのでおすすめです。

SpreadSheet

philan.netの特徴的なところは、ユーザーのデータはユーザーのGoogleアカウント権限で作成したGoogleスプレットシートに保存されるところです。

これによってphilan.netが消滅しても、今まで記録した寄付のデータはGoogleスプレットシートに残ります。 慈善活動は基本的に長期的なものなので、こういったバックアップ案は最初からあった方がいいと思ってこうなりました。

Next.js APIからGoogle SpreadSheetの更新は公式のGoogle APIs Node.js Clientを使っています。 ただ、SpreadSheetの更新はかなり直感的には書けないので、もっといいラッパーがほしいです。 SpreadSheetの末尾1行追加するだけでも、かなり長いbatchUpdateの処理が必要です。

特定のセルだけを更新するとかそういうのも結構面倒な気がします。

SpreadSheetはユーザーがデータをもつという透明性がいい感じに担保できますが、 これはユーザー側にデータがあるのでマイグレーションが結構面倒になるという性質も持っています。 (ネイティブアプリやブラウザのストレージのマイグレーションと同じような面倒くささは出てくると思います)

Next.js ISR + SpreadSheet

SpreadSheetから取得したデータを元にユーザーページを生成しています。

これは、Next.js + VercelのIncremental Static Regeneration(ISR)を使っているので、基本的に訪問する人には一瞬で表示されます。 (一定時間経ったらバックグランドでSpreadSheetからデータを取り直して再生成をするため、その間はキャッシュを表示できる)

ただ、VercelはデプロイするとISRのキャッシュ(実態はファイルシステムのファイル)が消えるので、デプロイ直後はキャッシュの生成し直しとなるのがイマイチな気がします。 Cloudflare Workers KVのキャッシュをココに挟む必要とかもそのうち出てくるのかもしれません。

Next.jsのISRやCloudflare Workersはこういった別の場所にあるデータを変換しながら表示するようなプロキシ的な処理にはかなり向いている感じがします。キャッシュが効くので、毎回SpreadSheetのAPIを叩く必要もないし、確実に最新の状態が表示する必要がない場合はコスパよく管理できます。 (Cloudflare Workers + Cloudflare Workers KVの場合は、Cacheのpurgeも一瞬なのでより細かくコントロールできます。Next.jsのISRは明示的にCacheを消す正式な方法はまだありません。)

Read OnlyなページはVercelのCacheに載っているので、だいたい100ms以下のレスポンスで返せていると思います。

VercelのAnalytics

Analytics - Vercel Documentation

Lighthouse

Lighthouse によるウェブアプリの監査  |  Tools for Web Developers  |  Google Developers

ログ

VercelにはCloudWatchのようなログ管理の仕組みがありません。(リアルタイムなFunctionのログしか見えない) そのためログの永続化はLogflareを使っています。

そこまで使いやすいとは言えない気もするので、別のものにするかもしれません。

各サービスのLimit

おわりに

philan.netの技術的なスタックについて紹介しました。

まだ作ってから数日しか経ってないですが、まあまあちゃんと動いているようです。

技術的にはNext.js + Vercel + Cloudflare Workers KV + Googleスプレットシートを使っています。 どのサービスにもクレカを登録してない状態な気もするので、維持費はドメイン代金を除けば0円ですむと思います。 (一応各サービスにもLimitはありますが、結構緩めなのでそこまで到達しない気もします)

ドメイン代を支援したい人はGitHub Sponsorsでサポートしてください。

GitHub Sponsorsみたいなサポートもphilan.netに記録して公開するのも良さそうです。 こういった定常的な寄付をつける仕組みがまだないので、今後実装していく予定です。

philan.netは、まだ作ったばかりですが、少なくてもやることはあるので少しずつ作っていきます。

オープンソースなので、Contributorも待っています!(多分ローカルで開発できると思いますが自信はないのでその辺Issue作ってください)

最後にストレッチゴールがメモに書いてあったのでそれを貼り付けて終わりです。

ゴール(仮)

  • 自分の寄付の状況を公開することで、透明性を出す(どっちも)
  • 寄付は特別なことではない状態を作ること

ストレッチゴール

  • 自分の寄付が公開できる(ポートフォリオ)
  • 自分の寄付に関連する情報が得られる??
  • GitHub Sponsorsと連携する/定期的な寄付管理
  • 統計データを表示する
  • 公開した寄付を見た人が寄付をする
  • 寄付したものが自動で蓄積される