ExposedにおけるDSLとDAOの使い分け

2023/02/02に公開

概要

最近、サーバサイドKotlinを触り始めていて、データベース管理のライブラリにJetBrains製のExposedを使っています。

Exposedの使用方法として大きくDSL方式とDAO方式の2種類あるのですが、この使い分けについて個人的な悩みがあったので記載しました。

https://github.com/JetBrains/Exposed

結論

  • データベースレイヤーとアプリケーションレイヤーを切り分けて管理したい場合は、DSLを中心に使う
  • アプリケーションレイヤーでデータベースについて管理しても問題ない場合は、DAOを中心に使う

ドキュメント

ExposedのドキュメントはgithubのWikiがあります。
必要十分ではありますが、お世辞にも手厚いわけではないとうのが、個人的な感想です。しかし、基本的な使い方については、ここを参照するのが一番わかり易いので、まずは一読するのが良いかと思います。

https://github.com/JetBrains/Exposed/wiki

DSLとDAO

Exposedを使って、データベースを操作するには大きく DSL(Domain Specific Language)DAO(Data Access Object) という2つの方法があります。

説明するまでもないかもしれませんが、簡単にこの2つの違いを説明しておきます。

DSLは、SQLのクエリビルダ的な位置づけで、SQLのシンタックスに寄せられたAPIを使うことでSQL発行をオブジェクト経由で行うことができます。

Users.select { Users.name eq "user name" }
    .limit(10)
    .orderBy(Users.id to SortOrder.ASC)
    .firstOrNull()

usersというテーブルがあるとして、このようなDSLを実行すると以下の様なSQLに変換され実行されます。
完全にSQLと同じわけではありませんが、ある程度SQLを触ったことがあれば操作できるようなSQLのDSLとしての位置づけのAPIです。

SELECT users.id, users.created_at, users.updated_at FROM users WHERE users.name = 'user name' ORDER BY users.id ASC LIMIT 10

一方、DAOはオブジェクトを中心にオブジェクトのAPIを実行することで、内部でSQLを発行してくれるという形式になります。代表される類似のものとしては、RubyOnRailsのActiveRecordなどがこれに該当します。

User.find { Users.name eq "user name" }
  .sortedBy { it.id }

先程のusersテーブルについてDAO経由でアクセスするとこのようになります。API名も特にSQLなどは意識されておらず、直感的に理解できる形になっています。

これは以下のようなSQLに変換されます。

SELECT users.id, users.created_at, users.updated_at FROM users WHERE WHERE users.name = 'user name'

考え方としては、SQLを中心にSQLを組み立てるためのオブジェクトが用意されているのがDSL。
オブジェクトによる直感的なデータ操作を中心に、対応する操作のSQLを発行できるのがDAOという感じです。

リレーションの違い

Exposedは基本的にRDBMSを対象としたライブラリなので、テーブル間のリレーションを操作します。
このときのDSLとDAOの違いをみていきます。

テーブル

データベースにおいて、以下のような関係があるテーブルがあるとします。(MySQLを想定)

CREATE TABLE `companies` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='会社';

