使用Jooq和flywayDB改善代码质量
@(个人博客)[数据库, mysql, 改进, jooq, kotlin, springboot, mock]
[TOC]
最近我们在项目中开始使用Jooq+flywayDB,来改善代码质量。当前项目使用的是SpringBoot+MyBatis,接手维护后存在以下几个问题:
- 基于MyBatis,难以进行快速单元测试。每次测试都要把SpringBoot整个应用程序启动,慢就一个字。
- MySQL数据库结构完全通过手工进行管理,经常出现测试环境OK,但是类生产和生产环境有问题,结果发现是schema没同步。
- 在XML中保存对应的SQL查询语句,不方便进行管理和维护。开发人员经常是在某个SQL工具上编写完成,再迁移到MyBatis中,有时候就会遇到各种兼容性问题。比如
<
的转义。 - Mybatis编写SQL,完全是纯文本,IDE无法给我们提供帮助。一些复杂的判断等逻辑操作,基本等同于自己手写。
于是我开始思考以下几个问题:
- 如何快速测试SpringBoot中的Controller?
- 如何以自动化的方式对数据库Schema进行管理,不希望人工干预?
- 如何简化SQL的编写和运维?
经过在网络上搜索资料,得出的初步结论如下:
- 使用FlywayDB可以解决数据库schema同步问题
- 使用jooq可以改善sql的维护,并且配合HikariCP连接池,更方便测试
接下我会介绍我们在项目中是如何使用这些工具,来改善代码质量和降低运维成本的。
使用FlywayDB对数据库进行版本管理
你是否在开发过程中遇到过如下场景?
- "我草,谁删除的XXX字段?都不通知下,什么时候修改的都不知道!"
- "生产环境XXX表里面怎么没有YYY字段???搞毛线哦!"
- "谁又忘了在生产环境上的XX表里面增加索引?查询慢死了!"
这些问题都是手工管理数据库带来的。我们作为新一代高效率程序猿,信奉的原则是:万物皆可自动化!让自动化来帮我们解决手工修改的各种问题吧。
各种语言、框架基本上都有自己的数据库迁移工具。比较有名的是Ruby的Rails。我们使用的是Java,因此选择了Java中
流行的FlywayDB。
项目使用的是gradle,只需要在配置文件中增加对应的配置,即可开始支持flywayDB。
修改gradle.build
增加flywaydb的plugin:
plugin {
id "org.flywaydb.flyway" version "5.1.4"
}
然后增加flyway配置:
flyway {
url = System.getenv("DB_MIGRATE_URL")
user = System.getenv("DB_MIGRATE_USER")
password = System.getenv("DB_MIGRATE_PASSWORD")
baselineOnMigrate = true
}
- 由于我们有测试、类生产、生产环境三个数据库,因此flyway的配置参数是通过环境变量传递的
- 由于我们是中途开始使用,必须使用
baselineOnMigrate
参数来建立基线。如果是空数据库则不用。强烈推荐从项目一开始就使用。
这个plugin会往gradle中增加多个以flyway
开头的任务,我们最常用的是flywayMigrate
。
配置好之后,在命令行里面配置了对应的环境变量,你就可以运行gradle flywayMigrate
来进行数据库版本迁移了。当然,最开始由于我们还没有迁移脚本,所以不会有啥影响。
增加迁移脚本
根据flywayDB的说明,我们只需要将迁移的SQL脚本放到resources\db\migration
里面即可。
脚本规则:
- 迁移脚本为V数字__名称.sql。比如
V2__Create_email_notify_table.sql
。注意是2个_
。 - 撤销脚本为U数字__名称.sql。比如
U3__Add_week_column.sql
。注意是2个_
。
flywayDB会根据版本的大小顺序进行迁移。比如V2->V3->V4...。
以我们的迁移目录为例:
V2__Create_email_notify_table.sql
V3__Add_week_column.sql
V4__Delete_unused_tables.sql
...
如果数据库中有不再使用的字段,也建议使用迁移脚本删除,减少维护成本。
运行迁移
有了迁移脚本后,就可以使用gradle来进行迁移了。最常用的任务为:gradle flywayMigrate
。
> Task :flywayMigrate
Task ':flywayMigrate' is not up-to-date because:
Task has not declared any outputs despite executing actions.
Flyway Community Edition 5.1.4 by Boxfuse
Database: jdbc:mysql://xxxx:3306/yyyy (MySQL 5.7)
Successfully validated 22 migrations (execution time 00:00.365s)
Current version of schema `archimedes_data`: 21
Migrating schema `archimedes_data` to version 22 - Add version table And update currentent
Successfully applied 1 migration to schema `archimedes_data` (execution time 00:02.679s)
:flywayMigrate (Thread[Task worker for ':',5,main]) completed. Took 5.064 secs.
如果迁移出错,会有详细的错误信息提示。可以运行gradle flywayRepair
来修复迁移,等修改完迁移脚本后,重新再次迁移。
迁移的结果存放在数据库的flyway_schema_history
表中,如果有问题需要手动修改迁移数据库也是可以的。
我最开始还是在Rails里面接触到数据库迁移的概念,全自动数据库管理带来的不仅仅是高效率,而且带来几乎为0的错误率。一条
rake db:migrate
之后,就可以准备上线了……
CI配置
我们使用的是GitlabCI,因此我们在每个阶段部署的时候,都配置了运行flywayDB
迁移脚本的流程。
- 测试环境包含数据库修复流程,类生产和生产环境不需要。
- 如果迁移失败,部署不会执行,失败原因可以到Gitlab上查看。
deploy-to-titan-dev:
stage: deploy
script:
- gradle flywayRepair -i
- gradle flywayMigrate -i
- pip3 install requests && python3 ./deploy/deploy_dev.py
variables:
DB_MIGRATE_URL: "jdbc:mysql://****:3306/****?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=true"
DB_MIGRATE_USER: "****"
DB_MIGRATE_PASSWORD: "****"
only:
- develop
deploy-to-titan-pre:
stage: deploy
script:
- gradle flywayMigrate -i
- pip3 install requests && python3 ./deploy/deploy_pre.py
variables:
DB_MIGRATE_URL: "jdbc:mysql://*******:3306/****?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=true"
DB_MIGRATE_USER: "****"
DB_MIGRATE_PASSWORD: "****"
only:
- develop
when: manual
特性分支开发时的数据库迁移
当前我们采用特性分支开发模式,可能会遇到多个分支上都会修改测试环境数据库问题。建议的解决方式:
- 如果开发人员A需要更新数据库,那么在开发群里面知会下,数据库版本更新到
XX
- 其他开发人员如果需要更新数据库,那么数据库版本应该更新到
XX+1
如果有没有及时交流导致迁移问题情况的,可以手动修改数据库和flyway_schema_history
来修复。本质上这也是一个竞争问题,只能通过解决冲突的方式尽量避免。
我猜测这也是
Rails
采用时间戳作为数据库版本的原因,数字太容易冲突了。Rails采用YYYYMMDDHHMMSS_数据库逻辑.rb
形式,很大程度上避免了冲突。比如20080906120000_create_products.rb
,精确到秒,基本上很少出现冲突。
使用HikariCP作为连接池
HikariCP是Java领域当前性能最高,最稳定的数据库连接池。我们使用HikariCP
连接池主要是利用它:
- 使用简单
- 方便测试
具体使用方式可以查看官方文档。我们在项目中做了简单封装:
fun dbContext(conf: DefaultDatabaseConf): DSLContext {
val source = DataSourcePool.getDataSource(jdbcUrl = conf.url,
username = conf.username,
password = conf.password,
twoLive = false
)
return dbContext(source, conf.database)
}
只需要知道JDBC URL、用户名、密码即可创建连接池开始测试。
使用JOOQ来编写SQL
当前非常多的项目都使用Mybatis来访问数据库,它非常流行。但是我们在开发过程中仍然遇到了一些问题:
- 实现分离。大部分SQL逻辑都被放到XML文件中进行管理,在服务层每次查看代码时,每次都要跳转查看。
- 无法感知类型安全。XML文件毕竟是文本,没法感知模型类、实体类、字段类型等信息,这些必须要开发人员自己保证。
- 编写一些复杂SQL逻辑时,表达力不足。需要编写一堆
include
语句,不利于阅读。
我们在网上找到了Jooq项目。Jooq项目实际上是SQL在Java领域的DSL实现。它让Java代码跟SQL代码基本上达到一对一映射,因此针对SQL语句,我们完全可以编写等价的Java实现。
DSL!
DSL!!
DSL!!!
需要再次强调的是,Jooq跟传统的Hibernate,Mybatis概念完全不同。其实jooq是ORM+DSL的结合体。下面的文章中我们会提到。
模型生成器
jooq有一个非常重要的特性就是模型生成器。在传统的Mybatis等框架中,我们都是自己定义Entity实体。但是jooq给我们提供了模型生成器。
模型生成器有几个好处:
- 减少手动编写实体类的繁琐。很大一部分实体类都可以直接使用Jooq生成的模型。
- 代码实现跟数据库保持一致。如果修改或者删除了表中的某个字段,编译会直接报错。
- 编写SQL的时候提供智能提示和语法检查。
我们项目使用的gradle,那么使用jooq提供的gradle插件来完成模型生成:
1. 在build.gradle
中增加jooq插件
plugins {
id 'nu.studer.jooq' version '3.0.2'
}
2. 增加jooq
运行时
dependencies {
jooqRuntime "mysql:mysql-connector-java:8.0.12"
}
3. 配置jooq
任务
jooq {
version = '3.11.2'
edition = 'OSS'
serviceDb(sourceSets.main) {
jdbc {
driver = 'com.mysql.cj.jdbc.Driver'
url = 'jdbc:mysql://***:3306/***?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=true'
user = '***'
password = '***'
}
generator {
name = 'org.jooq.codegen.DefaultGenerator'
strategy {
name = 'org.jooq.codegen.DefaultGeneratorStrategy'
}
database {
name = 'org.jooq.meta.mysql.MySQLDatabase'
inputSchema = '***'
}
generate {
relations = true
deprecated = false
records = false
immutablePojos = false
fluentSetters = true
}
target {
packageName = 'com.huawei.ajimide.main'
directory = 'src/main/java'
}
}
}
-
jdbc
: 配置jdbc信息 -
generator
/database
/inputSchema
: 配置为你的数据库名称 -
generator
/target
/packageName
: 配置生成Java文件的包名称 -
generator
/target
/directory
: 配置生成Java文件的路径
4. 运行jooq代码生成器
> Task :generateQueryDbJooqSchemaSource
Dec 03, 2018 7:14:44 PM org.jooq.tools.JooqLogger info
INFO: Initialising properties : /data/gitlab-runner/***/generateQueryDbJooqSchemaSource/config.xml
Dec 03, 2018 7:14:45 PM org.jooq.tools.JooqLogger info
INFO: No was provided. Generating ALL available catalogs instead.
运行完成,如果没有错误后,就会在你设置的目录生成数据库模型java文件,后续编写SQL时就会用到。
└─huawei
├─ajimide
│ ├─main
│ │ │ ArchimedesData.java
│ │ │ DefaultCatalog.java
│ │ │ Indexes.java
│ │ │ Keys.java
│ │ │ Tables.java
│ │ │
│ │ └─tables
│ │ ArchimedesPublicNotice.java
│ │ ArchimedesPublicNoticeType.java
│ │ AuthApply.java
类型安全
Jooq还有一个非常重要的特性就是类型安全。由于在前面我们已经生成了数据库模型,因此我们编写SQL使用的字段、表都可以用模型中的数据。
val exists = dslContext.fetchExists(
dslContext.selectOne()
.from(DASHBOARD_BOARD)
.where(DASHBOARD_BOARD.USER_ID.eq(userId))
.and(DASHBOARD_BOARD.NAME.eq(name))
.and(
if (parentId == 0 || parentId == -1) {
DSL.noCondition()
} else {
DASHBOARD_BOARD.PARENT_ID.eq(parentId)
}
)
.and(
if (kanbanId == null) {
DSL.noCondition()
} else {
DASHBOARD_BOARD.DASHBOARD_ID.ne(kanbanId)
}
)
)
可以看到,表DASHBOARD_BOARD
和属性DASHBOARD_BOARD.USER_ID
,DASHBOARD_BOARD.NAME
,DASHBOARD_BOARD.PARENT_ID
等都是访问的自动生成数据模型中的代码。
属性和dsl都有自己的类型:
- 比如
DASHBOARD_BOARD.NAME
类型是String
,那么eq
操作只能跟字符串做比较,传入数字或者浮点数或者其他类型,都会导致编译错误。 - 每个
dsl
语句也有自己的类型,比如and
只能写在where
后面,你在其他地方写,也会出现编译错误,也没有智能提示。 -
dsl
参数多了或者少了也会提示语法错误,同样编译不过。 -
dsl
还会自动帮我们处理SQL语法问题,比如防止SQL注入,符号转义,字段标识符等。
完善的文档
Jooq有非常完善的文档。推荐访问Manual HTML(Multi-Page)。
Jooq的作者Lukas Eder
是Java和SQL专家,我们也可以学习他的视频来了解jooq,比看文档舒服多了[1]。
- jOOQ Presentation at Java One 2014
- "NoSQL? No, SQL!" Presentation at JavaZone
单独测试某个Controller
使用Jooq和HikariCP,我们就可以简单地初始化控制器进行测试,无需把整个APP启动。
Controller的初始化
// 自动化测试专用数据库,请勿手动修改数据
private val conf = DefaultDatabaseConf(
url = "jdbc:mysql://***:3306/***?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&useSSL=true",
username = "***",
password = "***",
database = "***")
private val tepService = KanbanTemplateDBService(conf)
private val widService = KanbanWidgetService(conf)
private val service: IKanbanTemplateService = KanbanTemplateService().apply {
templateDbService = tepService
kanbanService = KanbanService(conf)
widgetService = widService
}
private val kanbanServ: IKanbanService = KanbanService(conf)
private var templateIdToDelete = mutableListOf()
@AfterMethod
fun teardown() {
tepService.deleteTemplates(templateIdToDelete)
widService.deleteKanbanWidgets(templateIdToDelete)
templateIdToDelete.clear()
}
@Test
fun testCreateNew() {
val controller = KanbanTemplateController().apply {
this.templateService = service
}
val r = controller.createNew(KanbanTemplateVo(
kanbanId = TEST_KANBAN_ID,
siteId = 1,
name = TEST_NAME,
authorW3 = "fakeUser"
))
templateIdToDelete.add(r.data.id)
r.data.kanbanId shouldBe 30
r.data.siteId shouldBe 1
r.data.author shouldBe "fakeUser"
// 应该会复制2条图表数据
widService.countTemplateKanbanWidgets(r.data.id) shouldBe 2
}
由于我们是自己创建的Controller对象,没有SpringBoot帮我们进行自动初始化。所以我们需要手动对@Autowired
对象进行配置。配置复杂度取决于Controller业务复杂程度。
写完测试之后,在IntelliJ界面上点击测试套或者测试用例运行,即可快速开始测试:
2018-12-04 16:40:27,745 INFO (HikariDataSource.:80) main - HikariPool-1 - Starting...
...
2018-12-04 16:40:37,214 DEBUG (JooqLogger.debug:261) main - Affected row(s) : 0
===============================================
Default Suite
Total tests run: 10, Failures: 0, Skips: 0
===============================================
10秒钟即可完成10个测试(还是直连数据库的),如果使用Springboot启动,估计还没开始测试呢:)。
kotlintest
使用kotlintest可以稍微简化测试断言的编写。Kotlintest支持很多Matcher,用起来也很简单。
r1.data.size.shouldBeGreaterThan(3)
widService.notExist(w1) shouldBe true
w2 shouldNotBe equals(w1)
r1.data.datasetParams[143]?.shouldContain("a", "b")
Mock测试
Mock测试就不再细讲,我们使用的是mockk
框架。具体使用手册请参考官方网站。
@Test
fun testSendKanbanEmailNotifies() {
val mocker = mockk(relaxed = true)
val sender = MailSenderService().apply {
this.kanbanEmailService = _emailService
this.kanbanService = _kanbanWidgetService
this.authGroupService = _authGroupService
this.mailSender = mocker
}
sender.sendKanbanEmailNotifies(LocalDateTime.of(2017, 8, 9, 0, 0, 0))
verify(atLeast = 1) { mocker.sendMail(to = listOf("***", "***"),
subject = "bug",
templateId = "***",
templateParams = mapOf(
"board_name" to "bug",
"board_owner" to "***",
"board_url" to "***"
)
) }
}
- 先使用mockk模拟接口
- 再使用verify函数验证模拟调用是否符合要求
参考资料
- JOOQ 3.8.2 使用 教程:从入门到提高
- jOOQ: generates Java code from your database and lets you build type safe SQL queries through its fluent AP
- FlywayDB: Version control for your database
- 光 HikariCP・A solid, high-performance, JDBC connection pool at last
- Powerful, elegant and flexible Kotlin test framework
- Mockk: mocking library for Kotlin
-
大部分视频在Youtube,需要翻墙查看 ↩