在 Web 應用上,當瀏覽器向伺服端索取資料,而資料尚未存在或尚未被輸入時,我們通常令伺服端回傳代表目前無資料的訊息,並告知使用者稍候再讀取。當 JavaScript 被帶入 Web 應用程式開發領域後,我們在客戶端設計上,便運用 JavaScript 在間隔一段時間後,主動地向伺服端查詢是否有資料可以讀取。由於這個動作是放在一個無窮迴圈中,令它反覆地向伺服端執行查詢動作,故而我們將之稱為「輪詢」(polling)。
然而這種普遍地解法,實際上是一種反模式(Anti-pattern),也就是把反面的例子當成正確的做法(當我正在思考該如何表達這種普遍地錯誤形式時,我剛好瞄到桌上的《Java AntiPattern》, Anti-pattern 正是貼切描述這種情形的詞語 )。在早期,瀏覽器的功能還很單純時,我們沒有別的選擇,只能使用這種輪詢式的解法。但隨著瀏覽器的功能強化與 Ajax 技術的普及,繼續使用輪詢式解法,就是反模式。正確地做法是,我們要在 Ajax 這種非同步設計模式中,導入同步 I/O 行為。在本文中,將說明如何運用 Blocking IO 解決 Ajax 設計中的輪詢反模式。
Polling anti-pattern
我先用 Ajax 設計一個輪詢反模式的範例。
本文所有程式碼,都是設定放置於 /workspace/blocking_io/ 的 URL 目錄下。實際操作時,請按你自己的 Web server 設定修改。
nonblocking_io_get.php
下列程式碼段落是伺服端內容。它的工作很簡單,當它被調用時,若有資料就讀出來回傳給瀏覽器,沒資料就回傳空資料。很典型的處理方式。
<?php
if ( file_exists ( '/tmp/nonblocking_io_get.txt' )) {
$s = file_get_contents ( '/tmp/nonblocking_io_get.txt' );
echo $s ;
}
else {
exit ( 0 );
}
?>
nonblocking_io_get.html
接著是瀏覽器(客戶端)的輪詢做法。
<html>
<head>
<meta http-equiv= "content-type" content= "text/html; charset=utf-8" >
<script type= "text/ecmascript" >
var board ;
function handler () {
if ( this . readyState == 4 && this . status == 200 ) {
if ( this . responseText . length > 0 ) {
board = document . getElementById ( ' board ' );
board . innerHTML = ' <pre class="language-text"> ' + this . responseText + ' </pre> ' ;
}
nonblocking_get ();
//alert('time out');
//setTimeout(nonblocking_get, 1000);
}
}
function nonblocking_get () {
var client = new XMLHttpRequest ();
client . onreadystatechange = handler ;
client . open ( " GET " , " /workspace/blocking_io/nonblocking_io_get.php " );
client . send ();
}
nonblocking_get ();
//setTimeout(nonblocking_get, 1000);
</script>
<style type= "text/css" >
.board {
border : 1px solid black ;
padding : 5px ;
margin : 5px ;
float : left ;
}
</style>
</head>
<body>
<div id= "board" class= "board" > Waiting (Nonblocking mode)</div>
</body>
</html>
客戶端使用 XMLHttpRequest 以非同步方式向伺服端輪詢資料。這種做法的最大問題就在於它絕大多數時候索取到的內容是空資料,於是它不停地產生新的 XMLHttpRequest 探詢資料,又繼續得到空資料。整個行程的執行週期都浪費在產生 XMLHttpRequest 與探詢資料上了。我們可以監看 CPU 的使用率觀察這點。如下圖所示,儘管什麼資料都沒拿到,瀏覽器仍然佔用了 70% 的 CPU 使用率。它全用在輪詢動作。
在這個反模式下,稍微聰明的做法是設定時間間隔,例如用 setTimeout()
,令程式每隔一段時間才執行下一次探詢動作。但它仍然沒有徹底地消除不必要的輪詢行為與其資源耗費。
Blocking IO
讓我們暫時放下 Web 程式,回到最基礎的終端機(文字模式)程式上。當我們的文字模式程式需要讀取使用者或另一端輸入的資料時,我們往往直覺地使用 read 方法,而且結果通常都如我們所願。編程初學者甚至不會注意到當程式執行到 read 方法,而資料來源尚無資料可讀取時,系統會發生什麼事?我們能夠用如此簡單的模式設計資料輸入動作的原因,在於作業系統預設的資料輸出入模式是 Blocking IO ,一種基本的同步輸出入模式。
在 Blocking IO 模式下,當設備內容中沒有資料可供讀取時,所有對此設備的資料讀取動作都會被作業系統擱置。直到有資料被寫入設備中時,作業系統才會主動喚醒被擱置的行程,讓它繼續執行資料讀取動作。這種處理策略在多工模式下更顯重要,作業系統介入輸出入過程中的行程等待行為,主動擱置與喚醒行程,有效地避免行程執行多餘地輪詢動作。對程序員而言,它也簡化許多設計工作。可閱讀《select() - I/O Multiplexer 》了解更多內容。
接下來我將以 POSIX Named Pipe (具名管線)為例,示範如何在伺服端運用 Blocking IO 機制,解決 Ajax 的輪詢反模式。
我在本文中採用 PHP5 設計伺服端程式。雖然 Windows 也支援 POSIX 所規範的 Named Pipe ,但是而 PHP5 Win32 的 POSIX 模組並不支援 Windows 的 Named Pipe 。所以本文的伺服端程式只能運作在 Unix 家族平台。
Under reading…
HTTP 協定基本上是以「文件」為處理單位。客戶端發出的每一次請求,就是要在一個連線動作中拿回一份文件。在一般的 Web 應用系統中,客戶端會持續從連線中讀取資料,當伺服端關閉連線時,客戶端就認為伺服端已經將文件內容完整送出了(如果伺服端有送出 Content-Length 標頭,那客戶端就以其指示的資料位元組數判斷文件是否完整 )。
由於 Web 應用系統是建構在 HTTP 協定之上,也就承繼了這種以「文件」為單位的處理策略。在以往,我們只關心文件完整取回後的工作。所以在 XMLHttpRequest 的事件處理方法中,我們總是把後續的資料處理工作放在 if (this.readyState == 4 && this.status == 200)
的段落中。如果你曾研讀過 XMLHttpRequest 規範 ,你當知道上述條件式代表著文件被完整讀取了。
但我們應當知道,文件並不是一瞬間就被伺服端送出,也不是一瞬間就被客戶端讀取。那麼在這段送出與讀取的時間中,發生了什麼事?答案是,伺服端會一塊一塊地送出資料,而客戶端也是一塊一塊地讀取資料。在 Ajax 中,客戶端指的是 XMLHttpRequest ,所以 XMLHttpRequest 將一塊一塊地讀取資料。每當它讀到一塊資料時,就會觸發一次 onreadystatechange
事件。此時 readyState 之值將被設定為 3;而新讀到的資料塊,將被添加到 responseText 的尾端。
blocking_io_get.php
下列的伺服端程式,讀取自己的內容,然後一行一行地輸出到客戶端。但是在每一行之間,停頓1秒,以便我們觀察 onreadystatechange
被觸發的現象。
<?php
$fh = fopen ( __FILE__ , 'r' );
while ( $buf = fgets ( $fh )) {
echo htmlentities ( $buf );
flush ();
sleep ( 1 );
}
fclose ( $fh );
?>
blocking_io_get.html
下列客戶端程式示範了在資料讀取過程中的 readyState == 3 的狀態。由於伺服端故意在傳送每行資料之間停頓一秒,所以我們可以很明顯地觀察到 onreadystatechange
被觸發的現象: 每隔一秒,頁面上的區塊內容就會增加一行。
<html>
<head>
<meta http-equiv= "content-type" content= "text/html; charset=utf-8" >
<script type= "text/ecmascript" >
function handler () {
if ( this . readyState == 4 && this . status == 200 ) {
alert ( this . status + ' : ' + this . responseText );
//blocking_get();
}
else if ( this . readyState == 3 ) {
//alert(this.status + ' : ' + this.responseText);
var board = document . getElementById ( ' board ' );
board . innerHTML = ' <pre class="language-text"> ' + this . responseText + ' </pre> ' ;
}
}
function blocking_get () {
var client = new XMLHttpRequest ();
client . onreadystatechange = handler ;
client . open ( " GET " , " /workspace/blocking_io/blocking_io_get.php " );
client . send ();
}
blocking_get ();
</script>
<style type= "text/css" >
.board {
border : 1px solid black ;
padding : 5px ;
margin : 5px ;
float : left ;
}
</style>
</head>
<body>
<div id= "board" class= "board" > Waiting</div>
</body>
</html>
Working with Pipe
我將設計一個模擬聊天室,它有一個頁面做為訊息顯示版面,它實際上是透過 XMLHttpRequest 調用伺服端的 board_get.php 取得新訊息。另一個頁面則是 board_post.php ,用於輸入與張貼訊息。board_get.php 和 board_post.php 是透過一個具名管線(Named Pipe)來傳遞訊息。 board_post.php 向管線寫入訊息,故我將它稱為管線的 poster ;board_get.php 自管線讀取訊息,故我將它稱為管線的 getter 。
board.html
下列是模擬聊天室的訊息顯示版面,我設定了2個發話者: rock 與 john。他們的發話內容各自顯示在自己的區塊上。
< html >
< head >
< meta http - equiv = " content-type " content = " text/html; charset=utf-8 " >
< script type = " text/ecmascript " >
function handler ( xhr , recipient ) {
if ( xhr . readyState == 4 && xhr . status == 200 ) {
//alert(xhr.status + ' : ' + xhr.responseText);
var board = document . getElementById ( recipient . name );
board . value = xhr . responseText + board . value ;
recipient . blocking_get ();
}
}
function Recipient ( name ) {
this . name = name ;
}
Recipient . prototype . blocking_get = function () {
var client = new XMLHttpRequest ();
var recipient = this ;
client . onreadystatechange = function () {
handler ( this , recipient );
};
client . open ( " GET " , " /workspace/blocking_io/board_get.php?name= " + this . name );
client . send ();
}
recipient1 = new Recipient ( ' rock ' );
recipient2 = new Recipient ( ' john ' );
recipient1 . blocking_get ();
recipient2 . blocking_get ();
< /script>
< style type = " text/css " >
. board {
border : 1 px solid black ;
padding : 5 px ;
margin : 5 px ;
float : left ;
}
< /style>
< /head>
< body >
< div class = " board " >
< label for = " rock " > rock say : < /label><br/ >
< textarea id = " rock " >< /textarea>
< /div>
< div class = " board " >
< label for = " john " > john say : < /label><br/ >
< textarea id = " john " >< /textarea>
< /div>
< /body>
< /html>
board_get.php
在這個模擬聊天室中,每一個發話者實際上配置一個具名管線。 getter 將會從管線上讀取發話者透過 poster 送來的訊息。藉由 Blocking IO 機制,當發話者尚未送來訊息前,它會被擱置在 fgets($fh)
處,等待系統喚醒。在擱置期間,此 HTTP 連線仍持續存在,而客戶端也會被擱置在等待接收資料處,不會觸發任何 XMLHttpRequest 事件。換句話說,伺服端與客戶端都處於閒置狀態,不會執行任何動作。
<?php
require_once 'board_init.php' ;
$fh = fopen ( PIPE , 'r' );
while ( $buf = fgets ( $fh )) {
echo $buf ;
}
fclose ( $fh );
?>
board_post.php
用瀏覽器開啟 poster ,它會顯示一個文字欄位表單供使用者輸入訊息。送出表單後,poster 就會開啟伺服端的具名管線,將訊息寫入管線。
<?php
require_once 'board_init.php' ;
if ( isset ( $_POST [ 'message' ]) and ! empty ( $_POST [ 'message' ])) {
$fh = fopen ( PIPE , 'a' );
fputs ( $fh , $_POST [ 'message' ]);
fclose ( $fh );
}
?>
<html>
<script type= "text/ecmascript" >
window . onload = function () {
document . getElementById ( ' message ' ). focus ();
}
</script>
<?php if ( isset ( $_GET [ 'name' ]) and ! empty ( $_GET [ 'name' ])) : ?>
<form method= "post" >
<label for= "message" > <?= $_GET [ 'name' ] ?> </label>
<input id= "message" name= "message" type= "text" />
<button type= "submit" > Send</button>
</form>
<?php else : ?>
<a href= " <?= $_SERVER [ 'REQUEST_URI' ] . '?name=rock' ?> " > Rock speaking...</a><br/>
<a href= " <?= $_SERVER [ 'REQUEST_URI' ] . '?name=john' ?> " > John speaking...</a><br/>
<?php endif ; ?>
</html>
board_init.php
下列為 getter 與 poster 共用的管線初始化程式。
<?php
if ( ! isset ( $_GET [ 'name' ]))
$_GET [ 'name' ] = '' ;
define ( 'PIPE' , '/tmp/board_fifo_' . $_GET [ 'name' ]);
if ( ! function_exists ( 'posix_mkfifo' )) {
echo "Your PHP environment does not support POSIX FIFO. \n " ;
exit ( 1 );
}
if ( ! file_exists ( PIPE )) {
if ( ! posix_mkfifo ( PIPE , 0666 )) {
echo "Create " . PIPE . " error \n " ;
exit ( 1 );
}
}
?>
結語
上列程式以精簡的方式展現了 Blocking IO 在 Ajax 設計上的應用,解決了輪詢反模式(Polling anti-pattern)問題。不過在實際應用上, Pipe 是有所不足的,我們通常要規劃另一種行程間通訊機制。以本文的模擬聊天室為例,它之所以是「模擬」的,就在於 Pipe 中的每一筆資料,只能被一個行程讀取一次而己,它不能把一筆訊息送給每一個等待中的行程。如果你開啟兩個訊息顯示頁面視窗,那麼你將發現 poster 送出的訊息,只會在其中一個顯示頁面中出現。
在我眼中,PHP 是最方便用於示範與驗證 Web 設計概念的工具。所以本文的伺服端程式,我選擇用 PHP 實作。但在應用上,我將用 Ruby on Rails 配合 Ruby-dbus 實作。用 Ruby 撰寫一個 D-Bus service 取代 Pipe 。 getter 將會是此 D-Bus service 的 signal recipient , poster 則負責發送此 D-Bus service signal 。有興趣了解 Ruby-dbus 的讀者,可閱讀《Write a D-Bus service by Ruby 》。
相關文章
樂多舊網址: http://blog.roodo.com/rocksaying/archives/12010463.html