CREATE TABLE `employees` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `company_id` bigint unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `company_id` (`company_id`),
  CONSTRAINT `employees_fk` FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`)
) ENGINE=InnoDB COMMENT='従業員';

この2つのテーブルは、employeesテーブルがcampaniesテーブルのキーを外部キーとして参照することで関係しています。

DSL

まずはDSLの操作から説明します。DSLに使うテーブルオブジェクトは以下になります。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 255)
    val companyId = integer("company_id")
}

データ作成は以下のようになります。

// NOTE: insertAndGetIdというAPIを使うと主キーを取得できる
val companyId = Companies.insertAndGetId {
    it[name] = "〇〇株式会社"
}
Employees.insert {
    it[Employees.name] = "佐藤拓郎"
    // 外部キーをセットする
    it[Employees.companyId] = companyId.value
}

全てのカラムをそのままセットする形です。発行されるSQLは以下のとおりです。

INSERT INTO companies (`name`) VALUES ('〇〇株式会社')
INSERT INTO employees (company_id, `name`) VALUES (2, '佐藤拓郎')

次にデータ取得になります。
複数のテーブルを結合するにはSQLと同じくjoinというAPIを使用します。

 val result = Employees.join(
    otherTable = Companies,
    joinType = JoinType.INNER,
    onColumn = Employees.companyId,
    otherColumn = Companies.id
).select {
    Employees.name eq "佐藤拓郎"
}.firstOrNull()

発行されるSQLは以下のとおりです。

SELECT employees.id, employees.`name`, employees.company_id, companies.id, companies.`name` FROM employees INNER JOIN companies ON employees.company_id = companies.id WHERE employees.`name` = '佐藤拓郎';

DAO

次はDAOの操作について説明します。DAOを使う場合でも、DSLで使用するテーブルのオブジェクト定義は必要になります。

DAOで外部テーブルとの関連を表現するためにはテーブルオブジェクトで reference を使って外部キーを指定します。

またDAO側のオブジェクトは、referencedOn を使って直接Companyを参照します。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

class Company(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Company>(Companies)

    var name by Companies.name
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 255)

    // 外部キーを指定して、Companyと関連を作成する
    val company = reference("company_id", Companies)
}

class Employee(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Employee>(Employees)

    var name by Employees.name
    // referencedOnを使ってCompanyへの参照を作成する
    var company by Company referencedOn Employees.company
}

データ作成は以下のようになります。
EmployeeのcompanyにはCompany.newで作成されたCompanyオブジェクトを直接設定しています。

val company = Company.new {
    this.name = "〇〇株式会社"
}
val employee = Employee.new {
    this.name = "佐藤拓郎"
    // 関連するCompanyオブジェクトをセットする
    this.company = company
}

発行されるSQLはDSLと同じです。

INSERT INTO companies (`name`) VALUES ('〇〇株式会社')
INSERT INTO employees (company_id, `name`) VALUES (8, '佐藤拓郎')

次にデータ取得になります。
DAOの場合JOINをする必要はありません。employee.companyにアクセスするタイミングでcompanyを取得するためのSQLが実行されます。

val employee = Employee.findById(10)
println(employee?.company?.readValues)

発行されるSQLは、JOINされたものではなくテーブル毎に実行されます。

SELECT employees.id, employees.`name`, employees.company_id FROM employees WHERE employees.id = 10
SELECT companies.id, companies.`name` FROM companies WHERE companies.id = 17

データベース側のデフォルト値

細かい話ではあるのですが、DAOを使用している場合は、データベースのDDLでデフォルト値を使用している場合、DAOを使っているとうまく処理できない場合があります。

例えば以下の様なテーブル定義になっているとします。

CREATE TABLE `companies` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT "デフォルト会社名",
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='会社';

このとき、データベース側にデフォルト指定があるからといって、以下のように書くとエラーになります。

val company = Company.new {}
Can't add a new batch because columns: companies.`name` don't have default values. DB defaults don't support in batch inserts

DAOを使ったアクセス時はデータベースのデフォルト値を考慮してくれないようです。
こういったケースではデータベース側にデフォルト値を設定するのではなく、Exposedのテーブルオブジェクトにデフォルト値を設定します。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

class Company(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Company>(Companies)

    var name by Companies.name
}

DSLでインサートを行う場合には、テーブルオブジェクトに default は不要です。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

// エラーは発生しない
Companies.insert {}

DAOを使う場合は、基本的にデフォルト設定はExposed側で行う必要があるようです。

バッチインサート

https://github.com/JetBrains/Exposed/wiki/DSL#batch-insert

Exposedには、大量にデータ挿入が発生したときに、SQLのbulk insertに変換するためのbatch-insertという機能があります。

この機能についても、データベース側でデフォルト値が設定されているとエラーが発生して使用できませんでした。

まとめ

ExposedはDAOとDSLという2種類の使用方法がありますが、完全に分離されているわけではなく、DAOを中心に使いたいのであれば、テーブル定義オブジェクトもDAO用に使う必要がありそうです。
その際は、データベース側でデフォルト値を設定しているとうまく使用できないことがあるので注意が必要です。
(他にも制限があるかもしれません)

この様な挙動は、Exposed自体のテーブル作成機能を使った場合は発生しないと思うので、フォローされていないのかもしれませんが、データベースのマイグレーション管理を別に行っている場合やExposedへの移行を検討している場合は気をつけた方が良いかもしれません。

個人的な運用になりますが、私はデータベースレイヤーとアプリケーションレイヤーは、ある程度は切り離して考えたく、マイグレーションもExposedに依存していないため、現状はDSLしか使わない運用で開発しています。
(reference機能も使わず、データベースレイヤーからモデルへのレイヤー変換処理を書いています。)
(バルクインサートも使いたい箇所があったのですが、今は使っていません)

これは、設計に対しての考え方にもよると思いますので、ある程度アプリケーションでデータベース定義を管理しても問題ないと考えている場合は、素直にDAO中心に記載したほうが工数は少なくなるかとも思います。

この辺り、うまく運用されている事例や、認識齟齬などあれば気軽にコメントいただければ幸いです。

Discussion