おぎろぐはてブロ

なんだかんだエンジニアになって10年以上

pharのしくみ

PHP5から追加された機能として、SimpleXMLとかSPLなど、動作的にユーザレベルでは実現できないおもしろい動きをするものがあります。5.3から標準モジュールとなったpharも同じように結構見えないところでPHP本体の動きを変えるような実装をしているので、調べてみました。

以下の部分に大きく分けています。

  1. stream wrapperの定義
  2. zend_compile_fileの差し替え
  3. ファイルアクセス系関数の差し替え
  4. パス解決の差し替え

stream wrapperの定義

pharは、stream wrapperとして動くことで、"phar://path/to/foo.phar/bar.php" といったファイルにアクセスすることができます。
普段よく使う http://〜 、 https://〜 、ftp:// などと同様で、抽象化してアクセスできるようになるしくみです。

定義

stream.c の中で構造体が宣言されています。見てわかるように、各ファイル操作に対して、関数を定義しています。

// phar/stream.c
  25 php_stream_ops phar_ops = {
  26     phar_stream_write, /* write */
  27     phar_stream_read,  /* read */
  28     phar_stream_close, /* close */
  29     phar_stream_flush, /* flush */
  30     "phar stream",
  31     phar_stream_seek,  /* seek */
  32     NULL,              /* cast */
  33     phar_stream_stat,  /* stat */
  34     NULL, /* set option */
  35 };
  36 
  37 php_stream_wrapper_ops phar_stream_wops = {
  38     phar_wrapper_open_url,
  39     NULL,                  /* phar_wrapper_close */
  40     NULL,                  /* phar_wrapper_stat, */
  41     phar_wrapper_stat,     /* stat_url */
  42     phar_wrapper_open_dir, /* opendir */
  43     "phar",
  44     phar_wrapper_unlink,   /* unlink */
  45     phar_wrapper_rename,   /* rename */
  46     phar_wrapper_mkdir,    /* create directory */
  47     phar_wrapper_rmdir,    /* remove directory */
  48 };
  49 
  50 php_stream_wrapper php_stream_phar_wrapper = {
  51     &phar_stream_wops,
  52     NULL,
  53     0 /* is_url */
  54 };
  55 

モジュール初期化時

pharのstream wrapperの定義をします。これで、"phar" から始まるストリームにアクセスする際に、各関数が呼ばれます。

// phar/phar.c
3530 PHP_MINIT_FUNCTION(phar) /* {{{ */
3531 {
         ...
3550     return php_register_url_stream_wrapper("phar", &php_stream_phar_wrapper TSRMLS_CC);
3551 }

読み込みの部分は長ったらしいので省略。
phar://でpharファイルにアクセスするときには、phar_wrapper_open_url()が呼ばれ、開いたあとは、php_stream_allocでストリームの動作の定義をします。

fpf = php_stream_alloc(&phar_ops, idata, NULL, mode);

zend_compile_fileの差し替え

モジュール初期化時に、zend_compile_file をpharのものに差し替えています。

// phar/phar.c
3530 PHP_MINIT_FUNCTION(phar) /* {{{ */
3531 {
         ...
3534     phar_orig_compile_file = zend_compile_file;
3535     zend_compile_file = phar_compile_file;
         ...
3551 }

pharへアクセスするのは、stream wrapperが役目を担っています。include "http://example.com" とかできるのと同じ仕組みです。
compile_fileでは、include/requireされたスクリプトコンパイルする役割を持ちます。pharのアーカイブからスクリプトを取り出す部分は、stream wrapperがやってくれるので、普通は、ここを差し替える必要はありません。また、スタブ部分はpharアーカイブの先頭から__halt_compiler()までの部分に含まれるので、普通にincludeするだけで実行されます。
ここを差し替えているのは例えばAPCなどのコンパイルキャッシュ系のモジュールです。ファイルにアクセスしてコンパイルする代わりに、キャッシュしたコンパイル結果を返します。

では、ここで何を処理しているかというと、include/requireの引数のファイルパスに .phar を含み且つ、 :// を含まないファイルについて読み込みを行い、圧縮ファイルであった場合にpharとして読み込み処理を行っています。
なので、include "/path/to/archive.phar.bz2" とか、

