用 PHP Daemon 寫一個 HTTP Server

Written by Simon Asika on

111143969_35533831ab.jpg

(Image via)

今年 PHPConf 的一大亮點在於大家終於發現,原來 PHP 結合 Libevent 可以爆發出驚人的效能,把各大對手遠遠甩在腦後。事實上,早從 PHP4 開始,就已經有完整的 Socket 與 cli 環境支援,很容易就能編寫即時回應的 Comet Service 用在留言板之類的功能。

之所以沒有那麼普及,是因為採用 Socket 編寫 Web Server 相較於直接搞個 Ajax long polling 來說太過難懂,而且至今網路上對於 PHP Socket 的教學也不多。不過相關的一些函式庫其實也蠻多的了,例如 Socket.io, phpsocket.iophpDaemon ,這幾套都可以幫助我們寫出即時回應的伺服器事件回應。

會想要寫這篇,是因為最近剛好想要用 PHP 寫個背景應用程式(Daemon)來處理一些常駐服務,研究的過程發現,其實藉由 PHP CLI 介面,fork 兩次自己成為 Daemon 守護進程以後,就可以提供一個獨立於 Apache 之外簡單的 HTTP Server 功能。

先從 PHP 架構談起

PHP 本身的核心只管理語言處理,與 Web 沒有直接的關聯,他是依靠 SAPI 來與其他系統協同合作,例如 Apache 的 mod_php 、命令列的 php-cli 就是依靠 SAPI 見面來實現的,甚至還有 Embed 將 PHP 函式嵌入其他語言中使用的功能。

p2013-10-13-1.jpg

其中除了 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 也有可能是 -1 ,這代表 fork 失敗,不過我們這次的程式碼沒有做這一層判斷。

接下來子程序返回的 pid 因為是 0 ,會略過 if 繼續執行。

然後我們運用 posix_setsid() 將這個子程序設為 Session Leader,讓 terminal 對我們的程序保有控制權。這一步牽涉到 Unix 的核心 process 處理原理,我也只是略懂,可以參考此篇文章的說明: Linux進程關係

大抵上,session 是控制終端 (control terminal) 對程序之間的連結,只要 session 存在,就不會從 terminal 中脫離,我們也隨時可以靠 Ctrl + C 終止程序,或者進行其他操作。我們的第三步是要讓程序脫離 terminal ,因此重複第一步的 fork,再複製第二個子程序後,讓第一個子程序(現在也是父程序了)終止,這是第二次脫殼。

由於我們不再將第二個子程序指派為 session leader ,這個程序就成為真正的 Daemon,不再能靠命令列控制了,而命令列也因為第一與第二個父程序都終止的關係,結束執行階段,可以再次輸入新指令了。

這也是為什麼第一次脫殼時,父程序需要用 pcntl_wait() 等待第一個子程序結束才跟著結束的原因,否則立刻結束的話,session leader 尚未換給子程序,命令列就無法控制子程序的行為了。

最後,兩次脫殼成功,我們的 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

p2013-10-13-2.jpg

的確出現兩個 httpserver.php 在背景中。

要刪除 Daemon 就直接輸入 kill {pid},例如: kill 6614

CURL 測試

接下來再用 CURL 發出請求看看:

$ curl -i http://localhost:9999

$ curl -i http://ocean2.asika.tw:9999

應該會出現:

p2013-10-13-3.jpg

瀏覽器

直接打開瀏覽器輸入網址吧:

p2013-10-13-5.jpg

p2013-10-13-6.jpg

結語

相較於 Node.js,這個 HTTP Server 其實非常簡陋,只能回應固定的訊息。作為 Daemon,我們也應該要 chroot 至 / 目錄,並產生 log 記錄才對。但這個 demo 算是說明了 PHP Daemon 的編寫原理,也確實實作了獨立於 Apache 之外的Web Service,至於後續怎麼玩,就交給各位自行延伸了。

可惜的是,Windows 沒辦法跑這些程式碼XD

參考資源

Control Tools

WS-logo