PHP で Server::Starter を使ってみた

さっき Server::Starter というものを知ったので PHP でも使ってみました（そもそも PHP で TCP サーバなんて作らないというツッコミは無しの方向でお願いしますm(__)m）。

 - [私家版のgoでホットデプロイの仕組み、もしくは椅子もマサカリも投げられたくないときの気遣い : D-7 &lt;altijd in beweging&gt;](http://lestrrat.ldblog.jp/archives/38601017.html "私家版のgoでホットデプロイの仕組み、もしくは椅子もマサカリも投げられたくないときの気遣い : D-7 &lt;altijd in beweging&gt;")
 - [Server::Starterから学ぶhot deployの仕組み - $shibayu36-&gt;blog;](http://shibayu36.hatenablog.com/entry/2012/05/07/201556 "Server::Starterから学ぶhot deployの仕組み - $shibayu36-&gt;blog;")

# Server::Starter をインストール

Server::Starter をインストールします。

自分でも、いつ、どうやって入れたのか覚えていないのですが cpanm というコマンドが使えたので cpanm で入れました。

Test::SharedFork は、理由はわかりませんがこいつがいないと Server::Starter のインストールに失敗したので入れました。

```console
$ cpanm Test::SharedFork
$ cpanm Server::Starter
```

# PHP のコード

次のようになりました。

はじめに書いていたコードは例として適切ではなかったので、シングルプロセス・シングルスレッドで複数の接続を捌くように書き換えました。

このコードでは TERM/INT/HUP シグナルを受けてもすぐには終了せず、listen ソケットだけ閉じてすべての接続が切れるのを待ってから終了するようになっています。

```php:server.php
<?php
(new Server())->main();

class Server
{
    private $pid;
    private $listen;
    private $port;
    private $sockets = [];
    private $buffers = [];

    public function __construct()
    {
        $this->pid = getmypid();
        list ($this->port, $this->listen) = $this->parseServerStarterEnv();
        $this->sockets[(int)$this->listen] = $this->listen;
    }

    private function log($id, $message)
    {
        if ($id === null)
        {
            echo "[$this->pid] $message\n";
        }
        else
        {
            echo "[$this->pid($id)] $message\n";
        }
    }

    private function parseServerStarterEnv()
    {
        $pair = getenv('SERVER_STARTER_PORT');

        if (strlen($pair) === 0)
        {
            throw new \RuntimeException("!!! getenv");
        }

        list ($port, $desc) = explode("=", $pair);

        if (!ctype_digit($port) || !ctype_digit($desc))
        {
            throw new \RuntimeException("!!! env $pair");
        }

        $fp = fopen("php://fd/$desc", "r+");

        if ($fp === false)
        {
            throw new \RuntimeException("!!! fopen");
        }

        $listen = socket_import_stream($fp);

        if ($listen == false)
        {
            throw new \RuntimeException("!!! import");
        }

        return [$port, $listen];
    }

    public function main()
    {
        pcntl_signal(SIGTERM, function () { $this->sigterm(); });
        pcntl_signal(SIGHUP, function () { $this->sigterm(); });
        pcntl_signal(SIGINT, function () { $this->sigterm(); });

        $this->log(null, "listen $this->port");

        while (count($this->sockets))
        {
            $this->process();
            pcntl_signal_dispatch();
        }
    }

    private function sigterm()
    {
        $this->log(null, "close listen socket");

        foreach ($this->sockets as $socket)
        {
            if ($socket === $this->listen)
            {
                $this->close($this->listen);
            }
        }
    }

    private function process()
    {
        list ($r, $w) = $this->select();

        foreach ($w as $socket)
        {
            $this->write($socket);
        }

        foreach ($r as $socket)
        {
            if ($socket === $this->listen)
            {
                $this->accept($socket);
            }
            else
            {
                $this->read($socket);
            }
        }
    }

    private function select()
    {
        $r = $this->sockets;
        $w = [];
        $e = [];

        foreach ($this->buffers as $id => $buf)
        {
            if (strlen($buf))
            {
                $w[$id] = $this->sockets[$id];
            }
        }

        $ret = @socket_select($r, $w, $e, null);

        if ($ret === false)
        {
            $errno = socket_last_error();

            if ($errno !== SOCKET_EINTR)
            {
                $errstr = socket_strerror($errno);
                throw new \RuntimeException("!!! select ... $errstr ($errno)");
            }
            return [[], []];
        }

        return [$r, $w];
    }

    private function accept($socket)
    {
        $peer = socket_accept($socket);

        if ($peer === false)
        {
            throw new \RuntimeException("!!! accept");
        }

        $id = (int)$peer;
        $this->log($id, "accept");

        socket_set_nonblock($peer);

        $this->sockets[$id] = $peer;
        $this->buffers[$id] = '';
    }

    private function write($socket)
    {
        $id = (int)$socket;
        $buf = &$this->buffers[$id];

        $len = socket_write($socket, $buf, strlen($buf));

        if ($len === false)
        {
            $this->log($id, "!!! write");
            $this->close($socket);
        }
        else
        {
            $buf = substr($buf, $len);
        }
    }

    private function read($socket)
    {
        $id = (int)$socket;
        $tmp = socket_read($socket, 4096);

        if ($tmp === false)
        {
            $this->log($id, "!!! read");
            $this->close($socket);
        }

        if (strlen($tmp) === 0)
        {
            $this->log($id, "goodbye");
            $this->close($socket);
        }
        else
        {
            $this->buffers[$id] .= strtoupper($tmp);
            //$this->buffers[$id] .= ucfirst($tmp);

            $tmp = trim($tmp);
            $this->log($id, "recv $tmp");
        }
    }

    private function close($socket)
    {
        $id = (int)$socket;
        socket_close($socket);
        unset($this->sockets[$id]);
        unset($this->buffers[$id]);
    }
}
```

# 実験

`start_server` コマンドで起動します。

```console:ターミナルその１
$ start_server --port 1234 -- php server.php
start_server (pid:2325) starting now...
starting new worker 2326
[2326] listen 1234
```

別のターミナルを開いて 1234 ポートに接続して "abc" と入力すると大文字になってエコーされます。

```console:ターミナルその２
$ nc localhost 1234
abc
ABC
```

read メソッドの中のコメントを入れ替えて、先頭の文字だけが大文字になるように改修します。

```php:server.php
            //$this->buffers[$id] .= strtoupper($tmp);
            $this->buffers[$id] .= ucfirst($tmp);
```

既に起動しているプロセスには影響しないので "xyz" と入力しても全部大文字になります。

```console:ターミナルその２
$ nc localhost 1234
abc
ABC
xyz
XYZ
```

別のターミナルを開いて Server::Starter に HUP シグナルを送ります。

```console:ターミナルその３
$ kill -HUP 2325
```

Server::Starter が新しいプロセスを起動し、古いプロセスに TERM シグナルを送ります。

古いプロセスは TERM シグナルを受けてもすぐには終了せず、listen ソケットだけ閉じた後、すべての接続が切れるまで待っています。

```console:ターミナルその１
received HUP (num_old_workers=0)
spawning a new worker (num_old_workers=0)
starting new worker 2390
[2390] listen 1234
new worker is now running, sending TERM to old workers:2326
sleep 0 secs
killing old workers
[2326] close listen socket
```

改めて 1234 ポートに接続すると新しいプロセスに接続されるため "abc" と入力すると先頭だけ大文字になります（古いプロセスではもう accept していないためです）。

```console:ターミナルその３
$ nc localhost 1234
abc
Abc
```

はじめに接続していた方も、まだ古いコードのまま生きています。

```console:ターミナルその２
$ nc localhost 1234
abc
ABC
xyz
XYZ
ore
ORE
```

ターミナルその２で Ctrl+C で接続を切ると古いコードのプロセスは終了します。

```console:ターミナルその２
$ nc localhost 1234
abc
ABC
xyz
XYZ
ore
ORE
^C
$ 
```

```console:ターミナルその１
old worker 2326 died, status:0
```