php hogehoge.phar.bz2

とかコマンドラインで圧縮されたpharを起動する場合に対応します。

3329 static zend_op_array *phar_compile_file(zend_file_handle *file_handle, int type TSRMLS_DC) /* {{{ */
3330 {
         ...
3339     if (strstr(file_handle->filename, ".phar") && !strstr(file_handle->filename, "://")) {
3340         if (SUCCESS == phar_open_from_filename(file_handle->filename, strlen(file_handle->filename), NULL, 0, 0, &phar, NULL TSRMLS_CC)) {
3341             if (phar->is_zip || phar->is_tar) {
3342                 zend_file_handle f = *file_handle;
                     ...
3358             } else if (phar->flags & PHAR_FILE_COMPRESSION_MASK) {
                     ...
3384             }
3385         }
3386     }
3387 
3388     zend_try {
3389         failed = 0;
3390         res = phar_orig_compile_file(file_handle, type TSRMLS_CC);
3391     } zend_catch {
3392         failed = 1;
3393     } zend_end_try();

パス解決の差し替え

phar://〜 で、pharの中のファイルを読み込めても、そのpharの中から相対パスで require 'hoge.php' などと書かれていたら、そのままでは普通にファイルを探そうとしてしまいます。そのため、PHPがパスを解決する部分に手が入れられています。
MINIT (拡張モジュールが初期化されるときに呼ばれる関数)で、pharのモジュールの初期化のタイミングで以下のことをやっています。

  • PHP5.3以降: zend_resolve_path の差し替え
  • PHP5.3以前: zend_stream_open_functionの差し替え
// phar/phar.c
3530 PHP_MINIT_FUNCTION(phar) /* {{{ */
3531 {
         ...
3537 #if PHP_VERSION_ID >= 50300
3538     phar_save_resolve_path = zend_resolve_path;
3539     zend_resolve_path = phar_resolve_path;
3540 #else
3541     phar_orig_zend_open = zend_stream_open_function;
3542     zend_stream_open_function = phar_zend_open;
3543 #endif
         ...
3551 }

pharのアーカイブ内で、相対パスで同じくアーカイブ内のファイルをincludeするようなことがあると思います。
たとえば、図のような構成になっているpharアーカイブのとき。

pharアーカイブの中で require '1.php' と同じ階層のファイルをrequireしたり、require '../2.php' とディレクトリを辿るような場合、通常のパス解決をすると、当然ファイルが見つかりません。これを解決するための実装で、PHP本体の仕様が異なることからバージョンに応じて変えている感じです。

[PHP5.3以上] zend_resolve_pathの差し替え

PHP5.3以降では、zend_resolve_pathをpharのものに差し替えています。zend_resolve_pathは、include pathを参照して、渡されたfilenameのrealpathを返却する関数です。(main/fopen_wrappers.cを参照)
これを phar_resolve_path という関数に差し替え、もし、requireなどをしている元のファイルが pharの中である場合に特殊動作をし、相対パス絶対パスの phar:///path/to/archive.phar/1.php と展開することでファイルの参照を可能としています。


pharで差し替えた関数では、

3321 static char *phar_resolve_path(const char *filename, int filename_len TSRMLS_DC)
3322 {
3323     return phar_find_in_include_path((char *) filename, filename_len, NULL TSRMLS_CC);
3324 }

と、phar内の相対パスを良きに計らう関数に渡して、パス解決をしています。

[PHP5.3未満] zend_stream_open_functionの差し替え

5.3以前では、zend_stream_open_function を同じく差し替えています。これは、ストリームをオープンするための関数です。同じく、phar_find_in_include_path を呼んでパス解決しています。

ファイルアクセス関数の差し替え

pharには、Phar::interceptFileFuncsという、ファイルアクセス関連の関数をpharに横取りさせるメソッドがあります。

void Phar::interceptFileFuncs(void)

fopen() や readfile()、 file_get_contents()、opendir() などの stat 関連の関数をすべて phar に横取りさせます。 phar アーカイブ内で相対パスを指定してこれらの関数がコールされると、 それが phar アーカイブ内のファイルへのアクセスに変更されます。 絶対パスの場合は、ファイルシステム上の外部ファイルを指すものとみなされます。

PHP: Phar::interceptFileFuncs - Manual

このメソッドを実現するため、モジュール初期化時に元の関数をphar関数に差し替えます。関数を呼んだときに差し替えるのではなく、最初に全部差し替えちゃっている感じです。

// phar/phar.c
3530 PHP_MINIT_FUNCTION(phar) /* {{{ */
3531 {
         ...
3547     phar_intercept_functions_init(TSRMLS_C);
3548     phar_save_orig_functions(TSRMLS_C);
         ...
3551 }

1つ目のphar_intercept_functions_init では、マクロでざっくりと、ファイルアクセス系関数を差し替えます。fopen を PHAR_G(orig_fopen) に移動し、元の fopen を phar_fopen に差し替えという感じに。

//func_interceptors.c
1056 #define PHAR_INTERCEPT(func) \
1057     PHAR_G(orig_##func) = NULL; \
1058     if (SUCCESS == zend_hash_find(CG(function_table), #func, sizeof(#func), (void **)&orig)) { \
1059         PHAR_G(orig_##func) = orig->internal_function.handler; \
1060         orig->internal_function.handler = phar_##func; \
1061     }
1062 
1063 void phar_intercept_functions_init(TSRMLS_D)
1064 {
1065     zend_function *orig;
1066 
1067     PHAR_INTERCEPT(fopen);
1068     PHAR_INTERCEPT(file_get_contents);
         ...
1087     PHAR_INTERCEPT(stat);
1088     PHAR_INTERCEPT(readfile);
1089     PHAR_G(intercepted) = 0;
1090 }

2つ目のphar_save_orig_functions では、上の関数で PHAR_G に突っ込んだ関数を phar_orig_functions に突っ込み直しているだけ。

1129 static struct _phar_orig_functions {
1130   void  (*orig_fopen)(INTERNAL_FUNCTION_PARAMETERS);
1131   void  (*orig_file_get_contents)(INTERNAL_FUNCTION_PARAMETERS);
                     ...
1150   void  (*orig_readfile)(INTERNAL_FUNCTION_PARAMETERS);
1151   void  (*orig_stat)(INTERNAL_FUNCTION_PARAMETERS);
1152 } phar_orig_functions = {NULL};
1153 
1154 void phar_save_orig_functions(TSRMLS_D) /* {{{ */
1155 {
1156     phar_orig_functions.orig_fopen             = PHAR_G(orig_fopen);
1157     phar_orig_functions.orig_file_get_contents = PHAR_G(orig_file_get_contents);
               ...
1176     phar_orig_functions.orig_readfile          = PHAR_G(orig_readfile);
1177     phar_orig_functions.orig_stat              = PHAR_G(orig_stat);
1178 }

元の関数から差し替えている先の phar_fopen などの関数はこういう実装になっていて、interceptがオフのときは、さっくりとgotoして、元の関数を呼び出します。

  26 PHAR_FUNC(phar_opendir) /* {{{ */
  27 {
  28     char *filename;
  29     int filename_len;
  30     zval *zcontext = NULL;
  31 
  32     if (!PHAR_G(intercepted)) {
  33         goto skip_phar;
  34     }
  35 
  36     if ((PHAR_GLOBALS->phar_fname_map.arBuckets && !zend_hash_num_elements(&(PHAR_GLOBALS->phar_fname_map)))
  37         && !cached_phars.arBuckets) {
  38         goto skip_phar;
  39     }
         
          /* ここで仮想的にファイル処理 */
 
  88 skip_phar:
  89     PHAR_G(orig_opendir)(INTERNAL_FUNCTION_PARAM_PASSTHRU);
  90     return;
  91 }

まとめ

今のところあんまりぱっとしないPharですが、思った以上にpharを動かすために PHP内部に特殊な動きを組み込んでいることが分かった感じです。
使わないともったいないですね!というのと、phar使わないから無駄な処理省きたいという場合は、phar拡張を抜くというのもありかと思います。そんなに変わらないと思いますが。