Skip to content

Laravel 中的自动模型版本控制:使用 Versionable Trait 构建审计跟踪

简介

维护模型变更的完整审计跟踪对许多应用程序至关重要。无论您需要跟踪谁在何时更改了什么,实现撤销/重做功能,还是遵守监管要求,自动版本控制都提供了强大的基础。

本指南演示如何构建一个 Versionable trait,在每次创建和更新操作时自动捕获模型版本。该 trait 使用 Laravel 的模型事件、多态关联和智能变更检测来创建零配置的版本控制系统。

前置条件

  • Laravel 10.x 或更高版本(稍作调整即可适配早期版本)
  • PHP 8.1 或更高版本
  • 基本了解 Eloquent 模型和 traits
  • 熟悉 Laravel 模型事件

架构设计

版本控制系统由三个主要组件组成:

  1. Versionable Trait - 自动挂钩到模型事件
  2. ModelVersion 模型 - 使用多态关联存储版本快照
  3. TextUtil - 智能变更检测(处理字符串、数组、JSON)

数据库模式

首先,为 model_versions 表创建迁移:

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('model_versions', function (Blueprint $table) {
            $table->id();
            $table->morphs('versionable');
            $table->unsignedInteger('version_number');
            $table->json('data');
            $table->foreignId('created_by')->nullable()->constrained('users');
            $table->timestamps();

            $table->index(['versionable_type', 'versionable_id', 'version_number']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('model_versions');
    }
};

构建 Versionable Trait

Boot 方法和事件钩子

trait 使用 Laravel 的 boot{TraitName} 约定自动注册事件监听器:

php
<?php

namespace App\Traits;

use App\Models\ModelVersion;
use Illuminate\Database\Eloquent\Relations\MorphMany;

trait Versionable
{
    /**
     * 自动将版本控制附加到模型事件。
     */
    public static function bootVersionable(): void
    {
        static::updating(function ($model) {
            if (method_exists($model, 'createVersion')) {
                $model->createVersion();
            }
        });

        static::created(function ($model) {
            // 在创建时捕获初始基线版本
            if (method_exists($model, 'createVersion')) {
                $model->createVersion(true);
            }
        });
    }
}

Laravel Boot 约定

当使用 trait 的模型被启动时,Laravel 会自动调用 boot{TraitName}()。这提供了一种无需显式配置即可注册事件监听器的简洁方式。

多态关联

定义与版本记录的关联:

php
/**
 * 获取此模型的所有版本。
 */
public function versions(): MorphMany
{
    return $this->morphMany(ModelVersion::class, 'versionable')
        ->orderBy('version_number', 'desc');
}

可自定义的版本控制行为

提供钩子让模型自定义哪些属性需要版本控制:

php
/**
 * 获取应该进行版本控制的属性。
 * 在模型中重写此方法以自定义版本控制的内容。
 */
public function getVersionableAttributes(): array
{
    return $this->attributesToArray();
}

/**
 * 获取更改时应触发版本控制的字段。
 * 在模型中重写此方法以指定要跟踪的字段。
 */
public function getVersionableFields(): array
{
    // 默认情况下,跟踪所有可填充字段
    return $this->fillable;
}

/**
 * 获取在检查重要变更时要忽略的字段。
 * 在模型中重写此方法以指定忽略的字段。
 */
public function getIgnoredFields(): array
{
    return [];
}

/**
 * 获取在检查重要变更时要忽略的字段前缀。
 * 在模型中重写此方法以指定忽略的前缀。
 */
public function getIgnoredPrefixes(): array
{
    return [];
}

智能变更检测

trait 使用工具方法来确定变更是否足够重要以创建新版本:

php
/**
 * 根据当前变更检查是否应创建版本。
 */
public function shouldCreateVersion(): bool
{
    $versionableFields = $this->getVersionableFields();

    foreach ($versionableFields as $field) {
        if ($this->isDirty($field)) {
            $original = $this->getOriginal($field);
            $current = $this->getAttribute($field);

            // 过滤被忽略的字段以获取上下文感知路径
            $ignoredFields = $this->getIgnoredFields();
            $contextualIgnoredFields = [];

            foreach ($ignoredFields as $ignoredField) {
                if (str_starts_with($ignoredField, "{$field}.")) {
                    // 移除字段前缀以获取相对路径
                    $contextualIgnoredFields[] = substr($ignoredField, strlen($field) + 1);
                } elseif (!str_contains($ignoredField, '.')) {
                    // 保持不带点的字段原样
                    $contextualIgnoredFields[] = $ignoredField;
                }
            }

            // 使用 TextUtil 检查重要变更
            if (TextUtil::hasSignificantChanges(
                $original,
                $current,
                $contextualIgnoredFields,
                $this->getIgnoredPrefixes()
            )) {
                return true;
            }
        }
    }

    return false;
}

变更检测

TextUtil::hasSignificantChanges() 方法处理各种数据类型(字符串、数组、JSON),并可以忽略特定字段或字段前缀。这可以防止为不重要的变更(如时间戳更新或计算字段)创建版本。

创建版本

核心版本控制逻辑:

php
/**
 * 为此模型创建新版本。
 */
public function createVersion(bool $force = false): ?ModelVersion
{
    try {
        // 仅在有重要变更时创建版本
        if (!$force && !$this->shouldCreateVersion()) {
            return null;
        }

        $nextVersionNumber = ($this->versions()->max('version_number') ?? 0) + 1;

        // 在更新期间调用时,捕获原始状态
        $dataToVersion = $this->exists && $this->isDirty()
            ? array_merge($this->getOriginal(), ['id' => $this->id])
            : $this->getVersionableAttributes();

        $version = new ModelVersion([
            'versionable_type' => get_class($this),
            'versionable_id' => $this->id,
            'version_number' => $nextVersionNumber,
            'data' => $dataToVersion,
            'created_by' => auth()->id(),
        ]);

        $version->save();

        // 清理旧版本 - 仅保留最近 50 个
        $totalVersions = $this->versions()->count();
        if ($totalVersions > 50) {
            $this->versions()
                ->orderBy('version_number', 'asc')
                ->limit($totalVersions - 50)
                ->delete();
        }

        return $version;
    } catch (\Throwable $e) {
        // 记录错误但不中断主要操作
        \Log::error('版本创建失败', [
            'model' => static::class,
            'id' => $this->id,
            'error' => $e->getMessage(),
        ]);

        return null;
    }
}

错误处理

版本控制系统优雅地失败。如果版本创建失败,它会记录错误但不会中断主要保存操作。这可以防止版本控制错误破坏您的应用程序。

辅助方法

添加用于处理版本的便捷方法:

php
/**
 * 获取最新版本。
 */
public function getLatestVersion(): ?ModelVersion
{
    return $this->versions()->first();
}

/**
 * 检查此模型是否有任何版本。
 */
public function hasVersions(): bool
{
    return $this->versions()->exists();
}

ModelVersion 模型

创建一个简单的模型来存储版本:

php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ModelVersion extends Model
{
    protected $fillable = [
        'versionable_type',
        'versionable_id',
        'version_number',
        'data',
        'created_by',
    ];

    protected $casts = [
        'data' => 'array',
    ];

    public function versionable(): MorphTo
    {
        return $this->morphTo();
    }

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'created_by');
    }
}

