• Czas czytania ~23 min
  • 05.07.2022

Pracuję nad fajnym projektem i nad funkcją, która przetwarza przychodzące pliki i przesyła je do pamięci masowej w chmurze. Pomyślałem, że partie Laravel będą do tego idealne, co było! Postanowiłem połączyć moc partii Laravel z wydarzeniami, Laravel Echo i Livewire, aby pokazać postępy w czasie rzeczywistym moim użytkownikom i nie zapomnieć o konfetti do świętowania 🎉 .

W tym artykule pokażę ci krok po kroku, jak to zrobić, abyś mógł zdmuchnąć swoich klientów (konfetti).

Czym jest Laravel Livewire?

Livewire to pełny framework dla Laravel autorstwa Caleba Porzio, który sprawia, że budowanie dynamicznych  interfejsów jest bardzo łatwe bez pisania ani jednej linii Javascript, co jest całkiem niesamowite, ponieważ możesz stworzyć uczucie SPA, ponownie bez konieczności pisania Javascript. Jak wspomniano na stronie Livewire, najlepszym sposobem na zrozumienie tego jest spojrzenie na kod, więc zacznijmy!

Instalowanie Laravel, Livewire i Tailwind CSS

Zamierzam użyć czystej instalacji Laravel 8, ale możesz oczywiście śledzić również jeden z istniejących projektów.

Pobierzmy i zainstalujmy Laravel, Livewire i Tailwind CSS dla tych, którzy chcą zacząć od zera.

Skupię się na partiach Laravel i postępach w czasie rzeczywistym z Livewire. Jeśli jesteś zupełnie nowy w Laravel, Livewire lub Tailwind CSS, wszystkie te produkty mają obszerną i dobrze napisaną dokumentację, która pomoże Ci zacząć.

# 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

Następnie musimy wprowadzić jeszcze kilka poprawek, aby Tailwind CSS działał. Zaktualizujmy nasz webpack.mix.js pierwszy.

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

Otwórz ./resources/css/app.css i dodaj dyrektywy CSS Tailwind:

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

Następnie chcemy się upewnić, że dołączamy nasz CSS do naszego pliku blade i uwzględniamy również dyrektywy Livewire. Więc otwórz ./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>

To powinno załatwić sprawę. Uruchomnpm run dev, aby upewnić się, że mamy skompilowany plik CSS.

Tailwind UI Jestem wielkim fanem Tailwind UI

I'm a big fan of Tailwind UI Jestem wielkim fanem Tailwind UI 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 Tailwind UI Jestem wielkim fanem Tailwind UI components while we progress. The HTML markup in this article, however, will not include any Tailwind UI Jestem wielkim fanem Tailwind UI components. I've made the following sample application in a couple of minutes:

wysokiego poziomu Chcemy osiągnąć następujące cele:Utwórz komponent Livewire Zacznijmy

od utworzenia naszego komponentu 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

Przenieśmy nasz kod HTML do manage-transfers.blade.php pliku.

<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>

Dodaj również dyrektywę @livewire, aby welcome.blade.php komponent się pojawił.

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

Przesyłanie plików za pomocą Livewire Livewire

sprawiło, że przesyłanie plików było dziecinnie proste! To dość szalone, jak łatwo jest to uruchomić.

Livewire file upload flow

Livewire wyśle żądanie POST do /livewire/upload-file punktu końcowego i zwróci tymczasowe nazwy plików za kulisami. Klient przeglądarki Livewire wyśle żądanie POST do komponentu Livewire, który zwróci kod HTML i zaktualizuje model DOM, aby wyświetlić podglądy obrazów.

Zaczniemy od zdefiniowania modelu dla naszego elementu przesyłania plików. W tym przypadku przejdźmy do pendingFiles tego, ponieważ ten model będzie zawierał wszystkie pliki wybrane przez użytkownika, ale nie są jeszcze przetwarzane.

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

Następnie przejdź do powiązanej klasy Livewire dla tego komponentu (app / HTTP / Livewire / ManageTransfers.php) i połącz to (gra słów przeznaczona).

<?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');
    }
}

Aby pracować z przesyłaniem plików, musisz uwzględnić cechę "Livewire\WithFileUploads". Ta cecha będzie składać się z niezbędnych metod przetwarzania plików przesłanych za pośrednictwem tego komponentu.

