在本文中,我们将了解如何使用按租户模式方法在Spring Boot项目中实现多租户。
本文还将提供一种在多租户环境中处理登录的方法。这很棘手,因为在那个阶段,并不总是清楚尚未登录的用户属于哪个租户。
本文假设读者已经熟悉多租户实践,但为了以防万一,下面简要介绍一下:
每个租户的数据库:每个租户都有自己的数据数据库。这是最高级别的隔离。
每个租户的模式:每个租户的数据保存在相同的数据库中,但是保存在不同的模式中。此方法可以通过两种不同的方式实施:
每个架构的连接池
所有架构的单个连接池-对于每个请求,将从池中检索一个连接,并set schema
在将其分配给上下文之前与相关租户一起调用。
鉴别符字段:所有租户的数据都保存在相同的表中,前提是这些表上有区分每个租户的鉴别符字段。
我不打算深入讨论每种方法的优缺点,但是如果您想了解更多,可以阅读this article,还有这个MSDN page。
在本文中,我选择使用按租户模式实现多租户,所有租户使用一个连接池。
为了处理登录,我们将使用一个通用模式(租户),该模式只有一个表,该表将系统中的每个用户映射到其相关的租户。此表的目的是在租户仍然未知的情况下,在登录时获取用户的租户标识,然后将租户标识保存在JWT中,但可以将其保存在不同的位置,如HTTP Header。
首先,我们需要当前租户的共享上下文。租户将在处理每个请求之前设置,并在处理后释放。另外,请注意上下文是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也需要提供到上下文的连接。在我们的示例中,我们请求来自数据源的连接,并将其模式设置为相关租户:
@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;
}
}
登录时,我们需要查询通用模式并检索用户的tenantId。只有这样,我们才能继续登录相关的租户模式。
请注意,一旦在上下文中建立了连接(通常是在执行第一个查询时),它就会按线程进行缓存,并且不能更改。因此,不能在控制器中间更改租户。这是Hibernate的一个限制,也有它的门票here因此,解决方法是对默认DB查询使用不同的线程,并强制Hibernate重新创建与所需租户的连接。此解决方法(取自here)仅对于登录部分是必需的。没有太多理由在人流中途更换租户。
在本例中,我创建了一个名为TenantResolver
,它包含查询默认模式以获取用户的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 repo。如果有人要求,我会的!