• Время чтения ~24 мин
  • 05.07.2022

Итак, я работаю над классным проектом, и я работал над функцией, которая обрабатывает входящие файлы и загружает их в облачное хранилище. Я думал, что партии Laravel идеально подойдут для этого, что и произошло! Я решил объединить мощь пакетов Laravel с событиями, Laravel Echo и Livewire, чтобы показать прогресс в реальном времени моим пользователям, и не забыть немного конфетти, чтобы отпраздновать 🎉.

Итак, в этой статье я покажу вам шаг за шагом, как это сделать, чтобы вы могли поразить своих клиентов (конфетти).

Что такое Laravel Livewire?

Livewire - это полнофункциональный фреймворк для Laravel от Калеба Порцио, который делает создание динамических  интерфейсов очень простым, не написав ни одной строки Javascript, что довольно круто, потому что вы можете создать ощущение SPA, опять же, без необходимости писать какой-либо Javascript. Как упоминалось на веб-сайте Livewire, лучший способ понять это - посмотреть на код, так что давайте начнем!

Установка Laravel, Livewire и Tailwind CSS

Я собираюсь использовать чистую установку Laravel 8, но вы, конечно, можете, конечно, следовать и одному из ваших существующих проектов.

Давайте скачаем и установим Laravel, Livewire и Tailwind CSS для тех, кто хочет начать с нуля.

Я сосредоточусь на партиях Laravel и прогрессе в реальном времени с Livewire. Если вы новичок в Laravel, Livewire или Tailwind CSS, все эти продукты имеют обширную и хорошо написанную документацию, которая поможет вам начать работу.

# Install Laravel
laravel new laravel-livewire-job-batches
# Require Livewire
composer require livewire/livewire
# Install Tailwind CSS
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
# Create Tailwind CSS configuration file
npx tailwindcss init

Далее нам нужно сделать еще несколько настроек, чтобы Tailwind CSS работал. Давайте сначала обновим нашwebpack.mix.js.

  // webpack.mix.js
  mix.js("resources/js/app.js", "public/js")
    .postCss("resources/css/app.css", "public/css", [
+     require("tailwindcss"),
    ]);

Откройте ./resources/css/app.css и добавьте директивы CSS Tailwind:

@tailwind base;
@tailwind components;
@tailwind utilities;

Далее мы хотим убедиться, что мы включили наш CSS в наш файл blade, а также включили директивы Livewire. Так что откройте ./resources/views/welcome.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>File Transfer</title>
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
    @livewireStyles
</head>
<body>
@livewireScripts
<script src="{{ asset('js/app.js') }}" type="text/javascript"></script>
</body>
</html>

Это должно сделать свое дело. Запускаемnpm run dev, чтобы убедиться, что у нас есть скомпилированный CSS-файл.

Пользовательский интерфейс

I'm a big fan of Пользовательский интерфейс because it looks fantastic; it saves me many hours designing and writing markup, which I frankly don't enjoy as much as writing PHP. In this article, you will see screenshots with Пользовательский интерфейс components while we progress. The HTML markup in this article, however, will not include any Пользовательский интерфейс components. I've made the following sample application in a couple of minutes:

Мы хотим добиться следующего:Создание компонента Livewire Давайте начнем с создания нашего компонента

Livewire:

  1. As a user, we want to upload one or more images.
  2. If a user provides an invalid file, we want to show an error message.
  3. We want to show a preview of the selected images which are valid.
  4. When the user submits their images successfully, the form should reset.
  5. When the user submits their images, our application should create a transfer object consisting of multiple transfer file objects.
  6. An TransferCreated event should occur with a listener, which will generate the batch and store the batch ID on the transfer object to track the progress.
  7. Create a read-only model for the job_batches table so we can eager-load our batches.
  8. Livewire should update the table in real-time to show the transfer status or progress and storage usage.
  9. Fire the confetti cannon

php artisan livewire:make ManageTransfers
COMPONENT CREATED  🤙
CLASS: app/Http/Livewire/ManageTransfers.php
VIEW:  resources/views/livewire/manage-transfers.blade.php

Давайте переместим наш HTML в manage-transfers.blade.php файл.

