>  기사  >  PHP 프레임워크  >  Laravel의 이벤트 소싱을 자세히 설명하는 기사

Laravel의 이벤트 소싱을 자세히 설명하는 기사

青灯夜游
青灯夜游앞으로
2022-12-13 20:32:371530검색

Laravel의 이벤트 소싱을 자세히 설명하는 기사

이벤트 소싱은 지난 몇 년 동안 PHP 커뮤니티에서 점점 인기를 얻은 용어이지만 여전히 많은 개발자에게는 미스터리입니다. 질문은 항상 어떻게, 왜이며, 물론 그것은 이해할 수 있습니다. 이 튜토리얼의 목적은 실용적인 방법으로 이벤트 소싱이 무엇인지 이해하는 데 도움을 줄 뿐만 아니라 이벤트 소싱을 언제 사용해야 하는지 알려주는 것입니다.

기존 애플리케이션에서는 애플리케이션 상태가 연결된 데이터베이스에 직접 표시됩니다. 우리는 그것이 어떻게 거기에 도달했는지 완전히 이해하지 못합니다. 우리는 그를 이렇게 알고 있습니다. 모델 변경을 감사하는 도구를 사용하여 무엇이 변경되었는지, 누가 변경했는지 확인하는 등 좀 더 자세히 이해할 수 있는 방법이 있습니다. 이것은 또한 올바른 방향으로 나아가는 단계입니다. 그러나 우리는 아직도 이 중요한 문제를 이해하지 못하고 있습니다. [관련 추천: laravel 동영상 튜토리얼]

왜? 이 모델이 변경된 이유는 무엇입니까? 이번 변경의 목적은 무엇인가요?

여기서 이벤트 소싱이 작동하여 애플리케이션 상태에 발생한 일과 변경된 이유에 대한 기록 보기를 유지합니다. 이벤트 소싱을 사용하면 과거를 기반으로 결정을 내려 보고서를 생성할 수 있습니다. 그러나 기본 수준에서는 애플리케이션 상태가 변경된 이유를 알 수 있습니다. 이는 이벤트를 통해 수행됩니다.

기본 Laravel 프로젝트를 구축하여 작동 방식을 안내해 드리겠습니다. 우리는 시간 추적의 논리를 이해하고 응용 프로그램 논리에 혼동되지 않도록 이 응용 프로그램을 간단하게 구축할 것입니다. 우리는 팀원을 축하하는 앱을 구축하고 있습니다. 좋아요. 간단하고 이해하기 쉽습니다. 우리는 사용자들과 팀을 이루고 있으며, 팀 내에서 공개적으로 무언가를 축하할 수 있기를 원합니다.

새로운 Laravel 프로젝트를 생성하겠지만, 인증과 팀 구조 및 기능을 활성화하고 싶기 때문에 Jetstream을 사용하겠습니다. 프로젝트를 생성한 후 IDE에서 엽니다. (정답은 물론 PHPStorm 입니다.) 이제 Laravel의 이벤트 소싱에 대해 알아볼 준비가 되었습니다.

우리는 애플리케이션을 위한 추가 모델을 만들고 싶습니다. 이것이 유일한 모델입니다. 이것은 Celebration 모델입니다. 다음 Artisan 명령을 사용하여 생성할 수 있습니다: Celebration 模型,你可以使用以下的Artisan命令创建它:

php artisan make:model Celebration -m

修改你的迁移文件 up 方法,它看起来应该像这样:

public function up(): void
{
    Schema::create('celebrations', static function (Blueprint $table): void {
        $table->id();

        $table->string('reason');
        $table->text('message')->nullable();

        $table
            ->foreignId('user_id')
            ->index()
            ->constrained()
            ->cascadeOnDelete();

        $table
            ->foreignId('sender_id')
            ->index()
            ->constrained('users')
            ->cascadeOnDelete();

        $table
            ->foreignId('team_id')
            ->index()
            ->constrained()
            ->cascadeOnDelete();

        $table->timestamps();
    });
}

我们有一个庆祝的原因 reason,一个简单的句子,然后是我们可能希望与庆祝活动发送的可选消 message

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

