league/object-mapper

将结构化数据转换为严格的对象。

dev-main 2023-12-16 09:26 UTC

This package is auto-updated.

Last update: 2024-09-16 11:17:21 UTC


README

安装

composer require league/object-mapper

跳转到 使用文档

关于

这个库允许无魔法转换从序列化数据到对象及其反向转换。与其它对象映射库不同,这个库不依赖于魔法反射来设置私有属性。它将对象湿化和序列化,就像手动操作一样。湿化机制检查构造函数,确定哪些键需要映射到哪些属性。序列化机制检查所有公共属性和getter方法,将对象的值转换为普通数据结构。与能够获取私有属性的“魔法”湿化机制不同,这种映射对象的方式开启了无需反射进行对象映射的大门。您将获得所有便利,没有任何罪恶(或性能损失)。

这是一个将结构化请求数据(例如:解码后的JSON)转换为复杂对象结构的实用工具。这个实用工具的预期用途是接收请求数据并将其转换为命令或查询对象。这个库被设计为遵循约定并且不验证输入。

何时以及为什么使用这个工具?

这是一个很好的问题,让我们深入探讨。最初,这个库被创建来将平面数据(如JSON请求体)映射到严格的对象结构。使用对象(DTOs、查询和命令对象)是创建易于理解的表达性代码的好方法。可以信任对象正确地表示域中的概念。使用这些对象的缺点是它们可能会很繁琐。构造和序列化变得重复,反复编写相同的代码很无聊。这个库旨在移除对象湿化和序列化中的无聊部分。

这个库是针对以下两个特定用例构建的

  1. DTOs、查询对象和命令对象的构建。
  2. 事件对象的序列化和湿化。

由于使用代码生成在编译时进行解析,对象湿化和序列化可以在零成本下实现。

快速链接

设计目标

在创建这个包时,有几个设计目标需要考虑。它们如下

  • 对象创建不应该太“魔法”(使用无反射的实例化)
  • 不应有关于反射的硬运行时要求
  • 构造的对象应该从构造时就是有效的
  • 支持通过(静态)命名构造函数进行构造

使用方法

这个库支持对象的湿化和序列化。

湿化使用

默认情况下,输入通过属性名进行映射,并且类型需要匹配。默认情况下,键从snake_case输入映射到camelCase属性。如果您想保留键名,可以使用 `KeyFormatterWithoutConversion`

use League\ObjectMapper\ObjectMapperUsingReflection;

$mapper = new ObjectMapperUsingReflection();

class ExampleCommand
{
    public function __construct(
        public readonly string $name,
        public readonly int $birthYear,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'name' => 'de Jonge',
        'birth_year' => 1987
    ],
);

$command->name === 'de Jonge';
$command->birthYear === 1987;

复杂对象将自动解析。

class ChildObject
{
    public function __construct(
        public readonly string $value,
    ) {}
}

class ParentObject
{
    public function __construct(
        public readonly string $value,
        public readonly ChildObject $child,
    ) {}
}

$command = $mapper->hydrateObject(
    ParentObject::class,
    [
        'value' => 'parent value',
        'child' => [
            'value' => 'child value',
        ]
    ],
);

简单的文档注释确保对象数组会自动转换。

class ChildObject
{
    public function __construct(
        public readonly string $value,
    ) {}
}

class ParentObject
{
    /**
     * @param ChildObject[] $list
     */
    public function __construct(
        public readonly array $list,
    ) {}
}

$object = $mapper->hydrateObject(ParentObject::class, [
  'list' => [
    ['value' => 'one'],
    ['value' => 'two'],
  ],
]);

$object->list[0]->value === 'one';
$object->list[1]->value === 'two';

该库支持以下格式

  • @param Type[] $name
  • @param array<Type> $name
  • @param array<string, Type> $name
  • @param array<int, Type> $name

自定义映射键

use League\ObjectMapper\MapFrom;

class ExampleCommand
{
    public function __construct(
        public readonly string $name,
        #[MapFrom('year')]
        public readonly int $birthYear,
    ) {}
}

从多个键映射

您可以将数组传递以捕获来自多个输入键的输入。当多个值代表单一代码概念时,这很有用。数组还允许您重命名键,进一步解耦输入和构建的对象图。

