如何为 Laravel Eloquent 模型及其关系实现由 UUID 组成的主键,而不是自动递增的整数?

How does one implement primary keys composed of UUIDs, instead of auto-incrementing integers, for Laravel Eloquent models and their relationships?

自动递增整数不能用作分布式数据库拓扑中的主键,其中存在潜在的冲突(碰撞)。

现存的关于 UUID 与自动递增整数主题的文献非常丰富,并且基本规则已被广泛理解。然而,与此同时,对于如何在 Laravel 中实现这一点并支持 Eloquent 模型 和关系 ,似乎没有任何单一、全面的解释。

下面的文章很有价值,它解释了在 VARCHAR(36)/CHAR(36) 中存储主键与通常用于自动递增键的 4/8 字节整数相比所产生的性能开销。我们应该听从这个建议(尤其是作者通篇注释的 post 出版更正):

https://tomharrisonjr.com/uuid-or-guid-as-primary-keys-be-careful-7b2aa3dcb439

同样有价值的是来自讨论的评论,内容广泛:

https://news.ycombinator.com/item?id=14523523

下面的文章解释了如何在 Laravel Eloquent 模型中使用 UUID 实现主键,但没有解释 如何为 Eloquent 关系,例如 "pivot tables" 的多对多(根据 Laravel 说法)。

https://medium.com/@steveazz/setting-up-uuids-in-laravel-5-552412db2088

其他人也提出了类似的问题,例如 ,但在那种情况下,提问者正在使用 MySQL 触发器生成要插入数据透视表 table 中的 UUID,我宁愿避免采用纯粹的 Eloquent 方法。

另一个类似的问题在 被问到,但问题的关键是如何 cast pivot attributes,而不是如何 generate custom附加或同步关系时 ID 列的值

明确地说,我们可以通过将可选的数组参数传递给 attach() 方法来轻松实现此目的:

->attach($modelAId, $modelBId, ['id' => Uuid::generate()]);

但是每次我们在任一模型上调用 attach() 时都需要这样做,这很麻烦并且违反了 DRY 原则。

如果采用在模型 类 本身中实现的事件驱动方法,我们会得到更好的服务。

这种方法可能是什么样的?

免责声明:这是一个正在进行的工作。到目前为止,该技术只关注多对多 Eloquent 关系,而不是更奇特的类型,例如 Has-Many-Through 或多态。

当前 Laravel v5.5.*

Laravel

的 UUID 生成包

开始之前,我们需要一种生成 UUID 的机制。

最流行的UUID生成包如下:

https://github.com/webpatser/laravel-uuid

为 Eloquent 模型实施 UUID

模型使用 UUID 作为其主键的能力可以通过扩展 Laravel 的基础模型 class 或通过实现特征来赋予。每种方法都有其优点和缺点,因为 Steve Azzopardi 的 medium.com 文章(上面引用)已经解释了特征方法(尽管它早于 Eloquent 的 $keyType = 'string'; 属性) ,我将演示模型扩展方法,当然,它可以轻松适应特征。

无论我们使用模型还是特征,关键的方面是 $incrementing = false;protected $keyType = 'string';。虽然扩展基础模型 class 由于 PHP 的单继承设计而施加了限制,但它消除了在每个应该使用 UUID 主键的模型中包含这两个关键属性的需要。相比之下,当使用一个特征时,忘记在每个使用该特征的模型中都包含这两个将导致失败。

基本 UUID 模型 class:

<?php

namespace Acme\Rocket\Models;

use Illuminate\Database\Eloquent\Model;

use Webpatser\Uuid\Uuid;

class UuidModel extends Model
{
    public $incrementing = false;

    protected $keyType = 'string';

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
    }

    public static function boot()
    {
        parent::boot();

        self::creating(function ($model) {
            $model->{$model->getKeyName()} = Uuid::generate()->string;
        });
    }
}

接下来,我们将定义两个模型中的第一个,UserRole,它们在多对多容量中相关。

User型号:

<?php

namespace Acme\Rocket\Models;

use Acme\Rocket\Models\UuidModel;

class User extends UuidModel
{
    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
    }
}

这是任何单个模型使用 UUID 作为其主键所需的全部。每当创建新模型时,id 列将自动填充新生成的 UUID。

为具有 UUID 主键的模型实施 Eloquent 关系

实现所需行为的必要条件是使用自定义数据透视模型,特别是因为我们需要禁用主键列 (id) 的自动递增,并将其类型从 intstring,就像我们在上面的 UuidModel class 中所做的那样。

自定义枢轴模型has been possible since Laravel 5.0, but the usage has evolved in more recent versions。有趣的是,需要将 5.0 的用法与 5.5+ 的用法结合起来才能使这一切正常。

自定义枢轴模型非常简单:

<?php

namespace Acme\Rocket\Models;

use Illuminate\Database\Eloquent\Relations\Pivot;

class RoleUser extends Pivot
{
    public $incrementing = false;

    protected $keyType = 'string';
}

现在,我们将关系添加到第一个 (User) 模型:

<?php

namespace Acme\Rocket\Models;

use Webpatser\Uuid\Uuid;

use Illuminate\Database\Eloquent\Model;

use Acme\Rocket\Models\UuidModel;
use Acme\Rocket\Models\Role;
use Acme\Rocket\Models\RoleUser;

class User extends UuidModel
{
    protected $fillable = ['name'];

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
    }

    public function roles()
    {
        return $this->belongsToMany(Role::class)
            ->using(RoleUser::class);
    }

    public function newPivot(Model $parent, array $attributes, $table, $exists, $using = NULL) {
        $attributes[$this->getKeyName()] = Uuid::generate()->string;

        return new RoleUser($attributes, $table, $exists);
    }
}