final class Celebration extends Model
{
    use HasFactory;

    protected $fillable = [
        'reason',
        'message',
        'user_id',
        'sender_id',
        'team_id',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(
            related: User::class,
            foreignKey: 'user_id',
        );
    }

    public function sender(): BelongsTo
    {
        return $this->belongsTo(
            related: User::class,
            foreignKey: 'sender_id',
        );
    }

    public function team(): BelongsTo
    {
        return $this->belongsTo(
            related: Team::class,
            foreignKey: 'team_id',
        );
    }
}

마이그레이션 파일 업로드 방법을 수정하세요. 다음과 같아야 합니다:

composer require spatie/laravel-event-sourcing

를 축하할 이유가 있습니다. >이유, 간단한 문장, 축하와 함께 보낼 선택적 메시지가 뒤따릅니다. 이 외에도 축하하는 사용자, 축하 메시지를 보낸 사용자, 그들이 속한 팀이라는 세 가지 관계가 있습니다. Jetstream을 사용하면 사용자는 여러 팀에 속할 수 있으며 두 명의 사용자가 같은 팀에 속할 수도 있습니다

. 우리는 올바른 팀에서 공개적으로 축하하고 싶습니다.

이 설정이 완료되면 모델 자체를 살펴보겠습니다.

php artisan migrate
이러한 관계를 다른 관련 모델에 매핑할 수 있습니다. 하지만 기본적으로 관계를 더 명확하게 하기 위해 모든 모델에 관계의 다른 측면을 추가할 것입니다. 이는 모델 자체를 다른 사람이 이해할 수 있도록 돕기 위해 개발한 습관입니다.

이제 모델 관점에서 생성된 애플리케이션의 기반이 생겼습니다. 내 생각에는 우리에게 도움이 될 몇 가지 패키지(종속성)를 설치해야 할 것 같습니다. 내 앱에서는

Laravel Livewire

를 사용하여 UI를 제어합니다. 하지만 이벤트 소싱 측면에 집중할 수 있기 때문에 이 튜토리얼에서는 이 패키지에 대해 자세히 설명하지 않겠습니다.

내가 만드는 대부분의 프로젝트와 마찬가지로 크든 작든 관계없이 애플리케이션에 모듈식 레이아웃, 즉 도메인 중심 디자인 접근 방식을 채택합니다. 이것은 단지 제가 하는 일이며 매우 주관적이므로 직접 따라야 할 필요는 없습니다.

다음 단계는 도메인을 설정하는 것입니다. 이 데모에는 Culture라는 도메인이 하나만 있습니다. 문화에서 나는 필요할 수 있는 모든 것에 대한 네임스페이스를 만들었습니다. 하지만 여러분이 프로세스를 이해할 수 있도록 살펴보겠습니다.

첫 번째 단계는 Laravel에서 이벤트 소싱을 사용할 수 있게 해주는 패키지를 설치하는 것이었습니다. 이를 위해 저는 저를 위해 많은 백그라운드 작업을 수행한

Spatie 패키지

를 사용했습니다.

php artisan make:model CelebrationStoredEvent -m

설치한 후에는 구성 및 마이그레이션에 릴리스가 필요하므로 패키지의 설치 지침을 따르십시오. 올바르게 설치한 후 마이그레이션을 실행하여 데이터베이스를 올바른 상태로 만드세요. 🎜
public function up(): void
{
    Schema::create('celebration_stored_events', static function (Blueprint $table): void {
        $table->id();
        $table->uuid('aggregate_uuid')->nullable()->unique();
        $table
        ->unsignedBigInteger('aggregate_version')
        ->nullable()
        ->unique();
        $table->integer('event_version')->default(1);
        $table->string('event_class');

        $table->json('event_properties');

        $table->json('meta_data');

        $table->timestamp('created_at');

        $table->index('event_class');
        $table->index('aggregate_uuid');
    });
}
🎜이제 이벤트 소싱 구현 방법에 대해 생각해 볼 수 있습니다. 이는 두 가지 방법, 즉 상태를 투영하는 프로젝터 또는 집계를 통해 달성할 수 있습니다. 🎜

