Week 6 Workshop 決定了網路上跑什麼(協定);Week 7 Workshop 決定程式內部怎麼切: 把「跟伺服器講話」的程式碼(客戶端程式庫)與「跟使用者互動」的程式碼(UI)分成兩塊,中間用一個介面隔開。 目標是——任何人寫的 UI 都能接上任何人寫的客戶端程式庫。這是 Lecture 06A「介面即契約」與 Lecture 07A「對介面測試」的綜合演練。

為什麼要切這一刀?

UI 只認識 介面,所以可以接真的網路實作,也可以接假的實作來測試。
  • 可測試:UI 邏輯(訊息怎麼排版、錯誤怎麼顯示)可以用 在沒有伺服器時測試——甚至在伺服器組還沒寫完之前。

  • 可替換:終端機版與圖形版 UI 共用同一個程式庫;程式庫從單執行緒升級成多執行緒,UI 一行都不用改。

  • 可分工:介面就是合約。UI 組與程式庫組各自對著介面開發,最後一定接得起來。

介面長什麼樣?

public interface ChatClient {

    /** 事件監聽者:UI 實作這個介面,程式庫在事件發生時呼叫它。 */
    interface Listener {
        void onMessage(String room, String from, String text);
        void onError(int code, String text);
        void onDisconnected();
    }

    void addListener(Listener listener);              // 可註冊多個

    /** 連線並登入。@throws IOException 網路失敗;@throws IllegalStateException 若已連線 */
    void connect(String host, int port, String name) throws java.io.IOException;

    void join(String room);

    /** 前置條件:已連線且已 join 該房間。 */
    void say(String room, String text);

    /** 告知伺服器離開並關閉連線;之後任何操作都是 IllegalStateException。 */
    void disconnect();

    boolean isConnected();
}

四個設計決定,每一個都對應本週學過的原則:

  1. 方法對應「使用者能做的事」,而不是協定。介面裡沒有 、沒有 / 這些字。 協定是實作細節——封裝的原則說:UI 不該知道、也不該依賴它。日後協定改版,UI 不受影響。

  2. 伺服器推來的事件用回呼(callback)。別人的訊息何時到達,UI 無法預知,所以不能用「呼叫方法拿回傳值」的方式取得。 改成 UI 註冊一個 ,程式庫在事件發生時反過來呼叫 UI。這是第 [sec:lab] 節 「把行為當參數傳」的同一招。

  3. 前置條件與例外寫進契約。未連線就 → (呼叫者的錯,第 [sec:encap] 節); 網路斷掉 → (環境的錯,呼叫者必須處理,所以是受檢例外)。UI 因此知道該接哪些例外。

  4. 行為子型別。任何實作——真的或假的——都必須遵守同一份契約(例如「 之後,所有 listener 會收到一次 」)。 否則用假實作測過的 UI,接上真實作就壞了;這正是第 [sec:subtyping] 節 LSP 要防的事。

用假實作測 UI

完全不碰網路: 直接把訊息回送給所有 listener(模擬伺服器的 廣播), 不在房間裡就回送 (模擬 )。UI 端用匿名類別實作 :

StringBuilder screen = new StringBuilder();                // 模擬「螢幕」
ChatClient client = new FakeChatClient();                  // 宣告型別是介面
client.addListener(new ChatClient.Listener() {             // 匿名類別:現場實作介面
    public void onMessage(String room, String from, String text) {
        screen.append("[").append(room).append("] ").append(from).append(": ").append(text).append("\n");
    }
    public void onError(int code, String text) { screen.append("ERROR ").append(code).append(": ").append(text).append("\n"); }
    public void onDisconnected()               { screen.append("(disconnected)\n"); }
});
client.connect("localhost", 5555, "angelia");
client.say("general", "hi");                               // 還沒 join → onError
client.join("general");
client.say("general", "hi again");                         // → onMessage
client.disconnect();                                       // → onDisconnected
assert screen.toString().equals("ERROR 403: not in room general\n[general] angelia: hi again\n(disconnected)\n");
=== 用 FakeChatClient 測 UI 邏輯(不需要網路)===
ERROR 403: not in room general
[general] angelia: hi again
(disconnected)
  斷線後操作 → IllegalStateException:尚未連線
全部通過。

是匿名類別:一個沒有名字、只用一次的實作。 若介面只有一個抽象方法(如 、、),可以進一步簡寫成 lambda: 。 有三個方法所以不能用 lambda; 另一種設計是拆成三個單方法介面( 等),各自都能用 lambda——這是 Workshop 中值得討論的取捨。

真實作的骨架與討論清單

真的 會用第 [sec:ws6] 節的 //, 並且需要一條背景執行緒不停 ,把每行 成 後分派給 listener:

private void readLoop() {                       // 在背景執行緒執行
    String line;
    while ((line = in.readLine()) != null) {
        ChatMessage m = ChatMessage.parse(line);
        switch (m) {
            case ChatMessage.Msg g -> listeners.forEach(l -> l.onMessage(g.room(), g.name(), g.text()));
            case ChatMessage.Err e -> listeners.forEach(l -> l.onError(e.code(), e.text()));
            case ChatMessage.Bye b -> { listeners.forEach(ChatClient.Listener::onDisconnected); return; }
            default -> { /* OK:忽略,或用來解除等待中的 connect/join */ }
        }
    }
    listeners.forEach(ChatClient.Listener::onDisconnected);   // 連線被對方關閉
}

Workshop 中應該寫進介面文件的問題:

  1. 是阻塞到伺服器回 才返回,還是立刻返回、之後用 回呼通知?(前者簡單,後者不會卡住 UI。)

  2. 回呼在哪一條執行緒被呼叫?圖形 UI 通常只能在 UI 執行緒更新畫面——這是 Week 8 的主題,但介面文件現在就要說清楚。

  3. 重複 、未 就 、 之後再操作:各自是例外還是靜默忽略?(本介面:全部 。)

  4. 伺服器主動斷線(沒有 )時,程式庫如何得知並通知 UI?( 回傳 。)

  5. 失敗()時,UI 如何知道是哪一則發言失敗?需要在協定或介面加上請求編號嗎?

寫一個終端機 UI:用 讀使用者輸入, 呼叫 ,其他文字呼叫 , 呼叫 ; 用 把收到的訊息印到螢幕。先用 跑起來。 思考:你的 UI 有沒有任何一行程式碼提到 以外的實作類別?除了 那一行之外,不應該有。

用 JUnit 5 為 寫一個抽象測試類別(仿照第 [sec:junit] 節的 ), 至少測:未連線就 拋出 ; 後 會讓 listener 收到恰好一次 ; 後 為 false 且 listener 收到 。 先用 子類別跑過;日後 寫好時,同一套測試應該也要能過。