API與協議
在我們編寫一個軟件模塊的時候,我們需要描述如何使用它。有一種做法就是爲模塊所有的導出函數定義一套編程語言API。爲了做到這一點,我們可以用3.9節提到過的類型系統。
定義API的方法其實是很普遍的。不同的語言之間,類型符號的細節有所不同,不同的系統之間,底層的語言實現對於類型系統的要求的強制性程度也不一樣。如果對類型系統有嚴格的強制要求,那麼這種語言就被稱爲是“強類型的”(strongly typed),否則它就被稱爲“弱類型的”(untyped)——這一點經常會引起混淆,因爲許多要求進行類型聲明的語言它的類型系統是很容易被違反的。Erlang語言不要求類型聲明,但是是“類型安全”(type safe)的,意思是不能以一種會破壞系統的方式違反底層類型系統。
即使我們的語言不是強類型的,但是類型聲明可以作爲一種有價值的文檔,而且可以作爲一個動態類型檢查器的輸入,動態類型檢查器能夠用來進行運行時類型檢查。
不幸的是,只按照慣常的方式寫出API的對於理解程序的行爲是不夠的。例如,看下面的代碼片斷:
silly() ->
{ok, H} = file:open("foo.dat", read),
file:close(H),
file:read_line(H).
按照類型系統的要求和3.9節的例子中給出的API,這段程序是完全合法的。但是它明顯是完全沒有意義的,因爲我們不可能期望從一個已經關閉了的文件中讀取東西。
爲了改正上面的問題,我們可以添加一個額外的狀態參數。輔以一種相當明瞭的符號,關於文件操作的API可以這樣寫:
+type start x file:open(fileName(), read | write) ->
166
{ok, fileHandle()} x ready
| {error, string()} x stop.
+type ready x file:read_line(fileHandle()) ->
{ok, string()} x ready
| eof x atEof.
+type atEof | ready x file:close(fileHandle()) ->
true x stop.
+type atEof | ready x file:rewind(fileHandle()) ->
true x ready
這種API模型用了四種狀態變量:start, ready, atEof和stop。狀態start表示文件還沒有被打開。狀態ready表示文件已經準備好被讀取,atEof表示到了文件的結尾。文件操作總是以start狀態開始,而以stop狀態終止。
現在API就可以這麼解釋了,例如,當文件處於狀態ready是,進行file:read_line的函數操作是合法的。它要麼返回一個字符串,這時候它仍然處於ready狀態;或者它返回eof,此時它處於atEof狀態。
在atEof狀態的時候,我們可以關閉文件或回倒(rewind)文件,所有其他的操作都是非法的。如果我們選擇回倒文件,那麼文件將重新回到ready狀態,這時候read_line操作就又變得合法了。
爲API增加了狀態信息,就爲我們提供了一種判定一系列操作是否與模塊的的設計相吻合的方法。
9.1 協議
可見我們可以標定一套API的使用順序,其實同樣的思想也可以應用到協議的定義上。
假設有兩個部件使用純消息傳遞的方式進行通信,我們要能夠在某一個抽象層次說明一下這兩個部件之間流動的消息的協議。
167
兩個部件A和B之間的協議P可以用一個非確定的有限狀態機(non-deterministic finite state machine)來描述。
假設進程B是一個文件服務器,而A是一個要使用這個文件服務器的客戶程序,進一步假設會話是面向連接的。那麼文件服務器應當遵循的協議可以按如下方式來說明:
+state start x {open, fileName(), read | write} ->
{ok, fileHandle()} x ready
| {error, string()} x stop.
+state ready x {read_line, fileHandle()} ->
{ok, string()} x ready
| eof x atEof.
+state ready | atEof x {close, fileHandle()}->
true x stop.
+state ready | atEof x {rewind, fileHandle()) ->
true x ready
這個協議描述的意思是,如果文件服務器處於start狀態,那麼它就可以接收{open, filename(), read|write}這種類型的消息,文件服務器的響應要麼是返回一個{ok, fileHandle()}類型的消息,並遷移到ready狀態,要麼是返回一個{error, string()}的消息,並遷移到stop狀態。
如果一個協議用類似上面的方式來描述,那麼就可能寫一個簡單的“協議檢查”程序,置於進行協議通信的兩個進程中間。圖9.1就展示了在進程X和Y之間放一個協議檢查器C的情形。
168
圖9.1:兩個進程和一個協議檢查器
當X向Y發送一個消息Q(Q是一個詢問)時,Y會以一個響應R和一個新狀態S作爲迴應。值對{R, S}就可以用協議描述中的規則進行類型檢查了。協議檢查器C位於X和Y之間,根據協議描述對X和Y之間來往的所有消息進行檢查。
爲了檢查協議規則,檢查器就需要訪問服務器的狀態,這是因爲協議描述可能還有如下的條目:
+state Sn x T1 -> T2 x S2 | T2 x S3
在這種情況下,只觀察返回消息T2的類型並不足以區分服務器的下一個狀態是S2還是S3。
如果我們回憶一下圖4.3的簡單的通用服務器的例子,我們程序的主循環就可以是這樣的:
loop(State, Fun) ->
receive
{ReplyTo, ReplyAs, Q} ->
{Reply, State1} = Fun(State, Q),
Reply ! {ReplyAs, Reply},
loop(State1, Fun)
end.
這個主循環又可以很容易地改成:
loop(State, S, Fun) ->
receive
169
{ReplyTo, ReplyAs, Q} ->
{Reply, State1, S1} = Fun(State, S, Q),
Reply ! {ReplyAs, S1, Reply},
loop(State1, S1, Fun)
end.
這裏S和S1代表協議描述中的狀態變量。注意接口(即協議描述中使用的狀態變量的值)的狀態與服務器的狀態State是不同的。
進行了上面的修改後,通用服務器就徹底變成了一種允許安裝在客戶和服務器之間的動態協議檢查器了。
9.2 API還是協議?
前面我們展示瞭如何用兩種本質上相同的方式來做同樣的事情。我們可以在我們的編程語言上強加一個類型系統,或者我們可以在用消息傳遞方式通信的兩個部件之間強加一個契約檢查機制。這兩個方法中,我更喜歡契約檢查器這種方法。
第一個方面的原因跟我們系統的組織方式有關係。在我們的編程模型中,我們採用了獨立部件和純消息傳遞的方式。每個部件被當作是“黑盒子”,從黑盒子的外面,完全看不到裏面的計算是怎麼進行的。唯一重要的事情就是黑盒子的行爲是否遵循他的協議描述。
在黑盒子的內部,可能因爲效率或其他編程方面的原因,我們需要使用一些晦澀的編程方法,甚至違背所有的常識規則和好的編程實踐。但是隻要系統的外部行爲遵守了協議描述,就沒有絲毫關係。
只要簡單的擴展,協議說明就可以擴展成系統的非功能屬性。例如,我們可以向我們的協議描述語言中添加一個時間的概念,那麼我們就可以這樣來表達:
+type Si x {operation1, Arg1} ->
value1() x Sj within T1
| value2() x Sk after T2
意思是operation1應該在T1時間內返回一個value1()類型的數據結構,或在
170
T2時間後返回一個value2()類型的數據結構。
第二個方面的原因跟我們所做的工作在系統中的位置有關。在一個部件的外面放置一個契約檢查器決不干涉到該部件本身的構造,並且還給我們的系統增加或刪除各種自我測試手段提供了一種靈活的途徑。使得我們的系統可以進行運行時檢查,並以能以更多的方式進行配置。
9.3 交互部件系統
Erlang系統如何與外界通信呢?——當我們想要構建一個分佈式系統,而Erlang只是許多交互部件中的一個時,這個問題就變得很有意思了。在參考文獻[35]中我們看到:
任何PLITS系統都是建立在模塊(module)和消息(message)這兩種基本構件之上的。模塊是一種自包含(self-contained)的實體,就如同Simula或Smalltalk中的類、SAIL進程、CLU模塊一樣。模塊本身用什麼編程語言來編碼並不重要,我們希望做到不同的機器上的不同模塊完全可以用不同的語言來編寫。——[35]
爲了做一個交互部件系統,我們必須使得不同部件在許多方面達成一致,我們需要: