Week 6/7 的 Workshop 是 COMP1140 專案「多人聊天系統」的一部分: 全班分成伺服器組與客戶端組,最後任何人的客戶端都要能連上任何人的伺服器。 這件事能成功的唯一前提,是大家先對一份協定規格達成共識——這正是 Lecture 06A「介面即契約」在系統層級的版本。 COMP1110/6710 不會考這一節,但它是本週觀念最好的實戰演練。

什麼是協定?

兩個程式之間關於「可以送什麼訊息、每則訊息長什麼樣、在什麼順序下合法、對方該怎麼回應」的完整約定。 協定跟程式語言無關:只要遵守規格,用 Java 寫的客戶端可以和用 Python 寫的伺服器對話。

網際網路本身就是靠協定運作的:HTTP(網頁)、SMTP(寄信)、IRC(最早的聊天室)都以 文件公開規格。 它們有幾個共同的設計選擇,值得我們直接借用:

  • 純文字、一行一則訊息。人可以直接用 / 手敲指令測試伺服器,除錯時用肉眼就能看懂封包。

  • 每行以「指令字」開頭,後面接以空白分隔的欄位;最後一個欄位可以含空白(自由文字)。

  • 回應帶狀態碼(HTTP 的 200/404、SMTP 的 250/550):程式好判斷,人也好讀。

  • 明確的狀態機:哪些指令在「尚未登入」時合法?哪些要「已加入房間」才能用?規格必須寫清楚。

一份協定草案

隨附程式 實作了下面這份極簡協定(欄位以單一空白分隔; 可含空白;每行以 結尾;UTF-8 編碼):

@lL0.20@lL0.20@ &
& 登入;名字不可含空白 & & 上一個請求成功
& 加入房間(不存在則建立) & & 上一個請求失敗,附錯誤碼
& 在房間發言 & & 有人(含自己)在房間發言
& 離開 & & 伺服器確認並關閉連線

錯誤碼借用 HTTP 的慣例: 格式錯誤、 尚未登入、 不在該房間。

與 的一次完整對話(訊息序列圖)。

把訊息變成型別: +

協定是文字,但程式內部不該到處 。好的做法是把「文字 \(\leftrightarrow\) 結構化資料」的轉換集中在一個地方, 其餘程式碼只操作型別安全的物件:

public sealed interface ChatMessage
        permits ChatMessage.Login, ChatMessage.Join, ChatMessage.Say, ChatMessage.Quit,
                ChatMessage.Ok, ChatMessage.Err, ChatMessage.Msg, ChatMessage.Bye {

    record Login(String name) implements ChatMessage {}
    record Join(String room) implements ChatMessage {}
    record Say(String room, String text) implements ChatMessage {}
    record Quit() implements ChatMessage {}
    record Ok(String text) implements ChatMessage {}
    record Err(int code, String text) implements ChatMessage {}
    record Msg(String room, String name, String text) implements ChatMessage {}
    record Bye() implements ChatMessage {}

    /** 一行文字 → 訊息;格式不合法就拋 ProtocolException。 */
    static ChatMessage parse(String line) { ... }

    /** 訊息 → 一行文字。性質:parse(encode(m)).equals(m) */
    static String encode(ChatMessage m) {
        return switch (m) {                       // sealed:編譯器確認八種都有處理
            case Login l -> "LOGIN " + l.name();
            case Join j  -> "JOIN " + j.room();
            case Say s   -> "SAY " + s.room() + " " + s.text();
            case Quit q  -> "QUIT";
            case Ok o    -> o.text().isEmpty() ? "OK" : "OK " + o.text();
            case Err e   -> "ERR " + e.code() + " " + e.text();
            case Msg g   -> "MSG " + g.room() + " " + g.name() + " " + g.text();
            case Bye b   -> "BYE";
        };
    }
}
  • :封閉的子型別集合——除了列出的八個 record,沒有別人能實作 。 這讓 模式比對可以不用 :漏掉任何一種訊息,編譯器就報錯。

  • 每種訊息是一個 :不可變、自動有 /。 讀起來遠比 、 清楚。

  • 解析時對每種格式錯誤拋 (一個自訂的 )。 伺服器接住它並回 ,永遠不會因為壞輸入而崩潰——這是伺服器程式的鐵律。

對協定編碼器最重要的測試只有一條:任何合法訊息 , 必須 。 對八種訊息各測一次(包含含空白與 emoji 的文字), 再確認七種壞輸入(空行、未知指令、缺欄位、錯誤碼不是整數……)都被拒絕。 第 [sec:junit] 節學了 JUnit 之後,可以把它改寫成 方法。

Java 網路程式的最小骨架

Java 用 套件裡的 與 建立 TCP 連線, 之後就像讀寫檔案一樣,用 與 一行一行收發:

try (ServerSocket server = new ServerSocket(port)) {            // 在 port 上「聽」
    while (true) {
        try (Socket client = server.accept();                   // 阻塞直到有人連進來
             BufferedReader in = new BufferedReader(
                     new InputStreamReader(client.getInputStream(), "UTF-8"));
             PrintWriter out = new PrintWriter(client.getOutputStream(), true, StandardCharsets.UTF_8)) {
            handle(in, out);                                    // 跟這位客戶完整對話
        }
    }
}

static void handle(BufferedReader in, PrintWriter out) throws IOException {
    String name = null;                                         // 狀態:null = 尚未登入
    String line;
    while ((line = in.readLine()) != null) {                    // null = 對方關閉了連線
        ChatMessage reply;
        try {
            ChatMessage m = ChatMessage.parse(line);
            switch (m) {
                case ChatMessage.Login l -> { name = l.name(); reply = new ChatMessage.Ok("welcome " + name); }
                case ChatMessage.Join j  -> reply = new ChatMessage.Ok("joined " + j.room());
                case ChatMessage.Say s   -> reply = name == null
                        ? new ChatMessage.Err(401, "login first")
                        : new ChatMessage.Msg(s.room(), name, s.text());
                case ChatMessage.Quit q  -> { out.println(ChatMessage.encode(new ChatMessage.Bye())); return; }
                default -> reply = new ChatMessage.Err(400, "clients may not send " + line.split(" ")[0]);
            }
        } catch (ChatMessage.ProtocolException e) {
            reply = new ChatMessage.Err(400, e.getMessage());   // 壞輸入 → ERR,而不是崩潰
        }
        out.println(ChatMessage.encode(reply));                 // println 加換行並 flush
    }
}

在兩個終端機分別執行 與 ,客戶端會印出:

>>> SAY general hi
<<< ERR 401 login first
    解析結果:Err[code=401, text=login first]
>>> LOGIN angelia
<<< OK welcome angelia
    解析結果:Ok[text=welcome angelia]
>>> JOIN general
<<< OK joined general
    解析結果:Ok[text=joined general]
>>> SAY general hello world
<<< MSG general angelia hello world
    解析結果:Msg[room=general, name=angelia, text=hello world]
>>> DANCE
<<< ERR 400 未知的指令:DANCE
    解析結果:Err[code=400, text=未知的指令:DANCE]
>>> QUIT
<<< BYE
    解析結果:Bye[]

與 都會阻塞(等到有東西才回來), 所以第二位客戶要等第一位 之後才會被理。 真正的聊天室需要多執行緒(每個連線一條 thread)與共享狀態的同步——那是 Week 8 Workshop 的主題。 協定設計本身不受影響:規格寫好了,實作可以之後再升級。

Workshop 討論清單

在 Workshop 中分組討論協定時,至少要對下面這些問題給出寫進規格書的答案:

  1. 識別:名字可以重複嗎?大小寫視為相同嗎?長度上限?允許哪些字元?

  2. 狀態機:未登入時送 怎麼辦?重複 ?對不存在的房間 ?

  3. 訊息內容:文字裡出現換行怎麼處理(禁止?跳脫?)。編碼一律 UTF-8?

  4. 廣播語意:自己的發言要不要收到 ?(本草案:要,這讓 UI 顯示邏輯只有一條路。)

  5. 錯誤處理:客戶端送出無法解析的行,伺服器回 還是直接斷線?伺服器主動斷線前要不要送 ?

  6. 版本:協定之後一定會改,第一則訊息要不要帶版本號()?

為協定新增「列出所有房間」()與「私訊」()。 寫出:(a) 規格表中的新列,含伺服器回應格式與可能的錯誤碼; (b) 在 中新增對應的 record 與 / 分支—— 注意 的 清單與 的 都要更新,否則無法編譯(這正是 sealed 的價值)。