使用示例

基本使用

只需将 trait 添加到任何模型:

php
<?php

namespace App\Models;

use App\Traits\Versionable;
use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
    use Versionable;

    protected $fillable = ['title', 'content', 'status'];
}

就是这样!模型现在会自动跟踪版本:

php
$article = Article::create([
    'title' => 'Laravel 入门',
    'content' => 'Laravel 是一个 Web 应用框架...',
    'status' => 'draft',
]);
// 自动创建版本 1

$article->update(['status' => 'published']);
// 自动创建版本 2

// 访问版本
$versions = $article->versions; // ModelVersion 集合
$latest = $article->getLatestVersion();

自定义版本控制字段

重写 trait 方法以自定义行为:

php
class Article extends Model
{
    use Versionable;

    protected $fillable = ['title', 'content', 'status', 'views'];

    /**
     * 仅对这些特定字段进行版本控制。
     */
    public function getVersionableFields(): array
    {
        return ['title', 'content', 'status'];
    }

    /**
     * 在版本比较中忽略 'updated_at' 的变更。
     */
    public function getIgnoredFields(): array
    {
        return ['updated_at'];
    }
}

忽略 JSON 子字段

对于具有 JSON 列的模型,您可以忽略特定的嵌套字段:

php
class UserSettings extends Model
{
    use Versionable;

    protected $casts = [
        'preferences' => 'array',
        'metadata' => 'array',
    ];

    /**
     * 忽略 JSON 列中的临时或计算字段。
     */
    public function getIgnoredFields(): array
    {
        return [
            'preferences.last_viewed_at',
            'metadata.session_data',
        ];
    }

    /**
     * 忽略所有以 'temp_' 开头的字段。
     */
    public function getIgnoredPrefixes(): array
    {
        return ['temp_', 'cache_'];
    }
}

查看版本历史

在应用程序中显示版本历史:

php
public function versionHistory(Article $article)
{
    $versions = $article->versions()
        ->with('creator')
        ->get()
        ->map(function ($version) {
            return [
                'version' => $version->version_number,
                'changed_by' => $version->creator?->name ?? '系统',
                'changed_at' => $version->created_at,
                'data_snapshot' => $version->data,
            ];
        });

    return view('articles.history', compact('versions'));
}

恢复先前版本

实现恢复功能:

php
public function restore(Article $article, int $versionNumber)
{
    $version = $article->versions()
        ->where('version_number', $versionNumber)
        ->firstOrFail();

    // 恢复数据(这将创建新版本)
    $article->update($version->data);

    return redirect()->back()->with('success', '文章已恢复到版本 ' . $versionNumber);
}

高级:版本比较

构建版本比较工具:

php
public function compareVersions(Article $article, int $version1, int $version2)
{
    $v1 = $article->versions()->where('version_number', $version1)->first();
    $v2 = $article->versions()->where('version_number', $version2)->first();

    if (!$v1 || !$v2) {
        abort(404);
    }

    $differences = [];
    foreach ($v1->data as $key => $value) {
        if (($v2->data[$key] ?? null) !== $value) {
            $differences[$key] = [
                'before' => $value,
                'after' => $v2->data[$key] ?? null,
            ];
        }
    }

    return view('articles.compare', compact('differences', 'v1', 'v2'));
}

性能考虑

自动清理

trait 自动为每个模型仅保留最近 50 个版本。在 createVersion() 中调整此设置:

php
// 保留最近 100 个版本而不是 50 个
if ($totalVersions > 100) {
    $this->versions()
        ->orderBy('version_number', 'asc')
        ->limit($totalVersions - 100)
        ->delete();
}

高效查询版本

显示版本历史时,使用预加载关联:

php
$articles = Article::with(['versions' => function ($query) {
    $query->latest()->limit(10);
}])->get();

索引

迁移包含在 (versionable_type, versionable_id, version_number) 上的索引以实现高效查询。对于大量版本控制的应用程序,考虑添加:

php
$table->index('created_at'); // 用于基于时间的查询
$table->index('created_by'); // 用于基于用户的查询

测试

编写测试以确保版本控制正确工作:

php
use Tests\TestCase;

class VersionableTest extends TestCase
{
    public function test_creates_initial_version_on_model_creation()
    {
        $article = Article::create([
            'title' => '测试文章',
            'content' => '内容',
        ]);

        $this->assertTrue($article->hasVersions());
        $this->assertEquals(1, $article->versions()->count());
        $this->assertEquals(1, $article->getLatestVersion()->version_number);
    }

    public function test_creates_new_version_on_update()
    {
        $article = Article::create(['title' => '原始']);

        $article->update(['title' => '已更新']);

        $this->assertEquals(2, $article->versions()->count());
        $this->assertEquals('原始', $article->versions()->skip(1)->first()->data['title']);
    }

    public function test_does_not_create_version_for_insignificant_changes()
    {
        $article = Article::create(['title' => '测试']);

        // 无实际变更的触碰
        $article->touch();

        // 应该仍然只有 1 个版本(初始版本)
        $this->assertEquals(1, $article->versions()->count());
    }
}

故障排除

版本未被创建

常见原因:

  • 模型没有 Versionable trait
  • ModelVersion 类未找到或配置错误
  • 数据库连接问题
  • bootVersionable() 方法被重写

检查日志以获取具体错误。

版本过多

如果您创建版本过于频繁:

  • 检查您的 getIgnoredFields() 实现
  • 确保 shouldCreateVersion() 逻辑正常工作
  • 考虑将保留限制从 50 减少到更小的数字

性能问题

对于具有大型 JSON 列的模型:

  • 使用 getIgnoredFields() 排除大型嵌套结构
  • 考虑仅通过 getVersionableFields() 对关键字段进行版本控制
  • 为旧版本实现自定义清理策略

结论

Versionable trait 提供了一个最小配置的生产就绪版本控制系统。通过利用 Laravel 的模型事件、多态关联和智能变更检测,您可以为任何 Eloquent 模型获得自动审计跟踪。

主要优势:

  • 零配置:添加 trait 即可自动工作
  • 灵活:自定义要跟踪的字段和变更检测方式
  • 安全:优雅失败而不会破坏保存操作
  • 高效:自动清理防止版本表膨胀
  • 生产就绪:为真实的 Laravel 应用程序构建

trait 模式使得在不重构的情况下向现有模型添加版本控制变得容易,多态设计意味着您可以使用单个 model_versions 表对任意数量的不同模型进行版本控制。

对于更高级的用例,考虑扩展 trait 以包含版本差异、计划清理作业或与外部审计系统集成等功能。