共享数据库,共享架构
- 所有租户共享一个数据库和表。每个表都有一个带有租户标识符的列,它显示了该行的所有者。每个人都住在同一个公寓里,所有东西都用便签标记,以显示谁拥有它。
这篇文章的 GitHub 存储库可以在以下位置找到:
在此示例中,我们将开发一个多租户应用程序来管理客户。创建数据库
首先创建philipp用于连接数据库的用户:
PS C:\Users\philipp> psql -U postgres psql (9.4.1) postgres=# CREATE USER philipp WITH PASSWORD 'test_pwd'; CREATE ROLE
|
然后我们可以创建数据库并将所有者设置为philipp:postgres=# CREATE DATABASE sampledb postgres-# WITH OWNER philipp; CREATE DATABASE
|
SQL 脚本
现在执行以下 SQL 脚本来创建架构、表和策略。该脚本还创建 app_user用于连接数据库的 ,。该存储库还附带一个批处理脚本和一个外壳脚本来创建数据库。
DO $$ BEGIN
--------------------------- -- Create the Schema -- --------------------------- IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'sample') THEN
CREATE SCHEMA sample;
END IF;
--------------------------- -- Create the Table -- --------------------------- IF NOT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'sample' AND table_name = 'customer' ) THEN
CREATE TABLE sample.customer ( customer_id SERIAL PRIMARY KEY, first_name VARCHAR(255) NOT NULL, last_name VARCHAR(255) NOT NULL, tenant_name VARCHAR(255) NOT NULL );
END IF;
--------------------------- -- Enable RLS -- --------------------------- ALTER TABLE sample.customer ENABLE ROW LEVEL SECURITY;
--------------------------- -- Create the RLS Policy -- ---------------------------
DROP POLICY IF EXISTS tenant_isolation_policy ON sample.customer;
CREATE POLICY tenant_isolation_policy ON sample.customer USING (tenant_name = current_setting('app.current_tenant')::VARCHAR);
--------------------------- -- Create the app_user -- --------------------------- IF NOT EXISTS ( SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_user') THEN
CREATE ROLE app_user LOGIN PASSWORD 'app_user'; END IF;
-------------------------------- -- Grant Access to the Schema -- -------------------------------- GRANT USAGE ON SCHEMA sample TO app_user; GRANT ALL ON SEQUENCE sample.customer_customer_id_seq TO app_user; GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE sample.customer TO app_user;
END; $$;
|
项目结构
首先看一下项目结构很有用:
- async
- AsyncConfig
- 提供为 TenantAware 处理装饰的 TaskExecutor。
- TenantAwareTaskDecorator
- 添加一个 Spring Boot TaskDecorator,将 TenantName 传递给子线程。
- core
- datasource
- TenantAwareHikariDataSource
- 覆盖HikariDataSource#getConnection设置连接的租户信息的方法。
- model
- Customer
- Customer实体,将在每个租户数据库中进行管理。
- repositories
- web
- configuration
- controllers
- CustomerController
- 实现用于持久化和删除客户的 REST Web 服务。
- converter
- interceptor
领域层
1、客户实体
客户实体对客户实体建模。我们使用命名空间中的javax.persistence注释来注释域模型并设置数据库列。Hibernate 可以很好地使用这些注释。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.model;
import javax.persistence.*;
@Entity @Table(schema = "sample", name = "customer") public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "customer_id") private Long id;
@Column(name = "first_name") private String firstName;
@Column(name = "last_name") private String lastName;
@Column(name = "tenant_name") private String tenantName;
protected Customer() { }
public Customer(Long id, String firstName, String lastName, String tenantName) { this.id = id; this.firstName = firstName; this.lastName = lastName; this.tenantName = tenantName; }
public Long getId() { return id; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getTenantName() { return tenantName; } }
|
2、客户资料库
使用 Spring Boot 添加 CRUD 功能很简单,它提供了一个所谓的CrudRepository. 您只需从 CrudRepository接口扩展,Spring 会自动为您的实体提供所有 CRUD 功能。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.repositories;
import de.bytefish.multitenancy.model.Customer; import org.springframework.data.repository.CrudRepository;
public interface ICustomerRepository extends CrudRepository<Customer, Long> { }
|
基础设施
存储租户标识符
该类ThreadLocalStorage包装 aThreadLocal以将租户数据存储在当前线程上下文中。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.core;
public class ThreadLocalStorage {
private static ThreadLocal<String> tenant = new ThreadLocal<>();
public static void setTenantName(String tenantName) { tenant.set(tenantName); }
public static String getTenantName() { return tenant.get(); }
}
|
创建 TenantAwareHikariDataSource
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.datasource;
import com.zaxxer.hikari.HikariDataSource; import de.bytefish.multitenancy.core.ThreadLocalStorage;
import java.sql.Connection; import java.sql.SQLException; import java.sql.Statement;
public class TenantAwareHikariDataSource extends HikariDataSource {
@Override public Connection getConnection() throws SQLException { Connection connection = super.getConnection();
try (Statement sql = connection.createStatement()) { sql.execute("SET app.current_tenant = '" + ThreadLocalStorage.getTenantName() + "'"); }
return connection; }
@Override public Connection getConnection(String username, String password) throws SQLException { Connection connection = super.getConnection(username, password);
try (Statement sql = connection.createStatement()) { sql.execute("SET app.current_tenant = '" + ThreadLocalStorage.getTenantName() + "'"); }
return connection; }
}
|
Web层
1、提取租户信息
有几种方法可以从传入请求中提取租户标识符。X-TenantIDWebservice 客户端将发送一个带有示例中名称的 HTTP 标头。在 Spring MVC 中,您可以实现 aHandlerInterceptorAdapter来拦截传入的请求并从中提取数据。
TenantNameInterceptor读取X-TenantID标头并将其值存储在ThreadLocalStorage.
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.web.interceptors;
import de.bytefish.multitenancy.core.ThreadLocalStorage; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
public class TenantNameInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Implement your logic to extract the Tenant Name here. Another way would be to // parse a JWT and extract the Tenant Name from the Claims in the Token. In the // example code we are just extracting a Header value: String tenantName = request.getHeader("X-TenantID");
// Always set the Tenant Name, so we avoid leaking Tenants between Threads even in the scenario, when no // Tenant is given. I do this because if somehow the afterCompletion Handler isn't called the Tenant Name // could still be persisted within the ThreadLocal: ThreadLocalStorage.setTenantName(tenantName);
return true; }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// After completing the request, make sure to erase the Tenant from the current Thread. It's // because Spring may reuse the Thread in the Thread Pool and you don't want to leak this // information: ThreadLocalStorage.setTenantName(null); } }
|
2、数据传输对象和转换器
您应该始终将 Web 层与域层分开。在理想的世界中,Web 层应该只关心接收和发送数据传输对象给消费者。它应该知道如何在数据传输对象和域模型之间进行转换,因此它可以使用域存储库。
数据传输对象使用CustomerDtoJackson 注释来提供 JSON 映射。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.web.model;
import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty;
public class CustomerDto {
private final Long id;
private final String firstName;
private final String lastName;
@JsonCreator public CustomerDto(@JsonProperty("id") Long id, @JsonProperty("firstName") String firstName, @JsonProperty("lastName") String lastName) { this.id = id; this.firstName = firstName; this.lastName = lastName; }
@JsonProperty("id") public Long getId() { return id; }
@JsonProperty("firstName") public String getFirstName() { return firstName; }
@JsonProperty("lastName") public String getLastName() { return lastName; } }
|
并且该类提供了两种方法在模型和模型Converters之间进行转换。CustomerDtoCustomer// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.web.converter;
import de.bytefish.multitenancy.model.Customer; import de.bytefish.multitenancy.web.model.CustomerDto;
import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport;
public class Converters {
private Converters() {
}
public static CustomerDto convert(Customer source) { if(source == null) { return null; }
return new CustomerDto(source.getId(), source.getFirstName(), source.getLastName()); }
public static Customer convert(CustomerDto source, String tenantName) { if(source == null) { return null; }
return new Customer(source.getId(), source.getFirstName(), source.getLastName(), tenantName); }
public static List<CustomerDto> convert(Iterable<Customer> customers) { return StreamSupport.stream(customers.spliterator(), false) .map(Converters::convert) .collect(Collectors.toList()); } }
|
控制器
使用 Spring MVC 实现 RESTful Web 服务需要我们实现一个RestController. 我们使用ICustomerRepository来查询数据库并使用Converters来在两种表示之间进行转换。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.web.controllers;
import de.bytefish.multitenancy.core.ThreadLocalStorage; import de.bytefish.multitenancy.model.Customer; import de.bytefish.multitenancy.repositories.ICustomerRepository; import de.bytefish.multitenancy.web.converter.Converters; import de.bytefish.multitenancy.web.model.CustomerDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import java.util.stream.StreamSupport;
@RestController public class CustomerController {
private final ICustomerRepository repository;
@Autowired public CustomerController(ICustomerRepository repository) { this.repository = repository; }
@GetMapping("/customers") public List<CustomerDto> getAll() { Iterable<Customer> customers = repository.findAll();
return Converters.convert(customers); }
@GetMapping("/customers/{id}") public CustomerDto get(@PathVariable("id") long id) { Customer customer = repository .findById(id) .orElse(null);
return Converters.convert(customer); }
@GetMapping("/async/customers") public List<CustomerDto> getAllAsync() throws ExecutionException, InterruptedException { return repository.findAllAsync() .thenApply(x -> Converters.convert(x)) .get(); }
@PostMapping("/customers") public CustomerDto post(@RequestBody CustomerDto customer) { // Get the current Tenant: String tenantName = ThreadLocalStorage.getTenantName();
// Convert to the Domain Object: Customer source = Converters.convert(customer, tenantName);
// Store the Entity: Customer result = repository.save(source);
// Return the DTO: return Converters.convert(result); }
@DeleteMapping("/customers/{id}") public void delete(@PathVariable("id") long id) { repository.deleteById(id); }
}
|
配置
要配置 Spring MVC,我们需要扩展 并将其WebMvcConfigurer添加TenantNameInterceptor到拦截器列表中。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy.web.configuration;
import de.bytefish.multitenancy.web.interceptors.TenantNameInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration public class WebMvcConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TenantNameInterceptor()); }
}
|
合在一起
最后,是时候使用 Spring Boot 将所有内容组合在一起了。我们所要做的就是为要使用的 定义Bean一个DataSource。这是TenantAwareHikariDataSource使用 Postgres 驱动程序和连接字符串。
我还为 Spring JPA 添加了一些健全的属性,因此 Spring 不会尝试自动检测数据库。
所有其他依赖项都由 Spring Boot 自动解决。
// Copyright (c) Philipp Wagner. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information.
package de.bytefish.multitenancy;
import com.zaxxer.hikari.HikariDataSource; import de.bytefish.multitenancy.datasource.TenantAwareHikariDataSource; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;
@SpringBootApplication @EnableAsync @EnableTransactionManagement public class SampleSpringApplication {
public static void main(String[] args) { SpringApplication.run(SampleSpringApplication.class, args); }
@Bean public DataSource dataSource() { HikariDataSource dataSource = new TenantAwareHikariDataSource();
dataSource.setInitializationFailTimeout(0); dataSource.setMaximumPoolSize(5); dataSource.setDataSourceClassName("org.postgresql.ds.PGSimpleDataSource"); dataSource.addDataSourceProperty("url", "jdbc:postgresql://127.0.0.1:5432/sampledb"); dataSource.addDataSourceProperty("user", "app_user"); dataSource.addDataSourceProperty("password", "app_user");
return dataSource; } }
|
警告去除
Spring Boot 引入了许多神奇的功能,可以用最少的编码使事情正常工作……有时约定优于配置会让人头疼。当 Spring Boot 启动时,Thread 中没有设置 Tenant,所以我们不能使用诸如自动检测数据库之类的东西。
所以我添加了一个属性文件application.properties来配置 Spring:
# Get Rid of the OIV Warning: spring.jpa.open-in-view=false # Show the SQL Statements fired by JPA: spring.jpa.show-sql=true # Set sane Spring Hibernate properties: spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl # Prevent JPA from trying to Initialize...: spring.jpa.database=postgresql # ... and do not Auto-Detect the Database: spring.datasource.initialize=false # Prevent Hibernate from Automatic Changes to the DDL Schema: spring.jpa.hibernate.ddl-auto=none
|
同样的事情发生在 Hibernate 内部,它尝试读取元数据来初始化 JDBC 设置。为了防止那些我们不想完成的连接,我添加了一个 hibernate.properties由 Hibernate 自动读取的属性文件:
hibernate.temp.use_jdbc_metadata_defaults=false
|
测试应用程序
我们首先将客户插入到 Tenant 的数据库中TenantOne:
> curl -H "X-TenantID: TenantOne" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Philipp\", \"lastName\" : \"Wagner\"}" http://localhost:8080/customers
{"id":13,"firstName":"Philipp","lastName":"Wagner"}
> curl -H "X-TenantID: TenantOne" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Max\", \"lastName\" : \"Mustermann\"}" http://localhost:8080/customers
{"id":14,"firstName":"Max","lastName":"Mustermann"}
|
获取所有客户的列表TenantOne现在将返回两个客户:> curl -H "X-TenantID: TenantOne" -X GET http://localhost:8080/customers
[{"id":13,"firstName":"Philipp","lastName":"Wagner"},{"id":14,"firstName":"Max","lastName":"Mustermann"}]
|
在请求所有客户的列表时TenantTwo返回一个空列表:> curl -H "X-TenantID: TenantTwo" -X GET http://localhost:8080/customers
[]
|
我们现在可以将客户插入TenantTwo数据库:> curl -H "X-TenantID: TenantTwo" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Hans\", \"lastName\" : \"Wurst\"}" http://localhost:8080/customers
{"id":15,"firstName":"Hans","lastName":"Wurst"}
|
查询TenantOne数据库仍然返回两个客户:> curl -H "X-TenantID: TenantOne" -X GET http://localhost:8080/customers
[{"id":13,"firstName":"Philipp","lastName":"Wagner"},{"id":14,"firstName":"Max","lastName":"Mustermann"}]
|
现在查询TenantTwo数据库将返回插入的客户:> curl -H "X-TenantID: TenantTwo" -X GET http://localhost:8080/customers
[{"id":15,"firstName":"Hans","lastName":"Wurst"}]
|
结论
使用 Spring Boot 和 Postgres 行级安全性提供多租户真的很容易。
如果您在项目中遇到问题,请随时在 GitHub 存储库中打开问题。