NestJS-API-er for virksomhetsbackend · leksjon

Databasetilkoblinger med ett skjema per tenant

Bytt databaseskjema eller tilkobling dynamisk basert på den oppløste tenanten.

Leksjon 2 av 413 trinn

Databasetilkoblinger med ett skjema per tenant er en gratis leksjon i NestJS-API-er for virksomhetsbackend på CoddyKit. Dette er leksjon 2 av 4. Du kan lese hele leksjonen gratis nedenfor – og deretter øve praktisk i nettleseren med en innebygd kodeeditor og en AI-veileder som er tilgjengelig døgnet rundt. Den er en del av læringsløpet i NestJS-API-er for virksomhetsbackend, og fremdriften din synkroniseres mellom nettet og CoddyKit-appen. Kurset i NestJS-API-er for virksomhetsbackend inneholder totalt 4 leksjoner.

Schema per tenant: Det store bildet

I et API med flere tenanter må dataene til hver tenant holdes isolert. Modellen schema per tenant beholder én fysisk database, men gir hver tenant sitt eget PostgreSQL-schema (for eksempel tenant_acme og tenant_globex). Tabellene har identisk struktur på tvers av schemaene.

  • Tabellsett (delt schema): ett sett med tabeller, med isolasjon via en tenant_id-kolonne. Enkelt, men lekkasjer er bare én manglende WHERE-betingelse unna.
  • Schema per tenant: sterkere isolasjon og enkel sikkerhetskopiering per tenant, men De må bytte aktivt schema for hver forespørsel.
  • Database per tenant: maksimal isolasjon, men høyest driftskostnad.

Denne leksjonen fokuserer på å rute hver forespørsel dynamisk til riktig schema eller tilkobling etter at tenant er fastslått.

Fastslå tenant for hver forespørsel

Før De kan bytte schema, trenger De tenant. Den fastslås vanligvis fra et subdomene, en header eller et JWT-claim. En lettvekts-middleware trekker den ut og legger den på forespørselen, slik at providers lenger ned i kjeden kan lese den.

Hold fastslåingen enkel og billig her; validering av om tenanten finnes, skjer når De oppretter tilkoblingen.

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  use(req: Request, _res: Response, next: NextFunction) {
    // Prefer an explicit header; fall back to subdomain.
    const headerTenant = req.headers['x-tenant-id'] as string | undefined;
    const host = req.headers.host ?? '';
    const subdomain = host.split('.')[0];

    const tenantId = headerTenant ?? subdomain;
    (req as any).tenantId = tenantId;
    next();
  }
}

Hvorfor REQUEST-scope er det naturlige valget

Det aktive schemaet endres ved hver forespørsel. NestJS-providers er singletoner som standard, så en singleton kan ikke trygt holde på et schema per forespørsel. Den ryddige løsningen er en request-scoped provider som mottar den gjeldende REQUEST.

  • Scope.REQUEST oppretter en ny provider-instans for hver innkommende forespørsel.
  • Alle providers som injiserer en request-scoped provider, blir også request-scopede (dette forplanter seg oppover i kjeden).
  • Avveining: instansiering per forespørsel har en kostnad, så hold request-scopede providers små og bufre de tunge delene (tilkoblingene) utenfor.

Bytte schema med SET search_path

Den enkleste måten å bytte schema på én Postgres-tilkobling er SET search_path. Den angir hvilket schema Postgres skal slå opp ukvalifiserte tabellnavn i resten av sesjonen.

Viktig forbehold: med en tilkoblingspool kan en tilkobling bli gitt til en annen tenants forespørsel etterpå. De må avgrense byttet til arbeidet og tilbakestille det, eller bruke en transaksjonslokal variant.

import { DataSource } from 'typeorm';

export async function withTenantSchema<T>(
  dataSource: DataSource,
  schema: string,
  work: () => Promise<T>,
): Promise<T> {
  const runner = dataSource.createQueryRunner();
  await runner.connect();
  try {
    // SET LOCAL is transaction-scoped and auto-resets on commit/rollback.
    await runner.startTransaction();
    await runner.query('SET LOCAL search_path TO $1', [schema]);
    const result = await work();
    await runner.commitTransaction();
    return result;
  } catch (err) {
    await runner.rollbackTransaction();
    throw err;
  } finally {
    await runner.release();
  }
}

Rense schemanavnet

Schemanavn er identifikatorer, ikke verdier. De kan ikke parameterisere en identifikator trygt i SET search_path på samme måte som data. En ondsinnet eller ugyldig tenant-id kan bli SQL-injeksjon.

Valider alltid det fastslåtte schemaet mot et strengt mønster for tillatte verdier (og helst også mot et register over kjente tenanter) før De setter det inn i en streng.

const SCHEMA_PATTERN = /^[a-z][a-z0-9_]{1,62}$/;

export function tenantSchema(tenantId: string): string {
  const candidate = `tenant_${tenantId.toLowerCase()}`;
  if (!SCHEMA_PATTERN.test(candidate)) {
    throw new Error(`Invalid tenant schema: ${candidate}`);
  }
  return candidate;
}