Projector 是一个位于你的应用程序中并处理你调度的事件的类。然后,这些将更改你的应用程序的状态。这不仅仅是简单地更新你的数据库。它位于中间,捕获一个事件,存储它,然后进行所需的更改 —— 然后 “投射” 应用程序的新状态

另一种方法,我的首选方法,聚合 - 这些是像投影仪一样为你处理应用程序状态的类。我们不是在我们的应用程序中自己触发事件,而是将其留给聚合为我们做。把它想象成一个中继,你要求中继做某事,它会为你处理。

在我们创建第一个聚合之前,需要在后台做一些工作。我非常喜欢为每个聚合创建一个事件存储,以便查询更快,并且该存储不会很快填满。这在包文档中进行了解释,但我将亲自引导你完成它,因为它在文档中并不是最清楚的。

第一步是创建模型和迁移,因为你将来需要一种方法来查询它以进行报告等。运行以下 artisan 命令来创建这些:

php artisan make:model CelebrationStoredEvent -m

以下代码是你在 up 方法中进行迁移所需的代码:

public function up(): void
{
    Schema::create('celebration_stored_events', static function (Blueprint $table): void {
        $table->id();
        $table->uuid('aggregate_uuid')->nullable()->unique();
        $table
        ->unsignedBigInteger('aggregate_version')
        ->nullable()
        ->unique();
        $table->integer('event_version')->default(1);
        $table->string('event_class');

        $table->json('event_properties');

        $table->json('meta_data');

        $table->timestamp('created_at');

        $table->index('event_class');
        $table->index('aggregate_uuid');
    });
}

如你所见,我们为我们的活动收集了大量数据。现在模型要简单得多。它应该如下所示:

declare(strict_types=1);

namespace App\Models;

use Spatie\EventSourcing\StoredEvents\Models\EloquentStoredEvent;

final class CelebrationStoredEvent extends EloquentStoredEvent
{
    public $table = 'celebration_stored_events';
}

当我们扩展 EloquentStoredEvent 模型时,我们需要做的就是改变它正在查看的表。模型的其余功能已经在父级上到位。

要使用这些模型,你必须创建一个存储库来查询事件。这是一个非常简单的存储库 —— 然而,这是一个重要的步骤。我将我的添加到我的域代码中,位于 src/Domains/Culture/Repositories/ 下,但您可以随意添加对您最有意义的位置:

declare(strict_types=1);

namespace Domains\Culture\Repositories;

use App\Models\CelebrationStoredEvent;
use Spatie\EventSourcing\StoredEvents\Repositories\EloquentStoredEventRepository;

final class CelebrationStoredEventsRepository extends EloquentStoredEventRepository
{
    public function __construct(
        protected string $storedEventModel = CelebrationStoredEvent::class,
    ) {
        parent::__construct();
    }
}

既然我们有了存储事件和查询它们的方法,我们可以继续我们的聚合本身。同样,我将我的存储在我的域中,但可以随意将你的存储在你的应用程序上下文中。

declare(strict_types=1);

namespace Domains\Culture\Aggregates;

use Domains\Culture\Repositories\CelebrationStoredEventsRepository;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use Spatie\EventSourcing\StoredEvents\Repositories\StoredEventRepository;

final class CelebrationAggregateRoot extends AggregateRoot
{
    protected function getStoredEventRepository(): StoredEventRepository
    {
        return app()->make(
            abstract: CelebrationStoredEventsRepository::class,
        );
    }
}

到目前为止,除了为我们连接到正确的事件存储之外,此聚合不会执行任何操作。要让它开始跟踪事件,我们首先需要创建它们。但在此之前,我们需要停下来想一想。我们希望在活动中存储哪些数据?我们想要存储我们需要的每一个属性吗?或者我们是否希望存储一个数组,就像它来自一个表单一样?我两种方法都不用,因为为什么要保持简单呢?我在所有事件中使用数据传输对象,以确保始终维护上下文并始终提供类型安全。

我构建了一个软件包,让我做这件事更容易。可以通过以下 Composer 命令安装它:

composer require juststeveking/laravel-data-object-tools

和以前一样, 我默认将我的数据对象保存在我的领域, 但你可以添加到对你最有意义的地方。 我创建了一个名为 Celebration 的数据对象,可以传递给事件和聚合器:

declare(strict_types=1);

namespace Domains\Culture\DataObjects;

use JustSteveKing\DataObjects\Contracts\DataObjectContract;

final class Celebration implements DataObjectContract
{
    public function __construct(
        private readonly string $reason,
        private readonly string $message,
        private readonly int $user,
        private readonly int $sender,
        private readonly int $team,
    ) {}

    public function userID(): int
    {
        return $this->user;
    }

    public function senderID(): int
    {
        return $this->sender;
    }

    public function teamUD(): int
    {
        return $this->team;
    }

    public function toArray(): array
    {
        return [
            'reason' => $this->reason,
            'message' => $this->message,
            'user_id' => $this->user,
            'sender_id' => $this->sender,
            'team_id' => $this->team,
        ];
    }
}

当我升级到 PHP 8.2 时,这会容易得多,因为我可以创建只读类 - 是的,我的包已经支持它们。

现在我们有了我们的数据对象。我们可以回到我们想要存储的事件。我已经调用了我的CelebrationWasCreated,因为事件名称应该总是过去时。让我们看看这个事件:

declare(strict_types=1);

namespace Domains\Culture\Events;

use Domains\Culture\DataObjects\Celebration;
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

final class CelebrationWasCreated extends ShouldBeStored
{
    public function __construct(
        public readonly Celebration $celebration,
    ) {}
}

因为我们使用的是数据对象,所以我们的类保持干净。所以,现在我们有了一个事件——以及一个可以发送的数据对象,我们需要考虑如何触发它。这让我们回到了聚合本身,所以让我们在聚合上创建一个可以用于此目的的方法:

declare(strict_types=1);

namespace Domains\Culture\Aggregates;

use Domains\Culture\DataObjects\Celebration;
use Domains\Culture\Events\CelebrationWasCreated;
use Domains\Culture\Repositories\CelebrationStoredEventsRepository;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
use Spatie\EventSourcing\StoredEvents\Repositories\StoredEventRepository;

final class CelebrationAggregateRoot extends AggregateRoot
{
    protected function getStoredEventRepository(): StoredEventRepository
    {
        return app()->make(
            abstract: CelebrationStoredEventsRepository::class,
        );
    }

    public function createCelebration(Celebration $celebration): CelebrationAggregateRoot
    {
        $this->recordThat(
            domainEvent: new CelebrationWasCreated(
                celebration: $celebration,
            ),
        );

        return $this;
    }
}

在这一点上,我们有一种方法来要求一个类记录事件。但是,这一事件还不会持续下去 —— 那是以后的事。此外,我们不会以任何方式改变应用程序的状态。那么,我们该如何做这项活动采购工作呢?这一部分是关于 Livewire 中的实现的,我现在将向你介绍它。

我喜欢通过调度一个事件来管理这个过程,因为它更高效。如果你考虑如何与应用程序交互,你可以从 Web 访问它,通过 API 端点发送请求,或者发生 CLI 命令可能运行的事件 —— 可能是一个 Cron 作业。在所有这些方法中,通常,你需要即时响应,或者至少您不想等待。我将在我的 Livewire 组件上向你展示我为此使用的方法:

public function celebrate(): void
{
    $this->validate();

    dispatch(new TeamMemberCelebration(
        celebration: Hydrator::fill(
            class: Celebration::class,
            properties: [
                'reason' => $this->reason,
                'message' => $this->content,
                'user' => $this->identifier,
                'sender' => auth()->id(),
                'team' => auth()->user()->current_team_id,
            ]
        ),
    ));

    $this->closeModal();
}

在这一点上,我们有一种方法来要求一个类记录事件。但是,这一事件还不会持续下去 —— 那是以后的事。此外,我们不会以任何方式改变应用程序的状态。那么,我们该如何做这项活动采购工作呢?这一部分是关于 Livewire 中的实现的,我现在将向你介绍它。

我喜欢通过调度一个事件来管理这个过程,因为它更高效。如果你考虑如何与应用程序交互,你可以从 Web 访问它,通过 API 端点发送请求,或者发生 CLI 命令可能运行的事件 —— 可能是一个 Cron 作业。在所有这些方法中,通常,你需要即时响应,或者至少你不想等待。我将在我的 Livewire 组件上向你展示我为此使用的方法:

public function celebrate(): void
{
    $this->validate();

    dispatch(new TeamMemberCelebration(
        celebration: Hydrator::fill(
            class: Celebration::class,
            properties: [
                'reason' => $this->reason,
                'message' => $this->content,
                'user' => $this->identifier,
                'sender' => auth()->id(),
                'team' => auth()->user()->current_team_id,
            ]
        ),
    ));

    $this->closeModal();
}

当我验证来自组件的用户输入,可以分派处理一个新的作业,然后结束这个流程。我使用我的包将一个新的数据对象传递给作业。它有一个 Facade,可以让我用一系列属性来为类添加——到目前为止它工作得很好。那么这是怎么实现的呢?让我们来看看。

declare(strict_types=1);

namespace App\Jobs\Team;

use Domains\Culture\Aggregates\CelebrationAggregateRoot;
use Domains\Culture\DataObjects\Celebration;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;

final class TeamMemberCelebration implements ShouldQueue
{
    use Queueable;
    use Dispatchable;
    use SerializesModels;
    use InteractsWithQueue;

    public function __construct(
        public readonly Celebration $celebration,
    ) {}

    public function handle(): void
    {
        CelebrationAggregateRoot::retrieve(
            uuid: Str::uuid()->toString(),
        )->createCelebration(
            celebration: $this->celebration,
        )->persist();
    }
}

我们的工作将数据对象接受到它的构造函数中,然后在处理它时存储它。处理作业时,它使用 CelebrationAggregateRoot 按 UUID 检索聚合,然后调用我们之前创建的 createCelebration 方法。在它调用了这个方法之后 - 它在聚合本身上调用了 persist。这就是将为我们存储事件的内容。但是,同样,我们还没有改变我们的应用程序状态。我们所做的只是存储一个不相关的事件而不是创建我们想要创建的庆祝活动?那么我们缺少什么?

我们的事件也需要处理。在另一种方法中,我们使用投影仪来处理我们的事件,但我们必须手动调用它们。这是一个类似的过程,但是我们的聚合正在触发事件,我们仍然需要一个投影仪来处理事件并改变我们的应用程序状态。

让我们创建我们的投影仪,我称之为处理程序 —— 因为它们处理事件。但我会让你决定如何命名你的。

declare(strict_types=1);

namespace Domains\Culture\Handlers;

use Domains\Culture\Events\CelebrationWasCreated;
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;
use Infrastructure\Culture\Actions\CreateNewCelebrationContract;

final class CelebrationHandler extends Projector
{
    public function __construct(
        public readonly CreateNewCelebrationContract $action,
    ) {}

    public function onCelebrationWasCreated(CelebrationWasCreated $event): void
    {
        $this->action->handle(
            celebration: $event->celebration,
        );
    }
}

我们的投影机 / 处理程序,无论你选择如何称呼它,都将从容器中为我们解析 - 然后它将寻找一个以 on 为前缀的方法,后跟事件名称本身。所以在我们的例子中,onCelebrationWasCreated。在我的示例中,我使用一个动作来执行事件中的实际逻辑 - 单个类执行一项可以轻松伪造或替换的工作。所以再一次,我们把树追到下一个班级。动作,这对我来说是这样的:

declare(strict_types=1);

namespace Domains\Culture\Actions;

use App\Models\Celebration;
use Domains\Culture\DataObjects\Celebration as CelebrationObject;
use Illuminate\Database\Eloquent\Model;
use Infrastructure\Culture\Actions\CreateNewCelebrationContract;

final class CreateNewCelebration implements CreateNewCelebrationContract
{
    public function handle(CelebrationObject $celebration): Model|Celebration
    {
        return Celebration::query()->create(
            attributes: $celebration->toArray(),
        );
    }
}

