Problem

Klient zażyczył sobie formularza ładownia zdjęć z możliwie najprostszą obsługą, ale jednocześnie z pewnymi wymaganiami. Zdjęcia mają być ładowane pojedynczo z możliwością podstawowego kadrowania do zadanego z góry i niezmiennego formatu. Zdjęcia dostarczone na serwer mają być wyświetlane w postaci zwykłej galerii miniatur, po kliknięciu, na wyświetlonym obrazku można wykonać dwie podstawowe czynności: edycję i usunięcie. Docelowo ma to być mikroserwis będący elementem większego projektu. Jak widać wymagania nie są wyśrubowane, tym bardziej że celem nie jest prezentacja zgromadzonych grafik, a przede wszystkim ich przechowywanie i udostępnianie dla innych usług.

Lista zadań wygląda tak:

  1. kadrowanie do rozmiaru 300x300 wg parametrów z background-size (auto, contain, cover, scale),
  2. maksymalny rozmiar ładowanego zdjęcia 2MB,
  3. przechowywany ma być plik oryginału i plik po konwersji,
  4. każda dodana grafika musi być opisana unikalnym tytułem,
  5. z każdą grafiką może być przechowywania kolekcja parametrów (do wykorzystania w przyszłości).

Proste, prawda? No więc do dzieła…

Zaczynamy na nowym projekcie Laravela:

laravel new simple-photo

Konfigurujemy bazę danych, podstawowe parametry i zawczasu definiujemy nowy udział w systemie plików, którego będziemy używali do przechowywania naszych obrazków. W pliku config/filesystem.php w sekcji disks dodajemy:

'media' => [
    'driver' => 'local',
    'root' => storage_path('app/public/media'),
    'url' => env('APP_URL') . '/media',
    'visibility' => 'public',
],

Na początek to wszystko.

Model, migracja, kontroler i walidacja danych

Projekt oparty jest na klasycznym wzorcu Laravela, z jedną klasą fasadową, a do obsługi danych z formularzy stosuję klasę HTTP Request. Wszystko po to, żeby zachować przejrzystość kodu i pozostawić pole manewru dla przyszłych modyfikacji.

Migracja i model

Korzystając z konsoli Artisan tworzymy model z plikiem migracji i przypisany do niego kontroler:

php artisan make:model Models/SimplePhoto -a

Polecenie utworzy pliki modelu, kontrolera i migracji. Najpierw zdefiniujemy ten ostatni, który znajdziemy w katalogu database/migrations:

<?php

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

class CreateSimplePhotosTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('simple_photos', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name', 512)->unique();
            $table->string('filename', 512)->nullable();
            $table->json('properties')->nullable();
            $table->boolean('status')->default(true);
            $table->softDeletes();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('simple_photos');
    }
}

Od razu możemy wstępnie zmodyfikować plik modelu:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class SimplePhoto extends Model
{
    use SoftDeletes;

    protected $casts = [
        'properties' => 'object'
    ];

    protected $fillable = [
        'name',
        'filename',
        'properties',
        'status',
    ];

$casts zapewni odczyt pola typu JSNON, jako obiektu, a $fillable wskazuje pola, które będziemy mogli obsługiwać wsadowo. Później dołożymy tu jeszcze inne funkcje.

Walidacja danych formularza

Na tym etapie zakładamy, że z formularza na serwer będzie przesyłany prosty zestaw danych: nazwa (tytuł), typ konwersji i plik grafiki. Do ich weryfikacji użyjemy klasy HTTP Request, w której będziemy weryfikowali poprawność dostarczonych danych. Plik klasy generujemy tradycyjnie przez konsolę Artisan:

php artisan make:request SimplePhotoRequest

Następnie edytujemy plik utworzony w katalogu app/Http/Requests:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class SimplePhotoRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        switch ($this->method()) {
            case 'GET':
            case 'DELETE': {
                    return [];
                }
            // update
            case 'PUT':
            case 'PATCH': {
                    return [
                        'image' => 'mimes:png,jpg,gif,jpeg|max:2048',
                        'name' => 'required|max:512|unique:simple_photos,name,' . request()->route()->simplePhoto->id,
                    ];
                }
            // create
            case 'POST': {
                    return [
                        'image' => 'required|mimes:png,jpg,gif,jpeg|max:2048',
                        'name' => 'required|max:512|unique:simple_photos,name',
                    ];
                }
        }
    }

    /**
     * Custom message for validation
     *
     * @return array
     */
    public function messages()
    {
        return [
            'image.required' => 'Musisz wskazać plik z dysku',
            'image.mimes' => 'Tylko pliki graficzne PNG, JPG lub GIF',
            'image.max' => 'Za duży plik - maks. 2MB',
            'name.required' => 'Musisz podać tytuł',
            'name.max' => 'Dopuszcalne 512 znaków',
            'name.unique' => 'Plik o takim tytule już istnieje',
        ];
    }
}

