Quindi, ho dovuto creare un livello API su un codebase esistente (ma noto) esistente.
Sembra facile, eh? Sì, è quello che ho pensato anch’io.
Sfortunatamente, le cose non erano così facili: quando ho iniziato a entrare nei dettagli, ho trovato alcune trappole.
La lista delle trappole
- ISCRIVITI all’inferno
La mappa delle entità sottostante è piuttosto estesa. Ci sono molte entità, tutte in qualche modo correlate, e per applicare alcuni filtri è necessario impostare una lunga catena di JOIN .
- Soft-delete master
Il sistema utilizza una tabella di eliminazione soft, con tutte le informazioni sui record eliminati per tutte le entità. Quindi questa tabella deve essere unita più volte in ogni query.
- Labirinto delle relazioni
Alcune entità hanno relazioni molti-a-molti.
Queste relazioni sono memorizzate in una tabella separata in cui le coppie entity id / entity type
sono collegate ad altre coppie.
Oltre a questo, non puoi nemmeno fare affidamento sull’ordine: la relazione può essere espressa in entrambe le direzioni, quindi devi duplicare tutte le tue domande.
- Dati molto grandi
La ciliegina sulla torta è che ci sono molti e molti record. Quindi, ovviamente, non ti è permesso prendere decisioni di design scadenti, poiché questo avrà un impatto enorme sulle prestazioni.
Quindi: senza creatività, avrei costruito alcuni metodi con una catena infinita di JOINs, il che rende una query illeggibile e umabile, e molto probabilmente una garanzia di cattiva esecuzione.
Puoi vedere che lo scenario era piuttosto cupo.
La soluzione carina, ma non è affatto carina
Una soluzione che abbiamo sperimentato in un recente progetto è quella di seguire i principi CQRS e costruire un modello di lettura.
Ciò significa che avrei potuto eseguire un processo in background per de-normalizzare tutti i dati in enormi tabelle di lettura , in cui un filtro non richiede alcun join.
Ma anche questo ha i suoi svantaggi: l’aggiornamento del modello di lettura non è istantaneo e richiede alcune risorse.
Devi prendere tutti i possibili posti nel codice in cui i dati vengono aggiornati e attivare un evento per aggiornare il modello letto.
Oppure puoi farlo a livello di database, ma poi hai qualche logica esterna all’applicazione, il che potrebbe rivelarsi complicato a lungo termine. Anche alcune logiche degli aggiornamenti sono piuttosto complesse.
E devi prevenire più esecuzioni parallele.
Continuo a credere che CQRS sia un grande concetto, ma potrebbe richiedere del tempo per costruire un’implementazione utilizzabile.
La soluzione di lavoro
Come detto sopra, gran parte della complessità delle query era dovuta alla necessità di applicare filtri che erano distanti molti JOIN dalla tabella delle risorse richieste.
Quindi, come possibile evoluzione dell’idea delle tabelle di lettura, ho pensato di costruire solo una tabella di lettura indice .
In altre parole, avrei de-normalizzato tutte le relazioni e creato una o più tabelle con solo ID correlati da tabelle diverse. Quindi farei 2 query:
- il primo in questa tabella indice per ottenere tutti gli ID di un’entità correlati a un altro ID entità
- il secondo sulla tabella delle risorse richieste, usando questa lista di ID come una semplice
WHERE IN (...)
clausola
Ora stavo andando da qualche parte.
Ma prima di iniziare a costruire questo sistema, si è evoluto ancora di più in una serie di pensieri:
- Ho già sperimentato che molte piccole query possono essere più veloci di una singola query di grandi dimensioni.
- Quindi potrei usare molte tabelle di indice piccole invece di una singola grande.
- E poiché utilizzo molti piccoli indici, essi cambiano meno spesso e posso memorizzare ogni risultato in cache per un po ‘.
- E dal momento che memorizzo il risultato nella cache, perché creare tabelle? Posso semplicemente ottenere il risultato e memorizzarlo nella cache.
Inserisci il IndexService .
Il servizio dell’indice
Il servizio indice contiene una serie di metodi con una struttura comune. Questo è un esempio:
funzione pubblica getFooIdsByBar ($ barValue) { $ cacheValue = $ this-> cache-> getValue ('getFooIdsByBar'. $ barValue); if ($ cacheValue) { return $ cacheValue; } $ query = $ this-> connessione -> createQueryBuilder () -> seleziona ("f.id AS id") -> from ("foo", "f") -> innerJoin ("f", "softdeleted", "s", "f.id = s.id AND s.type = 'foo'") -> andWhere ("s.deleted = 0") -> innerJoin ("f", "relazioni", "r", "f.id = r.id AND r.type = 'foo'" -> andWhere ("r.relatedId = '$ barValue'") -> andWhere ("r.relatedType = 'bar') -> getSQL (); $ id = $ this-> getIds ($ query); $ This-> Cache> setValue ( 'getFooIdsByBar'. $ barValue, $ ids ); restituire $ id; }
Fondamentalmente, ogni metodo inizia controllando se la cache ha già il valore richiesto, e in tal caso lo restituisce.
In caso contrario, creare la query, ottenere il valore e memorizzarlo nella cache per un utilizzo futuro.
There are many methods in the service; some of them may even use other methods from the same service to get filters.
The advantages
- Readability
As a developer, the biggest win of this solution is that it each query in the application is really easy to understand. No more than a couple of JOINs in each query; most of them retrieve a single field which is the ID. Comparing to what I had to build at the start of the project, before realizing it was insane, this was a piece of cake.
- Cache-friendly
The really nice thing, though, is that you win also from a performance point of view. Each method is cached separately. That means you could easily set different expiration times for each method, according to how often that data is changed and/or how important it is to have a really up-to-date result.
- Database-friendly
Since each query is so small, it will be run very quickly without loading the database and – as I mentioned already – many small queries may be run in a shorter time than a single big one. But that is not all: you cache all results, so the database is even less used. Isn’t that great?
- Test-friendly
Many small and simple methods are definitely easier to test. The most difficult part was building the fixtures data 🙂
The disadvantages
Honestly, I did not find any so far.
Of course, if you have some wrong data in the response and if you want to debug why, you may have to follow a few methods calls. But on the other hand, all of these methods are simpler to debug and to test, so that evens out in the end.
Al momento non so se questo paradigma potrebbe essere applicato con successo ad altri sistemi. Questo era un caso molto particolare, con una struttura di database sottostante così complessa che era obbligatorio trovare una soluzione creativa.
Ma mi sentivo così bene mentre sviluppavo ed estendevo il sistema degli indici, che in qualche modo immagino che non sarà la sua ultima apparizione …