use League\ObjectMapper\MapFrom;

class BirthDate
{
    public function __construct(
        public int $year,
        public int $month,
        public int $day
    ){}
}

class ExampleCommand
{
    public function __construct(
        public readonly string $name,
        #[MapFrom(['year_of_birth' => 'year', 'month', 'day'])]
        public readonly BirthDate $birthDate,
    ) {}
}

$mapper->hydrateObject(ExampleCommand::class, [
    'name' => 'Frank',
    'year_of_birth' => 1987,
    'month' => 11,
    'day' => 24,
]);

属性类型转换

当输入类型和属性类型不兼容时,值可以转换为特定的标量类型。

转换为标量值

use League\ObjectMapper\PropertyCasters\CastToType;

class ExampleCommand
{
    public function __construct(
        #[CastToType('integer')]
        public readonly int $number,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'number' => '1234',
    ],
);

转换为标量值列表

use League\ObjectMapper\PropertyCasters\CastListToType;

class ExampleCommand
{
    public function __construct(
        #[CastListToType('integer')]
        public readonly array $numbers,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'numbers' => ['1234', '2345'],
    ],
);

转换为对象列表

use League\ObjectMapper\PropertyCasters\CastListToType;

class Member
{
    public function __construct(
        public readonly string $name,
    ) {}
}

class ExampleCommand
{
    public function __construct(
        #[CastListToType(Member::class)]
        public readonly array $members,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'members' => [
            ['name' => 'Frank'],
            ['name' => 'Renske'],
        ],
    ],
);

转换为DateTimeImmutable对象

use League\ObjectMapper\PropertyCasters\CastToDateTimeImmutable;

class ExampleCommand
{
    public function __construct(
        #[CastToDateTimeImmutable('!Y-m-d')]
        public readonly DateTimeImmutable $birthDate,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'birthDate' => '1987-11-24',
    ],
);

转换为Uuid对象(ramsey/uuid)

use League\ObjectMapper\PropertyCasters\CastToUuid;
use Ramsey\Uuid\UuidInterface;

class ExampleCommand
{
    public function __construct(
        #[CastToUuid]
        public readonly UuidInterface $id,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'id' => '9f960d77-7c9b-4bfd-9fc4-62d141efc7e5',
    ],
);

每个属性使用多个转换器

通过使用多个转换器,创建丰富的转换组合。

use League\ObjectMapper\PropertyCasters\CastToArrayWithKey;
use League\ObjectMapper\PropertyCasters\CastToType;
use League\ObjectMapper\MapFrom;
use Ramsey\Uuid\UuidInterface;

class ExampleCommand
{
    public function __construct(
        #[CastToType('string')]
        #[CastToArrayWithKey('nested')]
        #[MapFrom('number')]
        public readonly array $stringNumbers,
    ) {}
}

$command = $mapper->hydrateObject(
    ExampleCommand::class,
    [
        'number' => [1234],
    ],
);

$command->stringNumbers === ['nested' => [1234]];

创建自己的属性转换器

您可以创建自己的属性转换器来处理无法遵循默认约定的复杂情况。转换器的常见情况是 联合 类型或 交集 类型。

属性转换器让您完全控制属性是如何构建的。属性转换器通过属性附加到属性上,实际上,它们 就是 属性。

让我们看看属性转换器的一个例子

use Attribute;
use League\ObjectMapper\ObjectMapper;
use League\ObjectMapper\PropertyCaster;

#[Attribute(Attribute::TARGET_PARAMETER)]
class CastToMoney implements PropertyCaster
{
    public function __construct(
        private string $currency
    ) {}

    public function cast(mixed $value, ObjectMapper $mapper) : mixed
    {
        return new Money($value, Currency::fromString($this->currency));
    }
}

// ----------------------------------------------------------------------

#[Attribute(Attribute::TARGET_PARAMETER)]
class CastUnionToType implements PropertyCaster
{
    public function __construct(
        private array $typeToClassMap
    ) {}

