slepic/value-object

简单的值对象和枚举

v0.2.0 2020-07-08 07:09 UTC

This package is auto-updated.

Last update: 2024-09-13 19:39:20 UTC


README

PHP 值对象

需求

  • PHP >=7.4

安装

composer require slepic/value-object

介绍

此库旨在提供所有值对象的共同特性的统一。此外,它还提供了一些可以利用统一环境的功能

  • 统一的语言来描述违规行为,可以与验证系统集成
  • 具有限制的枚举、集合和标量类型的常用基值对象
  • 接口以统一从和到原始类型或甚至对象之间的转换
  • 自动使用它们的类定义构建复合值对象

什么是值对象?

值对象是有效性的守护者。一旦值对象被构建,它就包含有效数据。只要该对象存在,我们就不需要再次验证它。

final class FullName
{
  public string $firstName;
  public string $surname;

  public function __construct(string $firstName, string $surname)
  {
    if ($firstName === '') {
      throw \InvalidArgumentException(''First name cannot be empty.');
    }

    if ($surname === '') {
      throw new \InvalidArgumentException('Surname cannot be empty.');
    }

    $this->firstName = $firstName;
    $this->surname = $surname;
  }
}

注意:为了简单起见,我在示例中将所有内容公开为公共属性,这允许修改。它们实际上应该是私有的,并带有公共获取器。

值对象有哪些共同点,我们如何统一它们?

  • 它们(或至少应该是)不可变的
    • 我们无法完全强制您创建的值对象是不可变的
    • 我们只能支持您创建不可变对象
    • 我们提供了基类和特质,以防止实现一些可变魔法方法和可变 \ArrayAccess 方法
  • 它们不能构建成无效状态
    • 我们也不能完全强制这一点
    • 我们可以通过提供遵守规则的基础值对象来支持您
  • 尝试使用无效数据构建它们会导致异常
    • 我们可以统一抛出哪种异常
  • 它们可以从原始数据类型构建
    • 我们可以统一值对象如何公开此能力
  • 它们可以转换为原始数据类型
    • 我们可以统一值对象如何公开此能力

我们如何利用统一?

统一错误

由于值对象需要有效状态,它们必须进行检查。这本质上意味着它们正在进行验证,并且它们必须自己执行验证。如果我们只是让值对象抛出 \InvalidArgumentException,并且我们事先进行验证(带有客户端错误报告),我们基本上是在进行两次验证。一次是向客户端提供合理的解释,告诉他在哪里出错,一次是在值对象中进行,以确保它不是从垃圾数据中构建的。如果我们懒惰,我们可能会省略其中一个或两个(在最坏的情况下,两个都省略)。

省略值对象之外的验证意味着我们的值对象可能会抛出,并最终导致500内部服务器错误。省略值对象内部的验证意味着我们将永远无法真正确定它们是有效的。省略它们都是灾难。两者都冗余,可能导致两者之间的不同步。

如果我们的值对象进行验证(它们应该这样做),并提供了统一的方式来描述它们的期望和违反这些期望的情况,那么它们实际上强制您在代码中的任何适当位置使用值对象的逻辑来验证数据

  • 一次
  • 在任何你找到合适的地方
  • 使用值对象的逻辑

值对象表示违规的统一使得应用程序能够将其错误纳入它们的输入验证过程。

然而,这种纳入特定应用程序的方式超出了库的范围。

这个库试图将错误异常统一为ViolationExceptionInterface,并使用getViolations(): array< ViolationInterface >方法。库还提供了一个默认实现ViolationException

ViolationInterface是用于表示违规的标记接口,每个违规类的名称代表一个错误代码。它还允许携带作为属性/获取器暴露的附加信息。这也允许错误代码继承,并避免供应商之间的错误代码冲突。

拥有违规数组也让我们有机会同时报告多个违规。

final class FullName
{
  public string $firstName;
  public string $surname;

