$30 off During Our Annual Pro Sale. View Details »

ツール比較しながら語るO/RマッパーとDBマイグレーション

Yu Watanabe
December 18, 2018

 ツール比較しながら語るO/RマッパーとDBマイグレーション

Yu Watanabe

December 18, 2018
Tweet

More Decks by Yu Watanabe

Other Decks in Technology

Transcript

  1. #ccc_a1
    ツール比較しながら語る
    O/RマッパーとDBマイグレーション
    JJUG-CCC 2018 Fall
    日本Javaユーザーズグループ
    クロスコミュニティカンファレンス
    ベルサール西新宿 2018-12-15
    Y.Watanabe

    View Slide

  2. #ccc_a1
    (ストップウォッチ スタート確認)

    View Slide

  3. #ccc_a1
    Who?
    ● 渡辺 祐
    ● (株)ビズリーチ
    ● SREグループ
    ○ Site Reliability Engeneering
    ● twitter: @nabedge
    [email protected]
    3

    View Slide

  4. #ccc_a1
    Software Design 2019 / 1月号に寄稿しました
    第2特集
    リリースモデルの変更にどう対処する?
    Javaのバージョン問題に前向きに
    取り組む方法
    第3章
    Javaをバージョンアップしやすくする
    アイデア
    進化に臆さず,そのメリットを
    享受するために
    4

    View Slide

  5. #ccc_a1
    今日、伝えたいこと
    ● アプリケーションの寿命 < DBの寿命
    ● 流れの速さのギャップに、アプリケーションのテク
    ノロジーで、うまいこと付き合う方法ってなんだろ
    う?
    5

    View Slide

  6. #ccc_a1
    さっそくですがアンケート
    ● MyBatis(iBatis)
    ● SpringのJdbcTemplate
    ● Hibernate
    ● QueryDSL
    ● jOOQ
    ● Doma
    ● DBFlute
    ● S2JDBC
    ● Flyway
    過去1年であなたが実際に仕事で使ったものは?
    6

    View Slide

  7. #ccc_a1
    ただし!
    ● 銀の弾丸は無い
    ● エンジニアが現場の状況にあわせて
    ツールをチョイスして運用するしかない
    7

    View Slide

  8. #ccc_a1
    もくじ1
    1. タイプセーフなO/Rマッパーの特徴
    2. DBマイグレーションツールとは?
    3. Flywayとは?
    8

    View Slide

  9. #ccc_a1
    もくじ2
    4. 開発のスタート地点は?
    CREATE/ALTER文? ER図? テーブル定義書.xls ?
    JPAのエンティティクラス.java @Table, @Column
    5. 開発用DBはどこにある?
    ローカルPC? 共有DBサーバ?
    9

    View Slide

  10. #ccc_a1
    もくじ3
    6. O/Rマッパーのソースコード自動生成を
    どのタイミングでやるか
    7. 自動生成したコードをgitに入れるか
    8. 自動生成したコードとドメインオブジェ
    クトのコードを分けるべきか
    10

    View Slide

  11. #ccc_a1
    もくじ4
    9. テストデータをどうやって投入するか
    10. 実際に実行されるSQLを見たい
    11. RDBMSの独自関数を使いたい
    12. テーブル定義書をどう作るか
    11

    View Slide

  12. #ccc_a1
    もくじ5
    13. 複数のO/Rマッパーを同じプロジェクトで使う
    or乗り換えるためのヒント
    12

    View Slide

  13. #ccc_a1
    (盛り込み過ぎ...)
    13

    View Slide

  14. #ccc_a1
    1. モダンなO/Rマッパーの特徴
    14

    View Slide

  15. #ccc_a1
    ● Hibernate 2001〜
    ● Spring-JDBC(JdbcTemplate) 2001〜
    ● iBatis/MyBatis 2005〜
    ● S2JDBC 2008〜
    ● QueryDSL 2008〜
    ● DBFlute 2008〜
    ● jOOQ 2010〜
    テーブル作成済みのDBサー
    バからメタデータを読み取っ
    てO/Rマッピング用Javaソー
    スを自動生成する方式
    古い順に並べて超ざっくり分類
    SQLを手で埋め込む方式
    素人にはおすすめできない(*1,2)
    15

    View Slide

  16. #ccc_a1
    SpringのJdbcTemplate
    List books = jdbcTemplate.query(
    “SELECT ISBN, TITLE FROM BOOKS”
    + “ WHERE ISBN = ? ”,
    new Object[]{“hoge”}, // “?”のところに入れたい引数
    new BeanPropertyRowMapper(Book.class)
    );
    16

    View Slide

  17. #ccc_a1
    MyBatis

    resultType="Book">
    SELECT ISBN, TITLE FROM BOOKS
    WHERE ISBN = #{isbn}
    ]]>
    // Javaコード
    List books = bookRepository.select(“hoge”);
    17

    View Slide

  18. #ccc_a1
    いま紹介したのは旧来型O/Rマッパー
    ● メリット
    ○ とにかくSQLを手で書かないと気が済まない人
    ● デメリット
    ○ タイプセーフではない
    ○ BOOKをBOOKSと書いても実行するまで(バグるまで)ミ
    スに気づけない
    ※想定しているテーブル名はBOOKです。前のページは
    わざと間違いを書いています。
    18

    View Slide

  19. #ccc_a1
    jOOQ(ジュークと読む)
    //テーブルのメタデータ情報クラス
    Book book = Tables.book;
    // SQLを組み立てて実行
    List books = dsl
    .select(book.isbn, book.title)
    .from(book)
    .where(book.isbn.eq(“hoge”))
    .fetchInto(BookVo.class); // PoJoであれば手作りクラスでも可
    タイプセーフ=間違えたらコンパイルエラーでわかる
    赤字は自動されたJavaコードを
    使っている箇所
    19

    View Slide

  20. #ccc_a1
    DBFlute
    Listbooks = bookBhv.selectEntity(
    condition -> {
    condition.query().setBookIsbn_equal("999");
    }
    ); 赤字は自動されたJavaコードを
    使っている箇所
    20

    View Slide

  21. #ccc_a1
    うたぐり深い人へ、本当の話。
    1. jOOQはもっと複雑なSQLを組み立てることも可能です。
    a. 参考文献
    https://docs.google.com/presentation/d/1MvsMo38Bt-2h4b_ZDSSXNSgq_UuweXx9P0HmlbO
    y8k8
    2. 正直に言うと、DBFluteは group by をサポートしていません。
    a. そういうことは「外出しSQL」で書く方向。
    b. 外出しSQLの結果マッピングや呼び出しコードの自動生成をサポート。
    21
    割愛

    View Slide

  22. #ccc_a1
    QueryDSL
    ● jOOQと似てる(ので、サンプルコードは省略)
    ● NoSQLも積極的(Lucene拡張、MongoDB拡張)
    ● 2016年9月、QueryDSLのメインなコミッターが
    「やりきったから、別の仕事やるわ。」
    と事実上の開発停滞宣言。*3
    ● jOOQ陣営「QueryDSLおつかれ。俺たちはまだやるぜ」宣言。*4
    ● 2018年5月 約2年ぶりのバージョンアップ
    22

    View Slide

  23. #ccc_a1
    いま風なO/Rマッパーの共通項
    ● タイプセーフなJavaコーディングでCRUDを書く
    ○ ミスったらコンパイルエラー
    ● DBにピッタリ合わせたJavaコードでCRUDを書く
    ○ DB変更の影響範囲がコンパイルエラーでわかる
    ● 上記を実現するために、
    ○ テーブル作成済みのDBサーバから自動的にメタデータを
    読み取って、Javaソースコードを自動生成
    23

    View Slide

  24. #ccc_a1
    2. DBマイグレーションツールとは?
    24

    View Slide

  25. #ccc_a1
    ここで言うDBマイグレーションとは
    DBに対する変更=DDL文の適用=を管理するツール
    ● O/Rマッパー同梱型
    ○ Ruby on Rails
    ○ DBFlute
    ○ Hibernate(?)
    ● 専用ツール型
    ○ LiquiBase
    ○ MyBatis Migration
    ○ Flyway
    25

    View Slide

  26. #ccc_a1
    「いまの状態のDB -> 変更のDDLをあてる -> 次の状態のDBになる」
    1. 「次の状態のDB」のフルDDL(CREATE文)を手で作っておく
    2. 「今の状態から変更するためのDDL」も手で作っておく
    3. DBFluteの”save-previous”コマンドで今の状態のDBの定義情報を保存
    4. 3と1を使ってDBFluteの”alter-check”コマンドで下記を検証できる
    今の状態 + 変更のDDL = 次の状態
    5. 4の結果を見たDBAは安心して「変更のDDL」を本番DBで実行
    6. 開発者はDBFluteの”replace-schema”で手元の開発DBを再構築
    26
    DBFluteのマイグレーション機能

    View Slide

  27. #ccc_a1
    LiquiBase
    ● 却下。
    ● 巨大なXMLファイルを手でメンテし続ける前提だから
    27
    割愛

    View Slide

  28. #ccc_a1
    MyBatis Migrations
    ● 時間が無いので割愛。
    ● 考え方はFlywayとよく似ている
    28
    割愛

    View Slide

  29. #ccc_a1
    3. Flywayとは
    (これが近年の本命)
    29

    View Slide

  30. #ccc_a1
    基本的な考え方
    DBマイグレーションツールが無い世界で
    DBA担当がDBに向かってやる基本動作は
    究極、これだけ。
    1. DDL文を FooBar-0001.sql ファイルに書いて保存。
    2. 順に、一度だけ、実行する。
    30

    View Slide

  31. #ccc_a1
    DB担当者の基本動作を
    そのままソース管理&実行管理する
    ツールが Flyway だと思えばいい
    31

    View Slide

  32. #ccc_a1 32
    src/main/resources/db/migration/
    V1.1__foo_init.sql <- 去年のサービス開始のとき
    V1.2__hoge_alter.sql <- 先月の機能追加のとき
    V1.3__add_foobar.sql <- 来週のための機能追加
    1. DBに対する変更を.sqlファイルで積み重ねてゆく
    2. flywayを実行
    $ ./gradlew flywayMigrate

    View Slide

  33. #ccc_a1 33
    3. 管理テーブルに無いsqlファイルだけが実行対象となる
    > SELECT ... FROM SCHEMA_VERSION
    version | script | success
    ---------+-----------------------+---------
    0.1 | << Flyway Baseline >> | true
    1.1 | V1.1__foo_init.sql | true
    1.2 | V1.2__hoge_alter.sql | true
    1.3 <- このレコードは未だ無いのでV1.3__add_foobar.sqlが対象
    4. sqlファイルの追加や変更がない状態でもう一度 flywayMigrate して
    も、全て実行済みでSCHEMA_VERSIONに記録されていれば、何も起きな
    い(べき等性)

    View Slide

  34. #ccc_a1
    補足
    ● 運用中のDBに、途中から導入することも可能
    ○ Flyway
    ■ “flyway baseline” でググる
    ○ DBFlute
    ■ 詳しくはマニュアルを
    ■ O/Rマッパーとして使わずとも、他のマイグレーション
    支援コマンド群だけ使うことが可能
    34

    View Slide

  35. #ccc_a1
    ちょっと休憩
    35
    1. 水を飲む
    2. 時間を確認
    20分くらい?

    View Slide

  36. #ccc_a1
    4. 開発のスタート地点はどこ?
    36

    View Slide

  37. #ccc_a1 37
    A. 手書きのDDL(を積んでゆくだけ)
    最初にCREATE TABLE、 運用しながら ALTER,
    CREATE/DROP INDEX, CREATE/DROP TABLE...
    B. ER図をまず書く。(そこからDDL文を自動生成)
    C. JPAのエンティティクラスを手書きし、Hibernate-JPAでDDL文
    を自動生成
    D. テーブル定義書.xlsと手書きのDDLを同時に書き続ける

    View Slide

  38. #ccc_a1 38
    A. 手書きのDDLをテキスト形式で積み上げる
    この方法以外はすべて、なんだかんだで...
    ● ツールのセットアップと使い方が難しい
    ● 引き継ぎが難しくなる
    ● ツールが有償かつツールにロックインされる
    ● バージョン管理システムとの相性が...

    View Slide

  39. #ccc_a1
    5. 開発DBサーバはどこにあるべき?
    39

    View Slide

  40. #ccc_a1 40
    A. 共有DB方式
    チームのエンジニア全員のPCからサーバ室の1台のDBサー
    バに接続
    B. ローカルDB方式
    それぞれのエンジニアのPCに自分専用の開発DBを構築

    View Slide

  41. #ccc_a1
    ローカルDB方式 = Docker時代のデファクト
    41
    $ docker run mysql:5.7
    $ ./gradlew flywayMigrate
    ● 不要なカラムを削除したい
    ● 不適切な名前のカラムを
    RENAMEしたい
    ● 新しい機能のために新しい
    テーブルを追加したい
    ● 並行して作業できる
    ● ただしFlywayの場合はsqlファイルのバージョン番号
    だけは衝突しないように話し合う
    $ docker run mysql:5.7
    $ ./gradlew flywayMigrate

    View Slide

  42. #ccc_a1
    6. O/Rマッパーのソースコード自動生成を
    どのタイミングでやるか
    7. 自動生成したコードをgitに入れるか
    42

    View Slide

  43. #ccc_a1
    ローカルDB方式 + 自動生成型O/R + Flyway の場合
    1. エンジニアはそれぞれやりたいDB変更をDDLで書く
    書いたら手元PCで ./gradlew flywayMigrate (手元のDBが変更される)
    2. エンジニアはそれぞれ手元でO/RマッパのJavaコード生成を実行
    自動生成したJavaコードはコミット対象外!(理由は後述)
    3. 2.に合わせてアプリのJavaコードも書く
    4. 手元のPCでアプリを起動 -> 動作確認
    5. プルリクを作る -> masterブランチにマージ
    (続く)
    43

    View Slide

  44. #ccc_a1
    ※以下はエンジニアのPCではなくCIサーバが実施
    6. 全てのソースコードツリーをチェックアウト
    7. CIサーバ内部でDockerでローカルDBを起動
    8. ./gradlew flywayMigrate (ローカルDBの再構成)
    9. ./gradlew [O/RマッパのJavaコード自動生成コマンド]
    10. ./gradlew build ->全てのコードがjar/warファイル化される
    11. アプリをデプロイする前に ./gradlew flywayMigrate -DdbHost=...
    ※今度はDBの向き先をデプロイ先環境内のDBにしておく
    12. jar/warをデプロイ
    44

    View Slide

  45. #ccc_a1
    前頁のポイント
    ● DB変更とアプリケーションコードの変更を
    同じブランチ/プルリクエストで作業できる
    ○ FlywayのマイグレーションSQLがバッティングしないように、変更内容
    と適用順序をエンジニア間で要調整
    ● O/Rマッパーの自動生成Javaコードはgitコミットしない
    ● そのかわりに開発者のPCと CIサーバそれぞれで
    必要なタイミングで自動生成を実行
    45

    View Slide

  46. #ccc_a1
    O/Rマッパーの自動生成コードもコミットしたい場合
    ● マイグレーションSQL文のコミットと、
    O/RマッパーのJavaコード自動生成の
    実行&コミットを、同時にやるべき。
    ● ということは、5頁前のような並行作業だとコンフリクトを起こし
    やすくなる。
    ○ 特に自動生成したJavaコード部分のコンフリクト
    ● ということは、直列にしか作業できない(かもしれない)
    46
    割愛

    View Slide

  47. #ccc_a1
    DBFlute = 自動生成コードをコミットする前提
    ● 例:他のカラムから導出、計算した結果を入れるプロパティを、
    自動生成したエンティティクラスに追加
    ● 例:共通のWHERE句を組み立て易くするために検索条件生
    成クラスに自作のメソッドを追加
    (正確には加筆用の継承クラスがあらかじめ自動生成される)
    47

    View Slide

  48. #ccc_a1
    8. O/Rマッパーが自動生成したJavaコードと
    ドメインオブジェクトのコードを
    分けるべきか?
    注:DDDのそれというよりはDTOに近いかも
    48

    View Slide

  49. #ccc_a1
    がぜん、分けるべき。
    49
    RDB
    Repository
    Logic
    O/Rマッパー
    自動生成したentity
    クラス
    ドメインクラス
    /DTO
    ドメインクラス
    /DTO
    Controller
    ドメインクラス
    /DTO
    ● setter/getterで地
    道に詰め替え
    ● MapStruct,
    Dozer, etc
    長寿
    長寿に
    なりがち
    コロコロ変
    わる

    View Slide

  50. #ccc_a1
    ドメインクラスと自動生成クラスの名前衝突に注意
    テーブル名
    BOOK
    O/Rマッパが自動生成したエンティティクラスやメタデータクラス名
    Book.java
    丹念に手作りしたいDDD的なドメインクラスの名前
    Book.java
    50
    名前衝突

    View Slide

  51. #ccc_a1
    // jOOQでのカスタム例
    public class FooPrefixGeneratorStrategy extends DefaultGeneratorStrategy {
    @Override
    public String getJavaClassName(final Definition definition, final Mode mode) {
    String name = super.getJavaClassName(definition, mode);
    switch (mode) {
    case POJO:
    return name + "Vo"; // エンティティクラスは BookVo.javaになる
    case DEFAULT:
    return 'Foo' + name; // メタデータクラスは FooBook.javaになる
    }
    return name;
    }
    51
    (正確にはTablesクラスの内部クラス)

    View Slide

  52. #ccc_a1
    ちょっと休憩
    52
    1. 水を飲む
    2. 時間を確認
    35分くらい?

    View Slide

  53. #ccc_a1
    9. テストデータの投入方法は?
    53

    View Slide

  54. #ccc_a1
    テストデータは必須。しかし....
    54
    ● 空っぽのテーブルでアプリケーションの動作確認はできない
    ● テストデータは固定ではない。特に日付。
    ○ 「発売前の本」のつもりのデータが常に 2018-12-15 だったら?

    View Slide

  55. #ccc_a1
    DBFluteの場合
    55
    ‘replace-schema’コマンドが
    1. 全てのテーブル、インデックスを DROP -> CREATE
    2. xls, tsv, csvファイルがあればテストデータとしてINSERT
    csvファイル上の “$sysdate.addDay(7)” は
    コマンド実行時刻の7日後の値がDBカラムに入る

    View Slide

  56. #ccc_a1
    他の方法
    56
    A. RDBMSのcsv, tsvのバルクロード機能
    a. 日付の相対指定が難しい
    B. INSERT文を用意して実行
    a. 大量の手書きINSERT文が今後のDB変更に耐えられるか?
    C. 上記A,Bのハイブリッド
    a. csvで入れて相対日付カラムはUPDATE文
    D. FlywayのJava-Based Migration
    a. DB定義変更用PJとは別PJとしてテスデータ用PJを作っておく
    b. SQL文ではなくJavaコードを作っておく
    c. INSERT文よりは楽。日付の相対指定も可能。

    View Slide

  57. #ccc_a1
    Flyway公式マニュアルによると
    57
    出典 *10
    src/main/java/db/migration/V1_2__Another_user.java
    src/main/resources/db/migration/V1_3__HogeHoge.sql
    ./gradlew flywayMigrate でファイル名順に実行される
    ループして値を変えながらINSERTすればいい

    View Slide

  58. #ccc_a1
    10. O/Rマッパが作るSQLを見たい
    58
    - 手書きのSQL以外は信用しないタイプのエンジニアのため
    に -

    View Slide

  59. #ccc_a1
    DBFluteの場合
    ● デフォルトでこんな感じ
    ○ SqlLogHandlerでさらに細かい制御も可能
    59
    出典 *5
    結果データも出てる
    呼び出し元クラス

    View Slide

  60. #ccc_a1
    jOOQのデバッグログ
    60
    出典 *6

    View Slide

  61. #ccc_a1
    O/Rマッパーを問わない方法もある
    ● JDBCドライバの中継器として稼働しつつ
    実行しようとしているSQLをログ出力
    (正確にはプリペアドステートメントだけのことがほとんど)
    ○ p6spy
    ○ log4jdbc
    61

    View Slide

  62. #ccc_a1
    11. RDBMSの独自関数を使いたい
    62

    View Slide

  63. #ccc_a1
    DBFluteの場合
    ● sql_calc_found_rowsくらいならデ
    フォルト対応
    ● 外出しSQLならなんでも書ける
    ● フォーマットは2-Way-SQL
    ● 呼び出し側コード
    (WHERE句の調整等)も
    自動生成
    63
    出典 *7

    View Slide

  64. #ccc_a1
    jOOQの場合
    64
    出典 *8,9

    View Slide

  65. #ccc_a1
    12. テーブル定義書をどう作るか
    65

    View Slide

  66. #ccc_a1
    自動生成 一択
    66

    View Slide

  67. #ccc_a1
    DBFluteの場合
    67
    ‘doc’コマンド一発

    View Slide

  68. #ccc_a1
    SchemaSpyの場合
    ● jarを直接実行、あるいはdocker run (*11)
    ● ER図も自動生成
    68

    View Slide

  69. #ccc_a1
    13. 同じプロジェクトで
    複数のO/Rマッパーを同時に使う
    or乗り換えるためのヒント
    69

    View Slide

  70. #ccc_a1
    ● 複数のO/Rマッパを好きに混ぜて使って、
    いいとこどりできたら幸せ。
    ● 一つのWeb/DBプロジェクトの開発で、
    2つ以上のO/Rマッパーを混ぜて使うことは
    無理?、危険?
    ● トランザクション管理ェェ...
    70

    View Slide

  71. #ccc_a1
    ※ Spring Frameworkを使っているとして
    71

    View Slide

  72. #ccc_a1 72
    @Autowired OrderBhv orderBhv; // DBFlute
    @Autowired DSLContext dsl; // jOOQ
    @Transactional
    public void order(String isbn, Long memberId) { // 本を購入するメソッド
    Order order = new Order();
    order.setIsbn(isbn);
    order.setMemberId(memberId);
    orderBhv.insert(order);
    Book book = Tables.Book;
    dsl.update(book)
    .set(book.STOCK, book.STOCK.minus(1))
    .where(book.ISBN.eq(isbn))
    .execute();
    }
    ● DBFluteでINSERT
    ● jOOQでUPDATE
    ● 一つのトランザクション
    (BIGIN〜 COMMIT)
    で実行されていればOK
    DBFlute
    jOOQ

    View Slide

  73. #ccc_a1
    2つのO/Rマッパが使用する
    javax.sql.DataSource オブジェクトが
    確実に同じ(インスタンス)であれば
    正しくトランザクション管理できる。
    73

    View Slide

  74. #ccc_a1
    @Bean
    public javax.sql.DataSource dataSource() {
    // コネクションプール機構を使うとして(ここではHikariCP)
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl(...);
    config.setUsername(...);
    config.setPassword(...);
    HikariDataSource ds = new HikariDataSource(config);
    // return ds; // ←こうじゃなくて↓こう
    return new TransactionAwareDataSourceProxy(ds);
    }
    74
    詳しくは TransactionAwareDataSourceProxy でググる。

    View Slide

  75. #ccc_a1
    まとめ
    75

    View Slide

  76. #ccc_a1
    Java/DB開発の今どきの手法とツール
    ● O/Rマッピングライブラリ
    ○ ソースコード自動生成によるタイプセーフ方式
    ○ 外部SQLファイル実行方式
    ● 実行したSQLのロギング
    ● DBマイグレーションの自動化
    ● テストデータ投入の自動化
    ● テーブル定義書の自動作成
    ● トランザクションに気をつけて複数のO/Rマッパーを同時に使用
    76
    選択肢と使い方をよく吟味して、レッツ快適開発!

    View Slide

  77. #ccc_a1
    Thank you !
    77

    View Slide

  78. #ccc_a1
    参考文献
    1. Hibernateはどのようにして私のキャリアを破滅寸前にしたか
    https://www.kaitoy.xyz/2017/02/23/how-hibernate-ruined-my-career/
    上記の原文 https://medium.com/@ggajos/how-hibernate-almost-ruined-16f31ba7d381
    2. 我々はいかにして技術選択を間違えたのか?
    https://blog.cybozu.io/entry/2016/12/28/101500
    3. https://groups.google.com/forum/#!msg/querydsl/fNFXliG8P-k/7dy2aAotVQ0J
    4. https://blog.jooq.org/2014/05/29/querydsl-vs-jooq-feature-completeness-vs-now-more-than-ever/
    5. http://dbflute.seasar.org/ja/manual/function/genbafit/implfit/debuglog/index.html
    6. https://www.jooq.org/doc/3.11/manual/sql-execution/logging/
    7. http://dbflute.seasar.org/ja/manual/function/ormapper/outsidesql/howto.html
    8. https://www.jooq.org/doc/3.11/manual/sql-execution/query-vs-resultquery/
    9. https://www.jooq.org/doc/3.11/manual/sql-building/plain-sql
    10. https://flywaydb.org/documentation/migrations#java-based-migrations
    11. https://hub.docker.com/r/schemaspy/schemaspy/
    12.
    78

    View Slide