博客评论系统搭建回顾

博客评论系统搭建回顾

1.绪言

其实在放暑假之前就已经搭好了,但是回学校之后突然发现诶怎么好像又寄了,排查了一下发现是Railway的服务挂了,Project是offline状态,再仔细一看哦原来是试用额度用完了,接下来还想用就得交钱

那我能受这气?于是重新去找免费渠道,鏖战了大半天终于把评论系统给重新拉起来了

说起来这应该是我首次比较接近真正意义上地部署一个全栈Web应用上线,跟着Agent折腾了好久,于是打算把这其中的搭建过程记录下来,故有此篇


2.整体架构总览

其实这个系统的整体流程算是比较简单,无非就是用户浏览器通过访问GitHub Pages或者Vercel页面,然后拉取评论服务应用,前端通过向评论服务应用发送API请求获取或者添加评论,应用再与数据库交互以读写评论
数据库 评论服务应用(后端 API) 静态前端(GitHub Pages / Vercel) 用户浏览器 数据库 评论服务应用(后端 API) 静态前端(GitHub Pages / Vercel) 用户浏览器 #mermaid-svg-viWkibrfT0lyiXrx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-viWkibrfT0lyiXrx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-viWkibrfT0lyiXrx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-viWkibrfT0lyiXrx .error-icon{fill:#552222;}#mermaid-svg-viWkibrfT0lyiXrx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-viWkibrfT0lyiXrx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-viWkibrfT0lyiXrx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-viWkibrfT0lyiXrx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-viWkibrfT0lyiXrx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-viWkibrfT0lyiXrx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-viWkibrfT0lyiXrx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-viWkibrfT0lyiXrx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-viWkibrfT0lyiXrx .marker.cross{stroke:#333333;}#mermaid-svg-viWkibrfT0lyiXrx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-viWkibrfT0lyiXrx p{margin:0;}#mermaid-svg-viWkibrfT0lyiXrx .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-viWkibrfT0lyiXrx text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-viWkibrfT0lyiXrx .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-viWkibrfT0lyiXrx .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-viWkibrfT0lyiXrx .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-viWkibrfT0lyiXrx .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-viWkibrfT0lyiXrx #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-viWkibrfT0lyiXrx .sequenceNumber{fill:white;}#mermaid-svg-viWkibrfT0lyiXrx #sequencenumber{fill:#333;}#mermaid-svg-viWkibrfT0lyiXrx #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-viWkibrfT0lyiXrx .messageText{fill:#333;stroke:none;}#mermaid-svg-viWkibrfT0lyiXrx .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-viWkibrfT0lyiXrx .labelText,#mermaid-svg-viWkibrfT0lyiXrx .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-viWkibrfT0lyiXrx .loopText,#mermaid-svg-viWkibrfT0lyiXrx .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-viWkibrfT0lyiXrx .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-viWkibrfT0lyiXrx .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-viWkibrfT0lyiXrx .noteText,#mermaid-svg-viWkibrfT0lyiXrx .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-viWkibrfT0lyiXrx .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-viWkibrfT0lyiXrx .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-viWkibrfT0lyiXrx .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-viWkibrfT0lyiXrx .actorPopupMenu{position:absolute;}#mermaid-svg-viWkibrfT0lyiXrx .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-viWkibrfT0lyiXrx .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-viWkibrfT0lyiXrx .actor-man circle,#mermaid-svg-viWkibrfT0lyiXrx line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-viWkibrfT0lyiXrx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt读取评论提交评论 访问页面 URL返回 HTML/JS/CSS触发评论操作(加载/提交评论)发送 API 请求(GET/POST /comments)查询评论数据返回评论列表返回 JSON 数据渲染显示评论写入新评论返回操作结果返回成功/失败状态更新界面提示


3.评论服务应用

3.1.应用主体

从上面的图可以看出,评论服务应用的作用是充当前端界面与数据库之间的耦合层,接收前端API请求,然后根据请求对数据库进行操作

app.ts中,通过导出createApp()函数以创建应用:

ts 复制代码
export function createApp(options: CreateAppOptions = {}) {
    const app = new Hono();
    ...
}

注意到这里创建了一个Hono实例作为应用app,什么叫Hono?其实就是一个轻量级的Web框架,非常契合现在搭建的这个评论HTTP API服务

例如通过use()方法给Hono实例注册中间件:

ts 复制代码
app.use("*", securityHeadersMiddleware);
app.use(
    "*",
    createCorsMiddleware({
        allowedOrigins: options.allowedOrigins ?? ["http://localhost:4321"],
    }),
);
app.use(
    "/api/comments/*",
    createRateLimitMiddleware({ windowMs: 60_000, maxRequests: 5 }),
);

在这里就是对所有路径*,都需要通过securityHeadersMiddleware以及createCorsMiddleware两个中间件给所有响应统一加安全相关的HTTP头以及处理跨域问题

