显示导航

在使用 DATABASE 分库分表时动态配置数据源

在为每个注册用户动态创建新的数据源连接时,了解如何使用 Grails 分库分表功能 DATABASE 模式。

作者:Sergio del Amo

Grails 版本 4.0.1

1 Grails 培训

Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和提供!

2 开始

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

2.1 需要了解知识

要完成此指南,您需要以下知识

  • 一些时间

  • 一个好的文本编辑器或 IDE

  • 安装了 JDK 1.8 或更高版本,并已正确配置 JAVA_HOME

2.2 如何完成指南

要开始,请执行以下操作

或者

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

  • initial 初始项目。通常是一个简单的 Grails 应用程序,其中包含一些额外的代码,以便您快速入门。

  • complete 一个已完成的示例。它是完成指南介绍的步骤并将这些更改应用于 initial 文件夹的结果。

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

  • cdgrails-guides/grails-dynamic-multiple-datasources/initial

并遵循下一部分中的说明。

如果你 cdgrails-guides/grails-dynamic-multiple-datasources/complete,你可以直接转到已完成的范例

3 编写应用程序

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

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

为了简化本指南,数据库 provisioning 过程被简化了。

使用以下模式创建两个 MySQL 数据库。

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

第一个数据库应该称为gru,第二个应称为vector

在指南应用程序中,当用户注册时,便会为该用户按其用户名开通一个数据库,因此每个注册用户将拥有自己独特的数据库。

在实际应用程序中,你可能需要设置更复杂的流程,其中可能涉及创建数据库、创建模式、将每个用户数据库的数据库 URL 和凭据安全地保存在默认数据源的数据库表中,等等。

3.1 配置

本指南将使用 MySQL,因此应将 MySQL 依赖添加到build.gradle

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

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

application.yml中只配置默认数据源以指向此数据库。此数据库的模式将由 Hibernate 生成,因为配置了dbCreate: update

每个应用程序用户的 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)
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.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import org.grails.orm.hibernate.HibernateDatastore
import org.springframework.beans.factory.annotation.Autowired
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.IgnoreIf

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

    @Autowired
    HibernateDatastore hibernateDatastore

    @Shared HttpClient client

    @OnceBefore
    void init() {
        String baseUrl = "http://localhost:$serverPort"
        this.client  = HttpClient.create(baseUrl.toURL())
    }

    String accessToken(String u, String p) {
        HttpRequest request = HttpRequest.POST('/api/login', [username: u, password: p])
        HttpResponse<Map> resp = client.toBlocking().exchange(request, Map)
        if ( resp.status == HttpStatus.OK ) {
            return resp.body().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:
        HttpRequest request = HttpRequest.GET('/plan').bearerAuth(gruAccessToken)
        HttpResponse<String> resp = client.toBlocking().exchange(request, String)

        then:
        resp.status == HttpStatus.OK
        resp.body() == '[{"title":"Steal the Moon"}]'

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

        then:
        vectorAccessToken

        when:
        request = HttpRequest.GET('/plan').bearerAuth(vectorAccessToken)
        resp = client.toBlocking().exchange(request, String)


        then:
        resp.status == HttpStatus.OK
        resp.body() == '[{"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 帮助

对象计算,公司(OCI) 赞助了本指南的创建。可提供各种咨询和支持服务。

OCI 是 Grails 的家园

认识团队