Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

Durable Objects の SQLite で座席予約アプリを作る

最終更新 Markdown で表示Agent セットアップ

このチュートリアルでは、Durable Objects を使って座席予約アプリを作る方法を学びます。このアプリでは、ユーザーが便の座席を予約できます。アプリは TypeScript で書き、データを保存するには Durable Object の新しい SQLite ストレージバックエンド を使います。

Durable Objects を使うと、複数クライアント向けの調整と状態管理を扱う再利用可能なコードを書けます。さらに、Durable Objects の SQLite への書き込みは同期でローカルディスクを使うため、すべてのクエリが高い性能で実行されます。Durable Objects の SQLite ストレージについて詳しくは、SQLite in Durable Objects のブログ記事 を参照してください。

アプリケーションの動作は次のとおりです。

  • ユーザーは、便番号をクエリパラメーターとして渡してアプリケーションを開きます。
  • その便番号の Durable Object がまだなければ、アプリケーションは新しく作成します。
  • Durable Object がすでにあれば、アプリケーションは SQLite データベースから座席情報を取得します。
  • Durable Object がなければ、アプリケーションは新しい Durable Object を作成し、座席情報で SQLite データベースを初期化します。このチュートリアルでは、座席情報はアプリケーション内にハードコードします。
  • ユーザーが座席を選ぶと、アプリケーションは名前を尋ねます。その後、座席を予約し、名前を SQLite データベースに保存します。
  • アプリケーションは、座席の変更をすべてのクライアントへブロードキャストします。

始めましょう。

前提条件

  1. Cloudflare アカウント に登録します。
  2. Node.js をインストールします。

Node.js のバージョンマネージャー

権限の問題を避け、Node.js のバージョンを切り替えるには、Voltanvm などの Node バージョンマネージャーを使います。このガイドの後半で扱う Wrangler には、16.17.0 以降の Node バージョンが必要です。

1. 新しいプロジェクトを作成する

アプリを作成してデプロイするため、新しい Worker プロジェクトを作ります。

  1. 次を実行して、seat-booking という Worker を作成します。

    npm create cloudflare@latest -- seat-booking

    セットアップでは、次のオプションを選びます。

    • What would you like to start with? では、Hello World example を選びます。
    • Which template would you like to use? では、Worker + Durable Objects を選びます。
    • Which language do you want to use? では、TypeScript を選びます。
    • Do you want to use git for version control? では、Yes を選びます。
    • Do you want to deploy your application? では、No を選びます(デプロイ前にいくつか変更します)。
  2. 開発を始めるため、新しいプロジェクトディレクトリへ移動します。

cd seat-booking

2. フロントエンドを作成する

アプリケーションのフロントエンドは、ユーザーが座席を選び、名前を入力できるシンプルな HTML ページです。フロントエンドの配信には Workers Static Assets を使います。

  1. プロジェクトルートに public という新しいディレクトリを作成します。

  2. public ディレクトリに index.html という新しいファイルを作成します。

  3. 次の HTML コードを index.html ファイルに追加します。

public/index.html

