春季引导+休眠项目的多租户实现


在本文中,我们将了解如何使用按租户模式方法在Spring Boot项目中实现多租户。

本文还将提供一种在多租户环境中处理登录的方法。这很棘手,因为在那个阶段,并不总是清楚尚未登录的用户属于哪个租户。

本文假设读者已经熟悉多租户实践,但为了以防万一,下面简要介绍一下:

多租户有三种不同的方法

  1. 每个租户的数据库:每个租户都有自己的数据数据库。这是最高级别的隔离。

  2. 每个租户的模式:每个租户的数据保存在相同的数据库中,但是保存在不同的模式中。此方法可以通过两种不同的方式实施:

    • 每个架构的连接池

    • 所有架构的单个连接池-对于每个请求,将从池中检索一个连接,并set schema 在将其分配给上下文之前与相关租户一起调用。

  3. 鉴别符字段:所有租户的数据都保存在相同的表中,前提是这些表上有区分每个租户的鉴别符字段。

我不打算深入讨论每种方法的优缺点,但是如果您想了解更多,可以阅读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。如果有人要求,我会的!