显示导航

使用 DATABASE 多租户时动态配置数据源

了解如何在动态创建新的数据源连接时使用 Grails 多租户功能的 DATABASE 模式。

作者:Sergio del Amo

Grails 版本 3.3.1

1 Grails 培训

Grails 培训 - 由那些创建并积极维护 Grails 框架的人员开发并交付!

2 入门

本指南从 JWT 自定义租户解析器 中中断的地方开始。请在开始本指南之前完成该指南以获得更好的理解。

2.1 你将需要

若要完成本指南,你需要以下内容

  • 一些时间

  • 一个合适的文本编辑器或 IDE

  • 已安装 JDK 1.7 或更高版本,且已适当地配置了 JAVA_HOME

2.2 如何完成指南

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

Grails 指南存储库包含两个文件夹

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

  • complete 完成的示例。它是通过对 initial 文件夹应用指南所介绍的步骤并实施这些更改而得出的结果。

要完成指南,请转到 initial 文件夹

  • cd 进入 grails-guides/grails-dynamic-multiple-datasources/initial

并按照以下章节中的说明进行操作。

如果进入 grails-guides/grails-dynamic-multiple-datasources/complete,你可以直接转到完整示例

3 编写应用程序

本指南使用多租户 DATABASE 模式。要了解更多信息,请阅读每个租户的数据库多租户指南。

本指南展示了使用多租户 DATABASE 模式创建 SaaS(软件即服务)应用程序时会看到的一个典型流程。

为了简化本指南,简化了数据库配置过程。

使用以下架构创建两个 MySQL 数据库。