    public function cast(mixed $value, ObjectMapper $mapper) : mixed
    {
        assert(is_array($value));

        $type = $value['type'] ?? 'unknown';
        unset($value['type']);
        $className = $this->typeToClassMap[$type] ?? null;

        if ($className === null) {
            throw new LogicException("Unable to map type '$type' to class.");
        }

        return $mapper->hydrateObject($className, $value);
    }
}

现在您可以将这些用作您希望填充的对象上的属性

class ExampleCommand
{
    public function __construct(
        #[CastToMoney('EUR')]
        public readonly Money $money,
        #[CastUnionToType(['some' => SomeObject::class, 'other' => OtherObject::class])]
        public readonly SomeObject|OtherObject $money,
    ) {}
}

静态构造函数

支持需要通过静态构造来构建的对象。使用 Constructor 属性标记静态方法。在这些情况下,属性应该放在静态构造函数的参数上,而不是放在 __construct 上。

use League\ObjectMapper\Constructor;
use League\ObjectMapper\MapFrom;

class ExampleCommand
{
    private function __construct(
        public readonly string $value,
    ) {}

    #[Constructor]
    public static function create(
        #[MapFrom('some_value')]
        string $value
    ): static {
        return new static($value);
    }
}

键格式化

默认情况下,键是从 snake_case 输入转换为 camelCase 属性。但是,您可以通过向传递给对象映射构造函数的 DefinitionProvider 传递一个键格式化程序来控制键的映射方式。

use League\ObjectMapper\DefinitionProvider;
use League\ObjectMapper\KeyFormatterWithoutConversion;
use League\ObjectMapper\ObjectMapperUsingReflection;

$mapper = new ObjectMapperUsingReflection(
    new DefinitionProvider(
        keyFormatter: new KeyFormatterWithoutConversion(),
    ),
);

此库附带了一个 KeyFormatterWithoutConversion,在不需要转换的情况下可以使用。如果您需要自定义转换逻辑,可以实现 KeyFormatter 接口。

序列化使用

默认情况下,此库将公共属性和获取器映射到 snake_cased 数组,其中包含纯数据。当遇到用户定义的对象时,这些对象会自动转换为纯数据对应的对象。

class ExampleCommand
{
    public function __construct(
        public readonly string $name,
        public readonly int $birthYear,
    ) {}
}


$command = new ExampleCommand('de Jonge', 1987);
$payload = $mapper->serializeObject($command);

$payload['name'] === 'de Jonge';
$payload['birth_year'] === 1987;

自定义键映射

序列化以对称方式反转了填充过程中使用的键映射,包括多个键的映射。

use League\ObjectMapper\MapFrom;

class BirthDate
{
    public function __construct(
        public int $year,
        public int $month,
        public int $day
    ){}
}

class ExampleCommand
{
    public function __construct(
        #[MapFrom('my_name')]
        public readonly string $name,
        #[MapFrom(['year_of_birth' => 'year', 'month', 'day'])]
        public readonly BirthDate $birthDate,
    ) {}
}

$command = new ExampleCommand(
  'de Jonge',
  new BirthDate(1987, 11, 24)
);

$payload = $mapper->serializeObject($command);

$payload['my_name'] === 'de Jonge';
$payload['year_of_birth'] === 1987;
$payload['month'] === 11;
$payload['day'] === 24;

属性序列化

类似于转换器,可以通过使用“属性序列化器”添加自定义序列化逻辑。属性序列化是自定义注解,它提供了对序列化过程的钩子,允许您在需要时完全控制序列化机制。

use League\ObjectMapper\ObjectMapper;
use League\ObjectMapper\PropertySerializer;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)]
class UppercaseString implements PropertySerializer
{
    public function serialize(mixed $value, ObjectMapper $mapper): string
    {
        assert(is_string($value));
        
        return strtoupper($value);
    }
}

class Shout
{
    public function __construct(
        private readonly string $message
    ) {}
    
    #[UppercaseString]
    public function what(): string
    {
        return $this->message();
    }
}

