Desenvolupem la teva web presencial SPA DES DE 300€. Si, es una bogeria. Web SPA DES DE 300€ — Si, es una bogeria. Parlem →

On calcular el valor d'un camp quan el model ja és ple d'observers

On calcular el valor d'un camp quan el model ja és ple d'observers

Els camps calculats són de les decisions més petites que es prenen en un projecte i de les que pitjor envelleixen. Slug, referència, títol compost, text de cerca: cadascun entra pel seu costat i tots acaben al mateix lloc. Aquí veuràs per què l'observer no és aquest lloc, per què un accessor tampoc no resol el problema quan necessites consultar el camp, i on col·locar-los perquè deixin de multiplicar-se. Els exemples van en Laravel perquè és una de les nostres especialitats, i comparem amb el que el mateix framework ja ofereix, però el problema apareix igual en qualsevol ORM amb cicle de vida.

L'observer comença net i acaba sent el lloc on hi cap tot

El patró és sempre el mateix. Necessites generar un slug en crear un registre i el poses a l'observer, que per això hi és. Després cal un codi de referència. Després un camp de text concatenat per buscar. Després un títol comercial que es compon a partir dels dos anteriors.

Sis mesos després l'observer té vuitanta línies, barreja lògica de negoci amb orquestració i el seu mètode creating fa cinc coses que no s'assemblen entre elles.

I hi ha un símptoma que delata que ja se n'ha anat de les mans: obres dos observers de models diferents i trobes la mateixa funció privada de generar slugs, copiada byte a byte, amb l'única diferència del text de reserva quan el resultat queda buit. Aquell dia ja no és un problema d'estil, és duplicació real.

Un camp calculat que vols consultar ha d'estar en una columna

Abans de moure res convé separar dos casos que la gent barreja, perquè tenen solucions diferents.

Si el valor només es mostra, un accessor és la resposta correcta. Es calcula en llegir, no ocupa espai i no pot quedar desincronitzat.

Però si el valor es consulta, l'accessor no serveix. Un camp de cerca concatenat només serveix si pots escriure:

WHERE search_text LIKE '%martinez%'

I això exigeix que sigui una columna real, amb el seu índex. El mateix amb un codi únic que necessita una restricció d'unicitat, o amb un slug pel qual es resol una ruta.

Aquí és on la conversa es posa interessant, perquè el càlcul ha de passar en escriptura i persistir-se, i aquest és justament el terreny de l'observer.

Treure el càlcul a una classe amb una convenció de noms

La solució que ens funciona és treure cada càlcul a la seva pròpia classe, deixant a l'observer només l'orquestració.

La classe rep el model i exposa un mètode per camp, amb el nom derivat del camp: search_text es resol amb getSearchText, advert_title amb getAdvertTitle. No hi ha cap registre de quin mètode correspon a quina columna, la convenció de noms és el registre.

class UserKeeper
{
    public function __construct(protected User $user) {}

    public function getSearchText(): string
    {
        $parts = array_filter([
            $this->user->name,
            $this->user->last_name,
            $this->user->email,
            $this->user->phone,
            $this->user->address,
            $this->user->postal_code,
        ]);

        return substr(implode(' ', $parts), 0, 1000);
    }
}

I l'observer es queda amb el que de debò li toca, que és decidir quan:

public function creating(User $user): void
{
    $user->slug = Str::uuid();
    $user->process(['search_text']);

    Password::sendResetLink($user->only('email'));
}

La classe s'associa al model amb un atribut, i l'atribut és repetible, així que un model gran pot repartir els seus càlculs en diverses classes en comptes d'acumular-los en una.

Andie recomana

Anomena els camps amb guions baixos i en minúscula, que és la convenció de columnes de Laravel i la que fa servir la resolució per nom. Si segueixes aquesta convenció, que és la que ja segueix qualsevol projecte Laravel normal, el mètode es troba sol i no hi ha res més a configurar.

L'ordre de l'array és l'ordre d'execució

Els camps calculats poques vegades són independents entre ells: el títol comercial necessita l'slug, i el text de cerca els necessita tots dos. Qualsevol sistema que els tracti com un conjunt desordenat obliga a resoldre aquestes dependències a mà, i aquesta és justament la feina que es pot estalviar.

Quan demanes diversos camps alhora, es calculen en l'ordre en què els escrius, i cadascun ja té disponible el que van calcular els anteriors:

$property->process(['code', 'reference', 'advert_title']);

Aquí el codi es genera primer, la referència l'utilitza per compondre's, i el títol comercial fa servir la referència. Invertir l'array trenca el càlcul sense donar cap error, així que aquest ordre és informació i convé tractar-lo com a tal.

És exactament el tipus de dependència que en un observer llarg queda enterrada entre altres vint línies i aquí queda en una sola línia llegible.

