🌦️

Compose V2時代に複数PHPバージョンのエクステンション開発

2023/07/07に公開

この記事はリンケージ×PR TIMES合同勉強会の発表内容を文章にしたものです。

背景

PHPerKaigi 2023 で発表しました PHPの配列とデータ構造 に関して情報を集めていくに当たり、 PHP の配列は PHP ≦ 8.1 と PHP ≧ 8.2 で実装が異なることに気づきました。

https://speakerdeck.com/meihei3/phperkaigi-2023?slide=45

PHP ≧ 8.2 からの新しい実装は、「Use more compact representation for packed arrays.」という Pull Request で議論・実装されており、PHP 7 以降で取り入れられた Packed Arrays (連続した整数添字の配列)[1][2]のメモリ空間の効率化するものでした。

https://github.com/php/php-src/pull/7491

この変更はソースコードを読めば自明ですが、実際に動いている PHP の HashTable の中身を見たいという願望が強くなったので、 HashTable に直接アクセスして内部の状態を出力するような PHP Extension を開発することにしました。[3]

1. プロジェクト構成や前提

開発環境

  • Docker Desktop for Mac
  • Compose V2
  • (PhpStorm)

※ PhpStorm は docker compose コマンドを GUI で実行する時の説明で使います。 CLI でもやっていることは同じなので無くても問題ないです。

プロジェクト構成

/something-dir
├── php-src
│   └── ...
└── this-project
    ├── Dockerfile
    ├── compose.yml
    ├── ext
    │   └── my_ext
    └── src
        └── hello.php

プロジェクト自体はthis-project以下になります。 my_ext が自作の PHP Extension になります。
今回は 1 つの Dockerfile で複数バージョンの PHP を扱います。
作成した Extension を動かす PHP コードはhello.phpになります。

php-src は PHP の本体である php/php-src です。これは、 PHP Extension Skeleton を作成する為の ext_skel.php を実行するために用意しています。

2. PHP Extension の作成と動作確認

手始めに、 Docker ではなくローカルマシンで動くことを確認してみます。

PHP Extension の雛形作成

$ cd /something-dir/php-src/ext
$ php ext_skel.php --ext my_ext

/something-dir/php-src/ext/my_extが生成されます。これが雛形になります。

php-src は PHP-8.2 にしています。 PHP-8.3 からはちょっとわからないですが、 PHP-8.x (≦8.2)であれば ext_skel.php で生成したコードを使い回せるかと思います。

ビルド

$ cd my_ext
$ phpize
$ ./configure --enable-my_ext
$ make

make まで行うと、modules/というディレクトリが出来ます。modules/にはmy_ext.soが生成されているかと思います。

では、この Extension を使って PHP を実行してみます。

$ php -d extension=modules/my_ext.so wip_hello.php
The extension my_ext is loaded and working!
wip_hello.php
<?php

test1();

Extension を改造してみる

作成した Extention は my_ext.c というファイルに実装が書かれています。

雛形で作成されている test2 を書き換えてみます。

PHP_FUNCTION(test2)
{
	char *var = "World";
	size_t var_len = sizeof("World") - 1;
	zend_string *retval;

	ZEND_PARSE_PARAMETERS_START(0, 1)
		Z_PARAM_OPTIONAL
		Z_PARAM_STRING(var, var_len)
	ZEND_PARSE_PARAMETERS_END();

+	retval = strpprintf(0, "Hello PHP %s", var);
-	retval = strpprintf(0, "Hello %s", var);

	RETURN_STR(retval);
}

再びビルドして実行してみます。

$ phpize
$ ./configure --enable-my_ext
$ make
$ php -d extension=modules/my_ext.so wip_hello.php
Hello PHP World
wip_hello.php
<?php

echo test2() . "\n";

3. Docker で動かす

無事 PHP Extention が動くことを確認できたので、次は Docker で動かしてみます。
使用するイメージは Official Image の 8.2-cli です。

https://hub.docker.com/_/php

docker build で PHP Extention も build する

すでに PHP Extention を build しましたが、そのままビルドした Extention を Docker 環境(linux環境)では使うことは出来ません。
なので、必要ファイルを Docker 環境内に COPY して Docker 環境内で build します。

PHP の Official Image には docker-php-ext-install というコマンドが用意されています。

