显示导航

租户级数据库多租户制

了解如何利用 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 如何完成指南

要开始执行,请执行以下操作

Grails 指南仓库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,带有一些额外的代码,让你快速上手。

  • complete完成的示例。它是 GORM 和应用这些更改 `initial` 文件夹中给出的步骤后工作的结果。

若要完成指南,请转至 `initial` 文件夹

  • cdgrails-guides/database-per-tenant/initial

并遵循后续部分中的说明。

如果您 cdgrails-guides/database-per-tenant/complete 进入,便可直接进入已完成示例

3 编写应用程序

因为应用程序请求 GORM 6.1.x,所以第一步是在 gradle.properties 中设置您的 GORM 版本

gradle.properties
grailsVersion=3.3.0
gormVersion=6.1.6.RELEASE
gradleWrapperVersion=3.5

3.1 设置多租户

3.1.1 多租户模式

为了使用多租户,您需要设置 GORM 使用的多租户模式,因为它支持三种不同的模式

  • DATABASE - 使用每个租户的一个不同数据库连接

  • SCHEMA - 使用一个数据库,但为每个租户使用不同的物理 schema

  • DISCRIMINATOR - 使用一个数据库,但使用一个鉴别符列来划分数据

通常DATABASESCHEMA模式都可以被认为是物理隔离的,而DISCRIMINATOR模式需要更多注意,因为不同租户的数据存储在同一个物理数据库中

multi tenancy modes

在这种情况下,所需的多租户模式为DATABASE,可以使用 grails.gorm.multiTenancy.mode设置

grails-app/conf/application.yml
grails:
    gorm:
        multiTenancy:
            mode: DATABASE
            tenantResolverClass: org.grails.datastore.mapping.multitenancy.web.SessionTenantResolver

3.1.2 租户解析程序

请注意,除了模式外,上述示例配置了 tenantResolverClass 以用于解析租户。

tenantResolverClass 是一个实现了 TenantResolver 界面的类。

GORM 中包含一些内置 TenantResolver 实现,包括

表 1. 可用 TenantResolver 实现
类型 描述

SessionTenantResolver

使用一个名为 gorm.tenantId 的属性来从 HTTP 会话解析租户 id

CookieTenantResolver

使用一个名为 gorm.tenantId 的属性来从 HTTP cookie 解析租户 id

SubDomainTenantResolver

从当前子域解析租户 id。例如,如果子域为 foo.mycompany.com,则租户 id 为 foo

SystemPropertyTenantResolver

从一个名为 gorm.tenantId 的系统属性解析租户 id。主要用于测试

上述实现方法开箱即用,但是 GORM 灵活多变,您可以通过实现 TenantResolver 接口来实现自己的策略。

例如,如果您使用的是 Spring Security,您可以编写一个 TenantResolver 来从当前登录的用户那里解析租户 ID。

在这个示例中,我们将使用 SessionTenantResolver,然后在当前用户会话中存储租户 ID。

3.1.3 多个数据源

除了默认的 dataSource 之外,我们还为每个租户配置了两个额外的数据源 audiford

grails-app/conf/application.yml
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,允许访问每个已配置的数据库

grails-app/domain/example/Manufacturer.groovy
package example

class Manufacturer {
    String name

    static constraints = {
        name blank: false
    }
}

接下来是定义仅能由给定租户访问的域类

grails-app/domain/example/Engine.groovy
package example

import grails.gorm.MultiTenant

class Engine implements MultiTenant<Engine> { (1)
    Integer cylinders

    static constraints = {
        cylinders nullable: false
    }
}
grails-app/domain/example/Vehicle.groovy
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 特质

VehicleEngine 域类均实现了 MultiTenant 特质,这将导致 GORM 根据从已配置的 TenantResolver 返回的结果租户 ID 来解析要使用的数据库。

3.3 设置测试数据

要设置一些测试数据,可以修改 Application 类以实现 ApplicationRunner 接口在启动时运行事务逻辑

grails-app/init/example/Application.groovy
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 使用 @Transactionalrun 方法标记为事务
3 使用 saveAll 来保存两个 Manufacturer 实例

在本示例中,大约有两个 Manufacturer 实列将被保存,这将对应于此应用程序支持的两个租户。使用名称 AudiFord,它们与在 grails-app/conf/application.yml 中配置的数据源的名称相对应。

3.4 实现租户选择

为您的应用程序提供多租户支持的第一步是实现某种形式的租户选择。这可能是通过 DNS 子域名解决租户,或者如果您使用 Spring Security 认证,则可能是您的应用程序注册过程的一部分。

为了简化此示例,我们将实现一种简单的机制,该机制提供 UI 来将 tenantId 存储在用户的 HTTP 会话中。

首先,使用 create-controller 或您首选的 IDE 创建新的 ManufacturerController

$ grails create-controller Manufacturer

接下来,修改 UrlMappings.groovy 文件以将应用程序的根映射到 index 操作

grails-app/controllers/example/UrlMappings.groovy
'/'(controller: 'manufacturer')

然后,定义一个将列出所有制造商并将呈现 grails-app/views/index.gsp 视图的 index 操作。

grails-app/controllers/example/ManufacturerController.groovy
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 操作的链接

