Pull to refresh

Hibernate, multi-tenancy и авто-обновление схемы БД

Reading time7 min
Views15K
Хотите пользоваться преимуществами (и недостатками) авто-обновления схемы БД при использовании Hibernate, но у вас имеется multi-tenant архитектура? Добро пожаловать под кат.

В чем, собственно, проблема?


Если в вашем приложении используется Hibernate и вам необходимо обеспечить автоматическое обновление схемы БД при использовании multi-tenant архитектуры со стратегией schema-per-tenant, вы не можете просто включить опцию для автообновления схемы:

<property name="hbm2ddl.auto">update</property>

Разберем почему. Придполагается, что читатель имеет представление о том, как работает multi-tenancy в Hibernate.

Конфигурация


БД: Postgres 9.2
ORM: Hibernate 4.3.7.Final + Envers
Multi-tenancy страдегия: schema-per-tenant с общим JDBC data source.

Основные настройки Hibernate:

<!--<property name="hbm2ddl.auto">update</property>-->
<property name="connection.datasource">java:/portalDS</property>
<property name="hibernate.dialect">by.tychina.PostgreSQL9MultiTenantDialect</property>
<property name="hibernate.multiTenancy">SCHEMA</property>
<property name="hibernate.multi_tenant_connection_provider">by.tychina.tenant.HibernateMultiTenantConnectionProvider</property>
<property name="hibernate.tenant_identifier_resolver">by.tychina.tenant.HibernateTenantIdentifierResolver</property>
<property name="org.hibernate.envers.audit_strategy">org.hibernate.envers.strategy.ValidityAuditStrategy</property>

Вспомогательные классы


Для хранения идентификатора tenant используется класс TenantId:

package by.tychina.tenant;

/**
 * @author Sergey Tychina
 */
public class TenantId {

    /**
     * Represents database schema name
     */
    private String tenantId;

    public TenantId(String tenantId) {
        this.tenantId = tenantId;
    }

    public String getTenantId() {
        return tenantId;
    }
}
, где поле tenantId представляет собой название схемы в БД.

Класс Persistence, отвечающий за инициализацию Hibernate конфигурации и инициализацию SessionFactory:

package by.tychina;

import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;

/**
 * @author Sergey Tychina
 */
public class Persistence {

    private final Configuration configuration;

    private final SessionFactory sessionFactory;

    private static Persistence instance = null;

    private Persistence() {
        configuration = new Configuration();
        configuration.configure();
        sessionFactory = configuration.buildSessionFactory();
    }

    public static synchronized Persistence getInstance() {
        if (instance == null) {
            instance = new Persistence();
        }

        return instance;
    }

    public Configuration getConfiguration() {
        return configuration;
    }

    public SessionFactory getSessionFactory() {
        return sessionFactory;
    }
}

Класс ConnectionProvider, неоходимый для окрытия коннекции к БД, используя data source:

package by.tychina;

import by.tychina.tenant.TenantId;

import javax.naming.InitialContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

/**
 * @author Sergey Tychina
 */
public class ConnectionProvider {

    private static final String DATASOURCE_JNDI_NAME = "java:/portalDS";

    private static DataSource dataSource = null;

