マルチテナンシーというコードの形態がある。同じコードベースで、実際の実行インスタンスなどは分けた形で複数のサービスを提供することである。例えば同じコードベースでAというECサイトとBというECサイトを提供する、という感じである。
このマルチテナンシーにも、実行インスタンスを分けるのか、DBを分けるのかといった分類があるのだが、いったんここでは措いて、マルチテナンシーという概念を型安全に表現できるかについて考察してみたい。
マルチテナンシーにおける「出し分け」
マルチテナンシーにおける最大の関心事は出し分けである。コードベースが提供するサービスが全く同一ならこのような事を考えなくても良いのだが、大抵の場合はサービスによって機能を出し分ける必要がある。
- 例: 同一のコードベースを利用したECサイト
- ECサイトAではStripeによる決済機能を提供したいが、ECサイトBでは契約の都合上これを提供しない
- 例: 同一のコードベースを利用したCMSによるメディア
- メディアCにはアクセス分析機能があるが、メディアDでは契約の都合上これを提供しない
さらに、コードベースが同一であることから生じる、「うっかり無関係なメディアの情報を出してしまう」ことも絶対に避けなければならない。
- 例: 設定値を間違えてECサイトAの文言をECサイトBで表示してしまう
もちろん、マルチテナンシーをやめて別々のコードベースを開発し、ワンオフの製品とすることでこのような問題を回避できるが、当然開発の柔軟性は失われてしまうし、いわゆる逆コンウェイの法則に従うと、組織が複数に分裂することになり、組織上の柔軟性も損われる。
したがって、最大限コードを共有しつつ、テナントごとの機能を管理する手法が必要になってくる。
ナイーブな実装
ナイーブな方法ではテナント名を使って条件分岐を所々で行うことで機能管理を行うことができる。
def renderHTML(media: String): String = { if (media == "mediaA") { "メディアA向けの専用文言" } else { "その他のメディア向けの文言" } }
しかしこのような方法ではテナントが増加すれば条件分岐が指数的に爆発し、早晩の崩壊は避けられない。人間が把握しきれる場合分けの数をすぐに超えてしまうからだ。
第一級市民としての「機能」
持続可能な開発を進めるためには、「機能」というなんらかの出し分けが必要な単位をコード上の第一級市民として扱えるような仕組みになっている必要がある。DDDで言うところのドメイン知識がそのまま扱える必要があるということだ。
このような仕組みをコード上にエンコードするための仕組みをいくつか考えることができる。例えばCake Patternを使うことで、特定の機能をComponentとして隔離し、互いの依存関係を自己型アノテーションという形にエンコードできる。 ソフトウェアの起動時に自分のテナンシーに対応したCakeを最終的にインスタンス化することで、安全に機能を出し分けることができそうだ。
Shapeless
Shapelessは、代数的データ型をScala上で第一級市民として扱うためのライブラリである。この入門を先日書いたので興味がある人は読んでもらえたらと思う。
このShapelessを使って、テナントごとの機能をHList
として表現してみるとどうなるのか、柔軟な表現を得られるのか、というのがこの考察の主眼である。
まずテナントの最低限の情報を表現するcase class
と、各機能を表現したcase class
を用意してみる。
以下のサンプルでは、CMSを想定して、テナントのことをMedia
と読み替えている。
// 最低限必要な情報 case class MediaIdentity(name: String, companyName: String) { def greeting: String = s"Hello, $name. Copyright $companyName" } // メディアごとにあったりなかったりする機能 case class OgDescription(description: String) case class Purchase(currency: String)
これら自体は単なるばらばらの機能であって、単体ではあまりワークしない。そこで、これらをHList
で結び付ける。
import shapeless.HNil val media1 = MediaIdentity("www.3qe.us", "windymelt") :: OgDescription("this is homepage of windymelt") :: HNil val media2 = MediaIdentity("money.3qe.us", "windymelt2") :: Purchase("JPY") :: HNil // 特定の機能を持ったメディアでしか呼べない機能を定義する import shapeless.HList import shapeless.ops.hlist import scala.annotation.implicitNotFound implicit class MediaWithOgDescription[H <: HList](media: H) { def renderOgDescription( implicit @implicitNotFound("Media should have OgDescription feature.") sel: hlist.Selector[H, OgDescription] ): String = { val og = sel(media).description s"""<meta property="og:description" content ="$og" />""" } } media1.renderOgDescription // <meta property="... // media2.renderOgDescription // Compile Error: Media should have OgDescription feature.
するとこのように、shapelessは「特定の型がHList
に含まれていること」を要請するSelector
という操作を用意しているので、これとimplicit class
による拡張メソッドを組み合わせることで、特定の機能を有するテナントでしか呼び出せない機能というものを、型安全に表現できる。
機能の有無の判定
ここまでは、機能を呼び出せるかどうかというレベルの話をしていたが、実用上は機能の有無で分岐して処理を変化させるということがよくある。これをshapelessでエンコードできるだろうか。
まず、特定の機能を表現する型を受け取ったときにのみtrue
を返し、それ以外のあらゆる型を受け取った場合にはfalse
を返すような多相関数(一般の関数とは異なり、さまざまな型を引数に受け付ける関数)を定義する。
// Purchaseを受け取ったときに限ってtrueを返す多相関数を定義する import shapeless.Poly1 object findPurchase extends Poly1 { implicit val atPurchase = at[Purchase](_ => true) implicit def default[T] = at[T](_ => false) } findPurchase("foo") // => false findPurchase(Purchase("JPY")) // => true
するとHList
上のfoldMap
を使って、機能のHList
をBoolean
のHList
に変換し、これを畳んでBoolean
に変換できる。
// HListをfindPurchaseでfoldMapすると、HListにPurchaseがあるか検査できる def purchaseable[H <: HList](media: H)( implicit folder: shapeless.ops.hlist.MapFolder[H, Boolean, findPurchase.type] ): Boolean = media.foldMap(false)(findPurchase)(_ || _) purchaseable(media1) // => false purchaseable(media2) // => true def showPurchaseableOrNot[H <: HList](media: H)( implicit folder: shapeless.ops.hlist.MapFolder[H, Boolean, findPurchase.type] ): String = { if (purchaseable(media)) { "purchaseable" } else { "not purchaseable" } } showPurchaseableOrNot(media1) // => "not purchaseable" showPurchaseableOrNot(media2) // => "purchaseable"
しかしながら、このままだとpurchaseable
を呼び出す箇所にMapFolder
を持って回らなければならない。もうすこし型を具体的にすれば自動的にMapFolder
を導出してくれるだろうか?
// このままだと、どこにでもMapFolderを引き回さなければならない。 // もうすこしMediaの型を具体的にしてみよう import shapeless.{:+:, CNil, Inl, Inr, ::} type Media1 = MediaIdentity :: OgDescription :: HNil type Media2 = MediaIdentity :: Purchase :: HNil type Media = Media1 :+: Media2 :+: CNil class findPurchaseHList[H <: HList](implicit folder: shapeless.ops.hlist.MapFolder[H, Boolean, findPurchase.type]) extends Poly1 { implicit val atMedia1 = at[Media1](h => purchaseable(h)) implicit val atMedia2 = at[Media2](h => purchaseable(h)) implicit def default[T] = at[T](_ => false) } // うまく実装できなかった!!
しかし、うまくMapFolder
を隠せる形でコードを書くことができなかった。shapelessを実用している人々は、どこでうまく実際の型を隠蔽しつつopsを導出しているのだろうか。識者の意見が欲しい。