Come creare un'applicazione multitenant: un tutorial di ibernazione

Pubblicato: 2022-03-11

Quando parliamo di applicazioni cloud in cui ogni cliente ha i propri dati separati, dobbiamo pensare a come archiviare e manipolare questi dati. Anche con tutte le fantastiche soluzioni NoSQL disponibili, a volte abbiamo ancora bisogno di usare il buon vecchio database relazionale. La prima soluzione che potrebbe venire in mente per separare i dati è aggiungere un identificatore in ogni tabella, in modo che possa essere gestito individualmente. Funziona, ma cosa succede se un cliente chiede il proprio database? Sarebbe molto ingombrante recuperare tutti quei record nascosti tra gli altri.

Applicazione Java EE multitenancy con Hibernate

La multitenancy in Java è più facile che mai con Hibernate.
Twitta

Il team di Hibernate ha trovato una soluzione a questo problema qualche tempo fa. Forniscono alcuni punti di estensione che consentono di controllare da dove devono essere recuperati i dati. Questa soluzione ha la possibilità di controllare i dati tramite una colonna identificatore, più database e più schemi. Questo articolo tratterà la soluzione a più schemi.

Allora, mettiamoci al lavoro!

Iniziare

Se sei uno sviluppatore Java più esperto e sai come configurare tutto, o se hai già il tuo progetto Java EE, puoi saltare questa sezione.

Innanzitutto, dobbiamo creare un nuovo progetto Java. Sto usando Eclipse e Gradle, ma puoi usare il tuo IDE preferito e strumenti di costruzione, come IntelliJ e Maven.

Se vuoi utilizzare i miei stessi strumenti, puoi seguire questi passaggi per creare il tuo progetto:

  • Installa il plugin Gradle su Eclipse
  • Clicca su File -> Nuovo -> Altro...
  • Trova Gradle (STS) e fai clic su Avanti
  • Informare un nome e scegliere Java Quickstart per il progetto di esempio
  • Fare clic su Fine

Grande! Questa dovrebbe essere la struttura del file iniziale:

 javaee-mt |- src/main/java |- src/main/resources |- src/test/java |- src/test/resources |- JRE System Library |- Gradle Dependencies |- build |- src |- build.gradle

Puoi eliminare tutti i file che arrivano all'interno delle cartelle di origine, poiché sono solo file di esempio.

Per eseguire il progetto, utilizzo Wildfly e ti mostrerò come configurarlo (di nuovo puoi usare il tuo strumento preferito qui):

  • Scarica Wildfly: http://wildfly.org/downloads/ (sto usando la versione 10)
  • Decomprimi il file
  • Installa il plug-in JBoss Tools su Eclipse
  • Nella scheda Server, fai clic con il pulsante destro del mouse su un'area vuota e scegli Nuovo -> Server
  • Scegli Wildfly 10.x (9.x funziona anche se 10 non è disponibile, a seconda della versione di Eclipse)
  • Fare clic su Avanti, scegliere Crea nuovo runtime (pagina successiva) e fare nuovamente clic su Avanti
  • Scegli la cartella in cui hai decompresso Wildfly come Home Directory
  • Fare clic su Fine

Ora configuriamo Wildfly per conoscere il database:

  • Vai alla cartella bin all'interno della tua cartella Wildfly
  • Esegui add-user.bat o add-user.sh (a seconda del tuo sistema operativo)
  • Segui i passaggi per creare il tuo utente come Manager
  • In Eclipse, vai di nuovo alla scheda Server, fai clic con il pulsante destro del mouse sul server che hai creato e seleziona Avvia
  • Sul tuo browser, accedi a http://localhost:9990, che è l'interfaccia di gestione
  • Inserisci le credenziali dell'utente appena creato
  • Distribuisci il file jar del driver del tuo database:
    1. Vai alla scheda Distribuzione e fai clic su Aggiungi
    2. Fare clic su Avanti, scegliere il file jar del driver
    3. Fare clic su Avanti e Fine
  • Vai alla scheda Configurazione
  • Scegli Sottosistemi -> Origini dati -> Non XA
  • Fare clic su Aggiungi, selezionare il database e fare clic su Avanti
  • Assegna un nome all'origine dati e fai clic su Avanti
  • Seleziona la scheda Rileva driver e scegli il driver appena distribuito
  • Immettere le informazioni sul database e fare clic su Avanti
  • Fare clic su Verifica connessione se si desidera assicurarsi che le informazioni del passaggio precedente siano corrette
  • Fare clic su Fine
  • Torna a Eclipse e ferma il server in esecuzione
  • Fare clic con il tasto destro su di esso, selezionare Aggiungi e rimuovi
  • Aggiungi il tuo progetto a destra
  • Fare clic su Fine

