以前、 各レイヤーからのエラーについて考える みたいな発表をしたのですが、このあたりの実装部分をもう少しピックアップしてみます。
今回は、インフラストラクチャ層について、取り上げます。
インフラストラクチャ層
インフラレイヤには、図のように、
- インフラサービス
- リポジトリ実装
- イベントパブリッシャ実装
を置いたりしますが、その中でリポジトリ実装では、以下のような実装になるかと思います。
trait UserRepositoryOnDynamoDB {
private lazy val dynamoDBClient = AmazonDynamoDBClientBuilder.standard().build()
private lazy val dynamoDB = new DynamoDB(dynamoDBClient)
private lazy val table = dynamoDB.getTable(tableName)
protected def insertInternal(entity: User): Try[User] =
Try {
val version = 1L
table.putItem(generateItem(entity, version))
entity.copy(version = Some(version))
}
}
上述のように、Javaライブラリを呼び出すレイヤーにおいては、 Try{}
でかこむことによって、例外をthrowさせずにTry型にして、呼び出し元に値を返します。
とはいえ、Try型ですと、その返り値のパターンが無数にあり、呼び出し元において、どういった例外が返ってくるのか、そのパターンを網羅することができません。
上述のように、単純にputItemをするだけであれば、この例外のパターンは、単純に成功か失敗に分類することができます。
ただ、以下のように、楽観排他制御を入れる場合は、単純にSuccess/Failureではなく、Failureにおいて例外の種類が異なることがあります。
protected def updateInternal(entity: User, version: Long): Try[User] =
Try {
val newVersion = version + 1L
val conditionExpression = "#v = :version"
val nameMap = new NameMap().`with`("#v", AttrVersion)
val valueMap = new ValueMap().withLong(":version", version)
table.putItem(generateItem(entity, newVersion), conditionExpression, nameMap, valueMap)
entity.copy(version = Some(newVersion))
}
この処理は、 version
が異なっていた場合に、楽観排他エラーになるため、 com.amazonaws.services.dynamodbv2.model.ConditionalCheckFailedException
がthrowされます。
これを呼び出し元で例外がこれだったら、ってあたりで処理を分けることも、もちろん可能ですし、それでも良いのですが、やはり呼び出し元でのパターンマッチが多くなるのと、それに気付くのも難しくなってくることから、これを型でしばることが有効かと思います。
であればと、Try型からEither型へとConvertすることを考えます。
protected def updateInternal(entity: User, version: Long): Either[RepositoryError, User] =
Try {
val newVersion = version + 1L
val conditionExpression = "#v = :version"
val nameMap = new NameMap().`with`("#v", AttrVersion)
val valueMap = new ValueMap().withLong(":version", version)
table.putItem(generateItem(entity, newVersion), conditionExpression, nameMap, valueMap)
entity.copy(version = Some(newVersion))
}.fold(
{
case _: ConditionalCheckFailedException => Left(RepositoryOptimisticError())
case e => Left(RepositorySystemError(e))
},
Right(_)
)
Try型からEither型へのConvertは、 def fold[U](fa: scala.Throwable => U, fb: T => U): U
を呼び出して、FailureとSuccessを置き換えます。
com.amazonaws.services.dynamodbv2.model.ConditionalCheckFailedException
がthrowされた場合は、その例外の内容を上位に引き継ぐ必要も特にないかと判断しており、 RepositoryOptimisticError.class
にその例外の内容を渡しておりません。
余談ですが、楽観排他エラーは、Web APIを考えた場合、その行為をリトライいただくことを要求するため、例外をいちいちログに出力したりする必要がないと考えています。
ここまでで、特に上位のレイヤーに受け渡しするのに、特に問題ないですが、この
.fold(
{
case _: ConditionalCheckFailedException => Left(RepositoryOptimisticError())
case e => Left(RepositorySystemError(e))
},
Right(_)
)
ってあたりが、色々なメソッドで頻出しますので、これを 型クラス implicit classを実装することで、
protected def updateInternal(entity: User, version: Long): Either[RepositoryError, User] =
Try {
val newVersion = version + 1L
val conditionExpression = "#v = :version"
val nameMap = new NameMap().`with`("#v", AttrVersion)
val valueMap = new ValueMap().withLong(":version", version)
table.putItem(generateItem(entity, newVersion), conditionExpression, nameMap, valueMap)
entity.copy(version = Some(newVersion))
}.toRepositoryError
みたいに呼び出せるようにします。
object RepositoryErrorConverters {
implicit class Try2RepositoryError[E](val t: Try[E]) extends AnyVal {
def toRepositoryError: Either[RepositoryError, E] =
t.fold(
{
case _: ConditionalCheckFailedException => Left(RepositoryOptimisticError())
case e => Left(RepositorySystemError(e))
},
Right(_)
)
}
}
このように 型クラス implicit classを作っておくことで、いちいちfoldを実装しなくてもいけます。
型クラス implicit classにしておくと、Repository実装をどんどん増やしていくにあたり、実装手法が統一されてコードの可読性をあげていくことができます。
foldの実装内容って、けっこー人によってクセが出たりするので、できるだけ統一しておくことで、ふらつきを抑えたいと考えています。
ただ逆に、統一することで、実は考慮漏れとかがあったときに、気付かずパターンから漏れることで想定と違った動きを実装してしまって、そのままリリースしてしまって、えーってことにもなりますので、良いこともあれば悪いこともある感じではあります。
ので、人によっては、「いやいやfoldはそれぞれ実装しましょうよ」ということもあり、どっちが良いとかは無いですが、できるだけ可読性と手間を減らしていきたいと思うあたりです。
まとめ
今回は、インフラ層のエラーについて考えてみました。
DDDで実装していくと、どうも、行きは良いが返りに困るなーってことが多くて、このあたりのエラーをどうやって返そうか悩んだことがありました。
このあたりを、レイヤーごとに色々と考察していましたので、もうちょい続けて、他のレイヤーについても書いてみたいと思います。
このあたりのソースは、ここに上げてます。
以上です。