Minimal Cloudflare Worker + Hono + Drizzle ORM (part 2)
Một dự án Cloudflare Worker Serverless tối giản, tương đương với Node.js, Express.js và Sequelize.
Sau khi đã chạy được Worker và thiết lập được MVC tối giản, nhu cầu tiếp theo gần như luôn là đưa dữ liệu ra khỏi file tĩnh.
Nếu đang quen với Express, phản xạ thường là: cài Sequelize, tạo file kết nối database, viết model, thêm migration, rồi để app server giữ connection đó trong suốt vòng đời process.
Nhưng với Hono chạy trên Cloudflare Workers, tư duy này cần đổi một chút.
Bạn vẫn có schema, query method, migration và type, nhưng cách “gắn” database vào app không giống Express. Không có một Node server chạy mãi để bạn giữ connection global theo kiểu quen thuộc. Thay vào đó, database binding được Cloudflare inject vào runtime, rồi Hono đọc nó qua c.env.
Bài viết chính là đoạn chuyển mình đó:
Trong bài này, mình sẽ hướng dẫn cách cài đặt và thiết lập Drizzle ORM cho một Hono Worker rất nhỏ, đồng thời luôn so sánh với cách nghĩ quen thuộc trong Express để bạn dễ map hơn.
Trước hết, cần cài những package nào?
Để cài đặt Drizzle, package.json cần được thêm các package và scripts sau:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:types": "wrangler types",
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"drizzle-orm": "^0.45.2",
"hono": "^4.6.10"
},
"devDependencies": {
"drizzle-kit": "^0.31.10",
"wrangler": "^3.99.0"
}
}
Ý nghĩa của từng phần:
So với Express:
Đây là khác biệt đầu tiên rất quan trọng: trong Express, app “chủ động đi kết nối database”. Trong Workers, runtime “đưa database binding vào app”.
Bước 1: khai báo D1 binding trong Wrangler
Mở rộng wrangler.toml như sau:
[[d1_databases]]
binding = "DB"
database_name = "youhono-db"
database_id = "xxxxxxxxxxxxxx"
preview_database_id = "xxxxxxxxxxxxxx"
Ở đây:
So với Express, phần này tương đương với việc bạn tạo DATABASE_URL hoặc một object config database. Nhưng thay vì:
const db = connect(process.env.DATABASE_URL)
thì trong Hono Worker bạn sẽ làm việc với:
c.env.DB
Điều này khiến code chạy gần với runtime của Cloudflare hơn, nhưng cũng buộc bạn phải nghĩ theo hướng binding thay vì connection string.
Bước 2: generate type cho binding
Sau khi có D1 binding, sẽ có thêm 2 lớp type. Tại sao cần 2 lớp type?
Hãy hiểu theo cách này: Wrangler và Hono là 2 thế giới khác nhau, chúng không tự biết về nhau.
Type do Wrangler generate
worker-configuration.d.ts là file Wrangler tự sinh ra để TypeScript biết rằng môi trường runtime của Cloudflare (c.env) có tồn tại binding tên DB:
Đây là type được sinh ra từ lệnh:
File này nói với TypeScript toàn cục: "Ở tầng Cloudflare Worker, có một thứ tên DB."
Nhưng Hono không đọc file này. Hono là một framework độc lập — nó không quan tâm đến Cloudflare hay Wrangler.
Type app tự định nghĩa
Hono dùng generic type để biết app của bạn có những gì.
src/types/env.ts
Type này nhìn hơi nhỏ, nhưng rất quan trọng. Nó là cầu nối để Hono hiểu Bindings của app.
Hiểu đơn giản như sau:
Khi bạn truyền AppBindings vào đây, Hono mới hiểu rằng: "Trong c.env sẽ có DB kiểu D1Database."
Nếu không có bước này, khi bạn gõ c.env.DB, TypeScript sẽ báo lỗi vì Hono không biết c.env có gì bên trong.
Lưu ý: Type do Wrangler thực tế sẽ tạo ra
Khi chạy npm run db:types không chỉ sinh type cho D1 binding — nó sinh type cho toàn bộ môi trường Cloudflare Worker của bạn.
Nhìn vào file thực tế, phần binding của bạn chỉ có vài dòng đầu:
Phần còn lại — hàng nghìn dòng bên dưới — là runtime types của toàn bộ môi trường Cloudflare Workers. Đây là các khai báo cho mọi thứ như DOMException, WebSocket, ReadableStream, D1Database, R2Bucket, KVNamespace, tất cả các AI model types, v.v.
Wrangler sinh ra file này không phải chỉ để mô tả binding của bạn — mà để thay thế hoàn toàn TypeScript lib mặc định (vốn được thiết kế cho trình duyệt, không phải Workers). Không có file này, TypeScript sẽ không biết D1Database là gì, fetch trong Workers hoạt động ra sao, hay ExecutionContext là cái gì.
Nói cách khác:
Phần bạn quan tâm chỉ là phần đầu. Phần còn lại là “nền tảng” mà Cloudflare cần inject để TypeScript hiểu đúng môi trường Workers thay vì môi trường browser.
Bước 3: tạo schema Drizzle
Phần trung tâm nhất của commit 4 là src/db/schema.ts:
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const sitemapEntries = sqliteTable('sitemap_entries', {
id: text('id').primaryKey(),
url: text('url').notNull().unique(),
lastmod: text('lastmod').notNull(),
})
Schema này khai báo bảng sitemap_entries với 3 cột:
Nếu bạn quen Express + Sequelize, ý tưởng không lạ:
Nhưng có một khác biệt nhẹ:
Thay vào đó, bạn định nghĩa schema riêng, rồi viết query function riêng. Đây là phong cách rất hợp với project nhỏ cần sự rõ ràng.
Ghi chú về phong cách Active Record
Có nghĩa là với Drizzle trong project này, chúng ta không tạo một class kiểu SitemapEntry rồi gắn method trực tiếp lên class đó như:
Đó là kiểu gần với Active Record: model vừa đại diện cho dữ liệu, vừa tự chứa luôn logic truy vấn/ghi dữ liệu.
Còn trong repo này, mình đang làm theo kiểu khác:
Tức là “model” không phải một class biết tự findAll() hay save().
Logic query được tách ra thành các function riêng.
Nếu so với Express:
Bước 4: tạo database client cho D1
Thêm src/db/client.ts:
import { drizzle } from 'drizzle-orm/d1'
import type { AppBindings } from '../types/env'
export const getDb = (dbBinding: AppBindings['DB']) => drizzle(dbBinding)
// câu này tương đương trong JS
export const getDb = (dbBinding) => {
const db = drizzle(dbBinding)
return db
}
Ý nghĩa rất đơn giản:
Bước 5: tạo query method thay cho model kiểu Express
Commit 4 xóa src/models/sitemapModel.ts và thay bằng src/queries/sitemapQuery.ts:
import { asc } from 'drizzle-orm'
import { getDb } from '../db/client'
import { sitemapEntries } from '../db/schema'
import type { AppBindings } from '../types/env'
export type SitemapEntry = {
id: string
url: string
lastmod: string
}
// truyền vào dbBinding, chính là c.env.db
// getDb() nhận dbBinding từ param của getSitemapEntries()
// getDb() xử lý trả lại db là Drizzle instance
export const getSitemapEntries = async (
dbBinding: AppBindings['DB'],
): Promise<SitemapEntry[]> => {
const db = getDb(dbBinding)
return db
.select({
id: sitemapEntries.id,
url: sitemapEntries.url,
lastmod: sitemapEntries.lastmod,
})
.from(sitemapEntries)
.orderBy(asc(sitemapEntries.url))
}
Đây là chỗ nên dừng lại một chút, vì nó thể hiện rõ triết lý khác với Express.
Trong Express, nhiều người quen với:
Ở repo này, tác giả chọn:
Cách này có vài lợi ích:
Nếu so với Express theo phong cách Mongoose hoặc Sequelize, đây là sự khác biệt khá lớn. Bạn không thao tác với “model instance” nữa, mà thao tác với “query function + schema object”.
Đó là lý do tại sao tên folder cũng thay đổi