W metodzie rules() definiujemy zasady walidacji danych, przy czym dzielimy je w zależności od zastosowanego typu formularza. Dla typu POST, czyli przy tworzeniu nowego rekordu, weryfikujemy obecność wszystkich danych oraz ich oczekiwane właściwości. Dla typu PUT lub PATCH, czyli przy aktualizacji istniejącego rekordu, z weryfikacji unikalności nazwy wykluczamy wartość z aktualizowanego rekordu i usuwamy sprawdzanie obecności pliku - brak pliku w przesyłanym żądaniu będzie oznaczał, że nie występuje modyfikacja tego pola i zachowamy już istniejący.

W metodzie messages() umieszczamy treści komunikatów, które będą używane w przypadku wystąpienia błędów.

Kontroler

Pozostał kontroler - app/Http/Controlers/SimplePhotoController.php

<?php

namespace App\Http\Controllers;

use App\Models\SimplePhoto;
use App\Http\Requests\SimplePhotoRequest;

class SimplePhotoController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $photos = SimplePhoto::all();
        return \view('index', ['photos' => $photos]);
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function create()
    {
        return \view('create');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \App\Http\Requests\SimplePhotoRequest  $request
     * 
     * @return \Illuminate\Http\Response
     */
    public function store(SimplePhotoRequest $request)
    {
        $newPhotoId = \SPT::storePhoto($request);

        return \redirect(route('photo.show', $newPhotoId));
    }

    /**
     * Display the specified resource.
     *
     * @param  \App\Models\SimplePhoto  $simplePhoto
     * 
     * @return \Illuminate\Http\Response
     */
    public function show(SimplePhoto $simplePhoto)
    {
        return \view('show', ['photo' => $simplePhoto]);
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  \App\Models\SimplePhoto  $simplePhoto
     * 
     * @return \Illuminate\Http\Response
     */
    public function edit(SimplePhoto $simplePhoto)
    {
        return \view('edit', ['photo' => $simplePhoto]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \App\Http\Requests\SimplePhotoRequest  $request
     * @param  \App\Models\SimplePhoto  $simplePhoto
     * 
     * @return \Illuminate\Http\Response
     */
    public function update(SimplePhotoRequest $request, SimplePhoto $simplePhoto)
    {
        $simplePhotoId = \SPT::storePhoto($request, $simplePhoto);

        return \redirect(route('photo.show', $simplePhotoId));
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Models\SimplePhoto  $simplePhoto
     * 
     * @return \Illuminate\Http\Response
     */
    public function destroy(SimplePhoto $simplePhoto)
    {
        $simplePhoto->delete();
        return response()->json('OK', 200);
    }
}

Zamiast domyślnie stosowanego obiektu typu HTTP Request do metod store() i update() dostarczamy obiekt zdefiniowanej wcześniej klasy SimplePhotoRequest. Dzięki temu unikamy konieczności definiowania logiki walidacji danych wewnątrz tych metod oraz zyskujemy na czytelności kodu. Do metod update(), show() i destroy() dostarczamy parametr typu SimplePhoto, czyli rekord pobrany z tabeli simple_photos dla podanego w zapytaniu id.

Jak widać, użyte zostały nazwy endpointów, które zdefiniujemy teraz w routes/web.php

Route::resource('/photo', 'SimplePhotoController', ['parameters' => [
    'photo' => 'simplePhoto'
]]);

Stosujemy typ reource, dzięki czemu jednym poleceniem zyskujemy definicję wszystkich endpointów dla wszystkich metod, a dodatkowo zapytania z wyszukiwaniem dla id automatycznie będą dostarczać obiekty modelu SimplePhoto, bez konieczności budowania logiki do tego celu.

Sprawdzamy routing:

php artisan route:list

Facade

Trzymając się zasady, według której nie przeładowujemy kodem metod kontrolera i wykorzystujemy je tylko zgodnie z przeznaczeniem, całą logikę niezwiązaną z Eloquent przeniesiemy do klasy narzędziowej, do której metod będziemy się odwoływali przez klasę fasadową. To technika charakterystyczna dla Laravel i jeżeli nie planujemy w przyszłości migrować z kodem poza ten framework, to stosowanie wzorca Facade jest bardzo wygodne i efektywne. Mechanizm Facade jest opisany i wyjaśniony w dokumentacji.

Procedura jest prosta i sprowadza się do kilku kroków:

  1. Definiujemy własnego dostawcę usług:

    php artisan make:provider SimplePhotoServiceProvider

    i w utworzonym pliku app/Providers/SimplePhotoServiceProvider.php rejestrujemy klasę narzędziową SimplePhotoTools, której jeszcze nie utworzyliśmy, ale już ją zaplanowaliśmy w katalogu app/Library:

    <?php
    
    namespace App\Providers;
    
    use Illuminate\Support\ServiceProvider;
    use App\Library\SimplePhotoTools;
    
    class SimplePhotoServiceProvider extends ServiceProvider
    {
       /**
        * Register services.
        *
        * @return void
        */
       public function register()
       {
           $this->app->bind('SimplePhotoTools', function () {
               return new SimplePhotoTools;
           });
       }
    
       /**
        * Bootstrap services.
        *
        * @return void
        */
       public function boot()
       {
           //
       }
    }
  2. W katalogu app/Facades tworzymy klasę fasadową w pliku SimplePhotoFacade.php, dzięki której uzyskamy możliwość statycznego odwoływania się do metod w zarejestrowanej powyżej klasie narzędziowej:

    <?php
    
    namespace App\Facades;
    
    use Illuminate\Support\Facades\Facade;
    
    class SimplePhotoFacade extends Facade
    {
       protected static function getFacadeAccessor()
       {
           return 'SimplePhotoTools';
       }
    }
  3. Teraz możemy utworzyć klasę narzędziową w app/Library/SimplePhotoTools.php i zdefiniować w niej wszystkie metody obsługujące logikę całego procesu:

    <?php
    
    namespace App\Library;
    
    use Spatie\Image\Image;
    use Spatie\Image\Manipulations;
    use App\Models\SimplePhoto;
    
    class SimplePhotoTools
    {
       private $fits = [
           'auto' => Manipulations::FIT_CROP,
           'contain' => Manipulations::FIT_CONTAIN,
           'cover' => Manipulations::FIT_MAX,
           'scale' => Manipulations::FIT_STRETCH,
       ];
       private $width;
       private $height;
       private $path;
       private $converted_path;
    
       /**
        * __construct
        *
        * @return void
        */
       public function __construct()
       {
           $this->width = config('spu.size.w');
           $this->height = config('spu.size.h');
           $this->path = config('spu.path') . \DIRECTORY_SEPARATOR;
           $this->converted_path = config('spu.converted_path') . \DIRECTORY_SEPARATOR;
       }
    
       /**
        * storePhoto
        *
        * @param  \App\Http\Requests\SimplePhotoRequest  $request
        * @param  \App\Models\SimplePhoto  $simplePhoto
        *
        * @return integer
        */
       public function storePhoto($request, SimplePhoto $simplePhoto = null)
       {
           $attributes = [
               'name' => $request->name,
               'properties' => ['fit' => $request->fit],
               'filename' => $request->image && $request->image->isValid() ? $this->setFilename($request->image) : $simplePhoto->filename,
           ];
           switch ($request->method()) {
               // update
               case 'PUT':
               case 'PATCH': {
                       $simplePhoto->update($attributes);
                       if ($request->image) {
                           $request->image->storeAs($this->path . $simplePhoto->id, $simplePhoto->filename, 'media');
                       }
                       break;
                   }
               // create
               case 'POST': {
                       $simplePhoto = SimplePhoto::create($attributes);
                       $request->image->storeAs($this->path . $simplePhoto->id, $simplePhoto->filename, 'media');
                       break;
                   }
           }
           $this->convertImage($simplePhoto);
    
           return $simplePhoto->id;
       }
    
       /**
        * Return new filename.
        *
        * @param  UploadedFile  $image
        * @return string
        */
       private function setFilename($image)
       {
           return \Str::slug(\basename($image->getClientOriginalName(), $image->getClientOriginalExtension())) . '.' . \strtolower($image->getClientOriginalExtension());
       }
    
       /**
        * convertImage
        *
        * @param  \App\Models\SimplePhoto  $simplePhoto
        *
        * @return void
        */
       private function convertImage(SimplePhoto $simplePhoto)
       {
           $photoFilePath = \Storage::disk('media')->path($this->path . $simplePhoto->id . \DIRECTORY_SEPARATOR . $simplePhoto->filename);
           $convertedPath = $this->path . $simplePhoto->id . \DIRECTORY_SEPARATOR . $this->converted_path;
           \Storage::disk('media')->makeDirectory($convertedPath);
           $convertedPhotoPath = \Storage::disk('media')->path($convertedPath . $simplePhoto->filename);
    
           switch ($simplePhoto->properties->fit) {
               case 'scale':
               case 'contain': {
                       Image::load($photoFilePath)->fit($this->fits[$simplePhoto->properties->fit], $this->width, $this->height)
                           ->save($convertedPhotoPath);
                       break;
                   }
               case 'cover': {
                       Image::load($photoFilePath)->crop(Manipulations::CROP_CENTER, $this->width, $this->height)
                           ->save($convertedPhotoPath);
                       break;
                   }
               default: {
                       Image::load($photoFilePath)->manualCrop($this->width, $this->height, 0, 0)
                           ->save($convertedPhotoPath);
                   }
           }
       }
    }

    Łatwo zauważyć pewną niekonsekwencję polegającą na tym, że w metodzie storePhoto() umieszczone są elementy odpowiedzialne za tworzenie i aktualizację modelu SimplePhoto (Eloquent), ale w tym przypadku nie ma to większego znaczenia. Metoda jest dedykowana głównie do konwersji i zapisania na dysku dostarczanego w żądaniu pliku graficznego, a create i update naturalnie tu pasują dopełniając całości procesu obsługi formularza.

  4. Na koniec zostało zgłoszenie providera i zdefiniowanie aliasu, którym będziemy posługiwali się w skryptach. W pliku config/app w sekcji providers dodajemy: App\Providers\SimplePhotoServiceProvider::class, a w sekcji aliases dopisujemy nasz alias, czyli 'SPT' => App\Facades\SimplePhotoFacade::class.

Logika

Metody klasy SimplePhotoTools odpowiadają za obsługę danych przekazanych w żądaniu z formularza. Dane są już zweryfikowane i na pewno poprawne, gdyż przeszły już przez opisaną wcześniej SimplePhotoRequest, więc nie musimy się o to martwić.

Konwersję dostarczonych plików graficznych można zrealizować wykorzystując bezpośrednio funkcje PHP, ale wygodniej będzie skorzystać z jednego z dostępnych pakietów. W tym projekcie użyjemy pakietu Image dostarczanego przez Spatie.be . W dokumentacji pakietu wszystko jest zgrabnie opisane, więc nie będę tutaj tego powtarzał, wystarczy tylko pakiet zainstalować:

composer require spatie/image

Całość procesu działa wg prostego schematu:

  1. Z formularza dostarczane są pola:
    • image - plik graficzny
    • name - nazwa/tytuł
    • fit - typ dopasowania do kontenera: auto, contain, cover lub scale
  2. W metodzie storePhoto() tworzona jest tablica atrybutów do zapisania lub aktualizacji modelu SimplePhoto, wykorzystuje do tego metodę setFilename(), w której nazwa pliku jest konwertowana do wersji "bezpiecznej", po obsłużeniu modelu oryginał pliku trafia do katalogu o nazwie zdefiniowanej w pliku konfiguracyjnym config/spu.php, a następnie do metody convertImage(), w której zostanie utworzona wersja pliku z obrazem dostosowanym do oczekiwanego rozmiaru.
  3. Metoda convertImage() w zależności od wartości dostarczonego z formularza pola fit, korzysta z odpowiedniej metody klasy Image i przetwarza dostarczony plik zapisując wynik pod tą samą nazwą, ale w dedykowanym katalogu, którego nazwa też jest zdefiniowana w pliku konfiguracyjnym.

Na końcu zadbajmy o dwie dodatkowe metody w modelu SimplePhoto, które z pobranym modelem będą dostarczały adresy URL plików oryginalnego i konwertowanego. W definicji modelu dopisujemy:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class SimplePhoto extends Model
{
    use SoftDeletes;

    protected $casts = [
        'properties' => 'object'
    ];

    protected $fillable = [
        'name',
        'filename',
        'properties',
        'status',
    ];

    public function getUrlAttribute()
    {
        return \Storage::disk('media')->url(config('spu.path') . \DIRECTORY_SEPARATOR . $this->id . \DIRECTORY_SEPARATOR . config('spu.converted_path') . \DIRECTORY_SEPARATOR . $this->filename);
    }

    public function getUrlOriginalAttribute()
    {
        return \Storage::disk('media')->url(config('spu.path') . \DIRECTORY_SEPARATOR . $this->id . \DIRECTORY_SEPARATOR . $this->filename);
    }
}

Wykorzystujemy dwie metody typu accessor, dzięki którym możemy odwoływać się bezpośrednio do właściwości $simplePhoto->url (adres obrazu po konwersji) i $simplePhoto->url_original (adres obrazu oryginalnego).

I to by było na tyle. Oczywiście pozostają jeszcze do utworzenia szablony blade: index, show, create i edit, ale to już nie jest problemem, przykładowe są dostępne w katalogu resources/views. Należy też pamiętać o utworzeniu dowiązania symbolicznego katalogu storage\app\public\media w katalogu public projektu oraz, jeżeli będzie taka konieczność, o nadaniu odpowiednich uprawnień dla katalogów public i storage.

Repozytorium projektu można pobrać z https://bitbucket.org/romangiminski/simple-photo

git clone https://romangiminski@bitbucket.org/romangiminski/simple-photo.git

Demo:

http://simple-photo.demo.e-lider.pl

blog comments powered by Disqus