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 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 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/Facades
tworzymy 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.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ę 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, acreate
iupdate
naturalnie 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/app
w sekcjiproviders
dodajemy:App\Providers\SimplePhotoServiceProvider::class
, a w sekcjialiases
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:
- Z formularza dostarczane są pola:
- image - plik graficzny
- name - nazwa/tytuł
- fit - typ dopasowania do kontenera:
auto
,contain
,cover
lubscale
- 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 klasyImage
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