<div>
    <table>
        <thead>
        <tr>
            <th>&nbsp;</th>
            <th>Status</th>
            <th>Batch ID</th>
            <th>Storage</th>
        </tr>
        </thead>
        <tbody>
        <tr>
            <td>√</td>
            <td>Uploaded</td>
            <td>d9cbb5a7-ea12-42b4-9fb3-3e5a7f10631f</td>
            <td>2MB</td>
        </tr>
        <tr>
            <td>X</td>
            <td>Finished with errors</td>
            <td>0d669854-fb2c-480f-ae04-8572ec695242</td>
            <td>0MB</td>
        </tr>
        <tr>
            <td>!!</td>
            <td>Failed</td>
            <td>e176a925-8534-446f-a1f6-3fc2e06fcb0f</td>
            <td>0MB</td>
        </tr>
        <tr>
            <td>
                <svg
                        xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                    <circle
                            stroke-width="4"></circle>
                    <path
                            d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                </svg>
            </td>
            <td>
                <div class="flex h-2 overflow-hidden rounded bg-gray-50">
                    <div style="transform: scale({{ 50 / 100 }}, 1)"
                         class="bg-indigo-500 transition-transform origin-left duration-200 ease-in-out w-full shadow-none flex flex-col"></div>
                </div>
            </td>
            <td>
                296fc64e-af31-401d-9895-3d18ce02931c
            </td>
            <td>
                0MB
            </td>
        </tr>
        </tbody>
    </table>

    <div>
        <h3>Create Batch</h3>
        <p>Select the files you want to upload.</p>
        <div>
            <input id="files" name="files" type="file" multiple>
        </div>
        <div>
            Files
        </div>

        <div>
            <img src="#" alt="">
            <img src="#" alt="">
            <img src="#" alt="">
        </div>
        <div>
            <button type="button">
                Do some magic
                <svg
                        xmlns="http://www.w3.org/2000/svg">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                          d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
                </svg>
            </button>
        </div>
    </div>
</div>

Кроме того, добавьте директиву @livewire, чтобы welcome.blade.php компонент отображался.

<body>
@livewire('manage-transfers')

Загрузка файлов с помощью Livewire Livewire

упростила загрузку файлов! Это довольно безумно, как просто заставить это работать.

Livewire file upload flow

Livewire сделает POST-запрос к /livewire/upload-file конечной точке и вернет временные имена файлов за кулисами. Клиент браузера Livewire выполнит POST-запрос к компоненту Livewire, который вернет HTML-код и обновит модель DOM для отображения предварительного просмотра изображений.

Мы начнем с определения модели для нашего элемента загрузки файлов. В этом случае давайте продолжимpendingFiles, так как эта модель будет содержать все файлы, выбранные пользователем, но еще не обработанные.

<input id="files" wire:model="pendingFiles" type="file" multiple>

Затем перейдите к связанному классу Livewire для этого компонента (app/Http/Livewire/ManageTransfers.php) и подключите его (каламбур).

<?php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
class ManageTransfers extends Component
{
    use WithFileUploads;
    
    public $pendingFiles = [];
    public function render()
    {
        return view('livewire.manage-transfers');
    }
}

Для работы с загрузкой файлов необходимо включить признак 'Livewire\WithFileUploads'. Эта черта будет состоять из необходимых методов для обработки файлов, загруженных через этот компонент.

Совет: Будьте осторожны при вводе общедоступных свойств в компонентах Livewire. Livewire в некоторых случаях выдает исключение, если свойство не является строкой, массивом, раздражительным значением, поскольку оно не знает, что делать. Например:

// Using array as typed property
public array $pendingFiles = [];
// Will throw a TypeError when you try to upload a file
TypeError Cannot assign string to property App\Http\Livewire\ManageTransfers::$pendingFiles of type array

Вернуться к магии Livewire. Мы добавили пару строк кода, и наша загрузка уже работает. Чтобы убедиться в этом, давайте быстро добавим несколько временных изображений для предварительного просмотра:

<div>
    @forelse($pendingFiles as $pendingFile)
    <img src="{{ $pendingFile->temporaryUrl() }}"
         alt="">
    @empty
    <p>No files selected</p>
    @endforelse
</div>
Upload preview images

Теперь попробуйте, выберите пару изображений и наблюдайте за волшебством. Сумасшедший, правда? Так как же это работает? Что ж, Livewire обрабатывает все выбранные файлы и помещает эти файлы в частный временный каталог.

Livewire отслеживает все наши загрузки. Свойство $pendingFiles вернет массив Livewire\TemporaryUploadedFile объектов. Метод temporaryUrl вернет подписанный маршрут, чтобы сделать загруженный файл доступным. В целях безопасности этот временный URL-адрес будет работать только для утвержденных расширений файлов. Поэтому, если вы загрузите zip-файл, это не сработает.

Если вы хотите изменить это поведение по умолчанию, вы можете настроить livewire.php файл конфигурации. Вам нужно выполнить следующую команду, чтобы опубликовать файл конфигурации:Если вы откроете этот файл, вы можете прокрутить вниз до раздела «Конфигурация конечной точки загрузки временных файлов Livewire» и настроить конфигурацию по своему вкусу:

php artisan livewire:publish --config

Пожалуйста, учтите, что,

'temporary_file_upload' => [
    'disk' => null,        
    'rules' => null,       
    'directory' => null,   
    'middleware' => null,  
    'preview_mimes' => [   
        'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
        'mov', 'avi', 'wmv', 'mp3', 'm4a',
        'jpg', 'jpeg', 'mpga', 'webp', 'wma',
    ],
    'max_upload_time' => 5, 
],

хотя Livewire поставляется с некоторыми разумными настройками по умолчанию, такими как ограничение загрузки в 12 МБ и дроссельная заслонка, кто-то может заполнить ваше дисковое пространство, если этот человек захочет. Так что дополнительная безопасность не помешает. Вы можете, например, ограничить количество временных файлов на пользователя.

Проверка файлов с помощью Livewire По умолчанию Livewire

реализует requiredfileправила , и и max:12288 для любых временных отправок файлов. Чтобы добавить нашу проверку, мы можем предоставить наши правила методуvalidate.

Давайте создадим общедоступный метод с именем initiateTransfer() и некоторые из наших собственных правил проверки (файл должен быть изображением и не более 5 МБ), подключим его к кнопке, чтобы пользователь мог отправить выбранные файлы для передачи.

public function initiateTransfer()
{
    $this->validate([
        'pendingFiles.*' => ['image', 'max:5120']
    ]);
}
<div>
    <button wire:click="initiateTransfer" type="button">
        Do some magic
    </button>
</div>

Например, если вы загрузите слишком большое изображение, вы пока не увидите ошибок. Итак, давайте добавим это в наше представление:

<div>
    @error('pendingFiles.*')
    {{ $message }}
    @enderror
</div>

Например, если я загружу файл размером 70 МБ, он немедленно завершится ошибкой, потому что он не проходит правило начальной проверки (максимум 12 МБ), определенное в конфигурации livewire.php.

Validation error

Если мы загрузим изображение размером 6,7 МБ, вы увидите временный предварительный просмотр, но без ошибки. Ошибка появится только после выполнения проверки, поэтому, если вы нажмете кнопку «Сделать волшебство», вы должны увидеть сообщение об ошибке, в котором говорится, что размер файла не может превышать 5 МБ.

Красноречивые модели и миграции

баз данных Чтобы отслеживать статус нашего пакета, нам нужно к чему-то прикрепить наш пакет. Например, вы можете захотеть вложить эти файлы в документ, проект; вы называете это. Вы также можете показать все содержимое таблицы «job_batches», но я не думаю, что это вероятный вариант использования.

Таблица партий заданий
Прежде чем мы сможем отправлять пакеты, нам нужно запустить команду artisan для создания таблицы базы данных, которую Laravel будет использовать для сохранения данных, связанных с нашим пакетом. Итак, выполните следующую команду:Таблица job_batches содержит следующую информацию:

php artisan queue:batches-table
Migration created successfully!

  • Название задания.
  • Общее количество заданий, выданных пакетом.
  • Общее количество ожидающих заданий, ожидающих обработки работником очереди.
  • Общее количество заданий, которые не удалось обработать работнику очереди.
  • Идентификаторы неудачных заданий, это ссылка на таблицу failed_jobs .
  • Любые параметры, такие как then, catch или finally.
  • Отметка времени отмены пакета.
  • Метка времени создания пакета.
  • Метка времени, когда работник очереди завершил обработку всех заданий для заданного пакета.

Мы будем использовать некоторые из этих данных на следующем этапе, чтобы, например, показать ход выполнения конкретной партии.

Пользовательская модель
Я хочу продемонстрировать, как вести трансляцию в частном порядке, чтобы только вошедший в систему пользователь мог видеть задания передачи и ход выполнения в режиме реального времени. Laravel уже поставляется с пользовательской моделью, поэтому единственное, что вам нужно сделать, это открыть и раскомментировать фабрику DatabaseSeeder пользователей.

Формирование шаблонов пользовательского интерфейса для проверки подлинности и т. д. выходит за рамки данной статьи. Тем не менее, я хотел бы поделиться альтернативой при работе локально и быстро войти в любую учетную запись без предоставления каких-либо учетных данных.

Откройте свой routes/web.php и добавьте следующее:

Route::get('dev-login', function () {
    abort_unless(app()->environment('local'), 403);
    auth()->loginUsingId(App\User::first());
    return redirect()->to('/');
});

Это будет работать только локально из-за abort_unless функции, которая выдаст ошибку 403, если среда не равна local. Я использую вспомогательное auth() средство для входа первого пользователя в базе данных.

Передача и файловая модель
Мы будем использовать модель Eloquent для отслеживания всех файлов. Как упоминалось ранее, вы можете рассматривать это как проект (или электронное письмо или твит с одним или несколькими вложениями), и вы хотите загрузить и связать определенные файлы с этим проектом.

Итак, давайте создадим модель и миграцию как для передачи, так и для файла.

php artisan make:model Transfer --migration 
Model created successfully.
Created Migration: 2021_02_04_174530_create_transfers_table
php artisan make:model TransferFile --migration
Model created successfully.
Created Migration: 2021_02_04_174548_create_transfer_files_table

Для нашей transfers таблицы нам нужно добавить только одно дополнительное поле, а именноbatch_id: :

Schema::create('transfers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id');
    $table->foreignUuid('batch_id')->nullable();
    $table->timestamps();
});

Таблица будет содержать путь к файлу и размер файла:

Schema::create('transfer_files', function (Blueprint $table) {
    $table->id();
    $table->foreignId('transfer_id');
    $table->string('disk');
    $table->string('path');
    $table->unsignedInteger('size');
    $table->timestamps();
});

Пока transfer_files мы здесь, давайте установим связь между ними и определим заполняемые поля.

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Transfer extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var string[]
     */
    protected $fillable = [
        'batch_id',
    ];
    
    public function files(): HasMany
    {
        return $this->hasMany(TransferFile::class);
    }
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class TransferFile extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var string[]
     */
    protected $fillable = [
        'disk',
        'path',
        'size'
    ];
    /**
     * The attributes that should be cast.
     *
     * @var array
     */
    protected $casts = [
        'disk' => 'string',
        'path' => 'string',
        'size' => 'integer'
    ];
    public function transfer(): BelongsTo
    {
        return $this->belongsTo(Transfer::class);
    }
}

Сохранение данных в базе данных

Выполнитеphp artisan migrate, чтобы выполнить все миграции, если вы еще этого не сделали. Теперь мы можем хранить ожидающие файлы, прошедшие проверку.

public function initiateTransfer()
{
    $this->validate([
        'pendingFiles.*' => ['image', 'max:5120'],
    ]);
   
    // This code will not execute if the validation fails
    $transfer = auth()->user()->transfers()->create();
    $transfer->files()->saveMany(
        collect($this->pendingFiles)
            ->map(function (TemporaryUploadedFile $pendingFile) {
                return new TransferFile([
                    'disk' => $pendingFile->disk,
                    'path' => $pendingFile->getRealPath(),
                    'size' => $pendingFile->getSize(),
                ]);
            })
    );
}

Когда вы выберете четыре изображения и нажмете волшебную кнопку, вы увидите одну новую запись базы данных в таблице и четыре записи в transfers таблицеtransfer_files.

Теперь давайте сбросим $pendingFiles свойство на пустой массив, чтобы сбросить форму и, наконец, событие (мы создадим этот класс на следующем шаге).

public function initiateTransfer()
{
    $this->validate([
        'pendingFiles.*' => ['image', 'max:5120'],
    ]);
    $transfer = auth()->user()->transfers()->create();
    $transfer->files()->saveMany(
      // ...
    );
    $this->pendingFiles = [];
    LocalTransferCreated::dispatch($transfer);
}

Событие Dispatch LocalTransferCreated

Теперь у нас есть все наши данные, мы можем отправить событие, которое мы сможем прослушать на будущих шагах. Давайте создадим событие, на которое мы ссылались в приведенном выше методеinitiateTransfer:

php artisan make:event LocalTransferCreated
Event created successfully.

В конечном итоге мы хотим обновлять нашу таблицу каждый раз, LocalTransferCreated когда событие отправляется. Для этого мы будем использовать Laravel Echo. Мы поговорим об этом на следующем этапе, но, поскольку мы здесь, давайте убедимся, что наше мероприятие реализует ShouldBroadcast интерфейс, чтобы Laravel знал, что мы хотим транслировать событие.

<?php
namespace App\Events;
use App\Models\Transfer;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class LocalTransferCreated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
    public function __construct(private Transfer $transfer)
    {
    }
    public function getTransfer(): Transfer
    {
        return $this->transfer;
    }
    
    public function broadcastOn()
    {
        return new PrivateChannel('channel-name');
    }
}

Создание и назначение прослушивателя

Давайте создадим новый прослушиватель, который будет прослушивать LocalTransferCreated событие и отправлять наш пакет.

php artisan make:listener CreateTransferBatch
Listener created successfully.

Затем добавьте сопоставление в ourEventServiceProvider, чтобы указать Laravel вызывать наш при LocalTransferCreated отправке события:

<?php
namespace App\Providers;
use App\Events\LocalTransferCreated;
use App\Listeners\CreateTransferBatch;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        LocalTransferCreated::class => [
            CreateTransferBatch::class
        ]
    ];
}

Создание нашего задания

передачи Прежде чем мы сможем создать наш CreateTransferBatch пакет, нам нужно иметь задание для передачи нашему пакету. Итак, давайте создадим один:

php artisan make:job TransferLocalFileToCloud
Job created successfully.

Теперь это относительно простая работа. Мы берем локальный файл, загружаем его в наше облачное хранилище, обновляем запись базы данных, изменяя диск на 's3', и путь к сгенерированному пути, содержащему уникальное имя файла, возвращаемое методом 'put'. Наконец, мы очищаем, удаляя файл из нашего локального хранилища.

Чтобы сделать вашу работу пакетной, вам нужно добавить черту «Пакетная»; в противном случае вы получите следующее исключение:

Call to undefined method App\Jobs\TransferLocalFileToCloud::withBatchId()

В зависимости от вашего поставщика облачного хранилища вам может потребоваться адаптер, напримерcomposer require "league/flysystem-aws-s3-v3 ~1.0"

<?php
namespace App\Jobs;
use App\Models\TransferFile;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\File;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class TransferLocalFileToCloud implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    public function __construct(private TransferFile $file)
    {
    }
    public function handle()
    {
        $cloudPath = Storage::disk('s3')->put('images', new File($localPath = $this->file->path));
        $this->file->update([
            'disk' => 's3',
            'path' => $cloudPath,
        ]);
        Storage::delete(explode('/app/', $localPath)[1]);
   
        // Dispatch event
    }
}