public/index.htmlhtml
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Flight Seat Booking</title>
		<style>
			body {
				font-family: Arial, sans-serif;
				display: flex;
				justify-content: center;
				align-items: center;
				height: 100vh;
				margin: 0;
				background-color: #f0f0f0;
			}
			.booking-container {
				background-color: white;
				padding: 20px;
				border-radius: 8px;
				box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
			}
			.seat-grid {
				display: grid;
				grid-template-columns: repeat(7, 1fr);
				gap: 10px;
				margin-top: 20px;
			}
			.aisle {
				grid-column: 4;
			}
			.seat {
				width: 40px;
				height: 40px;
				display: flex;
				justify-content: center;
				align-items: center;
				border: 1px solid #ccc;
				cursor: pointer;
			}
			.seat.available {
				background-color: #5dbf61ba;
				color: white;
			}
			.seat.unavailable {
				background-color: #f4433673;
				color: white;
				cursor: not-allowed;
			}
			.airplane {
				display: flex;
				flex-direction: column;
				align-items: center;
				background-color: #f0f0f0;
				padding: 20px;
				border-radius: 20px;
			}
		</style>
	</head>
	<body>
		<div class="booking-container">
			<h2 id="title"></h2>
			<div class="airplane">
				<div id="seatGrid" class="seat-grid"></div>
			</div>
		</div>

    	<script>
    		const seatGrid = document.getElementById("seatGrid");
    		const title = document.getElementById("title");

    		const flightId = window.location.search.split("=")[1];

        const hostname = window.location.hostname;

    		if (flightId === undefined) {
    			title.textContent = "No Flight ID provided";
    			seatGrid.innerHTML = "<p>Add `flightId` to the query string</p>";
    		} else {
    			handleBooking();
    		}

    		function handleBooking() {
    			let ws;
    			if (hostname === 'localhost') {
            const port = window.location.port;
    				ws = new WebSocket(`ws://${hostname}:${port}/ws?flightId=${flightId}`);
    			} else {
    				ws = new WebSocket(`wss://${hostname}/ws?flightId=${flightId}`);
    			}

    			title.textContent = `Book seat for flight ${flightId}`;

    			ws.onopen = () => {
    				console.log("Connected to WebSocket server");
    			};

    			function createSeatGrid(seats) {
    				seatGrid.innerHTML = "";
    				for (let row = 1; row <= 10; row++) {
    					for (let col = 0; col < 6; col++) {
    						if (col === 3) {
    							const aisle = document.createElement("div");
    							aisle.className = "aisle";
    							seatGrid.appendChild(aisle);
    						}

    						const seatNumber = `${row}${String.fromCharCode(65 + col)}`;
    						const seat = seats.find((s) => s.seatNumber === seatNumber);
    						const seatElement = document.createElement("div");
    						seatElement.className = `seat ${seat && seat.occupant ? "unavailable" : "available"}`;
    						seatElement.textContent = seatNumber;
    						seatElement.onclick = () => bookSeat(seatNumber);
    						seatGrid.appendChild(seatElement);
    					}
    				}
    			}

    			async function fetchSeats() {
    				const response = await fetch(`/seats?flightId=${flightId}`);
    				const seats = await response.json();
    				createSeatGrid(seats);
    			}

    			async function bookSeat(seatNumber) {
    				const name = prompt("Please enter your name:");
    				if (!name) {
    					return; // User canceled the prompt
    				}

    				const response = await fetch(`book-seat?flightId=${flightId}`, {
    					method: "POST",
    					headers: { "Content-Type": "application/json" },
    					body: JSON.stringify({ seatNumber, name }),
    				});
    				const result = await response.text();
    				fetchSeats();
    			}

    			ws.onmessage = (event) => {
    				try {
    					const seats = JSON.parse(event.data);
    					createSeatGrid(seats);
    				} catch (error) {
    					console.error("Error parsing WebSocket message:", error);
    				}
    			};

    			ws.onerror = (error) => {
    				console.error("WebSocket error:", error);
    			};

    			ws.onclose = (event) => {
    				console.log("WebSocket connection closed:", event);
    			};

    			fetchSeats();
    		}
    	</script>
    </body>

</html>
  • フロントエンドは、便の空き座席を取得するため、/seats エンドポイントへ HTTP GET リクエストを送ります。
  • 空き座席の更新を受け取るため、WebSocket 接続も使います。
  • ユーザーが座席をクリックすると、bookSeat() 関数が呼ばれ、名前の入力を求め、その後 /book-seat エンドポイントへ POST リクエストを送ります。
  1. Wrangler 設定ファイル のバインディングを更新し、public ディレクトリを配信するよう assets を設定します。
{
	"assets": {
		"directory": "public"
	}
}
[assets]
directory = "public"
  1. 次のコマンドで開発サーバーを起動すると、フロントエンドは http://localhost:8787 で配信されます。ただし、バックエンドがまだないため動作しません。
npm run dev

3. 便ごとにテーブルを作成する

アプリケーションには、Durable Objects クラス向けのバインディングが Wrangler 設定ファイル にすでに設定されています。src/index.ts で Durable Objects クラスの名前を変えた場合は、Wrangler 設定ファイル のバインディングも更新してください。

  1. Durable Objects の SQLite ストレージを使うよう、バインディングを更新します。Wrangler 設定ファイル で、new_classes=["Flight"]new_sqlite_classes=["Flight"] に、name = "FLIGHT"name = "FLIGHT" に、class_name = "MyDurableObject"class_name = "Flight" に置き換えます。Wrangler 設定ファイル は、次のようになります。
{
	"durable_objects": {
		"bindings": [
			{
				"name": "FLIGHT",
				"class_name": "Flight"
			}
		]
	},
	// Durable Object migrations.
	// Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations
	"migrations": [
		{
			"tag": "v1",
			"new_sqlite_classes": [
				"Flight"
			]
		}
	]
}
[[durable_objects.bindings]]
name = "FLIGHT"
class_name = "Flight"

[[migrations]]
tag = "v1"
new_sqlite_classes = [ "Flight" ]

これで、アプリケーションは Durable Objects の SQLite ストレージを使えます。

  1. Flight クラスに initializeSeats() 関数を追加します。この関数は Durable Object の初期化時に呼ばれます。テーブルがあるかを確認し、なければ作成します。座席情報もテーブルへ挿入します。

このチュートリアルでは、すべての便に同じ座席プランを作成します。本番では、便の種類に応じて座席を挿入するよう、この関数を更新してください。

