构建一个评论系统 API

前言

评论系统是一个比较常见的模块,各种博客等网站经常会有让用户阅读后留言交流的需求,比较常见的有老牌的 Disqus,或者基于 GitHub Issue 实现的一些方案,例如 utterancesgitment,它们在使用时需要用户拥有 GitHub 账户,还有更轻量,对隐私友好的 Cusdis。当然他们都有各自的优缺点,如果你有一定的开发能力,更喜欢自己动手,自己掌控所有的数据,并可以进行随心所欲的定制,又懒得去长期维护一台服务器,可以尝试利用 Cloudflare Worker 和 D1 来快速构建一个自己的评论系统。

Cloudflare Worker 是一个 Serverless 环境,让你完全不用操心各种基础设施的问题,只用专注于实现接口逻辑,部署只需要一行命令上传代码,而且免费额度提供了每日十万的请求数量,这对一些小流量的站点来说完全够用了。同样类型的产品还有很多,像 AWS Lambda,Deno deploy 等,但这次我们选择 Cloudflare Worker,因为正好它提供了一个内置的关系型数据库产品叫 D1,目前处于 Open Alpha 阶段,它是基于 SQLite 构建的。SQLite 是世界上使用最普遍的数据库,每天被数十亿台设备使用,而且还是一个不需要服务器的数据库,你现在可以在 Worker 里直接使用它,但和跑在服务器或者某个设备上的一个进程中使用的区别是,你的 Worker 跑在全球数百个边缘计算节点上!

准备环境

使用 Cloudflare Worker 需要懂一些简单的 JavaScript,这里会需要准备好 Node.js 环境,Cloudflare 提供了一个很方便的命令行工具叫 wrangler 可以通过 npm 来安装。

创建一个项目文件夹并且安装依赖:

mkdir my-project && cd my-project
npm init -y

pnpm add wrangler -D

然后利用 wrangler 来帮助我们生成一个 Cloudflare Worker 项目需要的相关文件,如果选择使用 TypeScript 它也会帮你生成相关配置和安装依赖,并且我们让它帮我们在入口 src/index.ts 生成 Fetch handler 代码:

npx wrangler init

其中 wrangler.toml 文件是 wrangler 的一些配置,之后的 D1 的配置也需要写到里面去,而 src/index.ts 是入口文件,它会导出一个 fetch 函数,这个函数就是收到请求的入口函数。

export default {
    async fetch(
        request: Request,
        env: Env,
        ctx: ExecutionContext
    ): Promise<Response> {
        return new Response("Hello World!");
    },
};

之后我们会在这个请求里加入路由的逻辑,让它处理评论系统的一些增删改查操作。

数据库设计

首先我们需要通过 wrangler 登录 Cloudflare,直接使用 npx wrangler login 命令然后在网页中操作就可以了,然后就可以创建一个数据库:

npx wrangler d1 create <DATABASE_NAME>

成功后可以通过命令查看数据库的 id:

npx wrangler d1 list

记录一下它的 uuid 和 name,然后更新到 wrangler.toml 配置里:

[[ d1_databases ]]
binding = "<BINDING_NAME>"
database_name = "<DATABASE_NAME>"
database_id = "<UUID>"

这里的 BINDING_NAME 可以随便取一个,它是用来告诉 Worker 如何访问数据库资源的,你可以看到 src/index.ts 里声明了一个 Env,那么在 Worker 里就可以通过 Env.<BINDING_NAME> 来访问数据库了。这里假设这个 BINDING_NAME 就叫 DB 好了,确定了 BINDING_NAME 以后来更新一下这个 Env 类型,:

binding = "DB"
export interface Env {
  DB: D1Database
}

完成这些准备工作以后,就可以设计一下数据库的表,评论表最简单的实现就是下面这样了,创建一个 schema.sql

DROP TABLE IF EXISTS Comments;
CREATE TABLE Comments(
  id          TEXT NOT NULL PRIMARY KEY,
  createdAt   TEXT NOT NULL,
  content     TEXT NOT NULL,
  nickname    TEXT NOT NULL
);

由于使用的是 SQLite,因此数据类型相比 MySQL 更简单一点,可以在 schema.sql 里再插入一些测试数据:

