编程

非正统 Eloquent 2

175 2024-04-11 20:38:00

这是“非正统 Eloquent" 的第二部分。你可以在此处查看上一篇。

上一篇文章,我们探讨了各种各样的“非正统”选项,这些选项可以与我们的 Eloquent 模型结合使用。然而,那篇文章只是冰山一角。在这篇文章中,我想介绍一些其他的技巧和窍门,这些技巧和窍门可能比第一篇文章中介绍的主题更深奥,但仍然很方便。例如,你有没有考虑过在 seeder 或测试之外使用模型工厂?没有吧,那么我很确定你会再学到一些不一样的东西,所以一定要坚持到最后!

就像现存的每一种工具一样,Eloquent 也有自己的一套权衡。作为负责任的开发人员,我们应该始终了解我们正在做的取舍。如果你想了解更多关于 ActiveRecord 及其设计理念的信息,我强烈推荐这篇文章。

快速导航

  • 应用代码中的模型工厂
  • "原生" belongsToThrough 关联
  • 全面理解 Eloquent

应用代码中的模型工厂

为了充分理解这一部分,我认为我们应该首先对工厂是什么有一个精确的共同理解。简单地说,它是负责产生另一种东西的东西。然而,我想更进一步,将其定义为负责另一个实体起源的东西。通常,所有模型/实体都有一个特定的生命周期:它们因某种特定的原因开始存在,经过某个过程,最终不存在或“死亡”。工厂对于处理实体生命周期的第一部分特别有用。

Eloquent 工厂已经存在很长一段时间了。根据文件,逐字逐句:

当测试应用或为数据库设定种子时,可能需要在数据库中插入一些记录。

它被描述成似乎测试和 seeding 是 Eloquent 工厂唯一有意义的用例,但与事实相去甚远。它们也可以在应用代码中使用,而无需担心意外破坏某些内容或赋予某人太多权限。但是我们会把测试/种子代码和应用代码混在一起,对吧?错误!

准备

首先,我们应该决定一个位置,放置仅测试的工厂,用于集成或功能测试。我更喜欢让 database/factories 单独处理应用代码,因为它也是由框架自动发现的。test/factories 将是安排我们的测试特定工厂的好选择。因此,继续将只进行测试的工厂转移到新的位置。文件被移动后,我们现在必须告诉工厂发现者在运行测试时检查我们的新位置。为了实现这一点,我们应该在测试文件夹的根目录中定义一个新的 FactoryResolver 类:

final readonly class FactoryResolver
{
    public function __invoke(string $fqcn): string
    {
        $model = class_basename($fqcn);

        return "Tests\\Factories\\{$model}Factory";
    }
}

接下来,我们应该在测试目录的根目录定义一个 TestingServiceProviderFactoryResolver 在此注册:

final class TestingServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Factory::guessFactoryNamesUsing(new FactoryResolver());
    }
}

无论何时创建测试应用,我们都可以注册这个自定义的 TestingServiceProvider,

trait CreatesApplication
{
    public function createApplication(): Application
    {
        $app = require __DIR__.'/../bootstrap/app.php';

        $app->make(Kernel::class)->bootstrap();
        $app->register(TestingServiceProvider::class); // 👈

        return $app;
    }
}

你可能已经注意到,我们没有将其添加到 app.providers 配置数组,那么为什么我们不应该这么做呢?TestingServiceProvider 只在测试中使用。在每个真实应用请求中注册它并没有什么意义,只会浪费宝贵的 CPU 周期。一旦一切就绪,我们就可以一方面在应用代码中使用工厂,另一方面在测试中使用测试用工厂。

示例解释:用户注册

我们假设有以下路由处理用户注册:

Route::post('users', [UserController::class, 'store']);

它应该能够注册各种角色的用户:高级、vip、试用等。用户注册后,根据其角色,应向其电子邮件地址发送欢迎电子邮件。一种方法是将所有必要的逻辑添加到相应的 Controller 方法中。然而,我们已经知道这不是一个好主意。我们可以用一种干净和非正统的方式来处理这个问题!

首先,让我们创建一个 UserFactory(在 database/factories 中使用 php artisan make:factory),它将负责生成 User 对象以及各种工厂方法,使它们处于一致状态:

final class UserFactory extends Factory
{
    protected $model = User::class;

    public function definition(): array
    {
        return [];
    }

    public function isPremium(): self
    {
        return $this->state([
            'type' => 'premium',
        ]);
    }

    public function isTrial(): self
    {
        return $this->state([
            'trial_expires_at' => Date::now()->addWeeks(2),
            'type' => 'trial',
        ]);
    }

    public function isVip(): self
    {
        return $this->state([
            'type' => 'vip',
        ]);
    }
}

你可能已经注意到,definition 方法是空的。这完全是有意为之,因为此方法的目标是为在执行功能测试期间不会设置的数据库字段默认值。但是,我们的目标是在应用代码中使用此类,而不是在测试代码中使用,所以我们应该将其留空。任何字段的缺少都将导致代码中存在 bug,我们应该尽快修复它。