: Поскольку мы хотим обновлять интерфейс в режиме реального времени, нам нужно отправлять событие каждый раз при передаче файла. Создадим новое событие:

php artisan make:event FileTransferredToCloud 
Event created successfully.

Возьмем модель Transfer в качестве параметра события и убедимся, что событие реализует интерфейс ShouldBroadcastNow. Мы хотим транслировать это событие сразу, иначе оно окажется в нижней части бэклога очереди, в результате чего это событие сработает, когда весь пакет уже будет завершен. Учитывая, что мы хотим показать прогресс в режиме реального времени, нам нужно, чтобы эта трансляция происходила в режиме реального времени.

<?php
namespace App\Events;
use App\Models\TransferFile;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FileTransferredToCloud implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
    public function __construct(private TransferFile $file)
    {
    }
    public function broadcastOn()
    {
        return new PrivateChannel('channel-name');
    }
}

Теперь отправим событие из нашего задания:

public function handle()
{
    // ...
    FileTransferredToCloud::dispatch($this->file);
}

Создать пакет заданий Пора создать наш пакет

заданий внутри нашего TransferLocalFileToCloud CreateTransferBatch прослушивателя. Мы можем использовать mapInto метод сбора для быстрого создания задания для каждого файла.

<?php
namespace App\Listeners;
use App\Events\LocalTransferCreated;
class CreateTransferBatch
{
    public function handle(LocalTransferCreated $event)
    {
        $jobs = $event->getTransfer()->files->mapInto(TransferLocalFileToCloud::class);
    }
}

Теперь мы можем использовать Batch фасад для отправки всех этих работ за один раз.

public function handle(LocalTransferCreated $event)
{
    $jobs = $event->getTransfer()->files->mapInto(TransferLocalFileToCloud::class);
    $batch = Bus::batch($jobs)->dispatch();
}

Не стесняйтесь попробовать! Пакет должен перенести ваши файлы в облачное хранилище. Вы можете убедиться в этом, взглянув на свою job_batches таблицу. Здесь вы должны увидеть новую запись и количество обработанных заданий.

Сохранение и настройка нашего пакета

Весь смысл нашей партии заключается в том, что мы хотим показать прогресс нашим пользователям. Итак, давайте начнем с прикрепления идентификатора пакета к нашей модели передачи.

public function handle(LocalTransferCreated $event)
{
    $jobs = $event->getTransfer()->files->mapInto(TransferLocalFileToCloud::class);
    $batch = Bus::batch($jobs)->dispatch();
    $event->getTransfer()->update([
        'batch_id' => $batch->id
    ]);
}

Затем мы хотим запустить событие, когда все наши задания будут обработаны. Фасад Batch предоставляет несколько методов, которые вы можете использовать для достижения этой цели.

$batch = Bus::batch($jobs)
    ->then(function (Batch $batch) {
        // All jobs completed successfully
    })->catch(function (Batch $batch, Throwable $e) {
        // First batch job failure detected
    })->finally(function (Batch $batch) {
        // The batch has finished executing
    })->dispatch();

В нашем случае мы будем использовать finally метод только для запуска TransferCompleted события. Итак, давайте сначала создадим это событие.

php artisan make:event TransferCompleted
Event created successfully.

Это событие примет модель в Transfer качестве параметра и реализует интерфейс, ShouldBroadcast как мы это делали ранее.

<?php
namespace App\Events;
use App\Models\Transfer;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TransferCompleted implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
    
    public function __construct(private Transfer $transfer)
    {
    }
    
    public function broadcastOn()
    {
        return new PrivateChannel('channel-name');
    }
}

Далее мы добавим его в finally закрытие.

class CreateTransferBatch
{
    public function handle(LocalTransferCreated $event)
    {
        $transfer = $event->getTransfer();
        $jobs = $transfer->files->mapInto(TransferLocalFileToCloud::class);
        $batch = Bus::batch($jobs)
            ->finally(function () use ($transfer) {
                TransferCompleted::dispatch($transfer);
            })->dispatch();
        $event->getTransfer()->update([
            'batch_id' => $batch->id
        ]);
    }
}

Я также назначил переменную модели передачи, чтобы передать модель вместо всего события. Laravel сериализует закрытие и сохранит его в options столбце job_batches таблицы.

Если вы хотите, вы можете дать всему потоку еще один шанс и использовать php artisan queue work его для просмотра обработанных заданий.

Довольно круто, правда?

Список наших трансферов Теперь мы можем сделать нашу статическую таблицу динамической, перечислив все трансферы

. Итак, вернитесь к своему классу компонентов Livewire и передайте нам коллекцию Eloquent, содержащую наши модели Transfer.

public function render()
{
    return view('livewire.manage-transfers', [
        'transfers' => auth()->user()->transfers
    ]);
}

Давайте откроем наш manage-transfers.blade.php и добавим в нашу таблицу@forelse.

<tbody>
@forelse($transfers as $transfer)
<tr>
    <td>
        {{-- status icon --}}
    </td>
    <td>
        {{-- status text --}}
    </td>
    <td>
        {{ $transfer->batch_id }}
    </td>
    <td>
        {{-- combined file size of transfer files --}}
    </td>