$payload = $mapper->serializeObject(new Shout('Hello, World!');

$payload['what'] === 'HELLO, WORLD!';

对称转换

如果配置一致,填充和序列化可以用来将对象转换为原始数据,然后再转换回原始对象。一个类可以实现 PropertyCasterPropertySerializer 接口,成为一个对称转换机制。

CastToArrayWithKey 的实现就是一个例子

use League\ObjectMapper\ObjectMapper;
use League\ObjectMapper\PropertyCaster;
use League\ObjectMapper\PropertySerializer;

class CastToArrayWithKey implements PropertyCaster, PropertySerializer
{
    public function __construct(private string $key)
    {
    }

    public function cast(mixed $value, ObjectMapper $mapper): mixed
    {
        return [$this->key => $value];
    }

    public function serialize(mixed $value, ObjectMapper $mapper): mixed
    {
        if (is_object($value)) {
            $value = $hydrator->serializeObject($value);
        }

        return $value[$this->key] ?? null;
    }
}

重要的是要知道,序列化和填充钩子在发生任何默认转换之前被触发。如果您希望在序列化或填充的数据上操作,您可以填充/序列化内部数据/对象。

⚠️ 转换器和序列化器顺序

为了使填充和序列化对称(允许双向转换),对于 提升 属性,调用序列化器的顺序是相反的。

省略序列化数据

可以省略序列化中的数据。默认情况下,所有公共方法和属性都包括在序列化中。有两种方法可以排除数据,两种方法都使用属性。

可以在类级别指定 MapperSettings 属性以排除所有公共属性、公共方法或两者(最后一个实际上是无用的)。

use League\ObjectMapper\MapperSettings;

#[MapperSettings(serializePublicMethods: false)]
class ClassThatDoesNotSerializePublicMethods
{
    // class body
}

#[MapperSettings(serializePublicProperties: false)]
class ClassThatDoesNotSerializePublicProperties
{
    // class body
}

在由类实现的接口上也可以指定 MapperSettings 属性。在这种情况下,将使用第一个可用的 MapperSettings

use League\ObjectMapper\MapperSettings;

#[MapperSettings(serializePublicMethods: false)]
interface InterfaceThatDoesNotSerializePublicMethods
{
    public function resource(): string;
}

class ClassThatInheritsMapperSettingsFromInterface implements InterfaceThatDoesNotSerializePublicMethods
{
    public function resource(): string
    {
        return 'foo';
    }
}

⚠️ 注意,父类中定义的 MapperSettings 不会被使用,以防止混淆并确保与类继承保持一致的性能。

或者,可以使用 DoNotSerialize 属性排除特定的方法和属性以进行序列化。

use League\ObjectMapper\DoNotSerialize;

class ClassThatExcludesCertainDataPoints
{
    public function __construct(
        public string $includedProperty,
        #[DoNotSerialize]
        public string $excludedProperty,
    ) {}
    
    public function includedMethod(): string
    {
        return 'included';
    }
    
    #[DoNotSerialize]
    public function excludedMethod(): string
    {
        return 'excluded';
    }
}

最大化性能

在热路径中,反射和动态代码路径可能会导致性能问题。为了消除这种开销,可以生成优化的实现。生成的PHP代码执行与基于反射的实现相同的类的水化和序列化。它相当于这个逻辑的预编译器。

可以为已知的一组类生成一个完全优化的映射器。生成器将生成构建整个对象树所需的代码,并自动解决其必须映射的嵌套属性。

生成的代码比基于反射的实现快 3-10倍

use League\ObjectMapper\ObjectMapper;
use League\ObjectMapper\ObjectMapperCodeGenerator;

$dumpedClassNamed = "AcmeCorp\\YourOptimizedMapper";
$dumper = new ObjectMapperCodeGenerator();
$classesToDump = [SomeCommand::class, AnotherCommand::class];

$code = $dumper->dump($classesToDump, $dumpedClassNamed);
file_put_contents('src/AcmeCorp/YourOptimizedMapper.php', $code);

/** @var ObjectMapper $mapper */
$mapper = new AcmeCorp\YourOptimizedMapper();
$someObject = $mapper->hydrateObject(SomeObject::class, $payload);

提示:使用 league/construct-finder

您可以使用来自PHP League的构建查找器包来查找给定目录中的所有类。

composer require league/construct-finder
$classesToDump = ConstructFinder::locatedIn($directoryName)->findClassNames();

替代方案

此包并非独一无二,还有几个实现与此包相同、相似或更强大的实现。