原文:
https://dzone.com/articles/spring-boot-hibernate-multitenancy-implementation
将多租户数据分隔到不同的schema中是构建多租户场景比较好的方式.
- 在这篇文章,我将会介绍如何通过 schema-per-tenant 方式实现多租户场景;
- 本篇也将提供一种方式去解决在多租户场景下的登录问题。这往往是最难的,因为在这种情况下,应用并不清楚是哪个租户的用户登陆了系统;
- 本篇文章面向已经熟悉了多租户场景的开发者,以防万一,我们可以简单的回顾一下。
多租户常用三种实现方式
- DB per tenant : 每个租户拥有属于自己的数据库实例(非db base)。这种情况租户数据拥有高隔离性。
- Schema per tenant : 每个租户的数据存放在同一个db 实例,不同的数据库schema中,这种方式可以同以下两种方式实现:
- 每个schema 一个连接池;
- 一个连接池 处理所有schema: 对于每个请求,从池中检索连接,并在将其分配给上下文之前与相关租户一起调用set schema;
- Discriminator field(租户字段识别) 所有租户的数据都保存在同一个表中,前提是这些表上有一个租户字段来区分每个租户。
我不会深入讨论每种方法的优缺点,但是如果您想了解更多信息,可以阅读这篇文章和这个 MSDN page
具体实现
本篇文章,我选择介绍 通过schema-per-tenant的方式构建多租户应用.
为了解决登录问题,我将使用 一个 通用 schema(named “tenants”),这个schema 只有一个表,本表映射了不同租户相关的用户信息。此表的目的是在租户仍然未知的情况下登录时获取用户的租户标识符。登录时, 租户标识符将被存储在JWT中,当时它也可以存储在其他不同的地方,例如http header 中.
多租户设置
首先,我们需要一个当前租户的共享context 上下文,单个租户将在一个请求前设置,并在请求后释放掉。另外,请注意上下文是ThreadLocal ,而不是静态的,因为服务器一次可以处理多个租户。
public class TenantContext {
private static Logger logger = LoggerFactory.getLogger(TenantContext.class.getName());
private static ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setCurrentTenant(String tenant) {
logger.debug("Setting tenant to " + tenant);
currentTenant.set(tenant);
}
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void clear() {
currentTenant.set(null);
}
}
下一步是Tenantinterceptor,它是一个拦截器,从JWT(或不同实现中的请求头)读取租户标识符并设置租户上下文:
@Component
public class TenantInterceptor extends HandlerInterceptorAdapter {
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.header}")
private String tokenHeader;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String authToken = request.getHeader(this.tokenHeader);
String tenantId = jwtTokenUtil.getTenantIdFromToken(authToken);
TenantContext.setCurrentTenant(tenantId);
return true;
}
@Override
public void postHandle(
HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception {
TenantContext.clear();
}
}
创建一个CurrentTenantidentifierResolver: 这是hibernate 用来解析当前租户信息的模块。
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
String tenantId = TenantContext.getCurrentTenant();
if (tenantId != null) {
return tenantId;
}
return DEFAULT_TENANT_ID;
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
下一个是multitenantconnectionprovider:Hibernate还需要提供到上下文的连接。在我们的例子中,我们需要从数据源获取连接,为相关租户设置他的schema:
@Component
public class MultiTenantConnectionProviderImpl implements MultiTenantConnectionProvider {
@Autowired
private DataSource dataSource;
@Override
public Connection getAnyConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantIdentifie) throws SQLException {
String tenantIdentifier = TenantContext.getCurrentTenant();
final Connection connection = getAnyConnection();
try {
if (tenantIdentifier != null) {
connection.createStatement().execute("USE " + tenantIdentifier);
} else {
connection.createStatement().execute("USE " + DEFAULT_TENANT_ID);
}
}
catch ( SQLException e ) {
throw new HibernateException(
"Problem setting schema to " + tenantIdentifier,
e
);
}
return connection;
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
try {
connection.createStatement().execute( "USE " + DEFAULT_TENANT_ID );
}
catch ( SQLException e ) {
throw new HibernateException(
"Problem setting schema to " + tenantIdentifier,
e
);
}
connection.close();
}
@SuppressWarnings("rawtypes")
@Override
public boolean isUnwrappableAs(Class unwrapType) {
return false;
}
@Override
public <T> T unwrap(Class<T> unwrapType) {
return null;
}
@Override
public boolean supportsAggressiveRelease() {
return true;
}
}
注入
@Configuration
public class HibernateConfig {
@Autowired
private JpaProperties jpaProperties;
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
return new HibernateJpaVendorAdapter();
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource,
MultiTenantConnectionProvider multiTenantConnectionProviderImpl,
CurrentTenantIdentifierResolver currentTenantIdentifierResolverImpl) {
Map<String, Object> properties = new HashMap<>();
properties.putAll(jpaProperties.getHibernateProperties(dataSource));
properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProviderImpl);
properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolverImpl);
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);
em.setPackagesToScan("com.autorni");
em.setJpaVendorAdapter(jpaVendorAdapter());
em.setJpaPropertyMap(properties);
return em;
}
}
登录处理
登录后,我们需要查询通用schema 并检索用户的tenantid。只有这样,我们才能在相关的租户schema上继续登录。
请注意,一旦在上下文中建立了连接(通常在执行第一个查询时),就可以对每个线程进行缓存,并且不能对其进行更改。因此,租户不能在控制器的中间进行更改。这是Hibernate的一个限制,这里也有一个问题,因此解决方法是为默认的DB查询使用不同的线程,并强制Hibernate重新创建与所需租户的连接。此解决方案(从这里获取)仅对登录部分是必需的。没有太多的理由让房客在流动的中间转换。
在这个例子中,我创建了一个名为tenentresolver的可调用文件,它包含查询默认模式以获取用户的tenantid的逻辑。
@RequestMapping(value = "login", method = RequestMethod.POST)
public ResponseEntity<?> createAuthenticationToken(@RequestBody JwtAuthenticationRequest authenticationRequest) throws AuthenticationException {
//Resolve the user's tenantId
try {
tenantResolver.setUsername(authenticationRequest.getUsername());
ExecutorService es = Executors.newSingleThreadExecutor();
Future<UserTenantRelation> utrFuture = es.submit(tenantResolver);
UserTenantRelation utr = utrFuture.get();
//TODO: handle utr == null, user is not found
//Got the tenant, now switch to the context
TenantContext.setCurrentTenant(utr.getTenant());
} catch (Exception e) {
e.printStackTrace();
}
// Perform the authentication
final Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
authenticationRequest.getUsername(),
authenticationRequest.getPassword()
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
// Reload password post-security so we can generate token
final User user = (User)userDetailsService.loadUserByUsername(authenticationRequest.getUsername());
final String token = jwtTokenUtil.generateToken(user);
// Return the token
return ResponseEntity.ok(new JwtAuthenticationResponse(token, user));
}
gitHub code
https://github.com/alonsegal/springboot-schema-per-tenant