完成后,我们应该转到 User 模型,定义一个 newFactory 方法,该方法将返回我们刚刚定义的工厂的新实例。这对我们稍后的测试至关重要:

use Database\Factories\UserFactory;

final class User extends Authenticatable
{
    use HasFactory;

    // omitted for brevity

    protected static function newFactory(): UserFactory
    {
        return UserFactory::new();
    }
}

我们现在可以继续实现必要的 Controller 逻辑,以满足业务需求:

final readonly class UserController
{
    public function store(SignUpUser $request): UserResource
    {
        $user = User::factory()
            ->when($request->wantsTrial(), static fn (UserFactory $usr) => $usr->isTrial())
            ->when($request->wantsPremium(), static fn (UserFactory $usr) => $usr->isPremium())
            ->when($request->wantsVip(), static fn (UserFactory $usr) => $usr->isVip())
            ->create($request->validated());

        return UserResource::make($user);
    }
}

正如你所见,“缺失”的 definition 属性是由 Laravel 的 FormRequest 验证方法提供的。根据用户在前端的选择,我们可以调用各种工厂方法,以创建一个有效的 User 对象,该对象的有效状态取决于所选角色。一切都很好,但我们仍然缺少一个关键要求:发送“欢迎”邮件。在这一点上,我们可以开始研究事件和监听器,创建专用的操作、处理管道… 尽管我们可以这么做,并不意味着我们应该这样做。相反,让我们充分利用 Eloquent 工厂的力量:

public function isTrial(): self
{
    return $this->state(...)->afterCreating(function (User $user) {
        Mail::send(new WelcomeTrialUserMail($user));
    });
}

很神奇,是吧。了解工具是如何工作将解锁源源不断的能量。清晰并重复三次,我们就完成了业务要求的实施。

自动化测试

但是测试呢?测试与生产代码本身同样重要,因为它验证了我们的假设,并确保代码满足业务需求。好消息:除了一件小事,你写特性测试的方式没有任何变化。

不要在准备测试用例时这样做:

User::factory()->create();

现在,应该这样做:

use Testing\Factories\UserFactory;

UserFactory::new()->create();

那是因为前者总是解析应用工厂,而后者将会使用专为测试场景创建的工厂。我们可以编写一个简单测试,确保代码如期运行:

final class UsersTest extends TestCase
{
    #[Test]
    public function premium_users_can_sign_up(): void
    {
        $this->post('users', [
            'email' => 'muhammed+evilalias@GMAIL.CoM',
            'name' => 'mUhAmMeD',
            'type' => 'premium',
        ]);

        $this->assertDatabaseHas(User::class, [
            'email' => 'muhammed@gmail.com',
            'name' => 'Muhammed',
            'type' => 'premium',
        ]);
        
        Mail::assertQueued(WelcomePremiumUserMail::class);
    }
}

“那么 UserFactory 在哪?”,你可能会问。此处使用测试工厂并没什么意义,因为我们测试的行为的意图是创建一个 User 对象。如果你愿意,你显然可以增加更多的测试,如果这能提高你的信心水平的话。请不要为了测试而添加测试。请确保你捕获的是正值。

另一个用例的示例可能如下所示:

use Tests\Factories\UserFactory;

#[Test]
public function only_premium_users_receive_access_to_discounts(): void
{
    $this
        ->actingAs(UserFactory::new()->create())
        ->get('discounts')
        ->assertForbidden();

    $this
        ->actingAs(UserFactory::new()->isPremium()->create())
        ->get('discounts')
        ->assertOk()
        ->assertSee('Halloween discounts!');
}

这就是我们前面定义的 FactoryResolver 发挥作用的地方。当在我们的测试工厂内部创建其他关联时,框架将使用它来自动解析关联工厂实例。我强烈反对在测试工厂之外使用工厂的关联功能,因为迟早会有人有此疑惑:“等等,他到底是怎么获得管理员访问权限的?!”在应用代码工厂中明确定义所有内容。在测试工厂中依靠框架魔法是完全可以的。

"原生" belongsToThrough 关联

我不知道你是怎么想的,但我常常希望 Laravel 有一个原生的 belongsToThrough 关联,因为这在某些情况下非常有用。Laravel 有哪些原生关联可以开箱即用(OOB)?其中一个肯定引起了我的注意:HasOneThrough。如果我们仔细一想,这几乎是我们想要的,不过这是反向的。不幸的是,这并不是我们想要的,所以我们必须退回到使用第三方软件包... 😔

等一下。为什么我们这么快就放弃了?为什么我们不继续看看窗帘后面,看看这个几乎是我们想要的关联[而不幸的是,并非我们想要的确切关联]是如何运作的?为什么当我们面临稍微困难一点的挑战时,我们的心态就会默认使用第三方软件包?我们绝对可以做得更好!

探索

public function hasOneThrough(
    $related, 
    $through, 
    $firstKey = null, 
    $secondKey = null, 
    $localKey = null, 
    $secondLocalKey = null
) {}

这是 HasOneThrough 关联的方法定义。我们可以看到,它允许我们自定义与需要执行的查询相关的一切,以满足关联的需求。如果我们以 Laravel 的 mechanics-cars-owners 为例,并执行关联,将生成以下 SQL(或非常接近的 SQL,这取决于你的驱动):

