こんにちは。24年度入社の吉岡です。
案件で、システムで用いられているPHPソースコードとフレームワークのバージョンアップ作業を行いっています。作業の内容として大量に同様の変更を行いたい箇所があり、自分たちのチームはPHP-Parserを使用しました。PHP-Parserは今後の案件でも作業の効率化に役立ってくれると思いまので、共有させていただきます。
〇PHP-Parserとは
PHP-Parserは「PHPで書かれたPHPパーサー」のことです。
PHPパーサーはPHPソースコードをAST(抽象構文木)に変換し、変更を加え、変更後のASTをPHPソースコードに出力することができます。
AST(抽象構文木)は指定されたプログラミング言語の文法に従ってソースコードの構造を表す中間プログラムです。ソースコードのアイテムごとに対応するノードを用いて表現されます。
「手作業でよいのでは?」と思われるかもしれませんね。確かに変更箇所が少数であったり複雑な変更を加えたい場合は手作業の方が楽な場合も多いでしょう。
しかし、単純な変更を大量にしなければならない場合はどうでしょうか?
「システム内のすべてのファイルに declare(strict_types=1); を追加したい」「システム内のすべての配列を、array() から [] に記述を統一したい」という場合に、手作業で行うと多くの時間が掛かりミスが生じやすくなってしまうかもしれません。
そんな時はPHP-Parserの使用を検討されてはいかがでしょうか?
〇PHP-Parserを用いた変更処理の流れ
1 PHPソースコードからASTへパース
2 ASTへ変更を加える
3 新ASTから新PHPソースコードへパース
*インストール
パースしたいPHPソースコードのバージョンによって、PHP-Parserのバージョンを選択してください。
・PHP-Parser 4.x
動作環境: PHP7.0以上で動く
サポート範囲: PHP5.2~8.3( PHP5.xの解析を完全にサポート)
https://github.com/nikic/PHP-Parser/blob/4.x/doc/0_Introduction.markdown
・PHP-Parser 5.x
動作環境:PHP7.4以上で動く
サポート範囲:PHP7~8( PHP5.xの解析も部分的にサポート)
https://github.com/nikic/PHP-Parser/blob/master/doc/0_Introduction.markdown
コマンド
1 |
composer require nikic/php-parser |
1 PHPソースコードからASTへパース
PHPソースコードを取得し、パーサーを用いてASTへパースを行います。
・入力用PHPソースコード (inputSampleData.php)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php namespace Sample\StandardClass; class SampleA { public function do_something(): bool { $this->standard->do(array(123,false)); return true; } public function do_nothing(): bool { $this->standard->donot(array('abc','xyz')); return false; } } } |
・パース処理(changePhp.php)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
<?php require __DIR__ . '/../vendor/autoload.php'; use PhpParser\Error; use PhpParser\NodeDumper; use PhpParser\ParserFactory; $inputFileDirPath = '/var/www/laravel/public/phpParser'; $outputFileDirPath = '/var/www/laravel/public/phpParser'; // 変更したいファイルを取得 $fromPath = $inputFileDirPath . '/inputSampleData.php'; $code = file_get_contents($fromPath, false, null, 0, null); if (!is_string($code)) { throw new \RuntimeException('ファイルの読み込みエラー'); } // PHPソースコードからASTにパース $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); try { $ast = $parser->parse($code); } catch (Error $error) { echo "Parse error: {$error->getMessage()}\n"; return; } // パースしたASTをダンプ $dumper = new NodeDumper; echo $dumper->dump($ast) . "\n"; |
・実行
1 |
php sample.php |
・実行結果(変換されたAST)
コストコのレシートくらい長いですので、ざっと目を通していただければ十分です。「Stmt_Classってやつはクラス定義のことなんだろうな」「Expr_Array_って書いてあるから配列のなんだろうな」ぐらいでOKです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
array( 0: Stmt_Namespace( name: Name( parts: array( 0: Sample 1: StandardClass ) ) stmts: array( 0: Stmt_Class( attrGroups: array( ) flags: 0 name: Identifier( name: SampleA ) extends: null implements: array( ) stmts: array( 0: Stmt_ClassMethod( attrGroups: array( ) flags: MODIFIER_PUBLIC (1) byRef: false name: Identifier( name: do_something ) params: array( ) returnType: Identifier( name: bool ) stmts: array( 0: Stmt_Expression( expr: Expr_MethodCall( var: Expr_PropertyFetch( var: Expr_Variable( name: this ) name: Identifier( name: standard ) ) name: Identifier( name: do ) args: array( 0: Arg( name: null value: Expr_Array( items: array( 0: Expr_ArrayItem( key: null value: Scalar_LNumber( value: 123 ) byRef: false unpack: false ) 1: Expr_ArrayItem( key: null value: Expr_ConstFetch( name: Name( parts: array( 0: false ) ) ) byRef: false unpack: false ) ) ) byRef: false unpack: false ) ) ) ) 1: Stmt_Return( expr: Expr_ConstFetch( name: Name( parts: array( 0: true ) ) ) ) ) ) 1: Stmt_ClassMethod( attrGroups: array( ) flags: MODIFIER_PUBLIC (1) byRef: false name: Identifier( name: do_nothing ) params: array( ) returnType: Identifier( name: bool ) stmts: array( 0: Stmt_Expression( expr: Expr_MethodCall( var: Expr_PropertyFetch( var: Expr_Variable( name: this ) name: Identifier( name: standard ) ) name: Identifier( name: donot ) args: array( 0: Arg( name: null value: Expr_Array( items: array( 0: Expr_ArrayItem( key: null value: Scalar_String( value: abc ) byRef: false unpack: false ) 1: Expr_ArrayItem( key: null value: Scalar_String( value: xyz ) byRef: false unpack: false ) ) ) byRef: false unpack: false ) ) ) ) 1: Stmt_Return( expr: Expr_ConstFetch( name: Name( parts: array( 0: false ) ) ) ) ) ) ) ) ) ) ) |
ノードを用いて変更箇所を指定して、追加・変更・削除などの変更処理を行います。それぞれのノードクラス定義に関する詳細は、nikic/php-parserの公式ドキュメント(5.x版) または vendor/nikic/php-parser/lib/PhpParser/Node/ ディレクトリ配下から探してください。(どのクラスなのか名前で推測できるものが多いと思います)
2 ASTへ変更を加える
今回の本命である、パースされたASTに対して変更を行います。変更を行うにNodeVisitorクラスを基に任意の操作を行うクラスを作成し、「どのタイミングでどの操作を」定義します。
・変更処理セットして実行(changePhp.php 続き)
1 2 3 4 5 6 7 8 |
$traverser = new NodeTraverser(); // PHPの厳密な型判定を設定する記述を追加するNodeVisitorを追加 $traverser->addVisitor(new StrictModeRevisingVisitor()); // 配列をarray()から[]に統一するNodeVisitorを追加 $traverser->addVisitor(new ArrayKindRevisingVisitor()); // 実行! $newAst = $traverser->traverse($ast); |
・PHPの厳密な型判定を設定する記述を追加するNodeVisitor(StrictModeRevisingVisitor.php)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<?php declare(strict_types=1); namespace phpParser; use \PhpParser\NodeVisitorAbstract; class StrictModeRevisingVisitor extends NodeVisitorAbstract { /** * ノードを走破する前に最上位ノードに型指定に厳密にチェックする設定を追加する * @param array $nodes 走破する前のノード全体 * @return array 変更を行ったこれから走破するノード全体 */ public function beforeTraverse(array $nodes): array { if (count($nodes) === 0) { return []; } // declare(strict_types=1); のノードで作成し、ノード全体の最上位ノードに加えて返す $value = new \PhpParser\Node\Scalar\LNumber(1, []); $newDeclareDeclare = new \PhpParser\Node\Stmt\DeclareDeclare('strict_types', $value, []); $newDeclare = new \PhpParser\Node\Stmt\Declare_([$newDeclareDeclare], null, []); $newNodes = [$newDeclare]; foreach ($nodes as $node) { $newNodes[] = $node; } return $newNodes; } } |
・配列をarray()から[]に統一するNodeVisitor(ArrayKindRevisingVisitor.php)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<?php declare(strict_types=1); namespace phpParser; use \PhpParser\NodeVisitorAbstract; class ArrayKindRevisingVisitor extends NodeVisitorAbstract { /** * ノードに入る際に、配列ノードでかつarray()のものを[]に変更する * @param \PhpParser\Node $node 入るノード * @return \PhpParser\Node|null 操作後のノード(操作しない場合はnullを返す) */ public function enterNode(Node $node): ?Node { // 配列ノードであり、括弧の書式がarray()であるもの以外は変更せずに返す if (!$node instanceof \PhpParser\Node\Expr\Array_ || $node->getAttribute('kind') === 2) { return null; } $newAttributes = $node->getAttributes(); // 1だとarray()、2だと[] $newAttributes['kind'] = 2; // 括弧が[]で、中身の値は変更前の値の、配列ノードを作成し、変更前の配列ノードの代わりに返す return new \PhpParser\Node\Expr\Array_($node->items, $newAttributes); } } |
これで変更を加えることができました。
3 新ASTから新PHPソースコードへパース
・changePhp.php 続き
1 2 3 4 5 6 7 8 9 |
// ASTからPHPソースコードにパース $prettyPrinter = new PhpParser\PrettyPrinter\Standard(); $newCode = $prettyPrinter->prettyPrintFile($newAst); // 出力 $toPath = $outputFileDirPath . '/outputSampleData.php'; if (file_put_contents($toPath, $newCode, 0, null) === false) { throw new RuntimeException('ファイルの書き込みエラー'); } |
・出力ファイル(outputSampleData.php)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<?php declare (strict_types=1); namespace Sample\StandardClass; class SampleA { public function do_something() : bool { $this->standard->do([123, false]); return true; } public function do_nothing() : bool { $this->standard->donot(['abc', 'xyz']); return false; } } |
変更できました。ただこの記述だとパース時に空欄削除などのフォーマットが行われます。
元のソースコードの書式を保ったままパースする記述を載せておきます。
・変更処理セットして実行(changePhpNoFormat.php)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
<?php declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/StrictModeRevisingVisitor.php'; require __DIR__ . '/ArrayKindRevisingVisitor.php'; use PhpParser\Error; use PhpParser\NodeTraverser; use phpParser\ArrayKindRevisingVisitor; use PhpParser\NodeVisitor\CloningVisitor; use PhpParser\PrettyPrinter\Standard; use phpParser\StrictModeRevisingVisitor; $inputFileDirPath = '/var/www/laravel/public/phpParser'; $outputFileDirPath = '/var/www/laravel/public/phpParser'; // 変更したいファイルを取得 $fromPath = $inputFileDirPath . '/inputSampleData.php'; $code = file_get_contents($fromPath, false, null, 0, null); if (!is_string($code)) { throw new RuntimeException('ファイルの読み込みエラー'); } // PHPソースコードからASTにパース //$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); $lexer = new \PhpParser\Lexer\Emulative([ 'usedAttributes' => [ 'comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos', ], ]); $parser = new \PhpParser\Parser\Php7($lexer, [ 'useIdentifierNodes' => true, 'useConsistentVariableNodes' => true, 'useExpressionStatements' => true, 'useNopStatements' => false, ]); $oldTokens = $lexer->getTokens(); try { $ast = $parser->parse($code); } catch (Error $error) { echo "Parse error: {$error->getMessage()}\n"; return; } // 変更前の書式を保ったASTを取得 $beforeTraverser = new NodeTraverser(); $beforeTraverser->addVisitor(new CloningVisitor()); $beforeAst = $beforeTraverser->traverse($ast); // ノードを走破しながら、NodeVisitorを用いて変更操作を行う $traverser = new NodeTraverser(); $traverser->addVisitor(new StrictModeRevisingVisitor()); $traverser->addVisitor(new ArrayKindRevisingVisitor()); $newAst = $traverser->traverse($beforeAst); // ASTからPHPソースコードにフォーマットを保ったパース $prettyPrinter = new Standard(); $newCode = $prettyPrinter->printFormatPreserving($newAst, $ast, $oldTokens); // 新たなファイルを作成し書き込み $toPath = $outputFileDirPath . '/outputSampleData.php'; if (file_put_contents($toPath, $newCode, 0, null) === false) { throw new RuntimeException('ファイルの書き込みエラー'); } |
・実行結果
入力したPHPソースコードと同じく、クラスメソッドが値を返す処理の前に空欄行が残したままにできていますね!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php declare (strict_types=1); namespace Sample\StandardClass; class SampleA { public function do_something(): bool { $this->standard->do(array(123,false)); return true; } public function do_nothing(): bool { $this->standard->donot(array('abc','xyz')); return false; } } |
〇おわりに
プログラミングやITの知識だけではなく、「必要な情報を得る力」は本当に重要ですね。特に、ネットの記事で親切丁寧に紹介されていない知識を得たい場合や古いドキュメントから使用を知りたい場合などだと、自分の「必要な情報を得る力」のなさを痛感することが多くあります…
今後AIがより身近になってきて仕事上でも活用しそうだからこそ、検索の仕方や質問の仕方をより一層鍛えていきます!
・参考にさせていただいた記事
https://www.komtaki.com/posts/php-meta-programming-introduction
https://qiita.com/ktplato/items/23ad2893d741bfb564f9meta-programming-introduction