跳转到内容
搜索文档

使用 Durable Objects 中的 SQLite 构建座位预订应用

最后更新 查看 MarkdownAgent 设置

在本教程中,您将学习如何使用 Durable Objects 构建座位预订应用。此应用允许用户为航班预订座位。应用将使用 TypeScript 编写,并使用 Durable Object 中新的 SQLite 存储后端 存储数据。

使用 Durable Objects,您可以编写可重用代码来处理多个客户端的协调和状态管理。此外,向 Durable Objects 中的 SQLite 写入数据是同步的并使用本地磁盘,因此所有查询都以出色性能执行。您可以在 SQLite in Durable Objects 博客文章 中了解更多关于 Durable Objects 中 SQLite 存储的信息。

应用功能如下:

  • 用户通过查询参数传递航班号导航到应用。
  • 如果尚不存在,应用将为该航班号创建新的 Durable Object。
  • 如果 Durable Object 已存在,应用将从 SQLite 数据库检索座位信息。
  • 如果 Durable Object 不存在,应用将创建新的 Durable Object 并使用座位信息初始化 SQLite 数据库。在本教程中,座位信息硬编码在应用中。
  • 当用户选择座位时,应用询问其姓名。应用随后将预订座位并将姓名存储在 SQLite 数据库中。
  • 应用还将座位变更广播给所有客户端。

让我们开始吧!

前提条件

  1. 注册 Cloudflare 账户
  2. 安装 Node.js

Node.js version manager

使用 Voltanvm 等 Node 版本管理器,可避免权限问题并切换 Node.js 版本。本指南稍后会介绍的 Wrangler 需要 Node 16.17.0 或更高版本。

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 配置文件中的绑定(binding),将 assets 配置为提供 public 目录。
{
	"assets": {
		"directory": "public"
	}
}
[assets]
directory = "public"
  1. 如果使用以下命令启动开发服务器,前端将在 http://localhost:8787 上提供。不过此时尚无法正常工作,因为后端尚未实现。
npm run dev

3. 为每个航班创建表

应用已在 Wrangler 配置文件中配置了 Durable Objects 类的绑定。如果更新 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中,你将更新 fetch 处理程序以处理 WebSocket 连接。
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. 接下来,更新 Worker 的 fetch 处理程序,为每个航班创建唯一的 Durable Object。
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。若表不存在,则会在初始化时创建。

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 函数来处理传入请求。若请求是发往 /seatsGET,Worker 会从 Durable Object 返回座位;若请求是发往 /book-seatPOST,Worker 会调用 Durable Object 的 bookSeat 方法将座位分配给乘客;若请求是 WebSocket 连接,则由 Durable Object 处理。

6. 测试应用

你可以在本地运行以下命令测试应用:

npm run dev

这将启动运行应用的本地开发服务器。应用在 http://localhost:8787 上提供。

在浏览器中打开 http://localhost:8787。由于未指定航班 ID,应用会显示错误消息。

将 URL 更新为 http://localhost:8787?flightId=1234。应用会显示 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 上找到。

这篇文档对您有帮助吗?