Wskazówka: Uważaj podczas wpisywania właściwości publicznych w komponentach Livewire. Livewire w niektórych przypadkach zgłosi wyjątek, jeśli właściwość nie jest ciągiem, tablicą, drażliwą wartością, ponieważ nie wie, co robić. Na przykład:

// 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

Powrót do Livewire Magic. Dodaliśmy kilka linijek kodu, a nasze przesyłanie już działa. Aby to sprawdzić, szybko dodajmy tymczasowe obrazy podglądu:

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

Teraz spróbuj, wybierz kilka obrazów i obejrzyj magię. Szalone, prawda? Jak to działa? Cóż, Livewire przetwarza wszystkie wybrane pliki i umieszcza te pliki w prywatnym katalogu tymczasowym.

Livewire śledzi wszystkie przesłane przez nas pliki. Właściwość $pendingFiles zwróci tablicę Livewire\TemporaryUploadedFile obiektów. Metoda temporaryUrl zwróci podpisaną trasę, aby udostępnić przesłany plik. Ze względów bezpieczeństwa ten tymczasowy adres URL będzie działał tylko w przypadku zatwierdzonych rozszerzeń plików. Więc jeśli prześlesz plik zip, to nie zadziała.

Jeśli chcesz zmienić to domyślne zachowanie, możesz dostosować plik konfiguracyjnylivewire.php. Musisz uruchomić następujące polecenie, aby opublikować plik konfiguracyjny:Jeśli otworzysz ten plik, możesz przewinąć w dół do sekcji "Livewire Temporary File Uploads Endpoint Configuration" i dostosować konfigurację do własnych upodobań:

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, 
],

Proszę wziąć pod uwagę, że chociaż Livewire jest dostarczany z pewnymi rozsądnymi ustawieniami domyślnymi, takimi jak limit wysyłania 12 MB i przepustnica, możliwe jest, że ktoś zapełni miejsce na dysku, jeśli ta osoba chce. Więc dodatkowe zabezpieczenia nie zaszkodzią. Można na przykład ograniczyć liczbę plików tymczasowych na użytkownika.

Sprawdzanie poprawności plików za pomocą Livewire Domyślnie Livewire

implementuje required, filei max:12288 reguły dla wszelkich tymczasowych przesyłanych plików. Aby dodać naszą walidację, możemy podać nasze reguły do validate metody.

Stwórzmy publiczną metodę o nazwie initiateTransfer() i niektóre z naszych własnych reguł sprawdzania poprawności (plik musi być obrazem i maksymalnym rozmiarem 5 MB), podłącz to do przycisku, aby użytkownik mógł przesłać wybrane pliki do przesłania.

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

Jeśli na przykład prześlesz zbyt duży obraz, nie zobaczysz jeszcze błędów. Dodajmy więc to do naszego widoku:

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

Jeśli na przykład prześlę plik o rozmiarze 70 MB, natychmiast się nie powiedzie, ponieważ nie przejdzie początkowej reguły walidacji (maks. 12 MB) zdefiniowanej w konfiguracji livewire.php.

Validation error

Jeśli prześlemy obraz o rozmiarze 6,7 MB, zobaczysz tymczasowy podgląd, ale bez błędu. Błąd pojawi się dopiero po wykonaniu walidacji, więc jeśli klikniesz przycisk "Zrób trochę magii", powinieneś zobaczyć błąd informujący, że plik nie może przekroczyć 5 MB.

Wymowne modele i migracje

baz danych Aby śledzić status naszej partii, musimy dołączyć naszą partię do czegoś. Na przykład można dołączyć te pliki do dokumentu, projektu; Nazwij to. Możesz również pokazać całą zawartość tabeli "job_batches", ale nie sądzę, że jest to prawdopodobny przypadek użycia.

Tabela partii zadań
Zanim będziemy mogli wysyłać partie, musimy uruchomić polecenie rzemieślnicze, aby wygenerować tabelę bazy danych, której Laravel użyje do utrwalenia danych związanych z naszą partią. Uruchom więc następujące polecenie:Tabela job_batches zawiera następujące informacje:

php artisan queue:batches-table
Migration created successfully!

  • Nazwa Twojej pracy.
  • Łączna liczba zadań podanej partii ma.
  • Łączna liczba oczekujących zadań oczekujących na przetworzenie przez pracownika kolejki.
  • Łączna liczba zadań, które nie zostały przetworzone przez pracownika kolejki.
  • Identyfikatory nieudanych zadań, jest to odwołanie do failed_jobs tabeli.
  • Wszystkie opcje zdefiniowane na przykład then, catch lub finally.
  • Sygnatura czasowa anulowania partii.
  • Sygnatura czasowa utworzenia partii.
  • Sygnatura czasowa, gdy pracownik kolejki zakończy przetwarzanie wszystkich zadań dla danej partii.

