(Image via)
今年 PHPConf 的一大亮點在於大家終於發現,原來 PHP 結合 Libevent 可以爆發出驚人的效能,把各大對手遠遠甩在腦後。事實上,早從 PHP4 開始,就已經有完整的 Socket 與 cli 環境支援,很容易就能編寫即時回應的 Comet Service 用在留言板之類的功能。
之所以沒有那麼普及,是因為採用 Socket 編寫 Web Server 相較於直接搞個 Ajax long polling 來說太過難懂,而且至今網路上對於 PHP Socket 的教學也不多。不過相關的一些函式庫其實也蠻多的了,例如 Socket.io, phpsocket.io 與 phpDaemon ,這幾套都可以幫助我們寫出即時回應的伺服器事件回應。
會想要寫這篇,是因為最近剛好想要用 PHP 寫個背景應用程式(Daemon)來處理一些常駐服務,研究的過程發現,其實藉由 PHP CLI 介面,fork 兩次自己成為 Daemon 守護進程以後,就可以提供一個獨立於 Apache 之外簡單的 HTTP Server 功能。
先從 PHP 架構談起
PHP 本身的核心只管理語言處理,與 Web 沒有直接的關聯,他是依靠 SAPI 來與其他系統協同合作,例如 Apache 的 mod_php
、命令列的 php-cli
就是依靠 SAPI 見面來實現的,甚至還有 Embed 將 PHP 函式嵌入其他語言中使用的功能。
其中除了 Web 以外,就屬 CLI 命令列架構是最常使用到的 SAPI 架構了,而 PHP 的 CLI 介面在搭配 Unix 的 pcntl 和 posix 模組之後,就能夠擁有操作 process 進程等等核心系統的管理能力,讓我們可以實現運用 PHP 寫 Deamon 守護進程的功能。
不知道什麼是 Daemon 的可以看看 Wiki 上的簡介:
守護行程(daemon) 是指在UNIX或其他多任務作業系統中在後台執行的電腦程序,並不會接受電腦用戶的直接操控。此類程序會被以行程的形式初始化。守護行程程序的名稱通常以字母「d」結尾:例如,syslogd就是指管理系統日誌的守護行程。
通常,守護行程沒有任何存在的父行程(即PPID=1),且在UNIX系統行程層級中直接位於init之下。守護行程程序通常通過如下方法使自己成為守護行程:對一個子行程調用fork,然後使其父行程立即終止,使得這個子行程能在init下執行。這種方法通常被稱為「脫殼」。
由於 Daemon 是不受使用者控制的系統服務,我們可以使用他來進行一些自動化功能,例如系統檢測、自動排程等等功能,大多數名字有 d 結尾的程式都是 Daemon,所以我們常見的 httpd, mysqld, ftpd 等等都是 Daemon。既然 PHP 也能寫 Daemon ,某種程度來說,不會 C 語言的開發者也可以依賴 PHP 來處理一些系統級別的任務了。
PHP Daemon 範例
我用 PHP Socket 監聽的功能,搭配 Daemon 來寫一個簡易的 HTTP Server ,讓 PHP 在 9999
port 上可以略過 Apache 直接回應我們想要的訊息,假設我在一個 httpserver.php
中編寫以下程式碼:
<?php
/**
* A PHP socket server demo file.
*
* @author Asika
* @email asika@asikart.com
* @date 2013-10-12
*
* @copyright Copyright (C) 2013 - Asika.
* @license GNU General Public License version 2 or later; see LICENSE
*/
/**
* Daemon Application.
*/
class DaemonHttpApplication
{
/**
* Socket address.
*
* @var string
*/
protected $domain;
/**
* Socket port.
*
* @var int
*/
protected $port;
/**
* Max backlog
*
* @var int
*/
protected $maxBacklog = 16;
/**
* Set domain.
*
* @param string $domain Your http server domain.
*
* @return DaemonHttpApplication Return self to support chaining.
*/
public function setDomain($domain)
{
$this->domain = $domain;
return $this;
}
/**
* set port
*
* @param int $post The port which socket listened.
*
* @return DaemonHttpApplication Return self to support chaining.
*/
public function setPort($port)
{
$this->port = $port;
return $this;
}
/**
* Create a daemon process.
*
* @return DaemonHttpApplication Return self to support chaining.
*/
public function execute()
{
// Create first child.
if(pcntl_fork())
{
// I'm the parent
// Protect against Zombie children
pcntl_wait($status);
exit;
}
// Make first child as session leader.
posix_setsid();
// Create second child.
if(pcntl_fork())
{
// If pid not 0, means this process is parent, close it.
exit;
}
// Create Http server
$this->createHttpServer();
return $this;
}
/**
* Create a http service.
*
* @return DaemonHttpApplication Return self to support chaining.
*/
protected function createHttpServer()
{
// Set response body
$response = <<<RES
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
Hello World~~~!!!
</body>
</html>
RES;
// Count text length
$responseLength = strlen($response);
// Create socket.
if(!($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)))
{
echo "Create socket failed!\n";
exit;
}
// Bind socket
if(!(socket_bind($socket, $this->domain, $this->port)))
{
echo "Bind socket failed!\n";
exit;
}
// Listen socket
if(!(socket_listen($socket, $this->maxBacklog)))
{
echo "Listen to socket failed!\n";
exit;
}
// Infinity loop for listening
while(true)
{
$acceptSocket = socket_accept($socket);
if(!$acceptSocket)
{
continue;
}
else
{
socket_write($acceptSocket, $response, $responseLength);
socket_close($acceptSocket);
}
} // End while
}
}
// Execute
// ---------------------------------------------
if(empty($_SERVER['argv'][1]))
{
fwrite(STDERR, "Missing argument 1, please provide a domain address.\n");
die;
}
$http = new DaemonHttpApplication();
$http->setDomain($_SERVER['argv'][1])
->setPort(9999)
->execute();
程式解說
execute()
execute()
是產生 Daemon 的核心功能,程式片段如下:
// Create first child.
if(pcntl_fork())
{
// I'm the parent
// Protect against Zombie children
pcntl_wait($status);
exit;
}
// Make first child as session leader.
posix_setsid();
// Create second child.
if(pcntl_fork())
{
// If pid not 0, means this process is parent, close it.
exit;
}
// Create Http server
$this->createHttpServer();
當主程序啟動後,我們運用 pcntl_fork()
來複製一份自己,fork 之後會返回一個 pid 值。此時父程序與子程序都同樣由這一行繼續跑下去,父程序會取得子程序的 pid,而子程序則返回 0 。因此父程序會進到 if block 內,並且終止自己,這是第一次脫殼。
接下來子程序返回的 pid 因為是 0 ,會略過 if 繼續執行。
然後我們運用 posix_setsid()
將這個子程序設為 Session Leader,讓 terminal 對我們的程序保有控制權。這一步牽涉到 Unix 的核心 process 處理原理,我也只是略懂,可以參考此篇文章的說明: Linux進程關係。
大抵上,session 是控制終端 (control terminal) 對程序之間的連結,只要 session 存在,就不會從 terminal 中脫離,我們也隨時可以靠 Ctrl + C 終止程序,或者進行其他操作。我們的第三步是要讓程序脫離 terminal ,因此重複第一步的 fork,再複製第二個子程序後,讓第一個子程序(現在也是父程序了)終止,這是第二次脫殼。
由於我們不再將第二個子程序指派為 session leader ,這個程序就成為真正的 Daemon,不再能靠命令列控制了,而命令列也因為第一與第二個父程序都終止的關係,結束執行階段,可以再次輸入新指令了。
最後,兩次脫殼成功,我們的 Daemon 變態成蟲了!? 於是執行 $this->createHttpServer()
來啟動 HTTP Server
createHttpServer()
Socket 的部分則不特別贅述(因為好晚了...),大抵上可以參考 PHP Socket 教學,也就是創建 Socket、綁定、監聽,之後透過無窮迴圈來處理每一次的 request。這是一個非常簡單的同步阻塞式 Server,沒有什麼異步非阻塞等超能力。
執行
我把這兩個 method 包在 DaemonHttpApplication
類別中,最後只要執行這段程式碼:
$http = new DaemonHttpApplication();
$http->setDomain('localhost')
->setPort(9999)
->execute();
然後在命令列輸入:
$ php httpserver.php
就可以監聽 localhost:9999 的請求了。
為了讓執行時可以方便監聽多個網域,我們改用參數來輸入 Domain:
// 判斷用戶有輸入網域不然返回錯誤訊息
if(empty($_SERVER['argv'][1]))
{
fwrite(STDERR, "Missing argument 1, please provide a domain address.\n"); // 驕傲的 Geek 應該要用 STDERR 怎麼可以直接 echo
die;
}
$http = new DaemonHttpApplication();
$http->setDomain($_SERVER['argv'][1])
->setPort(9999)
->execute();
這樣輸入就可以了:
$ php httpserver.php ocean2.asika.tw
另外要注意,如果你執行時,總是出現這個錯誤: PHP Warning: socket_bind(): unable to bind address [98]: Address already in use
,這表示與 Apache 正在監聽的位址有所衝突,建議你改用一個已經指向到這台主機,且尚未被 VirtualHost 註冊 ServerName 的網域來測試。
若是 PHP Warning: socket_bind(): unable to bind address [99]:
表示 port 被占用,換一個吧。
測試
用 ps 檢查進程
我實際上輸入以下兩組指令:
$ php httpserver.php localhost
$ php httpserver.php ocean2.asika.tw
如果都沒有報錯的話,應該就進入背景服務了。執行以下指令看看結果:
$ ps -ef | grep http
的確出現兩個 httpserver.php
在背景中。
CURL 測試
接下來再用 CURL 發出請求看看:
$ curl -i http://localhost:9999
或
$ curl -i http://ocean2.asika.tw:9999
應該會出現:
瀏覽器
直接打開瀏覽器輸入網址吧:
結語
相較於 Node.js,這個 HTTP Server 其實非常簡陋,只能回應固定的訊息。作為 Daemon,我們也應該要 chroot 至 / 目錄,並產生 log 記錄才對。但這個 demo 算是說明了 PHP Daemon 的編寫原理,也確實實作了獨立於 Apache 之外的Web Service,至於後續怎麼玩,就交給各位自行延伸了。
可惜的是,Windows 沒辦法跑這些程式碼XD