Bene, abbiamo Eclipse e Wildfly configurati insieme!

Queste sono tutte le configurazioni richieste al di fuori del progetto. Passiamo alla configurazione del progetto.

Progetto di bootstrap

Ora che abbiamo configurato Eclipse e Wildfly e creato il nostro progetto, dobbiamo configurare il nostro progetto.

La prima cosa che faremo è modificare build.gradle. Ecco come dovrebbe apparire:

 apply plugin: 'java' apply plugin: 'war' apply plugin: 'eclipse' apply plugin: 'eclipse-wtp' sourceCompatibility = '1.8' compileJava.options.encoding = 'UTF-8' compileJava.options.encoding = 'UTF-8' compileTestJava.options.encoding = 'UTF-8' repositories { jcenter() } eclipse { wtp { } } dependencies { providedCompile 'org.hibernate:hibernate-entitymanager:5.0.7.Final' providedCompile 'org.jboss.resteasy:resteasy-jaxrs:3.0.14.Final' providedCompile 'javax:javaee-api:7.0' }

Le dipendenze sono tutte dichiarate come “providedCompile”, perché questo comando non aggiunge la dipendenza nel file war finale. Wildfly ha già queste dipendenze e altrimenti causerebbe conflitti con quelle dell'app.

A questo punto, puoi fare clic con il tasto destro del tuo progetto, selezionare Gradle (STS) -> Aggiorna tutto per importare le dipendenze che abbiamo appena dichiarato.

È ora di creare e configurare il file "persistence.xml", il file che contiene le informazioni di cui Hibernate ha bisogno:

  • Nella cartella src/main/resource source, crea una cartella chiamata META-INF
  • All'interno di questa cartella, crea un file chiamato persistence.xml

Il contenuto del file deve essere qualcosa come il seguente, cambiando jta-data-source in modo che corrisponda all'origine dati che hai creato in Wildfly e il package com.toptal.andrehil.mt.hibernate con quello che creerai nel prossimo sezione (a meno che tu non scelga lo stesso nome del pacchetto):

 <?xml version="1.0" encoding="UTF-8" ?> <persistence xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd" version="2.0" xmlns="http://java.sun.com/xml/ns/persistence"> <persistence-unit name="pu"> <jta-data-source>java:/JavaEEMTDS</jta-data-source> <properties> <property name="hibernate.multiTenancy" value="SCHEMA"/> <property name="hibernate.tenant_identifier_resolver" value="com.toptal.andrehil.mt.hibernate.SchemaResolver"/> <property name="hibernate.multi_tenant_connection_provider" value="com.toptal.andrehil.mt.hibernate.MultiTenantProvider"/> </properties> </persistence-unit> </persistence>

Classi in letargo

Le configurazioni aggiunte a persistence.xml puntano a due classi personalizzate MultiTenantProvider e SchemaResolver. La prima classe è responsabile della fornitura di connessioni configurate con lo schema corretto. La seconda classe è responsabile della risoluzione del nome dello schema da utilizzare.

