iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Modern Web

重寫一套比我還老的系統:21 歲的校園文字廣播系統系列 第 20 篇

Day 20|回覆一則廣播,怎麼回著回著就變一棵樹了?

  • 分享至 

  • xImage
  •  

昨天,我們花了一整天研究廣播。

最後得到了一個非常重要的結論:

廣播會臭酸。

你到底還要玩這個梗多久?

至少再玩一天。

像「下節課前來找老師拿東西」這種訊息,如果等到下節課都上完了才送到學生手上,那它跟放在冰箱裡忘記吃的便當其實也沒差多少。

東西還在。

只是已經沒用了。

所以昨天,我們從「為什麼需要廣播」一路想到了廣播的時效性。

但想了這麼久,有一件事情我們到現在都還沒做。

寫廣播?

對。

這東西都叫「校園文字廣播系統」了,結果寫到 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。

希望不要挖到五天後的蘇某。

……

你不要提醒我。


上一篇
Day 19|我去,感覺做不完了。等等,你是鬼吧!
系列文
重寫一套比我還老的系統:21 歲的校園文字廣播系統 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言