もがき系プログラマの日常

もがき系エンジニアの勉強したこと、日常のこと、気になっている技術、備忘録などを紹介するブログです。

object-mapperで配列からオブジェクトへの簡単マッピング

はじめに

こんばんは。

最近やっとフレームワークからドメイン(関心事)の切り離しがわかってきた僕です。

切り離しに関して僕的に一番めんどくさいのがORM(データベース)です。

LaravelだとEloquentです。

EloquentデータからEntityへのマッピングで結構泥臭くさくやらないとだめですよね。

新原さんのコアレイヤパターンとかでも以下みたいな感じで、入れ直しています。

github.com

f:id:kojirooooocks:20191105020750p:plain

f:id:kojirooooocks:20191105020804p:plain

データの数が少ない場合は問題ないんですが、10〜15個とか膨大な数になっちゃうとケアレスミスも起きやすいし、何よりめんどくさい。

この解決方法ないかな〜と考えていたら、自分の師匠である @polidog さんが作ってくれました。

github.com

やってみた

例えばこんなEntityクラスがあるとします。

User.php

<?php

namespace Kojirock5260\Entity;

class User
{
    /**
     * @var int
     */
    private $id;

    /**
     * @var string
     */
    private $email;

    /**
     * @var UserProfile
     */
    private $userProfile;
}

UserProfile.php

<?php

namespace Kojirock5260\Entity;

class UserProfile
{
    /**
     * @var int
     */
    private $id;

    /**
     * @var int
     */
    private $userId;

    /**
     * @var string
     */
    private $name;

    /**
     * @var \DateTime
     */
    private $birthday;

    /**
     * @var int
     */
    private $sex;
}

先程あげたコアレイヤパターン的な感じで行くならば、 User.php に以下みたいなメソッドを生やす感じになると思います。

public static function ofByArray(array $values)
{
    return new self(
        $values['id'] ?? 0,
        $values['email'] ?? '',
        UserProfile::ofByArray($values['userProfile'] ?? [])
    );
}

このあたりを object-mapperで書き直すとこうなります。

<?php

$parser = new \Helicon\ObjectTypeParser\Parser();
$resolver = new \Helicon\TypeConverter\Resolver();
$hydrator = new \Zend\Hydrator\ReflectionHydrator();

$resolver->addConverter(new \Helicon\TypeConverter\TypeCaster\ScalarTypeCaster());
$resolver->addConverter(new \Helicon\TypeConverter\TypeCaster\DateTimeCaster());
$resolver->addConverter(new \Helicon\TypeConverter\TypeCaster\ClassTypeCaster($resolver, $parser, $hydrator));
$converter = new \Helicon\TypeConverter\Converter($resolver);

$mapper = new \Helicon\ObjectMapper\ObjectMapper($converter, $parser, $hydrator);
$user   = ($mapper)($data, \Kojirock5260\Entity\User::class);

結果

 $ php example.php 
/path/to/example.php:30:
class Kojirock5260\Entity\User#61 (3) {
  private $id =>
  int(100)
  private $email =>
  string(17) "example@gmail.com"
  private $userProfile =>
  class Kojirock5260\Entity\UserProfile#45 (5) {
    private $id =>
    int(1)
    private $userId =>
    int(1)
    private $name =>
    string(8) "kojirock"
    private $birthday =>
    class DateTime#64 (3) {
      public $date =>
      string(26) "1990-01-01 00:00:00.000000"
      public $timezone_type =>
      int(3)
      public $timezone =>
      string(3) "UTC"
    }
    private $sex =>
    int(2)
  }
}

実質的なマッピングは以下の行です。

$user   = ($mapper)($data, \Kojirock5260\Entity\User::class);

前段でもろもろ準備が必要ですけど、その点はうまくメソッド化すれば問題ないと思います。

DocCommentでマッピングが解決できるのがとにかく素敵ですよね。

拡張してみた(Carbon編)

拡張性が高いもの魅力の一つです。

例えば \DateTime ではなく Carbonを使いたいとなった場合、オリジナルのTypeCasterを作成することで解決できます。

CarbonTypeCaster.php

<?php


namespace Kojirock5260\Caster;

use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Helicon\TypeConverter\TypeCaster\TypeCasterInterface;

class CarbonTypeCaster implements TypeCasterInterface
{
    public function convert($value, string $type)
    {
        return new $type($value);
    }

    public function supports(string $type): bool
    {
        return Carbon::class === $type || CarbonImmutable::class === $type;
    }
}

UserProfile.php$birthday のDocCommentを変更します。

    /**
     * @var \DateTime
     */
    private $birthday;

    ↓

    /**
     * @var CarbonImmutable
     */
    private $birthday;

セットしていたDateTimeTypeCasterをCarbonTypeCasterへ差し替えます。

$resolver->addConverter(new \Helicon\TypeConverter\TypeCaster\DateTimeCaster());

↓

$resolver->addConverter(new \Kojirock5260\Caster\CarbonTypeCaster());

結果