Niektóre z tych danych wykorzystamy w przyszłym kroku, aby na przykład pokazać postęp konkretnej partii.

Model użytkownika
Chcę zademonstrować, jak nadawać prywatnie, aby tylko zalogowany użytkownik widział zadania transferu i postęp w czasie rzeczywistym. Laravel jest już dostarczany z modelem użytkownika, więc jedyne, co musisz zrobić, to otworzyć DatabaseSeeder i odkomentować fabrykę użytkownika.

Rusztowanie, interfejs użytkownika do uwierzytelniania itp. jest poza zakresem tego artykułu. Mimo to chciałbym podzielić się alternatywą podczas pracy lokalnej i szybko zalogować się na dowolne konto bez podawania żadnych danych uwierzytelniających.

Otwórz i routes/web.php dodaj następujące elementy:

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

Będzie działać tylko lokalnie ze względu na abort_unless funkcję, która wygeneruje błąd 403, jeśli środowisko nie jest równe local. Używam pomocnika auth() do logowania się do pierwszego użytkownika w bazie danych.

Przesyłanie i model plików
Użyjemy modelu Eloquent do śledzenia wszystkich plików. Jak wspomniano wcześniej, możesz zobaczyć to jako projekt (lub e-mail lub tweet z jednym lub więcej załącznikami) i chcesz przesłać i powiązane określone pliki do tego projektu.

Stwórzmy więc model i migrację zarówno dla transferu, jak i pliku.

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

Dla naszej transfers tabeli musimy tylko dodać jedno dodatkowe pole, którym jest batch_id:Tabela transfer_files będzie zawierać ścieżkę pliku i rozmiar pliku:

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();
});

Skoro tu jesteśmy, skonfigurujmy relację między nimi i zdefiniujmy pola do wypełnienia.

<?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);
    }
}

Utrwalanie danych w bazie danych

Uruchomphp artisan migrate, aby wykonać wszystkie migracje, jeśli jeszcze tego nie zrobiono. Możemy teraz przechowywać oczekujące pliki, które przeszły weryfikację.

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(),
                ]);
            })
    );
}

Po wybraniu czterech obrazów i kliknięciu magicznego przycisku zobaczysz jeden nowy wpis bazy danych w tabeli i cztery wpisy w transfers transfer_files tabeli.

Teraz zresetujmy $pendingFiles właściwość do pustej tablicy, aby zresetować formularz i na koniec zdarzenie (utworzymy tę klasę w następnym kroku).

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

Zdarzenie Dispatch LocalTransferCreated

Teraz mamy już zapisane wszystkie nasze dane, możemy wysłać zdarzenie, którego będziemy mogli nasłuchiwać w przyszłych krokach. Utwórzmy zdarzenie, do którego odwołujemy się w initiateTransfer powyższej metodzie:

php artisan make:event LocalTransferCreated
Event created successfully.

Ostatecznie chcemy aktualizować naszą tabelę za każdym razem, LocalTransferCreated gdy zdarzenie jest wywoływane. Aby to zrobić, użyjemy Laravel Echo. Porozmawiamy o tym w przyszłości, ale skoro tu jesteśmy, upewnijmy się, że nasze wydarzenie implementuje ShouldBroadcast interfejs, aby Laravel wiedział, że chcemy transmitować wydarzenie.

<?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');
    }
}

Utwórz i przypisz detektor Stwórzmy nowego detektora

, który będzie nasłuchiwał LocalTransferCreated zdarzenia i wysyłał naszą partię.

php artisan make:listener CreateTransferBatch
Listener created successfully.

Następnie dodaj mapowanie do naszegoEventServiceProvider, aby poinstruować Laravel, aby zadzwonił do nasCreateTransferBatch, gdy LocalTransferCreated zdarzenie zostanie wysłane:

<?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
        ]
    ];
}

Utwórz nasze zadanie transferu Zanim będziemy mogli utworzyć naszą partię, musimy mieć zadanie

do przekazania do naszej partii. Stwórzmy jeden:

php artisan make:job TransferLocalFileToCloud
Job created successfully.