</tr>
@empty
<tr>
    <td colspan="4">
        You have no transfers. Create a batch on the right 👉🏻
    </td>
</tr>
@endforelse
</tbody>

Нам нужно показать статус нашего задания и рассчитать общий размер всех передаваемых файлов. Теперь Laravel поставляется с методом поиска пакетов по их идентификатору: Bus::findBatch($batchId);. Этот метод будет использовать «DatabaseBatchRepository» за кулисами для получения данных из базы данных и преобразования данных в \Illuminate\Bus\Batch объект.

Метод findBatch работает отлично, но я хотел бы использовать Eloquent вместо этого, чтобы быстро загружать все пакеты, выполнять области запросов и т. Д. Итак, давайте создадим готовую модель для наших партий.

Laravel отвечает за поддержание целостности данных нашей job_batches таблицы, и я хочу, чтобы это было так, поэтому мне нравится, что модель доступна только для чтения.

Пакагист спешит на помощь. Если вы выполните быстрый поиск только для чтения, вы увидите следующий пакет. В этом пакете будет представлена черта, позволяющая сделать модели доступными только для чтения. Итак, давайте установим его:Далее мы также хотим создать нашу модель:

composer require michaelachrisco/readonly

php artisan make:model JobBatch

Давайте начнем с добавления в нашу модель признака, доступного только для чтения, поэтому наша модель будет выдавать исключение, если вы вызываете такой метод, как create, save, delete и т. д.

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use MichaelAChrisco\ReadOnly\ReadOnlyTrait;
class JobBatch extends Model
{
    use ReadOnlyTrait;
}

Если мы быстро посмотрим на job_batches таблицу, вы увидите, что каждый пакет имеет UUID вместо увеличивающегося целого числа, а также не имеет метки updated_at времени. Итак, давайте обновим нашу модель:Чтобы упростить задачу, мы можем привести свойства модели, чтобы мы получали Carbon объект, когда мы делаем что-то вроде$jobBatch->finished_at, или коллекцию, когда мы это делаем$jobBatch->options

class JobBatch extends Model
{
    use ReadOnlyTrait;
    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'job_batches';
    /**
     * The "type" of the primary key ID.
     *
     * @var string
     */
    protected $keyType = 'string';
    /**
     * Indicates if the IDs are auto-incrementing.
     *
     * @var bool
     */
    public $incrementing = false;
    /**
     * Indicates if the model should be timestamped.
     *
     * @var bool
     */
    public $timestamps = false;
}

:Далее мы хотим воссоздать некоторые методы, которые мы обычно получаем из  Illuminate\Bus\Batch объекта:

class JobBatch extends Model
{
    // ....
    /**
     * The attributes that should be cast.
     *
     * @var array
     */
    protected $casts = [
        'options'      => 'collection',
        'failed_jobs'  => 'integer',
        'created_at'   => 'datetime',
        'cancelled_at' => 'datetime',
        'finished_at'  => 'datetime',
    ];
}

  • processedJobs: получить общее количество заданий, которые были обработаны пакетом на данный момент.
  • прогресс: Получите процент обработанных заданий (от 0 до 100).
  • законченный: Определите, завершено ли выполнение пакета.
  • hasFailues: Определите, есть ли в пакете сбои заданий.
  • аннулированный: Определите, отменило ли что-нибудь партию.

Мы можем продублировать большинство этих методов в нашей модели и соответствующим образом настроить код, чтобы получить данные из нашей модели:

class JobBatch extends Model
{
    // ...
    /**
     * Get the total number of jobs that have been processed by the batch thus far.
     *
     * @return int
     */
    public function processedJobs()
    {
        return $this->total_jobs - $this->pending_jobs;
    }
    /**
     * Get the percentage of jobs that have been processed (between 0-100).
     *
     * @return int
     */
    public function progress(): int
    {
        return $this->total_jobs > 0 ? round(($this->processedJobs() / $this->total_jobs) * 100) : 0;
    }
    /**
     * Determine if the batch has pending jobs
     *
     * @return bool
     */
    public function hasPendingJobs(): bool
    {
        return $this->pending_jobs > 0;
    }
    /**
     * Determine if the batch has finished executing.
     *
     * @return bool
     */
    public function finished(): bool
    {
        return !is_null($this->finished_at);
    }
    /**
     * Determine if the batch has job failures.
     *
     * @return bool
     */
    public function hasFailures(): bool
    {
        return $this->failed_jobs > 0;
    }
    /**
     * Determine if all jobs failed.
     *
     * @return bool
     */
    public function failed(): bool
    {
        return $this->failed_jobs === $this->total_jobs;
    }
    /**
     * Determine if the batch has been canceled.
     *
     * @return bool
     */
    public function cancelled(): bool
    {
        return !is_null($this->cancelled_at);
    }
}

Мы почти готовы к нетерпеливой загрузке наших пакетов заданий. Единственное, что остается, это определение отношений. Итак, давайте откроем нашу Transfer модель и добавим связь:

class Transfer extends Model
{
    // ...
    public function jobBatch(): BelongsTo
    {
        return $this->belongsTo(JobBatch::class, 'batch_id');
    }
}

Теперь мы можем обновить наш код внутри нашего ManageTransfers компонента Livewire, чтобы с нетерпением загружать наши пакеты.

class ManageTransfers extends Component
{
    // ...
    public function render()
    {
        return view('livewire.manage-transfers', [
            'transfers' => auth()->user()->transfers()->with('jobBatch')->get(),
        ]);
    }
}

Наконец, давайте обновим наш HTML, чтобы отразить различные состояния задания:

@forelse($transfers as $transfer)
<tr class="bg-white">
    @if(is_null($transfer->jobBatch))
    <td>
        %
    </td>
    <td>
        <div class="flex h-2 overflow-hidden rounded bg-gray-50">
            <div style="transform: scale(0, 1)"
                 class="bg-indigo-500 transition-transform origin-left duration-200 ease-in-out w-full shadow-none flex flex-col"></div>
        </div>
    </td>
    @elseif($transfer->jobBatch->hasPendingJobs())
    <td>
        %
    </td>
    <td>
        <div class="flex h-2 overflow-hidden rounded bg-gray-50">
            <div style="transform: scale({{ $transfer->jobBatch->progress() / 100 }}, 1)"
                 class="bg-indigo-500 transition-transform origin-left duration-200 ease-in-out w-full shadow-none flex flex-col"></div>
        </div>
    </td>
    @elseif($transfer->jobBatch->finished() and $transfer->jobBatch->failed())
    <td>
        X
    </td>
    <td>
        Failed
    </td>
    @elseif($transfer->jobBatch->finished() and $transfer->jobBatch->hasFailures())
    <td>
        !!
    </td>
    <td>
        Finished with errors
    </td>
    @elseif($transfer->jobBatch->finished())
    <td>
        √
    </td>
    <td>
        Uploaded
    </td>
    @endif
    <td>
        {{ $transfer->batch_id }}
    </td>
    <td>
        {{-- combined file size of transfer files --}}
    </td>
</tr>
@empty
<tr>
    <td colspan="4">
        You have no transfers. Create a batch on the right 👉🏻
    </td>
</tr>
@endforelse

Отлично! Вещи начинают обретать очертания. Давайте закончим наше табличное представление, показав общий размер всех файлов. Мы могли бы сделать это, нетерпеливо загружая файлы и суммируя общий размер:

return view('livewire.manage-transfers', [
    'transfers' => auth()->user()->transfers()->with('jobBatch', 'files')->get(),
]);
// $transfer->files->sum('size');

Но это загрузило бы все файловые модели; хотя это работает, мы можем немного оптимизировать это, позволив Eloquent вычислить сумму через SQL.

return view('livewire.manage-transfers', [
    'transfers' => auth()->user()->transfers()->with('jobBatch')->withSum('files', 'size')->get(),
]);
// $transfer->files_sum_size

Довольно удобный маленький помощник для повышения производительности вашего приложения.    Теперь наше представление готово, и у вас должно получиться что-то вроде этого:

Если вы хотите увидеть различные состояния, вы можете подделать это, настроив job_batches записи. Например, присвойте столбцу failed_jobs or pending_jobs значение 1.

Прогресс в реальном времени с помощью Laravel Echo

Момент, которого все ждали, показывая прогресс и результаты передачи нашим пользователям в режиме реального времени. Мы собираемся сделать это с помощью Laravel Echo и Livewire. Итак, прежде чем двигаться дальше, давайте установим библиотеку Laravel Echo Javascript.

npm install --save-dev laravel-echo pusher-js

Далее вы можете раскомментировать следующий код в своем bootstrap.js файле:

import Echo from 'laravel-echo';
window.Pusher = require('pusher-js');
window.Echo = new Echo({
    broadcaster: 'pusher',
    key: process.env.MIX_PUSHER_APP_KEY,
    cluster: process.env.MIX_PUSHER_APP_CLUSTER,
    forceTLS: true
});

В этом примере я буду использовать Pusher в качестве нашего вещателя. Вы можете зарегистрировать бесплатную учетную запись и получить учетные данные своей учетной записи. Вам нужно добавить их в свой .env файл.

BROADCAST_DRIVER=pusher
PUSHER_APP_ID=10000
PUSHER_APP_KEY=h28f720sd6v5a02
PUSHER_APP_SECRET=jh8s02017cx0add
PUSHER_APP_CLUSTER=eu

Запустите npm run dev после установки пакета npm и определения переменных среды. Далее нам также нужно потребовать конфигурацию Pusher SDK:

composer require pusher/pusher-php-server "~4.0"

Channel
У нас есть три события, которые транслируются:

  1. LocalTransferCreated
  2. LocalTransferCreated
  3. TransferCompleted

Эти события транслируются на частный канал. Давайте вернем имя канала, которое включает идентификатор пользователя (владельца) данной модели передачи.

В большинстве случаев вы будете использовать частные каналы, но если вам не нужна проверка подлинности, вы можете вместо этого вернуть Illuminate\Broadcasting\Channel объект.