console.log(tenantSchema('Acme'));      // tenant_acme
try {
  tenantSchema('acme; DROP SCHEMA x');  // throws
} catch (e) {
  console.log((e as Error).message);
}

Tilkoblinger per tenant i stedet for search_path

Et alternativ til å endre search_path i en delt pool er å beholde en dedikert DataSource (og pool) per tenants schema. Hver DataSource konfigureres én gang med sitt schema og gjenbrukes.

  • Fordel: ingen endring av schema per forespørsel og ingen risiko for sammenblanding på tvers av poolen.
  • Ulempe: antallet tilkoblinger øker med antallet aktive tenanter – De må begrense poolstørrelsene og kaste ut inaktive tenanter.

Det er her en tilkoblingsadministrator som bygger og bufrer DataSources ved behov, virkelig kommer til sin rett.

En tilkoblingsadministrator for tenanter

Administratoren eier livssyklusen: bygg en DataSource første gang en tenant registreres, bufre den og gjenbruk den deretter. Den er en singleton – bare oppslaget skjer per forespørsel, mens de tunge tilkoblingene deles trygt fordi hver av dem er bundet til sitt eget schema.

Merk at cache-nøkkelen er schemaet, og at samtidige førstegangsoppslag ikke må bygge to ganger (lagre promise-objektet, ikke bare den ferdig oppløste verdien).

import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';

@Injectable()
export class TenantConnectionManager {
  private readonly pools = new Map<string, Promise<DataSource>>();

  get(schema: string): Promise<DataSource> {
    let pool = this.pools.get(schema);
    if (!pool) {
      pool = this.build(schema);
      this.pools.set(schema, pool); // cache the promise to dedupe races
    }
    return pool;
  }

  private async build(schema: string): Promise<DataSource> {
    const ds = new DataSource({
      type: 'postgres',
      url: process.env.DATABASE_URL,
      schema,
      entities: [__dirname + '/**/*.entity.{ts,js}'],
      poolSize: 5,
    });
    await ds.initialize();
    return ds;
  }
}

Eksponere tenantens DataSource som en provider

Koble nå inn en request-scoped factory-provider som leser tenant fra REQUEST, beregner schemaet og ber administratoren om riktig DataSource. Tjenester injiserer dette tokenet i stedet for en fast tilkobling.

Fordi factoryen injiserer REQUEST, er provideren request-scopet – men administratoren den kaller, er en singleton, så gjenbruk av tilkoblinger bevares.

import { Scope, Provider } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { DataSource } from 'typeorm';

export const TENANT_DATA_SOURCE = 'TENANT_DATA_SOURCE';

export const tenantDataSourceProvider: Provider = {
  provide: TENANT_DATA_SOURCE,
  scope: Scope.REQUEST,
  inject: [REQUEST, TenantConnectionManager],
  useFactory: (req: Request, manager: TenantConnectionManager): Promise<DataSource> => {
    const tenantId = (req as any).tenantId as string | undefined;
    if (!tenantId) {
      throw new Error('No tenant resolved for this request');
    }
    const schema = tenantSchema(tenantId);
    return manager.get(schema);
  },
};

Bruke tenantens DataSource i en tjeneste

En request-scoped tjeneste injiserer den fastslåtte DataSource-en via et token. Alle spørringene den kjører, går allerede mot riktig schema – det finnes ingen tenant_id-filter og ingen manuell endring av schema i forretningskoden.

Factoryen returnerer en Promise<DataSource>, så bruk await på den (eller la factoryen vente før den returnerer) før De åpner repositories.

import { Inject, Injectable, Scope } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Invoice } from './invoice.entity';

@Injectable({ scope: Scope.REQUEST })
export class InvoiceService {
  constructor(
    @Inject(TENANT_DATA_SOURCE) private readonly dataSource: DataSource,
  ) {}

  findAll(): Promise<Invoice[]> {
    // Already bound to tenant_<x> schema — no tenant filter needed.
    return this.dataSource.getRepository(Invoice).find();
  }
}

Dynamiske moduler for konfigurerbar tenancy

Gjenbrukbar tenancy-logikk hører hjemme i en dynamisk modul, slik at apper kan konfigurere strategi for fastslåing, schemaprefiks og poolstørrelse via forRoot/forRootAsync. Modulen eksporterer administratoren og DataSource-provideren med request-scope.

Dette er kombinasjonen av multi-tenancy og dynamiske moduler: konfigurasjonen er statisk (settes én gang ved oppstart), mens den fastslåtte tilkoblingen er dynamisk (per forespørsel).

import { DynamicModule, Module } from '@nestjs/common';

export interface TenancyOptions {
  schemaPrefix: string;
  poolSize: number;
}

@Module({})
export class TenancyModule {
  static forRoot(options: TenancyOptions): DynamicModule {
    return {
      module: TenancyModule,
      global: true,
      providers: [
        { provide: 'TENANCY_OPTIONS', useValue: options },
        TenantConnectionManager,
        tenantDataSourceProvider,
      ],
      exports: [TenantConnectionManager, TENANT_DATA_SOURCE],
    };
  }
}