这是当前执行的操作。如你所见,我的操作类本身实现了一个合同 / 接口。这意味着我将接口绑定到我的服务提供者中的特定实现。这使我可以轻松地创建测试替身 / 模拟 / 替代方法,而不会对需要执行的实际操作产生连锁反应。这不是严格意义上的事件溯源,而是通用编程。我们确实拥有的一个好处是我们的投影仪可以重放。因此,如果出于某种原因,我们离开了 Laravel Eloquent,也许我们使用了其他东西,我们可以创建一个新的操作 - 将实现绑定到我们的容器中,重放我们的事件,它应该都能正常工作。

在这个阶段,我们正在存储我们的事件并有办法改变我们的应用程序的状态 —— 但是我们做到了吗?我们需要告诉 Event Sourcing 库我们已经注册了这个 Projector/Handler 以便它知道在事件上触发它。通常我会为每个域创建一个 EventSourcingServiceProvider,这样我就可以在一个地方注册所有的处理程序。我的看起来如下:

declare(strict_types=1);

namespace Domains\Culture\Providers;

use Domains\Culture\Handlers\CelebrationHandler;
use Illuminate\Support\ServiceProvider;
use Spatie\EventSourcing\Facades\Projectionist;

final class EventSourcingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        Projectionist::addProjector(
            projector: CelebrationHandler::class,
        );
    }
}

剩下的就是确保再次注册此服务提供者。我为每个域创建一个服务提供者来注册子服务提供者 —— 但这是另一个故事和教程。

在这个阶段,我们正在存储我们的事件,并有一种办法改变我们的应用程序的状态——但是我们做到了吗?我们需要告诉 Event Sourcing 库,我们已经注册了 Projector/Handler 以便它知道在事件上触发它。通常,我会为每个域创建一个EventSourcingServiceProvider,以便可以在一个位置注册所有处理程序。如下:

declare(strict_types=1);

namespace Domains\Culture\Providers;

use Domains\Culture\Handlers\CelebrationHandler;
use Illuminate\Support\ServiceProvider;
use Spatie\EventSourcing\Facades\Projectionist;

final class EventSourcingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        Projectionist::addProjector(
            projector: CelebrationHandler::class,
        );
    }
}

剩下确保此服务提供者重新注册。我为每个域创建一个 Service Provider 来注册子服务提供者--但这是另一个故事和教程。

现在,当我们把它们放在一起时。我们可以要求我们的聚合创建一个庆祝活动,它将记录事件并将其保存在数据库中,并且作为副作用,我们的处理程序将被触发,随着新的变化改变应用程序的状态。

这似乎有点啰嗦,对吧?有没有更好的办法?可能,但在这一点上,我们知道何时更改了我们的应用程序状态。我们了解它们的制作原因。此外,由于我们的数据对象,我们知道谁进行了更改以及何时进行了更改。所以它可能不是最直接的方法,但它可以让我们更多地了解我们的应用程序。

원하는 만큼 이 작업을 수행하거나 가장 합리적인 이벤트 소싱에 발을 담글 수 있습니다. 이 튜토리얼이 오늘 이벤트 소싱 사용을 시작하기 위한 명확하고 실용적인 경로를 보여주었기를 바랍니다.

이 글을 읽고도 여전히 만족스럽지 못하다면 Spatie에서 Laravel 코스의 이벤트 소싱 부분에 사용할 수 있는 30% 할인 쿠폰을 아낌없이 제공해드립니다. 강좌 웹사이트를 방문하여 쿠폰 코드LARAVEL-NEWS-EVENT-SOURCING를 사용하세요.

이벤트 소싱을 사용해 본 적이 있나요? 어떻게 처리하셨나요? 댓글 섹션에서 알려주세요!

원본 주소 : https://laravel-news.com/event-sourcing-in-laravel

번역 주소 : https://learnku.com/laravel/t/71001

더 많은 프로그래밍 관련 지식, 방문하세요: 프로그래밍 비디오! !

위 내용은 Laravel의 이벤트 소싱을 자세히 설명하는 기사의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
이 기사는 learnku.com에서 복제됩니다. 침해가 있는 경우 admin@php.cn으로 문의하시기 바랍니다. 삭제