关注「索引目录」公众号,获取更多干货。
目录
- 介绍
- 传统验证方法的弊端
- 积木
- PHP 8.5 管道
- 验证上下文(函子)
- 可重用验证器库
- 误差累积
- 联合类型而非二选一(Monad)
- 结论
介绍
在之前的文章中,我探讨了值对象,从基本实现到高级模式、实体以及构建自定义类型系统。每篇文章都以前一篇文章为基础,展示了 PHP 8 的特性如何实现更优雅的解决方案。
但现在,随着 PHP 8.5 的发布,我们有了一个全新的、强大的盟友,它真正改变了游戏规则:管道运算符 ( |>)。
这个运算符为 PHP 中的函数式编程开辟了新的可能性。它使我们能够以一种与以往截然不同的方式来表达验证逻辑。
不同以往,也更好。但我仍然希望在值对象的使用方式上保持一定的一致性。
我并不是函数式编程的忠实拥趸,也不想用函数式风格编写所有代码,因为我认为它很容易变得晦涩难懂,难以维护。但有时,函数式编程确实能以非常优雅的方式解决问题,而这在面向对象编程中是无法实现的。
我知道 PHP 程序员通常不太熟悉函数式编程中的术语,比如函子和单子,所以我尽量避免深入理论细节,而是保持高屋建瓴,侧重于实用方法。不过,为了内容的完整性,我偶尔也会提及一些术语。
我将逐步分解该方法,展示实现此验证范式的每个组成部分。
如果你想尝试一下但又不想自己实现,这里有一个 GitHub 仓库。
传统验证方法的弊端
我在之前的文章中解释了值对象如何直接在构造函数中封装验证规则。例如:
readonly final class Age
{
public function __construct(public int $value)
{
($value < 0) or throw new InvalidArgumentException("Age cannot be negative");
($value > 150) or throw new InvalidArgumentException("Age cannot exceed 150");
}
}
虽然这种方法能够达到目的,但它也存在一些缺点:
- 系统仅报告第一个错误
。一旦验证失败,就会抛出异常并跳过后续检查。如果存在多个问题,用户只会看到第一个。 - 命令式、分步骤编写风格
。验证逻辑以一系列命令式指令的形式编写,精确描述了 PHP 应该如何进行验证。 - 验证逻辑与每个值对象紧密耦合
。每个值对象都包含自己的验证代码,这使得在不同的值对象之间共享或重用验证变得困难。 - 缺乏可组合性
。由于检查被隔离在每个类内部,因此很难以简洁优雅的方式组合或链接验证规则。 - 非线性代码流程
。依赖异常进行流程控制会导致非线性且有时相当复杂的结构,这会使维护变得具有挑战性。
当处理由多个属性组成的实体时,这些问题会更加突出。您将面临一些不太理想的选择:逐个验证每个属性并在出现问题时抛出异常(这会导致糟糕的用户体验),手动收集错误(这很快就会变得繁琐且容易出错),或者引入一个庞大的验证库作为依赖项。
如果我们能将验证逻辑移到一个可重用、可组合的验证器库中呢?如果我们能累积所有验证错误,而不是在出现第一个错误时就停止呢?如果我们能在值对象内部使用这些验证器(而不是作为外部检查),从而保持“对象实例化时,其值在形式上是有效的”这一前提呢?
多亏了 PHP 8.5,这一切现在都成为可能。我会一步一步地向你展示如何操作。
我们的目标是最终得到类似以下内容:
readonly final class Age
{
// Private constructor
private function __construct(public int $value) {}
public static function create(mixed $value): Age|ErrorsBag
{
$context = IntegerValue::from($value)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? new Age($context->getValue())
: $context->getErrors();
}
}
虽然这个例子很简单,但还是有一些重要的细节需要注意:
private function __construct(public int $value) {}
构造函数被设为私有,以确保所有实例化都通过create工厂方法进行。
$context = IntegerValue::from($value)
输入值被封装在另一个对象中,我们稍后会更深入地了解它。
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
我们不每次都重写验证逻辑,而是使用专门的验证库。每个检查都可以有自定义的错误信息。
return $context->isValid()
? new Age($context->getValue())
: $context->getErrors();
流程一直持续到最后,在那里我们会检查值是否有效。即使到了这一步,我们也不会抛出异常。相反,我们会返回值对象实例或错误本身。
积木
我目前展示的只是最终用法。为了更好地理解这个例子,我们需要把它分解成不同的“组成部分”,看看它背后的原理。
PHP 8.5 管道
PHP 8.5 引入了管道运算符( |>),它允许你将一个表达式的结果作为下一个表达式的第一个参数传递:
$result = $value
|> function1(...)
|> function2(...)
|> function3(...);
管道运算符使得函数式编程风格在 PHP 中得以实现,而这种风格在以前是很难实现的。它允许我们以一种既易读又可组合的方式将操作串联起来。该...运算符告诉 PHP,该函数需要将前一个函数的结果作为其参数。唯一的限制是该函数只能有一个参数。
对于验证而言,这具有变革性意义,因为它使我们能够将验证过程表示为一个流程:
$value
|> isString()
|> minLength(10)
|> maxLength(200)
|> startsWith('foo')
流程中的每一步都会转换值(或上下文),并将其传递给下一步。这是我们功能验证方法的基础。
验证上下文(函子)
与其立即抛出异常,我们需要一种方法来随着验证的进行累积错误。这就是 ` functor` 的作用ValidationContext所在。从函数式编程的角度来看,它实际上是一个函子。
在函数式编程中,函子是一种可以映射的类型。它封装了一个值,允许对其进行转换,同时保持其结构不变。对于验证操作,我们创建一个函子,它同时保存待验证的值和收集到的所有错误信息。
作为 PHP 开发人员,我们不习惯用“函子”这种概念来思考,所以我将使用“上下文”这个术语,它可能更容易理解。上下文对象会维护信息并在整个验证过程中累积所有错误。
readonly abstract class ValidationContext
{
private function __construct(
private mixed $value,
private array $errors
) {}
protected static function of(mixed $value): self
{
return new self($value, []);
}
public function validate(callable $predicate, string $errorMessage): self
{
$isValid = $predicate($this->value);
if (!$isValid) {
return $this->addError($errorMessage);
}
return $this; // Continue with the same context
}
public function getErrors(): ErrorsBag
{
// Convert error messages to ErrorsBag
}
public function isValid(): bool
{
return empty($this->errors);
}
}
上下文信息会沿着管道流动,并在此过程中累积错误。如果验证失败,我们会添加一个错误,但继续处理。这样我们就能收集到所有验证错误,而不仅仅是第一个错误。
上下文是不可变的:每个验证步骤都会返回一个新的实例,因此可以安全地通过管道传递。正是这种不可变性使其成为一个函子:我们可以对其进行转换(添加错误、更改值),同时保持其结构不变(它始终是一个 ValidationContext)。
可重用验证器库
我们不在每个值对象中编写验证逻辑,而是创建一个可重用的验证器库。
考虑一下传统方法:
// VALIDATION FOR AGE
($value < 0) or throw new InvalidArgumentException("Age cannot be negative");
($value > 150) or throw new InvalidArgumentException("Age cannot exceed 150");
// VALIDATION FOR PRICE
($value < 0) or throw new InvalidArgumentException("Price cannot be negative");
($value > 100000) or throw new InvalidArgumentException("Price cannot exceed 1000.00€");
注意到重复的地方了吗?两者都检查最小值和最大值,但逻辑分别嵌入在每个类中。
以下是使用函数式方法和验证器库对两者Age进行Price测试的结果:
// VALIDATION FOR AGE
$context = IntegerValue::from($value)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
// VALIDATION FOR PRICE
$context = IntegerValue::from($value)
|> IntegerValue::min(0, "Price cannot be negative")
|> IntegerValue::max(100000, "Price cannot exceed 1000.00€");
对于如此简单的例子,这种方法似乎并不值得。但当我们考虑更复杂的验证逻辑(例如电子邮件、密码、特定格式)时,这种方法可以避免每次都重复造轮子,更重要的是,它能使代码更易读,因为它明确地展示了正在执行的验证操作。
将此与传统方法进行比较:
-
没有嵌入式验证逻辑——验证工作委托给可重用的验证器。 -
声明式风格——我们描述的是要验证的内容,而不是如何验证。 -
可组合——验证器可以优雅地串联起来 -
错误累积——所有验证错误都会被收集,而不仅仅是第一个错误。 -
可重用的验证器—— IntegerValue::min()它们被两者(以及任何其他需要它们的值对象)IntegerValue::max()使用。AgePrice -
自定义错误消息——我们可以为每个不同的值对象设置自定义消息,从而提供更多上下文信息。
但这样的验证器究竟是什么样子呢?我的建议是:
readonly class IntegerValue extends ValidationContext
{
public static function from(mixed $value): IntegerValue
{
return $value
|> IntegerValue::of(...)
|> IntegerValue::isInteger();
}
public static function isInteger(?string $errorMessage = null): \Closure
{
return static function (ValidationContext $context) use ($errorMessage) {
$message = $errorMessage ?? "Value must be an integer";
$newContext = $context->validate(
fn(mixed $value) => is_int($value),
$message
);
return ($newContext->isValid())
? IntegerValue::of(intval($context->getValue()))
: $context;
};
}
public static function min(int $min, ?string $errorMessage = null): \Closure
{
$message = $errorMessage ?? "Value must be at least {$min}";
return static fn(ValidationContext $context) => $context->validate(
fn(int $value) => $value >= $min,
$message
);
}
public static function max(int $max, ?string $errorMessage = null): \Closure
{
$message = $errorMessage ?? "Value must be at most {$max}";
return static fn(ValidationContext $context) => $context->validate(
fn(int $value) => $value <= $max,
$message
);
}
public static function between(int $min, int $max, ?string $errorMessage = null): \Closure
{
$message = $errorMessage ?? "Value must be between {$min} and {$max}";
return static fn(ValidationContext $context) => $context->validate(
fn($value) => $value >= $min && $value <= $max,
$message
);
}
}
验证器方法有两种工作方式:
from()这是一个工厂方法,它接受一个原始值并创建一个对象 ValidationContext,从而启动验证管道。min()诸如`__getitem__`、 `__getitem__` max()、isInteger()`__getitem__` 和 `__getitem__`之类的方法between()是返回闭包的静态方法。这些闭包接受一个上下文并返回一个上下文,因此非常适合在管道链中使用。
这种模式使得验证器可以完全复用于任何需要整数验证的值对象。您可以将它们组合成一个管道,每一步都会在数据流经管道的过程中转换上下文。
同样的原理也适用于字符串:
readonly class StringValue extends ValidationContext
{
public static function from(mixed $value): StringValue {}
public static function isString(?string $errorMessage = null): \Closure { }
public static function minLength(int $min, ?string $errorMessage = null): \Closure { }
public static function maxLength(int $max, ?string $errorMessage = null): \Closure { }
public static function email(?string $errorMessage = null): \Closure { }
public static function hasUppercase(?string $errorMessage = null): \Closure { }
// ... and many more
}
我们不在每个值对象中嵌入验证逻辑,而是从共享库中组合验证器。这意味着:
- DRY 原则
:验证逻辑只需编写一次,即可在任何地方使用。 - 一致性
:相同的验证器产生相同的行为 - 可测试性
:独立测试验证器 - 可维护性
:在一个地方更新验证逻辑。
误差累积
传统的验证方法在遇到第一个错误时就会停止。但使用函子方法,我们可以累积所有错误。密码验证就是一个很好的例子,因为一个弱密码可能同时违反多个规则:
// Traditional approach - stops at first error
try {
$password = new Password("weak"); // Throws immediately on first failure
} catch (InvalidArgumentException $e) {
// Only see: "Password must be at least 8 characters long"
// Never know it also lacks uppercase, numbers, special characters, etc.
}
// New approach - collects all errors
$context = "weak"
|> StringValue::from(...)
|> StringValue::minLength(8, "Password must be at least 8 characters long") // Fail
|> StringValue::maxLength(20, "Password cannot exceed 20 characters")
|> StringValue::hasUppercase("Password must contain at least one uppercase letter") // Fail
|> StringValue::hasLowercase("Password must contain at least one lowercase letter")
|> StringValue::hasNumber("Password must contain at least one number") // Fail
|> StringValue::hasSpecialCharacter("Password must contain at least one special character"); // Fail
if ($context->hasErrors()) {
foreach ($context->getErrors()->getErrors() as $error) {
echo $error->message . "\n";
}
// Shows all four errors:
// - Password must be at least 8 characters long
// - Password must contain at least one uppercase letter
// - Password must contain at least one number
// - Password must contain at least one special character
}
这对于面向用户的验证尤其有用,因为您可以一次性显示所有问题,而不是让用户逐个修复错误。通过密码验证,用户可以在一次反馈周期内看到他们需要满足的所有要求。
上下文信息贯穿整个流程,在每个步骤中都会收集错误。只有在流程结束时,我们才会检查错误。
联合类型而非二选一(Monad)
在函数式编程中,Either 单子通常用于表示两种类型之一的值(通常是成功或失败)。它是一种强大的抽象,有助于处理管道和其他函数式编程模式。
在 PHP 中,可以实现 Either monad,但遗憾的是,无论是 IDE 还是语言本身都没有提供对其的原生支持。
从 PHP 8.0 开始,我们引入了一个类似但又不同的特性:联合类型。与 Either monad 提供的强大功能相比,联合类型存在一些局限性。但就我们正在探讨的用例而言,我认为联合类型提供了一种更具表现力且更原生的替代方案。
以下是一些真实案例来说明其中的区别:
单一单子方法
使用 Either 单子时,通常会将结果包装在一个支持函数式组合的单子容器中:
// Usage with Either monad
public static function create(mixed $value): Either
{
$context = $value
|> IntegerValue::from(...)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? Either::right(new Age($context->getValue()))
: Either::left($context->getErrors());
}
使用它需要方法调用,会丢失类型信息,而且也会降低可读性:
$result = Age::create(25);
// Functional composition example (but still loses types)
$result = Age::create(25)
->map(fn($age) => $age->value * 2) // Transform if valid
->flatMap(fn($value) => Age::create($value)); // Chain another validation
// Imperative example
if ($result->isRight()) {
$age = $result->getRight(); // IDE doesn't know this is Age
echo $age->value; // No type safety, no autocomplete
} elseif ($result->isLeft()) {
$errors = $result->getLeft(); // IDE doesn't know this is ErrorsBag
foreach ($errors->getErrors() as $error) { // No type hints
echo $error->message;
}
}
我们可以使用 Psalm 或 PHPStan 等静态分析工具,结合 docblock 泛型来改进 Either monad 方法,但它仍然缺乏可读性,并且需要具备函数式编程范式的知识。我希望尽可能地隐藏复杂性,并保持值对象的使用简洁明了,让普通 PHP 程序员能够轻松上手。
联合类型方法
与其将结果包装在 Either 单子中,我们可以直接使用联合类型:
public static function create(mixed $value): Age|ErrorsBag
{
$context = $value
|> IntegerValue::from(...)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? new Age($context->getValue())
: $context->getErrors();
}
该返回类型Age|ErrorsBag比 Either 单子更具表现力,因为:
-
原生语言支持:无需包装类或单子操作 -
直接类型检查: instanceof直接使用,无需使用isLeft()orisRight()方法 -
更清晰的意图:类型本身就告诉你你正在处理的是什么。 -
更好的 IDE 支持:自动完成和类型提示功能开箱即用
使用方法很简单:
$result = Age::create(25);
if ($result instanceof Age) {
// Handle valid age - IDE knows $result is Age here
echo $result->value; // Full autocomplete support
} elseif ($result instanceof ErrorsBag) {
// Handle validation errors - IDE knows $result is ErrorsBag here
foreach ($result->getErrors() as $error) {
echo $error->message;
}
}
这是类型安全且明确的。类型系统确保您不会意外忽略错误或使用无效值。IDE 在每个分支都提供完整的自动补全和类型检查。
结论
现在我们已经逐一分析了每个组成部分,最初的例子就有了全新的意义:
readonly final class Age
{
// Private constructor
private function __construct(public int $value) {}
public static function create(mixed $value): Age|ErrorsBag
{
$context = $value
|> IntegerValue::from(...)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? new self($context->getValue())
: $context->getErrors();
}
}
注意一下这个班级里缺少什么:
-
无 if声明 -
不抛出异常。 -
没有嵌入式验证逻辑
相反,我们:
-
利用管道串联操作 -
使用库中的可重用验证器 -
利用上下文(函子)累积误差 -
为了类型安全,返回联合类型。
验证是声明式的:我们描述的是要验证的内容,而不是验证的方式。管道运算符使流程一目了然:首先输入一个值,验证它是否为整数,检查最小值,检查最大值。
使用起来同样优雅:
// Valid age - returns Age object
$age = Age::create(25);
if ($age instanceof Age) {
echo "Age: {$age->value}"; // 25
}
// Invalid age - returns ErrorsBag with all errors
$result = Age::create(-5);
if ($result instanceof ErrorsBag) {
foreach ($result->getErrors() as $error) {
echo $error->message . "\n";
}
// Output: "Age cannot be negative"
}
这标志着验证方式从基于异常的验证方式转变。我们不再“快速失败”,而是“收集所有失败案例”。
关注「索引目录」公众号,获取更多干货。

