Problemă legată de implementarea unui `Factory Design Pattern`

Salut la voi! Vă citesc de mult timp. Nu sînt foarte activ online, dar am decis să-mi creez un cont pentru a pune o întrebare, poate poate mă descoase cineva. :relaxed:

Ce vreau să fac

Am creat un sistem prin care să pot încărca fișiere în aplicația mea (în mare, vorbim despre fotografii). Aplicația este scrisă folosind Symfony 5, cu PHP 8. Nu cred că va conta pentru ce urmează să descriu în continuare, dar zic să fie scris. Ideea mea era să pun la punct un sistem care să-mi permită pe mediul de lucru local, să pot să încarc fișiere local (undeva în /storage/…), iar în mediul de producție, fișierele să fie încărcate pe Amazon S3.

Pentru a realiza asta, am folosit pachetul league/flysystem-bundle, împreună cu adaptorul league/flysystem-aws-s3-v3 pentru abstractizarea apelurilor către S3.

Conform acestei documentații (https:// github .com/thephpleague/flysystem-bundle/blob/master/docs/4-using-lazy-adapter-to-switch-at-runtime.md), am configurat aplicația cu ceea ce ei numesc lazy adapter pentru a putea să schimb implementarea în funcție de variabila de mediu.

Pînă aici, toate bune. Dacă sînt pe local, fișierele se încarcă în local, dacă sînt pe producție, fișiere se trimit către S3.

Bun.

Problema

Vreau să aduc cumva adresa acestor fișiere încărcate. Pentru asta, gîndesc eu, am nevoie tot de acea variabilă de mediu care decide în ce mediu sînt.

Apelul preferabil, aș vrea să arate în felul următor:

$this->storage->getAdapter()->url($gallery->getImageUrl());

sau, preferabil:

$this->storage->url($gallery->getImageUrl());

Metoda url(string: $path): string aș vrea să-mi aducă:

  • dacă sînt în mediu local, adresa către fișierul cerut: http:// local .dev/[path-to-file]/file.jpg
  • dacă sînt în producție, adresa către fișierul de pe S3: https:// s3.[aws-region].amazonaws .com/[aws-bucket]/[path-to-file]/file.jpg

Ce am făcut eu

O clasă \App\Services\Storage (un fel de Factory, dar nu chiar).
Două adaptoare \App\Services\Adapter\Local și \App\Services\Adapter\Aws

Cele două adaptoare implementează interfața \App\Storage\Adapter\StorageAdapterInterface. Momentan interfața asta definește doar cum să arate metoda url(string: $path): string, dar plănuiesc să mai adaug.

În \App\Services\Storage, preiau din variabilele de mediu, valoarea care-mi spune pe ce mediu sînt, și în funcție de ea, creez o nouă instanță dintr-un adaptor (Local sau Aws):

services.yml:

services:
    App\Services\Storage\Storage:
        arguments:
            $adapter: '%env(APP_UPLOADS_SOURCE)%'

\App\Services\Storage:

public function getAdapter(): StorageAdapterInterface
{
    $class = 'App\\Services\\Storage\\Adapter\\'.$this->parseAdapterName();

    if (!class_exists($class)) {
        throw new \InvalidArgumentException("Storage adapter {$class} does not exist.");
    }

    $adapter = new $class();

    return $adapter;
}

Treaba asta ar funcționa, dacă ar fi să rămînă implementarea așa cum este acum.

Concluzii

Problema este că la instanțierea adaptorului, $adapter = new $class();, trebuie să trimit parametrii acelui adaptor, parametrii care diferă la Aws față de Local. Spre exemplu, pe local aș avea nevoie de informații de la Symfony, despre domeniu (URI), în timp ce pt. AWS aș avea nevoie de numele bucket-ului sau regiunea S3 pentru a forma acel URL corect.

Efectiv nu știu și nici nu am reușit să-mi dau seama cum să fac asta într-un mod care să nu polueze clasa \App\Services\Storage cu informații de care nu are nevoie. Practic, am nevoie să injectez în \App\Services\Adapter\Local și \App\Services\Adapter\Aws acești parametrii (luați în mare parte din variabilele de mediu). Dar nici nu vreau să pun în \App\Services\Storage if-uri sau switch-uri.

M-am documentat despre servicii tip Factory în Symfony de aici:

Dar nimic de acolo nu-mi explică cum să pasez alți parametri la un obiect, în funcție de o variabilă de mediu sau parametru. Mă gîndesc că acel adaptor ar trebui să poată să se construiască singur, cu toate dependințele de care are nevoie.

Voi ce ziceți? Gîndesc eu greșit? Drept e că nu am mai implementat un Factory pînă acum.

Pai daca iti faci un factory care sa-ti creeze instanta respectiva practic fie ai 2 factories, fie una singura care are ea un if in ea in functie de mediu (local/aws) si restul variabilelor de conf injectate ca sa poata sa creeze obiectele.
Practic muti partea de creatie din Storage in clasa asta de Factory.

In principiu nu e nici o problema cu abordarea ta, si legat de pasarea parametrilor eu i-as injecta direct in clasa inclusiv daca ai ceva ce iti spune daca esti pe local sau aws (nu stiu daca e posibil nu am mai facut php de mult timp).

Mai mult sau mai puțin, așa am făcut. Atîta doar că mă cam zgîria la ochi faptul că ar fi nevoie să fac ceva de genul:

switch ($storage) {
    case 'aws':
        return $this->createAwsAdapter();
    case 'local':
        return $this->createLocalAdapter(); 
    ...
// să zicem că metodele astea s-ar ocupa și de pasarea parametrilor corecți
}

Astfel încît la adăugarea unui adaptor, să fie nevoie să mai adaug o intrare în acel switch. Nu ar fi asta o soluție care ar duce la code rigidity?

Mă gîndesc la o soluție prin care să adaug un adaptor, specificînd-ul într-o configurație, undeva. Dar fără să mai modific cod care să verifice și să facă ceva doar pentru acel adaptor. Rutinele din el să se ocupe de dependințele lui.

:thinking:

  • adaugi în fiecare adapter un public StorageTypeEnum (Local / Production)
  • în factory aduci toate adapters care implementează interfața StorageAdapterInterface, citești environment și filtrezi în lista de adapters pe cel cu StorageTypeEnum dorit și îl instanțiezi
  • pui dependențele în fiecare adapter (local - citești url din env variables / prod - citești aws config din env variables)

În felul ăsta eviți switch sau if/else.

1 Like

Oarecum o abordare în genul ăsta am încercat. Nu la fel, dar pe acolo. Dar problema de care m-am lovit este următoarea:

  • în factory aduci toate adapters care implementează interfața StorageAdapterInterface, citești environment și filtrezi în lista de adapters pe cel cu StorageTypeEnum dorit și îl instanțiezi
  • pui dependențele în fiecare adapter (local - citești url din env variables / prod - citești aws config din env variables)

Modelul oferit de Symfony în a face Dependency Injection este prin constructor. Parametrii din .env îi pot transmite către serviciile mele (în cazul meu, adaptoarele) folosind fișierele de configuație iar Symfony le injectează automat.

Așadar, dacă pun dependințele în adapter, la instanțierea lui, folosind new, cum dădeam exemplu mai sus, trebuie să specific și parametrii. De aici și dilema mea. :flushed: Sau îmi scapă ceva?

Ai nevoie de cele 2 implementari ale interfeței, IStorage → AWSStorage, LocalStorage. At runtime inregistrezi implementarea de care ai nevoie pe baza setarii (aws, local). Tu vei folosi tot timpul interfața, nu te atingi de implementari în utilizare decât când se înregistrează dependințele.

Sau îmi scapă ceva?

2 Likes

Dacă interfețele și implementările lor sunt deja înregistrate la runtime, poți încerca să injectezi în factory o listă de AdapterStorageInterface. Le filtrezi în funcție de env, obții interfața dorită și apelezi direct metoda, fără să mai instanțiezi vreun obiect.

A mai scris cineva mai sus sa folosesti un switch, pentru ca esti pe PHP 8 iti recomand chiar un match.

Incearca sa eviti cod de genul (unde numele clasei eu un string)

Vezi ca te chinui unde nu trebuie. Nu incerca sa faci un adaptor dinamic daca te preocupa doar a avea un Storage in productie si altul in dev. Il configurezi pe cel dorit in .env.local respectiv in .env.prod si gata.

3 Likes

Mersi frumos pentru mesaje și explicații. Sînt prins momentan pe mai multe proiecte iar la treaba de mai sus n-am mai lucrat de Vineri. Am să încerc sugestiile voastre și mai refactorizez pe ici pe colo, și anume în punctele esențiale :grinning_face_with_smiling_eyes:, și-am să revin cu rezultate, ce mi-a ieșit într-un final.