Scala 2019 の投票で Slick を抜いて1位になっていた、Typelevel プロジェクトの関数型 RDB/JDBC アクセスライブラリ、doobie について。
doobie の特徴
Scala の RDB アクセスのための類似プロダクトには、Slick、Quill、ScalikeJDBC などがあるが、doobie には以下のような特徴がある。
- EDSL 系ではなく、文字列補完子でSQLをそのまま書く系。
- 記述1された DBアクセスは、Cats の Free Monad ベースの型で合成しやすい。動的な SQL も FP的に合成可。
- Free Monad からの変換先となる effect wrapper は、Cats Effect の
Async[F]
となるF[_]
なら何でもいい。典型的には、cats.effect.IO
やmonix.eval.Task
が使われる。 -
FS2 の
Stream
2がネイティブにサポートされていて、http4s など FS2 ベースのプロダクトと相性がいい。 - Select 結果から Scalaデータへのマッピング先に、プリミティブ型、タプル、case class に加えて、Shapeless の
HList
やRecord
もサポートされる。3 - REPL で使える YOLO というモードがあって4、試しに動かしてみたり、実DBのメタデータを突き合わせて不整合を見つけたりするのに便利。
- 現時点(2019/03)での最新バージョン 0.6.0 から、Cats Effect の
ContextShift
を活用した非同期APIがサポートされている。5 - JDBC を純粋関数型的に操作する low-level API と、プログラムを記述する high-level API があるが、通常はもっぱら後者を使う。
- Slick のようなテーブル定義をクラスで表現する仕組みや、Quill のような convention ベースのマッピングなどは特になく、Scala 的にタイプセーフというわけではない。ただし実DBから得たメタデータとクエリを照合して不整合を検出する仕組みが手厚い(後述)。
以下、サンプルコード。
※ doobie のバージョンは 0.6.0。その他のライブラリはbuild.sbt参照。
簡単なサンプル
簡単なサンプルは下のようなものになる。動かすには "world" データベースの事前セットアップ6が必要。
case class Country(code: String, name: String, population: Long)
object SimpleSampleMain extends TaskApp {
def xa[F[_]: Async: ContextShift]: Transactor[F] = Transactor.fromDriverManager[F](
"org.postgresql.Driver", "jdbc:postgresql:world", "postgres", "pass"
)
def find(n: String): ConnectionIO[Option[Country]] =
sql"select code, name, population from country where name = $n".query[Country].option
def run(args: List[String]): Task[ExitCode] = for {
line <- find("France").transact(xa[Task]) // ここで ConnectionIO[_] から Task[_] に変換
_ <- Task { println(line) }
} yield ExitCode.Success
}
ここでは effect に Task
を採用した。また、簡単のため、DriverManager を使ってTransactor
を得ているが、HikariCP との連携や7、既存 DataSource を利用する方式8もある。
http4s との連携
上述の通り、doobie の中では FS2 の Stream
を使っているので、http4s などとも相性がいい。
case class City(id: Int, name: String, district: String)
class MyService[F[_]: Async: ContextShift] extends Http4sDsl[F] {
def xa: Transactor[F] = Transactor.fromDriverManager[F](
"org.postgresql.Driver", "jdbc:postgresql:world", "postgres", "pass"
)
def findCitiesByCountry(c: String): doobie.Query0[City] =
sql"select id, name3, district from city where countrycode = $c".query[City]
object CountryCodeMatcher extends QueryParamDecoderMatcher[String]("countryCode")
def app: HttpApp[F] = HttpRoutes.of[F] {
case GET -> Root / "cities" :? CountryCodeMatcher(code) =>
Ok(findCitiesByCountry(code).stream.map(_.asJson).transact(xa))
}.orNotFound
}
MyService
では、まだ特定の effect に束縛していない。たとえば IO
に決めるとすると、実行するサーバは以下のように書ける。
object ServerMain extends IOApp {
def run(args: List[String]): IO[ExitCode] =
BlazeServerBuilder[IO]
.bindHttp(8080, "localhost")
.withHttpApp(new MyService[IO].app)
.serve
.compile.drain
.as(ExitCode.Success)
}
下記コマンドを実行すると、world データベース内の 248 の日本の街が返される。
$ curl 'http://localhost:8080/cities?countryCode=JPN'
※ 簡単のため、Json 化された City
が個別に連続で流れてくる感じにしたが、本当は JsonArray にしたほうがいい9。
クエリの検証
アーキテクチャの外縁層で JDBC にアクセスするコードは、Scala プログラムとしてのコンパイル時の型安全もさることながら、実DBのスキーマと合ってるかがより重要になる。
特に、スキーママイグレーションの頻度が多い状況では、新しいマイグレーションDDLが既存のクエリを壊していないことや、新規のクエリが最新スキーマに同調していることを、Scala コード上だけではなくDBと実際に突き合わせて、簡単かつ継続的に検証したい10。例えば以下のような観点がある。
- コード上のクエリ文字列とDBスキーマのテーブル名、カラム名などが合っているか
- コード上の型とスキーマ上の型が合っているか(NULL可否などを含む)
- クエリの結果データと変換先の Scala データの型、構造が合っているか
doobie では DBメタデータを使って、これらをチェックする仕組みが提供される。場合によっては、修正案まで含めてかなり親切に指摘してくれる。
例えば、上の MyService
のクエリ findCitiesByCountry
は以下のようにテストできる。11
object MyServiceQuerySpec extends Specification with IOChecker {
import scala.concurrent.ExecutionContext.Implicits.global
implicit val cs: ContextShift[IO] = IO.contextShift(global)
val sut = new MyService[IO]
def transactor: doobie.Transactor[IO] = sut.xa //実DBを見に行くので必要
check(sut.findCitiesByCountry("DUMMY"))
// 他にもクエリがあれば、以下、同様に並べて列挙すればいい
}
ここで、もし例えば district
の型を誤って Double
にしていると、テストが失敗して以下のようなメッセージが出る。
VARCHAR (varchar) is ostensibly coercible to Double according to the
JDBC specification but is not a recommended target type. Expected
schema type was FLOAT or DOUBLE.
ちなみに REPL 上の YOLO モードで確認できるので、新規開発中にも活用できると思う。
補足
Free Monad の扱いについて
JDBCプログラミング経験者にはおなじみの Connection
、Statement
や Record
といった各種オブジェクトについて、それぞれのメソッドに対応する Free Monad 代数が、サフィックス 〜Op
をつけた命名で定義される。例えば Connection なら、代数 ConnectionOp
とその要素、Commit
、Rollback
、Close
などが定義される。
また、各々の代数を cats.free.Free
にしたものが、サフィックス〜IO
をつけた形で提供される。例えば、Connection に対しては、
type ConnectionIO[A] = Free[ConnectionOp, A]`
として定義される。他の JDBC オブジェクトについても同様。
インタープリターも doobie で提供されるので、特に詳しく知らなくても普通に使えるようにはなっているが、観察すると Free Monad の活用例として参考になる。
DDL、insert、update 等について
SELECT系と同様に、文字列補完子を使ってConnectionIO[_]
型の DBアクセスが記述できる。これもシンプルに書ける12。
logging について
クエリ定義コードで、query
メソッドの代わりに、queryWithLogHandler
に LogHandler
を渡すと、実際に発行されたクエリと実行時間が標準出力(デフォルト)にログ出力される。13
所感
関数型な Scala 開発での RDBアクセス14の選択肢としてかなり有力ではなかろうか。特に Cats Effect と FS2 に慣れていたり、すでに導入していたりする開発現場なら、これが一番シンプルかつ自然な気がする。
参考サイト
- doobie official: A functional JDBC layer for Scala
- Typelevel: Libraries
- SoftwareMill Blog: Comparing Scala relational database access libraries