以及对于路径/api/comments/*,添加限流中间件

再后面就是get()方法:

ts 复制代码
app.get("/health", (c) => {
    return c.json({ status: "ok" });
});

这里就是定义对于具体的接口,Hono应该做出什么样的响应

那么这个c是什么?它是Hono自有的上下文(Context)对象,代表一次具体的HTTP请求处理中的请求环境

具体到这里来说,这个c代表的就是此次针对/health路径的GET请求的上下文,然后通过c.json()生成JSON响应,设置响应头中Content-Type: application/json

除了json()c中还有

  • c.req:表示当前请求对象,里面有请求方法c.req.method,有请求URLc.req.url,还能获取请求头以及其中的对应项c.req.header("authorization")

  • c.text():返回纯文本响应

  • c.status():设置响应的状态码

  • c.param():获取路由参数,例如:

    ts 复制代码
    app.get("/users/:id", (c) => {
      const id = c.param("id");
    });
  • c.req.query():获取查询参数,比如对于请求/search?q=astro可以写const q = c.req.query("q");

下面是route()方法:

ts 复制代码
app.route(
    "/api/comments",
    createCommentRoute({
        commentService,
        turnstileService: options.turnstileService,
    }),
);

if (options.authService) {
    app.route(
        "/api/admin",
        createAdminRoute({
            authService: options.authService,
            commentService,
        }),
    );
}

其实这里说起来,和FastAPI的include_router()有些类似,都是属于将子模块挂载到主应用程序上

我们来看createCommentRoute()

ts 复制代码
export function createCommentRoute({
    commentService,
    turnstileService
}: CreateCommentRouteOptions) {
    const route = new Hono();
    ...
}

这里创建的新的Hono()实例就类似于一个子应用,我们可以对子应用进行同样的定义操作

ts 复制代码
route.get("/", async (c) => {
    const query = listCommentsQuerySchema.safeParse({
        postSlug: c.req.query("postSlug"),
    });
    ...
});

route.post("/", async (c) => {
    const json = await readJson(c.req.raw);
    const body = createCommentBodySchema.safeParse(json);
    ...
});

最后

ts 复制代码
return route;

route子应用中定义的事件处理函数跟随route被挂载到app上,在这里把app.route中的路径拼起来,就得到了route中定义的事件处理函数的路径即为/api/comments

与FastAPI中的include_router()相同,这种挂载子模块的方式有效降低了代码的耦合度,能够合理拆分模块

最后则是onFound()onError()方法,这两个就没什么好解释的了:

ts 复制代码
app.notFound(() => {
    throw createHttpError(404, "NOT_FOUND", "请求的资源不存在");
});

app.onError((error, c) => {
    if (!(error instanceof Error) || error.name !== "HttpError") {
        console.error("Unhandled request error", {
        name: error instanceof Error ? error.name : "UnknownError",
        message: error instanceof Error ? error.message : String(error),
        });
    }

    const { status, body } = errorToResponse(error);
    return c.json(body, status);
});

3.2.worker

但是createApp()实际上并不是API入口,回想我们需要的是什么,是不是类似于输入请求,拿到响应的一个函数,所以在worker.ts中,我们有定义:

ts 复制代码
function getApp(env: WorkerEnv) {
    const config = loadConfig({
        PORT: env.PORT,
        ...
    });
    ...
    return createApp({
        allowedOrigins: config.allowedOrigins,
        authService,
        commentService,
        turnstileService,
    });
}

export default {
    fetch(request: Request, env: WorkerEnv) {
        return getApp(env).fetch(request);
    },
}

最后导出了一个包含fetch方法的对象,等价于

ts 复制代码
const worker = {
    fetch(request, env) {
        return getApp(env).fetch(request);
    },
};

export default worker;

这也是Cloudflare Workers运行环境要求的入口形式之一,这个对象就是主入口

我们来看Cloudflare打包好的bundle:

最终导出并被Cloudflare调用的入口就是这个worker对象


4.数据库

现在后端有了,需要存储数据,那么就得搞一个数据库

原本是一个Railway同时托管了后端和数据库的,但是现在寄了,所以得另找一个免费的平台

通过Agent推荐,发现Supabase貌似还不赖

那么存储数据的地方找到了,我们该如何让数据库知道我们的表结构呢?


4.1.Drizzle

Drizzle是一个ORM工具,相较于Prisma这些传统的ORM工具,它倾向于拥抱SQL,且极致轻量

重新审视这个评论系统服务,可以很容易看出,它并不是一个需要高频交互读写,对即时性要求强的应用场景

所以我们采用了Serverless架构,即无服务器架构,通过将核心业务代码上传到云端,由云厂商帮我们进行运维

它也有一个旗帜鲜明的特征就是事件驱动,不需要常驻后台维护,只有当用户需要读写评论的时候才运行

前面我们提到的Cloudflare worker,就是Serverless架构的典型代表,或者进一步说,它是Edge Serverless,区别于传统的Serverless基于容器或者微型虚拟机启动,带有冷启动延迟,Cloudflare worker通过隔离沙箱技术,部署在全球多个边缘节点,能够达到启动几乎零冷启动延迟

选择Drizzle的理由也是它对于Serverless架构的高度适配

首先通过Drizzle使用TypeScript描述表结构:

ts 复制代码
export const comments = pgTable(
    "comments",
    {
        id: uuid("id").primaryKey().defaultRandom(),
        postSlug: text("post_slug").notNull(),
        ...
    },
    (table) => [
        index("comments_post_status_created_idx").on(
            table.postSlug,
            table.status,
            table.createdAt.desc(),
        ),
        ...
    ]
)

随后Drizzle通过

bash 复制代码
pnpm --filter comment-api db:generate

生成迁移文件,再通过

bash 复制代码
pnpm --filter comment-api db:migrate

参考本地配置的DATABASE_URL环境变量,也就是数据库的连接串,从而执行迁移,Supabase的Postgres数据库就初始化成功了


4.2.client

现在只需拉起一个数据库的postgres实例,就能为worker所用:

ts 复制代码
export function createDb(config: Pick<AppConfig, "databaseUrl" | "hyperdriveConnectionString">) {
    const connectionString = config.hyperdriveConnectionString ?? config.databaseUrl;

    if (!connectionString) {
        throw new Error("DATABASE_URL or HYPERDRIVE_CONNECTION_STRING is required to create a database client");
    }

    const client = postgres(connectionString, {
        max: 1,
        prepare: false,
        fetch_types: false,
    });

    return drizzle(client, { schema });
}

然后再在worker中导入,worker就能直接调用数据库服务了

这个drizzle()通过传入postgres()创建出的底层数据库连接,以及表结构定义{ schema },返回了一个Drizzle ORM的数据库实例


5.Cloudflare Hyperdrive

通过上文,我们可以很容易看出,worker的工作方式是无状态的、短生命周期的、并发高实例多的

而我们采用的数据库是Postgres,它更倾向于长连接,建连成本较高

如果让worker层直接连接Supabase的Postgres,则容易导致连接频繁创建销毁,打满数据库连接上限,后发请求长时间排队,连接超时等问题

所以为了解决这个问题,我们引入Cloudflare Hyperdrive服务

具体来说,它是Cloudflare推出的数据库加速及连接池托管服务,主要用于解决的就是无服务器计算(就像现在这样)在连接传统关系型数据库像是PostgreSQL以及MySQL时面临的高延迟连接数被打爆两大痛点

具体来说,它有三大核心技术机制:

  • 全球分布连接池 :Hyperdrive在Cloudflare的边缘节点与你的源数据库之间维护常驻的长连接池,从而worker只需就近连接到Cloudflare内部的Hyperdrive节点,通过Hyperdrive帮大量的worker实例复用有限的数十个数据库连接
  • 自动TLS/TCP握手与协议优化 :传统SQL握手需要经历TCP + TLS + Auth,跨国请求消耗大量RTT时间;而Hyperdrive提前建立了与源数据库的加密通道,大幅减少网络往返成本
  • 边缘只读查询缓存 :Hyperdrive内部集成了一套极为保守的SQL级边缘缓存,通过缓存简单可预测的只读查询,在边缘节点缓解数据库压力,也提升查询速度

6.小结

从Cloudflare到Supabase折腾了一圈,也算是积累了一些应用上线部署的经验

其实我本来还想拓展一下JWT相关内容,但是写累了,再者这篇也有点长了,所以下次再说吧

相关推荐
梦醒沉醉18 分钟前
4、函数function
javascript
摇滚侠1 小时前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 22
javascript·spring boot·笔记
xiaominlaopodaren1 小时前
three.js地图视口瓦片(一):屏幕角点射线
javascript·gis·three.js
用户0934077735141 小时前
HarmonyOS WPS Open SDK 实践:registerApp 鉴权与就绪门禁
android·typescript·harmonyos
一水行1 小时前
博客评论系统搭建回顾
javascript·后端
穗余2 小时前
Typescript let和var区别
前端·javascript·typescript
晓得迷路了2 小时前
栗子前端技术周刊第 144 期 - Rspack 2.2、pnpm 12、Solid 2.0 RC...
前端·javascript·css
奈斯先生Vector3 小时前
从“能调用”到“可运营”:AI Agent 进入多模型时代后的架构升级
java·javascript·数据库·人工智能·算法·架构·aigc
苏灿烤鱼3 小时前
AI总改崩你的代码?4.6万星GitNexus架构深拆
typescript·开源·agent