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:
- kadrowanie do rozmiaru 300x300 wg parametrów z background-size (auto, contain, cover, scale),
- maksymalny rozmiar ładowanego zdjęcia 2MB,
- przechowywany ma być plik oryginału i plik po konwersji,
- każda dodana grafika musi być opisana unikalnym tytułem,
- 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:
-
Definiujemy własnego dostawcę usług:
php artisan make:provider SimplePhotoServiceProvideri w utworzonym pliku
app/Providers/SimplePhotoServiceProvider.phprejestrujemy klasę narzędziowąSimplePhotoTools, której jeszcze nie utworzyliśmy, ale już ją zaplanowaliśmy w kataloguapp/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() { // } } -
W katalogu
app/Facadestworzymy klasę fasadową w plikuSimplePhotoFacade.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'; } } -
Teraz możemy utworzyć klasę narzędziową w
app/Library/SimplePhotoTools.phpi 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ę modeluSimplePhoto(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, acreateiupdatenaturalnie tu pasują dopełniając całości procesu obsługi formularza. - Na koniec zostało zgłoszenie providera i zdefiniowanie aliasu, którym będziemy posługiwali się w skryptach. W pliku
config/appw sekcjiprovidersdodajemy:App\Providers\SimplePhotoServiceProvider::class, a w sekcjialiasesdopisujemy 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:
- Z formularza dostarczane są pola:
- image - plik graficzny
- name - nazwa/tytuł
- fit - typ dopasowania do kontenera:
auto,contain,coverlubscale
- W metodzie
storePhoto()tworzona jest tablica atrybutów do zapisania lub aktualizacji modeluSimplePhoto, 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 konfiguracyjnymconfig/spu.php, a następnie do metodyconvertImage(), w której zostanie utworzona wersja pliku z obrazem dostosowanym do oczekiwanego rozmiaru. - Metoda
convertImage()w zależności od wartości dostarczonego z formularza polafit, korzysta z odpowiedniej metody klasyImagei 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