4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[検証] コードをもとにコードを書くコードを書きたい ~ interfaceから実装を作る

Posted at

今日は皆さん。

エンジニアとしてこんなこと言っていいのかわかりませんが、コード書くの面倒なんですよ。
コード書けばバグは出るし、ファイルとクラス名のミスマッチではじめはまともに動かんし、単純に書く量だけが多いと、大したロジックを書いているわけでもないのにやたらと時間がかかったりと、とにかくストレスのもとになるのですよ。
コードを書かないための技の一つは、CakePHPのように規約を駆使しして書く必要のないコードを書かなくするというのがありまして、個人的には好きなんですが、一般的ではないオレオレルールにしちゃうと、あとがたいへんなんですよね、これ。
ほかの手段としては、コードは書くけど、あまり人間に書かせないという、要するに自動生成にしちゃおうという技です。

今回はどうやってコードを生成しようかなっていうのを検証しようと思います。

目標 ~ interfaceから実装を作る

とりあえず、今回の目標は、interfaceから実装を作る、にします。
実装の中身を作るのは流石にあれですが、少なくともひな型くらいは作ってもらって、ファイルを生成するストレスからの解放を狙おうと思います。
interfaceは自分で作らなければなりませんが、こいつはまあ、設計図みたいなもんなので。

とりあえず、以下のようなinterfaceがあったとして、

src/Contract.php
<?php
namespace Niisan\App;

interface Contract
{

    const CONTRACT_NAME = 'contract';

    /**
     * ベクトルを足す
     *
     * @param Vector $a
     * @param Vector $b
     * @return Vector
     */
    public function plus(Vector $a, Vector $b): Vector;

    /**
     * 適当な関数
     *
     * @param Vector $a
     * @param integer|null $num
     * @param string|null $word
     * @return void
     */
    public function tekito(Vector $a, ?int $num, ?string $word = 'default');
}

こいつをもとに次のクラスを自動生成できればいいかなって思います。

src/Imple/Something.php
<?php
namespace Niisan\App\Imple;

use Niisan\App\Contract;
use Niisan\App\Vector;

class Something implements Contract
{
    public function plus(Vector $a, Vector $b): Vector
    {

    }

    public function tekito(Vector $a, ?int $num, ?string $word = 'default')
    {

    }
}

これができれば、ひな型作成は簡単ですね。
最近のエディタは異様なほど賢いので、implements Contractとかやった瞬間に、足りない実装のひな型自動で作ってくれるとかありそうですが...。

コードをもとにコードを作るクラス

とりあえず、適当なクラスを作りましょう。

src/Generator/ImplementGenerator.php
<?php
namespace Niisan\App\Generator;

use Reflection;
use ReflectionClass;
use ReflectionMethod;
use RuntimeException;

class ImplementGenerator
{

}

ここに対して、

  • interfaceを解析する
  • interfaceの各メソッドを解析して、必要なメソッドを作る
  • 指定したクラス名で、interfaceの実装を作る

ってのをやっていきます。

interfaceを解析する

例によってReflectionClassを使って、interfaceの中身を丸裸にしていきます。

まず元になるinterfaceを指定します。

    private ReflectionClass $interface;

    public function from(string $class_name): self
    {
        $reflection = new ReflectionClass($class_name);
        if ($reflection->isInterface() === false) {
            throw new RuntimeException('interfaceではない');
        }
        $this->interface = $reflection;
        return $this;
    }

interfaceからコードを作りたいので、interfaceではないクラスははじきます。

このinterfaceからは次のような情報をとることができます。

$interface_name = $this->interface->getName();// interfaceの名前空間込みの名前を取得できる
$method_list = $this->interface->getMethods();// interfaceに存在するメソッドのリフレクションを取得できる

次はメソッドの中を解析しましょう。

各メソッドを解析し、メソッドのひな型を作成できるようにする

適当なメソッドを作って、必要なメソッドのリストを取得します。

    private function analyzeMethods(): array
    {
        $methods = [];
        foreach ($this->interface->getMethods() as $method) {
            $methods[] = $this->analyzeMethod($method);
        }
        return $methods;
    }

各メソッドは、実際にはReflectionMethodになっているので、次のようなメソッドを作って、関数を作るのに必要な情報を抜き取りましょう。

    private function analyzeMethod(ReflectionMethod $method)
    {
        $method_name = $method->getName();// メソッド名の取得
        $modifier = Reflection::getModifierNames($method->getModifiers());// メソッドの修飾子 ( public, abstract, staticなど ) を取得
        $modifier = array_filter($modifier, fn($name) => $name !== 'abstract');// abstractは実装する際には不要
        $return_type = ($method->hasReturnType()) ? $this->removeNamespace($method->getReturnType()->__toString()) : null;// 返り値の型
    }

これで、メソッドの外側の情報はゲットできていますが、大事なのは引数の情報なので、それを取得していきます。

foreach ($method->getParameters() as $param) {
    //
}

メソッドの引数はReflectionMethod::getParameters()で取得できます。各要素はReflectionParameterになっています。
各引数は主に以下の考慮要素があります。

  • nullabeかどうか
  • デフォルトの値

これらを考慮して、ループの中には以下のようなコードが書けます。