Calcular i desar són coses diferents, i separar-les et dona el reompliment retroactiu

Decisió de disseny que sembla una omissió i és el contrari: calcular no desa. Els valors s'assignen en memòria i ja decideixes tu què fer-ne.

Dins d'un observer això és el correcte, perquè el desat ve després de totes maneres i persistir enmig provocaria una escriptura extra o una recursió.

Però el bo ve a fora. El dia que afegeixes un camp calculat a un model amb cent mil registres existents, el reompliment retroactiu són tres línies i reutilitza exactament el mateix càlcul que fa servir l'alta:

foreach (Client::cursor() as $client) {
    $client->process(['search_text']);
    $client->save();
}

Si el càlcul estigués dins de l'observer, per reomplir hauries de tocar i tornar a desar cada registre només per disparar l'esdeveniment, o duplicar la lògica a l'ordre de consola. Les dues opcions són pitjors.

Què hi ha a Laravel per al mateix i en què es diferencia

Convé ser honest amb això: no aporta cap capacitat que Laravel no tingui. Aporta un lloc on posar el codi i una convenció. Tot i així, les alternatives natives tenen compromisos concrets que val la pena conèixer.

Els accessors amb la classe Attribute són l'eina correcta per a valors derivats que només es mostren. Calculen en llegir i no persisteixen, així que no es poden consultar, indexar ni sotmetre a una restricció d'unicitat. Per a un camp de cerca o un slug de ruta, no són una opció.

Els casts personalitzats transformen en escriptura i sí que persisteixen, que és la meitat del problema resolta. El compromís és la forma: són un a un entre camp i classe, i el cast rep el valor i el model però està pensat per transformar, no per compondre a partir d'altres columnes ni per llançar consultes. Generar un codi comprovant que no existeixi ja a la taula queda incòmode.

L'atribut #[ObservedBy] és l'equivalent natiu per associar comportament a un model, i és la peça correcta per decidir quan. El que no dona és invocació camp a camp: un observer es dispara per esdeveniments, així que no li pots demanar «recalcula només aquest camp» des d'una ordre de consola.

I la lògica directa a booted() o a l'observer és el punt de partida, el que funciona perfectament fins que hi ha quatre camps i dos models que comparteixen càlcul.

La diferència pràctica que a nosaltres ens serveix és que l'observer es queda decidint quan i el càlcul es queda decidint quant val, cadascun al seu fitxer, i el segon és invocable des d'on vulguis.

Com es veu en producció

A Abodara hi ha nou models amb camps calculats, nou classes de càlcul i vint-i-un mètodes repartits entre elles, invocats des de nou observers i una ordre de consola.

El cas més dens és el de fitxers, on el tipus depèn del MIME ja calculat i per això van en crides separades:

$file->process(['mime_type']);
if (empty($file->type)) $file->process(['type']);

$file->process(['slug', 'position', 'digest', 'size', 'access', 'default', 'published']);

Nou camps calculats en un observer que continua cabent en una pantalla.

I si no treballes amb Laravel

El criteri es trasllada tal qual i no depèn del framework:

  • Separa quan es calcula de com es calcula. El disparador i la fórmula són responsabilitats diferents i canvien per motius diferents.
  • Si el valor es consulta, persisteix-lo en una columna. Calcular en llegir és correcte només per al que es mostra.
  • Que la funció de càlcul sigui invocable tota sola, sense dependre del cicle de vida de l'ORM. És el que et permet reomplir registres antics i provar-la sense base de dades.
  • I fes explícites les dependències entre camps. Si un es calcula a partir d'un altre, que es vegi en una línia i no enterrat en l'ordre d'aparició d'un mètode llarg.

On és el codi

El paquet és a Packagist, el codi a GitHub i la documentació del paquet recull l'API completa. Són unes dues-centes línies, sense taules, sense configuració i amb un generador make:keeper per no escriure l'esquelet a mà.

Si tens un projecte on els observers s'han convertit en el calaix de sastre, això s'aplica de manera incremental: un model, un camp, i continues. Ho treballem a desenvolupament Laravel i aplicacions web, i si ens ensenyes el model que més fa mal et diem per on començar.

Escrit per
Edu Lazaro
Edu Lazaro
Founder & Lead Developer en AndorraDev

Desenvolupador full-stack amb més de 15 anys d'experiència en Laravel, React, Node.js i arquitectures cloud. Ajudo empreses a Andorra a construir la seva presència digital.

Partner de diseño · ionospace.
Necessites ajuda? ×
Andie by AndorraDev
Assistent IA + equip humà
Assistent IA d'AndorraDev
Andie
Hola! Soc Andie, l'assistent IA d'AndorraDev. En què et puc ajudar? Si necessites parlar amb Edu, només demana-ho.
17:28