$ php example.php 
/path/to/example.php:30:
class Kojirock5260\Entity\User#61 (3) {
  private $id =>
  int(100)
  private $email =>
  string(17) "example@gmail.com"
  private $userProfile =>
  class Kojirock5260\Entity\UserProfile#45 (5) {
    private $id =>
    int(1)
    private $userId =>
    int(1)
    private $name =>
    string(8) "kojirock"
    private $birthday =>
    class Carbon\CarbonImmutable#64 (3) {
      public $date =>
      string(26) "1990-01-01 00:00:00.000000"
      public $timezone_type =>
      int(3)
      public $timezone =>
      string(3) "UTC"
    }
    private $sex =>
    int(2)
  }
}

$birthday がCarbonImmutableに差し替わっています。

拡張してみた(ValueObject編)

例えば email とかは、ValueObjectのように最小単位の値クラスを作って管理したいです。

以下みたいなValueObjectがあるとします。

MailAddress.php

<?php

namespace Kojirock5260\ValueObject;

class MailAddress implements ValueObjectInterface
{
    /**
     * @var string
     */
    private $value;

    /**
     * MailAddress constructor.
     * @param string $value
     * @throws \InvalidArgumentException
     */
    public function __construct(string $value)
    {
        $this->value = $value;
        if (!$this->valid()) {
            throw new \InvalidArgumentException('invalid mail address');
        }
    }

    /**
     * @return string
     */
    public function value():string
    {
        return $this->value;
    }

    /**
     * @return bool
     */
    public function valid():bool
    {
        $result = filter_var($this->value, FILTER_VALIDATE_EMAIL);
        return $result === false ? false : true;
    }
}

この辺どうやるのだろう...。

と思って polidog師匠に相談すると F1レベルの速さで返事がコード付きで返ってきました。

こちらもCarbon同様拡張でどうにでもなるようです。

ValueObjectTypeCaster.php

<?php


namespace Kojirock5260\Caster;


use Helicon\ObjectTypeParser\Parser;
use Helicon\TypeConverter\Resolver;
use Helicon\TypeConverter\TypeCaster\TypeCasterInterface;
use Kojirock5260\ValueObject\ValueObjectInterface;
use Zend\Hydrator\ReflectionHydrator;

class ValueObjectTypeCaster implements TypeCasterInterface
{
    /**
     * @var Resolver
     */
    private $resolver;

    /**
     * @var Parser
     */
    private $parser;

    /**
     * @var ReflectionHydrator
     */
    private $reflectionHydrator;

    /**
     * @param Resolver           $resolver
     * @param Parser $parser
     * @param ReflectionHydrator $reflectionHydrator
     */
    public function __construct(Resolver $resolver, Parser $parser, ReflectionHydrator $reflectionHydrator)
    {
        $this->resolver = $resolver;
        $this->parser = $parser;
        $this->reflectionHydrator = $reflectionHydrator;
    }

    /**
     * @param $value
     * @param string $type
     * @return mixed|object|\Zend\Hydrator\object
     * @throws \ReflectionException
     */
    public function convert($value, string $type)
    {
        $refClass = new \ReflectionClass($type);
        $schemas = ($this->parser)($type);

        $type = $schemas['value']['type'];
        $convertedValue = $this->resolver->resolve($type)->convert($value, $type);

        return $this->reflectionHydrator->hydrate([
            'value' => $convertedValue,
        ], $refClass->newInstanceWithoutConstructor());
    }

    /**
     * @param string $type
     * @return bool
     * @throws \ReflectionException
     */
    public function supports(string $type): bool
    {
        $refClass = new \ReflectionClass($type);
        if ($refClass->isSubclassOf(ValueObjectInterface::class)) {
            return true;
        }

        return false;
    }
}

User.phpemail のDocCommentを修正します。

    /**
     * @var string
     */
    private $email;

    ↓

    /**
     * @var MailAddress
     */
    private $email;

先程作ったValueObjectTypeCasterをConverterとして追加します。

$resolver->addConverter(new \Kojirock5260\Caster\ValueObjectTypeCaster($resolver, $parser, $hydrator));

結果

$ php example.php 
/path/to/example.php:31:
class Kojirock5260\Entity\User#62 (3) {
  private $id =>
  int(100)
  private $email =>
  class Kojirock5260\ValueObject\MailAddress#49 (1) {
    private $value =>
    string(18) "example@gmail.com"
  }
  private $userProfile =>
  class Kojirock5260\Entity\UserProfile#54 (5) {
    private $id =>
    int(1)
    private $userId =>
    int(1)
    private $name =>
    string(8) "kojirock"
    private $birthday =>
    class Carbon\CarbonImmutable#68 (3) {
      public $date =>
      string(26) "1990-01-01 00:00:00.000000"
      public $timezone_type =>
      int(3)
      public $timezone =>
      string(3) "UTC"
    }
    private $sex =>
    int(2)
  }
}

email が指定のValueObjectのクラス MailAddress としてマッピングされています!

終わりに

こんな感じでマッピングが狙ったとおりにきれいにマッピングされるのが object-mapperです。

早速自分のプロジェクトでも推してみようと思います。

今回試したサンプルプロジェクトはgithubに上げておきます。

github.com

polidog師匠ありがとうございました。