class FileTransferredToCloud implements ShouldBroadcastNow
{
    // ...
    public function broadcastOn()
    {
        return new PrivateChannel("notifications.{$this->file->transfer->user_id}");
    }
}

class LocalTransferCreated implements ShouldBroadcast
{
    // ...
    public function broadcastOn()
    {
        return new PrivateChannel("notifications.{$this->transfer->user_id}");
    }
}
class TransferCompleted implements ShouldBroadcast
{
    // ...
    public function broadcastOn()
    {
        return new PrivateChannel("notifications.{$this->transfer->user_id}");
    }
}

Авторизация канала
Laravel Echo запросит определенную конечную точку, чтобы проверить, есть ли у вас доступ к частному каналу. Эта конечная точка не существует по умолчанию, так как по умолчанию она BroadcastServiceProvider отключена. Откройте routes/channels.php свой app.php и включите App\Providers\BroadcastServiceProvider::class

Далее мы можем определить авторизацию в файле.

Broadcast::channel('notifications.{channelUser}', function ($authUser, \App\Models\User $channelUser) {
    return $authUser->id === $channelUser->id;
});

Закрытие имеет два значения; Первое значение — это текущий вошедший в систему пользователь, второе — широковещательный идентификатор пользователя, который автоматически разрешается, когда вы вводите подсказку о закрытии, как при использовании маршрутов.

Интеграция Laravel Livewire с Laravel Echo
Теперь мы можем настроить наш компонент Livewire и настроить его на обновление при получении нового события от Pusher. Livewire имеет встроенную поддержку Laravel Echo, поэтому для этого требуется всего пара строк кода. ManageTransfers Откройте компонент и создайте новый getListeners() метод.

public function getListeners()
{
    return [];
}

Этот метод должен возвращать массив "ключ-значение", в котором ключ является событием, которое нужно прослушивать, а значение - методом, который запускается при возникновении события.

В нашем случае мы хотим обновить весь компонент. Самое замечательное в Livewire то, что он знает, какие элементы изменились, и обновляет DOM только для этих элементов. Это означает, что CSS-анимация будет работать, и мы увидим, как наш индикатор выполнения обновится с классной анимацией.

public function getListeners()
{
    $userId = auth()->id();
    return [
        "echo-private:notifications.{$userId},FileTransferredToCloud" => '$refresh',
    ];
}

Если вы используете Laravel Echo, вам нужно добавить префикс к echo-private уведомлению, чтобы Livewire знал, что он должен работать вместе с Laravel Echo. Затем мы указываем имя нашего частного каналаnotifications.{$userId}, за которым следует событие, которое мы хотим прослушать, в данном случае . У нас нет какого-либо конкретного метода, который мы хотим запустить; Мы просто хотим обновить компонент, FileTransferredToCloudпередав его $refresh.  

Ну вот! Иди и попробуй и посмотри, как происходит волшебство.

Конфетти-бонус

Какой прогресс в реальном времени без конфетти? Точно, ровным счетом ничего! Давайте украсим его конфетти, когда он закончит с передачей.

Давайте установим нашу пушку конфетти:Откройте resources/app.js и используйте Livewire.on() функцию прослушивания события конфетти:

npm install --save canvas-confetti

import confetti from "canvas-confetti";
Livewire.on('confetti', () => {
    confetti({
        particleCount: 80,
        spread: 200,
        origin: {y: 0.6}
    });
})

Затем вернитесь к компоненту Livewire и зарегистрируйте нового слушателя.

public function getListeners()
{
    $userId = auth()->id();
    return [
        "echo-private:notifications.{$userId},FileTransferredToCloud" => '$refresh',
        "echo-private:notifications.{$userId},TransferCompleted" => 'fireConfettiCannon',
    ];
}

Затем создайте новый метод и fireConfettiCannon используйте вспомогательный emit confetti метод для выдачи события:

public function fireConfettiCannon()
{
    $this->emit('confetti');
}

Это обертка! 🎉 Я хотел бы услышать, как вы использовали эту технику, чтобы показать прогресс в реальном времени (и, возможно, немного конфетти) своим клиентам. Напишите мне в Твиттере @Philo01 и поговорите! 🙌🏼


Wire Elements Pro - Искусно созданные компоненты Livewire Красивые компоненты 🚀

, созданные с помощью Livewire. Скоро запуск, обязательно подпишитесь.


Premium Series: Build a real-world application with Laravel, Livewire & Tailwind

Я подумываю о выпуске видеокурса премиум-класса в ближайшем будущем (если достаточное количество людей будет заинтересовано), где я покажу вам, как создать реальное приложение от начала до конца, используя Laravel, Livewire и Tailwind CSS. Я расскажу обо всем, начиная от регистрации домена, настройки сервера, написания тестов и так далее. Подпишитесь, если вы заинтересованы и хотите получать уведомления, и вы также можете получить доступ бесплатно, так как я буду раздавать подарки, когда курс запустится.

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Об авторе CrazyBoy49z
WORK EXPERIENCE
Контакты
Ukraine, Lutsk
+380979856297