  public function __construct(string $firstName, string $surname)
  {
    $violations = []
    if ($firstName === '') {
      $violations[] = new Violation('First name cannot be empty.');
    }

    if ($surname === '') {
      $violations[] = new Violation('Surname cannot be empty.');
    }

    if (\count($violations) > 0) {
      throw new ViolationException($violations);
    }

    $this->firstName = $firstName;
    $this->surname = $surname;
  }
}

try {
  $slimShady = new FullName('Slim', 'Shady');
} catch (ViolationExceptionInterface $e) {
  return $this->processViolations($e->getViolations());
}

该库提供了一系列实现,包括描述集合嵌套错误的某些违规。

统一转换

通常,值对象提供了命名构造函数,允许从原始值构造它们。值对象也可以转换为原始类型的情况并不少见。

该库提供了一组接口,定义了从原始类型到和从原始类型转换的样式。

以下示例中,它们是FromStringConstructableInterfaceToStringConvertibleInterface,分别定义了public static function fromString(string $value): selfpublic function __toString(): string方法。

这为我们提供了一个统一的环境。如果所有可以由单个字符串值构造的值对象都有相同的命名构造函数,那么这将更容易工作。至于转换为字符串,这已经由PHP的魔法方法__toString()覆盖了,但我们还提供了接口,如ToIntConvertibleInterface等,具有类似的名称...

final class FullName implements FromStringConstructableInterface, ToStringConvertibleInterface
{
  // ...

  public static function fromString(string $fullName): FullName
  {
    $parts = \explode(' ', $fullName, 2);
    if (\count($parts) !== 2) {
      throw \InvalidArgumentException('Full name must contain first name, a space and the surname.');
    }
    return new FullName($parts[0], $parts[1]);
  }

  public function __toString(): string
  {
    return $this->firstName . ' ' . $this->surname;
  }
}

$slimShady = FullName::fromString('Slim Shady');
echo (string) $slimShady;

它还允许自动使用它们的类定义来构造复合值对象。请参阅集合部分。

常见值对象

现在,等一下!名字和姓氏都检查同一件事情。让我们去掉这种重复。

final class StringIsEmpty extends Violation
{
  public function __construct(string $message = '')
  {
    parent::__construct($message ?: 'Value cannot be empty');
  }
}

class NonEmptyString
{
  private string $value;

  public function __construct(string $value)
  {
    if ($value === '') {
      throw ViolationException::for(new StringIsEmpty());
    }
    $this->value = $value;
  }

  public function __toString(): string
  {
    return $this->value;
  }
}

final class FullName
{
  public NonEmptyString $firstName;
  public NonEmptyString $surname;

  public function __construct(NonEmptyString $firstName, NonEmptyString $surname)
  {
    $this->firstName = $firstName;
    $this->surname = $surname;
  }
}

我们将检查值空白的责任移至NonEmptyString类。然而,我们失去了具体说明哪个属性为空的灵活性。

这是构造函数调用者的责任,因为他现在正在创建那些NonEmptyString值对象。

让我们看看这种调用者,它以从原始字符串创建对象的工厂的形式出现。它现在管理错误代码和消息,并覆盖默认值,同时使用相同的代码(违规类)。

function createFullName(string $firstName, string $surname): FullName
{
    $violations = [];
    
    try {
      $f = new NonEmptyString($firstName);
    } catch (ViolationExceptionInterface $e) {
      $violations[] = new StringIsEmpty('First name cannot be empty.');
    }
    
    try {
      $s = new NonEmptyString($surname);
    } catch (ViolationExceptionInterface $e) {
      $violations[] = new StringIsEmpty('Surname cannot be empty.');
    }
    
    if (\count($violations) > 0) {
      throw new ViolationException($violations);
    }
    
    return new FullName($f, $s);
}

但我们还增加了一种新的类型,它现在保证是非空字符串。

如果你对违规不是很关心,你可以这样做

function createFullName(string $firstName, string $surname): FullName
{
    return new FullName(
        new NonEmptyString($firstName),
        new NonEmptyString($surname)
    );
}

无论如何,我们现在可以避免在其他地方进行一些检查。

function extractFirstChar(NonEmptyString $text): string
{
  // if we accepted the primitive `string $text` here, we should check its emptiness
  // and throw an exception otherwise. Now we don't have to :)
  return \substr((string) $text, 0, 1);
}

echo extractFirstChar($fullName->firstName) . '.' . extractFirstChar($fullName->surname) . '.';

这有效地强制调用者在某个时刻进行验证,同时让函数只关注其逻辑。

该库提供了一系列基础值对象,它们封装了我们对原始数据类型的一些常见限制。

注意:ViolationInterface实现应仅描述错误,即违反了什么。如果消费者想了解所有可能违反的内容,他们必须直接查看值对象类型本身。

不可变特性

此包提供两个特性,支持值对象的不可变性。

ImmutableObjectTrait - 禁用实现魔法方法__set__unset

ImmutableArrayAccessTrait - 禁用实现ArrayAccess::offsetSetArrayAccess::offsetUnset

这些特性用于本包中的所有基础值对象。并鼓励您在自己的值对象中使用它们。

尽管如此,您仍然可以创建公共属性。实际上,这个包中有一个例外依赖于公共属性 - DataTransferObject类。但除此之外,我们不建议您使用它们,尽管如果您这样做,通常代码会更简洁一些。

您也可以自由地使用反射等方法修改您的对象。所以,在PHP中实现真正的不可变实际上是基本不可能的。但我们会尽最大努力:)

