macOS の PHP で空きポートを取得する（ランダムな未使用ポート検索）

PHP で**ランダムな空きポート番号（未使用のポート番号）を取得するスクリプト**です。PHP の[ビルトイン・ウェブサーバ](https://www.google.co.jp/search?q=site%3Aqiita.com+php+%E3%83%93%E3%83%AB%E3%83%88%E3%82%A4%E3%83%B3%E3%82%B5%E3%83%BC%E3%83%90+%E3%82%A6%E3%82%A7%E3%83%96%E3%82%B5%E3%83%BC%E3%83%90)のポート指定時の**空きポート探しなどに**ご利用ください。

なお「タイプ２」のスクリプトは PurePHP なので **Windowsでも動作すると思います**。

また、Mac 用ですが `$ dev chrome index.php` とすると、ランダムな空きポートでビルトイン Web サーバーが立ち上がり、[指定したブラウザで動作検証できるスクリプト](#%E6%9C%80%E7%B5%82%E7%9A%84%E3%81%AA%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88)を用意しました。

- **OS:** macOS HighSierra（OSX 10.13.5, 10.13.6）
- **PHP:** v7.2.6（CLI）
- 関連記事「[macOS の bash で空きポートを取得する（ランダムな未使用ポート検索）](https://qiita.com/KEINOS/items/98a5ce4415b3691a0d22)」 @ Qiita

## TL;DR（PHP で使用中のポートを元に空きポートを探す）

### タイプ１（外部コマンド利用タイプ）

シェルの [`lsof` コマンド](https://ja.wikipedia.org/wiki/Lsof)で使用中のポートを取得し、そこからランダムな未使用ポートを返します。

[`netstat` コマンド](https://ja.wikipedia.org/wiki/Netstat)でもよかったのですが、他のプロセスで使っていたビルトインサーバのポートなど、表示されないポートがあったので `lsof` にしました。

```php:タイプ１.php
function getPortRandom($port_min = 50000, $port_max = 65535)
{
    $cmd   = "lsof -i -P | grep -i 'tcp' | sed 's/\[.*\]/IP/' \
                   | sed 's/:/ /' | sed 's/->/ /'| awk -F' ' '{print $10}' \
                   | awk '!a[$0]++'";
    $ports = array_filter(explode(PHP_EOL, `$cmd`));

    while (true) {
        $port = rand($port_min, $port_max);
        if (false === array_search($port, $ports)) {
            break;
        }
    }
    return $port;
}
```

<details><summary>2018/08/23 追記：`$cmd` 行をより正確に表示するように変更しました。</summary><div>

```shell:before
lsof -i -P | grep -i 'listen' 2>&1
```
```shell:after
lsof -i -P | grep -i 'tcp' | sed 's/\[.*\]/IP/' \
                   | sed 's/:/ /' | sed 's/->/ /'| awk -F' ' '{print $10}' \
                   | awk '!a[$0]++'
```

以前は IPv4 の `listen` 行（監視対象のポート）だけを取得していたのですが、すでに接続済（`establish`）のポートと IPv6 も含めて、一意のポート一覧を取得するようにしました。

</div></details>



### タイプ２（ソケット利用タイプ）

こちらは PHP の標準機能を使ったタイプです。ソケットを作成（`socket_create_listen()`）する際に引数に「`0`」を渡すと**空きポートをランダムに自動で割り当てられたポートを使うタイプ**です。ビルトイン（標準）関数であるため、おそらく Windows でも利用可能かと思います。

前述の「外部コマンド利用のタイプ１」と比べ圧倒的に速いのですが「ネットワークの受信接続の許可」ポップアップが一瞬表示されるため、連続して何度か使うにはウザいタイプです。

```php:タイプ２.php
function getPortRandom()
{
    $socket = socket_create_listen(0);         //ランダムな空きポートの割り当て
    socket_getsockname($socket, $addr, $port); //ポート情報の取得
    socket_close($socket);                     //ソケットを解放

    return (int) $port;                        //解放されたソケットのポート番号を横取り
}
```

---

## TS;DR

### だって夏だから

Mac で PHP スクリプトの動作確認をするために Web サーバを建てるのも面倒なので、ビルトインサーバで検証していました。

しかし、そのたびに「空いているポート何かな」と悩むのが面倒になったのです。[ウェルノウン・ポート](https://ja.wikipedia.org/wiki/TCP%E3%82%84UDP%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E3%83%9D%E3%83%BC%E3%83%88%E7%95%AA%E5%8F%B7%E3%81%AE%E4%B8%80%E8%A6%A7#%E3%82%A6%E3%82%A7%E3%83%AB%E3%83%8E%E3%82%A6%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88%E7%95%AA%E5%8F%B7_(0%E2%80%931023))なんかも、いちいち確認してられません。すべてが面倒なのですよ。暑いし。エアコン制限されてるし。`_(:⁍」 )_`

```shell-session:ターミナル
$ cd /path/to/my/php/script
$ ls
index.php
$ 
$ # ポート指定（このポートを決めるのが面倒）
$ PORT_TEMP=8888
$ 
$ # ブラウザ（Safari）とWebサーバ起動
$ open -a "Safari" "http://localhost:$PORT_TEMP" && php -S localhost:$PORT_TEMP
$ 
$ # ブラウザ（Chrome）とWebサーバ起動
$ open -a "Google Chrome" "http://localhost:$PORT_TEMP" && php -S localhost:$PORT_TEMP
```

### そうだ PHP で行こう

そこで、以前作成した [CentOS7 の SSH 接続用に Bash で空きポートをランダムに取得するシェル・スクリプト](https://blog.keinos.com/20180125_3145)を使おうと思ったのですが、macOS の Bash では標準で [`shuf`コマンド](http://www.atmarkit.co.jp/ait/articles/1705/25/news018.html)に対応していないなど、ストレートに使えそうにありませんでした。

[`brew`](https://www.google.co.jp/search?q=site:qiita.com+brew+homebrew+mac+osx) でインストールするのも環境依存するのでちょっと違うし、それならば **OS に標準で入っている PHP で取得すればいいじゃない**、と思い立ちました。

まずは PHP の標準コマンドで「使用中のポートを確認するコマンドはないか」と確認してみたのですが、該当するものはありませんでした。

しかし、マニュアルの記述には無いのですが、マニュアルのコメント欄を見ると「 [`socket_create_listen()` 標準関数の引数を空欄にするか `0` を渡すとランダムに空いているポートを割り当ててくれるよ](http://php.net/manual/ja/function.socket-create-listen.php#44187)」という記述が。

マニュアルだと第１引数のポート番号指定は必須になっているのですが、確かにランダムなポートが割り当てられたのです。

これでいける。

### ん？いま一瞬何か見えなかった・・・か？ :scream: 

`socket_create_listen()`関数でランダムに空きポートが割り当てられることが確認できたので、「PHP にランダムに空きポートを割り当てさせて、直後に解放させたら、そのポートが使えるんじゃね？」と思いつきました。

```shell-session:ターミナル
$ php -a
Interactive shell

php > $sock = socket_create_listen(0);
php > socket_getsockname($sock, $addr, $port);
php > socket_close($sock);
php > echo $port;
58487

```

「お、いけるかも」と関数化したのも束の間、あることに気づきました。なんか一瞬ピロピロっと表示されるのです。

あまりにも、一瞬すぎて何のメッセージが出ているのか分からなかったのですが、スクリプトを何度も叩くと確かに表示されているのです。

`sleep()` を入れてみたところ、ソケットを作成するタイミングで以下の確認メッセージが出ていました。ソケットを解放（`socket_close()`）すると消えるのですが、どうやらポートを変えまくってるからか、Mac のファイヤーウォールに怪しまれたようです。

<img width="532" alt="スクリーンショット 2018-07-02 0.38.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/78776/46eb6403-a129-18be-d6f7-78a90dfda11d.png">

まぁ、一回「許可」しておけば良いだけなのですが、**他の環境で他の人が使う場合に面倒**です。

「ポップアップが出たら許可する」ように案内するのも面倒ですし、マニュアルに書いても「よく分からないのでお断り申し上げる」と、ポチッと「拒否」されることは、まれによくあります。

それにより PHP 自体がロックされて「マニュアル通り作業しても動かないし、他のプログラムも動かなくなった！:angry:」というストーリーが走馬灯のように見えました。

そこで素直に外部コマンドでシェルを叩いて取得したプロセスを料理する方法が確実ということで、シェルと PHP の美味しいところどりをしてみました。

### 最終的なスクリプト

ターミナルで `index.php` のあるディレクトリに移動後、`$ dev chorme` と叩けば、そのディレクトリをルートとした WEB サーバと引数で指定したブラウザが起動するスクリプトにしました。（インストール済みのブラウザに限る）

```shell-sessionサーバの起動の仕方
$ cd /path/to/your/dev/dir
$ ls
index.php
$ dev opera
```

具体的には、以下のスクリプトのように [`shebang` 付きの PHP スクリプト](https://www.google.co.jp/search?q=site:qiita.com+shebang%E3%81%A8%E3%81%AF+mac)を、[パスの通ったディレクトリ](https://www.google.co.jp/search?q=site:qiita.com+mac+%E3%83%91%E3%82%B9%E3%82%92%E9%80%9A%E3%81%99+%22PATH%22)に `dev` というファイル名（拡張子なし）で設置して、`chmod 0744 dev`で実行権限を与えて、「俺様簡易検証サーバ・コマンド」の完了です。

```php:dev
#!/usr/bin/env php
<?php

$browser_default = 'safari';
$browser         = isset($argv[1]) ? strtolower($argv[1]) : $browser_default;

switch($browser){
    case 'chrome':
        $browser = 'Google Chrome';
        break;
    case 'firefox':
        $browser = 'Firefox';
        break;
    case 'opera':
        $browser = 'Opera';
        break;
    default:
        $browser = 'Safari';
        break;
}

$port = getPortRandom();
$cmd  = "open -a \"{$browser}\" \"http://localhost:{$port}\" ";
$cmd .= "&& php -S localhost:{$port}";

`$cmd`;

/* [Functions] -------------------------------------------------------------- */

function getPortRandom($port_min = 50000, $port_max = 65535)
{
    $cmd   = 'lsof -i -P | grep -i "listen" 2>&1';
    $rows  = array_filter(explode(PHP_EOL, `$cmd`));
    $ports = array_map(
        function ($row) {
            $row  = str_replace(':', ' ', $row);
            $cols = explode(' ', $row);
            $cols = array_values(array_filter($cols));
            return (int) $cols[9];
        },
        $rows
    );

    while (true) {
        $port = rand($port_min, $port_max);
        if (false === array_search($port, $ports)) {
            break;
        }
    }
    return $port;
}
```

2018/08/24 追記：[macOS 標準のシェル・コマンドで空きポートを取得するスクリプト](https://qiita.com/KEINOS/items/98a5ce4415b3691a0d22)を書いたので、それを利用してよりコードが短くなりました。

```php:タイプ１改訂版.php
function getPortRandom($port_min = 50000, $port_max = 65535)
{
    $cmd   = "lsof -i -P | grep -i 'tcp' | sed 's/\[.*\]/IP/' \
                   | sed 's/:/ /' | sed 's/->/ /'| awk -F' ' '{print $10}' \
                   | awk '!a[$0]++'";
    $ports = array_filter(explode(PHP_EOL, `$cmd`));

    while (true) {
        $port = rand($port_min, $port_max);
        if (false === array_search($port, $ports)) {
            break;
        }
    }
    return $port;
}
```

## 参考文献

- [PHP:socket_create_listen - Manual](http://php.net/manual/ja/function.socket-create-listen.php#44187) @ PHP.net
- [How can I find a list of used and unused ports with a MacBook?](https://www.quora.com/How-can-I-find-a-list-of-used-and-unused-ports-with-a-MacBook) @ Quora.com
- [Install shuf on OS X?](https://apple.stackexchange.com/questions/142860/install-shuf-on-os-x) @ StackExchange
- [`lsof`コマンドの検索結果一覧](https://www.google.co.jp/search?q=site:qiita.com+lsof+%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89) @ Qiita via Google