INSERT INTO Comments(id,createdAt,content,nickname) VALUES ('989c9329-e989-46a7-87ab-dee178aa417a','2022-12-10T12:37:40.106Z','first!','test');

然后在本地跑一下试试:

npx wrangler d1 execute <DATABASE_NAME> --local --file=./schema.sql

记得带上 --local 这个 flag 让它在本地创建一个数据库执行,否则命令会直接在线上的数据库执行,然后检查一下结果:

npx wrangler d1 execute <DATABASE_NAME> --local --command='SELECT * FROM Comments'

顺利的话就能看到结果了。

实现接口

接下来只需要短短几十行代码,就可以在 Worker 里实现一个创建评论和一个获取评论的接口:

fetch 函数里可以根据请求的 path 和 method 来实现路由,如果你希望代码更干净一点,可以使用 itty-router 这样为 Cloudflare Worker 设计的路由库。

export default {
  async fetch(request: Request, env: Env) {
    const { pathname, searchParams } = new URL(request.url);

    if (request.method === 'GET' && pathname === '/api/comments') {
      const page = Number(searchParams.get('page')) || 1;
      const pageSize = Number(searchParams.get('pageSize')) || 10;
      return await getComments(env, page, pageSize);
    } else if (request.method === 'POST' && pathname === '/api/comments') {
      const body = await request.json<PostCommentBody>();
      const { content, nickname } = body;

      // 一些简单的校验
      if (
        !content ||
        !nickname ||
        content.length > 255 ||
        nickname.length > 100
      ) {
        return new Response(null, { status: 400 });
      }

      return await postComment(env, body.content, body.nickname);
    }

    return new Response(null, { status: 404 });;
  },
};

然后接下来实现 getCommentspostComment 这两个函数,这里面可以直接访问数据库来获取数据并返回。

interface Comment {
  id: string;
  content: string;
  createdAt: string;
  nickname: string;
}

const getComments = async (env: Env, page: number, pageSize: number) => {
  const { results } = await env.DB.prepare(
    'SELECT * FROM Comments ORDER BY rowid DESC LIMIT ? OFFSET ?'
  )
    .bind(pageSize, (page - 1) * pageSize)
    .all<Comment>();

  const res = await env.DB.prepare(
    `SELECT COUNT(*) as count FROM Comments`
  ).first<{ count: number }>();

  return Response.json({
    data: results,
    total: res.count,
  });
};

postComment 就是直接往插入数据库插入一条记录:

const postComment = async (env: Env, content: string, nickname: string) => {
  const uuid = crypto.randomUUID();
  const createdAt = new Date().toISOString();

  await env.DB.prepare(
    `INSERT INTO Comments(id,createdAt,content,nickname) VALUES (?,?,?,?)`
  )
    .bind(uuid, createdAt, content, nickname)
    .run();

  return Response.json({ message: 'ok' });
};

基本逻辑实现以后,可以在本地启动一个 Worker 的开发环境:

npx wrangler dev --local --persist

然后用 postman 之类的工具测试一下接口,如果你使用 Jetbrains IDE 的话可以创建一个 test.http 文件然后贴入下面的代码:

###
GET http://127.0.0.1:8787/api/comments?page=1 HTTP/1.1
content-type: application/json

###
POST http://127.0.0.1:8787/api/comments HTTP/1.1
content-type: application/json

{
  "content": "test_content1",
  "nickname": "test"
}

VSCode 可以通过安装 REST Client 插件来获得类似的功能,让你在编辑器里直接发送请求并查看结果。

部署

部署的方式也非常简单,之前我们都是在本地测试的,首先去掉 local 这个 flag 来给线上的数据库创建表:

npx wrangler d1 execute <DATABASE_NAME> --file=./schema.sql

然后发布 Worker:

npx wrangler publish

就完成了,然后可以把测试请求里的 URL 修改为 <YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev 重新测试一下。

总结

Cloudflare Worker 和 D1 的结合使用,不止是给 Worker 提供了一个开箱即用的关系型数据库的存储方案,并且很大程度避免了边缘计算节点和传统的数据库服务跨数据中心通信的高延迟,实际上我觉得对于像评论系统,日常使用的一些机器人等等这样流量不大的各种应用来说,选择使用 Serverless 这样的技术来实现是非常好的一个选择,在开发体验上已经非常好,而且完全摆脱了日常的服务器维护成本。