Warstwa Abstrakcji - stanowi izolację pomiędzy kodem programisty a zewnętrznymi modułami, pozwala odseparować system od jego zależności przez co czyni go podatnym na zmiany i aktualizacje. Przywiązując system do innej usługi czynimy go słabym.

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

Masz pytanie?
Napisz: kontakt@zawarstwaabstrakcji.pl

Feedback

Podobał Ci się ten wpis? Bardzo mnie to cieszy. Nie ma większej motywacji niż zadowolony odbiorca. Gdybyś uznał, że treść którą tworzę jest wartościowa byłbym wdzięczny za zostawienie recenzji na Facebooku. Taka recenzja nie tylko zmotywuje mnie do dalszej pracy ale pozwoli również zyskać większy zasięg. Zasięg, który pozwoli mi dotrzeć do większej grupy odbiorców.
Możesz też polubić fanpage, na którym staram się w miarę regularnie publikować krótsze ale nie mniej wartościowe treści.

Akceptuję

Ten serwis używa plików cookies. Więcej o plikach cookies.