Home >Java >javaTutorial >How to build Springboot multi-tenant SaaS

How to build Springboot multi-tenant SaaS

WBOY
WBOYforward
2023-05-12 16:49:061665browse

Technical Framework

The springboot version is 2.3.4.RELEASE

The persistence layer adopts JPA

Tenant Model design

Because all tenants of the saas application Using the same service and database, in order to isolate tenant data, a BaseSaasEntity

public abstract class BaseSaasEntity {
    @JsonIgnore
    @Column(nullable = false, updatable = false)
    protected Long tenantId;
    }

is created here. There is only one field tenantId, which corresponds to the tenant ID. All tenant business entities inherit this parent class. Finally, the tenantId is used to distinguish which tenant the data belongs to.

sql tenant data filtering

As usual, after the table is created, the CURD of the corresponding module should be followed. But the most basic requirement for saas applications is tenant data isolation, that is, people from company B cannot see the data of company A. How to filter it? The BaseSaasEntity we established above will work here. By distinguishing which company the current request comes from, Add where tenant=? to all tenant business sql to implement tenant data filtering.

Hibernate filter

If we add tenant sql filtering code to our business, the workload will not only be large, but the probability of errors will also be high. The ideal is to process the filtered SQL splicing together and enable SQL filtering on the tenant business interface. Because JPA is implemented by hibernate, here we can use some functions of hibernate

@MappedSuperclass
@Data
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "long")})
@Filter(condition = "tenant_id=:tenantId", name = "tenantFilter")
public abstract class BaseSaasEntity {
    @JsonIgnore
    @Column(nullable = false, updatable = false)
    protected Long tenantId;


    @PrePersist
    public void onPrePersist() {
        if (getTenantId() != null) {
            return;
        }
        Long tenantId = TenantContext.getTenantId();
        Check.notNull(tenantId, "租户不存在");
        setTenantId(tenantId);
    }
}

Hibernate3 provides an innovative way to process data with "visibility" rules, that is Use Hibernate filters. Hibernate filters are globally valid, named filters that can take parameters. You can choose whether to enable (or disable) a filter for a specific Hibernate session.

Here we pre-define a sql filter condition through @FilterDef and @Filter. Then use a @TenantFilter annotation to identify that the interface requires data filtering

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Transactional
public @interface TenantFilter {

    boolean readOnly() default true;

}

It can be seen that this interface is placed on the method, which corresponds to the Controller layer. The significance of adding the transaction annotation @Transactional is that a transaction must be enabled to activate the hibernate filter. The default here is a read-only transaction. Finally define an aspect to activate the filter

@Aspect
@Slf4j
@RequiredArgsConstructor
public class TenantSQLAspect {
    private static final String FILTER_NAME = "tenantFilter";
    private final EntityManager entityManager;
    @SneakyThrows
    @Around("@annotation(com.lvjusoft.njcommon.annotation.TenantFilter)")
    public Object aspect(ProceedingJoinPoint joinPoint) {
        Session session = entityManager.unwrap(Session.class);
        try {
            Long tenantId = TenantContext.getTenantId();
            Check.notNull(tenantId, "租户不存在");
            session.enableFilter(FILTER_NAME).setParameter("tenantId", tenantId);
            return joinPoint.proceed();
        } finally {
            session.disableFilter(FILTER_NAME);
        }
    }
}

The object of the aspect here is the @TenantFilter annotation just customized. Get the current tenant id before the method is executed and enable the filter. In this way, the tenant data isolation is complete. Just need Just add the @TenantFilter annotation on the tenant business interface, and development only needs to care about the business code. The TenantContext in the above picture is the current thread tenant context. By agreeing with the front-end, the tenant id is added to the interface request header, and the server uses the interceptor to cache the obtained tenant id in ThreadLocal

public class IdentityInterceptor extends HandlerInterceptorAdapter {
    public IdentityInterceptor() {
        log.info("IdentityInterceptor init");
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = request.getHeader(AuthConstant.USER_TOKEN_HEADER_NAME);
        UserContext.setToken(token);
        String tenantId = request.getHeader(AuthConstant.TENANT_TOKEN_HEADER_NAME);
        TenantContext.setTenantUUId(tenantId);
        return true;
    }
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
        UserContext.clear();
        TenantContext.clear();
    }
}

sub-library

As the number of tenants increases, the data of a single MySQL database and a single table will definitely reach a bottleneck. Here, only the method of sub-database is used. Utilize multiple data sources to perform many-to-one mapping of tenants and data sources.

public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
    private Map<Object, Object> targetDataSources;
    public DynamicRoutingDataSource() {
        targetDataSources =new HashMap<>();
        DruidDataSource druidDataSource1 = new DruidDataSource();
        druidDataSource1.setUsername("username");
        druidDataSource1.setPassword("password");
        druidDataSource1.setUrl("jdbc:mysql://localhost:3306/db?useSSL=false&useUnicode=true&characterEncoding=utf-8");
        targetDataSources.put("db1",druidDataSource1);
        
        DruidDataSource druidDataSource2 = new DruidDataSource();
        druidDataSource2.setUsername("username");
        druidDataSource2.setPassword("password");
        druidDataSource2.setUrl("jdbc:mysql://localhost:3306/db?useSSL=false&useUnicode=true&characterEncoding=utf-8");
        targetDataSources.put("db2",druidDataSource1);
        
        this.targetDataSources = targetDataSources;
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }
    public void addDataSource(String key, DataSource dataSource) {
        if (targetDataSources.containsKey(key)) {
            throw new IllegalArgumentException("dataSource key exist");
        }
        targetDataSources.put(key, dataSource);
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContext.getSource();
    }
}

Declare a dynamic routing data source by implementing AbstractRoutingDataSource. Before the framework uses datesource, spring will call the determineCurrentLookupKey() method to determine which data source to use. The DataSourceContext here is similar to the TenantContext above. After obtaining the tenantInfo in the interceptor, find the data source key corresponding to the current tenant and set it in ThreadLocal.

The above is the detailed content of How to build Springboot multi-tenant SaaS. For more information, please follow other related articles on the PHP Chinese website!

Statement:
This article is reproduced at:yisu.com. If there is any infringement, please contact admin@php.cn delete