Meerdere schema's gebruiken in Spring Boot: een praktische gids voor productieve en testbare code

Toen we bezig waren met het uitfaseren van een van onze databasesystemen, stonden we voor de taak om gegevens te migreren naar onze MySQL Database. De gegevens kwamen echter niet overeen met onze huidige bedrijfsgegevens/model die in de database waren opgeslagen. Dit bracht ons tot een beslissing: een geheel nieuwe MySQL-instantie creëren of een nieuw schema opstellen binnen de bestaande database.

De keuze voor een nieuwe database werd ingegeven door de complexiteit van het opzetten van een nieuwe verbinding en configuratie. In dit artikel delen we onze aanpak voor het beheren van verbindingen met verschillende schema's binnen dezelfde MySQL database. Zonder in te gaan op de redenen om MySQL te kiezen boven potentieel geschiktere opties voor gegevensopslag, richten we ons op de praktische aspecten van het effectief omgaan met meerdere schema's.

Configuratie voor de verbinding

Voor het maken van een verbinding met een MySQL instantie wordt meestal gebruik gemaakt van een verbindings URL. De URL-structuur ziet er gewoonlijk als volgt uit:

 url: jdbc:mysql://<url>:<port>/<schemaName>?<additionalSettings> 

Gelukkig hoefden we niets te veranderen in de URL van de verbinding. De <schemaName> parameter in de URL definieerde ons bestaande schema, dat we omwille van de eenvoud het standaardschema zullen noemen (ook al is het technisch gezien niet het standaardschema). Laten we aannemen dat het standaardschema de naam "schema1" draagt, terwijl het nieuwe schema de naam "schema2" draagt. Tenzij anders gespecificeerd, worden al onze entiteiten opgeslagen in schema1.

Aangezien er geen nieuwe databaseverbinding nodig is, is het configureren van elementen zoals de TransactionManager of de Hikari Connection Pool niet nodig. Het is cruciaal om hier rekening mee te houden, omdat het enkele neveneffecten met zich mee kan brengen die onopgemerkt kunnen blijven (bijvoorbeeld queries naar schema2 die dezelfde Hikari Connection Pool gebruiken als queries naar schema1).

Implementatie voor het maken van schema's en het toevoegen van tabellen

Ten eerste moet je een nieuw schema in je database maken, een taak die je ofwel handmatig kunt uitvoeren of met een tool voor database wijzigingsbeheer. In ons geval kozen we voor Liquibase. Liquibase heeft geen vooraf geconfigureerde tag voor het maken van schema's, waardoor we een aangepast SQL-script moesten schrijven:

   <changeSet author="kai" id="20231010210000-1">
        <sql>
            create schema schema2;
        </sql>
    </changeSet>

Na het succesvol aanmaken van het nieuwe schema, is de volgende taak om er nieuwe tabellen aan toe te voegen. Dit is een verschuiving van onze gebruikelijke workflow die zich uitsluitend concentreerde op schema1.


    <createTable schemaName="schema2" tableName="first_table_in_new_schema">
        <column name="id" type="bigint" autoIncrement="true">
            <constraints primaryKey="true" nullable="false"/>
        </column>
        <column name="column_1" type="bigint">
            <constraints nullable="true"/>
        </column>
        ...
    </createTable>

In vette letters heb ik het cruciale deel gemarkeerd dat aangeeft hoe je Liquibase kunt instrueren om de gewenste wijzigingen in een specifiek schema door te voeren. Dit is in wezen alles wat nodig is om Liquibase ons nieuwe schema te laten gebruiken. Als we niet specificeren schemaNaamLiquibase gebruikt standaard het oorspronkelijke schema (schema1).

Schemaverklaring op een JPA-eenheid

Om het schema op een JPA-entiteit te specificeren, moeten we de @Table annotatie als volgt wijzigen:

 @Entiteit
 @Table(naam = "first_table_in_new_schema", catalogus = "schema2")
 openbare klasse FirstTableInNewSchema {
 ...
 }  

Het is essentieel om op te merken dat we hiervoor het veld catalogus gebruiken.

Hoewel er ook een schema veld is in @Table, is het bij het werken met MySQL cruciaal om catalogus te gebruiken. Dit omvat in wezen alle noodzakelijke stappen. Als je de overeenkomstige opslagplaats voor de bovenstaande entiteit gebruikt, wordt het nieuwe schema automatisch voorafgegaan door de tabelnaam.