    private static synchronized DataSource getDataSource() {
        if (dataSource == null) {
            try {
                dataSource = (DataSource) new InitialContext().lookup(DATASOURCE_JNDI_NAME);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        return dataSource;
    }

    public static Connection getConnection() {
        try {
            return getDataSource().getConnection();
        } catch (SQLException e) {
            throw new RuntimeException("Unable to get connection", e);
        }
    }

    public static Connection getConnection(TenantId tenantId) {
        Connection connection = getConnection();
        try {
            connection.createStatement().execute("SET SCHEMA '" + tenantId.getTenantId() + "'");
        } catch (SQLException e) {
            try {
                connection.close();
            } catch (SQLException e1) {
                e1.printStackTrace();
            }
            throw new RuntimeException("Could not alter connection to '" + tenantId.getTenantId() + "' schema", e);
        }

        return connection;
    }
}

Hibernate необходим класс, который будет открывать подключение к БД с указанным tenant и без него (опция hibernate.multi_tenant_connection_provider):

package by.tychina.tenant;

import by.tychina.ConnectionProvider;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;

import java.sql.Connection;
import java.sql.SQLException;

/**
 * @author Sergey Tychina
 */
public class HibernateMultiTenantConnectionProvider implements MultiTenantConnectionProvider {

    @Override
    public Connection getAnyConnection() throws SQLException {
        return ConnectionProvider.getConnection();
    }

    @Override
    public void releaseAnyConnection(Connection connection) throws SQLException {
        connection.close();
    }

    @Override
    public Connection getConnection(String tenantIdentifier) throws SQLException {
        return ConnectionProvider.getConnection(new TenantId(tenantIdentifier));
    }

    @Override
    public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
        releaseAnyConnection(connection);
    }

    @Override
    public boolean supportsAggressiveRelease() {
        return false;
    }

    @Override
    public boolean isUnwrappableAs(Class unwrapType) {
        return false;
    }

    @Override
    public <T> T unwrap(Class<T> unwrapType) {
        return null;
    }
}

Hibernate необходим класс, необходимый для определения текущего tenant (опция hibernate.tenant_identifier_resolver):

package by.tychina.tenant;

import org.hibernate.context.spi.CurrentTenantIdentifierResolver;

/**
 * @author Sergey Tychina
 */
public class HibernateTenantIdentifierResolver implements CurrentTenantIdentifierResolver {
    @Override
    public String resolveCurrentTenantIdentifier() {
        // Put some logic here to get current transaction tenant id
        // Use ThreadLocal for example to store TenantId when starting a transaction
        TenantId transactionTenantId = null;

        return transactionTenantId.getTenantId();
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

Postres диалект для поддержки multi-tenancy


В случае, если в маппингах классов используются sequence для генерирования id, стандартный PostgreSQL9Dialect, поставляемый Hibernate, не позволит корректно генерировать update скрипты для создания sequence. Проблема кроется в следующем методе класса org.hibernate.dialect.PostgreSQL81Dialect:

public String getQuerySequencesString() {
    return "select relname from pg_class where relkind='S'";
}

Данный метод вызывается Hibernate для получения существующих sequence, чтобы определить, какие update скрипты надо сгенерировать. Проблема в том, что данный SQL запрос вернет sequence для всех схем, а не только для той, для которой мы генерируем update скрипты. Решением данной проблемы занимается созданный нами диалект:

package by.tychina;

import org.hibernate.dialect.PostgreSQL9Dialect;

/**
 * @author Sergey Tychina
 */
public class PostgreSQL9MultiTenantDialect extends PostgreSQL9Dialect {

    /**
     * Query to get sequences for current schema ONLY. Original method returns query to get all sequences (in all schemas)
     * that breaks multi-tenancy.
     */
    @Override
    public String getQuerySequencesString() {
        return "SELECT c.relname FROM pg_catalog.pg_class c " +
                "LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " +
                "WHERE c.relkind IN ('S','') " +
                "AND n.nspname <> 'pg_catalog' " +
                "AND n.nspname <> 'information_schema' " +
                "AND n.nspname !~ '^pg_toast' " +
                "AND pg_catalog.pg_table_is_visible(c.oid)";
    }
}

Такой SQL запрос был получен при запуске psql с параметром -E и выполнением комманды \ds:

demo_portal=> \ds
********* QUERY **********
SELECT n.nspname as "Schema",
  c.relname as "Name",
  CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' WHEN 'i' THEN 'index' WHEN 'S' THEN 'sequence' WHEN 's' THEN 'special' WHEN 'f' THEN 'foreign table' END as "Type",
  pg_catalog.pg_get_userbyid(c.relowner) as "Owner"
FROM pg_catalog.pg_class c
     LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('S','')
      AND n.nspname <> 'pg_catalog'
      AND n.nspname <> 'information_schema'
      AND n.nspname !~ '^pg_toast'
  AND pg_catalog.pg_table_is_visible(c.oid)
ORDER BY 1,2;
**************************

В конфигурации Hibernate указываем диалект:

<property name="hibernate.dialect">by.tychina.PostgreSQL9MultiTenantDialect</property>

Авто-обновление схемы


В конфигурации Hibernate закомментирована опция hbm2ddl.auto, так как Hibernate не умеет обновлять схему БД при использовании выбранной multi-tenancy стратегии. Для автоматического обновления схемы напишем свою реализацию (почти полностью скопированную из исходных кодов Hibernate):

package by.tychina.schema;

import by.tychina.ConnectionProvider;
import by.tychina.Persistence;
import by.tychina.tenant.TenantId;
import org.hibernate.cfg.Configuration;
import org.hibernate.cfg.Environment;
import org.hibernate.dialect.Dialect;
import org.hibernate.engine.jdbc.internal.FormatStyle;
import org.hibernate.engine.jdbc.internal.Formatter;
import org.hibernate.tool.hbm2ddl.DatabaseMetadata;
import org.hibernate.tool.hbm2ddl.SchemaUpdateScript;

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;

/**
 * @author Sergey Tychina
 */
public class MultiTenantHibernateSchemaUpdate {

    public static void execute(TenantId tenantId) {
        Connection connection = ConnectionProvider.getConnection(tenantId);

        Configuration configuration = Persistence.getInstance().getConfiguration();
        String originalSchema = configuration.getProperty(Environment.DEFAULT_SCHEMA);

        Statement stmt = null;
        try {
            Dialect dialect = Dialect.getDialect(configuration.getProperties());
            Formatter formatter = FormatStyle.DDL.getFormatter();

            configuration.setProperty(Environment.DEFAULT_SCHEMA, tenantId.getTenantId());

            DatabaseMetadata meta = new DatabaseMetadata(connection, dialect, configuration);
            stmt = connection.createStatement();

            List<SchemaUpdateScript> scripts = configuration.generateSchemaUpdateScriptList(dialect, meta);
            for (SchemaUpdateScript script : scripts) {
                String formatted = formatter.format(script.getScript());
                stmt.executeUpdate(formatted);
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        } finally {
            if (originalSchema != null) {
                configuration.setProperty(Environment.DEFAULT_SCHEMA, originalSchema);
            } else {
                configuration.getProperties().remove(Environment.DEFAULT_SCHEMA);
            }

            try {
                if (stmt != null) {
                    stmt.close();
                }
                connection.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }
}

Порядок работы:
  • Установить текущую схему в конфигурации Hibernate, используя
    configuration.setProperty(Environment.DEFAULT_SCHEMA, tenantId.getTenantId());
    
  • Сгенерировать скрипты для обновления схемы, используя
    List<SchemaUpdateScript> scripts = configuration.generateSchemaUpdateScriptList(dialect, meta);
    
  • Применить скрипты
    for (SchemaUpdateScript script : scripts) {
        String formatted = formatter.format(script.getScript());
        stmt.executeUpdate(formatted);
    }
    
  • Восстановить оригинальное название схемы в конфигурации
    if (originalSchema != null) {
        configuration.setProperty(Environment.DEFAULT_SCHEMA, originalSchema);
    } else {
        configuration.getProperties().remove(Environment.DEFAULT_SCHEMA);
    }
    

При старте приложения, после инициализации Hibernate, достаточно вызвать

MultiTenantHibernateSchemaUpdate.execute(tenantId);

и схема будет обновлена.

Спасибо.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+7
Comments15

Articles

Change theme settings