Ecco l'implementazione delle due classi:

 public class MultiTenantProvider implements MultiTenantConnectionProvider, ServiceRegistryAwareService { private static final long serialVersionUID = 1L; private DataSource dataSource; @Override public boolean supportsAggressiveRelease() { return false; } @Override public void injectServices(ServiceRegistryImplementor serviceRegistry) { try { final Context init = new InitialContext(); dataSource = (DataSource) init.lookup("java:/JavaEEMTDS"); // Change to your datasource name } catch (final NamingException e) { throw new RuntimeException(e); } } @SuppressWarnings("rawtypes") @Override public boolean isUnwrappableAs(Class clazz) { return false; } @Override public <T> T unwrap(Class<T> clazz) { return null; } @Override public Connection getAnyConnection() throws SQLException { final Connection connection = dataSource.getConnection(); return connection; } @Override public Connection getConnection(String tenantIdentifier) throws SQLException { final Connection connection = getAnyConnection(); try { connection.createStatement().execute("SET SCHEMA '" + tenantIdentifier + "'"); } catch (final SQLException e) { throw new HibernateException("Error trying to alter schema [" + tenantIdentifier + "]", e); } return connection; } @Override public void releaseAnyConnection(Connection connection) throws SQLException { try { connection.createStatement().execute("SET SCHEMA 'public'"); } catch (final SQLException e) { throw new HibernateException("Error trying to alter schema [public]", e); } connection.close(); } @Override public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException { releaseAnyConnection(connection); } }

La sintassi utilizzata nelle istruzioni precedenti funziona con PostgreSQL e alcuni altri database, questo deve essere modificato nel caso in cui il database abbia una sintassi diversa per modificare lo schema corrente.

 public class SchemaResolver implements CurrentTenantIdentifierResolver { private String tenantIdentifier = "public"; @Override public String resolveCurrentTenantIdentifier() { return tenantIdentifier; } @Override public boolean validateExistingCurrentSessions() { return false; } public void setTenantIdentifier(String tenantIdentifier) { this.tenantIdentifier = tenantIdentifier; } }

A questo punto è già possibile testare l'applicazione. Per ora, il nostro risolutore punta direttamente a uno schema pubblico codificato, ma è già stato chiamato. Per fare ciò, arresta il server se è in esecuzione e riavvialo. Puoi provare a eseguirlo in modalità di debug e posizionare un punto di interruzione in qualsiasi punto delle classi precedenti per verificare se funziona.

Uso pratico del resolver

Quindi, in che modo il risolutore potrebbe effettivamente contenere il nome corretto dello schema?

Un modo per ottenere ciò è mantenere un identificatore nell'intestazione di tutte le richieste e quindi creare un filtro per inserire il nome dello schema.

Implementiamo una classe di filtri per esemplificare l'utilizzo. È possibile accedere al risolutore tramite SessionFactory di Hibernate, quindi ne approfitteremo per ottenerlo e iniettare il nome dello schema corretto.

 @Provider public class AuthRequestFilter implements ContainerRequestFilter { @PersistenceUnit(unitName = "pu") private EntityManagerFactory entityManagerFactory; @Override public void filter(ContainerRequestContext containerRequestContext) throws IOException { final SessionFactoryImplementor sessionFactory = ((EntityManagerFactoryImpl) entityManagerFactory).getSessionFactory(); final SchemaResolver schemaResolver = (SchemaResolver) sessionFactory.getCurrentTenantIdentifierResolver(); final String username = containerRequestContext.getHeaderString("username"); schemaResolver.setTenantIdentifier(username); } }

Ora, quando una classe ottiene un EntityManager per accedere al database, sarà già configurata con lo schema corretto.

Per semplicità, l'implementazione mostrata qui sta ottenendo l'identificatore direttamente da una stringa nell'intestazione, ma è una buona idea usare un token di autenticazione e memorizzare l'identificatore nel token. Se sei interessato a saperne di più su questo argomento, ti suggerisco di dare un'occhiata a JSON Web Tokens (JWT). JWT è una libreria semplice e piacevole per la manipolazione dei token.

Come usare tutto questo

Con tutto configurato, non c'è nient'altro da fare nelle tue entità e/o classi che interagiscono con EntityManager . Tutto ciò che esegui da un EntityManager verrà indirizzato allo schema risolto dal filtro creato.

Ora, tutto ciò che devi fare è intercettare le richieste sul lato client e inserire l'identificatore/token nell'intestazione da inviare al lato server.

In un'applicazione reale, avrai un mezzo di autenticazione migliore. L'idea generale di multitenancy, tuttavia, rimarrà la stessa.

Il collegamento alla fine dell'articolo punta al progetto utilizzato per scrivere questo articolo. Utilizza Flyway per creare 2 schemi e contiene una classe di entità chiamata Car e una classe di servizio di riposo chiamata CarService che può essere utilizzata per testare il progetto. Puoi seguire tutti i passaggi seguenti, ma invece di creare il tuo progetto, puoi clonarlo e usarlo. Quindi, durante l'esecuzione puoi utilizzare un semplice client HTTP (come l'estensione Postman per Chrome) e fare una richiesta GET a http://localhost:8080/javaee-mt/rest/cars con le intestazioni key:value:

  • nome utente:joe; o
  • nome utente:fred.

In questo modo, le richieste restituiranno valori diversi, che sono in schemi diversi, uno chiamato joe e l'altro chiamato "fred".

Parole finali

Questa non è l'unica soluzione per creare applicazioni multitenancy nel mondo Java, ma è un modo semplice per raggiungere questo obiettivo.

Una cosa da tenere a mente è che Hibernate non genera DDL quando si utilizza la configurazione multi-tenancy. Il mio suggerimento è di dare un'occhiata a Flyway o Liquibase, che sono ottime librerie per controllare la creazione di database. Questa è una buona cosa da fare anche se non utilizzerai il multitenancy, poiché il team di Hibernate consiglia di non utilizzare la generazione automatica del database in produzione.

Il codice sorgente utilizzato per creare questo articolo e la configurazione dell'ambiente sono disponibili su github.com/andrehil/JavaEEMT

Correlati: Tutorial avanzato di classe Java: una guida al ricaricamento della classe