基本值对象

我们经常需要封装原始值并强制实施某种限制,比如限制字符串长度、只允许字符串中的子集字符或限制数字的最大值。

具有这些常见限制的标量对象的简单实现可以在该包中找到。

字符串

  • Slepic\ValueObject\Strings\StringValue
    • 无限制的字符串值对象
    • 违规:StringViolation
  • Slepic\ValueObject\Strings\MaxRawLengthString
    • 具有最大长度(使用strlen)的字符串值对象
    • 子类需要实现 protected static function maxLength(): int
    • 违规:StringTooLong
  • Slepic\ValueObject\Strings\MinRawLengthString
    • 具有最小长度(使用strlen)的字符串值对象
    • 子类需要实现 protected static function minLength(): int
    • 违规:StringTooShort
  • Slepic\ValueObject\Strings\BoundedRawLengthString
    • 具有最小和最大长度(使用strlen)的字符串值对象
    • 子类需要实现 protected static function minLength(): intprotected static function maxLength(): int
    • 违规:StringLengthOutOfBounds
  • Slepic\ValueObject\Strings\MaxMbLengthString
    • 具有最大长度(使用mb_strlen)的字符串值对象
    • 子类需要实现 protected static function maxLength(): int
    • 违规:StringTooLong
  • Slepic\ValueObject\Strings\MinMbLengthString
    • 具有最小长度(使用mb_strlen)的字符串值对象
    • 子类需要实现 protected static function minLength(): int
    • 违规:StringTooShort
  • Slepic\ValueObject\Strings\BoundedMbLengthString
    • 具有最小和最大长度(使用mb_strlen)的字符串值对象
    • 子类需要实现 protected static function minLength(): intprotected static function maxLength(): int
    • 违规:StringLengthOutOfBounds
  • Slepic\ValueObject\Strings\RegexTemplateString
    • 检查值以匹配正则表达式模式的字符串值对象
    • 子类需要实现 protected static function pattern(): string
    • 违规:StringPatternViolation

整数

  • 请参阅Slepic\ValueObject\Integers命名空间

浮点数

  • 请参阅Slepic\ValueObject\Floats命名空间

枚举

  • 请参阅Slepic\ValueObject\Enums命名空间

我们对枚举考虑了几个方面

  • 强与弱
    • 强枚举
      • 强枚举作为单例实例存在
      • 可以使用===!==进行严格比较
    • 弱枚举
      • 可以创建新实例来表示相同的值
    • 必须比较底层值才能判断实例是否相同
  • 值类型
    • 枚举的所有允许值必须具有相同类型
    • 我们区分字符串枚举和整型枚举
    • 浮点枚举可能在未来得到支持,但目前我们没有用例
  • 允许值集合的定义方式
    • 类的常量值
    • 类的常量键
    • 类的命名构造函数
    • 由枚举类驱动的自定义方式。

每个方面都有利有弊。目前仅实现了强字符串枚举。

集合

该包支持3种主要的集合类型

  • 请参阅Slepic\ValueObject\Collections命名空间

DataTransferObject

  • 期望数组键与公共属性名称匹配,值必须与相应属性类型匹配
  • 属性类型由它们的位置提示表示。
  • 违规
    • InvalidPropertyValue - 如果已知属性有错误
    • MissingRequiredProperty - 如果没有默认值的属性没有提供
    • UnknownProperty - 如果输入包含DTO上不存在的属性
      • 这可以通过在子类中重写类的受保护常量IGNORE_UNKNOWN_PROPERTIES来关闭
