こんにちは、happy_ryo です。仕事で Laravel を書くことが増えた事により、Laravel に関して調べる機会がぼちぼちあるのですが、今回はテストの時にダミーのデータを持ったインスタンス作成するときに利用する Eloquent の Factory(以下 Factory) が、どのようにしてインスタンスを生成しているのかを調べて見た(ソースコードを読んだ感想)記事を書きます。
対象のバージョンは Laravel 5.4 です。文中で EloquentFactory という名前のクラスが出て来ますが、Laravel には各パッケージ毎と言って良いほどに大量の Factory という名前のクラスがあり、衝突を避けるために定義されたエイリアスです。
そもそも Factory とは
以下のような定義を PHP ファイルに記載することで、Factory 内にクラスとコールバック関数の組み合わせが保存され、テストコード内で factory(App\User::class)->make();
といったコードを記述することで、対象となるモデルにデータがセットされたインスタンスを取得する事ができる、という物です。
1 2 3 4 5 6 7 8 |
<?php /** @var \Illuminate\Database\Eloquent\Factory $factory */ $factory->define(App\Models\Client::class, function (Faker\Generator $faker) { return [ 'name' => $faker->name, 'phone_number' => $faker->phoneNumber, ]; }); |
ちなみに、$factory->define();
の中ではこの様に、Factory 内の配列に定義した内容を保存しているだけです。サンプルやドキュメントでは生成部分でだけ触れられていて、定義に関しては触れられていないのですが、生成するモデルの内容をタイプ別にわけたい場合は、第3引数に定義名を渡す事によって同一のモデルに対してタイプ別に複数の定義を登録する事ができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * Define a class with a given set of attributes. * * @param string $class * @param callable $attributes * @param string $name * @return $this */ public function define($class, callable $attributes, $name = 'default') { $this->definitions[$class][$name] = $attributes; return $this; } |
実装を追いかけてみる
それでは、実装を追いかけてみます。
そもそも $factory はどこから来ているのか。
先述のコード内では、$factory のインスタンスを自身では生成していません。Laravel では DI を採用しているため、ルールに則ってコードを記述すればインスタンスの生成の一部を Laravel に任せることができます。その仕組自体は今回は割愛しますが、コンテナに対して Factory を紐付ける処理は Illuminate\Database\DatabaseServiceProvider
で行われていました。基本的に Factory は Faker と合わせて利用される為、以下のように同じタイミングで処理が行われており、Factory に対しては Faker のインスタンスと Factory の定義ファイルの置かれている場所のパスが渡されるようになっています。Laravel はテストの実行時、自動的に APP_ENV を testing
にする機能がありますが、そのような分岐はなくテスト実行時以外にもコンテナに対して Factory は登録されているようです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/** * Register the Eloquent factory instance in the ainer. * * @return void */ protected function registerEloquentFactory() { $this->app->singleton(FakerGenerator::class, function p) { return FakerFactory::create($app['config']->get('app.faker_locale', 'en_US')); }); $this->app->singleton(EloquentFactory::class, function p) { return EloquentFactory::construct( $app->make(FakerGenerator::class), $this->app->databasePath('factories') ); }); } |
どのようにして定義したものを管理しているか
Laravel の Factory は、Factory のインスタンスが生成されるタイミングでコンストラクタに渡された、Factory の定義ファイルの置かれているパス内にある php ファイルを読み込んで、Factory 自身のなかで require したのちに、自分自身のインスタンスを返します。以下のコードが Factory の生成時に実行されています。ココまでで、一旦全ての定義ファイルが Factory の管理下に置かれ、データセット済みのモデルのインスタンスを生成する準備が整いました。
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 |
/** * Create a new factory container. * * @param \Faker\Generator $faker * @param string|null $pathToFactories * @return static */ public static function construct(Faker $faker, $pathToFactories = null) { $pathToFactories = $pathToFactories ?: database_path('factories'); return (new static($faker))->load($pathToFactories); } /** * Load factories from path. * * @param string $path * @return $this */ public function load($path) { $factory = $this; if (is_dir($path)) { foreach (Finder::create()->files()->name('*.php')->in($path) as $file) { require $file->getRealPath(); } } return $factory; } |
インスタンス生成の為のビルダーをつくる
ここまで来てやっと factory(User::class)->make();
の部分を見ていきます、ちなみにこの factory()
という関数は、composer が autoload している helper.php
で以下のように定義されています。app()
を利用してコンテナから Factory のインスタンスを取得し、factory()
に渡された引数の数によって処理が分岐しています。factory()
は最大で3つの引数を受け取り、全てのパターンで共通してコールされている of()
は引数にわたされたモデルのインスタンスを生成するための FactoryBuilder を生成するメソッドであり、第2引数として文字列が渡された場合はデフォルトではない、名前付きの定義を利用する FactoryBuilder を返します。第2引数に文字列以外(数値)が渡された場合 FactoryBuilder の times()
に引数として渡され、FactoryBuilder がインスタンスを生成して呼び出し元に返す数を指定することができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
if (! function_exists('factory')) { /** * Create a model factory builder for a given class, name, and amount. * * @param dynamic class|class,name|class,amount|class,name,amount * @return \Illuminate\Database\Eloquent\FactoryBuilder */ function factory() { $factory = app(EloquentFactory::class); $arguments = func_get_args(); if (isset($arguments[1]) && is_string($arguments[1])) { return $factory->of($arguments[0], $arguments[1])->times(isset($arguments[2]) ? $arguments[2] : null); } elseif (isset($arguments[1])) { return $factory->of($arguments[0])->times($arguments[1]); } else { return $factory->of($arguments[0]); } } } |
インスタンス生成!
factory(User::class)->make();
の make()
部分です。ここで、当初の目的だった実際のインスタンス生成が行われています。内部で利用されている amount
は factory()
の第2引数に数値を渡した場合にその値がセットされた状態になっています。amount
に何もセットされていない場合は、そのままインスタンスを生成する makeInstance()
へ、1以下の値がセットされている場合は空のコレクションを返し、そのどちらでも無い場合はセットされた数分のインスタンスを makeInstance()
で生成して返すようになっています。make()
は引数を受け取る事が可能であり、ここにモデルのプロパティのキーと値の配列を渡すことで登録されている定義情報に値を上書きすることが可能です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
/** * Create a collection of models. * * @param array $attributes * @return mixed */ public function make(array $attributes = []) { if ($this->amount === null) { return $this->makeInstance($attributes); } if ($this->amount < 1) { return (new $this->class)->newCollection(); } return (new $this->class)->newCollection(array_map(function () use ($attributes) { return $this->makeInstance($attributes); }, range(1, $this->amount))); } |
makeInstance()
内では、モデルで定義されている複数代入禁止設定($guard
)による生成失敗を予防するため Model:: unguarded()
を利用して該当機能を無効化しています。最後に make()
に渡された引数を getRawAttributes()
に渡し、FactoryBuilder に設定されたモデルクラスと定義名を利用して、Factory より引き渡された定義の配列からコールバック関数を取得・実行し、定義されたキーと値の配列をここで作成しています、こうして得られた配列を引数にして最終的なインスタンスが生成され呼び出し元に提供されています。(expandAttributes()
は定義された値が Model だった場合や、クロージャだった場合にそれを適切な値に変える処理をし、applyStates()
は今回触れなかったステートを反映する処理を行っています)
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 |
/** * Make an instance of the model with the given attributes. * * @param array $attributes * @return \Illuminate\Database\Eloquent\Model * * @throws \InvalidArgumentException */ protected function makeInstance(array $attributes = []) { return Model::unguarded(function () use ($attributes) { if (! isset($this->definitions[$this->class][$this->name])) { throw new InvalidArgumentException("Unable to locate factory with name his->name}] [{$this->class}]."); } return new $this->class( $this->getRawAttributes($attributes) ); }); } /** * Get a raw attributes array for the model. * * @param array $attributes * @return mixed */ protected function getRawAttributes(array $attributes = []) { $definition = call_user_func( $this->definitions[$this->class][$this->name], $this->faker, $attributes ); return $this->expandAttributes( array_merge($this->applyStates($definition, $attributes), $attributes) ); } |
感想
一部を除いて、引数の方などもしっかり定義されていて、このくらいの規模であれば結構読みやすいと感じました。(が、index.php周りは結構巨大でつらい)途中、さらっと触れましたが Laravel は割とリッチなフレームワークで、起動時にオーバーヘッドがボチボチあるので、その点が気になる場合は別のフレームワークを選択するか、もしくはその後のバージョンアップ時に余り辛くならない手段でのカスタマイズが必要だと改めて感じました。
記事中で引用したソースコードは全て Laravel 5.4 のもので、ライセンスは MIT になります。
営業→工場で作業→Word&Excel→Java→shellscript→Java→PHP→Python→Objective-C→Swift→PHP→JavaScript→ベトナム 子会社CTO→本社CTO←イマココ