Kyselyt ja lukumallin projektiot
Erottakaa lukutoiminnot QueryBus-käsittelijöillä, joiden taustalla ovat optimoidut denormalisoidut projektiot.
Kyselyt ja lukumallin projektiot on ilmainen NestJS-yritysbackendien API:t-oppitunti CoddyKitissä. Tämä on oppitunti 2/4. Voit lukea koko oppitunnin alta ilmaiseksi ja harjoitella sen jälkeen käytännössä selaimessa sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla. Oppitunti kuuluu NestJS-yritysbackendien API:t-oppimispolkuun, ja edistymisesi synkronoituu verkon ja CoddyKit-sovelluksen välillä. NestJS-yritysbackendien API:t-kurssilla on yhteensä 4 oppituntia.
Miksi erillinen lukupuoli?
CQRS:ssä (Command Query Responsibility Segregation) järjestelmä jaetaan kirjoituspuoleen (tilaa muuttavat komennot) ja lukupuoleen (dataa palauttavat kyselyt). Näillä on perustavanlaatuisesti erilaiset tarpeet.
- Kirjoitusmalli optimoidaan johdonmukaisuutta ja liiketoiminnan invariantteja varten — se käyttää normalisoituja aggregaatteja.
- Lukumalli optimoidaan nopeita ja täsmällisen muotoisia lukuja varten — se käyttää kullekin näkymälle tai päätepisteelle räätälöityjä denormalisoituja projektioita.
Projektio on datasta etukäteen laskettu ja kyselyihin soveltuva näkymä, joka yleensä muodostetaan kuuntelemalla toimialuetapahtumia. Sen sijaan että kuusi taulua yhdistettäisiin pyynnön aikana, kyselykäsittelijä lukee yhden valmiiksi muotoillun rivin.
Kyselyt eivät ole komentoja
kysely on tavallinen DTO, joka kuvaa, mitä kutsuja haluaa lukea. Se ei sisällä käyttäytymistä eikä saa koskaan muuttaa tilaa. @nestjs/cqrs-kirjastossa kyselyt kulkevat QueryBus-väylän kautta vastaavalle @QueryHandler-käsittelijälle.
Pidä kyselyt vapaina toimialuesäännöistä. Niiden ainoa tehtävä on nimetä aikomus ja kuljettaa parametreja (tunnisteita, suodattimia ja sivutustietoja). Kaikki varsinainen työ tehdään käsittelijässä lukumallia vasten.
export class GetOrderSummaryQuery {
constructor(
public readonly orderId: string,
public readonly tenantId: string,
) {}
}
export class ListCustomerOrdersQuery {
constructor(
public readonly customerId: string,
public readonly page = 1,
public readonly pageSize = 20,
) {}
}QueryBus ja QueryHandler
@QueryHandler(SomeQuery)-luokka toteuttaa IQueryHandler<SomeQuery, Result>-rajapinnan ja tarjoaa execute()-metodin. Rekisteröikää käsittelijät moduulin providers-luetteloon ja lähettäkää kysely kutsulla queryBus.execute(new SomeQuery(...)).
Huomaatte, että käsittelijä lukee suoraan projektiotaulusta (tässä order_summary) — aggregaattia ei muodosteta uudelleen eikä tapahtumia toisteta pyynnön aikana.
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderSummaryView } from './order-summary.view';
import { GetOrderSummaryQuery } from './get-order-summary.query';
@QueryHandler(GetOrderSummaryQuery)
export class GetOrderSummaryHandler
implements IQueryHandler<GetOrderSummaryQuery, OrderSummaryView> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async execute(query: GetOrderSummaryQuery): Promise<OrderSummaryView> {
const row = await this.repo.findOne({
where: { orderId: query.orderId, tenantId: query.tenantId },
});
if (!row) throw new Error('Order summary not found');
return row;
}
}Projektion muodon suunnittelu
Projektio on tarkoituksella denormalisoitu. Dataa monistetaan, jotta luku voidaan tehdä yhden rivin ja yhden taulun haulla. Suunnittele muoto kuluttajan (päätepisteen tai käyttöliittymän) tarpeiden, ei toimialuemallin, perusteella.
- Litistä suhteet: tallenna asiakkaan nimi tilauksen yhteenvetorivin sisään.
- Laske summat, lukumäärät ja tunnisteet etukäteen, jotta API:n ei tarvitse tehdä lainkaan laskutoimituksia.
- Lisää kyselyn tarvitsemat indeksit (esim.
(tenantId, customerId, placedAt)).
Tämä entiteetti vastaa vain lukuun tarkoitettua taulua, johon kirjoituspuoli ei koske suoraan.
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
@Entity('order_summary')
@Index(['tenantId', 'customerId', 'placedAt'])
export class OrderSummaryView {
@PrimaryColumn('uuid')
orderId: string;
@Column('uuid')
tenantId: string;
@Column('uuid')
customerId: string;
@Column()
customerName: string; // denormalized copy
@Column('int')
lineItemCount: number; // precomputed
@Column('numeric', { precision: 12, scale: 2 })
totalAmount: string;
@Column()
status: string;
@Column('timestamptz')
placedAt: Date;
}Projektioiden muodostaminen tapahtumista
Projektioita pitävät ajan tasalla projektorit — tapahtumakäsittelijät, jotka muuntavat toimialuetapahtumat lukutaulun upsert-operaatioiksi. @nestjs/cqrs-kirjastossa projektori on @EventsHandler.
Jokainen tapahtuma muuttaa täsmälleen niitä sarakkeita, joihin se vaikuttaa. Projektori on projektiotaulun ainoa kirjoittaja, mikä selkeyttää omistajuutta ja estää ristiriidat komentopuolen kanssa.
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderPlacedEvent } from '../events/order-placed.event';
import { OrderSummaryView } from './order-summary.view';
@EventsHandler(OrderPlacedEvent)
export class OrderPlacedProjector
implements IEventHandler<OrderPlacedEvent> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async handle(event: OrderPlacedEvent): Promise<void> {
await this.repo.upsert(
{
orderId: event.orderId,
tenantId: event.tenantId,
customerId: event.customerId,
customerName: event.customerName,
lineItemCount: event.lines.length,
totalAmount: event.total,
status: 'PLACED',
placedAt: event.occurredAt,
},
['orderId'],
);
}
}Tapahtumakohtaiset inkrementaaliset päivitykset
Useimmat tapahtumat eivät rakenna koko riviä uudelleen, vaan päivittävät siitä vain osan. OrderShippedEvent muuttaa ainoastaan tilan ja merkitsee lähetyspäivän. Pidä projektorit pieninä ja tapahtumakohtaisina.
Koska projektori omistaa taulun, perusavaimella tehtävä UPDATE on edullinen eikä aiheuta ristiriitoja. Idempotenssi on tässä tärkeää — saman tapahtuman toistaminen ei saa rikkoa riviä (tästä lisää pian).
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderShippedEvent } from '../events/order-shipped.event';
import { OrderSummaryView } from './order-summary.view';
@EventsHandler(OrderShippedEvent)
export class OrderShippedProjector
implements IEventHandler<OrderShippedEvent> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async handle(event: OrderShippedEvent): Promise<void> {
await this.repo.update(
{ orderId: event.orderId },
{ status: 'SHIPPED' },
);
}
}Lopullinen johdonmukaisuus on kompromissi
Kun lukumalli päivitetään asynkronisesti komennon vahvistamisen jälkeen, projektio on lopulta johdonmukainen. Lyhyen aikaa kysely voi palauttaa vanhentuneita tietoja — esimerkiksi juuri luotu tilaus ei ehkä vielä näy yhteenvetoluettelossa.
- Hyväksykää tämä koontinäytöissä, luetteloissa, raporteissa ja hauissa, joissa pieni viive on hyväksyttävä.
- Lieventäkää vaikutusta käyttöliittymässä optimistisilla päivityksillä tai palauttakaa uusi tunniste komennosta ja antakaa asiakkaan kysellä lukupuolta toistuvasti.
- Kun lukujen on nähtävä omat kirjoitukset välittömästi, kyselkää suoraan kirjoitusmallista tai päivittäkää projektio synkronisesti saman transaktion sisällä.
Dokumentoikaa johdonmukaisuustakuu kullekin päätepisteelle, jotta kuluttajat tietävät, mitä odottaa.
Idempotentit projektorit
Tapahtumat toimitetaan yleensä vähintään kerran, joten projektori voi vastaanottaa saman tapahtuman kahdesti. Tehkää käsittelijöistä idempotentteja, jotta uudelleenkäsittely on harmitonta.
- Käyttäkää avaimen perusteella tehtävää
upsert- taiUPDATE-operaatiota sokeanINSERT-operaation sijaan. - Seuratkaa kunkin projektion viimeksi käsiteltyä tapahtumasijaintia (tarkistuspistettä) ja ohittakaa kaikki jo nähdyt tapahtumat.
- Välttäkää suhteellista laskentaa, kuten
count = count + 1, ellette samalla poista tapahtumien kaksoiskappaleita tapahtumatunnisteen perusteella.
Tämä pieni apufunktio näyttää kaksoiskappaleiden poistamisen idean puhtaassa TypeScriptissä: tarkistuspistejoukko estää soveltamisen kahdesti.
type Event = { id: string; type: string; orderId: string };
class IdempotentProjection {
private processed = new Set<string>();
private rows = new Map<string, { orderId: string; status: string }>();
apply(event: Event): boolean {
if (this.processed.has(event.id)) return false; // already seen
this.processed.add(event.id);
const row = this.rows.get(event.orderId) ?? { orderId: event.orderId, status: 'NEW' };
if (event.type === 'OrderShipped') row.status = 'SHIPPED';
this.rows.set(event.orderId, row);
return true;
}
status(orderId: string): string | undefined {
return this.rows.get(orderId)?.status;
}
}
const p = new IdempotentProjection();
const e = { id: 'evt-1', type: 'OrderShipped', orderId: 'ord-9' };
console.log(p.apply(e)); // true -> applied
console.log(p.apply(e)); // false -> duplicate ignored
console.log(p.status('ord-9')); // SHIPPEDSivutus ja suodatus lukupuolella
Luettelopäätepisteet kuuluvat kokonaan lukumalliin. Koska projektio on jo litteä ja indeksoitu, sivutus ja suodatus ovat yksinkertaisia WHERE + LIMIT/OFFSET- (tai keyset-)kyselyjä — ei liitoksia eikä N+1-ongelmaa.
Palauttakaa pieni sivu-DTO, joka sisältää kohteet ja kokonaismäärän. Pitäkää lajittelu indeksoiduissa sarakkeissa, jotta tietokanta voi tuottaa järjestyksen ilman filesortia.
@QueryHandler(ListCustomerOrdersQuery)
export class ListCustomerOrdersHandler
implements IQueryHandler<ListCustomerOrdersQuery> {
constructor(
@InjectRepository(OrderSummaryView)
private readonly repo: Repository<OrderSummaryView>,
) {}
async execute(q: ListCustomerOrdersQuery) {
const [items, total] = await this.repo.findAndCount({
where: { customerId: q.customerId },
order: { placedAt: 'DESC' },
take: q.pageSize,
skip: (q.page - 1) * q.pageSize,
});
return { items, total, page: q.page, pageSize: q.pageSize };
}
}Kytkeminen kontrolleriin
Kontrollerit pysyvät ohuina: muuntakaa HTTP-pyyntö kyselyksi ja välittäkää se QueryBus-väylälle. Kontrollerissa ei ole liiketoimintalogiikkaa eikä repositoryjen käyttöä.
Näin siirtokerros pysyy irrotettuna lukujen toteutustavasta. Projektion tallennus voidaan myöhemmin vaihtaa (Postgres → Elasticsearch) muuttamatta kontrolleria.
import { Controller, Get, Param, Query } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GetOrderSummaryQuery } from './get-order-summary.query';
import { ListCustomerOrdersQuery } from './list-customer-orders.query';
@Controller('orders')
export class OrdersQueryController {
constructor(private readonly queryBus: QueryBus) {}
@Get(':id/summary')
getSummary(@Param('id') id: string, @Query('tenantId') tenantId: string) {
return this.queryBus.execute(new GetOrderSummaryQuery(id, tenantId));
}
@Get()
list(@Query('customerId') customerId: string, @Query('page') page = 1) {
return this.queryBus.execute(
new ListCustomerOrdersQuery(customerId, Number(page)),
);
}
}Projektioiden uudelleenrakentaminen
Tapahtumapohjaisten lukumallien suuri etu on, että projektio voidaan rakentaa uudelleen alusta alkaen toistamalla tapahtumavirta. Näin lukumuotoa voi muuttaa, projektorin virheen korjata tai kokonaan uuden näkymän lisätä ilman vanhojen tietojen manuaalista siirtämistä.
- Tyhjennä projektiotaulu (tai versioi se).
- Toista kaikki asiaankuuluvat tapahtumat projektorin kautta järjestyksessä.
- Seuraa tarkistuspistettä, jotta voit jatkaa keskeytyksen jälkeen ja siirtää lukijat uuteen versioon, kun se on ajan tasalla.
Blue/green-projektioiden kaltaiset strategiat rakentavat uuden version vanhan rinnalle ja vaihtavat lukijat sitten atomisesti — näin lukumallin migraatio onnistuu ilman käyttökatkoa.
Pikatarkistus: nopean luetteloluvun tarjoaminen
Tarvitsette paljon liikennettä vastaanottavan päätepisteen, joka luettelee asiakkaan tilaukset ja näyttää kullakin rivillä asiakkaan nimen, summan ja nimikemäärän. Tiedot ovat normalisoiduissa orders-, order_lines- ja customers-tauluissa. Lukuja on huomattavasti enemmän kuin kirjoituksia, ja pieni vanhentuneisuus on hyväksyttävää.
Mikä CQRS-lähestymistapa on sopivin?
Yhteenveto
Erotitte lukemiset kirjoituksista CQRS:n kyselypuolen avulla:
- Kyselyt ovat käyttäytymistä sisältämättömiä DTO-objekteja, jotka välitetään
QueryBus-väylän kautta@QueryHandler-luokille. - Projektiot ovat denormalisoituja ja indeksoituja lukutauluja, jotka on muotoiltu kuluttajaa varten ja joita projektorit (
@EventsHandler) hallitsevat ja päivittävät reagoimalla toimialuetapahtumiin. - Asynkroninen projektio tuo mukanaan eventual consistency -mallin eli lopullisen yhdenmukaisuuden – se sopii erinomaisesti listoihin ja koontinäyttöihin. Kun tarvitaan read-your-writes-käyttäytymistä, käsitelkää se tietoisesti.
- Projektorien on oltava idempotentteja (upsert avaimen perusteella, tarkistuspisteet), koska toimitus tapahtuu vähintään kerran.
- Lukumallit voidaan rakentaa uudelleen tai siirtää toistamalla tapahtumat, mikä mahdollistaa blue/green-julkaisut ja näkymien muutokset ilman käyttökatkoa.
Hyötynä on, että luvuista tulee yhden rivin ja yhden taulun hakuja – nopeita, skaalautuvia ja irrotettuja kirjoituspuolen aggregaateista.
Opi TypeScript tekoälytuutorin avulla — ilmaiseksi
Kirjoita ja suorita oikeaa koodia selaimessa, saa välitöntä apua tekoälytuutorilta ympäri vuorokauden ja jatka siitä, mihin jäit, verkossa tai sovelluksessa.
- Kurssit
- 20
- Oppitunnit
- 76
Usein kysytyt kysymykset
Onko oppitunti ”Kyselyt ja lukumallin projektiot” ilmainen?
Kyllä – oppitunnin ”Kyselyt ja lukumallin projektiot” koko tekstin voi lukea täällä verkossa ilmaiseksi. Jos haluat harjoitella interaktiivisesti sisäänrakennetulla koodieditorilla ja ympäri vuorokauden käytettävissä olevan tekoälytuutorin avulla sekä avata koko NestJS-yritysbackendien API:t-kurssin, päivitä CoddyKit PROhon. NestJS-yritysbackendien API:t-kurssilla on yhteensä 4 oppituntia.
Mitä opin oppitunnilla ”Kyselyt ja lukumallin projektiot”?
Erottakaa lukutoiminnot QueryBus-käsittelijöillä, joiden taustalla ovat optimoidut denormalisoidut projektiot. Harjoittelet NestJS-yritysbackendien API:t-aihetta koodilla, jonka suoritat suoraan selaimessa. Ympäri vuorokauden käytettävissä oleva tekoälytuutori vastaa kysymyksiisi oppitunnin aikana.
Tarvitsenko kokemusta aloittaakseni NestJS-yritysbackendien API:t-opiskelun?
Aiempi kokemus ei ole tarpeen. CoddyKitin NestJS-yritysbackendien API:t-oppimispolku sopii vasta-alkajista edistyneisiin, joten voit aloittaa tästä tai alusta ja edetä omaan tahtiisi. Tämä on oppitunti 2/4.
Kuinka kauan ”Kyselyt ja lukumallin projektiot”-oppitunnin suorittaminen kestää?
Useimmat CoddyKitin oppitunnit kestävät noin 5–10 minuuttia. Jokainen oppitunti on lyhyt ja interaktiivinen, joten edistyt tasaisesti ja voit jatkaa siitä, mihin jäit – sekä verkossa että sovelluksessa.
Voinko kirjoittaa ja suorittaa koodia tällä NestJS-yritysbackendien API:t-oppitunnilla?
Kyllä. Jokainen NestJS-yritysbackendien API:t-oppitunti sisältää sisäänrakennetun koodieditorin, joten voit kirjoittaa ja suorittaa oikeaa koodia suoraan selaimessa ja saada välitöntä palautetta tekoälyltä – paikallista asennusta ei tarvita.
Kaikki tämän kurssin oppitunnit
- Komennot, käsittelijät ja komentoväylä
- Kyselyt ja lukumallin projektiot
- Toimialuetapahtumat ja AggregateRoot
- Pitkäkestoisten työnkulkujen sagat