class MyDto extends DataTransferObject
{
  public int $intProperty;
  public string $stringProperty;
}

new MyDto([
  'intProperty' => 10,
   'stringProperty' => 'text',
]); // ok
new MyDto([]); //ViolationsException with 2 MissingRequiredProperty violations

注意:如果您有时需要直接从单独的变量而不是从数组构造对象,请考虑不扩展DataTransferObject,实现自己的对象,带有自己的构造函数,并使用FromArrayConstructor辅助类简化从数组创建。

ArrayList

  • 期望可迭代的索引从0开始,并且所有值匹配相同的数据类型
  • 值类型由current()方法的返回类型表示。
  • 违规
    • InvalidListItem - 当项目违反预期的数据类型时
    • TypeViolation - 当索引不是基于0时
class MyList extends ArrayList
{
   public function current(): int
   {
      return parent::current();
   }
}

new MyList([1,2,3]); // ok
new MyList(['1', 2, 3]); // ViolationException with a InvalidListItem violation

ArrayMap

  • 期望关联数组具有字符串键,并且值匹配相同的数据类型
  • 值类型由current()方法的返回类型表示。
  • 违规
    • InvalidPropertyValue - 如果属性值无效。
class MyMap extend ArrayMap
{
  public function current(): float
  {
     return parent::current();
  }
}

new MyMap(['a' => 1.0, 'b' => 2.0, 'c' => 3.5]); // ok
new MyMap(['a' => 1, 'b' => 2.0, 'c' => 3.5]); //ViolationException with InvalidPropertyValue

FromArrayConstructor

这不是一个值对象,它是一个静态辅助类,简化了从类构造函数的命名参数关联数组创建对象。

class MyValueObject
{
  private string $x;
  private int $y;

  public function __construct(string $x, int $y)
  {
    $this->x = $x;
    $this->y = $y;
  }

  public static function fromArray(array $data): self
  {
    return FromArrayConstructor::constructFromArray(static::class, $data);
  }

  public function with(array $data): self
  {
    return FromArrayConstructor::combineWithArray($this, $data);
  }

  public functin toArray(): array
  {
    return FromArrayConstructor::extractConstructorArguments($this);
  }
}

$vo = MyValueObject::fromArray([
  'x' => 'test',
  'y' => 10,
]);

$vo2 = $vo->with(['y' => $vo->y + 1]);

此辅助类还支持向上转换和向下转换。请参阅向上转换/向下转换部分。

构造函数参数必须具有默认值才能在输入数组中变为可选。参数还必须具有类型提示。

默认情况下,该方法通过UnknownProperty违规报告任何意外的输入属性。可以通过将true作为第三个参数$ignoreExtraProperties传递来关闭此功能。

基本上,此方法会抛出与DataTransferObject相同的违规。

combineWithArray方法期望存在非实例属性,其名称与每个构造函数参数相同,并且没有作为combineWithArray方法的$data参数传递。这些属性必须与相应的构造函数参数具有兼容的数据类型。在违反此规则的对象上调用combineWithArray方法将导致抛出LogicException,并且不会报告任何违规。

extractConstructorArguments方法期望存在非实例属性,其名称与每个构造函数参数相同,这些属性必须与相应的构造函数参数具有兼容的数据类型。在违反此规则的对象上调用extractConstructorArguments方法将导致抛出LogicException,并且不会报告任何违规。

注意:使用PHP8的构造函数提升功能编写这些值对象将变得更加容易。如果您只需要从数组构建对象,并且构造函数本身似乎很麻烦,那么您应该考虑使用DataTransferObject。

DataStructure

这基本上是FromArrayConstructor功能的包装,它还提供了从和到数组的向上转换/向下转换功能。如果您愿意编写具有所有属性的构造函数,这是不可变数据结构的最坚实的基础。如果您不愿意,请使用DataTransferObject。但是,使用PHP8的构造函数提升功能,这将成为同样简单,DataStructure类将成为首选。

class MyStructure extends DataStructure
{
  private NonEmptyString $name;
  private PositiveInteger $age;

  public function __construct(NonEmptyString $name, PositiveInteger $age)
  {
    $this->name = $name;
    $this->age = $age;
  }

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