Teraz jest to stosunkowo prosta praca. Pobieramy plik lokalny, przesyłamy go do naszego magazynu w chmurze, aktualizujemy rekord bazy danych, zmieniając dysk na "s3", a ścieżkę do wygenerowanej ścieżki zawierającej unikalną nazwę pliku zwróconą przez metodę "put". Na koniec sprzątamy, usuwając plik z naszej lokalnej pamięci masowej.

Aby Twoje zadanie było możliwe do wykonania partii, musisz dodać cechę "Batchable"; W przeciwnym razie otrzymasz następujący wyjątek:

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

W zależności od dostawcy pamięci masowej w chmurze może być konieczne wymaganie adaptera, np. Ponieważ chcemy aktualizować interfejs w czasie rzeczywistym, musimy wysyłać zdarzenie za każdym razem, gdy plik jest przesyłany.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
    }
}

Utwórzmy nowe zdarzenie:Weźmy model transferu jako parametr zdarzenia i upewnijmy się,

php artisan make:event FileTransferredToCloud 
Event created successfully.

że zdarzenie implementuje interfejs ShouldBroadcastNow. Chcemy natychmiast rozgłaszać to zdarzenie, w przeciwnym razie skończy się ono na dole zaległości kolejki, powodując wyzwolenie tego zdarzenia, gdy cała partia jest już zakończona. Biorąc pod uwagę, że chcemy pokazać postępy w czasie rzeczywistym, potrzebujemy tej transmisji, aby odbywała się w czasie rzeczywistym.

<?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');
    }
}

Teraz wyślij zdarzenie z naszego zadania:

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

Utwórz partię zadań Czas utworzyć naszą partię

zadań wewnątrz naszego TransferLocalFileToCloud CreateTransferBatch detektora. Możemy użyć mapInto metody zbierania, aby szybko wygenerować zadanie dla każdego pliku.

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

Teraz możemy użyć fasadyBatch, aby wysłać wszystkie te zadania za jednym razem.

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

Zapraszam do wypróbowania! Partia powinna przenieść pliki do magazynu w chmurze. Możesz to sprawdzić, patrząc na swoją job_batches tabelę. Tutaj powinieneś zobaczyć nowy wpis i liczbę przetworzonych zadań.

Zapisywanie i konfigurowanie naszej partii Cały sens naszej partii

polega na tym, że chcemy pokazać postęp naszym użytkownikom. Zacznijmy więc od dołączenia identyfikatora partii do naszego modelu transferu.

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

Następnie chcemy uruchomić zdarzenie, gdy wszystkie nasze zadania zostaną przetworzone. Fasada Batch zapewnia kilka metod, których można użyć, aby to osiągnąć.

$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();

W naszym przypadku użyjemy tylko tej finally metody do odpalenia TransferCompleted zdarzenia. Najpierw stwórzmy to wydarzenie.

php artisan make:event TransferCompleted
Event created successfully.

To zdarzenie zaakceptuje Transfer model jako parametr i zaimplementuje interfejs, ShouldBroadcast tak jak zrobiliśmy to wcześniej.

<?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');
    }
}

Następnie dodamy go do zamknięciafinally.

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
        ]);
    }
}

Przypisałem również zmienną do modelu transferu, aby przekazać model zamiast całego zdarzenia. Laravel serializuje zamknięcie i zapisze je w options kolumnie job_batches tabeli.

Jeśli chcesz, możesz dać całemu przepływowi kolejną szansę i użyć php artisan queue work do wyświetlenia przetworzonych zadań.

Całkiem fajne, prawda?

Lista naszych transferów Możemy teraz uczynić naszą tabelę statyczną dynamiczną, wymieniając wszystkie transfery

. Wróć więc do swojej klasy komponentów Livewire i przekaż kolekcję Eloquent zawierającą nasze modele Transfer do naszego widoku.

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

Otwórzmy nasze manage-transfers.blade.php i dodajmy do @forelse naszej tabeli.

<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>

Musimy pokazać nasz status zadania i obliczyć łączny rozmiar wszystkich plików przesyłania. Teraz Laravel jest dostarczany z metodą wyszukiwania partii według ich identyfikatora: Bus::findBatch($batchId);. Ta metoda użyje "DatabaseBatchRepository" za kulisami, aby pobrać dane z bazy danych i przekształcić dane w \Illuminate\Bus\Batch obiekt.

