显示导航

基于当前登录用户自定义租户解析器

学习如何创建自定义租户解析器并使用 Grails 多租户功能根据当前登录用户或 JWT 切换租户。

作者:Sergio del Amo

Grails 版本 4.0.1

1 Grails 培训

Grails 培训 - 通过创建和积极维护 Grails 框架的人员制定和交付!。

2 开始

GORM 多租户支持包括 内置租户解析器。在本指南中,您将创建自己的租户解析器,根据 Grails rest-api 应用程序中登录的用户切换租户,该应用程序使用 Spring Security REST 插件

2.1 你需要什么

为完成此指南,您需要以下内容

  • 一些时间

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

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

2.2 如何完成指南

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

Grails 指南代码库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用,其中有些其他代码可以给你一些入门优势。

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

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

  • cd进入grails-guides/grails-custom-security-tenant-resolver/initial

然后按后续部分的指导操作。

如果您cd进入grails-guides/grails-custom-security-tenant-resolver/complete,即可直接进入已完成示例

3 编写应用程序

本指南使用多租户 DISCRIMINATOR 模式。要了解详情,请阅读单数据库多租户鉴别列指南。

3.1 创建域类

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

import grails.gorm.MultiTenant

class Plan implements MultiTenant<Plan> { (1)
    String title
    String username

    static mapping = {
        tenantId name: 'username' (2)
    }
}
1 实现MultiTenant特性,将此域类视为多租户。
2 将租户标识符设置为名为username的列

创建一个接口PlanService,它是GORM 数据服务

grails-app/services/demo/PlanDataService.groovy
package demo

import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service

@Service(Plan) (1)
@CurrentTenant (2)
interface PlanDataService {
    List<Plan> findAll()
    Plan save(String title)
    void deleteByTitle(String title)
}
1 使用grails.gorm.services.Service注释对其进行注释,其中包含服务所应用到的域类。
2 解析此类的上下文中的当前租户

3.2 控制器

创建一个简单控制器,该控制器与之前定义的PlanService服务配合使用。

grails-app/controllers/demo/PlanController.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
class PlanController {
    PlanDataService planDataService

    def index() {
        [planList: planDataService.findAll()]
    }
}

借助JSON 视图,我们将Plan实例呈现为 JSON

grails-app/views/plan/index.gson
import demo.Plan

model {
    Iterable<Plan> planList
}

json tmpl.plan('plan', planList)
grails-app/views/plan/_plan.gson
import demo.Plan

model {
    Plan plan
}

json {
    title plan.title
}

3.3 连接安全

创建安全域类。您可以使用 Spring Security Core s2-quickstart创建这些类。

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

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {

    private static final long serialVersionUID = 1

    String username
    String password
    boolean enabled = true
    boolean accountExpired
    boolean accountLocked
    boolean passwordExpired

    Set<Role> getAuthorities() {
        (UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
    }

    static constraints = {
        password nullable: false, blank: false, password: true
        username nullable: false, blank: false, unique: true
    }

    static mapping = {
            password column: '`password`'
    }
}
grails-app/domain/demo/Role.groovy
package demo

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable {

        private static final long serialVersionUID = 1

        String authority

        static constraints = {
                authority nullable: false, blank: false, unique: true
        }

        static mapping = {
                cache true
        }
}

创建一个类UserPasswordEncoderListener,用于处理用户的密码编码。

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

src/main/groovy/demo/UserPasswordEncoderListener.groovy
package demo

import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.beans.factory.annotation.Autowired
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic

@CompileStatic
class UserPasswordEncoderListener {

    @Autowired
    SpringSecurityService springSecurityService

    @Listener(User)
    void onPreInsertEvent(PreInsertEvent event) {
        encodePasswordForEvent(event)
    }

    @Listener(User)
    void onPreUpdateEvent(PreUpdateEvent event) {
        encodePasswordForEvent(event)
    }

    private void encodePasswordForEvent(AbstractPersistenceEvent event) {
        if (event.entityObject instanceof User) {
            User u = event.entityObject as User
            if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
                event.getEntityAccess().setProperty('password', encodePassword(u.password))
            }
        }
    }

    private String encodePassword(String password) {
        springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
    }
}

将先前的监听程序定义为 Bean

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

application.yml中配置 Spring Security Core

