Scala関西 Summit 2015での発表で触れていたN+1クエリ問題をなんとかするためのライブラリを公開した.
発表は以下のもので, ここでは「関係モナド」という名前で紹介していたけれど, これは口頭でも説明したように便宜上てきとーにつけた名前であって, とくにそういう名前のよく知られたモナドがあるというわけでもなければ, そもそもモナドであるかどうかはあまり本質的ではない. この発表のあとに, Rails (Active Record)でのbulletのようにN+1問題の検出をScalaでやる方法はないだろうか, と言っている人がいたので, そういうものを探していて辿りつけるとよかろうということで, bullet-scalaという名前にした. もちろんN+1問題の検出のためのライブラリというわけではないし, 動的に検出するのではなく原理的に問題が発生しないようにするものなので, 思想は全く異なる.
どういうものか
英語で書いたREADMEの説明を和訳したものを載せておこう.
問題
Car
とEngine
というクラスがあって, Car
が持つEngine
が型クラスのメソッドtoEngine
によって解決できるとしよう.
type CarId = Long type EngineId = Long case class Car(id: CarId) case class Engine(id: EngineId, carId: CarId) implicit class CarRelation(val car: Car) extends AnyVal { def toEngine: Option[Engine] = ... }
toEngine
の実装をDBクエリをともなうリポジトリクラスを用いて実装するのは極めて普通のことだろう.
implicit class CarRelation(val car: Car) extends AnyVal { def toEngine: Option[Engine] = EngineRepository.findByCarId(car.id) } val db = ... object EngineRepository { def findByCarId(carId: CarId): Option[Engine] = db.run { sql"SELECT * FROM engine WHERE car_id = $carId LIMIT 1".as[Engine] }.headOption }
ひとつのCar
のインスタンスからEngine
のインスタンスを解決するなら, とくになんの問題もない. この場合, 内部的にひとつのSELECT
クエリが実行される.
val car: Car = Car(1234L) val engine: Option[Engine] = car.toEngine // SELECT * FROM engine WHERE car_id = 1234 LIMIT 1
もし複数のCar
インスタンスがあって, それらの持つEngine
インスタンスを得ようとおもうと, こう書いたらよさそうに思える.
val cars: Seq[Car] = Seq(Car(1L), Car(2L), Car(3L), ...) val engines: Seq[Engine] = cars.map(_.toEngine).flatten // SELECT * FROM engine WHERE car_id = 1 LIMIT 1 // SELECT * FROM engine WHERE car_id = 2 LIMIT 1 // SELECT * FROM engine WHERE car_id = 3 LIMIT 1 // ...
これは確かに動く. けれど, SELECT
クエリがcars
の各id
ごとに実行されてしまう. cars
の要素数がある程度以上になってくるとこれはパフォーマンス上の問題となる.
この問題を解決するひとつの方法は, あらかじめテーブルをJOIN
しておくこと. 複数のCar
をDBのcar
テーブルから引いてきてインスタンス化するときに, engine
テーブルもINNER JOIN
などしておけばよいだろう. これはよく知られた解決方法ではあるものの, 最善ではない. もしEngine
の他にもWheel
, Bumper
, Door
などをCar
が持っているとしたら, これらもすべてJOIN
する? けれど, これらすべてが(Car
を使うときに)常に必要というわけでもないだろう. だとしたら, これらのうち必要なものの組み合わせのみJOIN
しながらCar
をインスタンス化するメソッドをいちいち用意してまわる?
理想的には, 上の例の最後の式で, 単一のSELECT
クエリになってくれると嬉しい.
val engines: Seq[Engine] = cars.map(_.toEngine).flatten // SELECT * FROM engine WHERE car_id IN (1, 2, 3, ...)
こんなことできるの? できる. そう, Scalaならね!
bullet-scalaによる解決方法
toEngine
メソッドの返り値をあるモナドにするだけでよい. このモナドは, Car
からEngine
を解決する方法を指定するHasA[Car, Engine]
を, HasA.Monadic
に渡すことで作られる.
import com.github.tarao.bullet.HasA implicit class CarRelation(val car: Car) extends AnyVal { def toEngine = HasA.Monadic(car, hasEngine) } val hasEngine: HasA[Car, Engine] = ...
toEngine
の使い方はほとんど変わらない. 返り値を受け取る変数の型(今回はOption[Engine]
やSeq[Engine]
)を省略できない点と, もはやflatten
を呼ぶ必要がない点にだけ注意する.
import com.github.tarao.bullet.Implicits._ val car: Car = ... val engine: Option[Engine] = car.toEngine val cars: Seq[Car] = ... val engines: Seq[Engine] = cars.map(_.toEngine)
hasEngine
の実装では, 複数のCar
からEngine
をひとつのクエリで解決する. このために, HasA[Car, Engine]
のmap()
というSeq[Car] => Seq[Engine]
な型のメソッドを実装する. 実装は次のような具合になる.
val hasEngine: HasA[Car, Engine] = new HasA[Car, Engine] { def map(cars: Seq[Car]): Seq[Engine] = db.run { sql"SELECT * FROM engine WHERE car_id IN (${cars.map(_.id)})".as[Engine] } }
このmap()
メソッドはOption[Engine]
を解決するときにも, Seq[Engine]
を解決するときにも使用される. どちらの場合も, 結果的にそれぞれひとつのSELECT
-WHERE
-IN
クエリが実行されることになる.
val car: Car = Car(1234L) val engine: Option[Engine] = car.toEngine // SELECT * FROM engine WHERE car_id IN (1234L) val cars: Seq[Car] = Seq(Car(1L), Car(2L), Car(3L), ...) val engines: Seq[Engine] = cars.map(_.toEngine) // SELECT * FROM engine WHERE car_id IN (1, 2, 3, ...)
どうやって動くのか
鍵はtoEngine
の返り値のモナドで, 例で見たように返り値を受け取る変数の型は(モナドの型ではなく)Option[Engine]
かSeq[Engine]
になっている. これは実は暗黙変換で, もし明示的に書くなら次のようになる.
val car: Car = ... val engine: Option[Engine] = car.toEngine.run val cars: Seq[Car] = ... val engines: Seq[Engine] = cars.map(_.toEngine).run
HasA[].map()
を実際に呼ぶのはこのrun()
ということになる. これが呼ばれるまでは, HasA[].map()
の呼出しはモナドの中で遅延される. レシーバがモナドのリストの場合は, これらはHasA[].map()
の単一の呼出しにまとめられる*1.
なぜモナドなのか
ここまではモナドとしての使い方は一切説明してこなかった. 実際, 本質的にはモナドである必要はなく, なんらかの遅延オブジェクトでさえあればよい. モナドになっているのは利便性のためでしかない.
例を見てみよう. Engine
がさらにCrankshaft
を持つとして, toEngine
のときと同様にtoCrankshaft
で引けるとしよう. もしモナドとしての性質がなければ, Car
のインスタンスからEngine
のインスタンスを経由してCrankshaft
のインスタンスを得るには, 次のようにする必要がある.
val car: Car = ... val engine: Option[Engine] = car.toEngine val crankshaft: Option[Crankshaft] = engine.map(_.toCrankshaft: Option[Crankshaft]).flatten
モナドになっていれば, 次のように書ける.
val car: Car = ... val crankshaft: Option[Crankshaft] = for { e <- car.toEngine c <- e.toCrankshaft } yield(c)
もしe
やc
(これらはモナドの中身を参照しているので直接Engine
型およびCrankshaft
型として扱える)を使ってなにか複雑な操作をする必要がある場合はとくに, この書き方ができると簡単になる.
関係のあるオブジェクトを結合する
関係性を解決して得られた2つのオブジェクトを1つにまとめたい場合もある. たとえば, Car
とEngine
があったら, CarWithEngine
にまとめてしまいたいかもしれない. このためには, Join.Monadic
を使って以下のようにwithEngine
メソッドを実装するとよい.
import com.github.tarao.bullet.Join type CarWithEngine = (Car, Engine) implicit class CarRelation(val car: Car) extends AnyVal { def withEngine = Join.Monadic(car, joinEngine) } type JoinEngineToCar = Join[CarWithEngine, CarId, Car, Engine] val joinEngine: JoinEngineToCar = new JoinEngineToCar { def map(cars: Seq[Car]): Seq[Engine] = ... // HasA[Car, Engine]と同じ def leftKey(car: Car): CarId = car.id def rightKey(engine: Engine): CarId = engine.carId def merge(car: Car, engine: Engine): CarWithEngine = (car, engine) }
今回は実装すべきメソッドが4つある: map()
, leftKey()
, rightKey()
, merge()
. map()
はHasA[Car, Engine]
のときとまったく同じ. leftKey()
とrightKey()
は関係元のオブジェクトと関係先のオブジェクトの結びつけ方を提供する. 今回の場合, Car
とEngine
は共通するCarId
を持つ. 結びつけられた2つのオブジェクトはmerge()
に渡される.
使い方はtoEngine
に非常によく似ている.
val car: Car = ... val enginedCar: Option[CarWithEngine] = car.withEngine val cars: Seq[Car] = ... val enginedCars: Seq[CarWithEngine] = cars.map(_.withEngine)
補足事項
結合する際の結果の型
上の例ではCarWithEngine
はただの組になっていて, これだと明らかにまずい. たとえばwithBumper
とかしたくなったら今度は(Car, Bumper)
にするのか, でもEngine
もBumper
も両方ほしいときがあって, (Car, Engine, Bumper)
なのか(Car, Bumper, Engine)
なのかで互換性がないのはどうなのか.
setterにするとそういう問題は起きないかもしれないけれど, そもそもsetterがあるということは型安全ではないし不変でもない. あるいは型安全なsetterというものも想定できないわけではなくて,
val engine: Engine = ... val bumper: Bumper = ... val car1: Car = ... val car2 : Car with { def engine: Engine } = car1.withEngine(engine) val car3 : Car with { def engine: Engine } with { def bumper: Bumper } = car2.withBumper(bumper)
みたいなことができればよいのかもしれない. そんなことできるだろうか? 何をsetできるかが予め決まっているのなら, いちおうできなくはない.
object Engine { trait Field { val engine: Engine } } object Bumper { trait Field { val bumper: Bumper } } sealed abstract class Car(val id: CarId) { protected def detailed: Car.Detailed } object Car { def apply(id: CarId): Car = new Detailed(id) case class Detailed private[Car] ( override val id: CarId, engine: Engine = null, bumper: Bumper = null ) extends Car(id) with Engine.Field with Bumper.Field { override protected def detailed: Detailed = this private[Car] def as[C >: Detailed <: Car]: C = this } implicit class Setter[C >: Detailed <: Car](val car: C) extends AnyVal { def withEngine( engine: Engine ): C with Engine.Field = car.detailed.copy(engine = engine).as def withBumper( bumper: Bumper ): C with Bumper.Field = car.detailed.copy(bumper = bumper).as } }
うーん, けっこうめんどくさい. こういうことを汎用的にやる方法はないものだろうか?
このような型安全なsetterを実現する型というのは一般的な名前をつけるならExtensible Recordと呼ばれる. Scalaにそれをやれるライブラリはないだろうか? 実はshapelessにある. ただこれはまぁコンパイルがすごく遅くなる.
他のやり方でレコード型を実現するものにはscala-recordsがある. ただしこれは拡張できない(setterはない). 拡張できるようなレコード型の実験的な実装としてはcompossibleがあり, この作者はどうやらscala-recordsに機能をマージしようとしているので, これが完成すれば非常に期待できそう.
- (追記) 2015-08-17
- Extensible Recordの話したかったので完全に書くのわすれていたけれど, こういうときはLens使えよ, という気もしないではない. ただLensの場合もふつうのやり方で記号メソッドのオンパレードだとかなり見た目が厳しいし, 新しく定義する場合も含めてチームメンバーにLensを使いこなせるか, というのはだいぶ大変に思える(のでいったん検討から外していて触れるのを忘れてしまった).
遅延することの是非
一般的になにかを遅延して実行すると, 実際にそれがいつ実行されるのかわかりにくくなる. それが副作用のある計算であれば, わかりにくくなることでメンテナンス性が下がるかもしれない. なのでbullet-scalaのような仕組みをただ闇雲に導入するのはあまりおすすめしない.
もともと, これを導入することに決めた動機はドメイン駆動設計(DDD)の徹底した実践のためだった. 曰く, 実装の都合をドメインロジックに持ち込んではならない.
N+1問題をただ回避したいだけであれば, SELECT
-WHERE
-IN
で引いてくるメソッドを別で用意してそれを使えばよい. けれどひとつのCar
からひとつのEngine
を引いてくるメソッドを残したままで, どうやってループの中でそのメソッドを引いてしまうのを防ぐのだろう? コードレビューで? クエリを監視してN+1クエリになってそうなものを潰していく? 次の10年間もそれをし続ける? 実際のところ, 現状のはてなブックマークではうんざりするほどそうしてきた. もう本当にうんざりしたので二度とそういうことはしたくない.
まとめて引いてくるメソッドを用意して使いわけるにせよ, とにかく実装上の都合でドメインロジックの記述方法が変わってしまうのは避けたい. それがbullet-scalaが作られた理由だった.
逆に, DDDをきちんと遂行していれば, おそらくCarRelation
は自明にドメインサービスの一種として配置されることになるだろう. これを触ってよいのはアプリケーションサービスか他のドメインサービスのみで, run()
せずにモナドをメソッドの外に返してはならない. なぜならモナド自体はドメインに記述されたモデルを表すものではないから.
toEngine
しているメソッド内で必ずrun()
されているなら, そう酷いことにはならないだろう, というのがいまのところの感触.
暗黙変換か明示的なrun()
か
遅延していても, せめてrun()
が明示的に書いてあれば, 実際にどこで実行されるのかまだわかりやすい, という意見があった. まぁこれはそうかもしれない. もし前述のDDDのきちんとした実践ができないなら, 間違いなく明示的にrun()
を呼んだ方がよいとおもう. 局所的な範囲内で必ずrun()
されるようなモデルになっているのであれば, toEngine
している箇所のすぐ近くで実行されているはずなので, それほど酷いことにはならない.
暗黙変換でクエリが走るのがキモいという意見もある. まぁキモいかキモくないかで言えば当然キモいとおもう. キモいのがいいのか悪いのかというのは主義によるとおもう. 個人的にはキモい言語大好きなのでキモいことはいくらでもすればいいとおもっているし, これを受け入れられないなら世の中のアレもコレもダメじゃないの? とおもうけれど細かくは書かないでおこう. とにかく, キモいのはまぁそうかとおもったしとにかくキモいのを排除したい人もいるとおもうので, デフォルトではrun()
が必要で, 暗黙変換でやりたければそれ用のimport
が必要なようにした.
実装の詳細
まとめてrun()
する方法
ふつうに考えると, HasA[].map()
を利用してSeq[Monad[R]] => Monad[Seq[R]]
のような変換をしなければならない. けれど一般的にはそんなことはできなくて, Monad[R]
と言ってもR
の値からそのまま作られたものだったり, map()
やflatMap()
で別の型から変換されてきたものかもしれない. HasA[].map()
でまとめてやれるのは, Seq[Monad[R]]
の要素すべてがHasA.Monadic()
で生成されたものの場合のみということになる.
HasA.Monadic()
で作られたモナドをMonad.Resolve[]
型で表し, R
型の値から直接作られたものをMonad.Unit[]
型で, flatMap()
で作られたものをMonad.FlatMapped[]
型で表すことにしよう(map()
はflatMap()
とUnit()
の組み合わせで実現できるので専用の型は必要ない).
まとめてrun()
しようとしているのがSeq[Monad.Resolve[]]
であれば, 要素のうちのどれかひとつのHasA[].map()
を使って全要素の解決を一度にできる. Seq[Unit[]]
だったときには元の値をただ取り出せばよいし, Seq[FlatMapped[]]
だったときには, 各要素に対してそれぞれflatMap()
に渡された関数を適用していけばよい.
一点だけ注意が必要なのは, flatMap()
に渡された関数を適用した結果もまたモナドだということ. その結果のモナドのリストをまた再帰的にrun()
する必要がある. このときはふたたび, リスト要素のモナドが実際にはどの型なのかによってrun()
の計算の仕方を分岐する必要がある.
型安全性
簡単に「Seq[Monad.Resolve[]]
であれば」と言っているけれど, リスト内のモナドの型が全部いっしょかどうかに注意しないといけない. とくに, flatMap()
した先のモナド型が, リストの各要素について全て揃っている必要がある. これを保証するために, Monad.FlatMapped[]
の型にはflatMap()
する前と後の型が型引数に現れるようなものになっている. 具体的に言うと, たとえば以下の2つはSeq[]
に入れてもまとめてrun()
できない(できてはいけない).
val car: Car = ... val crankshaft: Crankshaft = ... val m1 = car.toEngine.flatMap(_.toCrankshaft) val m2 = car.toEngine.flatMap { _ => Monad.Unit(crankshaft) } val crankshafts: Seq[Crankshaft] = Seq(m1, m2).run // type error
もしモナドの型を単にMonad[R]
で表すとすると, m1
とm2
はどちらもMonad[Crankshaft]
型ということになる. そのままrun()
できるとすると, 最初のtoEngine
ではどちらもHasA[].map()
で解決すべきモナドになっているはずなので問題ない. けれど, flatMap()
された後の返り値のモナドは, m1
の方はHasA[].map()
で解決すべきもので, m2
の方はcrankshaft
の値をそのまま返す必要があって, これらは到底まとめてrun()
できない*2. なのでこのような組み合わせの場合はどうやってrun()
するかという以前にrun()
できないように静的なエラーにしなければならない.
感想
今回のモナドはflatMap()
などした途中の操作が型引数に蓄積されていくので, あんまりふつうのものではない気がする. こういうのが既に提案されていないか探してみたけれどちょっとよくわからなかった. Sig[]
とか出てくるせいで, モナド則を満たすかどうかもcontextual equivalenceではなくrun()
した結果が等しいというくらい(β-同値性のようなもの)でしか言えない. まとめて実行するrun()
ではなく, リストの要素を個別に実行するrun()
(ライブラリ内では.diverge.run
で呼び出せるようになっている)でだけ考えればいいならMonad[R]
上に定義されているのでcontextual equivalenceまで成り立つはず. ただ, そもそもモナド則に出てくる同値性がどういう意味の同値性であるべきなのかよく知らないのでどこまで保証すべきなのかよくわからない.
なにかもっとよい抽象化をするともう少し汎用的にできるような気もしないでもない. けれど労力の割にはきっとオーバースペックになるような気がして, いまのところはこの程度に留めている.
*1:HasAのインスタンスはそれぞれのモナドごとに渡しているのにどうしてそんなことができるのかと疑問に思うかもしれない. 実はHasAのインスタンスのうち使用されるのはリストの先頭要素のものだけである.
*2:もちろんこの場合に片方はHasA[].map()で解決してもう片方はそのまま返す, という実装も不可能ではない. ただしこれをやるには動的にモナドの実際の型をかなり詳細に把握できないといけないし, そのような動的な分岐はパフォーマンス上も不利になる. なにより, これでは「まとめてrun()される」ことを期待しておきながら実際にはされない, という事態が発生しやすくなって, 当初の目的から離れてしまう.