租户级数据库多租户制
了解如何利用 GORM 6.1 的多租户功能来构建使用每个租户一个独立数据库的应用程序
作者:Graeme·罗彻
Grails 版本 3.3.0
1 Grails 培训
**Grails 培训** - 由创建和积极维护 Grails 框架的人员开发和交付!.
2 开始入门
在此指南中,你将构建一个多租户应用程序,它利用 Grails 和 GORM 6.1 的“租户级数据库”。
租户级数据库允许你使用一个唯一的租户标识符将不同的租户(用户)重定向到不同的物理数据库。
2.1 需要准备什么
要完成本指南,你需要以下内容
-
一些时间
-
一款不错的文本编辑器或 IDE
-
已安装 JDK 1.8 或更高版本,并正确配置了
JAVA_HOME
2.2 如何完成指南
要开始执行,请执行以下操作
-
下载并解压缩源代码
或
-
克隆Git 仓库
git clone https://github.com/grails-guides/database-per-tenant.git
Grails 指南仓库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,带有一些额外的代码,让你快速上手。 -
complete
完成的示例。它是 GORM 和应用这些更改 `initial` 文件夹中给出的步骤后工作的结果。
若要完成指南,请转至 `initial` 文件夹
-
cd
为grails-guides/database-per-tenant/initial
并遵循后续部分中的说明。
如果您 cd 为 grails-guides/database-per-tenant/complete 进入,便可直接进入已完成示例 |
3 编写应用程序
因为应用程序请求 GORM 6.1.x,所以第一步是在 gradle.properties
中设置您的 GORM 版本
grailsVersion=3.3.0
gormVersion=6.1.6.RELEASE
gradleWrapperVersion=3.5
3.1 设置多租户
3.1.1 多租户模式
为了使用多租户,您需要设置 GORM 使用的多租户模式,因为它支持三种不同的模式
-
DATABASE - 使用每个租户的一个不同数据库连接
-
SCHEMA - 使用一个数据库,但为每个租户使用不同的物理 schema
-
DISCRIMINATOR - 使用一个数据库,但使用一个鉴别符列来划分数据
通常DATABASE
和SCHEMA
模式都可以被认为是物理隔离的,而DISCRIMINATOR
模式需要更多注意,因为不同租户的数据存储在同一个物理数据库中
在这种情况下,所需的多租户模式为DATABASE
,可以使用 grails.gorm.multiTenancy.mode
设置
grails:
gorm:
multiTenancy:
mode: DATABASE
tenantResolverClass: org.grails.datastore.mapping.multitenancy.web.SessionTenantResolver
3.1.2 租户解析程序
请注意,除了模式外,上述示例配置了 tenantResolverClass
以用于解析租户。
tenantResolverClass
是一个实现了 TenantResolver 界面的类。
GORM 中包含一些内置 TenantResolver
实现,包括
类型 | 描述 |
---|---|
使用一个名为 |
|
使用一个名为 |
|
从当前子域解析租户 id。例如,如果子域为 |
|
从一个名为 |
上述实现方法开箱即用,但是 GORM 灵活多变,您可以通过实现 TenantResolver
接口来实现自己的策略。
例如,如果您使用的是 Spring Security,您可以编写一个 TenantResolver 来从当前登录的用户那里解析租户 ID。 |
在这个示例中,我们将使用 SessionTenantResolver
,然后在当前用户会话中存储租户 ID。
3.1.3 多个数据源
除了默认的 dataSource
之外,我们还为每个租户配置了两个额外的数据源 audi
和 ford
dataSource:
pooled: true
jmxExport: true
driverClassName: org.h2.Driver
username: sa
password: ''
dbCreate: create-drop
url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
dataSources:
ford:
dbCreate: create-drop
url: jdbc:h2:mem:fordDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
audi:
dbCreate: create-drop
url: jdbc:h2:mem:audiDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
数据源的名称与配置的 TenantResolver
应返回的租户 ID 对应。
如果默认数据源也可以被视为一个租户,那么应将 ConnectionSources.DEFAULT 的值返回为租户 ID。 |
3.2 创建域类
为您应用程序创建域类时,您通常会有多租户域类和非多租户域类。
对于不会使用多租户的域类,只需按照正常的方式定义它们,然后它们将映射到默认的 dataSource
。
在这个示例中,Manufacturer
将提供租户 ID。Manufacturer
的名称将用作租户 ID
,允许访问每个已配置的数据库
package example
class Manufacturer {
String name
static constraints = {
name blank: false
}
}
接下来是定义仅能由给定租户访问的域类
package example
import grails.gorm.MultiTenant
class Engine implements MultiTenant<Engine> { (1)
Integer cylinders
static constraints = {
cylinders nullable: false
}
}
package example
import grails.gorm.MultiTenant
class Vehicle implements MultiTenant<Vehicle> { (1)
String model
Integer year
static hasMany = [engines: Engine]
static constraints = {
model blank:false
year min:1980
}
}
1 | 两个域类均实现 MultiTenant 特质 |
Vehicle
和 Engine
域类均实现了 MultiTenant 特质,这将导致 GORM 根据从已配置的 TenantResolver 返回的结果租户 ID 来解析要使用的数据库。
3.3 设置测试数据
要设置一些测试数据,可以修改 Application
类以实现 ApplicationRunner
接口在启动时运行事务逻辑
package example
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@CompileStatic
class Application extends GrailsAutoConfiguration implements ApplicationRunner { (1)
static void main(String[] args) {
GrailsApp.run(Application, args)
}
@Override
@Transactional
void run(ApplicationArguments args) throws Exception { (2)
Manufacturer.saveAll( (3)
new Manufacturer(name: 'Audi'),
new Manufacturer(name: 'Ford')
)
}
}
1 | 实现 ApplicationRunner 接口 |
2 | 使用 @Transactional 将 run 方法标记为事务 |
3 | 使用 saveAll 来保存两个 Manufacturer 实例 |
在本示例中,大约有两个 Manufacturer
实列将被保存,这将对应于此应用程序支持的两个租户。使用名称 Audi
和 Ford
,它们与在 grails-app/conf/application.yml
中配置的数据源的名称相对应。
3.4 实现租户选择
为您的应用程序提供多租户支持的第一步是实现某种形式的租户选择。这可能是通过 DNS 子域名解决租户,或者如果您使用 Spring Security 认证,则可能是您的应用程序注册过程的一部分。
为了简化此示例,我们将实现一种简单的机制,该机制提供 UI 来将 tenantId
存储在用户的 HTTP 会话中。
首先,使用 create-controller
或您首选的 IDE 创建新的 ManufacturerController
$ grails create-controller Manufacturer
接下来,修改 UrlMappings.groovy
文件以将应用程序的根映射到 index
操作
'/'(controller: 'manufacturer')
然后,定义一个将列出所有制造商并将呈现 grails-app/views/index.gsp
视图的 index
操作。
package example
import org.grails.datastore.mapping.multitenancy.web.SessionTenantResolver
import grails.gorm.transactions.ReadOnly
import groovy.transform.CompileStatic
@CompileStatic
class ManufacturerController {
@ReadOnly
def index() {
render view: '/index', model: [manufacturers: Manufacturer.list()]
}
}
在 grails-app/views/index.gsp
文件中,只需遍历每个结果并创建一个指向 select
操作的链接
<div id="controllers" role="navigation">
<h2>Available Manufacturers:</h2>
<ul>
<g:each var="m" in="${manufacturers}">
<li class="controller">
<g:link controller="manufacturer" action="select" id="${m.name}">${m.name}</g:link>
</li>
</g:each>
</ul>
</div>
select
操作选择当前租户并将该租户存储在当前用户的 HTTP 会话中
@ReadOnly
def select(String id) {
Manufacturer m = Manufacturer.where {
name == id
}.first() (1)
if ( m ) {
session.setAttribute(SessionTenantResolver.ATTRIBUTE, m.name.toLowerCase()) (2)
redirect controller: 'vehicle' (3)
}
else {
render status: 404
}
}
1 | 获取由提供的 id 标识的一个制造商 |
2 | 选择的租户存储在会话属性中。 |
select
操作将找到一个 Manufacturer
,并将该 Manufacturer
的名称(使用小写,以便对应一个已配置的数据源)存储为 HTTP 会话中的当前租户。
这会导致 SessionTenantResolver 从 HTTP 会话中解决正确的租户 ID。
最后,为改善错误处理,您可以将 TenantNotFoundException
的每一次发生映射到重定向回制造商列表
'500' (controller: 'manufacturer', exception: TenantNotFoundException)
这样改动后,您将能够从主页中选择每个租户
现在已经能够选择租户,让我们创建一个逻辑,该逻辑能够使用当前激活的租户。
3.5 编写多租户感知数据逻辑
关于构建每个租户使用一个唯一数据库连接的应用程序,面临的挑战之一是您必须从容扩展的方式管理多个持久性上下文。
对每个租户以及进入应用程序的每个请求都绑定 Hibernate 会话无法扩展,因此,您必须能够编写考虑到当前用于访问当前租户数据的事实(不是目前绑定到当前控制器操作的执行)的逻辑。
为简化此挑战,GORM 提供一系列 多租户变换,包括
类型 | 描述 |
---|---|
解决当前租户并绑定一个 Hibernate 会话到方法的范围 |
|
解析一个特定的租户,并为方法的作用域绑定一个 Hibernate 会话 |
|
在没有租户的情况下在方法中执行某些逻辑 |
这些通常应该应用于 Grails 应用程序中的服务,当与 GORM 6.1 中引入的 GORM Data Services 概念结合使用时,它们的效果非常好。
要实现保存和检索 Vehicle
实例的逻辑,请创建一个新文件 grails-app/services/example/VehicleService.groovy
,并在 CurrentTenant 和 Service 注解内对其进行标注
import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Join
import grails.gorm.services.Service
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@Service(Vehicle) (1)
@CurrentTenant (2)
@CompileStatic
abstract class VehicleService {
}
1 | Service 转换将确保 GORM 可以实现任何抽象方法都得到实现 |
2 | CurrentTenant 转换将确保对服务执行的任何方法首先解析当前的租户,并为已解析的数据库连接绑定一个 Hibernate 会话。 |
该类是 abstract ,因为很多方法都将由 GORM 为你实现。 |
现在,让我们看看如何实现多租户应用程序的查询逻辑。
3.5.1 执行支持多租户的查询
要在 GORM Data Service 中实现多租户查询,只需添加与 GORM 支持的惯例之一 相对应的抽象方法
@Join('engines') (1)
abstract List<Vehicle> list(Map args ) (2)
abstract Integer count() (3)
@Join('engines')
abstract Vehicle find(Serializable id) (4)
1 | 每个查询方法都用 @Join 进行标注 |
2 | list 方法返回一个 Vehicle 实例的列表,并可选地采用一个映射作为参数来执行分页 |
3 | count 方法统计 Vehicle 实例的数量 |
4 | find 方法通过 id 查找单个 Vehicle |
对 @Join
的使用需要进一步说明。请注意,在多租户应用程序中,会针对为当前租户 ID 找到的连接创建一个新的 Hibernate 会话。
然而,一旦该方法完成,此会话就会关闭,这意味着由于会话已关闭,查询未加载的任何关联都可能导致 LazyInitializationException
。
因此,你的查询始终返回渲染视图所需的数据至关重要。这样通常会导致查询性能更好,实际上将帮助你设计一款性能更好的应用程序。
@Join
注解是一种实现联结查询的简单方法,但在某些情况下,使用 JPA-QL 查询 可能更简单。
现在该编写一个可以使用这些新定义的方法的控制器了。首先,使用 create-controller
命令或你喜欢的 IDE 创建一个新类 grails-app/controllers/example/VehicleController.groovy
。
VehicleController
应定义引用先前创建的 VehicleService
的属性
import static org.springframework.http.HttpStatus.NOT_FOUND
import grails.gorm.multitenancy.CurrentTenant
import grails.validation.ValidationException
import groovy.transform.CompileStatic
@CompileStatic
class VehicleController {
static allowedMethods = [save: 'POST', update: 'PUT', delete: 'DELETE']
VehicleService vehicleService
...
}
现在运行 grails generate-views
以生成一些默认 GSP 视图,它们可以呈现 Vehicle
实例
$ grails generate-views example.Vehicle
接下来,在 UrlMappings.groovy
文件中添加一条分目,将 /vehicles
URI 映射
'/vehicles'(resources: 'vehicle')
现在,可以对查询逻辑添加对每个租户读取 Vehicle
实例。使用以下读取操作更新 VehicleController
def index(Integer max) {
params.max = Math.min(max ?: 10, 100)
respond vehicleService.list(params), model: [vehicleCount: vehicleService.count()]
}
def show(Long id) {
Vehicle vehicle = id ? vehicleService.find(id) : null
respond vehicle
}
find
操作和 show
操作都使用 VehicleService
来查找 Vehicle
实例。VehicleService
将确保解决正确的租户,为每个租户返回正确的数据。
3.5.2 执行多租户更新操作
要添加用于执行写入操作的逻辑,只需修改 VehicleService
,并对 save
和 delete
添加新的抽象方法
abstract Vehicle save(String model,
Integer year)
abstract Vehicle delete(Serializable id)
为上述 save
和 delete
方法的实现将自动完成。
GORM 数据服务能够智能地为每个方法添加适当的事务语义(例如,对读取操作使用 readOnly )。不过,可以通过自己添加 @Transactional 注解来覆盖事务语义。 |
要实现更新操作,可以添加一个新方法以调用现有的抽象 find
方法
@Transactional
Vehicle update( Serializable id, (5)
String model,
Integer year) {
Vehicle vehicle = find(id)
if (vehicle != null) {
vehicle.model = model
vehicle.year = year
vehicle.save(failOnError:true)
}
vehicle
}
这演示了 GORM 数据服务的一个重要概念:可以轻松地混用自己定义的方法和 GORM 自动为自己实现的方法。
调用 VehicleService
并在控制器操作中公开这些写入操作也是很简单的
def save(String model, Integer year) {
try {
Vehicle vehicle = vehicleService.save(model, year)
flash.message = 'Vehicle created'
redirect vehicle
} catch (ValidationException e) {
respond e.errors, view: 'create'
}
}
def update(Long id, String model, Integer year) {
try {
Vehicle vehicle = vehicleService.update(id, model, year)
if (vehicle == null) {
notFound()
}
else {
flash.message = 'Vehicle updated'
redirect vehicle
}
} catch (ValidationException e) {
respond e.errors, view: 'edit'
}
}
protected void notFound() {
flash.message = 'Vehicle not found'
redirect uri: '/vehicles', status: NOT_FOUND
}
def delete(Long id) {
Vehicle vehicle = vehicleService.delete(id)
if (vehicle == null) {
notFound()
}
else {
flash.message = 'Vehicle Deleted'
redirect action: 'index', method: 'GET'
}
}
4 多租户单元测试
使用多租户的控制器逻辑的测试需要特别考虑。
幸运的是,GORM 6.1 使编写单元测试变得相对简单。
要为 VehicleController
类编写单元测试,请创建一个新的 src/test/groovy/example/VehicleControllerSpec.groovy
Spock 规范
@Stepwise
class VehicleControllerSpec extends HibernateSpec implements ControllerUnitTest<VehicleController> {
...
}
如上所示,该测试扩展了 HibernateSpec
。
要简化测试过程,可以通过覆盖 HibernateSpec
的 getConfiguration()
方法来覆盖 tenantResolverClass
@Override
Map getConfiguration() {
[(Settings.SETTING_MULTI_TENANT_RESOLVER_CLASS): SystemPropertyTenantResolver]
}
这将允许你在测试中使用 SystemPropertyTenantResolver 来更改租户 ID。
下一步是提供一个 setup
方法,该方法为控制器配置 VehicleService
VehicleService vehicleService (1)
def setup() {
System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'audi') (2)
vehicleService = hibernateDatastore.getService(VehicleService) (3)
controller.vehicleService = vehicleService (4)
}
1 | 将 vehicleService 定义为单元测试的属性 |
2 | 为了测试的目的,将租户 ID 设为 audi |
3 | 从 GORM 查找 VehicleService 实现 |
4 | 将 VehicleService 赋值给控制器进行测试 |
为了确保正确清理,您还应该在 cleanup
方法中清除租户 ID
def cleanup() {
System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, '')
}
在这样做了之后,测试控制器逻辑变得容易了,例如,使用没有数据来测试 index
动作
void 'Test the index action returns the correct model'() {
when: 'The index action is executed'
controller.index()
then: 'The model is correct'
!model.vehicleList
model.vehicleCount == 0
}
您还可以编写测试来测试没有租户 ID 的情况,方法是清除租户 ID
void 'Test the index action with no tenant id'() {
when: 'there is no tenant id'
System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, '')
controller.index()
then:
thrown(TenantNotFoundException)
}
测试更复杂的交互(例如,保存数据)也是可以的
void 'Test the save action correctly persists an instance'() {
when: 'The save action is executed with an invalid instance'
request.contentType = FORM_CONTENT_TYPE
request.method = 'POST'
controller.save('', 1900)
then: 'The create view is rendered again with the correct model'
model.vehicle != null
view == 'create'
when: 'The save action is executed with a valid instance'
response.reset()
controller.save('A5', 2011)
then: 'A redirect is issued to the show action'
response.redirectedUrl == '/vehicles/1'
controller.flash.message != null
vehicleService.count() == 1
}
请注意,在上文的测试断言中我们使用了 vehicleService
,它确保在进行断言时使用正确的数据库连接。
5 功能测试
借助 Geb Pages 映射 CRUD 页面
package example
import geb.Page
class ManufacturersPage extends Page {
static at = { $('h2').text().contains('Available Manufacturers') }
static content = {
audiLink { $('a', text: 'Audi') }
fordLink { $('a', text: 'Ford') }
}
void selectAudi() {
audiLink.click()
}
void selectFord() {
fordLink.click()
}
}
package example
import geb.Page
class NewVehiclePage extends Page {
static at = { title.contains('Create Vehicle') }
static content = {
inputModel { $('input', name: 'model') }
inputYear { $('input', name: 'year') }
createButton { $('input', name: 'create') }
}
void newVehicle(String model, int year) {
inputModel << model
inputYear = year
createButton.click()
}
}
package example
import geb.Page
class ShowVehiclePage extends Page {
static at = { title.contains('Show Vehicle') }
static content = {
listButton { $('a', text: 'Vehicle List') }
}
void vehicleList() {
listButton.click()
}
}
package example
import geb.Page
class VehiclesPage extends Page {
static at = { title.contains('Vehicle List') }
static content = {
newVehicleLink { $('a', text: 'New Vehicle') }
vehiclesRows { $('tbody tr') }
}
void newVehicle() {
newVehicleLink.click()
}
int numberOfVehicles() {
vehiclesRows.size()
}
}
我们在功能测试的帮助下测试租户选择
package example
import geb.spock.GebSpec
import grails.testing.mixin.integration.Integration
@Integration
class TenantSelectionFuncSpec extends GebSpec {
def "it is possible to change tenants and get different lists of vehicles"() {
when:
go '/'
then:
at ManufacturersPage
when:
selectAudi()
then:
at VehiclesPage
when:
newVehicle()
then:
at NewVehiclePage
when:
newVehicle('A5', 2000)
then:
at ShowVehiclePage
when:
vehicleList()
then:
at VehiclesPage
numberOfVehicles() == 1
when:
newVehicle()
then:
at NewVehiclePage
when:
newVehicle('A3', 2001)
then:
at ShowVehiclePage
when:
vehicleList()
then:
at VehiclesPage
numberOfVehicles() == 2
when:
go '/'
then:
at ManufacturersPage
when:
selectFord()
then:
at VehiclesPage
when:
newVehicle()
then:
at NewVehiclePage
when:
newVehicle('KA', 1996)
then:
at ShowVehiclePage
when:
vehicleList()
then:
at VehiclesPage
numberOfVehicles() == 1
}
}
6 运行应用程序
要运行应用程序,请使用 ./gradlew bootRun
命令,这将在端口 8080 上启动应用程序。
现在执行以下步骤
-
导航到主页并选择“奥迪”
-
输入
Vehicle
的数据来创建一个新的Vehicle
-
请注意,数据将在
audi
数据库中创建。
然后如果您导航回到主页并选择“福特”,当前租户将发生切换,您可以看到,如果您查看“福特”的车辆数据,应用程序现在将使用 ford
数据库,有效隔离了这两个租户之间的数据。