Avatar użytkownika - upload plików post
2015-11-02
Upload plików, wydawało by się proces trywialny jednak wystarczy rzucić okiem na dokumentację Doctrine dla Symfony aby zobaczyć jak bardzo można ten proces skomplikować.
// src/AppBundle/Entity/Document.php
namespace AppBundle\Entity;
class Document
{
public $id;
public $name;
public $path;
public function getAbsolutePath()
{
return null === $this->path
? null
: $this->getUploadRootDir().'/'.$this->path;
}
public function getWebPath()
{
return null === $this->path
? null
: $this->getUploadDir().'/'.$this->path;
}
protected function getUploadRootDir()
{
// the absolute directory path where uploaded
// documents should be saved
return __DIR__.'/../../../../web/'.$this->getUploadDir();
}
protected function getUploadDir()
{
// get rid of the __DIR__ so it doesn't screw up
// when displaying uploaded doc/image in the view.
return 'uploads/documents';
}
}
Przykład nie dość, że skomplikowany (prostego kodu nie trzeba oszpecać komentarzami) to jeszcze może okazać się tragiczny w skutkach. Dlaczego dokument, reprezentujący plik ma decydować o tym gdzie jest zapisany? To tak jakby adres email miał decydować czy jest unikalny w kontekście systemu. Zarówno plik jak i email to wartości, wartości istniejące w jakimś kontekście. Wymuszanie na pliku aby wiedział pod jaką nazwą jest zapisany to prawie to samo co wymuszanie na adresie email aby potrafił sprawdzić czy jest unikalny.
Z czego wynika ten problem?
Wydaje mi się, że przeniesienie tej całej logiki do encji wynika nie tyle z niechęci do pisania prostego kodu co z faktu, że po wgraniu pliku
trzeba się jeszcze jakoś do niego odwołać. Cóż mogłoby być prostszego niż pobranie dokumentu przez repozytorium lub inną encję i wykonanie $document->getUploadDir()
?
Patrząc na efekt końcowy wydawać by się mogło, że kompromis w postaci łatwego dostępu do pliku w zamian za obniżenie jakości
encji może sie opłacić. Zastanówmy się jednak jak można rozwiązać podobny problem bez takiego kompromisu.
Specyfikacja przypadku
Załóżmy, że piszemy system w którym użytkownicy mogą posiadać avatar.
Obrazek o określonych rozmiarach i formacie, wyświetlany tam gdzie wyświetlana jest aktywność użytkownika,
na przykład komentarze.
Naturalnym mogłoby się wydawać umieszczenie pola $avatar
wewnątrz encji User
, czy aby na pewno jest dobre podejście?
Nie trzeba robić szczególnej analizy przypadku żeby zauważyć, że najistotniejszą częścią systemu są komentarze.
Komentarze tworzone przez użytkowników.
Avatary użytkowników wydają się być mniej istotne, gdyby się na tym głębiej zastanowić komentarze oraz ich autorzy
nie są definiowane poprzez avatary. Avatar to jedynie przyjemny dodatek, coś co odróżnia użytkowników od siebie, pozwala
pokazać unikalność jednak w żaden sposób nie wpływa na sens i sposób działania całego systemu.
Nie zmienia to faktu, że avatar jest istotną częścią aplikacji. W końcu klienci naszego klienta, czyli użytkownicy bardzo
często kupują oczami. Jak więc zrealizować upload plików w taki sposób aby z jednej strony nie zanieczyścić encji użytkownika
potencjalnie nieistotnymi detalami a z drugiej nie zignorować czegoś co sprawia, że aplikacja jest atrakcyjna?
Implementacja
class User
{
private $email;
public function __construct(Email $email)
{
$this->email = $email;
}
/**
* @return Email
*/
public function email()
{
return $this->email;
}
}
Klasa użytkownika, encja będąca modelem twórcy komentarza.
class Image
{
private $content;
private $mimeType;
private $extension;
public function __construct($content)
{
// check if content is not empty, is valid file, has metadata if required etc.
$this->content = $content;
$this->mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($this->content);
switch ($this->mimeType) {
case 'image/jpeg':
case 'image/jpg':
$this->extension = 'jpg';
break;
default :
throw new InvalidPhotoMimeTypeException();
}
}
public function content()
{
return $this->content;
}
public function extension()
{
return this->extension;
}
public function size()
{
return strlen($this->content);
}
}
Klasa obrazka, reprezentująca plik, zawierająca jego zawartość na podstawie, której jest w stanie określić np. rozszerzenie pliku. Ta klasa celowo nie zawiera nazwy pliku, nazwa pliku nie jest częścią pliku i nie jest w nim zapisana. Jeden plik, w różnych filesystemach może istnieć pod tą samą nazwą i na odwrót. Nazwa pliku istnieje tylko w filesystemie.
interface AvatarStorage
{
public function hasAvatarFor(Email $email);
public function saveAvatarFor(Email $email, Image $file);
public function removeAvatarFor(Email $email);
public function getPathOfAvatarFor(Email $email);
}
Interfejs reprezentujący miejsce, w którym zapisywane są avatary.
final class AvatarUploadCommand
{
private $path;
public function __construct($userEmail, $uploadedFilePath)
{
$this->email = $userEmail;
$this->path = $uploadedFilePath;
}
public function email()
{
return $this->email;
}
public function path()
{
return $this->path;
}
}
Komenda będąca reprezentacją żądania wykonania operacji zapisu pliku znajdującego się w konkretnej lokalizacji jako avatara dla konkretnego użytkownika.
final class AvatarUploadHandler
{
private $storage;
private $users;
private $spec;
public function __construct(AvatarStorage $storage, UserRepository $users, AvatarSpecification $avatarSpec)
{
$this->storage = $storage;
$this->users = $users;
$this->spec = $avatarSpec;
}
public function handle(AvatarUploadCommand $command)
{
$email = new Email($command->email());
$image = new Image(file_get_contents($command->path));
$this->users->getByEmail($email);
$this->spec->checkIfSatisfiedBy($image);
$this->storage->saveAvatarFor($email, $image);
}
}
Usługa, która najpierw sprawdza czy użytkownik istnieje oraz czy obrazek spełnia wymogi bycia avatarem a następnie zapisuje go w
AvatarStorage
class UserController extends Controller
{
/**
* @Route("/user/upload-avatar", name="app_user_avatar_upload")
*/
public function uploadAvatarAction(Request $request)
{
$form = $this->createForm(new UserAvatarType());
$form->handleRequest($request);
if ($form->isValid()) {
$uploadedFile = $form->get('avatar')->getData();
$command = new AvatarUploadCommand(
$this->getUserEmailFromSession(),
$uploadedFile->getRealPath()
);
$this->get('handler.upload_avatar')->handle($command);
return $this->redirect($this->generateUrl('app_user_profile', ['email' => $email]));
}
return $this->render('user/upload-avatar.html.twig', [
'form' => $form->createView(),
]);
}
}
Kontroler, miejsce gdzie Request HTTP jest przetwarzany za pomocą formularza. Ten konkretny przykład oparty jest o framework Symfony2, jednak nie ma to większego znaczenia, równie dobrze może to być Laravel.
Omówienie
Jak widać nie ma potrzeby aby encja użytkownika była jakoś bezpośrednio powiązana z avatarem. Co więcej nie ma nawet
potrzeby aby informacja o tym, że użytkownik posiada avatar była zapisana w bazie danych czy w innym miejscu, w którym
przechowujemy informację o użytkowniku. Jeżeli w odpowiednich miejscach będziemy posługiwać się odpowiednią implementacją
AvatarStorage
łatwo dowiemy się czy avatar dla danego użytkownika istnieje.
Ludzie często wiążą te dwie rzeczy na siłę twierdząc, że przecież avatar jest przypisany do użytkownika. Jest to prawdą
jednak bardziej niż avatar do użytkownika przypisany jest adres email. Użytkownik nie może go od tak zmienić gdyż przeważnie
jest to jego główny identyfikator. Co jednak, jeżeli użytkownik może zmienić adres email lub dodać nowy? Wtedy AvatarStorage
powinien posługiwać się czymś co zmienić się nie może a jednoznacznie identyfikuje użytkownika. Może to być nick, unikalny
identyfikator nadawany podczas rejestracji czy nawet id z bazy danych. Przy takim podejściu nie ma to znaczenia.
Kolejną ważna, moim zdaniem najważniejszą zaletą tego rozwiązania jest całkowita separacja miejsca przechowywania plików
od logiki aplikacji. Gdyby w powyższym przypadku doszło do konieczności zmiany lokalnego systemu plików na Amazon S3
operacja ta mogłaby się odbyć zupełnie nieodczuwalnie dla użytkowników. W zasadzie wystarczyłoby tylko zrobić export
plików do S3 oraz podmienić implementację AvatarStorage
.
Ponieważ AvatarStorage
zdefiniowany jest w warstwie aplikacji, może posługiwać się elementami występującymi w tej
warstwie jak np. id nadawane przez bazę danych.
Ważne - Na pewno ktoś zwróci uwagę na to, że w pewnym miejscu treść obrazka ładowana jest do pamięci. Tak, w tym przypadku
nie ma to jednak większego znaczenia, gdyż rozmiar avataru nie przekracza przeważnie kilkuset kilobajtów. Jednak dla wszystkich, którym
coś takiego nie dałoby spać bo kilka dni wcześniej optymalizowali aplikację zmieniając for
na foreach
(lub odwrotnie)
polecam zapoznać się z wzorcem projektowym Virtual Proxy.
Umożliwi on przechowywanie w pamięci jedynie ściężki do pliku.
Odpowiadając więc na pytanie z wstępu do tego wpisu, prostszym i o wiele bardziej elastycznym rozwiązaniem od $document->getUploadDir()
jest $fileStorage->getUserDocumentPathBy(Email $email);
.
Bardzo ważne - to podejście sprawdza się w przypadkach kiedy pliki stanowią raczej uzupełnienie funkcjonalności systemu a nie jego podstawę. Przykładowo tworząc system opierający się na przepływie plików pomiędzy konkretnymi użytkownikami w systemie, podejście tego typu mogłoby okazać się niewystarczające. W takim wypadku poza samym plikiem istotna byłaby również jego historia czy nawet dodatkowe komentarze i opis pliku. Wtedy plik a raczej jego historia, opis a może nawet różnice między konkretnymi wersjami tworzą encję.