只知道文章的服务
和auth的分割方式一样:blog是它自己的Go服务,不知道密码是什么,不知道会话是什么。它接收一个已经验证过的JWT请求(blog自己对比auth发布的密钥检查签名,不需要回调auth的网络请求),然后处理文章、评论、点赞和举报。这篇文章里的所有东西都存在一个叫blog_db的Postgres数据库里,完全独立于账户所在的auth_db。
Slugs,不信任标题来生成
每篇文章都需要一个slug作为URL,我最初的想法就是把标题变成小写,把空格换成连字符,完成了:
// 第一个想法:完全没有碰撞处理
func slugFromTitle(title string) string {
return strings.ReplaceAll(strings.ToLower(title), " ", "-")
}除了标题会碰撞。两篇名字相似的文章都想要同一个slug,第二篇要么直接失败,要么默默地覆盖了什么东西。所以slug生成会检查数据库,不断添加数字直到找到一个空闲的:
func (s *Server) uniqueSlugFromTitle(ctx context.Context, title string) (string, error) {
base := slug.Generate(title)
if base == "" {
base = "post"
}
candidate := base
for n := 2; ; n++ {
exists, err := s.Queries.SlugExists(ctx, candidate)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
candidate = fmt.Sprintf("%s-%d", base, n)
}
}你也可以在创建时直接给它你自己的slug,我有时候会这样做,当生成的slug出来比较丑的时候。
草稿、发布、存档
一篇文章在数据库里有三种状态:draft、published或archived。我没想到的是有多少逻辑都挂在这一列上。你只能存档已发布的东西(存档一篇草稿会产生一篇可以通过自己的URL访问但没有发布日期的文章,这没有意义),回到草稿模式会清除任何待发布的计划表,这样它就不会在我不看的时候偷偷发出去。
调度的部分是我最骄傲的。你可以把文章设置为draft并有一个未来的publish_at时间戳,然后一个后台扫描在时间真正过去时把它翻转到已发布:
// D2扫描声明1:一旦文章的计划时间过去,就把草稿翻转到已发布,
// 用计划时间戳记录published_at(不是now()),清除publish_at这样它就永远不会再触发。
func (q *Queries) PublishScheduledPosts(ctx context.Context) error {
_, err := q.db.Exec(ctx, publishScheduledPosts)
return err
}UPDATE posts SET status = 'published', published_at = publish_at, publish_at = NULL
WHERE status = 'draft' AND publish_at <= now()这个声明每30秒从一个goroutine里运行一次,这个goroutine就持续不断地运行直到进程关闭。它完全是幂等的,一行不再匹配WHERE子句的话就不会被触碰,所以可以随意调用,从任何地方,甚至从测试里。实际上这整个开发日志系列就是通过这个机制发出去的:提前写好并回溯日期,让扫描在它有时间的时候来处理。
评论是一棵假装是表的树
评论需要支持回复,所以一条评论可以指向一条父评论。Postgres对此不需要什么奇特的东西,只是一个可空的自引用:
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
author_id UUID NOT NULL,
author_username TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);不过API本身不返回嵌套树,它返回一个按创建时间排序的扁平列表,每一行都带着自己的parent_comment_id:
comments, err := s.Queries.GetCommentsForPost(ctx, post.ID)前端是把那个扁平列表变成你看到的嵌套线程的。最初感觉有点不对,像我跳过了什么步骤,但实际上这是更灵活的形状:排序、分页和审核都保持了简单查询反对一个扁平表,而且从带父指针的扁平列表建立树这件事,一旦你已经有了数据在手,就完全是一个机械的事情。
删除评论也不删除行。它设置deleted_at,API清除内容,但保持行活着,因为否则删除一条有三个回复的评论要么会级联整个对话,要么留下孤立的指向无物的回复。一个"[已删除]"占位符在线程里保持位置比这两种情况都要少混淆得多。
点赞和举报,故意保持简单
点赞是一个直接的连接表,每个用户每条评论一行,数数是一个group-by。举报是我唯一需要稍微想想滥用的地方:没什么能阻止有人五次报告同一条评论,除了一个作用域只在报告还是开放时的唯一索引:
CREATE UNIQUE INDEX reports_open_unique_idx ON reports (comment_id, reporter_id) WHERE resolved_at IS NULL;一个部分唯一索引。每个人对每条评论一个开放报告,但一旦主持人解决了它,这个人如果它后来真的发生了,可以再报告一次。Postgres用一行做到这件事的感觉还是像一个小魔术。
迁移,或者说变得不那么害怕改表
这些表中的每一个都来自它自己的编号迁移,通过goose运行:
-- +goose Up
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED
);
-- +goose Down
DROP TABLE posts;在这个项目之前,我最接近真实迁移的是Django的manage.py migrate,它为你做了大量的思考。自己为每一个改变手写up和down,让我实际读了我在对模式做什么,而不是相信一个工具在后台安静地想清楚它。那个search_vector列是一个生成列,Postgres在每次一行改变时自动从标题和内容建立它,这就是博客上的全文搜索实际运行的东西。在我需要它之前不知道这个特性存在。