https://github.com/docker-library/php/blob/master/docker-php-ext-install

docker-php-ext-installを使うように Dockerfile を書きます(この挙動については後ほど説明します)

Dockerfile
FROM php:8.2-cli

COPY ext/my_ext /usr/src/php/ext/my_ext

RUN docker-php-ext-install my_ext

Docker compose を使うので compose.yml は以下のように書きます。

compose.yml
services:
  php-8.2:
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

docker compose up -d --buildを行い、イメージのビルドと共に PHP Extention の build も行います。

PhpStorm で立ち上げる

Docker ツール設定から Use Compose V2 にチェックを入れます。これで Docker Compose を使用する時に Compose V2 が選択されるようになります。

次にcompose.ymlファイルの横にある実行マークをポチッと押します。

サービスツールウィンドウを見て、実行中になっている事を確認します。

右側にTerminalというボタンがあるので、これを押して Terminal を起動します。

ここで hello.php を実行すると、 PHP Extention が読み込まれた PHP を実行できます。

docker-php-ext-install は何をしているか

Dockerfile 内で docker-php-ext-install を呼び出していましたが、この挙動を確認してみます。

RUN docker-php-ext-install my_ext

このコマンドは cd /usr/src/php/ext をして、指定された Extension 名と同じ名前のディレクトを探します。

https://github.com/docker-library/php/blob/0bae31aa4aa0afffdc394072bda779fa652f74fc/docker-php-ext-install#L19

https://github.com/docker-library/php/blob/0bae31aa4aa0afffdc394072bda779fa652f74fc/docker-php-ext-install#L68-L79

なので、 PHP Extension のディレクトリごと /usr/src/php/ext/ 配下にコピーします。

COPY ext/my_ext /usr/src/php/ext/my_ext

次に、この Extension に移動して、 docker-php-ext-configure を呼び出します。これは phpize./configure を実行します。

https://github.com/docker-library/php/blob/0bae31aa4aa0afffdc394072bda779fa652f74fc/docker-php-ext-install#L105-L107
https://github.com/docker-library/php/blob/0bae31aa4aa0afffdc394072bda779fa652f74fc/docker-php-ext-configure#L67-L69

最後に make を実行して、 docker-php-ext-enable を呼び出します。これで、実行中の PHP に自作 Extension が使える状態になりました。

https://github.com/docker-library/php/blob/0bae31aa4aa0afffdc394072bda779fa652f74fc/docker-php-ext-install#L124-L130

4. 複数バージョンに対応する

Docker で PHP Extension を動かすことが出来るようになったので、続いては複数のバージョンに対応してみます。今回は 8.2 に加え、 8.1 に対応してみます。

compose.yml
services:
  php-8.1:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        PHP_VERSION: 8.1-cli
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

  php-8.2:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        PHP_VERSION: 8.1-cli
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

ARG を使う

Docker のイメージに引数を指定できる ARG というものがあります。これを使っていきます。

https://docs.docker.com/engine/reference/builder/#arg

まずは、 Dockerfile に ARG を加え、 PHP_VERSION という引数をつくります。これを FROM で指定するバージョンを指定できます。

Dockerfile
+ ARG PHP_VERSION
+ FROM php:${PHP_VERSION}
- FROM php:8.2-cli

COPY ext/my_ext /usr/src/php/ext/my_ext

RUN docker-php-ext-install my_ext

compose.ymlには、それぞれが指定するバージョンを引き渡すように設定します。

compose.yml
services:
  php-8.1:
    build:
      context: .
      dockerfile: Dockerfile
+     args:
+       PHP_VERSION: 8.1-cli
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

  php-8.2:
    build:
      context: .
      dockerfile: Dockerfile
+     args:
+       PHP_VERSION: 8.1-cli
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

これで複数のバージョンの PHP のコンテナを立ち上げられました。

ここで先程同様に Terminal を開いて、 PHP を実行すると、自作 PHP Extension がインストールされた状態になっています。

(PHP ≦ 7.x について)

EOL ですね。

いや、 EOL ですが、どうしても必要っていう場合ありますね。わかります。

(これは当然ですが、) PHP はバージョンアップすると Zend API も追加・変更・削除されます。なので、メジャーバージョンを全くと難易度が上がります。

