【Scala】type-safe builder パターンを応用して、保存前にバリデーションが必須であることを示す
Javaのビルダーパターンは有名ですね。Javaでイミュータブルにしたいけど、インスタンスの生成が難しかったり、デフォルト値が多く部分的に変えたい場合などによく使われます。Javaの場合だと、setterで準備したくなる誘惑に駆られるわけですね。しかし、状態のミューテーションはバグの元。コンピュータリソースがたくさんある現在のエンプラ領域ではミュータブルは基本アンチパターンと考えられています。私の場合、業務だとAWS SDKを扱う際によくBuilderパターンの実装に出会う気がします。 Javaでは有用なパターンですが、 私が普段使うScalaは基本がイミュータブルなので、あまりBuilderパターンを使う場面に出会うことはほぼありません。しかし、Builderパターンの更なる発展系としての type-safe builder パターンで使われる考え方は非常に有用です。
Builderパターンとtype-safe builder の話は過去に記事で挙げているのでこちらをご確認ください。
実装例は下記リポジトリに上げています。
実装アイデアを利用できそうな状況
- 処理の順番を明示したい時。例えば、処理Bをする前に処理Aをしてほしいことを明示したい時
- 状態をクラスメンバではなく、型パラメータによって表現し、コンパイル時にそれで示された状態による事前条件チェックがしたい時
業務でよく出会うDBと通信しながらのバリデーション
業務で開発をしていると、マスタにあるかどうか?とか、既存のものと重複がないかとか色々と通信しながらのバリデーションを行うことは常です。 そして、そのバリデーションをどこで行うか?絶対必須のバリデーションをどうやって明示するかは開発者の悩みの種です。
今回は、例としてドメインサービスによるバリデーションを検討します。 その例として、「ドメイン駆動設計入門」のドメインサービスの章の例を使って考えていきます。
「現実において同姓同名は起こりえますが、システムにおいてはユーザ名の重複を許可しないことはありえます。ユーザ名の重複を許さないというのはドメインのルールであり、ドメインオブジェクトのふるまいとして定義すべきものです。さて、このふるまいは具体的にどのオブジェクトに記述されるべきでしょうか。」
—『ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本』成瀬 允宣著 https://a.co/hf7h5Nj
上記の本では、ユーザー名の重複をクラスの振る舞いとして持たせるのではなく、ドメインサービスとして実装しましょうと例を挙げています。
下記のようなドメインサービスですね。
/** * ユーザーのドメインサービス */ trait UserService extends DomainService { /** * DBに問い合わせをして、ユーザー名が重複してないかを確認する * * @param user ユーザー */ def exists(user: User): Either[UserNameDuplicateError, Unit] }
そして、保存の際にこれを使ってチェックすることでバリデーションとします。
このくらいの簡単な場合は見ればわかりますが、業務システムではもっと要件は複雑で関わる人も多くいます。このバリデーションの仕様を知らない人が、ユーザーをあるとき保存使用しようとしてバリデーションせずにいきなり
userRepository.insert(newUser)
することは容易に想像できます。 後続の開発者になんとかして制約があることを示してあげたいところです。そこで思いだしたのが、type-safe builderです。
型パラメータを利用してバリデーション状態を表現する
type-safe builder を思い出す
Scalaでビルダーパターンを使う機会ってほぼないですが、type-safe builder の考え方はかなり有用です。 type-safe builder で重要な点はADT(抽象データ型)によって状態を表現していることにあります。
最初に紹介した記事のtype-safe builderの実装で下記の記述がありました。
sealed trait BuildStep sealed trait HasFirstName extends BuildStep sealed trait HasLastName extends BuildStep
これはビルドの状態を示しているわけです。HasFirstName
の状態でなければ、setLastName
することはできません。
def setLastName(lastName: String)(implicit ev: PassedStep =:= HasFirstName): Builder[HasLastName] = { this.lastName = lastName new Builder[HasLastName](this) }
また、HasLastName
の状態でなければ、build
することはできません。
def build(implicit ev: PassedStep =:= HasLastName): Person = { new Person(firstName, lastName, age) }
これはつまり、状態に対する事前条件として機能しているわけです。型パラメータを使わずに書くととこのような状態です。この場合は実行時に呼び出し順が間違っていれば実行事例外が投げられる。
そう、つまり type-safe builder で用いられている HasFirstName
や HasLastName
などのADTは状態を示しているというのがわかったと思います。
ADTが状態ならば、バリデーションした、してないの状態を持たせることができるのでは?
それでは、User
を書き換えましょう。生成処理を絞り、validate
を通すことでのみ、Valid
な User
が生成できるようにしています。
create
時は生成前なので、NotValid
です。
そして利用する際は下記のようになります。
上記にも記載しましたが、validation 前にinsertをしようとすると type mismatch でエラーになります。
これで、ADTを利用して、利用者にバリデーションの必要性を示すことができました。
最後に
この実装アイデアは、業務で複数エンティティ間の整合性を担保しなければならないことが判明し、それを直す際に思いつきました。 通常ならばそのような場合は集約を利用して複数エンティティをまとめ、トランザクション整合を保つように実装するのですが設計を変えるのはコストが大きくすぐにはできそうにありませんでした。そこでまずはドメインサービスでバリデーションを追加しようとなるわけですが、業務で扱うようなアプリケーションはまあ複雑です。今実装している人はいいかもしれませんが、後から見たらバリデーションが必要かどうかなんて分かりませんし忘れそうです。なんとかしてバリデーション必須になるように制約をつけたいと考えました。そこで、バリデーションの有無を型パラメータで示せばいいのでは?とtype-safe builderのことを思い出したのです。
問題点もあります。
最終的な実装は入門ドメイン駆動設計では、バリデーションの振る舞いがあるべきはそこじゃないよね?とされる箇所に舞い戻ってしまっています。 理想的には集約にしたい。でもそれもできない。せめて事前条件としてのバリデーションを。それが今回の思いつきです。
多分木をフラットにする, Scala
以前メモ的に書いたこれ、少し使い勝手が悪いのと、この手の処理はぱっと書けないのでメモする。
Node trait
上の記事で作った、単純な多分木をフラットにする処理を拡張してみた。
import scala.annotation.tailrec trait Node[A <: Node[_]]{ val children: List[A] protected def flatChildren[B](z: B)(f: A => B): List[B] = { // 継承先の具体的な実装での利用を想定しているので、protected にした。 @tailrec def loop(children: List[A], vs: List[B]): List[B] = { children match { case ::(head, next) => loop(next ++ head.children.asInstanceOf[List[A]], vs.appended(f(head))) case Nil => vs } } loop(children, List(z)) } }
使い方
Node trait
を継承して、利用先の具象クラスの振る舞いにふさわしい表現のメソッド名をつけてあげてください。
case class SampleTree( value: Int, children: List[SampleTree] ) extends Node[SampleTree] { // 第一引数には初期値を、第二引数には元となる木をベースにした変換処理を書きます。 def allValues: List[Int] = flatChildren(value)(t => t.value) }
実行
// サンプルデータ val tree = { SampleTree( 1, List( SampleTree(2, List.empty), SampleTree(3, List.empty), SampleTree(4, List( SampleTree(5, List.empty), SampleTree(6, List.empty), SampleTree(7, List( SampleTree(8, List( SampleTree(9, List( SampleTree(12, List( SampleTree(13, List.empty) )) )) )), SampleTree(10, List( SampleTree(11, List.empty) )) )) )) ) ) } // 確認 println(tree.allValues) // List(1, 2, 3, 4, 5, 6, 7, 8, 10, 9, 11, 12, 13)
木がおきくなると計算量も多くなるので、lazy val
とかにしておいた方がいいもしれません。
case class SampleTree( value: Int, children: List[SampleTree] ) extends Node[SampleTree] { lazy val allValues: List[Int] = flatChildren(value)(t => t.value) }
集約とクラス不変表明
DDDには集約という概念があります。これが本当に難しくて、どうモデリングしてあげるべきなのかが掴めないでいました。しばらくDDDを実践していくなかで、 バートランド・メイヤーのオブジェクト指向入門(通称OOSC)の契約による設計の章を読み返していたら、少し掴めた気がしたのでここにまとめたいと思います。
概要
- 集約で重要な考え方は「不変条件」と「トランザクション」
- トランザクションの方が取り上げられがちだが、重要なのは「不変条件」の方
- 不変条件 = クラス不変表明
- 集約は不変条件をオブジェクトのライフサイクル(生成 -> 保存 -> 再構築)の中で常に守らせるための実装パターン
Evans本の集約
まずはEvans本の集約の章を見てみます。
複雑な関連を伴うモデルでは、オブジェクトに対する変更の一貫性を保証するのは難しい。維持すべき不変条件には、個々のオブジェクトに適用されるものだけでなく、密接に関連するオブジェクトのグループに適用されるものもある。だが、慎重にロックしすぎると、今度は複数のユーザが指針もなく相互に干渉し合い、システムが使いものにならなくなる。 ... 集約とは、関連するオブジェクトの集まりであり、データを変更するための単位として扱われる。各集約にはルートと境界がある。境界は集約の内部に何があるかを定義するものだ。ルートは集約に含まれている特定の 1エンティティである。
集約において重要なのは下記の二点だと考えます。
- 不変条件
- トランザクション
Evans本でも、集約の説明はトランザクションへの言及が多くなっています。しかし、集約においてまず第一に重要なのは「不変条件」の方です。集約のモデルが不変条件を維持することに加えて、どのように永続化時の競合を回避するのか?それをまとめたものが「集約」です。
上記の「不変条件」はオブジェクト指向の「クラス不変表明」に対応します。
不変条件 = クラス不変表明
ちなみに、「不変」って出てきていますが、ここでいう「不変」は immutable ではなく、invariant の方です。
クラス不変表明(class invariant)
では、クラス不変表明についてさらっていきましょう。クラス不変表明は契約による設計で出てくる概念です。
事前条件と事後条件は個々のルーチンの特性を記述する。このほかに、すべてのルーチンで維持されなければならない、クラスインスタンスに共通する全体的な特性を表す必要がある。そのような特性は、クラス不変表明(class invariant)といい、クラスの深い意味的た特性をとらえ、整合性を強いることで、クラスを特徴付ける。
ソフトウェアの重要な仕事に状態の変更があります。状態は複数のオブジェクト(エンティティや値オブジェクト)が複雑に絡み合って維持しなければならない条件があることがあります。クラスが常に維持しなければならない条件を満たしていることを保証するのがクラス不変表明です。これは、事前条件、事後条件を組み合わせて実現されます。
不変表明規則
表明が正しい不変表明になっているかは次の不変表明規則を満たしていることで確認できます。
次の2つの条件が満たされている場合に限り、表明 l は、クラス C の正しいクラス不変表明である。
- E1: C の生成プロシージャすべてにおいて、属性がデフォルト値の状態で、事前条件を満たす引数を使い、その結果、l が成立する。
- E2: クラスのエクスポートされたルーチンすべてにおいて、l とルーチンの事前条件の両方を満たす状態で引数を使い、その結果、引き続き l が成立している。
Scalaの場合だと、生成プロシージャはプライマリコンストラクタ(Scalaはクラス本体がプライマリコンストラクタとしての挙動をする)、事前条件は require 句、エクスポートされたルーチンは public メソッドのことです。
つまり、生成時とミューテーションの前後で常に不変条件満たすように実装すれば良いということです。Scalaだと通常imutableに実装するので、基本的にはプライマリコンストラクタに制約をつけておけば達成できると思います。
具体例
具体例で見ていきましょう。Evans本の購入注文の整合性を例に使います。
ここの具体例で出てくるコードは下記のリポジトリに置いてあります。
集約ルートとして購入注文(PurchaseOrder), その集約内部エンティティとして購入注文品目(PurchaseOrderLineItem)があり、その他に商品 Entity があります。(商品の価格は変動すると思ったので、Entityにしましたが、ここではValueObjectとしてモデリングした方が良かったかもしれません。)
ここで重要なのは、不変条件の矢印のところです。「注文品目総額が承認限度額以下である」の部分です。これを満たすように実装します。
上記の使用に従い実装してみた PurchaseOrder
クラスをまずは下記に示します。
少し長いですが、分解してみていきます。何度も重要だと言っている不変表明(不変条件)部分は下記です。購入注文モデルは、注文総額が承認限度額以下でなくてはならないという不変条件(これは、業務上の仕様にあたる)を持つので、それをここでは require
を使って表現しています。
// クラス不変表明 private def invariant(): Unit = { require( mustLessEqualThanApprovedLimit(total, approvedLimit), // <--- ここが、注文総額が承認限度額以下であることを期待している。 s"$MustLessThanEqualApprovedLimitMessage, 総額($total), 承認限度額($approvedLimit) " ) require(mustNotDuplicateOrderLineItemId, "購入注文品目のIDは集約内部で重複しない") // <--- 集約内部エンティティである、PurchaseOrderLineItemのIDが重複しないこと表明で担保している。 } }
これにより、このクラスは不変条件を常に保つことが約束されました。この集約単位でトランザクションしてあげれば、常に不変条件は保たれることになります。この単位で永続化するというのが Repository
になるわけです。永続化を集約単位でのみ行うことで、オブジェクトのライフサイクルの間ずっと不変条件が保たれるようになります。
trait PurchaseOrderRepository extends Repository[PurchaseOrder] { def findById(id: PurchaseOrderId): Option[PurchaseOrder] def insert(purchaseOrder: PurchaseOrder): Unit def update(purchaseOrder: PurchaseOrder): Either[String, Unit] def delete(id: PurchaseOrderId): Unit }
しかし、これが例えばこんな実装だとしたらどうでしょうか?
object PurchaseOrderLineItemDAO { def addNewLineItem(purchaseOrderId, newPurchaseOrder: PurchaseOrderLineItem): Unit = { ... } }
集約の考え方を知らなけれ自然な実装のように感じます。ある購入注文に、新たな注文品目を追加するという実装です。具体的な実装では、シンプルに PurchaseOrderLineItem
が対応するテーブルに新たなレコードが追加されるでしょう。この時、少しマシな実装者ならば、insert する前に追加後の購入注文の総額が承認限度額を超えていたら検査例外(ScalaならEitherのLeft)を返してくるでしょう。
この実装は私が思うに3つ問題があります。
- 単一責任原則に違反している
- 仕様がDB接続部分の具体的な実装の方に書かれてしまっている。
- 不変条件が保てない
単一責任原則に違反している
単一責任原則とは
モジュールを変更する理由はたったひとつであるべきである
という原則です(Clean Architectureでは少しレベルアップして単一のアクターに対して〜となっているのですが、ここでは変更理由が一つの方が分かりやすいのでそちらを採用します。)
上記の実装には少なくとも二つの変更理由が存在します。
- 業務仕様(注文総額は承認限度額以下でなくてはならない)の変更
- DB関連の変更(スキーマ設計やライブラリの変更など)
この理由はわかりやすいですね。業務的な知識と、永続化の手段は分けるべきです。
仕様がDB接続部分の具体的な実装の方に書かれてしまっている
これは先ほどの単一責任原則違反とも関連しますが、ドメイン知識がインフラストラクチャのレイヤに漏れ出してしまっています。SQLやその他製品との具体的な実装部分は複雑で難解になりやすく、そこに業務ロジックを記載してしまうと後でみた開発者が業務仕様をコードから読み解くことが困難になってしまいます。
不変条件が保てない
これは正直実装の仕方次第なのですが、人間、不注意な生き物ですし、開発者なので注意して見るとかいう無駄な努力で解決したくもありません。何の規律もなくDBを更新する実装を書かれてしまっては、あなたは繊細な注意を払って不変条件を守ったとしても(それも怪しいものですが)、別の誰かが必ず不変条件を破りにきます。それを防ぐのに集約という実装パターンが役に立ちます。
まとめ
集約はトランザクション整合性の方に焦点がいきがちですが、一番重要なのは不変条件(クラス不変表明によって実現される)の方です。不変条件を保つべき単位でRepository(のみ)を介してトランザクションを行うことで、オブジェクトのライフサイクルの間で常に不変条件を守らせようとするのがこの、集約という実装パターンだと考えます。
追記 2022/5/1
Evans本を読み返していたら、しなやかな設計の章で「表明」取り扱われていたし、集約との関係にも言及されていました。後ろの方もちゃんと読まないとな。。
「契約による設計」学派は次の段階に進み、クラスとメソッドについて、開発者によって保証される「表明」を作成する。このスタイルは『オブジェクト指向入門』(Meyer 1988)において詳述されている。要約すると、「事後条件」(post-conditions)が操作の副作用、つまり、メソッドを呼び出すことで保証される結果を記述する。「事前条件」(preconditions)とは契約にあるただし書きのようなもので、事後条件が成り立つことを保証するために満たさなければならない条件のことである。クラスの不変条件は、あらゆる操作が終わった時のオブジェクトの状態に関する表明となる。不変条件は、集約全体に対しても宣言することができ、整合性に関するルールを厳密に定義する。
トイレクラスで見る凝集度と責務
社内向けに凝集度と責務について発表しました。
正直、プレゼン形式での発表は慣れないので全然うまく話せませんでしたが、調べるためのインデックスができればいいくらいの気持ちと、LTにも慣れていきたいなと思ってやってます。
折角なのでブログにも少し残すことにしました。
さて、今回の発表では「トイレ」クラスを例に使いました。どこで見たのか忘れたのですが、「凝集度が低いとは、自分の家のトイレのレバーが100m先の人の家の居間にあるような状態」みたいな例を挙げている方がいて、その印象が強かったからです。
トイレクラスに入る前に先に凝集度と責務について確認します。
凝集度
関連性の強いデータとロジックだけを集めたクラスを凝集度が高いと言います。
現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法 増田 亨
責務
責務は、ソフトウェアオブジェクトについて大雑把に記述したものです。責務には以下の3つの主要な項目が含まれます。
・オブジェクトが行う動作
・オブジェクトが持つ知識
・オブジェクトが他に影響を与える主要な判断
オブジェクトデザイン レベッカ・ワーフすブラック, アラン・マクキーン 4.1 責務とは何か
下記の記事も参考になります。
具体例でイメージを掴む〜トイレクラスの例〜
私が凝集度と責務を意識するようになった例から、トイレクラスを考えます。
トイレは下記のような知識?と動作をもつと考えられます。
- トイレはタンク(便器中央の水が張っているところ)を持つ。
- トイレは流す(タンクを空にし、新たな水を張る)ことができる。
低凝集な実装が行われる時は大体、2つ目の流す機能の方が注目されて実装されます。
object HomeHelper { // 対象のトイレのタンクを空にする def flushToToilet(toilet: Toilet): Unit = { toilet.tank.dequeueAll(_ => true) toilet.tank.enqueue(Water(1.0)) } // 対象のトイレに何かを put する def putSomethingToToilet(something: Any, targetToilet: Toilet): Unit = targetToilet.tank.enqueue(something) }
低凝集な実装でよく見るパターンは上記のような実装ですね。綺麗にデータクラスと、機能クラスが分かれています。最悪です。
上記は水洗トイレではなく、中世の欧州の方の貴族は道端で用を足していたと聞きますがそんな感じです。
まず、人間が床(メモリ)の上に用を足します。
それをなんか清掃作業員みたいな人がトイレって名前のついたバケツを持ってきて回収し、最後川に流すみたいな実装です。つまりこういうこと。
// 人がまず床に便をする val poops = person.defecation // 便回収用のバケツ準備する val poopBucket = Toilet(mutable.Queue(Water(1.0))) // バケツに便を回収する HomeHelper.putSomethingToToilet(poops, poopBucket) // バケツに溜まった便を川に流す。 HomeHelper.flushToilet(poopBucket)</pre>
もはやトイレでもなんでもないただのバケツですね。
次は、責務がおかしいトイレを考えましょう。スライドにも載せているんですが、イラスト屋に責務がおかしいトイレの画像があったのでそれを例に使いましょう。
この画像は明らかにトイレの責務がおかしいですね。トイレは便や尿を流すためのものであって、本をstackするのは明らかに責務違反です。ちなみにこの状態をコードで表現すると以下のようになります。
class Toilet { private val tank: mutable.Queue[Any] = mutable.Queue(Water(1.0)) // トイレに本を積む責務を持たせてしまっている private val bookShelf: mutable.Stack[Book] = mutable.Stack() def stackBook(book: Book): Unit = bookShelf.push(book) ... }
明らかにおかしいですが、現実世界のプロダクトではわかりにくいビジネス上の概念を扱うためによく起こります。
最後、高凝集かつ責務が正しいトイレを見ていきましょう。
class Toilet { // トイレは最初水を張っている状態 private val tank: mutable.Queue[Any] = mutable.Queue(Water(1.0)) // トイレに(茶色い)何かをputする def put(something: Any): Unit = tank.enqueue(something) // トイレを流す def flush(): Unit = { // タンクに入っているものを流し tank.dequeueAll(_ => true) // 新たな水を追加する tank.enqueue(Water(1.0)) } }
私たちが普段使っているトイレが表現できていると思います。
これならば、後続の開発者が見たときに「トイレ」がどういうものかを理解できると思います。
Scalaz の Tree で違いがあるかを判定する
※Treeのflattenの結果が同じ値ならば常に同じであることを前提としていて、実際そうなのかをちゃんと確認してないので少し怪しいところあり。
ここ1年くらい木構造に悩まされていますが、scalaz の Tree を使っていて、2つの木に違いが存在するかを判定したい場合にどうするかのメモです。
どう考えたか?
scalaz の Tree の flatten を同じ木構造のデータに実施した際に同じ並び順のListになるならば、一旦Listにして同じindexの要素を比較してあげれば、違いがあるかどうかわかるんじゃない?って単純な発想。
Tree の拡張として作りたいので implicit class で定義する。
implicit class MyTreeOps[A](value: Tree[A]) { def isSame(other: Tree[A]): Boolean = { val list1 = value.flatten.toList val list2 = other.flatten.toList list1.zipWithIndex.map { case (v, index) => list2.lift(index) match { case Some(value) => value == v case None => false } }.forall(b => b) } }
自分自身をflattenしたものと、比較対象をflattenしたものに対して、同じ位置にある要素を比較して、全てtrueならば一致しているとみなしています。
これをさらに簡潔に書くと
implicit class MyTreeOps[A](value: Tree[A]) { def isSame(other: Tree[A]): Boolean = { val list1 = value.flatten.toList val list2 = other.flatten.toList list1.zipWithIndex.map { case (v, index) => list2.lift(index).contains(v) // 上記パターンマッチと同義の処理 }.forall(b => b) } }
のようにも書ける。最初の方が意図が伝わりやすい気もするのでどう書くかは好みで。
実際に試してみる。
import scalaz.Scalaz.ToTreeOps import scalaz.Tree object Main { def main(args: Array[String]): Unit = { println(freeTree isSame freeTree) // true println(freeTree isSame freeTree2) // false } implicit class MyTreeOps[A](value: Tree[A]) { def isSame(other: Tree[A]): Boolean = { val list1 = value.flatten.toList val list2 = other.flatten.toList list1.zipWithIndex.map { case (v, index) => list2.lift(index).contains(v) }.forall(b => b) } } def freeTree: Tree[Char] = 'P'.node( 'O'.node( 'L'.node('N'.leaf, 'T'.leaf), 'Y'.node('S'.leaf, 'A'.leaf)), 'L'.node( 'W'.node('C'.leaf, 'R'.leaf), 'A'.node('A'.leaf, 'C'.leaf))) def freeTree2: Tree[Char] = 'P'.node( 'O'.node( 'L'.node('N'.leaf, 'T'.leaf), 'Y'.node('S'.leaf, 'A'.leaf)), 'L'.node( 'W'.node('C'.leaf, 'R'.leaf), 'A'.leaf ) ) }
Scalaでの例外処理メモ
例外についてものすごく参考になった記事と、個人的メモ。
参考になった記事
Scala界隈では有名ながくぞさんのSlideですね。私はこれを読んで初めて例外が何か理解できた気がしました。
他にもスライドはあります。
https://gakuzzzz.github.io/slides/
Javaにおける例外
Scalaにおける例外の立ち位置や、どう扱うべきかを理解するにはJavaで例外をどう扱うかを先に理解するのがわかりやすいです。
Javaの例外には3種類あります。
- 致命的なエラー:
Error
- 非検査例外:
RuntimeException
- 検査例外:
Exception
このうち、致命的な例外と非検査例外については捕捉せず、検査例外に対してのみ呼び出し側でハンドリングをします。
エラーの種類 ハンドリング 例 致命的なエラー( Error
)No OutOfMemoryError
, StackOverflowError
など。非検査例外( RuntimeException
)No IllegalArgumentException
など。バグ起因系。検査例外( Exception
)Yes 業務例外など。
基本的に、検査例外以外はハンドリングする必要がありません。※ハンドリングすべきでない。
Scalaにおける例外
Scalaにおいては、検査例外をなくし、例外は全て致命的なエラーもしくは、非検査例外のハンドリングしてはならないもの、しなくて良いものだけになりました。
- 致命的な例外
- 非検査例外
のみ。
検査例外をどう扱うか?
Scalaで検査例外を扱う際は型で表現します。Try
や Either
などですね。Option
もそうです。この辺りの詳しい説明はドワンゴの新卒研修資料がわかりやすいと思います。
これにより、型で利用者側が明確に例外処理をいけないんだなと分かりますし、Scalaのパターンマッチを使ってあげれば網羅性検査も可能です。
実装アイデア
実際にどういうふうに書くかの実装アイデアを考えます。
下記のようなシチュエーションを考えます。
インターネット上でプリペイド式の書籍を購入できるサービスを開発しています。
今現在、ある書籍(Book
)を購入する処理を書いています。
この処理には次の業務例外が発生すると想定されています。
- チャージされているお金が足りない場合
- 倉庫に在庫が存在しない場合
成功時には、荷物追跡IDを返すことにします。
これをScalaで書く場合を考えます。
// ISBNコード case class ISBN(value: String) // 価格 case class Price(value: Int) // 本 case class Book(isbn: ISBN, title: String, price: Price) // 追跡番号 case class TrackingNumber(value: String)trait BookPurchaseService { import BookPurchaseService._ // 購入処理 def purchase(book: Book): Either[PurchaseError,TrackingNumber] }
object BookPurchaseService { sealed trait PurchaseError // 在庫が足りない case object NoStock extends PurchaseError // チャージ金額が足りない case class InsufficientChargeAmount(insufficientAmount: Int) extends PurchaseError }
こんな感じにしてあげると、PurchaseError
は sealed trait
になっているので、網羅性検査も言語機能側でやってくれて便利になるかなと思います。
書き方のイメージは自分の中では今のところ上記のような感じですが、ここで重要なのは
- 例外には3種類あって、捕捉すべき例外と、捕捉すべきでない例外がある
- 捕捉すべき例外はScalaでは値で表現する
の2点かと思います。
【Scala】Option 利用指針
Scalaでよく使うものに Option
型があると思います。Option
型は値が存在するかもしれないことを示す型です。Javaで値がないを表現するためには null
を使うのが一般的でした。Java 8からは Optional
型 ができたようですが、古いJavaのプロダクトでは null
が使われていたと思います。
この、Option
ですが、非常に便利で無闇に多用されているのを見かけます。例えば、
val users: Seq[User] = Seq(User(1, "司馬"), User(2, "光井"), User(3, "北山")) val maybeUser: Option[User] = users.find(_.id == 1) // 仕様上、絶対存在するはず
このような形です。重要なのは「仕様上、絶対存在するはず」の部分です。仕様上存在するはずですが、find
メソッドは Option
で返すので、Option
のまま後続の処理を続けようとしているのを見かけます。
maybeUser match { case Some(user) => // 何らかの処理 case None => // 仕様上絶対に起こらないけど不安だから一応書いているハンドリング }
だったり、雰囲気で
val user = users.find(_.id == 1).getOrElse(User(0, ""))
のように謎の値がデフォルト値として採用されていたりします。私も新卒で入社して、Scala書きはじめた頃はこのような書き方をよくしてしまっていました。しかし、この書き方に大きな問題が3つ存在します。
3つの問題
- コードが複雑になる
- 不安になる
- バグに気づけなくなる
1 コードが複雑になる
これは単純です。Option
の値を参照するクライアントは Some
or None
でハンドリングをしなければなりません。仕様上必ず存在するはずの値をいちいちハンドリングするのは無駄でしかありませんし、コードの可読性を下げるだけです。
2 不安になる
頭の中では、絶対に存在すると分かっていても、値が Option
のままだと不安になります。この値は参照しようとしている今この瞬間本当に存在しているのだろうか。ないことはないのだろうかと不安になります。不安は脳のリソースを奪い、生産性を下げます。
3 バグに気づかなくなる
これが最大の問題です。最初から言っている通り、ここで議論しているのは「仕様上必ず存在するはず」の値です。仕様上必ず存在するはずの値が存在しない時、それはバグなので、バグとして気付けなければなりません。つまり、getOrElse
やパターンマッチでハンドリングしてしまうと、バグを握りつぶすことになってしまいます。非検査例外を、キャッチして握りつぶしてしまっている感じですね。
どうすべきか
単純です。最初の段階で get
してしまいましょう。
val user: User = users.find(_.id == 1).get
これで NoSuchElementException
が投げられたら、それはバグです。`users に問題があったことになります。
どういう時に見かけるのか
例に出した、配列の中に仕様上必ず存在しなければならない時もありますが、結構困るのは、RDBのテーブル定義が、値は必ず存在するが、テーブル定義は null
になっている(not null
になっていない)時です。テーブル定義を変えてしまうのがベストですが、DBスキーマ変更のコミュニケーションコストが高いプロジェクトの場合ですと、気軽に変更できなかったりもします。そういう時は、仕様をよく確認し、スキーマ上 null
だけど実はnull
にはならない値の時は迷わず get
を使います。
まとめ
- そもそも
Option
を気軽に利用しないようにしよう。Option
を使うべき時は、仕様上Optional な時だけだ。 - 安易に
getOrElse
をしないようにしよう。安易なデフォルト値はバグの見落としに繋がる。 - 仕様上、必ず存在する値だとわかったら、迷わず
get
でOption
から取り出そう。大丈夫、もしエラーが出た時は、バグを発見できたと喜ぼう。
RDBのカラムを安易に nullable
にするやつを俺は許せない。