(ハンズオン資料なので足りない部分は口頭で捕捉します)

$ npx create cloudflare@latest
  • Hello World
  • Worker + Durable + Assets を選択
  • TypeScript

Workers Assets を知る

(TODO 別の記事に分割したほうがいいかも)

プロキシサーバーをつくる人には興味がないかもしれませんが、Cloudflare Workers は静的アセット配信が組み込まれています。

wrangler.json

  "assets": {
    "binding": "ASSETS",
    "directory": "./public",
    // "run_worker_first": true // あとで解説
  },

静的アセット(この例だと public/*) を優先的に解決します。マッチした場合、Worker は起動しません。これは静的アセットを配信するのに有利です。

/public/index.html にマッチします。

今回のテンプレートだと、次のような HTML が解決されます。

DOCTYPE html>
html lang="en">
  head>
    meta charset="UTF-8" />
    meta name="viewport" content="width=device-width, initial-scale=1.0" />
    title>Hello, World!title>
  head>

  body>
    h1 id="heading">h1>
    script>
      fetch("/message")
        .then((resp) => resp.text())
        .then((text) => {
          const h1 = document.getElementById("heading");
          h1.textContent = text;
        });
    script>
  body>
html>

読み込まれると /message にリクエストを投げます。

run_worker_first: true

"run_worker_first": true を設定すると、静的アセットに同名の解決可能なパスがあったとしても、必ず fetch ハンドラーを経由します。

そのうえで、更に元のアセットにルーティングしたい場合、次のようになります。

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/message")) {
      return new Response(JSON.stringify({ name: "Cloudflare" }), {
        headers: { "Content-Type": "application/json" },
      });
    }
    return env.ASSETS.fetch(request);
  },
};

env.ASSETS. を元のリソースの静的サーバーと見なして、フォールバックするイメージです。

DurableObject

Cloudflare Workers を学ぶなら、Durable Objects について知っておく必要があります。これは(おそらく)他サービスの基盤であり、これで実現できるかどうかが Cloudflare で実現可能か考える際の物差しになります。

  • Durable Objects は強整合で一意な ID を持ちます
  • クラススコープ内の変数が、インメモリのステートとしてシリアライズされます

先に設定した DurableObject のテンプレートなら次の設定が wrangler.json にあるはずです。

  "durable_objects": {
    "bindings": [
      {
        "class_name": "MyDurableObject",
        "name": "MY_DURABLE_OBJECT"
      }
    ]
  },
import { DurableObject } from "cloudflare:workers";

export class MyDurableObject extends DurableObject {
  async sayHello(name: string): Promisestring> {
    return `Hello, ${name}!`;
  }
}

export default {
  async fetch(request, env, ctx): PromiseResponse> {
    const id: DurableObjectId = env.MY_DURABLE_OBJECT.idFromName("foo");
    const stub = env.MY_DURABLE_OBJECT.get(id);
    const greeting = await stub.sayHello("world");
    return new Response(greeting);
  },
} satisfies ExportedHandlerEnv>;

これはリクエストに応じて名前空間の上の foo の Durable Objects インスタンスを解決します。

Durable Objects は RPC になっており、サーバー上では別のインスタンスとして起動しますが、JSON や Request/Response がシリアライズされて疎通します。

Counter の実装

/message にアクセスされるたびに値をインクリメントする Durable Objects を作ってみます。

import { DurableObject } from "cloudflare:workers";

export class MyDurableObject extends DurableObject {
  async increment() {
    let value: number = (await this.ctx.storage.get("value")) || 0;
    const nextValue = value + 1;
    await this.ctx.storage.put("value", nextValue);
    return nextValue;
  }
}

export default {
  async fetch(request, env, ctx): PromiseResponse> {
    const url = new URL(request.url);
    if (url.pathname === "/message") {
      const id: DurableObjectId = env.MY_DURABLE_OBJECT.idFromName("foo");
      const counter = env.MY_DURABLE_OBJECT.get(id);
      const current = await counter.increment();
      return new Response(`Hello World ${current}`, {
        headers: {
          "content-type": "text/html;charset=UTF-8",
        },
      });
    }
    return new Response("error", {
      status: 404,
    });
  },
} satisfies ExportedHandlerEnv>;

今までは foo というキーで Worker を初期化していました。
これをユーザー個別のカウンタにしましょう。リクエストユーザーごとに cookie を読み取る、または Set-Cookie を返して、ユーザーごとにアクセスカウンタを設定します。

import { DurableObject } from "cloudflare:workers";

export class MyDurableObject extends DurableObject {
  async increment() {
    let value: number = (await this.ctx.storage.get("value")) || 0;
    const nextValue = value + 1;
    await this.ctx.storage.put("value", nextValue);
    return nextValue;
  }
}

export default {
  async fetch(request, env, ctx): PromiseResponse> {
    const url = new URL(request.url);
    if (url.pathname === "/message") {
      console.log("message", request.headers.get("cookie"));
      const cookie: Recordstring, string> = Object.fromEntries(
        request.headers
          .get("cookie")
          ?.split(";")
          ?.map((c) => c.split("=")) ?? []
      );
      const isNew = !cookie["uid"];
      const uid = cookie["uid"] ?? Math.random().toString(36).slice(2);
      const id: DurableObjectId = env.MY_DURABLE_OBJECT.idFromName(uid);
      const counter = env.MY_DURABLE_OBJECT.get(id);
      const current = await counter.increment();
      return new Response(`${uid} ${current} ${isNew ? "(New)" : ""}`, {
        headers: {
          "set-cookie": `uid=${uid}; Path=/; HttpOnly`,
        },
      });
    }
    return new Response("error", {
      status: 404,
    });
  },
} satisfies ExportedHandlerEnv>;

これだと 1 ユニークユーザーごとに Durable Objects を生成しています。1 ワーカーあたり、128MB までの状態が割り振られます。

これは無料プランだと 1 位 10 万リクエストまでです。同接が多いウェブサイトだと耐えきれなくなります。

- Free: 100,000 per day
- Standard: 10 million included per month
  +$0.30 per additional million

https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/

例えば 1 日 1000 万アクセスのサイトの場合、$87/Month になります。巨大サイトなら、個人サイトならともかく、企業規模ならまあ別にって感じですね。

ケチるために UUID でシャーディングして同じインスタンスに載せてしまう方法などもあります。

https://github.com/g45t345rt/do-counter-sharding

Durable Objects に WebSocket から繋ぐ

WebSocket コネクションとして直接 Durable Objects にコネクションを張ります。

https://developers.cloudflare.com/workers/examples/websockets/#write-a-websocket-client

  "durable_objects": {
    "bindings": [
      {
        "class_name": "MyDurableObject",
        "name": "MY_DURABLE_OBJECT"
      },
      {
        "class_name": "WebSocketHibernationServer",
        "name": "WEBSOCKET_HIBERNATION_SERVER",
      }
    ]
  },

こんな感じの Worker 実装を追加します。

export class WebSocketHibernationServer extends DurableObject {
  async fetch(request: Request): PromiseResponse> {
    const webSocketPair = new WebSocketPair();
    const [client, server] = Object.values(webSocketPair);
    this.ctx.acceptWebSocket(server);
    return new Response(null, {
      status: 101,
      webSocket: client,
    });
  }
  async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
    ws.send(
      `[Durable Object] message: ${message}, connections: ${
        this.ctx.getWebSockets().length
      }`
    );
  }

  async webSocketClose(
    ws: WebSocket,
    code: number,
    reason: string,
    wasClean: boolean
  ) {
    ws.close(code, "Durable Object is closing WebSocket");
  }
}

export default {
  async fetch(request, env: Env, ctx): PromiseResponse> {
    
    if (request.method === "GET" && request.url.endsWith("/websocket")) {
      const upgradeHeader = request.headers.get("Upgrade");
      if (!upgradeHeader || upgradeHeader !== "websocket") {
        return new Response(null, {
          status: 426,
          statusText: "Durable Object expected Upgrade: websocket",
          headers: {
            "Content-Type": "text/plain",
          },
        });
      }
      let id = env.WEBSOCKET_HIBERNATION_SERVER.idFromName("foo");
      let stub = env.WEBSOCKET_HIBERNATION_SERVER.get(id);
      return stub.fetch(request);
    }
    
  },
};

Web 標準オブジェクトを生で扱ってるので少し見辛いですが、やってることは単純です。

  • GET /websocket で Upgrade ヘッダがない場合、 426 で WebSocket Upgrade リクエストを返す
  • GET /websocket に WebSokect Upgrade ヘッダ付きでがリクエストが来た場合、WebSocketHibernationServer で 101 を返してコネクション確立

fetchWebSocketHibernationServer はインメモリ空間は共有してませんが、 Request/Response は疎通します。そこで WebSocket のソケットインスタンスを引き回しています。

これだけです。今まで WebSocket をやったことない、知らない人にとってはそうなんだ、って感じだと思いますが、今まで自力で WebSocket をやってた人にはこれが感動ポイントです。

今まではユーザーの状態を応じてリクエストを振り分ける Sticky Session 自分で作る必要がありましたが、Durable Objects は強整合なので Durable Objects にルーティングするだけです。

クライアントから繋いでみます。public/index.html にこれだけ追記

script>
  const socket = new WebSocket("/websocket");
  socket.addEventListener("message", (event) => {
    console.log("Message from server ", event.data);
  });
  socket.addEventListener("open", (event) => {
    socket.send("Hello Server!");
  });

  
  setInterval(() => {
    if (socket.readyState === WebSocket.OPEN) {
      socket.send("Ping");
    }
  }, 1000);
script>

これだけで WebSokect 通信ができます。

Sqlite in D1

Durable Objects では sqlite のインメモリインスタンスを持つことができます。

export class MyDurableObject extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.storage.sql.exec(`
      CREATE TABLE IF NOT EXISTS task (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL
      );
		`);
  }
  async addTask(name: string) {
    this.ctx.storage.sql.exec(
      `
			INSERT INTO task (name) VALUES (?);
		`,
      [name]
    );
  }
  async getTasks() {
    const tasks = await this.ctx.storage.sql
      .exec(`SELECT * FROM task;`)
      .toArray();
    return tasks.map((task: any) => ({
      id: task.id,
      name: task.name,
    }));
  }
}

これを単に CRUD として使います。

POST /tasks/newGET /tasks を実装します。


if (request.method === "POST" && request.url.endsWith("/tasks/new")) {
  const id = env.MY_DURABLE_OBJECT.idFromName("foo");
  const stub = env.MY_DURABLE_OBJECT.get(id);
  const form = await request.formData();
  const name = form.get("name");
  if (!name) {
    return new Response("name is required", { status: 400 });
  }
  await stub.addTask(name as string);
  return new Response("", {
    status: 302,
    headers: {
      Location: "https://zenn.dev/",
    },
  });
}

if (request.method === "GET" && request.url.endsWith("/tasks")) {
  const id = env.MY_DURABLE_OBJECT.idFromName("foo");
  const stub = env.MY_DURABLE_OBJECT.get(id);
  const tasks = await stub.getTasks();
  return new Response(JSON.stringify(tasks), {
    headers: {
      "Content-Type": "application/json",
    },
  });
}

public/index.html にこれを叩くフォームと View を実装します。


form id="form" action="/tasks/new" method="POST">
  input type="text" name="name" placeholder="todo..." required />
  button type="submit">Submitbutton>
form>

script type="module">
  const tasks = await fetch("/tasks").then((resp) => resp.json());
  
  const ul = document.createElement("ul");
  document.body.appendChild(ul);
  for (const task of tasks) {
    const li = document.createElement("li");
    li.textContent = task.name;
    ul.appendChild(li);
  }
script>

Cloudflare D1 との違いは、D1 がレプリケーションがあるのに対して、sqlite in durable_objects にはありません。

https://www.cloudflare.com/ja-jp/developer-platform/products/d1/

フラッグシティパートナーズ海外不動産投資セミナー 【DMM FX】入金

Source link