End-to-end testen met H2 Database

Alles wat hierboven is genoemd werkt naadloos... totdat je de noodzaak tegenkomt om je end-to-end tests uit te voeren met een ander databasesysteem dan MySQL. Hoewel sommigen misschien pleiten voor het gebruik van MySQL testcontainers om ervoor te zorgen dat er getest wordt met dezelfde onderliggende database, hebben wij ervoor gekozen om deze uitdaging aan te gaan met behulp van onze bestaande H2 Database.



Om de productiedatabase nauwkeurig te repliceren, gebruiken we identieke Liquibase-scripts voor zowel onze test- als productieomgevingen. De uitdaging ontstaat doordat H2 het gebruik van het schemaveld in de @Table-annotatie verplicht stelt in plaats van het catalogusveld. Simpel, toch? Neem gewoon beide annotaties op in de entiteit:

 @Entiteit
 @Table(naam = "first_table_in_new_schema", schema = "schema2", catalogus = "schema2")
 openbare klasse FirstTableInNewSchema {
 ...
 }

Deze aanpak mislukt echter en u zult een schema validatiefout zien tijdens het opstarten:

 Veroorzaakt door: org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validatie: ontbrekende tabel [schema2.schema2.first_table_in_new_schema].

Om dit op te lossen moeten we het catalogusveld verwijderen bij het opstarten van de End-to-End Test. Spring/Hibernate gebruikt de interface PhysicalNamingStrategy om effectieve namen voor tabellen en velden te maken. Er zijn een paar standaard implementaties die bijvoorbeeld underscore kunnen vervangen door punten, of camelcase kunnen veranderen in snakecase. We hebben de SpringPhysicalNamingStrategy gebruikt, overridden met een aangepaste naamgevingsstrategie, alleen voor E2E-tests. Deze strategie verwijdert de waarde van het catalogusveld. In het bestand application.yml voor de E2E-tests hebben we Spring/Hibernate geconfigureerd om onze aangepaste naamgevingsstrategie te gebruiken:

 lente:
  jpa:
    hibernate:
      naming:
        physical-strategy: path.to.config.CustomPhysicalNamingStrategy

De CustomPhysicalNamingStrategy ziet er als volgt uit:

 @Configuration
 @Profile("test")
 public class CustomPhysicalNamingStrategy extends SpringPhysicalNamingStrategy {

    @Override
    public Identifier toPhysicalCatalogName(Identifier name, JdbcEnvironment jdbcEnvironment) {
        if (name != null) {
            return super.toPhysicalCatalogName(null, jdbcEnvironment);
        }
        return super.toPhysicalCatalogName(name, jdbcEnvironment);
    }
 }

Uitleg van annotaties en gebruikte code:

@ConfiguratieNodig om de bean te initialiseren bij het opstarten van de toepassing.

@Profiel("test")Specificeert dat deze bean alleen wordt geïnitialiseerd wanneer de applicatie start met het testprofiel (in ons geval tijdens E2E-tests). De CustomPhysicalNamingStrategy moet uitsluitend worden gebruikt in E2E-tests, zoals geconfigureerd in de application.yml. Deze annotatie voorkomt dat de bean wordt geïnitialiseerd buiten een E2E-test.

toPhysicalCatalogNameDeze overridden methode controleert of de catalogusnaam is ingesteld. Als dat zo is, stellen we het in op nul tijdens het opstarten, waardoor het catalogusveld in de @Table annotatie wordt "verwijderd" als het is ingesteld.

Informatie over transactiebeheer in meerdere schema's

Omdat we dezelfde connectie URL gebruiken voor beide schema's, worden alle database configuraties gedeeld, zoals eerder vermeld. Dit houdt in dat wanneer je werkt binnen een @Transactionele context, de transactie zich uitstrekt over beide schema's als er wijzigingen worden aangebracht in beide. Daarom, als je schrijft naar het standaard schema en ook naar schema2 in één transactie, en er treedt een probleem op tijdens het schrijven naar schema2, dan wordt de hele transactie teruggedraaid. Zelfs als er geen fouten optraden tijdens het schrijven naar schema1, worden de wijzigingen ook teruggedraaid.

Kai Müller
Software-ingenieur