Tagbangers Blog

Spring Boot で Hibernate Search のマルチテナンシーをやってみる

Hibernate Search がマルチテナンシーに対応したので、Spring Boot と組み合わせて試してみた。

試した環境は下記の通り。
Spring Boot 1.2.5.RELEASE
Hibernate Search 5.3.0.Final

サンプルコードはここにも置いてあります。
https://github.com/tagbangers/spring-best-practices/tree/master/spring-best-practice-hibernate-search-multi-tenancy

マルチテナンシー (multi-tenancy) ってなに?

ひとつのアプリケーションを複数のお客さん(テナント)が使えるようにすること。SaaS型のサービスを構築する場合と考えればOK。

Hibernate ORM と Hibernate Search

結局のところ、Hibernate Search でマルチテナンシーを設定するには、親プロジェクトである Hibernate ORM のマルチテナンシーを設定すればOKということ。なので、今回の内容はほぼ Hibernate ORM に対するマルチテナンシーの設定になった。

DataSource の設定

Hibernate ORM におけるマルチテナンシーは、次の3つのやり方がある。

  1. データベースを分ける (今回はこれを使ってみる)
  2. スキーマを分ける
  3. ディスクリミネータを使う

ただし、今の時点での Spring Boot の設定はマルチテナンシーを想定していないので、そのままでは DataSource を複数定義できない。なので、@ConfigurationProperties を使って複数の DataSource が設定できるようにしてみる。

@ConfigurationProperties("spring.additional")
public class AdditionalProperties {

   @NestedConfigurationProperty
   private DataSourceProperties datasource1;

   @NestedConfigurationProperty
   private DataSourceProperties datasource2;

   public DataSourceProperties getDatasource1() {
      return datasource1;
   }

   public void setDatasource1(DataSourceProperties datasource1) {
      this.datasource1 = datasource1;
   }

   public DataSourceProperties getDatasource2() {
      return datasource2;
   }

   public void setDatasource2(DataSourceProperties datasource2) {
      this.datasource2 = datasource2;
   }
}

application.properties はこんな感じで。

spring.additional.datasource1.url=jdbc:h2:mem:test1
spring.additional.datasource2.url=jdbc:h2:mem:test2

2つの DataSource を Bean 登録する。
ポイントは Spring Boot のオートコンフィグレーションで dataSource が参照されるので、どちらかの DataSource の Bean名 には、dataSource という名前もつけること。

@Configuration
public class DataSourceConfiguration {

   @Autowired
   private AdditionalProperties additionalProperties;

   @Bean(name = {"dataSource", "dataSource1"})
   @ConfigurationProperties(prefix = "spring.additional.datasource1")
   public DataSource dataSource1() {
      DataSourceBuilder factory = DataSourceBuilder
            .create(this.additionalProperties.getDatasource1().getClassLoader())
            .driverClassName(this.additionalProperties.getDatasource1().getDriverClassName())
            .url(this.additionalProperties.getDatasource1().getUrl());
      return factory.build();
   }

   @Bean(name = "dataSource2")
   @ConfigurationProperties(prefix = "spring.additional.datasource2")
   public DataSource dataSource2() {
      DataSourceBuilder factory = DataSourceBuilder
            .create(this.additionalProperties.getDatasource2().getClassLoader())
            .driverClassName(this.additionalProperties.getDatasource2().getDriverClassName())
            .url(this.additionalProperties.getDatasource2().getUrl());
      return factory.build();
   }
}

DataSourceAutoConfiguration は単一の DataSource しか考慮していないので、除外しておく。

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableConfigurationProperties(AdditionalProperties.class)
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

CurrentTenantIdentifierResolver を実装する

マルチテナンシーを設定するにあたってまず大事なことは、何をもってお客さん (テナント) を判定するかを決めること。
今回は、URLの最初パスが tenant1 から始まるか、tenant2 から始まるかで判断することにする。URLのパスを判定するのは後で記載する MVC の Interceptor で行ない、RequestAttribute に入れておく。

@Component
public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {

   public static final String IDENTIFIER_ATTRIBUTE = CurrentTenantIdentifierResolverImpl.class.getName() + ".IDENTIFIER";
   public static final String TENANT_1_IDENTIFIER = "tenant1";
   public static final String TENANT_2_IDENTIFIER = "tenant2";

   @Override
   public String resolveCurrentTenantIdentifier() {
      RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
      if (requestAttributes != null) {
         String identifier = (String) requestAttributes.getAttribute(IDENTIFIER_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
         if (identifier != null) {
            return identifier;
         }
      }
      return TENANT_1_IDENTIFIER;
   }

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

MultiTenantConnectionProvider を実装する

TenantIdentifier に対応する DataSource を定義する。

@Component
public class DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {

   @Autowired
   private DataSource dataSource1;

   @Autowired
   private DataSource dataSource2;

   private Map<String, DataSource> dataSourceMap = new HashMap<>();

   @PostConstruct
   public void init() {
      dataSourceMap.put(CurrentTenantIdentifierResolverImpl.TENANT_1_IDENTIFIER, dataSource1);
      dataSourceMap.put(CurrentTenantIdentifierResolverImpl.TENANT_2_IDENTIFIER, dataSource2);
   }

   @Override
   protected DataSource selectAnyDataSource() {
      return dataSource1;
   }

   @Override
   protected DataSource selectDataSource(String tenantIdentifier) {
      return dataSourceMap.get(tenantIdentifier);
   }
}

EntityManager の設定

マルチテナンシー対応の EntityManager を定義する。

@Configuration
@EnableConfigurationProperties(JpaProperties.class)
public class MultiTenancyJpaConfiguration {

   @Autowired
   private DataSource dataSource;

   @Autowired
   private JpaProperties jpaProperties;

   @Autowired
   private MultiTenantConnectionProvider multiTenantConnectionProvider;

   @Autowired
   private CurrentTenantIdentifierResolver currentTenantIdentifierResolver;

   @Bean
   public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder factoryBuilder) {
      Map<String, Object> vendorProperties = new LinkedHashMap<>();
      vendorProperties.putAll(jpaProperties.getHibernateProperties(dataSource));
      vendorProperties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.DATABASE);
      vendorProperties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);
      vendorProperties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver);
      return factoryBuilder.dataSource(dataSource)
            .packages(Item.class.getPackage().getName())
            .properties(vendorProperties)
            .jta(false)
            .build();
   }
}

TenantIdentifier を決定するための Interceptor を定義する

Controller は /{tenant} でマッピングし、PathValiable から TenantIdentifier を取得することにする。

public class CurrentTenantIdentifierChangeInterceptor extends HandlerInterceptorAdapter {

   @Override
   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      Map<String, Object> pathVariables = (Map<String, Object>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
      if (pathVariables.containsKey("tenant")) {
         request.setAttribute(CurrentTenantIdentifierResolverImpl.IDENTIFIER_ATTRIBUTE, pathVariables.get("tenant"));
      }
      return true;
   }
}

WebMvcConfigurerAdapter を拡張して Interceptor を追加。

@Configuration
public class WebMvcConfiguration extends WebMvcConfigurerAdapter {

   @Override
   public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(new CurrentTenantIdentifierChangeInterceptor());
   }
}


以上で Hibernate Search のマルチテナンシー対応は完了。
Spring Boot に 2つの DataSource を認識させることがちょっとやっかいだが、Hibernate側のマルチテナンシーの対応はとてもシンプルに実現できる。

参考URL

https://docs.jboss.org/hibernate/orm/4.3/devguide/en-US/html/ch16.html
http://in.relation.to/2015/04/17/multi-tenancy-for-hibernate-search-users-520-beta-1-released/