在每个项目的核心,你都可以找到数据。几乎每个应用程序的任务都可以这样概括:以业务所需的任何方式提供,解释和处理数据。

可能你自己也注意到了这一点:在项目开始时,你不会一开始就构建控制器和处理逻辑,而是从构建 Laravel 所谓的模型开始。大型项目受益于制作关系模型和其他类型的图来概念化应用程序将处理哪些数据。只有明确了这一点,你才能开始构建适用于数据的入口点和钩子。

在本章中,我们将仔细研究如何以一种结构化的方式处理数据,这样团队中所有开发人员能够编写应用程序去以一种可预测且安全的方式处理这些数据。

你现在可能正在考虑模型,但是我们首先需要后退几步。

类型理论

为了理解数据传输对象(DTO)的使用-剧透:这是本章的内容-你需要有一些关于类型系统的背景知识。

在谈论类型系统时,并非所有人都同意所使用的词汇。因此,为在这里使用它们,让我们阐明一些术语。

类型系统(强类型或弱类型)的强度表明变量在定义后是否可以更改其类型。

一个简单的例子:给定一个字符串变量 $a ='test'; ;弱类型系统可以将该变量重新分配给另一种类型,例如整数 $a = 1;

PHP 是一种弱类型语言——我觉得这里有一个更真实的例子:

$id = '1'; // 如从 URL 检索的 id

function find(int $id): Model
{
    // 输入的 “1” 将自动转换为 int 类型
}

find($id);

需要说明的是:PHP 拥有一个弱类型系统是有意义的。作为一种主要处理 HTTP 请求的语言,所有内容基本上都是一个字符串。

你可能会认为,在现代PHP中,可以通过使用严格类型功能来避免这种幕后类型切换(即类型变戏法),但这并不是完全正确的。声明严格类型可以防止将其他类型传递给函数,但是仍然可以在函数内部更改变量的值。

declare(strict_types=1);

function find(int $id): Model
{
    $id = '' . $id;

    /*
     * 这在PHP中是完全允许的
     * `$id`  现在是个字符串
     */

    // …
}

find('1');  // 这将触发类型错误

find(1); // 这会是正常的

即使具有严格的类型和类型提示,PHP的类型系统也很弱。类型提示仅确保该时间点的变量类型,而不能保证该变量将来可能具有的任何值。

就像我之前说过的:PHP 有一个弱类型系统是有意义的,因为它必须处理的所有输入都是以字符串形式开始的。但是强类型有一个有趣的特性:它们有一些保证。如果一个变量的类型是不可更改的,那么整个范围的意外行为就不会再发生。

你看,数学上可以证明,如果一个强类型的程序可以编译,那么这个程序不可能有一系列在弱类型语言中可能存在的 bug。换句话说,强类型为程序员提供了更好的保障,确保代码的实际行为符合预期。

顺便说一句: 这并不意味着强类型语言不能有 bug!你完全能够编写一个充满 bug 的实现。但是,当强类型程序成功编译时,可以肯定该程序中不会出现某些 bug 和错误。

强类型系统允许开发人员在编写代码时就对程序有更深入的了解,而不是必须运行它。

我们还需要了解一个概念:静态类型和动态类型——这就是事情开始变得有趣的地方。

你可能知道,PHP 是一种解释性语言。这意味着 PHP 脚本在运行时被翻译成机器代码。当你向运行 PHP 的服务器发送请求时,它将接收那些普通的 .php 文件,并将其中的文本解析为处理器可以执行的内容。

同样,这也是 PHP 的优点之一:简单地编写脚本、刷新页面,所有内容都已更新。这与在运行之前必须进行编译的语言相比是一个很大的区别。

显然,有缓存机制可以对此进行优化,因此上面的陈述过于简单。但足以说明下一个要点。

同样,这也有一个缺点:因为 PHP 只在运行时检查它的类型,所以程序的类型检查可能在运行时失败。这意味着你可能有一个更清晰的错误需要调试,但是这个程序还是崩溃了。

这种在运行时进行的检查使 PHP 成为一种动态类型语言。另一方面,静态类型语言将在执行代码之前完成所有类型检查。

