en tjeneste som bare vet om innlegg
Samme oppdeling som auth: blog er sin egen Go-tjeneste, vet ikke hva et passord er, vet ikke hva en sesjon er. Den får en forespørsel med et allerede verifisert JWT hengende ved (blog sjekker selv signaturen mot auths publiserte nøkler, ingen nettverkskall tilbake til auth nødvendig) og den håndterer innlegg, kommentarer, likes, og rapporter. Alt i dette innlegget bor i en Postgres-database kalt blog_db, helt separat fra auth_db kontoene bor i.
slugs, og å ikke stole på at tittelen lager en
Hvert innlegg trenger en slug for URL-en, og min første idé var bare å gjøre tittelen liten, bytte mellomrom med bindestreker, ferdig:
// first idea: no collision handling at all
func slugFromTitle(title string) string {
return strings.ReplaceAll(strings.ToLower(title), " ", "-")
}Bortsett fra at titler kolliderer. To innlegg med en tilstrekkelig lignende tittel ville begge ønske samme slug, og den andre ville enten feile helt eller stille overskrive noe. Så slug-genereringen sjekker databasen og legger til et tall til den finner en som er ledig:
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)
}
}Du kan også bare gi den din egen slug ved oppretting hvis du vil, noe jeg bruker av og til når den genererte kommer ut styggere enn jeg liker.
utkast, publisert, arkivert
Et innlegg er én av tre tilstander i databasen: draft, published, eller archived. Det jeg ikke forventet på forhånd er hvor mye logikk som henger på den ene kolonnen. Du kan bare arkivere noe som faktisk var publisert (å arkivere et utkast ville produsert et innlegg som er nåbart på sin egen URL uten noen publiseringsdato i det hele tatt, som gir ingen mening), og å gå tilbake til utkast-modus rydder eventuell ventende publiseringsplan slik at den ikke kan snike seg ut senere når jeg ikke så etter.
Planleggingsdelen er biten jeg er mest stolt av. Du kan sette et innlegg til draft med et publish_at-tidsstempel i fremtiden, og en bakgrunnsfeiing flipper det til publisert så snart den tiden faktisk passerer:
// D2 sweep statement 1: flips a draft to published once its schedule has
// passed, stamping published_at with the scheduled time (not now()) and
// clearing publish_at so it never fires again.
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()Den setningen kjører hvert 30. sekund fra en goroutine som bare tikker til prosessen skrus av. Den er helt idempotent, en rad som ikke lenger matcher WHERE-klausulen blir rett og slett ikke rørt, så den er trygg å kalle så ofte du vil, fra hvor som helst, til og med en test. Hele denne devlog-serien gikk faktisk ut gjennom nøyaktig denne mekanismen: skrevet og tilbakedatert på forhånd, overlatt til feiingen å plukke opp når den fikk tid til det.
kommentarer er et tre som later som det er en tabell
Kommentarer måtte støtte svar, så en kommentar kan peke på en foreldrekommentar. Postgres trenger ikke noe eksotisk for det, bare en nullable selvreferanse:
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
);Selve API-et returnerer likevel ikke et nøstet tre, det returnerer en flat liste ordnet etter opprettelsestidspunkt, hver rad med sin egen parent_comment_id:
comments, err := s.Queries.GetCommentsForPost(ctx, post.ID)Frontend er den som gjør om den flate listen til den nøstede tråden du faktisk ser. Føltes litt feil i starten, som om jeg hoppet over et steg et sted, men det er faktisk den mer fleksible formen: sortering, paginering, og moderering forblir alle enkle spørringer mot én flat tabell, og å bygge et tre av en flat liste med foreldrepekere er en helt mekanisk ting å gjøre når du allerede har dataen i hånden.
Å slette en kommentar fjerner heller ikke raden. Den setter deleted_at og API-et blanker innholdet, men holder raden i live, fordi ellers ville sletting av en kommentar med tre svar under den enten kaskadert bort hele samtalen eller latt foreldreløse svar peke på ingenting. En "[slettet]"-plassholder som holder plassen sin i tråden er en god del mindre forvirrende enn noen av de to.
likes og rapporter, holdt bevisst enkelt
Likes er en grei join-tabell, én rad per bruker per kommentar, telling er en group-by. Rapporter var det ene stedet jeg måtte tenke litt på misbruk: ingenting stopper noen fra å rapportere samme kommentar fem ganger, unntatt en unik indeks som bare gjelder mens en rapport fortsatt er åpen:
CREATE UNIQUE INDEX reports_open_unique_idx ON reports (comment_id, reporter_id) WHERE resolved_at IS NULL;En delvis unik indeks. Én åpen rapport per person per kommentar, men så snart en moderator løser den, kan samme person rapportere den igjen hvis det genuint skjer igjen senere. Postgres som gjør det på én linje føles fortsatt som et lite magisk triks for meg.
migrasjoner, eller å bli mindre redd for å endre en tabell
Hver eneste av disse tabellene kom ut av sin egen nummererte migrasjon, kjørt gjennom 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;Før dette prosjektet var det nærmeste jeg hadde kommet en ekte migrasjon Djangos manage.py migrate, som gjør en enorm mengde av tenkingen for deg. Å skrive up og down selv, for hånd, for hver eneste endring, fikk meg til faktisk å lese hva jeg gjorde med skjemaet i stedet for å stole på at et verktøy finner det ut stille i bakgrunnen. Den search_vector-kolonnen er en generert kolonne, Postgres bygger den automatisk fra tittel og innhold hver gang en rad endres, og det er det full-tekst-søket på bloggen faktisk kjører mot. Visste ikke at den funksjonen fantes før jeg trengte den.
