Remix on Cloudflare WorkersからDurable Objectsを使う

Cloudflare WorkersにデプロイしたRemixアプリケーションからDurable Objectsを扱う方法についてご紹介します。
2022.06.12

はじめに

こんにちは、CX事業本部MAD事業部の森茂です。
RemixをCloudflare WorkersでModule Workerへ移行する記事を紹介させていただきましたが、今回は引き続きCloudflare WorkersにデプロイしたRemixアプリケーションからDurable Objectsを扱う方法について紹介させていただきます。

Durable Objectsについて

定義が非常に難しくひとことで言いまとめることは難しそうですが、Durable Objectsはエッジ上で展開され、強い整合性を持つKey-Value型のオブジェクトストレージとして利用できるクラスインスタンスとも言えるでしょう。それぞれのDurable Objectsは一意のIDを持ち、Workerからはバインディングされた複数のDurable Objectsを利用できます。強い整合性を持つため単にストレージとしてでなく、アトミックな動作が必要なアプリケーションや、チャットやホワイトボードなどWebSocketを利用したリアルタイムコラボレーションツールのストレージとして活用できます。

料金体系(2022年6月現在)

Durable ObjectsはWorkers有料プランでのみ利用可能です。無料枠はかなり大きくリアルタイムでよほど大量の情報をやり取りするアプリケーションでなければかなり安価に収めることができそうです。

Workers有料プランのみ
リクエスト 100万回、超過分は$0.15/100万回
期間 400,000GB-s、超過分は$12.50/100万・GB-s
読み込み 100万回、超過分は$0.20/100万回
書き込み 100万回、超過分は$1.00/100万回
削除 100万回、超過分は$1.00/100万回
ストレージ 1GB、超過分は$0.20/GB・月

1つのDurable ObjectがWorkerから150万回呼び出され、1か月で100万秒動作したとすると、1か月の推定コストは以下のようになります。(Pricingより抜粋。読み書き削除・ストレージの利用回数は除く。)

合計金額: 約$0.08 + Workers最低料金 $5/月 = 約$5.08

  • (150万回 - 無料枠100万回) x $0.15/100万回 = $0.075
  • 1,000,000秒 x 128MB/1GB = 128,000GB-s(Durable Objectsの割当メモリは128MB)
  • 128,000GB-sは無料枠400,000GB-s未満

Pricing · Cloudflare Workers docs

Durable Objectsの詳細については下記ドキュメントも参照ください。

Remix on Cloudflare WorkersからDurable Objectsを利用する

Cloudflare WorkersからDurable Objectsを利用するにはWorkerをModule Worker形式で記載する必要があります。

Remixアプリケーションの準備

Remixの標準テンプレートではService Worker形式となるため、下記記事で用意したModule Worker形式のボイラープレートを利用して構築します。

$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-module-worker-boilerplate

Durable Objectsの設定

Durable Objectsを利用するには少々贅沢ですが、Durable ObjectsをTodoを管理するデータストレージとしてアプリケーションを構築してみます。Durable ObjectsはWorkers KVやR2とは違いWrangler CLIではなくwrangler.tomlへ用意したDurable Objectsを記載する形となります。

今回はWorkerのEnvironmentをdev環境として、またDurable ObjectsはTasksDurableObjectとして追記します。

wrangler.toml

#...

[env.dev.durable_objects]
bindings = [
  {name = "TASKS", class_name = "TasksDurableObject"},
]

[[migrations]]
new_classes = ["TasksDurableObject"]
tag = "v1"

Durable Objectsの利用にはbindingsの設定の他に作成したクラスインスタンスを登録するmigrationsという部分の記載が必要となります。

TasksDurableObjectの作成

wrangler.tomlに登録したDurable ObjectsTasksDurableObjectを作成します。Durable Objectsは下記のようなクラス構文がベースとなります。ただクラスの中は他のWorkerと同様にfetchを利用した構文となっています。

export class DurableObject {
  constructor(state, env) {
    // 初期化処理など
  }

  async fetch(request) {
    // 利用するロジックなど
  }
}

利用できる構文やAPIについては下記ドキュメントも参照ください。

constructorからはStateEnvを受け取り利用できます。StateにはDurable Objectsの状態が、Envからは他のDurable ObjectsやWorkers KVなどバインディングされたサービスや環境変数が利用できます。

