Magento2用のPHPStan拡張機能を作成してみる。



本記事は、[Magento Advent Calendar 2019](https://adventar.org/calendars/4213)の10日目の記事になります。

本記事では、Magento2でPHPStanを実行する上で必要となる、リフレクション拡張の作成方法を紹介いたします。


目次

1. PHPStanとは (PHPStanのカスタマイズとは)
- Magento用のリフレクション拡張の作り方


##そもそもPHPStanとは

PHPStanとは、PHP専用の静的解析ツールです。

こちらのスライドを読むと、PHPStanがどのようなものか良く分かります。

[PHPStanで始める継続的静的解析](https://speakerdeck.com/hirak/php-static-analysis)

ざっくりまとめてみると

- Autoloadを利用してテストを実行するため、他の静的解析ツールよりも高速
- Phanみたいに新たにphp拡張を入れる必要がない。単独で動作
- クラス拡張やカスタムルールなど、拡張機能が豊富

##PHPStanの拡張機能

↑でも書いた通り、高い拡張性がPHPStanの特徴。

PHPStanで提供されている主な拡張機能は下記の二点です。

1. Class Reflection Extensions
-  Dynamic Return Type Extensions

> 1. Class Reflection Extensions

既存のクラス定義の拡張を行うためのエクステンション。

皆さんご存知の通り、PHPには　\_\_get、\_\_set,\_\_call などのマジックメソッドが存在します。

マジックメソッドを利用することで、実行時にだけ定義されるメソッドやプロパティを作成することが出来ます。

静的解析では通常、そのようなマジックメソッドへのアクセスは出来ないため、「Call to undefined function ~~」としてエラーが表示されてしまいます。

ですが、PHPStanではClass Reflection Extensionsという拡張機能を利用することで、テスト実行時にだけ既存のクラスに仮のメソッドやプロパティを追加してあげることが出来ます。

Magentoでマジックメソッドとして定義されているGetter (getHoge()みたいな), Setterはこの拡張を利用して対応することができます。

Magentoでは、通常下記のコードはエラーではないはず。

```
18: $test = $block->getHoge(1);
 ------ -------------------------------------------------------------- 
  Line   Block/Hoge.php                              
 ------ -------------------------------------------------------------- 
  18     Call to an undefined method                                   
         Test\Module\Block\Hoge::getHoge().  
 ------ -------------------------------------------------------------- 
 [ERROR] Found 1 error 
```


> 2.Dynamic Return Type Extensions

メソッドの戻り値が引数などの値に応じて、動的に変化するメソッドに対応するための拡張機能がDynamic Return Type Extensionsです。

例えば下記のような決まった型の戻り値を返すメソッドであれば、特に問題はなく、No errors もしくは Errorsが返ります。

```
/**
* @return int
**/
public function getDouble()
{
  return 1 * 2;
}
```
<font color="LimeGreen">[OK] No errors </font>

```
/**
* @return int
**/
public function getDouble()
{
  return (string)(1 * 2);
}
```
<font color="DeepPink">::getDouble() should return int but returns string. </font>

ただしMagentoでいうところの、ObjectManagerに定義されているcreate()やget()メソッドは戻り値の型が決まっておりません。

そのような時に、Dynamic Return Type Extensionsを正しく定義してあげると、

> とあるインターフェイスが渡されたら、とある所定のクラスを返して欲しい

といったようなメソッドの戻り値の拡張が可能です。

今回の解説では、この拡張は利用しませんが。。 
(Magentoの開発においてObjectManagerの利用は禁止されているので。)

```
$productRepository = $this->objectManager->create('Magento\Catalog\Api\ProductRepositoryInterface');
$SalesRepository = $this->objectManager->create('Magento\Sales\Api\SalesRepositoryInterface');
```

---------------

PHPStanの拡張機能の紹介はここまでにしておき、以下ではMagentoのマジックメソッドの一つである、Getterに対応するためのClass Reflection Extensionsを作成していきます。

<font color="Red">\#注意</font>
PHPStanの現在の最新版は、ちょうど一週間ほど前に公開されたv0.12になりますが、今回作成する拡張機能はv0.11用のものになります。

v0.11からv0.12では、インターフェイスにがっつり変更が入っているので互換性はないです。

## MagentoのGetterに対応する方法


主に作るのは2つのクラスと３つのメソッドです。

1. MethodReflectionExtensionインターフェイスを実装したクラスの作成 (クラスリフレクション拡張用の定義クラス)
    1. hasMethod()の作成
    1. getMethod()の作成
    
1. MethodReflectionインターフェイスを実装したクラスの作成 (get~~()の振る舞いを定義するクラス)
    1. getVariants()の作成


```
最終的なディレクトリ構成
src/ 
   Reflection/
            ┣ MagicGetMethodReflectionExtension.php (MethodReflectionExtensionを実装)
            ┣ MethodReflection/
                      └ MagicGetMethodReflection.php (MethodReflectionを実装)

```

### 1.MagicGetMethodReflectionExtensionクラスを作成

まずはじめに、PHPStan\Reflection\MethodsClassReflectionExtensionインターフェイスを実装した、

MagicGetMethodReflectionExtensionクラスを作成します。

PHPStan\Reflection\MethodsClassReflectionExtensionインターフェイスを実装したクラスには、下記二つのメソッドが必要となります。
・hasMethod() //メソッドが存在するかどうかの判定 (ここでfalseを返すと、get~~が undefinedのエラーになる)
・getMethod() //メソッドの振る舞いを定義したMethodReflectionクラスを返す

```
<?php
namespace Magento\PHPStan\Reflection;

class MagicMethodReflectionExtension implements MethodsClassReflectionExtension
{
    public function hasMethod(ClassReflection $classReflection, string $methodName): bool
    public function getMethod(ClassReflection $classReflection, string　$methodName): MethodReflection
}
```

### 1.1. hasMethodの実装

hasMethodでは、引数として渡される

- \$classReflection\(メソッドの呼び出し元のクラスリフレクション\)
- \$methodName (メソッドの名前)

を元に、渡ってきた\$methodNameが存在するかしないかを判断します。

Magentoでは 、Magento\Framework\DataObjectにGetterに関するマジックメソッドが定義されているので、下記の項目が全てYESの場合はhasMethodでイエスが返るように実装すれば良さそうです。

1. メソッドの呼び出し元クラスがMagento\Framework\DataObject自身もしくはそれを継承している
- メソッド名の先頭３文字がgetになっている

実際に書くとこんな感じ。

```
/**
     * @param ClassReflection $classReflection
     * @param string $methodName
     * @return bool
     */
    public function hasMethod(ClassReflection $classReflection, string $methodName): bool
    {
        $dataObject = 
           $classReflection->getName() === \Magento\Framework\DataObject::class ||
           $classReflection->isSubclassOf(\Magento\Framework\DataObject::class);
        return $dataObject && substr($methodName, 0, 3) === 'get';
    }

```

### 1.2. getMethodの実装 

getMethodは、hasMethodがYESを返した場合に呼び出されるメソッドで、メソッドの振る舞いを定義するMethodReflectionインターフェイスを実装したクラスを返します。

returnしているMagicGetMethodReflectionクラスは、これから作成するMethodReflectionインターフェイスを実装したクラスです。

```
     /**
     * @param ClassReflection $classReflection
     * @param string $methodName
     * @return MethodReflection
     */
    public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
    {
         return new MagicGetMethodReflection($methodName, $classReflection);
    }
```

###2. MethodReflectionの実装

Magentoで定義されているGetterと同じ振る舞いをするクラスリフレクションを作成していきます。

Magentoで定義されているget~~()は下記の要素を持つため、下記の要件をみたすMagicGetMethodReflection
クラスを新たに作成すれば良さそうです。

1. stringのオプション引数を一つ持つ
2. 戻り値としてmixedを返す
3. アクセス修飾子はpublic 

実際に書くとこんな感じ。 (詳細は、[github](https://github.com/Taku-Yamashita/phpstan_magento2/blob/master/src/Magento/PHPStan/Reflection/MethodReflection/MagicGetMethodReflection.php)より)

```
<?php
namespace Magento\PHPStan\Reflection;
〜〜〜
class MagicGetMethodReflection implements MethodReflection
{
    /**
     * @var string
     */
    private $name;
    /**
     * @var ClassReflection
     */
    private $declaringClass;

    /**
     * MagicGetMethodReflection constructor.
     * @param string $name
     * @param ClassReflection $declaringClass
     */
    public function __construct(string $name, ClassReflection $declaringClass)
    {
        $this->name = $name;
        $this->declaringClass = $declaringClass;
    }

    public function getDeclaringClass(): ClassReflection
    {
        return $this->declaringClass;
    }

    public function isStatic(): bool
    {
        return false;
    }

    public function isPrivate(): bool
    {
        return false;
    }

    public function isPublic(): bool
    {
        return true;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getPrototype(): ClassMemberReflection
    {
        return $this;
    }

    /**
     * @return ParametersAcceptor[]
     */
    public function getVariants(): array
    {
        return [new FunctionVariant(
            [new NativeParameterReflection(
                'index',
                true,
                TypeCombinator::addNull(new StringType()),
                PassedByReference::createNo(),
                false)
            ],
            false,
            new MixedType()
        )];
    }

}

```

分かりやすい名前で各メソッドが定義されているうえ、メソッドの振る舞いを柔軟に定義できる感じで使いやすい。

直感的に分かりやすいメソッド名がほとんどですが、重要でかつ少し分かりずらいのがgetVariants()

このメソッドで、get~~()の引数、戻り値の型を指定してあげています。

大事なのは

- 第1引数 : メソッドの引数を指定
- 第3引数 : メソッドの返り値を指定

これで、get~~()の機能を忠実に再現したMagicGetMethodReflectionクラスが作成できました。

もちろん、開発時にget~~()の引数にstring型以外の引数を渡した場合は、しっかりとエラーを吐いてくれます。

以上でGetメソッド用の拡張エクステンションの完成です。

###3.MethodReflectionExtensionクラスを設定ファイルに登録

各クラスの作成が出来たら、後はPHPStanの定義ファイルである、phpstan.neonに拡張クラスを登録してあげるだけです。

```
services:
	-
		class: Magento\PHPStan\Reflection\MagicMethodReflectionExtension
		tags:   
			- phpstan.broker.methodsClassReflectionExtension
```

## 結果


修正前に表示されていた、下記のエラーが↓

```
18: $test = $this->getHoge('1');

 ------ -------------------------------------------------------------- 
  Line   Block/Hoge.php                              
 ------ -------------------------------------------------------------- 
  18     Call to an undefined method                                   
         Test\Module\Block\Hoge::getHoge().  
 ------ -------------------------------------------------------------- 

 [ERROR] Found 1 error 
```

 表示されなくなりました。↓

```
18: $test = $this->getHoge('1');
[OK] No errors 
```


また、試しにgetHogeの引数にstring型ではなく、数値やオブジェクトを渡してみると、

```

18: $test = $this->getHoge(1);
19: $test = $this->getHoge(new \Magento\Framework\DataObject());

 ------ --------------------------------------------------------------------- 
  Line   Block/Hoge.php                                     
 ------ --------------------------------------------------------------------- 
  18     Parameter #1 $string of method                                       
         Test\Module\Block\Hoge::getHoge() expects  
         string, int given.                                                   
  19     Parameter #1 $string of method                                       
         Test\Module\Block\Hoge::getHoge() expects  
         string, Magento\Framework\DataObject given.                          
 ------ --------------------------------------------------------------------- 

 [ERROR] Found 2 errors
```

しっかりエラーを吐いてくれます。  なんとも頼もしい。。

### 追記

Setter用のMethodReflectionクラスの実装は下記のようになるかと思います。

- Mixedのオプション引数を一つ
- 戻り値として自分自身を返す
- アクセス修飾子はPublic

```
<?php
namespace Magento\PHPStan\Reflection;
〜〜〜
class MagicSetMethodReflection implements MethodReflection
{
    /**
     * @var string
     */
    private $name;
    /**
     * @var ClassReflection
     */
    private $declaringClass;

    /**
     * MagicMethodReflection constructor.
     *
     * @param string $name
     * @param ClassReflection $declaringClass
     */
    public function __construct(string $name, ClassReflection $declaringClass)
    {
        $this->name = $name;
        $this->declaringClass = $declaringClass;
    }

    public function getDeclaringClass(): ClassReflection
    {
        return $this->declaringClass;
    }

    public function isStatic(): bool
    {
        return false;
    }

    public function isPrivate(): bool
    {
        return false;
    }

    public function isPublic(): bool
    {
        return true;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getPrototype(): ClassMemberReflection
    {
        return $this;
    }

    /**
     * @return ParametersAcceptor[]
     */
    public function getVariants(): array
    {
        return [new FunctionVariant(
            [new NativeParameterReflection(
                'value',
                true,
                new MixedType(),
                PassedByReference::createNo(), false),
            ],
            false,
            new ObjectType($this->declaringClass->getName())
        )];
    }
}

```

少し変更が入ってはいますが、今回作成したものはこちらになります。
https://github.com/Taku-Yamashita/phpstan_magento2

### 参考にした情報

- https://github.com/phpstan/phpstan
- https://github.com/phpstan/phpstan-symfony
- https://github.com/bitExpert/phpstan-magento
- https://github.com/fooman/phpstan-magento2-magic-methods
- https://www.youtube.com/watch?v=xemXGgx0w0k

PHPStanのオンラインテスト環境

- https://phpstan.org/