Grails多数据源
了解如何使用 Grails 应用程序消费和处理对多个数据源的事务。
作者:Sergio del Amo
Grails版本 3.3.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 注解指定查询联接。 |
如果您使用 @ReadOnly 代替 @ReadOnly('books') ,您将收到如下异常:org.hibernate.HibernateException: No Session found for current thread |
添加常规服务
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 注解指定查询联接。 |
添加常规服务
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 groovy.transform.CompileStatic
@CompileStatic
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
}
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
}
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 功能测试
添加 grails-datastore-rest-client 作为 testCompile 依赖关系
testCompile "org.grails:grails-datastore-rest-client"
添加一个功能测试,该测试验证 Grails 是否按预期处理了多个数据源
package demo
import grails.plugins.rest.client.RestBuilder
import grails.plugins.rest.client.RestResponse
import grails.testing.mixin.integration.Integration
import spock.lang.Shared
import spock.lang.Specification
@Integration
class MultipleDataSourceSpec extends Specification {
@Shared
RestBuilder rest = new RestBuilder()
private RestResponse saveResource(String resource, String itemTitle, List<String> itemKeywords) {
rest.post("http://localhost:${serverPort}/${resource}") {
accept('application/json')
contentType('application/json')
json {
title = itemTitle
keywords = itemKeywords
}
}
}
private RestResponse deleteResource(String resource, String itemTitle) {
rest.delete("http://localhost:${serverPort}/${resource}") {
accept('application/json')
contentType('application/json')
json {
title = itemTitle
}
}
}
private RestResponse fetchResource(String resource) {
rest.get("http://localhost:${serverPort}/${resource}") {
accept('application/json')
}
}
private RestResponse resourceKeywords(String resource) {
rest.get("http://localhost:${serverPort}/${resource}/keywords") {
accept('application/json')
}
}
def "Test Multi-Datasource support saving and retrieving books and movies"() {
when:
RestResponse resp = saveResource('book', 'Change Agent', ['dna', 'sci-fi'])
then:
resp.statusCode.value() == 201
when:
resp = saveResource('book', 'Influx', ['sci-fi'])
then:
resp.statusCode.value() == 201
when:
resp = saveResource('book', 'Kill Decision', ['drone', 'sci-fi'])
then:
resp.statusCode.value() == 201
when:
resp = saveResource('book', 'Freedom (TM)', ['sci-fi'])
then:
resp.statusCode.value() == 201
when:
resp = saveResource('book', 'Daemon', ['sci-fi'])
then:
resp.statusCode.value() == 201
when:
resp = saveResource('movie', 'Pirates of Silicon Valley', ['apple', 'microsoft', 'technology'])
then:
resp.statusCode.value() == 201
when:
resp = saveResource('movie', 'Inception', ['sci-fi'])
then:
resp.statusCode.value() == 201
when:
resp = fetchResource('book')
then:
resp.statusCode.value() == 200
resp.json.collect { it.title }.sort() == ['Change Agent', 'Daemon', 'Freedom (TM)', 'Influx', 'Kill Decision']
when:
resp = fetchResource('movie')
then:
resp.statusCode.value() == 200
resp.json.collect { it.title }.sort() == ['Inception', 'Pirates of Silicon Valley']
when:
resp = resourceKeywords('book')
then:
resp.statusCode.value() == 200
resp.json.keywords == ['dna', 'drone', 'sci-fi']
when:
resp = resourceKeywords('movie')
then:
resp.statusCode.value() == 200
resp.json.keywords == ['apple', 'microsoft', 'sci-fi', 'technology']
when:
resp = deleteResource('book', 'Change Agent')
then:
resp.statusCode.value() == 204
when:
resp = deleteResource('book', 'Influx')
then:
resp.statusCode.value() == 204
when:
resp = deleteResource('book', 'Kill Decision')
then:
resp.statusCode.value() == 204
when:
resp = deleteResource('book', 'Freedom (TM)')
then:
resp.statusCode.value() == 204
when:
resp = deleteResource('book', 'Daemon')
then:
resp.statusCode.value() == 204
when:
resp = deleteResource('movie', 'Pirates of Silicon Valley')
then:
resp.statusCode.value() == 204
when:
resp = deleteResource('movie', 'Inception')
then:
resp.statusCode.value() == 204
}
}
4 测试应用程序
运行测试
./grailsw
grails> test-app
grails> open test-report
或者
./gradlew check
open build/reports/tests/index.html