やり方としては、 Extension の雛形を作成する ext_skel.php を呼び出す php-src のローカルブランチを PHP-7.4 とか PHP-5.6 とかに切り替えて、 PHP Extension を作成し、 #if (PHP_VERSION_ID < 80000) のようにマクロを使って呼び出す API を切り替えると良いかなと思います。

※あくまで、個人の見解です。

PHP 8.2 の配列を見る PHP Extension

PHP Extension の実装

さて、ここから先はタイトルから少し外れますが、背景で話していた PHP ≧ 8.2 で配列の内部実装が異なることを PHP Extension を使って確認してみます。
配列を受け取って HashTable の情報をプリントする array_info という関数を作ってみます。

my_ext.c
PHP_FUNCTION(array_info)
{
	zval *arr;
	// $a という引数を取り、配列であることを期待する
	ZEND_PARSE_PARAMETERS_START(1, 1)
		Z_PARAM_ARRAY(arr)
	ZEND_PARSE_PARAMETERS_END();
	
	php_printf("ht->nTableMask: %#x\r\n", Z_ARRVAL_P(arr)->nTableMask);
	php_printf("ht->nNumUsed: %d\r\n", Z_ARRVAL_P(arr)->nNumUsed);
	php_printf("ht->nTableSize: %d\r\n", Z_ARRVAL_P(arr)->nTableSize);
	php_printf("ht->nNumOfElements: %d\r\n", Z_ARRVAL_P(arr)->nNumOfElements);
	php_printf("ht->nNextFreeElement: %d\r\n", Z_ARRVAL_P(arr)->nNextFreeElement);
	php_printf("ht->nInternalPointer: %d\r\n", Z_ARRVAL_P(arr)->nInternalPointer);
	php_printf("-------------\r\n");
	
	#if (PHP_VERSION_ID >= 80200)
		php_printf("HT_PACKED_SIZE: %d\r\n", HT_PACKED_SIZE(Z_ARRVAL_P(arr)));
		php_printf("HT_PACKED_USED_SIZE: %d\r\n", HT_PACKED_USED_SIZE(Z_ARRVAL_P(arr)));
	#else
		php_printf("HT_SIZE: %d\r\n", HT_SIZE(Z_ARRVAL_P(arr)));
		php_printf("HT_USED_SIZE: %d\r\n", HT_USED_SIZE(Z_ARRVAL_P(arr)));
	#endif
}

引数や返り値については C のコード内でも指定していますが、 my_ext.stab.php というファイルにも記述します。

my_ext.stab.php
+function array_info(array $a): void {}

準備が出来ました。 docker compose up します。

実行

実行するファイルは以下の通りです。配列は Packed Array になるようにします。

hello.php
<?php

echo PHP_VERSION . "\n";

$a = [1, 2, 3, 4];
array_info($a);

実行結果です。

期待通り、 PHP 8.2 では Packed Array の使用メモリが少なくなっていることがわかりました。

(その他)余談

最近、コロプラさんが開発されている PHP Extension を OSS として公開されました。

https://blog.colopl.dev/entry/2023/07/06/110000

このリポジトリの実行されている Actions を見ると感動します。

https://github.com/colopl/php-colopl_bc/actions/workflows/ci.yaml

まとめ

Dockerfile で ARG を設定する、作成された EXtension はディレクトリごと /usr/src/php/ext/[extension] にコピー、 docker-php-ext-install を実行。

Dockerfile
ARG PHP_VERSION
FROM php:${PHP_VERSION}

COPY ext/my_ext /usr/src/php/ext/my_ext

RUN docker-php-ext-install my_ext

PHP のバージョンは compose.yml の args で指定する。

compose.yml
services:
  php-8.1:
    build:
     context: .
     dockerfile: Dockerfile
     args:
       PHP_VERSION: 8.1-cli
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

  php-8.2:
    build:
     context: .
     dockerfile: Dockerfile
     args:
       PHP_VERSION: 8.1-cli
    volumes:
      - ./src:/workspace
    working_dir: /workspace
    tty: true

参考文献

脚注
  1. PHP's new hashtable implementation ↩︎

  2. 実際には必ずしも整数が連続している必要は無く、テーブルサイズを下回っていれば Packed Array となる ↩︎

  3. 「それって gdb で print_ht すれば良いのでは?」「わかる」 ↩︎

Discussion