grails-app/views/index.gsp
<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 会话中

grails-app/controllers/example/ManufacturerController.groovy
@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 的每一次发生映射到重定向回制造商列表

grails-app/controllers/example/UrlMappings.groovy
'500' (controller: 'manufacturer', exception: TenantNotFoundException)

这样改动后,您将能够从主页中选择每个租户

available tenants

现在已经能够选择租户,让我们创建一个逻辑,该逻辑能够使用当前激活的租户。

3.5 编写多租户感知数据逻辑

关于构建每个租户使用一个唯一数据库连接的应用程序,面临的挑战之一是您必须从容扩展的方式管理多个持久性上下文。

对每个租户以及进入应用程序的每个请求都绑定 Hibernate 会话无法扩展,因此,您必须能够编写考虑到当前用于访问当前租户数据的事实(不是目前绑定到当前控制器操作的执行)的逻辑。

为简化此挑战,GORM 提供一系列 多租户变换,包括

表 1. 多租户变换
类型 描述

CurrentTenant

解决当前租户并绑定一个 Hibernate 会话到方法的范围

Tenant

解析一个特定的租户,并为方法的作用域绑定一个 Hibernate 会话

WithoutTenant

在没有租户的情况下在方法中执行某些逻辑

这些通常应该应用于 Grails 应用程序中的服务,当与 GORM 6.1 中引入的 GORM Data Services 概念结合使用时,它们的效果非常好。

要实现保存和检索 Vehicle 实例的逻辑,请创建一个新文件 grails-app/services/example/VehicleService.groovy,并在 CurrentTenantService 注解内对其进行标注

grails-app/services/example/VehicleService.groovy
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 支持的惯例之一 相对应的抽象方法

grails-app/services/example/VehicleService.groovy
@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 的属性

grails-app/controllers/example/VehicleController.groovy
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 映射

grails-app/controllers/example/UrlMappings.groovy
'/vehicles'(resources: 'vehicle')

现在,可以对查询逻辑添加对每个租户读取 Vehicle 实例。使用以下读取操作更新 VehicleController

grails-app/controllers/example/VehicleController.groovy
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,并对 savedelete 添加新的抽象方法

grails-app/services/example/VehicleService.groovy
abstract Vehicle save(String model,
                        Integer year)

abstract Vehicle delete(Serializable id)

为上述 savedelete 方法的实现将自动完成。

GORM 数据服务能够智能地为每个方法添加适当的事务语义(例如,对读取操作使用 readOnly)。不过,可以通过自己添加 @Transactional 注解来覆盖事务语义。

要实现更新操作,可以添加一个新方法以调用现有的抽象 find 方法

grails-app/services/example/VehicleService.groovy
@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 并在控制器操作中公开这些写入操作也是很简单的

grails-app/controllers/example/VehicleController.groovy
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 规范

src/test/groovy/example/VehicleControllerSpec.groovy
@Stepwise
class VehicleControllerSpec extends HibernateSpec implements ControllerUnitTest<VehicleController> {
        ...
}

如上所示,该测试扩展了 HibernateSpec

要简化测试过程,可以通过覆盖 HibernateSpecgetConfiguration() 方法来覆盖 tenantResolverClass

src/test/groovy/example/VehicleControllerSpec.groovy
@Override
Map getConfiguration() {
    [(Settings.SETTING_MULTI_TENANT_RESOLVER_CLASS): SystemPropertyTenantResolver]
}

这将允许你在测试中使用 SystemPropertyTenantResolver 来更改租户 ID。

下一步是提供一个 setup 方法,该方法为控制器配置 VehicleService

src/test/groovy/example/VehicleControllerSpec.groovy
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

src/test/groovy/example/VehicleControllerSpec.groovy
def cleanup() {
    System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, '')
}

在这样做了之后,测试控制器逻辑变得容易了,例如,使用没有数据来测试 index 动作

src/test/groovy/example/VehicleControllerSpec.groovy
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

src/test/groovy/example/VehicleControllerSpec.groovy
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)
}

测试更复杂的交互(例如,保存数据)也是可以的

src/test/groovy/example/VehicleControllerSpec.groovy
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 页面

src/integration-test/groovy/example/ManufacturersPage.groovy
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()
    }
}
src/integration-test/groovy/example/NewVehiclePage.groovy
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()
    }
}
src/integration-test/groovy/example/ShowVehiclePage.groovy
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()
    }
}
src/integration-test/groovy/example/VehiclesPage.groovy
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()
    }
}

我们在功能测试的帮助下测试租户选择

src/integration-test/groovy/example/TenantSelectionFuncSpec.groovy
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 上启动应用程序。

现在执行以下步骤

  1. 导航到主页并选择“奥迪”

  2. 输入 Vehicle 的数据来创建一个新的 Vehicle

  3. 请注意,数据将在 audi 数据库中创建。

然后如果您导航回到主页并选择“福特”,当前租户将发生切换,您可以看到,如果您查看“福特”的车辆数据,应用程序现在将使用 ford 数据库,有效隔离了这两个租户之间的数据。

7 您需要对 Grails 提供帮助吗?

Object Computing, Inc.(OCI)赞助了此指南的创建。可提供各种咨询和支持服务。

OCI 是 Grails 的家园

认识团队