实战:Fresh 2 + PostgreSQL + Drizzle 博客
按里程碑实现文章列表、详情、受保护创建,再加 Markdown、草稿、图片与搜索
本教程把 项目蓝图 1 展开为可执行步骤。基础路由、Island 与中间件语法见 Fresh 2 全栈开发,本文只写博客特有的部分。
目标与最终形态
fresh-blog/
├── routes/posts/[slug].tsx # 文章详情(服务端渲染)
├── routes/index.tsx # 文章列表
├── routes/admin/posts.tsx # 受保护的创建页 + POST
├── routes/admin/_middleware.ts # admin 区域访问控制
├── routes/admin/login.tsx # 登录表单(写入 cookie)
├── islands/PostEditor.tsx # 唯一需要 hydration 的组件
├── components/
├── src/db/{client,schema}.ts # Drizzle 连接与表结构
├── drizzle.config.ts
└── deno.json
阶段一交付:列表、详情、受保护的创建。阶段二再加 Markdown 渲染、草稿、图片与搜索。创建表单用原生 <form method="post"> 提交,无 JavaScript 时全站可读可写。
初始化
deno run -Ar jsr:@fresh/init
cd fresh-blog
deno install npm:drizzle-orm npm:drizzle-kit npm:pg npm:@types/pg
本地 PostgreSQL 用 Docker 起一个即可,连接串写入 .env:
docker run --name blog-pg -e POSTGRES_PASSWORD=dev-only -p 5432:5432 -d postgres
DATABASE_URL=postgresql://postgres:dev-only@localhost:5432/postgres
里程碑一:列表、详情、受保护创建
表结构与迁移
import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const posts = pgTable("posts", {
id: serial().primaryKey(),
slug: text().notNull().unique(),
title: text().notNull(),
body: text().notNull(),
published: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(),
});
slug 上的 unique() 是验收点之一:重复 slug 必须在数据库层被拒绝,而不是靠应用层查重。
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: Deno.env.get("DATABASE_URL") });
export const db = drizzle(pool);
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "postgresql",
dbCredentials: { url: Deno.env.get("DATABASE_URL")! },
});
在 deno.json 的 tasks 中追加(模板自带的 dev、check 保持不变):
{
"tasks": {
"db:generate": "deno run -A --node-modules-dir npm:drizzle-kit generate",
"db:migrate": "deno run -A --env --node-modules-dir npm:drizzle-kit migrate"
}
}
-A 只授给本地受信的迁移工具(drizzle-kit 需要读配置、连数据库、写 SQL 文件),不是应用运行时权限;线上进程的权限单独按最小集合配置。
--env 让 Deno 加载 .env。先 deno task db:generate 产出 drizzle/ 下的 SQL,审查后再 deno task db:migrate 应用。
数据访问函数
把查询写成普通函数,路由和测试共用:
import { desc, eq } from "drizzle-orm";
import { db } from "./client.ts";
import { posts } from "./schema.ts";
export function listPublishedPosts() {
return db.select().from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt));
}
export function getPostBySlug(slug: string) {
return db.select().from(posts).where(eq(posts.slug, slug)).limit(1);
}
export async function createPost(input: { slug: string; title: string; body: string }) {
// 阶段一提交即发布;草稿流程在里程碑二引入
await db.insert(posts).values({ ...input, published: true });
}
路由
import { define } from "@/utils.ts";
import { HttpError } from "fresh";
import { getPostBySlug } from "@/src/db/posts.ts";
export default define.page(async (ctx) => {
const [post] = await getPostBySlug(ctx.params.slug);
if (!post || !post.published) {
// 交给 Fresh 的错误处理,响应状态是真正的 404,而不是 200 的“不存在”页面
throw new HttpError(404);
}
return (
<main>
<h1>{post.title}</h1>
<article>{post.body}</article>
</main>
);
});
列表页 routes/index.tsx 同样用 define.page 调 listPublishedPosts() 渲染 <ul>。两者都是服务端组件,浏览器不收到对应 JavaScript。
创建页用原生表单加 POST handler,提交成功后 303 跳转:
import { define } from "@/utils.ts";
import { createPost } from "@/src/db/posts.ts";
export const handlers = define.handlers({
async POST(ctx) {
const form = await ctx.req.formData();
const slug = form.get("slug")?.toString() ?? "";
const title = form.get("title")?.toString() ?? "";
const body = form.get("body")?.toString() ?? "";
if (!/^[a-z0-9-]+$/.test(slug) || !title || !body) {
return new Response("invalid input", { status: 400 });
}
try {
await createPost({ slug, title, body });
} catch (err) {
// 只把唯一约束冲突(SQLSTATE 23505)映射为 409,连接失败等按 500 处理
if (err && typeof err === "object" && "code" in err && err.code === "23505") {
return new Response("slug already exists", { status: 409 });
}
console.error("createPost failed", err);
return new Response("internal error", { status: 500 });
}
return new Response(null, {
status: 303,
headers: { location: `/posts/${slug}` },
});
},
});
export default define.page<typeof handlers>(function NewPost() {
return (
<main>
<h1>新文章</h1>
<form method="post">
<input name="slug" required pattern="[a-z0-9-]+" />
<input name="title" required />
<textarea name="body" required />
<button type="submit">发布</button>
</form>
</main>
);
});
admin 区域保护
子目录的 _middleware.ts 只作用于该子目录的路由:
浏览器表单无法附加 Authorization 头,所以中间件同时接受 cookie(浏览器登录后)和 Bearer(API/curl):
import { define } from "@/utils.ts";
import { getCookies } from "jsr:@std/http/cookie";
const token = Deno.env.get("ADMIN_TOKEN");
export default define.middleware((ctx) => {
if (new URL(ctx.req.url).pathname === "/admin/login") return ctx.next();
const bearer = !!token && ctx.req.headers.get("authorization") === `Bearer ${token}`;
const cookie = !!token && getCookies(ctx.req.headers)["admin_token"] === token;
if (!bearer && !cookie) {
return new Response("Unauthorized", { status: 401 });
}
return ctx.next();
});
登录页校验口令并写入 HttpOnly; SameSite=Lax cookie——SameSite=Lax 下跨站 POST 不携带 cookie,是这个占位方案的 CSRF 底线:
import { define } from "@/utils.ts";
export const handlers = define.handlers({
async POST(ctx) {
const form = await ctx.req.formData();
const token = Deno.env.get("ADMIN_TOKEN");
if (!token || form.get("token") !== token) {
return new Response("Unauthorized", { status: 401 });
}
return new Response(null, {
status: 303,
headers: {
location: "/admin/posts",
"set-cookie": `admin_token=${token}; Path=/admin; HttpOnly; SameSite=Lax`,
},
});
},
});
export default define.page<typeof handlers>(function Login() {
return (
<main>
<h1>管理员登录</h1>
<form method="post">
<input name="token" type="password" required />
<button type="submit">登录</button>
</form>
</main>
);
});
里程碑二:Markdown、草稿、图片、搜索
- Markdown:渲染逻辑放在服务端组件里做(官方 Examples 有 Rendering Markdown 示例),不要为渲染建 island。
- 草稿:
published字段已在 schema 中;列表和详情用where(eq(posts.published, true))过滤,admin 增加发布/下线操作。 - 图片:小站先把静态图放
static/;用户上传走单独的受保护 endpoint,校验 MIME 与大小(参考 图片处理 API 教程 的校验思路)。 - 搜索:
routes/search.tsx读取ctx.url查询参数,用 Drizzle 的ilike做标题/正文匹配即可起步;量大后再换 PostgreSQL 全文索引。 - 编辑器:只有
islands/PostEditor.tsx(预览、快捷键等)需要 hydration。文章正文、导航、列表全部保持服务端组件。
测试策略
- 数据访问层:对独立的测试数据库跑
deno test,覆盖 slug 唯一约束冲突、草稿过滤。 - handler:Fresh 官方测试方式是把路由 handler 挂到内存
App上,用 WebRequest驱动:
import { App } from "fresh";
import { handlers } from "./posts.tsx";
Deno.test("POST rejects invalid slug", async () => {
const handler = new App().post("/admin/posts", handlers.POST).handler();
const form = new FormData();
form.set("slug", "Bad Slug!");
form.set("title", "t");
form.set("body", "b");
const res = await handler(
new Request("http://localhost/admin/posts", { method: "POST", body: form }),
);
if (res.status !== 400) throw new Error(`expected 400, got ${res.status}`);
});
- 无 JS 验收:禁用浏览器 JavaScript(或直接
curl)走一遍列表 → 详情 → 创建流程。
部署
Fresh 可部署到新 Deno Deploy:关联 PostgreSQL 后,平台会注入 DATABASE_URL、PGHOST 等变量,迁移命令可配置为 pre-deploy command,在 revision 接流量前运行——这正是"迁移是独立发布步骤"的落点。细节见 数据库、Cron 与 Timelines。部署前运行 deno task check 与全部测试。
验收清单
- 禁用 JavaScript 后文章列表与详情可读、创建表单可提交
- 重复 slug 被数据库唯一约束拒绝并返回 409
- 除 PostEditor island 外没有组件向浏览器发送 JavaScript
- drizzle-kit generate 的 SQL 经过审查,migrate 在独立发布步骤运行
- admin 路由未授权访问返回 401
- deno fmt --check、deno lint、deno task check、deno test 全部通过
官方参考:Fresh Getting started、Fresh Forms、Fresh Testing、Deno + Drizzle 教程、Drizzle PostgreSQL。