Metoda findBatch działa idealnie, ale chciałbym użyć Eloquent zamiast tego do chętnego ładowania wszystkich partii, wykonywania zakresów zapytań itp. Stwórzmy więc gotowy model tylko dla naszych partii.

Laravel jest odpowiedzialny za utrzymanie integralności danych naszej job_batches tabeli i chcę, aby tak pozostało, dlatego lubię, aby model był tylko do odczytu.

Packagist na ratunek. Jeśli wykonasz szybkie wyszukiwanie tylko do odczytu, zobaczysz następujący pakiet. Ten pakiet wprowadzi cechę, dzięki której modele będą tylko do odczytu. Więc zainstalujmy go:Next, chcemy również utworzyć nasz model:

composer require michaelachrisco/readonly

php artisan make:model JobBatch

Zacznijmy od dodania cechy tylko do odczytu do naszego modelu, więc nasz model zgłosi wyjątek, jeśli wywołasz metodę taką jak create, save, delete itp.

<?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;
}

Jeśli spojrzymy na job_batches tabelę, zobaczysz, że każda partia ma UUID zamiast przyrostowej liczby całkowitej, a także nie ma znacznika updated_at czasu.

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;
}

Aby to nieco ułatwić, możemy rzutować właściwości modelu, dzięki czemu otrzymujemy obiekt, gdy robimy coś takiego $jobBatch->finished_at lub kolekcję, gdy robimy

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',
    ];
}

$jobBatch->options :Następnie chcemy odtworzyć kilka metod, które zwykle otrzymujemy Carbon z  Illuminate\Bus\Batch obiektu:

  • processedJobs: Pobierz łączną liczbę zadań, które zostały przetworzone przez partię do tej pory.
  • postęp: Pobierz procent zadań, które zostały przetworzone (z zakresu od 0 do 100).
  • gotowy: Sprawdź, czy partia zakończyła wykonywanie.
  • hasFailues: Określ, czy partia ma błędy zadań.
  • Anulowane: Sprawdź, czy coś anulowało partię.

Możemy powielić większość z tych metod w naszym modelu i odpowiednio dostosować kod, aby uzyskać dane z naszego modelu:

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);
    }
}

Jesteśmy prawie gotowi do chętnego ładowania naszych partii zadań. Jedyne, co pozostaje, to zdefiniowanie związku. Otwórzmy więc nasz Transfer model i dodajmy relację:

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

Teraz możemy zaktualizować nasz kod wewnątrz naszego ManageTransfers komponentu Livewire, aby chętnie ładować nasze partie.

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

Na koniec zaktualizujmy nasz kod HTML, aby odzwierciedlał różne stany zadań:

@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

Świetnie! Sprawy zaczynają nabierać kształtów. Zakończmy nasz widok tabeli, pokazując łączny rozmiar wszystkich plików. Możemy to zrobić, chętnie ładując pliki i sumując całkowity rozmiar:

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

Ale to załadowałoby wszystkie modele plików; chociaż działa, możemy to nieco zoptymalizować, pozwalając Eloquent obliczyć sumę za pomocą SQL.

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

Całkiem przydatny mały pomocnik, aby poprawić wydajność aplikacji.    Teraz nasz widok jest gotowy i powinieneś mieć coś takiego:

Jeśli chcesz zobaczyć różne stany, możesz to sfałszować, dostosowując wpisy job_batches . Na przykład ustaw kolumnę failed_jobs lub pending_jobs na 1.

Postęp w czasie rzeczywistym za pomocą Laravel Echo

Moment, na który wszyscy czekali, pokazujący postęp transferu i wyniki naszym użytkownikom w czasie rzeczywistym. Zamierzamy to zrobić, używając Laravel Echo i Livewire. Zanim przejdziemy dalej, zainstalujmy bibliotekę Laravel Echo Javascript.

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

Następnie możesz odkomentować następujący kod w pliku 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
});

W tym przykładzie użyję Pushera jako naszego nadawcy. Możesz założyć bezpłatne konto i uzyskać dane uwierzytelniające konta. Musisz dodać je do pliku .env .

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

Uruchom npm run dev po zainstalowaniu pakietu npm i zdefiniowaniu zmiennych środowiskowych. Następnie musimy również wymagać konfiguracji Pusher SDK:

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

Channel
Mamy trzy wydarzenia, które są transmitowane:

  1. LocalTransferCreated
  2. LocalTransferCreated
  3. TransferCompleted