$arg = [
    'type' => null,
    'optional' => $param->isOptional(),// これは初期値があるかどうかを判別している
    'name' => $param->getName(),
];

// 引数の型が指定されている場合は型を用意しておく
if ($param->hasType()) {
    $type = $param->getType();
    $arg['type'] = $type->__toString();// nullableをあらわす ? はこの時点でついてくる
}

// デフォルトの値が設定されている場合
if ($arg['optional']) {
    $default = $param->getDefaultValue();
    $arg['default'] = is_string($default) ? "'$default'": $default;
}

ここで引数の型は実はかなり厄介です。
ユーザ定義の型を指定している場合、echo $type->__toString();とした場合、?Niisan/App/Vectorみたいに、オプショナルマーク付きのフルパスのクラス名が取得されます。
これを実装コードに書かれるのは嫌なので、引数の型を作っている部分をぐりっと変えます。

if ($param->hasType()) {
    $type = $param->getType();
    $optional = (strpos($type->__toString(), '?') === 0 and $type->isBuiltin() === false) ? '?': '';
    $arg['type'] = $optional . $this->removeNamespace($type->__toString());// 名前空間とって、オプショナル
    if ($type->isBuiltin() === false) {
        $this->uses[] = ltrim($type->__toString(), '?');// ユーザ定義のクラスはuseに追加する
    }
}

名前空間をとる関数は名前空間だけを取得する関数とセットで作っておきます。

src/Generator/ImplementGenerator.php

    private $defaultNameSpace = 'Niisan\\App';

    private function getNameSpace(string $filename)
    {
        $segment = explode('/', $filename);
        if (count($segment) === 1) {
            return $this->defaultNameSpace;
        }

        $name = $this->defaultNameSpace;
        foreach ($segment as $key => $val) {
            if ($key === count($segment) - 1) {
                break;
            }
            $name .= '\\' . $val;
        }

        return $name;
    }

    private function removeNamespace(string $class): string
    {
        $segment = explode('\\', $class);
        return $segment[count($segment) - 1];
    }

組み立てる

必要なメソッドが大体そろってきたので、組み立てをします。

src/Generator/ImplementGenerator.php
    public function output(string $filename)
    {
        $interface_name = $this->interface->getName();
        $this->uses[] = $interface_name;
        $methods = $this->analyzeMethods();
        $text = $this->buildText($filename, $methods);
        file_put_contents('src/' . $filename . '.php', $text);
    }

これがファイルを生成するためのアクションメソッドになります。

$gen = new ImplementGenerator;
$gen->from(Contract::class)->output('Imple/Something');

こんな風に使えればいいかなって思います。

あとは未実装のメソッドであるbuildTextを実装します。

private function buildText(string $filename, array $methods)
{
    $text = "<?php\n";
    $text .= 'namespace ' . $this->getNameSpace($filename) . ";\n\n";

    // use の部分書き連ねる
    $this->uses = array_unique($this->uses);
    foreach ($this->uses as $use) {
        $text .= 'use ' . $use . ";\n";
    }

    // クラス名とインターフェースを書く
    $text .= "\n" . 'class ' . $this->getClassName($filename) . ' implements ' . $this->removeNamespace($this->interface->getName());
    $text .= "\n{";
    $indent = str_repeat(' ', self::INDENT);

    // メソッドのひな型を作る
    foreach ($methods as $method) {
        $text .= "\n" . $indent . $method['modifier'] . ' function ' . $method['name'] . '(';
        $args = [];

        // 引数を入れる
        foreach ($method['args'] as $arg) {
            $args[] .= (($arg['type']) ? $arg['type'] . ' ' : '')
                . ('$' . $arg['name'])
                . (($arg['optional']) ? ' = ' . $arg['default'] : ''); 
        }
        $text .= implode(', ', $args) . ')';

        // メソッドの型
        $text .= ($method['return_type']) ? ': ' . $method['return_type'] : '';
        $text .= "\n" . $indent . "{\n\n" . $indent . "}\n"; 
    }
    $text .= '}';
    return $text;
}

頭の痛くなる泥臭い文字列結合ですね。
最終コードはgistに置いておきます。

動かす

最後に動かして確認してみます。

generator.php
require 'vendor/autoload.php';

use Niisan\App\Contract;
use Niisan\App\Generator\ImplementGenerator;

$gen = new ImplementGenerator;

$gen->from(Contract::class)->output('Imple/Something');

これを実行して作られるファイルがこんなやつです。

src/Imple/Something.php
<?php
namespace Niisan\App\Imple;

use Niisan\App\Contract;
use Niisan\App\Vector;

class Something implements Contract
{
    public function plus(Vector $a, Vector $b, ?Vector $c): Vector
    {

    }

    public function tekito(Vector $a, ?int $num, ?string $word = 'default')
    {

    }
}

とりあえず第一目標は達成できたかなって感じですね。

まとめ

というわけで、最近物量の多さに動きを変える必要があると考え、コードジェネレータを作るための準備をしています。
今回はinterfaceをもとにクラスのひな型を作る、あまり面白みのないやつですが、ここからより実践的なものに発展させていければと思います。

こんなところですかね。

4
4
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?