grails-app/conf/application.yml
grails
grails:
    plugin:
        springsecurity:
            rest:
                token:
                    storage:
                        jwt:
                            secret: qrD6h8K6S9503Q06Y6Rfk21TErImPYqa # change this for a new one
            securityConfigType: InterceptUrlMap
            userLookup:
                userDomainClassName: demo.User
                authorityJoinClassName: demo.UserRole
            authority:
                className: demo.Role
            filterChain:
                chainMap:
                    - # Stateless chain
                        pattern: /**
                        filters: 'JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
            interceptUrlMap:
                -
                    pattern: /
                    access:
                        - ROLE_VILLAIN
                -
                    pattern: /error
                    access:
                        - permitAll
                -
                    pattern: /api/login
                    access:
                        - ROLE_ANONYMOUS
                -
                    pattern: /api/validate
                    access:
                        - ROLE_ANONYMOUS
                -
                    pattern: /oauth/access_token
                    access:
                        - ROLE_ANONYMOUS
                -
                    pattern: /plan
                    access:
                        - ROLE_VILLAIN

创建多个GORM 数据服务,以使用涉及我们应用程序安全的域类。

grails-app/services/demo/RoleDataService.groovy
package demo

import grails.gorm.services.Service

@Service(Role)
interface RoleDataService {
    void delete(Serializable id)
    void deleteByAuthority(String authority)
    Role findByAuthority(String authority)
    Role saveByAuthority(String authority)
}
grails-app/services/demo/UserDataService.groovy
package demo

import grails.gorm.services.Service
import groovy.transform.CompileStatic

@CompileStatic
@Service(User)
interface UserDataService {
    User save(String username, String password)
    void delete(Serializable id)
    User findByUsername(String username)
}
grails-app/services/demo/UserRoleDataService.groovy
package demo

import grails.gorm.services.Query
import grails.gorm.services.Service
import groovy.transform.CompileStatic

@Service(UserRole)
@CompileStatic
interface UserRoleDataService {

    @Query("""select $user.username from ${UserRole userRole}
    inner join ${User user = userRole.user}
    inner join ${Role role = userRole.role}
    where $role.authority = $authority""")
    List<String> findAllUsernameByAuthority(String authority)

    UserRole save(User user, Role role)

    void delete(User user, Role role)

    void deleteByUser(User user)
}

创建一个服务来处理具有特定角色的用户创建。

grails-app/services/demo/UserService.groovy
package demo

import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic

@CompileStatic
class UserService {
    RoleDataService roleDataService

    UserRoleDataService userRoleDataService

    UserDataService userDataService

    @Transactional
    User save(String username, String password, String authority) {
        Role role = roleDataService.findByAuthority(authority)
        if ( !role ) {
            role = roleDataService.saveByAuthority(authority)
        }
        User user = userDataService.save(username, password)
        userRoleDataService.save(user, role)
        user
    }
}

3.4 自定义租户解析程序

下表包含所有随 GORM 提供且开箱即用的TenantResolver实现。已将包名从org.grails.datastore.mapping缩写为o.g.d.m以求简洁

名称 描述

o.g.d.m.multitenancy.resolvers.FixedTenantResolver

针对固定租户 ID 解析

o.g.d.m.multitenancy.resolvers.SystemPropertyTenantResolver

从名为 `gorm.tenantId` 的系统属性中解析租户 ID

o.g.d.m.multitenancy.web.SubDomainTenantResolver

通过 DNS 从子域名解析租户 ID

o.g.d.m.multitenancy.web.CookieTenantResolver

默认情况下,从名为 `gorm.tenantId` 的 HTTP cookie 中解析当前租户

o.g.d.m.multitenancy.web.SessionTenantResolver

默认情况下,通过使用属性 `gorm.tenantId` 从 HTTP 会话中解析当前租户

不过,你可以轻松创建自定义租户解析器。为此,创建一个实现 `org.grails.datastore.mapping.multitenancy.TenantResolver` 的类。

以下部分将向你展示如何创建两个自定义租户解析器。

3.5 通过 JWT 的租户解析器

Grails Spring Security REST 插件 允许你使用存在于 `Authorization` 标头中的 JWT 令牌对你的应用程序进行身份验证。

示例:对 `path/plan` 的请求中包含 `Authorization` 标头中的 JWT 令牌。

## Request Duplicate
curl "http://localhost:8080/plan" \
     -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9....." \
     -H "Accept: application/json"

我们可以创建自定义租户解析器,它会提取 JWT 令牌、重新注入它并提取用户名。

src/main/groovy/demo/CurrentUserByJwtTenantResolver.groovy
package demo

import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.rest.token.storage.TokenStorageService
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest

import javax.servlet.http.HttpServletRequest

@CompileStatic
class CurrentUserByJwtTenantResolver implements AllTenantsResolver { (1)

    public static final String HEADER_NAME = 'Authorization'
    public static final String HEADER_VALUE_PREFFIX = 'Bearer '

    String headerName = HEADER_NAME
    String headerValuePreffix = HEADER_VALUE_PREFFIX

    @Autowired
    TokenStorageService tokenStorageService

    @Override
    Serializable resolveTenantIdentifier() throws TenantNotFoundException {

        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes()
        if(requestAttributes instanceof ServletWebRequest) {

            HttpServletRequest httpServletRequest = ((ServletWebRequest) requestAttributes).getRequest()
            String token = httpServletRequest.getHeader(headerName.toLowerCase())
            if ( !token ) {
                throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
            }

            if (token.startsWith(headerValuePreffix)) {
                token = token.substring(headerValuePreffix.length())
            }
            UserDetails userDetails = tokenStorageService.loadUserByToken(token)
            String username = userDetails?.username
            if ( username ) {
                return username
            }
            throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
        }
        throw new TenantNotFoundException("Tenant could not be resolved outside a web request")
    }

    @Override
    Iterable<Serializable> resolveTenantIds() {
        User.withTransaction(readOnly: true) {
            new DetachedCriteria(User)
                    .distinct('username')
                    .list()  as Iterable<Serializable>
        }
    }
}
1 当使用基于鉴别器的多租户时,你可能需要在你希望在任何时间迭代所有可用租户时,在你的 TenantResolver 中实现 AllTenantsResolver 接口。`AllTenantsResolver 扩展了 `TenantResolver`。若要创建你自己的租户解析器,请实现 `org.grails.datastore.mapping.multitenancy.TenantResolver`。

将自定义租户解析器定义为 bean

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

创建集成测试,以便测试自定义租户解析器。

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

import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.TokenGenerator
import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest
import spock.lang.Specification
import spock.lang.IgnoreIf

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class CurrentUserByJwtTenantResolverSpec extends Specification {

    @Autowired
    CurrentUserByJwtTenantResolver currentUserTenantResolver

    TokenGenerator tokenGenerator


    void "Test HttpHeader resolver throws an exception outside a web request"() {
        when:
        currentUserTenantResolver.resolveTenantIdentifier()

        then:
        def e = thrown(TenantNotFoundException)
        e.message == "Tenant could not be resolved outside a web request"
    }


    void "Test not tenant id found"() {
        setup:
        def request = new MockHttpServletRequest("GET", "/foo")
        RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))

        when:
        currentUserTenantResolver.resolveTenantIdentifier()

        then:
        def e = thrown(TenantNotFoundException)
        e.message == "Tenant could not be resolved from HTTP Header: ${CurrentUserByJwtTenantResolver.HEADER_NAME}"

        cleanup:
        RequestContextHolder.setRequestAttributes(null)
    }

    void "Test HttpHeader value is the tenant id when a request is present"() {

        setup:
        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/foo")
        def userDetails = Stub(UserDetails) {
            getUsername() >> 'vector'
            isAccountNonExpired() >> true
            isAccountNonLocked() >> true
            isCredentialsNonExpired() >> true
            isEnabled() >> true
        }
        AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails, 3600)
        String jwt = accessToken.accessToken
        request.addHeader('Authorization', "Bearer $jwt")
        RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))


        when:
        def tenantId = currentUserTenantResolver.resolveTenantIdentifier()

        then:
        tenantId == "vector"

        cleanup:
        RequestContextHolder.setRequestAttributes(null)
    }
}

接下来,我们将配置我们的应用以使用此自定义租户解析器。

通过修改 `application.yml` 配置多租户。

grails-app/conf/application.yml
grails
    gorm:
        multiTenancy:
            mode: DISCRIMINATOR (1)
            tenantResolverClass: demo.CurrentUserByJwtTenantResolver (2)
        reactor:
            # Whether to translate GORM events into Reactor events
            # Disabled by default for performance reasons
            events: false
1 将多租户模式定义为 `DISCRIMINATOR`
2 设置使用自定义类的租户解析器类,稍后你会在本指南中编写此类。

3.6 按用户划分的租户解析器

或者,你可能想要使用 Spring Security REST 保护的无状态端点和 Spring Security Core 保护的有状态端点的组合。你将有兴趣按当前登录的用户创建自定义解析器。

src/main/groovy/demo/CurrentUserTenantResolver.groovy
package demo

import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.SpringSecurityService
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.TenantResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy
import org.springframework.security.core.userdetails.UserDetails

@CompileStatic
class CurrentUserTenantResolver implements AllTenantsResolver { (1)

    @Autowired
    @Lazy
    SpringSecurityService springSecurityService

    @Override
    Serializable resolveTenantIdentifier() throws TenantNotFoundException {
        String username = loggedUsername()
        if ( username ) {
            return username
        }
        throw new TenantNotFoundException("Tenant could not be resolved from Spring Security Principal")
    }

    String loggedUsername() {
        if ( springSecurityService.principal instanceof String ) {
            return springSecurityService.principal
        }
        if (springSecurityService.principal instanceof UserDetails) {
            return ((UserDetails) springSecurityService.principal).username
        }
        null
    }

    @Override
    Iterable<Serializable> resolveTenantIds() {
        User.withTransaction(readOnly: true) {
            new DetachedCriteria(User)
                    .distinct('username')
                    .list()  as Iterable<Serializable>
        }
    }
}
1 当使用基于鉴别器的多租户时,你可能需要在你希望在任何时间迭代所有可用租户时,在你的 TenantResolver 中实现 AllTenantsResolver 接口。`AllTenantsResolver 扩展了 `TenantResolver`。若要创建你自己的租户解析器,请实现 `org.grails.datastore.mapping.multitenancy.TenantResolver`。

将自定义租户解析器定义为 bean

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

创建集成测试,以便测试自定义租户解析器。

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

import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.test.annotation.Rollback
import spock.lang.Specification

@Integration
class CurrentUserTenantResolverSpec extends Specification {

    UserDataService userDataService
    RoleDataService roleDataService
    UserRoleDataService userRoleDataService

    @Autowired
    CurrentUserTenantResolver currentUserTenantResolver

    void "Test Current User throws a TenantNotFoundException if not logged in"() {
        when:
        currentUserTenantResolver.resolveTenantIdentifier()

        then:
        def e = thrown(TenantNotFoundException)
        e.message == "Tenant could not be resolved from Spring Security Principal"
    }

    void "Test current logged in user is resolved "() {
        given:
        Role role = roleDataService.saveByAuthority('ROLE_USER')
        User user = userDataService.save('admin', 'admin')
        userRoleDataService.save(user, role)

        when:
        loginAs('admin', 'ROLE_USER')
        Serializable username = currentUserTenantResolver.resolveTenantIdentifier()

        then:
        username == user.username

        when: "verify AllTenantsResolver::resolveTenantIds"
        Iterable<Serializable> tenantIds
        demo.User.withNewSession {
            tenantIds = currentUserTenantResolver.resolveTenantIds()
        }

        then:
        tenantIds.toList().size() == 1
        tenantIds.toList().get(0) == 'admin'

        cleanup:
        userRoleDataService.delete(user, role)
        roleDataService.delete(role)
        userDataService.delete(user.id)
    }

    void loginAs(String username, String authority) {
        User user = userDataService.findByUsername(username)
        if ( user ) {
            // have to be authenticated as an admin to create ACLs
            List<GrantedAuthority> authorityList = AuthorityUtils.createAuthorityList(authority)
            SecurityContextHolder.context.authentication = new UsernamePasswordAuthenticationToken(user.username,
                    user.password,
                    authorityList)
        }
    }


}

接下来,我们将配置我们的应用以使用此自定义租户解析器。

通过修改 `application.yml` 配置多租户。

grails-app/conf/application.yml
grails
    gorm:
        multiTenancy:
            mode: DISCRIMINATOR (1)
            tenantResolverClass: demo.CurrentUserTenantResolver  (2)
1 将多租户模式定义为 `DISCRIMINATOR`
2 设置使用自定义类的租户解析器类,稍后你会在本指南中编写此类。

3.7 功能测试

创建一个功能测试,验证我们能否使用切换用户的 API。

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 spock.lang.Shared
import spock.lang.Specification
import spock.lang.IgnoreIf

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class PlanControllerSpec extends Specification {
    PlanDataService planDataService
    UserDataService userDataService
    UserRoleDataService userRoleDataService
    UserService userService
    RoleDataService roleDataService

    @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"() {
        given:
        User vector = userService.save('vector', 'secret', 'ROLE_VILLAIN')
        User gru = userService.save('gru', 'secret', 'ROLE_VILLAIN')
        Tenants.withId("gru") { (1)
            planDataService.save('Steal the Moon')
        }
        Tenants.withId("vector") {
            planDataService.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") { (1)
            planDataService.deleteByTitle('Steal the Moon')
        }
        Tenants.withId("vector") {
            planDataService.deleteByTitle('Steal the Pyramid')
        }
        userRoleDataService.deleteByUser(gru)
        userDataService.delete(gru.id)
        userRoleDataService.deleteByUser(vector)
        userDataService.delete(vector.id)
        roleDataService.deleteByAuthority('ROLE_VILLAIN')
    }
}
1 你可以使用 withId 方法使用特定的租户 ID

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 的所在地

认识团队