public function carOwner(): HasOneThrough
{
    return $this->hasOneThrough(Owner::class, Car::class);
}

Mechanic::find(504)->carOwner;
SELECT
	*
FROM
	`owners`
	INNER JOIN `cars` ON `cars`.`id` = `owners`.`car_id`
WHERE
	`cars`.`mechanic_id` = 504

你注意到什么了吗?这是所有 belongsToThrough 包都使用的查询模式!他们正在做的唯一一件事,就是反转外键和本地键,仅此而已!刚才我们不是断言 hasOneThrough 关联允许我们自定义与查询相关的所有内容吗?让我们试试吧!

方案

这是当前 Mechanic 模型中所拥有的:

public function carOwner(): HasOneThrough
{
    return $this->hasOneThrough(Owner::class, Car::class);
}

让我们去到 Owner 模型并定义 Mechanic 模型的关联:

public function carMechanic(): HasOneThrough
{
    return $this->hasOneThrough(Mechanic::class, Car::class);
}

即使我们尝试执行,SQL 会崩溃因为这个查询没有意义。让我们手动反转所有键,看看会发生什么:

public function carMechanic(): HasOneThrough
{
    return $this->hasOneThrough(
        Mechanic::class, 
        Car::class, 
        'id', 
        'id', 
        'car_id', 
        'mechanic_id',
    );
}

Owner::find(2156)->carMechanic;

很好!如果我们检查生成的 SQL,我们可以看到根据我们的定义,键被反转了:

SELECT
	*
FROM
	`mechanics`
	INNER JOIN `cars` ON `cars`.`mechanic_id` = `mechanics`.`id`
WHERE
	`cars`.`id` = 2156

我们还可以对关联定义进行微小的改进,以便未来的自己和其他开发人员能够快速了解发生了什么:

use Illuminate\Database\Eloquent\Relations\HasOneThrough as BelongsToThrough;

public function carMechanic(): BelongsToThrough
{
    return $this->hasOneThrough(...);
}

一些理解

下次,当你对默认行为进行轻微修改时,试着看看是否可以根据你的需求使其发挥作用,而不是搁置现有的工具。你会惊讶于你能应付多少情形!请记住,引入额外的依赖项也会带来维护负担,因此在决定实际引入特定的依赖项之前,你应该至少考虑四次

全面理解 Eloquent

我不得不承认,这将是一个非常主观的部分,不过,请听我说完。非常感谢。🙏

Eloquent ORM 提供了各种 OOB 工具:事件调度、事务管理、观察者、全局范围、自动时间戳管理等。虽然这种方法有其优点,但也有其缺点。Eloquent 坚持 SRE 原则:对一切都单一职责。话虽如此,我们到底看了多少次这种锋利工具的内部工作原理?

HasEvents

例如,你知道所有 Eloquent 模型都可以直接访问事件 Dispatcher 吗?那么,我们为什么要这样做:

public function accept(): void
{
    // omitted for brevity

    event(new ApplicationWasAccepted($this));
}

替代方式:

public function accept(): void
{
    // omitted for brevity

    static::$dispatcher->dispatch(new ApplicationWasAccepted($this));
}

HasTimestamps

为什么这么做:

public function accept(): void
{
    $this->fill([
        'accepted_at' => now(),
    ]);
}

替代方案:

public function accept(): void
{
    $this->fill([
        'accepted_at' => $this->freshTimestamp(),
    ]);
}

这将返回 Illuminate\Support\Carbon 而非 Carbon\Carbon,后者是你一直在用的错误的类型提升。

模型

出于 ActiveRecord 的设计理念,所有模型都有底层数据库连接的访问权限。那么我们为什么要这么做呢:

public static function directory(User $downloader, Directory $directory): self
{
    // omitted for brevity

    DB::transaction(static fn () => $model->save() && $model->directories()->attach($directory));

    return $model;
}

我们可以这样做:

public static function directory(User $downloader, Directory $directory): self
{
    // omitted for brevity

    $model
        ->getConnection()
        ->transaction(static fn () => $model->save() && $model->directories()->attach($directory));

    return $model;
}

我的观点

我只是想激发思考并探索我们一直在做什么。我提出的替代方案与使用其他选项一样“摸索框架”。那么,我们接下来为什么不看看正在使用的工具呢?

后一种选择表明我是一个经验丰富的开发人员,知道工具在幕后是如何工作的。为什么我们要经历整个 IoC 容器服务解析周期来检索一个始终存在的对象?方便?我认为这两种选择都不比另一种更方便,因为适当的 IDE 总是会自动补全“更长”的选项…

总结

老实说,我还有很多其他的诀窍。然而,这些甚至比这篇博客文章中强调的更深奥,所以我想今天就到此为止。我真正想了解的一点是,好奇心是开启新方法、观点、方法和习惯的关键。即使你已经使用同一种东西很长时间了,也要毫不犹豫地尝试一下,看看你是否能改善目前的习惯。完美是不存在的,但精致总是有可能的

欢迎留言讨论!我很想知道你对这篇文章的看法。

感谢阅读!