今回は下記のような構成でAPIとして動作する仕組みを用意します。

pathname method 動作
/latest GET タスクの一覧を取得(最新100件)
/task POST タスクを登録
/task?id=taskId GET taskIdのタスクを取得
/task?id=taskId DELETE taskIdのタスクを削除

worker/tasks-do.ts

export interface Task {
  id: string;
  title: string;
  timestamp: number;
  isCompleted: boolean;
}

export default class TasksDurableObject {
  // state、envを受け取り利用が可能
  constructor(private state: DurableObjectState) {
    this.state = state;
  }

  async fetch(request: Request) {
    const url = new URL(request.url);

    switch (url.pathname) {
      // GET /latest タスクの一覧を取得
      case '/latest': {
        const data = await this.state.storage.list<Task>({
          reverse: true,
          limit: 100,
        });
        const tasks = [...data.values()];

        return new Response(JSON.stringify(tasks));
      }
      case '/task': {
        // GET /task?id=taskId タスクを取得
        if (request.method === 'GET') {
          const params = new URLSearchParams(url.search);
          const taskId = params.get('id');
          const task = await this.state.storage.get<Task>(taskId!);

          return new Response(JSON.stringify(task));
        }
        // POST /task タスクを登録
        if (request.method === 'POST') {
          const params = await request.json<Pick<Task, 'title'>>();
          const taskData: Task = {
            id: Date.now().toString(),
            title: params.title || 'no title',
            timestamp: Date.now(),
            isCompleted: false,
          };

          await this.state.storage.put(taskData.id, taskData);

          return new Response(JSON.stringify(taskData), { status: 200 });
        }
        // DELETE /task?id=taskId タスクを削除
        if (request.method === 'DELETE') {
          const params = await request.json<Pick<Task, 'id'>>();
          const response = await this.state.storage.delete(params.id);

          return new Response(
            JSON.stringify({ message: response ? 'ok' : 'failed' }),
            { status: 200 },
          );
        }
      }
      default:
        return new Response('Not found', { status: 404 });
    }
  }
}

いくつかDurable ObjectsのAPIはありますが、ほとんどがWeb APIを利用することになり書き方は自由です。Node.jsなどJavaScript/TypeScriptでAPIを作成したことがあればほぼ同様のイメージで作成することができるかと思います。

Durable Objectsが作成できたところでWorkerのエントリーファイルにもtasks-do.tsを追記します。

worker/index.ts

//...
export { default as TasksDurableObject } from './tasks-do';
//...

RemixからDurable Objectsを利用する

作成したTasksDurableObjectをRemix側から利用します。トップページでタスクの新規登録フォームとタスクの一覧を表示し、詳細ページではタスクの情報と削除ができる動きを組み込んでいきます。

url ファイル 用途
http://localhost:8787/ app/routes/index.tsx タスク一覧
http://localhost:8787/task/[$taskId] app/routes/task.$taskId.tsx タスク詳細

少し長いですが2ページのみのためソースコードを全文掲載しています。違和感を感じる部分としてはWorker内ではドメイン、ホスト名がないため指定は不要という点かもしれません。今回は公式ドキュメントにあわせてhttps://.../を利用しています。

app/routes/index.tsx

import { redirect } from '@remix-run/cloudflare';
import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { Form, Link, useLoaderData } from '@remix-run/react';
import type { Task } from 'worker/tasks-do';

type LoaderData = {
  tasks: Task[];
};

export const action: ActionFunction = async ({ context: { env }, request }) => {
  const formData = await request.formData();
  const taskTitle = formData.get('taskTitle') || '';

  if (!taskTitle) {
    throw json({
      error: 'Task title is required',
      status: 400,
    });
  }

  // tasksという一意のIDのDurable Objectsを取得
  const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks'));
  // /taskにPOSTする Worker内ではドメイン、ホスト名がないため指定は不要
  const response = await tasksDo.fetch('https://.../task', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: taskTitle,
    }),
  });

  if (response.status !== 200) {
    throw json({
      error: 'Failed to add task',
      status: response.status,
    });
  }

  return redirect('/');
};

