Cloudflare Workers 経由でオブジェクトストレージ代わりにGoogle ドライブを使う
年の瀬ですね。Cloudflare Workers からオブジェクトストレージを利用する際は Cloudflare R2 を利用することが一般的かと思いますが、API 経由で Google ドライブのファイルを読み出す形にしても面白いかと思ったので実装してみました。
なお、Google ドライブを使うメリットとしては、以下の要素が考えられます。
-
安価
料金体系は R2 が $0.015/GB であるのに対し、Google One は ¥3,800/200GB に設定されています。GB 単位だと 1.71 円程度になるため、レートにもよりますが 200 GB 以上であればドライブの方が単価は安いことになります[1]。 -
GUI 経由にてアップロード可能
使い慣れた UI でアップロードでき、安定しているので便利です。自前でアップロードの UI を用意するのは結構面倒なものです。
実装
Google Drive API を利用します。無料で利用可能であり、レートリミットも 12,000回/分と十分な値に設定されています。
一方、アクセスの度に API を呼び出していては遅延が生じて体験が悪いため、Cache API を用いて CDN にキャッシュしてもらうことにします。
REST API によるファイル読み出し
フレームワークに Hono を、JWT(JSON Web Token)の生成に @tsndr/cloudflare-worker-jwt を使用します。
yarn add hono @tsndr/cloudflare-worker-jwt
手始めに、JWT を生成してアクセストークンを取得します。以下のソースコードにこの処理を示します。今回はサービスアカウントの使用[2]を想定しているため、GCP のコンソール →「APIとサービス」→「認証情報」からサービスアカウントを追加した後、「鍵」から JSON 形式の秘密鍵を取得します。これを、スクリプトと同階層に配置します(今回は key.json
とします)。
Google 謹製のパッケージである googleapis を利用できれば手っ取り早いのですが、Workers は Service Workers であっても Node.js ではないため、REST API を経由してアクセスを行います。取得したアクセストークンは 1 時間有効であるため、Workers KV に保管します。
import { sign as jwtSign } from "@tsndr/cloudflare-worker-jwt";
import key from "./key.json";
const scope = "https://www.googleapis.com/auth/drive";
const tokenKey = "google_token";
const ttl = 60 * 60;
interface Token {
iat: number;
access_token: string;
expires_in: number;
token_type: string;
}
const getDriveToken = async (kv: KVNamespace) => {
// KV にアクセストークンが存在する場合はその値を使用
const iat = Math.round(Date.now() / 1000);
const tokenStr = await kv.get(tokenKey);
if (tokenStr) {
const token = JSON.parse(tokenStr) as Token;
if (iat < token.iat + token.expires_in) {
return token;
}
}
// JWT を作成
const signedToken = await jwtSign(
{
iss: key.client_email,
scope,
aud: key.token_uri,
iat,
exp: iat + ttl,
},
key.private_key,
{ header: { typ: "JWT" }, algorithm: "RS256", }
);
// アクセストークンを取得
const formData = new FormData();
formData.set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
formData.set("assertion", signedToken);
formData.set("scope", scope);
const response = await fetch(key.token_uri, {
method: "POST",
body: formData,
});
const result: Record<string, string> = (await response.json());
const token = { ...result, iat } as Token;
// KV に保存
await kv.put(tokenKey, JSON.stringify(token), { expirationTtl: ttl });
return token;
}
};
続いて、アクセストークンを用いて Google ドライブのファイルを取得します。alt=media
パラメータを省略すると JSON 形式のメタデータが、パラメータを付与すると生のデータが得られます。
export const getDriveFile = async (id: string, kv: KVNamespace) => {
const token = await getDriveToken(kv);
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${token.access_token}`,
},
}
);
return response.arrayBuffer();
};
CDN へのキャッシュ
Hono のハンドラの中から上記の getDriveToken
関数を呼びます。その前処理として、Cache API を用いて現在の URL をキーとするキャッシュが存在するかをチェックして、キャッシュが存在する場合はその内容を返します。キャッシュが存在しなかった場合には、waitUntil
メソッド内にてレスポンスをキャッシュに追加します。
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import z from "zod";
import { Bindings } from "../bindings";
import { getDriveFile } from "../lib/drive";
const app = new Hono<{ Bindings: Bindings }>();
const getParamSchema = z.object({ id: z.string() });
app.get("/:id", zValidator("param", getParamSchema), async (c) => {
// キャッシュを確認
const url = new URL(c.req.url).toString();
const cacheKey = new Request(url, c.req);
const cache = caches.default;
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const { id } = c.req.valid("param");
const buffer = await getDriveFile(id, c.env.KV);
const response = c.body(buffer);
c.executionCtx.waitUntil(cache.put(cacheKey, response.clone()));
return c.body(buffer);
});
export default app;
一連の処理により、Workers 経由で Google ドライブ上のファイルにアクセスすることが可能となります。手元の環境で 3.9 MB の PDF ファイルを用いて試してみたところ、初回のリクエストでは 2.56 秒掛かった読み込みが、2 回目以降では 0.22 秒に短縮されていることが確認されました。
cf-cache-status: HIT
Discussion