Livssyklus, utkastelse og migreringer

Pooler per tenant er en ressurslekkasje som bare venter på å oppstå. Administrer dem bevisst:

  • Begrens samtidighet: liten poolSize per tenant; mange tenanter × store pooler bruker opp Postgres-max_connections.
  • Kast ut inaktive tenanter: registrer tidspunktet for siste bruk, og kall destroy() på DataSources som blir inaktive (en LRU holder minnebruken begrenset).
  • Avslutt ryddig: implementer OnModuleDestroy for å lukke alle bufrede DataSources.
  • Migreringer: en ny tenant innebærer CREATE SCHEMA og at migreringer kjøres mot schemaet før første bruk; kjør migreringene gjennom alle tenantschemaer ved utrulling.

Behandle klargjøring av schema som et uttrykkelig onboarding-trinn, aldri som en utilsiktet følge av den første spørringen.

Kort kontroll: Unngå lekkasjer på tvers av tenanter

De bytter schema ved hjelp av SET search_path på tilkoblinger som lånes fra en delt TypeORM-pool. Av og til ser tenant A radene til tenant B. Hva er den mest sannsynlige årsaken, og hva er riktig løsning?

Oppsummering: Dynamisk schemaruting

De har lært å rute hver forespørsel til riktig tenantschema:

  • Fastslå tenant tidlig (header, subdomene eller JWT) i middleware, og legg den på forespørselen.
  • Bytt schema enten via transaksjonsavgrenset SET LOCAL search_path i en delt pool, eller via en dedikert DataSource per tenant som bufres i en singleton-administrator.
  • Koble inn en factory-provider med request-scope som leser REQUEST, validerer schemanavnet og returnerer riktig DataSource – tjenestene forblir uavhengige av tenant.
  • Konfigurer alt gjennom en dynamisk modul (forRoot), slik at konfigurasjonen forblir statisk mens tilkoblingen er dynamisk.
  • Drift sikkert: valider identifikatorer, begrens og kast ut pooler, lukk dem ved avslutning, og klargjør/migrer schemaer som et uttrykkelig onboarding-trinn.
Gratis å komme i gang

Lær deg TypeScript med en AI-veileder – gratis

Skriv og kjør ekte kode i nettleseren, få umiddelbar hjelp fra en AI-veileder som er tilgjengelig døgnet rundt, og fortsett der du slapp – på nettet eller i appen.

Kurs
20
Leksjoner
76

Ofte stilte spørsmål

Er leksjonen «Databasetilkoblinger med ett skjema per tenant» gratis?

Ja – hele teksten i «Databasetilkoblinger med ett skjema per tenant» er gratis å lese her på nettet. For å øve interaktivt med en innebygd kodeeditor og en AI-veileder som er tilgjengelig døgnet rundt, og for å låse opp resten av NestJS-API-er for virksomhetsbackend-kurset, kan du oppgradere til CoddyKit PRO. Kurset i NestJS-API-er for virksomhetsbackend inneholder totalt 4 leksjoner.

Hva lærer jeg i «Databasetilkoblinger med ett skjema per tenant»?

Bytt databaseskjema eller tilkobling dynamisk basert på den oppløste tenanten. Du øver på NestJS-API-er for virksomhetsbackend med praktisk kode som du kjører direkte i nettleseren, mens en AI-veileder som er tilgjengelig døgnet rundt, svarer på spørsmålene dine mens du jobber deg gjennom leksjonen.

Trenger jeg erfaring for å begynne med NestJS-API-er for virksomhetsbackend?

Ingen tidligere erfaring er nødvendig. NestJS-API-er for virksomhetsbackend på CoddyKit er lagt opp for både nybegynnere og viderekomne, så De kan begynne her eller helt fra start og lære i Deres eget tempo. Dette er leksjon 2 av 4.

Hvor lang tid tar leksjonen «Databasetilkoblinger med ett skjema per tenant»?

De fleste CoddyKit-leksjoner tar omtrent 5–10 minutter. Hver leksjon er kort og interaktiv, slik at De gjør jevne fremskritt og kan fortsette akkurat der De slapp – både på nettet og i appen.

Kan jeg skrive og kjøre kode i denne NestJS-API-er for virksomhetsbackend-leksjonen?

Ja. Alle NestJS-API-er for virksomhetsbackend-leksjoner har en innebygd kodeeditor, slik at De kan skrive og kjøre ekte kode direkte i nettleseren og få umiddelbar tilbakemelding fra AI – uten lokal konfigurering.

Alle leksjonene i dette kurset

  1. Oppløsning av tenant via middleware og AsyncLocalStorage
  2. Databasetilkoblinger med ett skjema per tenant
  3. Bygging av konfigurerbare dynamiske moduler
  4. Forespørselsavgrensede providere og avveiningene deres
← Tilbake til NestJS-API-er for virksomhetsbackend