自 PHP 7.0 起,其类型系统已得到很大改进。以至于有很多像 PHPStanphanpsalm 之类的工具最近开始变得非常流行。这些工具采用动态语言,即PHP,但会对代码进行大量静态分析。

这些选择加入的库可以为你的代码提供很多洞察,而无需运行或进行单元测试,像 PhpStorm 这样的 IDE 也具有许多内置的静态检查。

记住所有这些背景信息后,现在回到应用程序的核心:数据。

结构化非结构性数据

你是否曾经需要处理实际上不仅仅是列表的“数组内容”?你是否将数组键用作字段?你是否曾为不知道数组中确切的是什么而痛苦?不确定其中是你实际期望的数据,或者哪些字段是有效的?

让我们想象一下我在说什么:处理Laravel的请求。将此示例视为更新现有客户的基本 CRUD 操作:

function store(CustomerRequest $request, Customer $customer) 
{
    $validated = $request->validated();
    
    $customer->name = $validated['name'];
    $customer->email = $validated['email'];
    
    // …
}

你可能已经看到了出现的问题:我们不完全知道 $validated 数组中有哪些数据可用。虽然 PHP 中的数组是一种功能强大的数据结构,但只要它们不是用来表示“事务列表”以外的东西,就会有更好的方式来解决问题。

在寻找解决方案之前,你可以做以下事情来应对这种情况:

  • 阅读源代码
  • 阅读文档
  • 打印输出 $validate 来检查它
  • 或使用 debugger 调试器检查

现在想象一下,你正在与一个由多个开发人员组成的团队一起从事此项目,并且你的同事五个月前已经编写了这段代码:我可以保证,不做上面列出的任何一件麻烦事,你不会知道正在使用什么数据。

事实证明,强类型系统与静态分析的结合可以极大地帮助我们了解正在处理的内容。例如,Rust 之类的语言可以彻底解决此问题:

struct CustomerData {
    name: String,
    email: String,
    birth_date: Date,
}

结构体就是我们所需要的!不幸的是,PHP 没有结构体,它有数组和对象,仅此而已。

但是……对象和类可能就足矣:

class CustomerData
{
    public string $name;
    public string $email;
    public Carbon $birth_date;
}

现在我知道,类型属性仅在 PHP 7.4 及更高版本中可用。根据你阅读本书的时间,你可能还无法使用它们-在本章的稍后部分,我将提供解决方案,请继续阅读。

对于那些能够使用 PHP 7.4 或更高版本的用户,可以执行以下操作:

function store(CustomerRequest $request, Customer $customer) 
{
    $validated = CustomerData::fromRequest($request);
    
    $customer->name = $validated->name;
    $customer->email = $validated->email;
    $customer->birth_date = $validated->birth_date;
    
    // …
}

IDE中内置的静态分析器始终可以告诉我们正在处理哪些数据。

这种将非结构化数据包装为类型以便我们能够以可靠的方式使用数据的模式称为“数据传输对象”。这是我强烈建议在大于平均水平的 Laravel 项目中使用的第一个具体模式。

当和你同事,朋友或者在 Laravel 社区里讨论这本书的时候,你可能会偶然发现一些对强类型系统没有相同的看法的人。事实上,有很多人更愿意接受 PHP 动态和弱的一面。这绝对是有道理的。

以我的经验,尽管与多个开发人员的团队一起在一个项目上花费大量时间,强类型方法仍具有更多优势。你必须抓住一切机会减少认知负担。你不希望开发人员每次想知道变量中到底有什么内容时都必须开始调试代码。这些信息必须就在眼前,以便开发人员可以专注于重要的事情:构建应用程序。

当然,使用 DTO 需要付出一定的代价:不仅仅是定义这些类的开销,而且还需要额外的开销。例如,还需要将请求数据映射到 DTO。

使用 DTO 的好处肯定超过了你必须付出的代价。不管你在写这段代码中损失了多少时间,从长远来看,都可以弥补。

然而,关于从“外部”数据构建 DTO 的问题仍然需要回答。

DTO 工厂

我们如何构建 DTO?我将和你分享两种可能性,并解释我个人偏好的是哪一种。