  public function getAge(): PositiveInteger
  {
    return $this->age;
  }
}

// automatically construct from array, potentialy using upcasting/downcasting
$vo = MyStructure::fromArray([
  'name' => 'Slim Shady',
  'age' => 18,
]);

// automatic withers with upcasting/downcasting ability
$vo2 = $vo->with(['age' => 19]);

// automatic to array conversion
echo \json_encode($vo2->toArray()); // {"name": "Slim Shady", "age": 19}

$vo->with(['name' => '']); // throws ViolationExceptionInterface

标准

此外,我们提供了一组标准值对象,用于常见的项目,例如电子邮件等。但此部分目前尚未准备就绪,并且在将来这可能会成为一个单独的包。

  • 请参阅Slepic\ValueObject\Standard命名空间

向上转换

每当集合期望值对象类型并且接收原始类型时,它将在目标值对象类上查找适当的向上转换接口。如果存在,它将自动使用接口构造值对象。

final class MyDto extends DataTransferObject
{
  public StringValue $string;
}

new MyDto([
  'string' => 'value',
]);

现有的向上转换接口有

  • FromIntConstructableInterface
  • FromFloatConstructableInterface
  • FromStringConstructableInterface
  • FromArrayConstructableInterface
  • FromObjectConstructableInterface
  • FromBoolConstructableInterface

向下转换

每当集合期望原始类型并且接收对象时,它将在值上查找适当的向下转换接口。如果存在,它将使用该接口来获取原始值。

fina class MyDto extends DataTransferObject
{
  public string $string;
}

new MyDto([
  'string' => new StringValue('value'),
]);

现有的向下转换接口有

  • ToIntConvertibleInterface
  • ToFloatConvertibleInterface
  • ToStringConvertibleInterface
  • ToArrayConvertibleInterface
  • ToBoolConvertibleInterface

常见问题解答

这是否仅适用于验证

不,这只是一种副作用,它允许增强并与您的验证系统集成。

还有哪些其他用例。

这基本上适用于您会用值对象的任何用例。它只是帮助您以统一的方式编写它们,并简化其中的一些问题。

关于上下文验证呢?如果另一个字段具有特定值,某些字段必须是电子邮件吗?

这取决于值对象的构造函数。这样的规则不能归因于任何单独的字段。定义自己的违规类并最终在多个值对象中重复使用它是绝对没有问题的。

关于嵌套验证规则呢?客户端(用户/API消费者等)发送数据到您的应用程序,并返回一些错误?我如何传达“嘿,addresses[5].state不是一个有效的状态”?

向客户端传达无效状态的控制权在您的应用程序手中。您可以利用统一的违规环境,但您仍然需要了解值对象抛出的违规类型,并相应地表示每个违规。虽然违规以类表示,但它们实际上只是具有附加功能的简单错误代码,利用了PHP类系统。Collections命名空间中的违规应该足以轻松创建嵌套值对象的嵌套违规。查看collections代码以了解它们是如何使用的。您还可以查看它们的测试以了解我们是如何检查嵌套违规树中的发生的。

关于自定义验证消息?

ViolationInterface::getMessage()提供的验证消息不是直接传达给客户端的。它们代表了对开发者来说立即可读的违规解释。但如果违规要传达给普通用户,消息应该基于错误代码以及最终其他违规属性在您的应用程序的验证系统中生成。

您可能只能在只有开发者会阅读它们的API上使用默认消息。

但是,将来可能会完全删除ViolationInterface::getMessage()方法。

将来可能会创建一些简化将违规集成到验证系统中的任务的组件。目前,这超出了库的范围,它可能最终成为单独的包。

关于需要依赖项(例如:数据库连接)的验证呢?

当然,您可以从任何想要的工厂抛出统一的ViolationExceptionInterface。但这不能在自动上转换中发生,因为它是通过静态命名构造函数发生的。但没有问题将已创建的值对象传递给从这个包自动构建的值对象。这使得传入的值对象是否需要创建数据库变得无关紧要。

类似的项目和灵感

  • spatie/data-transfer-object
    • 这是一个很好的开始灵感。
    • 数据传输对象类之所以得名,是因为这个包。
  • immutablephp/immutable
    • 对不可变值对象的简单实现。
    • 这激发了在这个包中创建不可变特质的想法。

谢谢大家!