Te wydarzenia są transmitowane na kanał prywatny. Zwróćmy nazwę kanału, która zawiera identyfikator użytkownika (właściciela) danego modelu transferu.

W większości przypadków należy użyć kanałów prywatnych, ale jeśli nie jest potrzebne żadne uwierzytelnianie, można zamiast tego zwrócić Illuminate\Broadcasting\Channel obiekt.

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}");
    }
}

Autoryzacja kanału
Laravel Echo poprosi o konkretny punkt końcowy, aby sprawdzić, czy masz dostęp do kanału prywatnego. Ten punkt końcowy nie istnieje domyślnie, ponieważ BroadcastServiceProvider jest domyślnie wyłączony. Otwórz swoje app.php i włącz App\Providers\BroadcastServiceProvider::class

Następnie możemy zdefiniować autoryzację w routes/channels.php pliku.

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

Zamknięcie ma dwie wartości; Pierwsza wartość to bieżący zalogowany użytkownik, druga wartość to broadcast user ID, która jest automatycznie rozpoznawana po wpisaniu podpowiedzi zamknięcia, tak jak w przypadku korzystania z tras.

Integracja Laravel Livewire z Laravel Echo
Możemy teraz skonfigurować nasz komponent Livewire i poinstruować go, aby odświeżył się, gdy otrzymamy nowe zdarzenie z Pushera. Livewire ma natywne wsparcie dla Laravel Echo, więc wymaga to tylko kilku linii kodu. ManageTransfers Otwórz komponent i utwórz nową getListeners() metodę.

public function getListeners()
{
    return [];
}

Ta metoda powinna zwracać tablicę klucz-wartość, gdzie klucz jest zdarzeniem do nasłuchiwania, a wartość jest metodą wyzwalaną po wystąpieniu zdarzenia.

W naszym przypadku chcemy odświeżyć cały komponent. Fajną rzeczą w Livewire jest to, że wie, które elementy się zmieniły i aktualizuje DOM tylko dla tych elementów. Oznacza to, że animacje CSS będą działać, a nasz pasek postępu zostanie zaktualizowany z fajną animacją.

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

Jeśli korzystasz z Laravel Echo, musisz poprzedzić powiadomienie, aby Livewire wiedział, echo-private że musi współpracować z Laravel Echo. Następnie podajemy mu naszą prywatną nazwę notifications.{$userId} kanału, a następnie wydarzenie, którego chcemy słuchać, w tym przypadku . Nie mamy żadnej konkretnej metody, FileTransferredToCloudktórą chcemy uruchomić; chcemy tylko odświeżyć komponent, przekazując go $refresh.  

To wszystko! Idź i spróbuj i zobacz, jak dzieje się magia.

Bonus

za konfetti Czym jest postęp w czasie rzeczywistym bez konfetti? No właśnie, absolutnie nic! Dajmy mu podmuch konfetti, gdy skończy się z transferem.

Załóżmy nasze działo konfetti:Otwórz resources/app.js i użyj tej Livewire.on() funkcji, aby nasłuchiwać zdarzenia konfetti:

npm install --save canvas-confetti

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

Następnie wróć do komponentu Livewire i zarejestruj nowego słuchacza.

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

Następnie utwórz nową metodę o nazwie fireConfettiCannon i użyj emit pomocnika, aby wyemitować confetti zdarzenie:

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

To jest okład! 🎉 Chciałbym usłyszeć, w jaki sposób użyłeś tej techniki, aby pokazać swoim klientom postęp w czasie rzeczywistym (i może trochę konfetti). Napisz do mnie tweeta @Philo01 i porozmawiaj! 🙌🏼


Wire Elements Pro - Profesjonalnie wykonane komponenty Livewire Piękne komponenty 🚀

wykonane z Livewire. Premiera wkrótce, pamiętaj, aby subskrybować.


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

Myślę o wydaniu kursu wideo premium w najbliższej przyszłości (jeśli wystarczająco dużo osób będzie zainteresowanych), w którym pokażę, jak zbudować rzeczywistą aplikację od początku do końca przy użyciu Laravel, Livewire i Tailwind CSS. Będę omawiał wszystko, od rejestracji domeny, konfiguracji serwera, pisania testów, co tylko chcesz. Zapisz się, jeśli jesteś zainteresowany i chcesz otrzymywać powiadomienia, a możesz również uzyskać dostęp za darmo, ponieważ będę rozdawał, gdy kurs się rozpocznie.

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

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...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297