第一个是最正确的:使用专门的工厂。

class CustomerDataFactory
{
    public function fromRequest(
       CustomerRequest $request
    ): CustomerData {
        return new CustomerData([
            'name' => $request->get('name'),
            'email' => $request->get('email'),
            'birth_date' => Carbon::make(
                $request->get('birth_date')
            ),
        ]);
    }
}

拥有独立的工厂可以使你的代码在整个项目中保持整洁。对于该工厂而言,让其位于应用层中是最有意义的。

虽然这成为正确的解决方案,可能会注意到我在上一个示例中的 DTO 类本身中使用了简写形式:CustomerData::fromRequest

这种方法有什么问题?不错的一个:它在域中添加了特定于应用程序的逻辑。现在,位于域中的 DTO 必须了解位于应用程序层中的 CustomerRequest 类。

use Spatie\DataTransferObject\DataTransferObject;

class CustomerData extends DataTransferObject
{
    // …
    
    public static function fromRequest(
        CustomerRequest $request
    ): self {
        return new self([
            'name' => $request->get('name'),
            'email' => $request->get('email'),
            'birth_date' => Carbon::make(
                $request->get('birth_date')
            ),
        ]);
    }
}

显然,在领域中混合特定于应用程序的代码并不是最好的主意。但是,它确实有我的偏爱。这有两个原因。

首先:我们已经确定 DTO 是数据进入代码的入口点。一旦我们从外部处理数据,我们想将其转换为 DTO ,我们需要在某个地方进行这种映射,因此我们也可以在它打算使用的类中进行映射。

其次,由于 PHP 自身的一个限制:它不支持命名参数,这是我更喜欢这种方法的最重要的原因。

看吧,你不希望你的 DTO 最终拥有一个为每个属性带有单独参数的构造函数:这不够弹性,并且在使用可为空或默认值的属性时会非常混乱。这就是为什么我更喜欢将数组传递给 DTO 并使其基于该数组中的数据进行构造的方法。顺便说一句:我们使用 spatie/data-transfer-object 包来精确地做到这一点。

因为不支持命名参数,所以也没有可用的静态分析,这意味着在构建 DTO 时,你不知道需要什么数据。我更喜欢在 DTO 类中保持这种”不为人知“的状态,这样就可以在使用时不需要考虑外部因素。

但是,如果 PHP 支持命名参数之类的东西,那我可以说工厂模式是可行的方法:

public function fromRequest(
    CustomerRequest $request
): CustomerData {
    return new CustomerData(
        'name' => $request->get('name'),
        'email' => $request->get('email'),
        'birth_date' => Carbon::make(
            $request->get('birth_date')
        ),
    );
}

注意,在构造 CustomerData 时没有使用数组。

在 PHP 支持这一点之前,我会选择实用的解决方案,而不是理论上正确的解决方案。但这取决于你。你可以随意地选择最适合你团队的方案。

类型化属性的替代方法

正如我前面提到的,使用类型化属性来支持 DTO 还有一个替代方法:docblocks。我之前链接的 DTO 包 也支持他们。

use Spatie\DataTransferObject\DataTransferObject;

class CustomerData extends DataTransferObject
{
    /** @var string */
    public $name;
    
    /** @var string */
    public $email;
    
    /** @var \Carbon\Carbon */
    public $birth_date;
}

但是默认情况下,docblocks 不能保证数据是它们所说的类型。幸运的是,PHP 具有其反射 API,有了它,还有更多的可能。

这个包提供的解决方案可以看作是 PHP 类型系统的扩展。虽然在用户领域和运行时只能做这么多,但它仍然增加了价值。如果你不能使用 PHP 7.4,而又想要更确定你的 docblock 类型确实受到重视,这个包可以满足你的要求。


因为数据几乎是每个项目的核心,它是最重要的构建模块之一。数据传输对象(DTO)为你提供了一种以结构化、类型安全且可预测的方式处理数据的方法。

你将在本书中注意到,DTO 的使用非常频繁。这就是为什么在一开始就深入了解他们是如此重要。同样,还有另一个关键的组成部分需要我们彻底关注:行为。这是下一章的主题,将在下周发布。