Flight クラスを次のコードに置き換えます。

src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
	sql = this.ctx.storage.sql;

	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);
		this.initializeSeats();
	}

	private initializeSeats() {
		const cursor = this.sql.exec(`PRAGMA table_list`);

		// Check if a table exists.
		if ([...cursor].find((t) => t.name === "seats")) {
			console.log("Table already exists");
			return;
		}

		this.sql.exec(`
				  CREATE TABLE IF NOT EXISTS seats (
					seatId TEXT PRIMARY KEY,
					occupant TEXT
				  )
				`);

		// For this demo, we populate the table with 60 seats.
		// Since SQLite in DOs is fast, we can do a query per INSERT instead of batching them in a transaction.
		for (let row = 1; row <= 10; row++) {
			for (let col = 0; col < 6; col++) {
				const seatNumber = `${row}${String.fromCharCode(65 + col)}`;
				this.sql.exec(`INSERT INTO seats VALUES (?, null)`, seatNumber);
			}
		}
	}
}
  1. Flight クラスに fetch ハンドラーを追加します。このハンドラーはテキストレスポンスを返します。手順 5 で、WebSocket 接続を扱うよう fetch ハンドラーを更新します。
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
  ...
  async fetch(request: Request): Promise<Response> {
    return new Response("Hello from Durable Object!", { status: 200 });
  }
}
  1. 次に、便ごとに一意の Durable Object を作成するよう、Worker の fetch ハンドラーを更新します。
src/index.tsts
export default {
	async fetch(request, env, ctx): Promise<Response> {
		// Get flight id from the query parameter
		const url = new URL(request.url);
		const flightId = url.searchParams.get("flightId");

		if (!flightId) {
			return new Response(
				"Flight ID not found. Provide flightId in the query parameter",
				{ status: 404 },
			);
		}

		const stub = env.FLIGHT.getByName(flightId);
		return stub.fetch(request);
	},
} satisfies ExportedHandler<Env>;

クエリパラメーターの便 ID を使い、一意の Durable Object が作成されます。この Durable Object は、テーブルがなければ初期化します。

4. Durable Object にメソッドを追加する

  1. Flight クラスに getSeats() 関数を追加します。この関数は、テーブル内のすべての座席を返します。
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
    ...

	private initializeSeats() {
		...
	}

	// Get all seats.
	getSeats() {
		let results = [];

		// Query returns a cursor.
		let cursor = this.sql.exec(`SELECT seatId, occupant FROM seats`);

		// Cursors are iterable.
		for (let row of cursor) {
			// Each row is an object with a property for each column.
			results.push({ seatNumber: row.seatId, occupant: row.occupant });
		}

		return results;
	}
}
  1. Flight クラスに assignSeat() 関数を追加します。この関数は乗客に座席を割り当てます。座席番号と乗客名をパラメーターとして受け取ります。
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
	...

	private initializeSeats() {
		...
	}

	// Get all seats.
	getSeats() {
		...
	}

	// Assign a seat to a passenger.
	assignSeat(seatId: string, occupant: string) {
		// Check that seat isn't occupied.
		let cursor = this.sql.exec(
			`SELECT occupant FROM seats WHERE seatId = ?`,
			seatId,
		);
		let result = cursor.toArray()[0]; // Get the first result from the cursor.

		if (!result) {
			return {message: 'Seat not available',  status: 400 };
		}
		if (result.occupant !== null) {
			return {message: 'Seat not available',  status: 400 };
		}

		// If the occupant is already in a different seat, remove them.
		this.sql.exec(
			`UPDATE seats SET occupant = null WHERE occupant = ?`,
			occupant,
		);

		// Assign the seat. Note: We don't have to worry that a concurrent request may
		// have grabbed the seat between the two queries, because the code is synchronous
		// (no `await`s) and the database is private to this Durable Object. Nothing else
		// could have changed since we checked that the seat was available earlier!
		this.sql.exec(
			`UPDATE seats SET occupant = ? WHERE seatId = ?`,
			occupant,
			seatId,
		);

		// Broadcast the updated seats.
		this.broadcastSeats();
		return {message: `Seat ${seatId} booked successfully`, status: 200 };
	}
}

上記の関数は broadcastSeats() 関数を使い、更新した座席を接続中のすべてのクライアントへブロードキャストします。次のセクションで broadcastSeats() 関数を追加します。

5. WebSocket 接続を処理する

