Tema
/
Interneto svetainių serverio dalies kūrimas GO
📖 Teorija

Papildomas-tik, polimorfinis veikėjas ir be išorinio rakto

Kiekvienas šios programos būsenos pokytis — įkėlimas, siuntimas, peržiūra, pasirašymas, užbaigimas, trynimas, atmestas žetonas, klastojimo aptikimas — nusipelno nuolatinio įrašo: kas ką padarė ir kada. Ši pamoka stato tą įrašą kaip papildomą-tik audito pėdsaką, o įdomiausios dalys — apie tai, kad „papildomas-tik" ir „kas" būtų tiesa, ne tik ketinimas.

Papildomas-tik, užtikrintas duomenų bazės — trigeriu. Pėdsakas, kurį gali redaguoti, nėra pėdsakas. Todėl taisyklė gyvena Postgres, ne Go:

CREATE FUNCTION audit_events_append_only() RETURNS trigger AS $$
BEGIN
    RAISE EXCEPTION 'audit_events is append-only: % is not permitted', TG_OP;
END; $$ LANGUAGE plpgsql;

CREATE TRIGGER audit_events_no_update_delete
    BEFORE UPDATE OR DELETE ON audit_events
    FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();

Kodėl būtent trigeris? Dvi atmestos alternatyvos ir priežastis, kodėl kiekviena nepavyksta:

  • Perrašymo RULE galėtų paversti UPDATE/DELETE į nieką — bet tyliai. Rašymas atrodo pavykęs ir tiesiog nieko nedaro. Tylus blogiau nei uždraustas: manytum, kad ištrynei eilutę, o neištrynei. Trigeris RAISE — jis suvokia garsiai, su klaida.
  • REVOKE UPDATE, DELETE teisės atrodo teisingai, bet programa dev jungiasi kaip supervartotojas, o supervartotojai apeina lentelės teises. Trigeris galioja visiems, įskaitant supervartotoją.

Ir sąžininga riba, pasakyta aiškiai: supervartotojas vis tiek galėtų DROP trigerį. Tikram nekintamumui prieš priešišką DBA reikia įrašų siuntimo už dėžės ribų — įvykių rašymo kažkur, kur duomenų bazės administratorius nepasiekia. Šis trigeris sustabdo programą — ir bet kokį eilinį vaidmenį — nuo istorijos perrašymo. Tai tikra, apribota garantija; teigti daugiau būtų būtent tokia perdėta pretenzija, kurios šis kursas nuolat atsisako.

Kas tai padarė — polimorfinis veikėjas. Veikėjas ne visada vartotojas. Tai gali būti:

  • vartotojas (savininkas įkelia, siunčia, trina),
  • pasirašytojas (be paskyros — peržiūri ir pasirašo per žetoną),
  • sistema (automatinis sent → completed perėjimas),
  • anoniminis (atmestas ar pasibaigęs žetonas — niekas, ką galėtume įvardyti).

Priversti user_id kiekvienoje eilutėje būtų melas trims iš tų keturių. Todėl veikėjas modeliuojamas sąžiningai:

actor_type      TEXT NOT NULL CHECK (actor_type IN ('user','signer','system','anonymous')),
actor_user_id   UUID,           -- gali būti NULL, JOKIO išorinio rakto
actor_signer_id UUID,           -- gali būti NULL, JOKIO išorinio rakto
actor_label     TEXT NOT NULL,  -- žmogaus tapatybė, UŽŠALDYTA įvykio metu

actor_type yra diskriminatorius; du ID stulpeliai gali būti NULL ir neša tai, kas taikoma (arba nieko). O actor_label — el. paštas arba „system" — užšaldytas eilutėje rašymo metu, tad pėdsakas skaitosi teisingai be jokių sujungimų net ištrynus vartotoją ar pasirašytoją.

Be išorinio rakto tyčia — eilutės turi pergyventi tai, ką aprašo. Tai aštriausias pamokos kraštas. Ne tik veikėjo ID, bet ir document_id yra paprastas UUID be išorinio rakto. Pagalvok apie juodraščio trynimą: nori, kad jo „Deleted draft" įvykis — ir kiekvienas ankstesnis — išliktų. Išorinis raktas duoda du blogus pasirinkimus:

  • ON DELETE CASCADE ištrintų įrodymą vos ištrynus dokumentą — tikslus audito pėdsako priešingumas.
  • ON DELETE RESTRICT užblokuotų trynimą, nes audito eilutės į jį nurodo.

Be išorinio rakto — trečias kelias: eilutės lieka, document_id vis dar rodo į jau dingusį dokumentą, o pėdsakas nepažeistas. Naivus išorinis raktas čia tyliai sugriautų visą esmę.

seq stabiliai tvarkai. Keli įvykiai gali dalytis viena transakcija — paskutinis parašas rašo signed ir completed kartu, tad jų now() laiko žymos yra identiškos (now() fiksuotas visai transakcijai). Vien laiko žyma jų neišrikiuos. Todėl lentelė neša seq BIGINT GENERATED ALWAYS AS IDENTITY, monotoninį skaitiklį, o pėdsakas rikiuojamas pagal seq.

Užšaldytos žinutės. Kiekvienas įvykis saugo pilną, paprasta kalba parašytą sakinį, sukurtą tą akimirką, kai jis įvyko („alice@example.com signed as ..."). UI atvaizduoja jį pažodžiui — niekada neišvedžioja iš neapdorotų laukų — tad istorinis įrašas negali pasislinkti, jei atvaizdavimo kodas vėliau keistųsi.

Ši pamoka:

  1. audit_events lentelė su papildomo-tik trigeriu, dvi užklausos ir veikėjo modelis.
  2. Vieno įvykio įpynimas į kiekvieną būsenos pokytį — ir įrodymas, kad eilutės pergyvena dokumento trynimą.