昨天,我們花了一整天研究廣播。
最後得到了一個非常重要的結論:
廣播會臭酸。
你到底還要玩這個梗多久?
至少再玩一天。
像「下節課前來找老師拿東西」這種訊息,如果等到下節課都上完了才送到學生手上,那它跟放在冰箱裡忘記吃的便當其實也沒差多少。
東西還在。
只是已經沒用了。
所以昨天,我們從「為什麼需要廣播」一路想到了廣播的時效性。
但想了這麼久,有一件事情我們到現在都還沒做。
寫廣播?
對。
這東西都叫「校園文字廣播系統」了,結果寫到 Day 20,資料庫裡甚至還沒有一張真正拿來存廣播的 Table。
今天總算可以把鏟子拿出來,開始挖資料庫了。
先從最基本的開始:
broadcasts
----------
id
author_id
content
created_at
ack_deadline_at
id 是廣播自己的 ID。
author_id 紀錄誰發的。
content 放廣播內容。
created_at 紀錄建立時間。
最後的:
ack_deadline_at
則代表:
資訊股長對這則廣播的確認責任,到什麼時候為止。
為什麼不是 expires_at?
因為我們根本沒有打算讓廣播「過期就消失」。
真正有期限的是資訊股長的確認責任。
假設學校 17:00 放學,那確認截止點就是放學前一小時:
16:00
如果老師 14:30 發:
14:30 發送廣播
│
│ 資訊股長有責任確認
▼
16:00 確認期限
│
▼
17:00 放學
很合理。
但如果老師 16:37 才發,我們總不能寫:
ack_deadline_at = 16:00
出生即死亡。
而且還要資訊股長背鍋。
所以如果廣播是在確認期限之後才送出,就不能再要求資訊股長負責在當天確認。
這也是為什麼我把它叫做:
ack_deadline_at
而不是 expires_at。
它描述的是確認責任的截止時間,不是資料的死亡時間。
那超過 ack_deadline_at 之後,廣播會消失嗎?
不會。
新版首頁的廣播會以:
一週
作為主要顯示週期。
本週 UI 顯示本週的廣播;到了下一週,上週的內容則移到另一個 UI 裡回看。
所以這兩個時間其實處理的是不同問題:
created_at
│
└── 決定它屬於哪一週
ack_deadline_at
│
└── 決定資訊股長的確認責任到哪裡
如果要查本週廣播,看的不是:
ack_deadline_at > now()
而是類似:
本週開始 <= created_at < 下週開始
資料留著。
顯示週期照週切。
確認責任則有自己的截止時間。
三件事情,各做各的。
所以便當臭酸之後還在冰箱?
……
我開始後悔昨天用了這個比喻。
前面花了不少時間處理 Account、Role、Permission,甚至還搞出了一套 RBAC。
但這次反而不用搞得那麼複雜。
因為廣播的身分邊界很明確:
Teacher → 發送廣播
資訊股長 → 代表班級接收、回覆
資訊股長在 Account 的身分類型上仍然屬於 Student。
目前也沒有「某些 Student 可以發、某些不能發」之類的複雜規則。
所以沒必要為了證明前面做了 RBAC,就硬把所有東西都塞進 RBAC。
RBAC 是拿來解決需要 RBAC 的問題。
不是拿來讓所有問題看起來都需要 RBAC。
聽起來很像你會做的事。
……
閉嘴。
舊系統還有一個不能漏掉的功能:
資訊股長可以回覆廣播。
例如老師發:
明天中午請各班資訊股長到資訊中心領取資料。
399 班可能會回:
老師,我們班中午有活動,可以晚一點過去嗎?
所以我們需要另一張 Table:
replies
-------
id
broadcast_id
author_id
content
created_at
broadcast_id 表示這則 Reply 屬於哪則 Broadcast。
author_id 則指向實際送出 Reply 的 Account。
畫面上當然不會顯示一串 UUID,而是根據 Account 的班級與 Position 產生:
399班 資訊股長
這只是 Semantic Label(語意化標籤)。
真正存進資料庫的仍然只有:
author_id → accounts.id
這樣以後就算想把顯示名稱改成:
高二399|資訊股長
也不用修改任何 Reply。
資料庫負責保存關係。
人話交給 API 再翻譯就好。
現在假設 399 班資訊股長回:
老師,我們班中午有活動,可以晚一點過去嗎?
老師接著回:
那午休結束再過來就好。
問題來了。
如果兩筆資料都只有:
broadcast_id = 100
資料庫只知道它們都屬於 Broadcast #100,卻不知道老師那句話是在回誰。
所以 Reply 還需要一個欄位:
replies
-------
id
broadcast_id
author_id
content
ref_id
created_at
例如:
Reply #1
broadcast_id = 100
ref_id = NULL
而老師的回覆:
Reply #4
broadcast_id = 100
ref_id = 1
也就是:
Reply #4
│
└── ref → Reply #1
其中:
replies.ref_id → replies.id
這就是 Self-referencing Foreign Key(自我參照外鍵)。
同一張 Table 裡的一筆資料,可以指向同一張 Table 裡的另一筆資料。
不過 Reply #1 又有一個問題。
它不是在回其他 Reply。
它是在回最上面的 Broadcast。
我們當然可以搞成:
ref_type = "broadcast"
ref_id = 100
或:
ref_type = "reply"
ref_id = 1
但這樣 ref_id 一下指向 broadcasts、一下指向 replies,Referential Integrity(參照完整性)反而會變得麻煩。
而且根本沒必要。
因為 Reply 本來就有:
broadcast_id
所以直接規定:
ref_id = NULL
代表直接回覆 Broadcast。
而:
ref_id = <Reply ID>
則代表回覆某一則 Reply。
最後資料就會長成:
Broadcast #100
│
├─ Reply #1
│ 399班 資訊股長
│
│ └─ Reply #4
│ 王老師
│
├─ Reply #2
│ 398班 資訊股長
│
└─ Reply #3
397班 資訊股長
等等。
怎麼了?
這不是一棵樹嗎?
……
對。
我們只是想讓資訊股長回個廣播。
結果回著回著——
長出一棵樹了。
既然長成 Tree(樹狀結構),那就還有一條規則要守。
假設:
Reply #1
broadcast_id = 100
結果又新增:
Reply #2
broadcast_id = 200
ref_id = 1
這筆資料等於同時說:
我屬於 Broadcast #200。
又說:
但我正在回覆 Broadcast #100 裡面的 Reply。
腳踏兩條船。
不要把資料庫講得這麼渣。
所以只要 ref_id 不為 NULL,就必須保證:
reply.broadcast_id == ref.broadcast_id
也就是目前的 Reply 和它引用的 Reply,必須屬於同一則 Broadcast。
這是一條 Invariant(不變條件)。
至於最後要由 Service Layer(服務層)檢查,還是讓 Database(資料庫)也一起守,就留到真正實作時處理。
現在我們有:
Broadcast、Account、文字 Reply,而且 Reply 還可以回 Reply。
恭喜,你又做出 Discord 了。
才沒有。
這裡有一條很重要的界線:
所有 Reply 都必須依附在某一則 Broadcast 底下。
沒有私人聊天室、群組聊天室,也不能獨立開啟對話。
每一段討論都一定從 Broadcast 開始。
所以這不是一套 Messaging System(訊息系統)。
而是:
圍繞著一則 Broadcast 產生的回覆與討論。
這條 Scope(功能邊界)得先畫好。
不然照我的個性,再寫下去大概很快就會冒出:
「既然都可以回覆了,要不要順便做已讀?」
「既然有已讀,要不要顯示誰看過?」
「既然老師跟學生可以互相回,要不要上 WebSocket?」
……
要不要?
不要。
至少今天不要。
今天我們先建立:
broadcasts
----------
id
author_id
content
created_at
ack_deadline_at
其中 created_at 負責顯示週期,ack_deadline_at 負責資訊股長的確認責任期限。
接著建立:
replies
-------
id
broadcast_id
author_id
content
ref_id
created_at
其中:
ref_id = NULL
代表直接回覆 Broadcast。
而:
ref_id = <Reply ID>
則代表回覆另一則 Reply。
於是原本只是:
Broadcast
└─ Reply
最後變成:
Broadcast
│
├─ Reply
│ └─ Reply
│ └─ Reply
│
├─ Reply
│ └─ Reply
│
└─ Reply
嗯。
真的長成一棵樹了。
所以明天要幹嘛?
都把樹畫好了。
明天當然是——
把它種進 PostgreSQL。
希望不要挖到五天後的蘇某。
……
你不要提醒我。