すべてのクライアントは WebSocket で Durable Object に接続します。Durable Object は、更新した座席を接続中のすべてのクライアントへブロードキャストします。これにより、クライアントは UI をリアルタイムで更新できます。

  1. Flight クラスに handleWebSocket() 関数を追加します。この関数は WebSocket 接続を処理します。
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
	...

	private initializeSeats() {
		...
	}

	// Get all seats.
	getSeats() {
		...
	}

	// Assign a seat to a passenger.
	assignSeat(seatId: string, occupant: string) {
		...
	}

  private handleWebSocket(request: Request) {
		console.log('WebSocket connection requested');
		const [client, server] = Object.values(new WebSocketPair());

		this.ctx.acceptWebSocket(server);
		console.log('WebSocket connection established');

		return new Response(null, { status: 101, webSocket: client });
	}
}
  1. Flight クラスに broadcastSeats() 関数を追加します。この関数は、更新した座席を接続中のすべてのクライアントへブロードキャストします。
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
	...

	private initializeSeats() {
		...
	}

	// Get all seats.
	getSeats() {
		...
	}

	// Assign a seat to a passenger.
	assignSeat(seatId: string, occupant: string) {
		...
	}

  private handleWebSocket(request: Request) {
		...
	}

  private broadcastSeats() {
		this.ctx.getWebSockets().forEach((ws) => ws.send(this.getSeats()));
	}
}
  1. 次に、Flight クラスの fetch ハンドラーを更新します。このハンドラーは Worker からの着信リクエストをすべて処理し、handleWebSocket() メソッドで WebSocket 接続を扱います。
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
	...

	private initializeSeats() {
		...
	}

	// Get all seats.
	getSeats() {
		...
	}

	// Assign a seat to a passenger.
	assignSeat(seatId: string, occupant: string) {
		...
	}

  private handleWebSocket(request: Request) {
		...
	}

  private broadcastSeats() {
		...
	}

  async fetch(request: Request) {
		return this.handleWebSocket(request);
	}
}
  1. 最後に、Worker の fetch ハンドラーを更新します。
src/index.tsts
export default {
	...

	async fetch(request, env, ctx): Promise<Response> {
		// Get flight id from the query parameter
		...

		if (request.method === "GET" && url.pathname === "/seats") {
			return new Response(JSON.stringify(await stub.getSeats()), {
				headers: { 'Content-Type': 'application/json' },
			});
		} else if (request.method === "POST" && url.pathname === "/book-seat") {
			const { seatNumber, name } = (await request.json()) as {
				seatNumber: string;
				name: string;
			};
			const result = await stub.assignSeat(seatNumber, name);
			return new Response(JSON.stringify(result));
		} else if (request.headers.get("Upgrade") === "websocket") {
			return stub.fetch(request);
		}

		return new Response("Not found", { status: 404 });
	},
} satisfies ExportedHandler<Env>;

Worker の fetch ハンドラーは、着信リクエストに応じた Durable Object の関数を呼び出します。/seats への GET リクエストなら、Worker は Durable Object から座席を返します。/book-seat への POST リクエストなら、Worker は Durable Object の bookSeat メソッドを呼び出して乗客に座席を割り当てます。WebSocket 接続なら、Durable Object が WebSocket 接続を処理します。

6. アプリケーションをテストする

次のコマンドを実行して、アプリケーションをローカルでテストできます。

npm run dev

これで、アプリケーションを動かすローカル開発サーバーが起動します。アプリケーションは http://localhost:8787 で配信されます。

ブラウザーで http://localhost:8787 を開きます。便 ID が指定されていないため、アプリケーションはエラーメッセージを表示します。

URL を http://localhost:8787?flightId=1234 のように便 ID 付きに更新します。アプリケーションは、ID 1234 の便の座席を表示します。

7. アプリケーションをデプロイする

アプリケーションをデプロイするには、次のコマンドを実行します。

npm run deploy
 ⛅️ wrangler 3.78.8
-------------------

🌀 Building list of assets...
🌀 Starting asset upload...
🌀 Found 1 new or modified file to upload. Proceeding with upload...
+ /index.html
Uploaded 1 of 1 assets
 Success! Uploaded 1 file (1.93 sec)

Total Upload: 3.45 KiB / gzip: 1.39 KiB
Your worker has access to the following bindings:
- Durable Objects:
  - FLIGHT: Flight
Uploaded seat-book (12.12 sec)
Deployed seat-book triggers (5.54 sec)
  [DEPLOYED_APP_LINK]
Current Version ID: [BINDING_ID]

[DEPLOYED_APP_LINK] を開いてアプリケーションを確認します。ここでも、便 ID をクエリ文字列パラメーターとして渡してください。

まとめ

このチュートリアルでは、次を行いました。

  • Durable Objects の SQLite ストレージバックエンドを使い、便の座席を保存しました。
  • 座席予約を管理する Durable Object クラスを作成しました。
  • アプリケーションを Cloudflare Workers へデプロイしました。

このチュートリアルの完全なコードは GitHub にあります。

役に立ちましたか?