遠端呼叫總覽:三種溝通典範
讓遠端呼叫「看起來」像本機呼叫,是這整章所有招式共同的目標
先搞懂為什麼要有這一章:本機呼叫的方便,遠端全部沒有
在一台電腦裡,你的程式呼叫一個函式(function)再自然不過:寫下 f(x) 就好。但在分散式系統裡,程式碼被拆到不同電腦、不同處理程序(process)上跑,它們之間沒有共用的記憶體,只能靠網路互傳訊息。
問題是:直接操作網路訊息——打包、送出、等回覆、解包——非常瑣碎又容易出錯。於是工程師發明了一層中介軟體(middleware),蓋在底層的IPC(sockets、訊息傳遞、多播)之上,提供更友善的「遠端呼叫」抽象。
想像你想跟外地一家工廠訂貨。沒有祕書,你得自己查電話、撥號、報出每個欄位、等對方覆述、記下回覆——每個細節都要親手處理,這就像直接寫 socket 程式。有祕書,你只要說一句「幫我訂 100 個 A 零件」,剩下撥號、講話、記回覆全交給她,對你而言就像在公司內部交辦一樣輕鬆。中介軟體,就是那位幫你處理所有網路細節的祕書。
這整章要講的,就是這位「祕書」到底怎麼運作——而且她有三種等級的服務方式,一種比一種貼心。
直接讀原文,旁邊就是白話
這是本章開場,作者親自定義了這三種典範分別是什麼、彼此什麼關係。原文放左邊,白話導讀放右邊——先讀懂原文的氣口,再看整段在說什麼。
請求-回覆協定是建立在「傳訊息」上的一個模式,撐起 client-server 那種一來一回的雙向訊息交換——這是最原始、最貼近底層的一層。
再往上,最早也最有名的「更親切的模型」,是把大家早就熟悉的程序呼叫概念,延伸到分散式系統:讓 client 程式呼叫跑在另一個處理程序(通常在別台電腦)上的 server 程序,感覺就像呼叫本機程序一樣——這就是 RPC。
到了 1990 年代,物件導向的程式設計模型也被延伸了:不同處理程序裡的物件,可以靠遠端方法呼叫互相溝通。RMI 就是本機方法呼叫的延伸——讓活在某個處理程序裡的物件,可以呼叫另一個處理程序裡的物件的方法。
提醒一句:這裡的「RMI」是泛指『遠端方法呼叫』這個通用概念,別跟某個具體產品「Java RMI」搞混——就像「水果」跟「蘋果」不是同一件事。
請求-回覆是地基,RPC 建立在它之上,把「一來一回的訊息」包裝成「像呼叫本機程序一樣」;RMI 再建立在 RPC 的概念之上,把「程序」換成「物件」,還多了一項本事——物件參考可以當參數傳來傳去。三層不是三個互不相干的技術,而是同一個目標(讓遠端呼叫變簡單)逐步升級的三個台階。
先定位:這位「祕書」站在整個通訊堆疊的哪一層?
電話線路本身(底層 IPC)當然還是得靠 UDP、TCP 實際把封包送出去,但你的應用程式從來不直接碰它——中間永遠隔著中介軟體這一層。點下面的方塊,看看每一層各管什麼。
它是「疊」在 UDP/TCP 之上,不是把它們換掉。之後你看到 gRPC、Java RMI、CORBA 這些技術名字,都可以先問自己三個問題:它跑在哪一層?底下用什麼?它幫我藏掉了什麼細節?幾乎每次都能定位出「中介軟體層,底下是 TCP,藏掉了打包參數、傳訊息、配對回覆、處理逾時」這一套答案。
招牌互動:三張卡,看懂誰疊在誰上面
把三種典範想成「交辦事情」的進化——一種比一種讓你少操心。
最底層、最原始。像在櫃台遞一張便利貼寫需求,對方做完回你一張紙條——規則要自己定(怎麼把哪張回覆配對到哪張請求),但最輕量、最可控。這是 RPC 與 RMI 共同的地基。
建立在請求-回覆之上,把它延伸成「像呼叫本機函式一樣」。就像打內線電話說「請幫我跑一下報表程式」——你不必管對方在哪台機器,講出來就像在自己辦公室喊一聲。1980 年代的重大突破。
再建立在 RPC 的概念之上,多了「物件參考可以當參數傳遞」。就像有個語音助理,你不只能下指令,還能把「另一個物件」交給它——甚至對方回你一個可以繼續使喚的物件參考。1990 年代物件導向興起後的延伸。
RPC 只能傳值(輸入/輸出參數);RMI 額外能傳「物件參考」——當資料很大或很複雜時,傳一個參考讓對方需要時再遠端呼叫,往往比整包複製過去划算得多。這正是物件導向世界比程序式世界多出來的那一項本事。
同樣是「遠端呼叫」,什麼時候該挑哪一種?
三種典範不是「新的比舊的好」,而是解決不同層次的問題。挑錯層次,不是白費工夫,就是自找麻煩。
例如自訂的小型協定、或像 NFS 那種固定區塊傳輸——用底層的請求-回覆協定就夠了,不需要額外的抽象包裝。
而資料是程序式的(一堆函式、一堆參數)——用 RPC(如 Sun RPC、gRPC)。你寫起來像呼叫本機方法,底層其實是打包成訊息、走 TCP、對方解包執行、再把結果送回。
需要傳遞物件參考、用得到繼承與多型——用 RMI(如 Java RMI、CORBA)。這也是本章第 5 節要做的案例研究:Java RMI。
下次看到任何分散式技術(gRPC、Java RMI、CORBA、REST),都可以問自己:它跑在哪一層?幾乎都在中介軟體層。它底下用什麼?通常是 TCP 或 UDP。它幫我藏掉了什麼?多半是打包參數、傳訊息、配對回覆、處理逾時。理解這個分層,遇到效能或失敗問題時就知道該往哪一層找原因。
小試身手
抓住這三層的關係,你就摸到整章的骨架了。來兩題:
先搞懂最原始的那一層對話,才看得懂上面兩層加了什麼。往下捲。
最基礎的對話:請求-回覆協定
在什麼保證都沒有的網路上,兩台機器要怎麼確定對話真的完成了
先認識三個動作:整個協定就靠它們撐起來
上一站我們看到訊息傳遞是分散式系統唯一的溝通方式。這一站要落地:client-server 之間最基本的對話樣式,就是請求-回覆協定(request-reply protocol)。
正常情況下,這種通訊是同步的(synchronous):client 送出請求後會阻塞(block),站在原地等,直到 server 的回覆回來才繼續往下跑。它也可以是可靠的——因為 server 的回覆本身,就等於是對 client 的一份「確認收到」。
整個協定靠三個基本動作互相接力:
client 用它發起遠端操作:告訴協定要找哪台 server、呼叫哪個操作、帶什麼參數,回傳一包裝著回覆的位元組陣列。呼叫者會被阻塞,直到回覆送達。
server 用它從自己的連接埠取得進來的請求,這是它「聽」到 client 在呼喊的方式。
server 執行完操作後,用它把回覆送回 client;client 那頭的 doOperation 因此解除阻塞,繼續往下跑。
你走到櫃台點餐(doOperation 送出請求),然後站在旁邊等(阻塞)。店員收到你的單(getRequest),去做餐;餐做好了叫號交給你(sendReply),你拿到餐才離開(解除阻塞)。三個動作,剛好對上點餐的三個瞬間。
直接讀原文,旁邊就是白話
這是課本描述請求-回覆協定最核心的幾句話——先講同步阻塞的性質,再講三個動作怎麼互相銜接。原文放左邊,白話導讀放右邊。
正常情況下,請求-回覆通訊是同步的——因為 client 程序會一路阻塞,等到 server 的回覆抵達才繼續。
它也可以視為可靠的,因為 server 送回來的那個回覆,本身就等於是給 client 的一張收據。
doOperation 這個方法,就是 client 用來發起遠端操作的入口。
呼叫 doOperation 的那一方會被卡住,直到 server 真的執行完請求的操作、把回覆訊息傳回來為止。
client 端的 doOperation 會替每個請求訊息生成一個 requestId,server 收到後會把同一個編號原封不動抄進對應的回覆裡。
速食店裡同時有很多人在點餐,怎麼確保你拿到的是「你的餐」而不是別人的?靠號碼牌。requestId 就是這張號碼牌——client 每送一個請求就生一個獨一無二的編號,server 回覆時把同一個編號抄回去,doOperation 才能確認「這份回覆是我這次的,不是上一筆延遲的舊回覆」。
一封請求/回覆訊息,長什麼樣
請求與回覆訊息通常包含這些欄位——這是它們的「身分證」格式:
0 代表 Request,1 代表 Reply——先講清楚這是去程還是回程的信。
訊息識別碼,用來把請求與回覆配成對——收到回覆時,靠它確認「這是我等的那一份」。
要呼叫的遠端物件/服務參考,告訴協定這通電話該打給誰。
要執行哪個操作,以及已經打包成位元組陣列的參數——內容都準備好了,等 server 開工。
完整的訊息識別碼其實由兩部分組成:requestId(送出端自己遞增的整數)加上送出端的識別(例如它的連接埠與網際網路位址)。前者讓編號在「同一個送出端內」唯一,後者讓它在「整個分散式系統內」唯一。requestId 遞增到無號整數最大值就會歸零重來——只要一個編號的存活期遠短於整個序列繞完一圈的時間,就不會撞號。
招牌互動:一次完整對話,外加「訊息遺失」分支
下面演給你看兩段劇情。第一段是順利的三步對話:doOperation → getRequest → sendReply。第二段是訊息半路搞丟時,逾時、重送、用 requestId 過濾重複執行的補救戲。按「下一步」照順序看。
doOperation 在等回覆時會設逾時(timeout)。逾時後最常見的作法不是直接放棄,而是重送請求,直到收到回覆、或合理確認真的是 server 沒回應為止;真的不行時,才用例外通知 client「沒拿到結果」。但重送也帶來新問題:server 可能收到重複的請求,導致同一操作被執行多次。
重送很方便,但操作被做兩次怎麼辦
你寄信請廠商出貨,過了約定時間還沒回音(逾時)。你不知道是你的信掉了,還是對方早出貨了、只是回函在路上丟了。保險作法是再寄一次——但若對方其實已經出貨,再寄一次可能害你收到兩批貨,這就是重複執行的風險。
解法主要有兩層:一是冪等操作(idempotent operation)——有些操作天生「做幾次都一樣」,例如把開關設成 ON,重複設一百次結果都是 ON;但「餘額加 10 元」就不是冪等的,做兩次就多加了。二是讓 server 自己記得誰問過什麼:
server 認得來自同一 client、帶相同 requestId 的後續訊息,並把它濾掉。若回覆還沒送出,不必特別處理,等做完再送即可。
history 是一份「已送出回覆」的紀錄,含 requestId、訊息內容、收件 client。client 沒收到回覆再問一次時,server 可直接重送舊回覆,不必重新執行操作。
若服務的每個操作天生都是冪等的,server 根本不必特別防範重複執行——例如 NFS 傳固定大小的檔案區塊,就刻意設計成冪等,不需要維護 history。
若請求-回覆建在 UDP 之上,就會有遺漏失敗(omission failure)——訊息可能直接掉包,也不保證送達順序。再加上我們假設程序只會發生當機失效(crash failure):程序當掉就停住,不會做出亂七八糟的 Byzantine 行為。這些假設,正是逾時、重送、過濾重複三件事存在的理由。
三種交換樣式:R、RR、RRA,該挑哪一種
面對通訊失敗,可依需求挑選不同的訊息交換樣式(Spector, 1982)——不是每次對話都需要一來一回。
只有 Request 一則訊息。適用於操作沒有回傳值、client 也不需要確認的情境——像投一張明信片就走,不在乎對方有沒有回。
Request + Reply,最常用。不需要額外的確認訊息,因為 server 的回覆本身就等於確認;client 下一次呼叫又等於確認了上一次的回覆。
Request + Reply + Acknowledge。client 再多送一個帶著 requestId 的 Acknowledge,讓 server 能安心把 history 裡對應的紀錄刪掉。
HTTP 正是請求-回覆協定的經典實例,建在 TCP 之上。它跟前面描述的協定最大的不同:所有資源共用一組固定方法(GET 取得資源、POST 可能改變伺服器狀態、PUT/DELETE 都是冪等的),而不是每個服務各自定義操作。早期 HTTP 每次請求都開新連線、回完就關;HTTP/1.1 改用持久連線(persistent connection),一條連線可重複用於多次請求-回覆,省下反覆建連線的開銷。
小試身手
從三個基本動作、到逾時重送、再到三種交換樣式,來兩題檢查一下:
最基礎的對話學會了,接下來看它怎麼被包裝成「像呼叫函式一樣」。往下捲。
遠端程序呼叫 RPC:讓呼叫跨越機器
把「呼叫一個函式」這個你每天做的動作,搬到千里之外的另一台機器上
先講規則:你只看得到「菜單」,看不到「廚房」
寫程式時,模組之間要溝通,通常靠程序呼叫(procedure call),或是直接讀寫對方的變數。但在分散式系統裡,第二條路完全走不通——你的程式跑在一台機器,對方的變數在另一台機器的記憶體裡,你伸手也摸不到。
所以每個模組都要有一份明確的服務介面(service interface)——它只列出「有哪些程序可以呼叫、參數是什麼型別」,完全不揭露這些程序內部到底怎麼寫。這正是用介面寫程式(programming with interfaces)的精神:使用者只管介面這層抽象,不必也不能過問實作細節。
菜單告訴你「有哪些菜、可以加辣還是加大」,這就是介面;廚房用瓦斯爐還是電磁爐、主廚是台灣人還是法國人,你完全不用知道,這是實作。只要菜單不變,換廚師、換設備,你照樣點得到同一道菜——這就是軟體演進能夠發生的原因。
用介面寫程式在分散式系統裡還有一個額外的好處:你甚至不必知道對方用什麼語言、什麼平台實作,這正是駕馭異質性(heterogeneity)的關鍵一步。但分散式的本質也反過來限制了介面能長什麼樣子——這就是下一段要講的。
直接讀原文,旁邊就是白話
書上這幾句話,講的是「跨程序」這個事實,會反過來限制介面能規定什麼、不能規定什麼。
跑在某個處理程序(process)裡的 client 模組,沒辦法直接去讀寫另一個處理程序裡的變數。所以服務介面根本不能規定「直接存取變數」這種事。
本機呼叫慣用的傳參方式——傳值(call by value)、傳址(call by reference)——到了跨處理程序的場合,傳址就失效了。因為位址只在自己的處理程序裡有意義,分散式系統直接不支援 call by reference。
client stub 表面上裝得跟本機的一個函式一樣,但它其實不執行任何運算,而是把「你要呼叫哪個程序+你給的參數」打包(marshal)成一則請求訊息,透過通訊模組送到 server 那邊去。
等回覆訊息送回來了,它才把結果解包(unmarshal)出來還給你。
「兩端在不同處理程序」→ 位址跨程序無效 → 不支援 call by reference → 參數只能講清楚「送進去的」與「帶回來的」,也就是接下來要介紹的 in / out 參數。
語言不一樣,還能點同一份菜單嗎?
如果 client 和 server 都用同一種語言寫(例如全都用 Java),介面可以直接用那個語言定義。但真實世界常常是 client 用 Python、server 用 C++——這時候就需要一套介面定義語言(IDL):語言中立,只負責把「有哪些程序、參數是 in 還是 out」講清楚,不綁定任何一種程式語言。
以 CORBA IDL 為例,這是課本給的範例:
struct Person {
string name;
string place;
long year;
};
interface PersonList {
readonly attribute string listname;
void addPerson(in Person p);
void getPerson(in string name, out Person p);
long number();
};
先定義一個資料結構 Person,裡面有姓名、地點、年份。
介面 PersonList 講清楚這個服務能被呼叫的所有程序。
addPerson 的參數標了 in:這個 Person 是你送進去給 server 的。
getPerson 的第二個參數標了 out:這個 Person 是 server 查好之後帶回來給你的。
number() 沒有參數,只回傳一個數字。
IDL 概念最早是為 RPC 而生的,但後來被整個家族沿用:Sun XDR(RPC 用)、CORBA IDL(RMI 用)、WSDL(web services 用)、Google 的 protocol buffers——說法不同,但都在做同一件事:讓說不同語言的人,照著同一份規格點餐。
招牌互動:一次 RPC 呼叫,裡面到底發生了什麼
RPC 想讓你「像呼叫本機函式一樣」呼叫遠端程序,魔法全藏在幾個自動產生的元件裡。按「下一步」,跟著一次呼叫,從 client 一路走到 server、再走回來。
想像你在台灣請代購買日本商品。你(client)只說「我要買這個」;台灣代購(client stub)把你的需求填成標準表單、裝箱(marshal);國際物流(通訊模組)把包裹送到日本;日本倉庫的分揀員(dispatcher)看標籤把包裹分到正確窗口;日本窗口人員(server stub)拆箱,交給真正的日本店家(service procedure)去準備商品。你全程只跟台灣代購講話,感覺就像在巷口買東西——這正是 RPC 想營造的透明感。
這些「打包/分派」的苦工,通常不用手寫
client stub、server stub 與 dispatcher,都可以由介面編譯器從介面定義自動產生,程式設計師不必手寫——畢竟每個程序都要重複做「打包、送出、解包」這件事,交給機器生成既省事又不容易出錯。
由 RFC 1831 描述,最初是為 Sun 的Network File System(NFS)而設計的 client-server 通訊機制。
它用 XDR 作為 IDL,搭配介面編譯器 rpcgen,主要給 C 語言用。rpcgen 能從介面定義自動產生 client stub、server 主程式與 dispatcher、server stub,以及打包/解包用的程序。
Sun RPC 不用介面名稱識別服務,而是用程式編號+版本編號;版本編號在簽章改變時更新,讓 client/server 能確認彼此版本一致。
port mapper是本機的繫結服務:server 啟動時登記自己的程式編號、版本與連接埠;client 則向目標主機的 port mapper 查詢,才能找到正確的連接埠。
你打電話到一間公司,只報出「部門名稱」(程式編號),總機(port mapper)就告訴你該打哪個分機(連接埠)——你不需要事先知道對方部門搬去哪個分機。Sun RPC 採用at-least-once語意,這是下一段要細談的重點。
本機呼叫恰好一次,遠端呼叫呢?
本機程序呼叫的語意很單純:exactly once(恰好一次),除非當機,否則每個程序剛好執行一次。但遠端呼叫的請求或回覆訊息會遺失、server 會當機——沒辦法輕易保證恰好一次。於是書上用三種容錯措施,組合出三種不同的呼叫語意:
不重送、不過濾、不保存結果——什麼容錯措施都沒有。程序可能執行一次,也可能完全沒執行,你無從確認。只適合「偶爾失敗也沒差」的場合,例如定期回報的感測器讀數,掉一筆無妨。
沒收到回覆就重送請求,直到收到結果或判定 server 失敗。你收到結果時,知道程序至少被執行了一次——但可能不只一次!若操作不是冪等(idempotent),重複執行就會出錯。
用上全部措施:重送請求+在 server 端過濾重複請求+保存結果以便直接重送回覆(而不是重新執行)。收到結果時,知道恰好執行一次;沒收到結果,則是「執行了一次或完全沒執行」,但絕不會超過一次。
Maybe 就像寄出去就不管的明信片,可能到、可能掉;At-least-once 像「沒回音就一直重寄」,你至少能確定事情被做了,但如果請求是「幫我把餘額加 10 元」(非冪等),重複執行帳就爆了;At-most-once 則是掛號信加去重機制,用上全部措施,保證絕不超過一次。一句話心法:先問操作是否冪等——冪等就用 at-least-once 省開銷,非冪等(例如轉帳、扣庫存)幾乎必選 at-most-once。
動手配配看:呼叫語意配上對應的容錯措施
把下面三種呼叫語意,拖到它對應的容錯措施組合上。配完按「對答案」。
IDL 可以提供機制去指定一個程序要用哪種呼叫語意,這能幫服務設計者省心——例如如果選擇 at-least-once 來避開 at-most-once 的開銷,就必須確保這些操作都設計成冪等。書上也提到,RPC 的當前共識是:遠端呼叫的語法要跟本機呼叫一樣(保持透明),但本機呼叫與遠端呼叫的差異應該在介面裡表達清楚——別讓使用者以為兩者完全零差別。
小試身手
從介面、stub、dispatcher 到呼叫語意,這一站的骨架都在這了。來兩題:
某轉帳操作會把帳戶餘額減少 100 元,這是一個非冪等操作。依三種呼叫語意的定義,最該選哪一種?
函式的世界搞懂了,接下來把同一套想法搬進物件的世界。往下捲。
遠端方法呼叫 RMI 與分散式物件
當「呼叫的對象」從函式變成物件,連物件本身要去哪裡找都成了問題
把物件搬到不同程序,會冒出什麼新問題?
物件導向程式,說穿了就是一群物件互相呼叫彼此的方法。每個物件有自己的資料、自己的方法,透過物件參考互相存取。現在把這些物件實體分散到不同程序、甚至不同電腦——這就是分散式物件。
只要方法呼叫跨越了程序或電腦的邊界,就是遠端方法呼叫(RMI);還留在同一程序內的呼叫,就只是本機方法呼叫。一句話:物件沒變,只是呼叫的距離變遠了,但這一變,麻煩就全冒出來了。
把一群分散式物件想成跨城市的多家分公司。本機方法呼叫=同一辦公室走過去找同事;遠端方法呼叫=打去外地分公司找人。而遠端物件參考就是對方那個人的「全公司通用工號」——不管他在哪個分公司,這個工號都能唯一指到他。
分散式物件模型的核心概念,就靠兩件事撐起來:一個能被跨程序找到的遠端物件,加上一張指名「哪些方法可以被外人呼叫」的遠端介面——就像該員工的「對外服務窗口清單」:外人只能請他做清單上列出的事,他內部還在忙什麼雜事,外人碰不到。相對地,一般沒有遠端介面的本機物件,就像不對外的內部員工,只有同辦公室的人才能直接找他。
直接讀原文,旁邊就是白話
這幾句話界定了「遠端物件」與「遠端介面」這兩個分散式物件模型的地基。原文放左邊,白話導讀放右邊。
不同程序之間的物件互相呼叫方法——不管在同一台電腦上還是不同電腦——都叫遠端方法呼叫;同一程序內的物件互相呼叫,就只是本機方法呼叫。
能接收遠端呼叫的物件,我們就叫它遠端物件。
遠端物件參考是一個識別子,在整個分散式系統裡都能用來指向某一個特定、獨一無二的遠端物件。
每個遠端物件都有一份遠端介面,上面寫明了它哪些方法可以被遠端呼叫。
「跨程序」=遠端呼叫;「同程序」=本機呼叫——這條界線一劃下去,後面所有機制(proxy、dispatcher、skeleton)都是為了「讓跨程序呼叫看起來還是像本機呼叫」而存在的。
設計遠端物件時,幾個你會踩到的坑
知道「遠端介面」跟「遠端物件」還不夠,實作起來還有幾個反直覺的細節:
遠端介面裡的方法才能被遠端呼叫;本機物件則可以呼叫遠端物件「所有」的方法(包括沒列在遠端介面裡的)。外人只能走對外窗口,內部人反而看得到全貌。
你不能用遠端呼叫去 new 一個遠端物件,因為遠端介面沒有建構子(constructor)。要建物件,得請一個既有的遠端物件用工廠方法(factory method)代為建立——就像請現有窗口幫你「開一個新窗口」。
物件 A 可以從物件 B 的回傳值裡拿到對物件 F 的遠端參考,接著直接去呼叫 F——參考本身可以在系統裡到處流動,不必每次都繞回原點。
遠端呼叫除了方法本身可能丟的例外,還可能因為「程序當機、太忙、訊息遺失」而失敗,所以必須能丟出像逾時這類因分散式而生的例外——呼叫端要準備好接。
把物件狀態逼到只能透過方法存取(不能再直接戳到內部欄位),跨程序這件事反而自然強化了封裝——避免了未授權的存取,也躲開某些並行衝突。麻煩事帶來的副作用,有時候是好事。
招牌互動:一次呼叫,到底繞了多遠
client 呼叫「看起來」是本機物件的 B,其實這通呼叫在背後繞了一大圈。按「下一步」,跟著這通呼叫走一遍完整路徑。
對 A 而言,全程就像身邊真的有個 B——這就是proxy(代理)帶來的透明性。proxy 長得跟 B 一模一樣(實作同樣的遠端介面),但它不會真的算,只是把要求包成訊息送到遠方,再把答案包裝成「我剛剛自己算出來的」交給你。
proxy 是自動生的,但也有它罷工的時候
dispatcher(分派器)與skeleton(骨架)不是手寫的——由介面編譯器自動產生,Java RMI 的 RMI 編譯器就是從遠端物件的類別直接生出 proxy、dispatcher、skeleton。
但靜態 proxy 有一個前提:client 在編譯期就要知道那個遠端介面長什麼樣。萬一不知道呢?比方說一個共享白板系統,要能顯示「編譯時還不存在的新圖形」——這種情況就得靠動態呼叫(dynamic invocation):不再靠靜態產生的 proxy,而是用通用的 doOperation 形式,client 直接把遠端參考、方法名稱、參數包好送出去。沒有 proxy 方便,但能應付「設計時根本無法預期」的介面。
雞生蛋蛋生雞的問題:要呼叫遠端物件得先有它的參考,但參考要從哪來?答案是binder(繫結器)——一個獨立服務,維護一張「文字名稱 → 遠端物件參考」的對應表:server 拿名字去註冊,client 拿名字去查。Java 裡這個角色就是 RMIregistry。
還有兩個實務細節值得記住。第一,server 通常為每個遠端呼叫配一條執行緒,避免一個呼叫卡住另一個——這意味著遠端物件的實作者必須自己處理並行存取下的狀態安全。第二,長壽的物件不必一直佔著執行中的程序:被動(passive)物件只是「方法實作+封裝過的狀態」,需要時才由 activator 把它啟動(activation)成可被呼叫的主動(active)物件——就像平時休眠、有客人上門才開張的店面,Java RMI 就支援這種 activatable 物件。
沒人用了才回收:分散式垃圾回收怎麼運作
物件在遠端持續存在,要花記憶體。分散式垃圾回收的目標很單純:只要還有任何本機或遠端參考指向某物件,它就繼續活著;一旦沒有任何人再持有指向它的參考,就把它與佔用的記憶體回收掉。
Java 的做法以參考計數(reference counting)為基礎,跟本機垃圾回收器協同運作。每個 server 為自己的每個遠端物件維護一個holders 集合——記錄「哪些 client 程序持有指向它的遠端參考」,例如 B.holders 就是持有 B 之 proxy 的 client 集合。
把遠端物件想成圖書館裡一本珍貴的書。每本書有一張借閱名冊(holders),記錄誰正借著它——有人來借(addRef)就登記上名冊,還書(removeRef)就劃掉。只要名冊上還有人,書就不能銷毀;名冊清空了,館員才能把書下架回收。addRef/removeRef 正是靠這一借一還,隨時維護著 holders 集合的正確性。
萬一讀者搬家失聯,書永遠下不了架怎麼辦?
借閱名冊解決了「正常歸還」的情況,但問題來了:萬一某位讀者搬家失聯(client 程序當機),永遠不還書怎麼辦?名冊上的名字會永遠掛著,書永遠下不了架。
解法是租約(lease):借書不是「無限期」,而是「借你到某個時間點」。租期從 client 發出 addRef 開始算,結束於時間到、或 client 主動發出 removeRef。到期不續借,館員就視同歸還,把名字劃掉;讀者若還想繼續借,必須在到期前主動續約。
addRef 與 removeRef 都設計成冪等(idempotent)。若 addRef(B) 回傳例外(代表它「執行了一次或完全沒執行」),client 就不建立 proxy,並補發一個 removeRef(B)——不管 addRef 是否真的成功,removeRef 的效果都是對的。另外還有個競態陷阱:若某 client 的 removeRef 和另一 client 的 addRef 幾乎同時到達,可能在 addRef 抵達前就把 holders 清空、誤刪了 B——解法是當遠端參考被傳出、而當下 holders 為空時,先加一個暫時項目撐到 addRef 真正到達。
Jini 把租約的概念推廣成任何物件提供資源給別人時都能附帶的通用機制,避免使用者失去興趣、或程式結束後資源仍被永久佔用。與 Java RMI 不同的是,Jini 的租期可以在授予者與接收者之間協商。
小試身手
從遠端物件、proxy 呼叫鏈,到租約如何救回失聯 client 的資源——來兩題檢查一下:
所有抽象概念都齊了——遠端物件、proxy、dispatcher、skeleton、holders、租約。最後一站,我們看它們怎麼變成真的能跑的程式碼。往下捲。
案例研究:Java RMI
Java RMI 把前面所有抽象概念,變成你真的能跑起來的幾行程式碼
先講規則:介面上要「老實承認」自己是遠端的
Java RMI 最厲害的地方,是把 Java 的物件模型直接延伸到分散式世界——你呼叫遠端物件的方法,語法跟呼叫本機物件幾乎一樣,連型別檢查都照樣管用。
但它並不打算把「這其實是遠端呼叫」這件事藏起來,反而刻意在介面上暴露分散式的本質,要求兩件事:
一、遠端介面要繼承 Remote(在 java.rmi 套件裡);二、介面裡的每個方法都必須宣告 throws RemoteException——這等於在合約上白紙黑字寫明「這次呼叫可能因故無法送達」,逼呼叫者事先正視、處理遠端失敗,而不是天真地假設遠端呼叫一定跟本機呼叫一樣穩。
本機方法呼叫幾乎不會無故失敗,但遠端呼叫要經過網路,對方可能當機、網路可能斷線。throws RemoteException 就是把這個風險寫進介面簽章,讓每個呼叫端在寫程式的當下就不得不面對它,不能假裝沒看到。
直接讀原文,旁邊就是白話
下面是課本定義兩個遠端介面 Shape、ShapeList 的原文片段,以及類別下載機制的說明。原文放左邊,白話導讀放右邊。
遠端介面的定義方式,就是繼承 java.rmi 套件裡一個叫 Remote 的介面。
介面裡的方法都必須宣告會丟出 RemoteException,當然也可以另外丟出應用程式自訂的例外。
任何實作了 Remote 介面的物件,一旦被序列化,就會被換成它的遠端物件參考,裡面帶著它的類別名稱。
一般(非遠端)物件當參數或回傳值時是傳值複製;遠端物件則是傳參考。
如果接收端本來就沒有那個「傳值物件」的類別,它的程式碼會自動下載過去。
同樣地,如果收到遠端物件參考的一方沒有對應 proxy 的類別,proxy 的程式碼也會自動下載。
參數型別是遠端介面(像 Shape)→ 傳的是遠端物件參考;參數型別是普通可序列化(Serializable)物件(像 GraphicalObject)→ 傳的是值的複製品。
生活妙喻:傳一份影本,還是傳一把鑰匙?
Java RMI 傳參數時,分兩種截然不同的方式,課本用一個很生活化的畫面幫你分清楚。
參數是可序列化的一般物件(如 GraphicalObject),就像把文件影印一份寄出去。對方拿到的是全新的獨立副本,他改他的、你改你的,兩邊從此互不影響。
參數型別是遠端介面(如 Shape),就像把一把遠端保險箱的鑰匙交給對方。對方拿到的不是箱子本體,而是能遠端打開那個箱子的鑰匙——他之後的每個操作,動的仍是「原本那個」遠端物件。
以值複製傳遞的物件,接收端得到的是獨立的新物件,之後雙方各自修改,狀態自然會分歧——這不是網路把資料「竄改」了,純粹是複製之後各走各的路。
實例拆解:共享白板的一次呼叫
用白板程式舉例最清楚。GraphicalObject 是可序列化、存放圖形狀態的一般物件;Shape、ShapeList 則是遠端介面。
public interface Shape extends Remote {
int getVersion() throws RemoteException;
GraphicalObject getAllState() throws RemoteException;
}
public interface ShapeList extends Remote {
Shape newShape(GraphicalObject g) throws RemoteException;
Vector allShapes() throws RemoteException;
int getVersion() throws RemoteException;
}
Shape s = shapeList.newShape(myGraphicalObject);
Shape 是一個遠端介面:只要繼承 Remote,方法都要能丟出 RemoteException。
getVersion 回傳一個普通整數,一樣要面對「可能失敗」。
getAllState 回傳 GraphicalObject——這是一般物件,會以值複製回傳。
收尾這個介面。
ShapeList 也是遠端介面,管理整塊白板上的所有圖形。
newShape 收一個一般物件當參數,卻回傳一個遠端介面 Shape。
allShapes 回傳目前白板上所有圖形的清單。
getVersion 回傳整份白板目前的版本號,client 可以用它判斷有沒有新圖形要抓。
收尾這個介面。
client 呼叫 newShape,把自己畫的圖形物件傳給 server。
這一行呼叫背後發生的事:myGraphicalObject 是一般可序列化物件,以值複製送到 server,server 端拿到的是一份新副本;server 用這份狀態建立一個型別為 Shape 的遠端物件,並把它的遠端物件參考回傳給 client。client 拿到的 s 之後每次呼叫(例如 s.getVersion()),都是真正打到 server 上那個物件的遠端呼叫。
newShape 一次示範了兩種規則:參數 GraphicalObject g(一般物件)傳值;回傳值 Shape(遠端介面)傳參考。判斷依據只看型別,不用去記例外情況。
Java 的招牌能力:類別自動下載
Java 設計上允許類別(class)從一個虛擬機下載到另一個虛擬機,這對遠端呼叫特別有用——接收端不一定要事先準備好每一個會用到的類別。
具體規則承接上一節的傳值/傳參考:如果接收端沒有某個「以值傳遞之物件」的類別,程式碼會自動下載;如果接收端拿到一個遠端參考,卻沒有對應 proxy 的類別,proxy 程式碼也會自動下載。
想像你網購一款全新型號的家具寄給朋友,他從沒見過這款、不知道怎麼組。沒有類別下載時,朋友收到零件卻沒有說明書,組不起來。有類別下載時,包裹裡附上組裝說明書(類別程式碼),朋友照著說明書就能把家具組好——即使他以前從沒見過這個型號。
白板範例正好示範了這件事:假設一開始的 GraphicalObject 不支援文字,某個 client 自己寫了一個處理文字的子類別,把它的實例當參數丟給 server。這個新類別的程式碼會自動從那個 client 下載到 server,再下載到其他需要的 client——沒有人需要事先約好都裝這個類別。
一、不必要求每位使用者的環境都預先放齊所有類別;二、client 與 server 都能透明地使用新增類別的實例——系統多了新功能,大家不用手動更新就用得到。
招牌互動:看 RMIregistry 怎麼幫 client 找到 server
client 要呼叫遠端物件,得先拿到至少一個遠端物件參考——這第一個參考是怎麼來的?答案是 RMIregistry——Java RMI 的 binder,維護一張「名稱 → 遠端物件參考」的對應表。按「下一步」看 server 怎麼註冊、client 怎麼查到它。
server 把自己「登記」到名錄上(rebind),client 拿著同一個名字去名錄「查號」(lookup)。兩邊只要約好同一個名稱字串,就不需要事先知道對方的網路細節。名稱格式是 //computerName:port/objectName,省略時預設用本機與預設連接埠。
lookup 拿到的東西,本質上跟 newShape 回傳的 Shape 是同一種東西——都是遠端物件參考,指向遠方那個唯一的物件本體。這跟一般物件當參數傳遞時被複製一份新物件,是徹底不同的兩條路:一個給你操作原物件的「鑰匙」,一個給你一份可以自己改的「影本」。
小試身手
從介面規則、傳值傳參考,到 RMIregistry 的註冊查詢,來兩題檢查一下:
Naming.rebind("//bruno/ShapeList", stub),這行程式碼做了什麼?這一章從最抽象的請求-回應協定與marshalling 講起,一路走到遠端方法呼叫(RMI)把這些底層機制包裝成「看起來像本機呼叫」的介面。Java RMI 正是這條路的終點示範:extends Remote 老實承認自己是遠端的,throws RemoteException 逼你面對失敗,傳值與傳參考的分野決定了物件是被複製還是被遠端操作,而 RMIregistry 這個 binder,替 client 與 server 牽起了第一條線。
從分散式物件的抽象模型,到 Java 把它具體實作成幾個介面、一個 registry——你已經看完了這一整章怎麼把「隔著網路呼叫」這件事,變得幾乎跟呼叫本機物件一樣自然。抽象概念落地成真正能編譯、能跑的程式碼,往往就是這麼幾行;下一章,我們會繼續往分散式系統的另一塊拼圖走去。