CREATE TABLE `plan` (
  `id` bigint(20) NOT NULL,
  `version` bigint(20) NOT NULL,
  `title` varchar(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

第一个数据库应命名为 gru,第二个数据库应命名为 vector

在指南应用程序中,当用户注册时,会为用户指定一个与用户名相同的数据库,因此每个注册用户都有自己唯一的数据库。

在实际应用程序中,你可能会有更复杂的设置,其中可能涉及创建数据库、创建架构、以安全的方式将每个用户的数据库的网址和凭据保存在默认数据源等的数据库表中。

3.1 配置

在本指南中,我们将使用 MySQL,因此应将 MySQL 依赖项添加到 build.gradle

build.gradle
    runtime 'mysql:mysql-connector-java:5.1.40'

在 MySQL 数据库中创建一个名为 minions 的数据库。

application.yml 中配置默认数据源以指向此数据库。由于 dbCreate: update 配置,此数据库的架构将由 Hibernate 生成。

应用程序的每个用户的 dataSource 设置将进行动态配置并继承默认设置。

处理安全性的领域类 UserUserRoleRole 映射到默认 dataSource。

grails-app/conf/application.yml
hibernate:
    cache:
        queries: false
        use_second_level_cache: false
        use_query_cache: false
dataSource:
    pooled: true
    jmxExport: true
    driverClassName: com.mysql.jdbc.Driver
    dialect: org.hibernate.dialect.MySQL5InnoDBDialect
    username: root (1)
    password: root (1)
    dbCreate: update
    url: jdbc:mysql://127.0.0.1:8889/minions  (1)
1 根据你的 MySQL 安装配置这些设置。

application.yml 中将多租户模式更改为 DATABASE

grails-app/conf/application.yml
    gorm:
        multiTenancy:
            mode: DATABASE (1)
            tenantResolverClass: demo.CurrentUserByJwtTenantResolver (2)
        reactor:
            # Whether to translate GORM events into Reactor events
            # Disabled by default for performance reasons
            events: false
1 将多租户模式定义为 DATABASE
2 将租户解析器类设置为在通过 JWT 实现自定义租户解析器指南中编写的自定义类。

3.2 领域类

由于本指南使用了每个租户都有一个单独数据库的 DATABASE 多租户,因此你不再需要配置用于处理 tenantId 的列,因此可以删除 tenantId 属性

grails-app/domain/demo/Plan.groovy
package demo

import grails.gorm.MultiTenant

class Plan implements MultiTenant<Plan> { (1)
    String title
}
1 实施MultiTenant特性将此领域类视为多租户。

3.3 GORM 事件

创建一个名为 UserInsertedListener 的类。它会在插入新用户时创建一个新的连接源。

它使用 @Listener 注释同步侦听来自 GORM 的事件

src/main/groovy/demo/UserInsertedListener.groovy
package demo

import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.datastore.mapping.engine.event.PostInsertEvent
import org.grails.orm.hibernate.HibernateDatastore
import org.springframework.beans.factory.annotation.Autowired

@CompileStatic
@Slf4j
class UserInsertedListener {

    @Autowired
    HibernateDatastore hibernateDatastore

    @Autowired
    DatabaseProvisioningService databaseProvisioningService

    @Listener(User) (1)
    void onUserPostInsertEvent(PostInsertEvent event) { (2)
        String username = event.getEntityAccess().getPropertyValue("username")
        DatabaseConfiguration databaseConfiguration = databaseProvisioningService.findDatabaseConfigurationByUsername(username) (3)
        hibernateDatastore.getConnectionSources().addConnectionSource(databaseConfiguration.dataSourceName, databaseConfiguration.configuration) (4)
    }
}
1 同步侦听域类 User 的 GORM 事件
2 侦听 PostInsertEvent 事件
3 使用协作者来检索 DatabaseConfiguration 对象(见下文)。
4 使用 ConnectionSources API 来动态配置新数据源。

UserInsertedListener 定义为 Bean

grails-app/conf/spring/resources.groovy
...
import demo.UserInsertedListener
...
beans = {
...
    userInsertedListener(UserInsertedListener)
...
}

上一个侦听器使用了几类作为协作者

src/main/groovy/demo/DatabaseConfiguration.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
class DatabaseConfiguration {
    String dataSourceName
    Map configuration
}
grails-app/services/demo/DatabaseProvisioningService.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
class DatabaseProvisioningService {

    UserRoleService userRoleService

    List<DatabaseConfiguration> findAllDatabaseConfiguration() {
        List<String> usernames = userRoleService.findAllUsernameByAuthority(VillainService.ROLE_VILLAIN)
        usernames.collect { findDatabaseConfigurationByUsername(it) }
    }

    DatabaseConfiguration findDatabaseConfigurationByUsername(String username) {
        new DatabaseConfiguration(dataSourceName: username, configuration: configurationByUsername(username))
    }

    Map<String, Object> configurationByUsername(String username) {
        [
                'hibernate.hbm2ddl.auto':'none', (1)
                'username': 'root', (2)
                'password': 'root', (2)
                'url':"jdbc:mysql://127.0.0.1:8889/$username" (2)
        ] as Map<String, Object>
    }
}
1 等效于 dbCreate: none
2 将这些配置设置更改为与你的系统匹配。
上一个设置未指定 MySQL 驱动器或方言。这些是从在 application.yml 中配置的默认数据源中继承的

3.4 功能测试

我们对 使用 JWT 的自定义承租人解析器 指南的功能测试进行了少量修改,以验证插入新 User 时创建新连接源。

src/integration-test/groovy/demo/PlanControllerSpec.groovy
package demo

import grails.gorm.multitenancy.Tenants
import grails.plugins.rest.client.RestBuilder
import grails.testing.mixin.integration.Integration
import org.grails.orm.hibernate.HibernateDatastore
import org.springframework.beans.factory.annotation.Autowired
import spock.lang.Specification
import spock.lang.IgnoreIf

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class PlanControllerSpec extends Specification {
    PlanService planService
    UserService userService
    VillainService villainService
    RoleService roleService

    @Autowired
    HibernateDatastore hibernateDatastore

    RestBuilder rest = new RestBuilder()

    String accessToken(String u, String p) {
        def resp = rest.post("http://localhost:${serverPort}/api/login") {
            accept('application/json')
            contentType('application/json')
            json {
                username = u
                password = p
            }
        }
        if ( resp.status == 200 ) {
            return resp.json.access_token
        }
        null
    }

    def "Plans for current logged user are retrieved"() {
        when:
        User vector = villainService.saveVillain('vector', 'secret')

        then:
        hibernateDatastore.connectionSources.size() == old(hibernateDatastore.connectionSources.size()) + 1 (1)

        when:
        User gru = villainService.saveVillain('gru', 'secret')

        then:
        hibernateDatastore.connectionSources.size() == old(hibernateDatastore.connectionSources.size()) + 1 (1)

        Tenants.withId("gru") {
            planService.save('Steal the Moon')
        }
        Tenants.withId("vector") {
            planService.save('Steal a Pyramid')
        }

        when: 'login with the gru'
        String gruAccessToken = accessToken('gru', 'secret')

        then:
        gruAccessToken

        when:
        def resp = rest.get("http://localhost:${serverPort}/plan") {
            accept('application/json')
            header('Authorization', "Bearer ${gruAccessToken}")
        }

        then:
        resp.status == 200
        resp.json.toString() == '[{"title":"Steal the Moon"}]'

        when: 'login with the vector'
        String vectorAccessToken = accessToken('vector', 'secret')

        then:
        vectorAccessToken

        when:
        resp = rest.get("http://localhost:${serverPort}/plan") {
            accept('application/json')
            header('Authorization', "Bearer ${vectorAccessToken}")
        }

        then:
        resp.status == 200
        resp.json.toString() == '[{"title":"Steal a Pyramid"}]'

        cleanup:
        Tenants.withId("gru") {
            planService.deleteByTitle('Steal the Moon')
        }
        Tenants.withId("vector") {
            planService.deleteByTitle('Steal a Pyramid')
        }
        userService.deleteUser(gru)
        userService.deleteUser(vector)
        roleService.delete(VillainService.ROLE_VILLAIN)
    }
}
1 验证存在新连接源

3.5 在启动时添加连接源

当应用程序重新启动时,我们要为每个已注册用户连接数据源。修改 BootStrap.groovy 以实现此目标

grails-app/init/demo/BootStrap.groovy
package demo

import groovy.transform.CompileStatic
import org.grails.orm.hibernate.HibernateDatastore

@CompileStatic
class BootStrap {

    HibernateDatastore hibernateDatastore
    DatabaseProvisioningService databaseProvisioningService

    def init = { servletContext ->
        for (DatabaseConfiguration databaseConfiguration : databaseProvisioningService.findAllDatabaseConfiguration() ) { (1)
            hibernateDatastore.getConnectionSources().addConnectionSource(databaseConfiguration.dataSourceName, databaseConfiguration.configuration)
        }
    }

    def destroy = {
    }
}

4 运行测试

要运行测试

./grailsw
grails> test-app
grails> open test-report

./gradlew check
open build/reports/tests/index.html

5 Grails 帮助

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

OCI 是 Grails 的家

认识团队