使用Jooq和flywayDB改善代码质量

使用Jooq和flywayDB改善代码质量

@(个人博客)[数据库, mysql, 改进, jooq, kotlin, springboot, mock]

[TOC]

最近我们在项目中开始使用Jooq+flywayDB,来改善代码质量。当前项目使用的是SpringBoot+MyBatis,接手维护后存在以下几个问题:

  1. 基于MyBatis,难以进行快速单元测试。每次测试都要把SpringBoot整个应用程序启动,慢就一个字。
  2. MySQL数据库结构完全通过手工进行管理,经常出现测试环境OK,但是类生产和生产环境有问题,结果发现是schema没同步。
  3. 在XML中保存对应的SQL查询语句,不方便进行管理和维护。开发人员经常是在某个SQL工具上编写完成,再迁移到MyBatis中,有时候就会遇到各种兼容性问题。比如<的转义。
  4. Mybatis编写SQL,完全是纯文本,IDE无法给我们提供帮助。一些复杂的判断等逻辑操作,基本等同于自己手写。

于是我开始思考以下几个问题:

  1. 如何快速测试SpringBoot中的Controller?
  2. 如何以自动化的方式对数据库Schema进行管理,不希望人工干预?
  3. 如何简化SQL的编写和运维?

经过在网络上搜索资料,得出的初步结论如下:

  1. 使用FlywayDB可以解决数据库schema同步问题
  2. 使用jooq可以改善sql的维护,并且配合HikariCP连接池,更方便测试

接下我会介绍我们在项目中是如何使用这些工具,来改善代码质量和降低运维成本的。

使用FlywayDB对数据库进行版本管理

db-migrate

你是否在开发过程中遇到过如下场景?

  1. "我草,谁删除的XXX字段?都不通知下,什么时候修改的都不知道!"
  2. "生产环境XXX表里面怎么没有YYY字段???搞毛线哦!"
  3. "谁又忘了在生产环境上的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
}
  1. 由于我们有测试、类生产、生产环境三个数据库,因此flyway的配置参数是通过环境变量传递的
  2. 由于我们是中途开始使用,必须使用baselineOnMigrate参数来建立基线。如果是空数据库则不用。强烈推荐从项目一开始就使用。

这个plugin会往gradle中增加多个以flyway开头的任务,我们最常用的是flywayMigrate

配置好之后,在命令行里面配置了对应的环境变量,你就可以运行gradle flywayMigrate来进行数据库版本迁移了。当然,最开始由于我们还没有迁移脚本,所以不会有啥影响。

增加迁移脚本

根据flywayDB的说明,我们只需要将迁移的SQL脚本放到resources\db\migration里面即可。

脚本规则:

  1. 迁移脚本为V数字__名称.sql。比如V2__Create_email_notify_table.sql。注意是2个_
  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迁移脚本的流程。

  1. 测试环境包含数据库修复流程,类生产和生产环境不需要。
  2. 如果迁移失败,部署不会执行,失败原因可以到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

特性分支开发时的数据库迁移

当前我们采用特性分支开发模式,可能会遇到多个分支上都会修改测试环境数据库问题。建议的解决方式:

  1. 如果开发人员A需要更新数据库,那么在开发群里面知会下,数据库版本更新到XX
  2. 其他开发人员如果需要更新数据库,那么数据库版本应该更新到XX+1

如果有没有及时交流导致迁移问题情况的,可以手动修改数据库和flyway_schema_history来修复。本质上这也是一个竞争问题,只能通过解决冲突的方式尽量避免。

我猜测这也是Rails采用时间戳作为数据库版本的原因,数字太容易冲突了。Rails采用YYYYMMDDHHMMSS_数据库逻辑.rb 形式,很大程度上避免了冲突。比如20080906120000_create_products.rb,精确到秒,基本上很少出现冲突。

使用HikariCP作为连接池

HikariCP是Java领域当前性能最高,最稳定的数据库连接池。我们使用HikariCP连接池主要是利用它:

  1. 使用简单
  2. 方便测试

具体使用方式可以查看官方文档。我们在项目中做了简单封装:

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

jooq

当前非常多的项目都使用Mybatis来访问数据库,它非常流行。但是我们在开发过程中仍然遇到了一些问题:

  1. 实现分离。大部分SQL逻辑都被放到XML文件中进行管理,在服务层每次查看代码时,每次都要跳转查看。
  2. 无法感知类型安全。XML文件毕竟是文本,没法感知模型类、实体类、字段类型等信息,这些必须要开发人员自己保证。
  3. 编写一些复杂SQL逻辑时,表达力不足。需要编写一堆include语句,不利于阅读。

我们在网上找到了Jooq项目。Jooq项目实际上是SQL在Java领域的DSL实现。它让Java代码跟SQL代码基本上达到一对一映射,因此针对SQL语句,我们完全可以编写等价的Java实现。

DSL!
DSL!!
DSL!!!

需要再次强调的是,Jooq跟传统的Hibernate,Mybatis概念完全不同。其实jooq是ORM+DSL的结合体。下面的文章中我们会提到。

模型生成器

jooq有一个非常重要的特性就是模型生成器。在传统的Mybatis等框架中,我们都是自己定义Entity实体。但是jooq给我们提供了模型生成器。

模型生成器有几个好处:

  1. 减少手动编写实体类的繁琐。很大一部分实体类都可以直接使用Jooq生成的模型。
  2. 代码实现跟数据库保持一致。如果修改或者删除了表中的某个字段,编译会直接报错。
  3. 编写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 <inputCatalog/> 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]

单独测试某个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<Int>()

@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.<init>: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<IHicMailSender>(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 "***"
            )
    ) }
}
  1. 先使用mockk模拟接口
  2. 再使用verify函数验证模拟调用是否符合要求

参考资料

  1. JOOQ 3.8.2 使用 教程:从入门到提高
  2. jOOQ: generates Java code from your database and lets you build type safe SQL queries through its fluent AP
  3. FlywayDB: Version control for your database
  4. 光 HikariCP・A solid, high-performance, JDBC connection pool at last
  5. Powerful, elegant and flexible Kotlin test framework
  6. Mockk: mocking library for Kotlin


  1. 大部分视频在Youtube,需要翻墙查看

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容