export const loader: LoaderFunction = async ({ context: { env } }) => {
  // tasksという一意のIDのDurable Objectsを取得
  const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks'));
  // /taskにPOSTする Worker内ではドメイン、ホスト名がないため指定は不要
  const response = await tasksDo.fetch('https://.../latest');

  if (response.status !== 200) {
    throw new Error(`Failed to fetch task list: ${response.status}`);
  }

  const tasks = await response.json<Task[]>();

  return json({ tasks });
};

export default function Index() {
  const { tasks } = useLoaderData() as LoaderData;

  return (
    <div>
      <h1>
        <Link to="/">Welcome to Remix</Link>
      </h1>
      <div>
        <Form replace method="post">
          <input type="text" name="taskTitle" placeholder="Task title" />
          <button type="submit">Add task</button>
        </Form>
      </div>
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link to={`/task/${task.id}`}>{task.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

Durable Objectsへのアクセス部分のみ特殊な書き方になりますが、ほかは通常のRemixアプリケーションと同等です。続けて詳細画面を作成します。

app/routes/task.$taskId.tsx

import { redirect } from '@remix-run/cloudflare';
import type { ActionFunction, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import { Form, Link, useLoaderData } from '@remix-run/react';
import type { Task } from 'worker/tasks-do';

type LoaderData = {
  task: Task;
};

export const action: ActionFunction = async ({ context: { env }, params }) => {
  const taskId = params.taskId;
  const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks'));
  const response = await tasksDo.fetch('https://.../task', {
    method: 'DELETE',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      id: taskId,
    }),
  });

  if (response.status !== 200) {
    throw json({
      error: 'Failed to delete task',
      status: response.status,
    });
  }

  return redirect('/');
};

export const loader: LoaderFunction = async ({ context: { env }, params }) => {
  const taskId = params.taskId;
  const tasksDo = env.TASKS.get(env.TASKS.idFromName('tasks'));
  const response = await tasksDo.fetch(`https://.../task?id=${taskId}`);

  if (response.status !== 200) {
    throw new Error(`Failed to fetch task detail: ${response.status}`);
  }

  const task = await response.json<Task>();

  return json({ task });
};

export default function Index() {
  const { task } = useLoaderData() as LoaderData;

  return (
    <div>
      <h1>
        <Link to="/">Welcome to Remix</Link>
      </h1>
      <h2>{task.title}</h2>
      <p>{new Date(task.timestamp).toISOString()}</p>
      <Form method="post">
        <button type="submit">Delete</button>
      </Form>
    </div>
  );
}

動作の確認

トップページと詳細ページができあがったところで動作を確認していきます。Durable Objectsはwrangler devのローカルモードでも確認が可能です。なお、Wranglerのdevサーバーは起動ごとにDurable Objectsのstateはリセットされます。

$ yarn dev
//...
[0]  ⛅️ wrangler 2.0.8
[0] -----------------------------------------------------
[0] Your worker has access to the following bindings:
[0] - Durable Objects:
[0]   - TASKS: TasksDurableObject
[0] - Vars:
[0]   - SESSION_SECRET: "should-be-secure-in-prod"
[0] ⎔ Starting a local server...
//...

開発サーバー起動時にもDurable Objectsが認識されているのが確認できます。

ブラウザでhttp://localhost:8787を開き登録、削除の動きも見てみます。

登録、削除ともに動作が確認できました。今回はtasksという共通のidでDurable Objectsにアクセスしています、この部分をカテゴリーやユーザーのIDごとに発行することで別のDurable Objectsとして利用できるので、カウンターのような誰でも共通した状態を利用したい場合とユーザーごとに状態を分けて利用したい場合など用途にあわせてDurable Objectsを用意するのがよいでしょう。

さいごに

今回はCloudflare Workers上で動作するRemixからDurable Objectsを利用する方法を紹介しました。Durable Objectsの利用用途としては少々贅沢な使い方になっていますが使い方のイメージを少しばかり想定いただけたでしょうか。Durable ObjectsはWorkers有料プランかつModule Worker形式でないと利用できませんが、リアルタイム性を重視したアプリケーション、スケールするアプリケーションでは必須となるサービスのひとつになると思います。ぜひRemixとCloudflare Workersの組み合わせを試してみてください。