Week 6 Workshop 決定了網路上跑什麼(協定);Week 7 Workshop 決定程式內部怎麼切: 把「跟伺服器講話」的程式碼(客戶端程式庫)與「跟使用者互動」的程式碼(UI)分成兩塊,中間用一個介面隔開。 目標是——任何人寫的 UI 都能接上任何人寫的客戶端程式庫。這是 Lecture 06A「介面即契約」與 Lecture 07A「對介面測試」的綜合演練。
為什麼要切這一刀?
可測試: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();
}
四個設計決定,每一個都對應本週學過的原則:
方法對應「使用者能做的事」,而不是協定。介面裡沒有 、沒有 / 這些字。 協定是實作細節——封裝的原則說:UI 不該知道、也不該依賴它。日後協定改版,UI 不受影響。
伺服器推來的事件用回呼(callback)。別人的訊息何時到達,UI 無法預知,所以不能用「呼叫方法拿回傳值」的方式取得。 改成 UI 註冊一個 ,程式庫在事件發生時反過來呼叫 UI。這是第 [sec:lab] 節 「把行為當參數傳」的同一招。
前置條件與例外寫進契約。未連線就 → (呼叫者的錯,第 [sec:encap] 節); 網路斷掉 → (環境的錯,呼叫者必須處理,所以是受檢例外)。UI 因此知道該接哪些例外。
行為子型別。任何實作——真的或假的——都必須遵守同一份契約(例如「 之後,所有 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 中應該寫進介面文件的問題:
是阻塞到伺服器回 才返回,還是立刻返回、之後用 回呼通知?(前者簡單,後者不會卡住 UI。)
回呼在哪一條執行緒被呼叫?圖形 UI 通常只能在 UI 執行緒更新畫面——這是 Week 8 的主題,但介面文件現在就要說清楚。
重複 、未 就 、 之後再操作:各自是例外還是靜默忽略?(本介面:全部 。)
伺服器主動斷線(沒有 )時,程式庫如何得知並通知 UI?( 回傳 。)
失敗()時,UI 如何知道是哪一則發言失敗?需要在協定或介面加上請求編號嗎?
寫一個終端機 UI:用 讀使用者輸入, 呼叫 ,其他文字呼叫 , 呼叫 ; 用 把收到的訊息印到螢幕。先用 跑起來。 思考:你的 UI 有沒有任何一行程式碼提到 以外的實作類別?除了 那一行之外,不應該有。
用 JUnit 5 為 寫一個抽象測試類別(仿照第 [sec:junit] 節的 ), 至少測:未連線就 拋出 ; 後 會讓 listener 收到恰好一次 ; 後 為 false 且 listener 收到 。 先用 子類別跑過;日後 寫好時,同一套測試應該也要能過。