Grails 多数据源
了解如何从 Grails 应用程序使用和处理对多个数据源的事务。
作者:塞尔吉奥·德尔阿莫
Grails 版本 4.0.1
1 培训
Grails 培训 - 由创建和积极维护 Grails 框架的人员开发和交付!。
2 入门
在本文档中,你将...
2.1 所需内容
要完成本文档,你需要以下内容
-
一些时间
-
一个不错的文本编辑器或 IDE
-
已安装 JDK 1.8 或更高版本且已正确配置
JAVA_HOME
2.2 如何完成指南
要开始,请执行以下操作
-
下载并解压缩源代码
或
-
克隆 Git 存储库
git clone https://github.com/grails-guides/grails-multi-datasource.git
Grails 指南存储库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,其中包含一些额外的代码,以便让你开始。 -
complete
一个已完成的示例。这是完成指南介绍的步骤并将这些更改应用于initial
文件夹的结果。
要完成指南,请转到 initial
文件夹
-
cd
到grails-guides/grails-multi-datasource/initial
并按照下一部分中的说明进行操作。
如果你 cd 到 grails-guides/grails-multi-datasource/complete ,你可以直接转到已完成示例 |
3 编写应用程序
我们正在使用连接到两个数据源的 rest-profile
编写 Grails 应用程序。
在 Grails 的以前版本中,对于多个数据源,最佳事务链用于尝试跨所有已配置数据源管理事务。而从 Grails 3.3 开始,此功能已被禁用,因为它会引起困惑,因为它不是真正的 XA 实现,而且还会影响性能,因为对于每个事务,您都有一个与数据源绑定的事务,无论这是否为实际要求。 |
3.1 配置
在 application.yml
中连接两个数据源
dataSource:
pooled: true
jmxExport: true
driverClassName: org.h2.Driver
username: sa
password: ''
environments:
development:
dataSource:
dbCreate: create-drop
url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
dataSources:
books:
dbCreate: create-drop
url: jdbc:h2:mem:bookDevDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
test:
dataSource:
dbCreate: create-drop
url: jdbc:h2:mem:testDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
dataSources:
books:
dbCreate: create-drop
url: jdbc:h2:mem:bookTestDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
3.2 域类
创建一个 Movie
域类。如果我们未指定任何数据源,则它将与 **default** 数据源关联。
package demo
class Movie {
String title
static hasMany = [keywords: Keyword]
}
创建一个 Book
域类。
package demo
class Book {
String title
static hasMany = [keywords: Keyword]
static mapping = {
datasource 'books' (1)
}
}
1 | Book 域类与 books 数据源关联。 |
创建一个 Keyword
域类。
package demo
import org.grails.datastore.mapping.core.connections.ConnectionSource
class Keyword {
String name
static mapping = {
datasources([ConnectionSource.DEFAULT, 'books']) (1)
}
}
1 | Keyword 域类与两个数据源关联(dataSource - 默认之一 - 和 books )。 |
3.3 服务
为 Movie
域类创建一个 DataService。
package demo
import grails.gorm.services.Join
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@Service(Movie)
interface MovieDataService {
void deleteByTitle(String title)
@Join('keywords') (1)
List<Movie> findAll()
}
1 | 您可以使用 @Join 注解指定查询联接。 |
添加常规服务
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@CompileStatic
class MovieService {
@Transactional
Movie addMovie(String title, List<String> keywords) {
Movie movie = new Movie(title: title)
if ( keywords ) {
for ( String keyword : keywords ) {
movie.addToKeywords(new Keyword(name: keyword))
}
}
if ( !movie.save() ) {
log.error 'Unable to save movie'
}
movie
}
}
package demo
import grails.gorm.services.Join
import grails.gorm.services.Service
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@CompileStatic
@Service(Book)
interface BookDataService {
@Join('keywords') (2)
@ReadOnly('books') (1)
List<Book> findAll()
@Transactional('books') (1)
void deleteByTitle(String title)
}
1 | 在 @Transactional 和 @ReadOnly 注解中指定数据源名称。 |
2 | 您可以使用 @Join 注解指定查询联接。 |
如果您使用 @ReadOnly 代替 @ReadOnly('books') ,则您将获得异常:org.hibernate.HibernateException: 未针对当前线程发现会话 |
添加常规服务
package demo
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@CompileStatic
class BookService {
@Transactional('books') (1)
Book addBook(String title, List<String> keywords) {
Book book = new Book(title: title)
if ( keywords ) {
for ( String keyword : keywords ) {
book.addToKeywords(new Keyword(name: keyword))
}
}
if ( !book.save() ) {
log.error 'Unable to save book'
}
book
}
}
1 | 在 @Transactional 注解中指定数据源名称。 |
添加适用于多个数据源的 Keyword
服务。
当不使用显式名称空间时,在域类中指定的第一数据源为默认数据源。对于 Keyword
,默认使用第一个指定的数据源 ConnectionSource.DEFAULT
。
package demo
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import groovy.transform.CompileStatic
@CompileStatic
class KeywordService {
@ReadOnly('books')
List<Keyword> findAllBooksKeywords() {
booksQuery().list()
}
@ReadOnly
List<Keyword> findAllDefaultDataSourceKeywords() {
defaultDataSourceQuery().list()
}
private DetachedCriteria<Keyword> booksQuery() {
Keyword.where {}.withConnection('books') (1)
}
private DetachedCriteria<Keyword> defaultDataSourceQuery() {
Keyword.where {}
}
}
1 | 使用 withConnection 为查询指定数据源名称。 |
您可以使用动态查找器或条件查询代替 *where 查询* 来编写 books
查询。
Where 查询:Keyword.where {}.withConnection('books').list()
动态查找器:Keyword.books.findAll()
条件:Keyword.books.createCriteria().list { }
3.4 控制器
创建 BookController
和 MovieController
。它们使用先前实现的服务。
package demo
import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
@GrailsCompileStatic
class SaveBookCommand implements Validateable {
String title
List<String> keywords
static constraints = {
title nullable: false
keywords nullable: true
}
}
package demo
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
@CompileStatic
@Transactional
class BookController {
static allowedMethods = [save: 'POST', index: 'GET']
static responseFormats = ['json']
BookService bookService
KeywordService keywordService
BookDataService bookDataService
def save(SaveBookCommand cmd) {
bookService.addBook(cmd.title, cmd.keywords)
render status: 201
}
def index() {
[bookList: bookDataService.findAll()]
}
def delete(String title) {
bookDataService.deleteByTitle(title)
render status: 204
}
def keywords() {
render view: '/keyword/index',
model: [keywordList: keywordService.findAllBooksKeywords()]
}
}
package demo
import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
@GrailsCompileStatic
class SaveBookCommand implements Validateable {
String title
List<String> keywords
static constraints = {
title nullable: false
keywords nullable: true
}
}
package demo
import groovy.transform.CompileStatic
@CompileStatic
class MovieController {
static allowedMethods = [save: 'POST', index: 'GET']
static responseFormats = ['json']
MovieService movieService
MovieDataService movieDataService
KeywordService keywordService
def save(SaveMovieCommand cmd) {
movieService.addMovie(cmd.title, cmd.keywords)
render status: 201
}
def index() {
[movieList: movieDataService.findAll()]
}
def delete(String title) {
movieDataService.deleteByTitle(title)
render status: 204
}
def keywords() {
render view: '/keyword/index',
model: [keywordList: keywordService.findAllDefaultDataSourceKeywords()]
}
}
`
3.5 视图
添加多个 JSON 视图 以渲染输出。
import demo.Book
model {
Book book
}
json {
title book.title
keywords book.keywords*.name.unique().sort()
}
import demo.Book
model {
Iterable<Book> bookList
}
json tmpl.book(bookList)
import demo.Movie
model {
Movie movie
}
json {
title movie.title
keywords movie.keywords*.name.unique().sort()
}
import demo.Movie
model {
Iterable<Movie> movieList
}
json tmpl.movie(movieList)
import demo.Keyword
model {
Iterable<Keyword> keywordList
}
json {
keywords keywordList*.name.unique().sort()
}
3.6 功能测试
将 *micronaut-http-client* 作为 *testCompile* 依赖项添加
testCompile "io.micronaut:micronaut-http-client"
添加一个验证 Grails 预期处理多个数据源的功能测试
package demo
import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.core.type.Argument
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
@Integration
class MultipleDataSourceSpec extends Specification {
@Shared
HttpClient client
@OnceBefore
void init() {
String baseUrl = "http://localhost:$serverPort"
this.client = HttpClient.create(baseUrl.toURL())
}
private HttpResponse<Map> saveResource(String resource, String itemTitle, List<String> itemKeywords) {
HttpRequest request = HttpRequest.POST("/$resource", [title: itemTitle, keywords: itemKeywords])
client.toBlocking().exchange(request, Map)
}
private HttpResponse deleteResource(String resource, String itemTitle) {
HttpRequest request = HttpRequest.DELETE("/$resource", [title: itemTitle])
client.toBlocking().exchange(request)
}
private HttpResponse<List<Map>> fetchResource(String resource) {
HttpRequest request = HttpRequest.GET("/$resource")
client.toBlocking().exchange(request, Argument.of(List, Map))
}
private HttpResponse<Map> resourceKeywords(String resource) {
HttpRequest request = HttpRequest.GET("/$resource/keywords")
client.toBlocking().exchange(request, Map)
}
def "Test Multi-Datasource support saving and retrieving books and movies"() {
given:
List<Map> books = [
[title: 'Change Agent', tags: ['dna', 'sci-fi']],
[title: 'Influx', tags: ['sci-fi']],
[title: 'Kill Decision', tags: ['drone', 'sci-fi']],
[title: 'Freedom (TM)', tags: ['sci-fi']],
[title: 'Daemon', tags: ['sci-fi']],
]
List<Map> movies = [
[title: 'Pirates of Silicon Valley', tags: ['apple', 'microsoft', 'technology']],
[title: 'Inception', tags: ['sci-fi']],
]
books.each { book ->
HttpResponse<Map> resp = saveResource('book', book.title as String, book.tags as List<String>)
assert resp.status == HttpStatus.CREATED
}
movies.each { movie ->
HttpResponse<Map> resp = saveResource('movie', movie.title as String, movie.tags as List<String>)
assert resp.status == HttpStatus.CREATED
}
when:
HttpResponse<List<Map>> resourceResp = fetchResource('book')
then:
resourceResp.status == HttpStatus.OK
resourceResp.body().collect { it.title }.sort() == books.collect { it.title }.sort()
when:
resourceResp = fetchResource('movie')
then:
resourceResp.status == HttpStatus.OK
resourceResp.body().collect { it.title }.sort() == movies.collect { it.title }.sort()
when:
HttpResponse<Map> resp = resourceKeywords('book')
then:
resp.status == HttpStatus.OK
(resp.body().keywords as List<String>).sort() == books.collect { it.tags }.flatten().unique().sort()
when:
resp = resourceKeywords('movie')
then:
resp.status == HttpStatus.OK
(resp.body().keywords as List<String>).sort() == movies.collect { it.tags }.flatten().unique().sort()
cleanup:
books.each { book ->
assert deleteResource('book', book.title as String).status() == HttpStatus.NO_CONTENT
}
movies.each { movie ->
assert deleteResource('movie', movie.title as String).status() == HttpStatus.NO_CONTENT
}
}
}
4 测试应用程序
运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew check
open build/reports/tests/index.html