需要注意的关键元素是roles()方法中的自定义数据透视模型,->using(RoleUser::class),以及newPivot()方法覆盖;每当模型被 attach()ed.

时,两者都是将 UUID 插入枢轴 table 的 id 列所必需的

接下来,我们需要定义Role模型,它本质上是相同的,但是多对多关系颠倒了:

<?php

namespace Acme\Rocket\Models;

use Webpatser\Uuid\Uuid;

use Illuminate\Database\Eloquent\Model;

use Acme\Rocket\Models\UuidModel;
use Acme\Rocket\Models\User;
use Acme\Rocket\Models\RoleUser;

class Role extends UuidModel
{
    protected $fillable = ['name'];

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
    }

    public function users()
    {
        return $this->belongsToMany(User::class)
            ->using(RoleUser::class);
    }

    public function newPivot(Model $parent, array $attributes, $table, $exists, $using = NULL) {
        $attributes[$this->getKeyName()] = Uuid::generate()->string;

        return new RoleUser($attributes, $table, $exists);
    }
}

演示其工作原理的最佳方式是迁移:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

//use Webpatser\Uuid\Uuid;

use Acme\Rocket\Models\User;
use Acme\Rocket\Models\Role;

class UuidTest extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->uuid('id');
            $table->primary('id');
            $table->string('name');
            $table->timestamps();
        });

        Schema::create('roles', function (Blueprint $table) {
            $table->uuid('id');
            $table->primary('id');
            $table->string('name');
            $table->timestamps();
        });

        Schema::create('role_user', function (Blueprint $table) {
            $table->uuid('id');
            $table->primary('id');
            $table->unique(['user_id', 'role_id']);
            $table->string('user_id');
            $table->string('role_id');
        });

        $user = User::create([
            'name' => 'Test User',
        ]);

        $role = Role::create([
            'name' => 'Test Role',
        ]);

        // The commented portion demonstrates the inline equivalent of what is
        // happening behind-the-scenes.

        $user->roles()->attach($role->id/*, ['id' => Uuid::generate()->string]*/);
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::drop('role_users');
        Schema::drop('users');
        Schema::drop('roles');
    }
}

在 运行 上述迁移之后,role_user table 看起来像这样:

MariaDB [laravel]> SELECT * FROM `role_user`;
+--------------------------------------+--------------------------------------+--------------------------------------+
| id                                   | user_id                              | role_id                              |
+--------------------------------------+--------------------------------------+--------------------------------------+
| 6f7b3820-6b48-11e8-8c2c-1b181bec620c | 6f76bf80-6b48-11e8-ac88-f93cf1c70770 | 6f78e070-6b48-11e8-8b2c-8fc6cc4722fc |
+--------------------------------------+--------------------------------------+--------------------------------------+
1 row in set (0.00 sec)

要检索模型和关系,我们将执行以下操作(使用 Tinker):

>>> (new \Acme\Rocket\Models\User)->first()->with('roles')->get();
=> Illuminate\Database\Eloquent\Collection {#2709
     all: [
       Acme\Rocket\Models\User {#2707
         id: "1d8bf370-6b1f-11e8-8c9f-8b67b13b054e",
         name: "Test User",
         created_at: "2018-06-08 13:23:21",
         updated_at: "2018-06-08 13:23:21",
         roles: Illuminate\Database\Eloquent\Collection {#2715
           all: [
             Acme\Rocket\Models\Role {#2714
               id: "1d8d4310-6b1f-11e8-9c1b-d33720d21f8c",
               name: "Test Role",
               created_at: "2018-06-08 13:23:21",
               updated_at: "2018-06-08 13:23:21",
               pivot: Acme\Rocket\Models\RoleUser {#2712
                 user_id: "1d8bf370-6b1f-11e8-8c9f-8b67b13b054e",
                 role_id: "1d8d4310-6b1f-11e8-9c1b-d33720d21f8c",
                 id: "89658310-6b1f-11e8-b150-bdb5619fb0a0",
               },
             },
           ],
         },
       },
     ],
   }

可以看出,我们已经定义了两个模型并通过多对多关系将它们相关联,在所有实例中使用 UUID 代替自动递增整数。

这种方法使我们能够在任何数量的分布式或复制数据库场景中避免主键冲突,从而为未来几十年可扩展的大型复杂数据结构铺平道路。

最后的想法

sync()syncWithoutDetaching()toggle()等多对多的同步方法似乎都有效,虽然我没有彻底测试过。

这不是更大技术的唯一方法,也不太可能是 "best" 方法。虽然它适用于我有限的用例,但我确信其他比我更精通 Laravel 和 Eloquent 的人可以提供改进建议(请这样做!)。

我打算将整个方法论扩展到其他关系类型,例如“多次通过”和“多态”,并将相应地更新此问题。

在 MySQL/MariaDB

中使用 UUID 的一般资源

http://www.mysqltutorial.org/mysql-uuid/

MySQL

中本机 UUID 支持的状态

我的理解是 MySQL 8 只是添加了新功能,使使用 UUID 更容易;它不添加 "native" UUID 数据类型。

并且到 "easier",新函数似乎减轻了 VARCHAR(36)/CHAR(36) 字符串和 BINARY(16) 表示之间转换的一些挑战。显然,后者要快得多。

https://mysqlserverteam.com/mysql-8-0-uuid-support/

MariaDB 中本机 UUID 支持的状态

有一个 "Feature Request" 开放以获得更好的 UUID 支持(这张票解释了一些基本原理):

https://mariadb.atlassian.net/browse/MDEV-4958