entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Scala で Writer モナドを利用して警告処理を実装した話

はじめましての方ははじめまして。21新卒エンジニアの Javakky です。 今回は、「ログに出力はしたいけど、ロジックからは分離したい」そんなモチベーションを叶えるモナド、 Writer モナドを利用した話について書いていきます。

前回のお話

社内の管理画面に権限管理機能を作りました。

inside.pixiv.blog

背景

Casbin というアクセス制御ライブラリの導入により、簡単にロールとグループ、ユーザーの管理ができるようになりました。しかし、どんなロールが管理画面上のどのページとリンクしているかはコード上で管理する必要があり、ロール一覧に対応した case object を作成することにしました。

運用していて問題になったのが、管理画面のページに権限機能を追加する際に各メンバーとロールの対応をDBに記録しておく必要があるという点です。 権限機能はホワイトリスト方式をとっているため、権限機能を設定した変更を先に入れてしまうと一時的に社内の全メンバーがその機能にアクセスできなくなってしまいます。この管理画面は社内の多くの部署のメンバーが利用しているため、一時的であっても可能な限り操作できない時間は作りたくありません。 逆に、先にDBにデータを入力してから変更を入れる方式だと存在しないロールについて対応する処理が必要になってしまいます。

これだけであれば、ロールの追加のみをリリース → DB作業 → 権限機能追加をリリース の手順で作業を行えば解決します。しかし、管理画面のリポジトリはビルドに非常に時間がかかるため、なるべくリリースはまとめて行いたいというモチベーションがありました。また、DBに直接おかしなデータが混入する可能性も0とは言えないので、やはりそんざいしないロールに対する処理が必要となります。

def getPermission(user: String): List[Permission] = {
  UsingEnforcer { enforcer =>
    enforcer
      // casbin を利用して権限データを取り出す
      .getPermissionsForUser(user)
      .asScala.toList
      .map(_.asScala.toList)
      .map {
        // リストに入った文字列に要素順ごとに名前をつける
        case _ :: feature :: Nil =>
          // 権限オブジェクトを生成する
          // Feature.resolve() は `Option` を返すので、対応が必要
          Permission(Feature.resolve(feature))
      }
  }
}

設計案

案1: 存在しないロールは無視しちゃえ!

DBから取り出したデータに異常値があったとしても、それは存在しない権限なので無視してしまおうという手法です。しかし、この方法では DB 操作などで権限を間違ったデータが混入してしまった場合、そのデータを見つけ出すことは困難になってしまいます。

def getPermission(user: String): List[Permission] = {
  UsingEnforcer { enforcer =>
    enforcer
      .getPermissionsForUser(user)
      .asScala.toList
      .map(_.asScala.toList)
      // collect にしたので、 `case None` の時はスキップされる
      .collect {
        case _ :: feature :: Nil =>
          Feature.resolve(feature) match {
            case Some(feature) => Permission(feature)
          }
      }
  }
}

案2: 変なデータが入っているはずがないので例外にしちゃおう

これで、変なデータが入ってしまった場合にはエラーログに流れてくるようになったため、検知することができるようになりました! しかし、おやおや?例外処理にして終了するようにしてしまったせいで、変なデータが入ってしまった時は全ての管理画面が落ちるようになってしまいました...... 検知こそしたいものの、別に処理を中断してほしいわけではないので、例外処理も不適切だということが分かりました。

def getPermission(user: String): List[Permission] = {
  UsingEnforcer { enforcer =>
    enforcer
      .getPermissionsForUser(user)
      .asScala.toList
      .map(_.asScala.toList)
      .map {
        case _ :: feature :: Nil =>
          Permission(
            Feature.resolve(feature)
              // `None` になった (解決失敗した) 場合には例外にする
              .getOrElse(throw new RuntimeException(s"不明なロールが設定されています: ${feature}"))
          )
      }
  }
}

案3: 検知したいだけなら、ログに直接出力しちゃおう

案外悪くないですね!ここまでで、目標としていた無効なデータは無視するが検出はできるようにしたいという要求を達成することができました。 しかし、これで十分なのでしょうか?権限管理機能の汎用性を考えると、呼び出し元によってはログレベルや出力先などの設定を分けたい!ということがあるかもしれません。

def getPermission(user: String): List[Permission] = {
  UsingEnforcer { enforcer =>
    enforcer
      .getPermissionsForUser(user)
      .asScala.toList
      .map(_.asScala.toList)
      .collect {
        case _ :: feature :: Nil =>
          Feature.resolve(feature) match {
            case Some(feature) => Permission(feature)
            case None => logger.warn(s"不明なロールが設定されています: ${feature}")
          }
      }
  }
}

案4: ログの出力内容も戻り値にして、呼び出し元に解決方法を決めてもらおう

戻り値として警告のリストと権限のリストを両方返すことで、呼び出し側でそれぞれに対する処理を定義することができるようになりました。

Tuple2[List[String], List[Permission]] という型になってしまったので、若干中身の操作が煩雑になったり、処理を書くために一時変数を命名する必要が生まれてしまったので、なんとかラップして便利にしたいなあと思いますね〜

// 警告文のリストと権限のリストを返す
def getPermission(user: String): (List[String], List[Permission]) = {
  UsingEnforcer { enforcer =>
    enforcer
      .getPermissionsForUser(user)
      .asScala.toList
      .map(_.asScala.toList)
      // `List#partitionMap` を利用することで、 `map` した結果を二つのリストに振り分けることができる
      .partitionMap {
        case _ :: feature :: Nil =>
          Feature.resolve(feature) match {
            case Some(feature) => Right(Permission(feature))
            case None => Left(s"不明なロールが設定されています: ${feature}")
          }
      }
  }
}

Writer モナドの導入

Writer モナドとは?

The Writer[L, A] datatype represents a computation that produces a tuple containing a value of type L and one of type A. Usually, the value L represents a description of the computation. A typical example of an L value could be a logging String and that’s why from now on we will refer to it as the Logging side of the datatype.

typelevel.org

つまり、 LA のそれぞれのデータをタプルとして持ち、多くの場合、 L にはログが入るデータ型、ということです。

// データに対して処理を行ったり
val mapExample = Writer("map Example", 1).map(_ + 1)
// mapExample: Writer[String, Int] = Writer(("map Example", 2))

// ログ側にデータを追加することもできます。
val tellExample = Writer("tell example", 1).tell("log append")
// tellExample: Writer[String, Int] = Writer(("tell examplelog append", 1))

* コードは https://github.com/typelevel/cats/ を元に作成 (ライセンス)。

Scala で Writer モナドを利用するには?

Cats という、関数型プログラミングの抽象化を Scala に提供しているライブラリを導入することで使うことができます。

typelevel.org

導入は build.sbt に以下を追加するだけで完了します。

libraryDependencies += "org.typelevel" %% "cats-core" % "2.7.0"

警告処理用にカスタマイズしたい

Writer モナドはそれ単体でも十分に便利なログ機能を提供しているのですが、ある処理中に発生した警告を取り扱うという部分に焦点を当てるともうすこし具体的な機能や型の制約が欲しくなってきます。

そこで、警告そのものを表す Warn クラスと、 Writer モナドをラップした警告処理用のクラス Through を作成することにしました。 (ちなみに、 Through と名付けた理由としては、例外が途中で処理を投げる Throw のに対して、一旦そのまま通過して処理を続けるという意味を込めたというものがあります)

具体的には、警告を取得しつつロギング処理をするというようなメソッドが欲しかったので、 getWith を作成しました。

final def getWith(fa: Seq[A] => Unit): B = {
  if (isWarning) fa(getWarns)
  get
}

また、 Through[A <: Warn, B]Writer[Seq[A], B] のラップとしたことで、複数の警告をまとめて扱うクラスであることを明示することができました。

以下に、作成したクラスの全文を記載しておきます。

package utils
import cats.data.Writer
import utils.Through.WriterToThrough

sealed class Through[A <: Warn, B] private (writer: Writer[Seq[A], B]) {

  /**
    * このインスタンスが警告を1件以上持っているか
    * @return 持っていれば true
    */
  final def isWarning: Boolean = writer.run._1.nonEmpty

  /**
    * このインスタンスが警告を持たないか
    * @return 持っていなければ true
    */
  final def isPerfect: Boolean = writer.run._1.isEmpty

  /**
    * もし警告が1件でも存在する場合には `fab` を適用した結果を返す。
    * 1件も存在しなければ `fb` の結果を返す。
    */
  final def fold[C](fb: B => C)(fab: (Seq[A], B) => C): C = {
    val (left, right) = writer.run
    if (isPerfect) fb(right) else fab(left, right)
  }

  final def foldLeft[C](c: C)(f: (C, B) => C): C = writer.foldLeft(c)(f)

  /** 警告のあるなしにかかわらず実行結果を返す。 */
  final def get: B = writer.value

  /** 警告のリストに値を結合する */
  final def tell(warns: Seq[A]): Through[A, B] = toWriter.tell(warns).asThrough

  /** 警告のリストに `ft.warns` を**左から**結合し、値に `ft.get` を適用する */
  final def applicative[C](ft: Through[A, B => C]): Through[A, C] = toWriter.ap(ft.toWriter).asThrough

  /** ログを削除する */
  final def reset: Through[A, B] = toWriter.reset.asThrough

  /**
    * 値を取得しつつ警告に対して処理を行う。
    * getWarns が Perfect な場合に処理を実行しない。
    * `getWarns` に `fa` を適用しつつ `getResult` の結果を返す。
    */
  final def getWith(fa: Seq[A] => Unit): B = {
    if (isWarning) fa(getWarns)
    get
  }

  /**
    * 値を取得しつつ警告に対して処理を行う。
    * `getWarns` に `fa` を適用しつつ `getResult` の結果を返す。
    */
  final def getWithForeach(f: A => Unit): B = {
    warnsForeach(f)
    get
  }

  /**
    * 警告のリストを `Option` でくるんで返す。
    * このインスタンスが `Through.Perfect` だった場合には `None`。
    */
  final def getWarns: Seq[A] = writer.run._1

  /** 各警告に対して `f` を適用する。 */
  final def warnsForeach(f: A => Unit): Unit = getWarns.foreach(f(_))

  /** 警告のリストを維持したまま、実行結果を変更した値を返す。 */
  final def map[C](f: B => C): Through[A, C] = toWriter.map(f).asThrough

  final def flatMap[C](f: B => Through[A, C]): Through[A, C] = toWriter.flatMap[C](b => f(b).toWriter).asThrough

  /**
    * `Through[A, Through[A, B]]` のように入れ子になっているものを `Through[A, B]` に平坦化する。
    * この際、警告の結合は**外側にある要素が左**に置かれる。
    */
  final def flatten[C](implicit evidence: B <:< Through[A, C]): Through[A, C] = flatMap(x => x)

  final def toWriter: Writer[Seq[A], B] = writer
}

object Through {
  implicit class WriterToThrough[A <: Warn, B](writer: Writer[Seq[A], B]) {
    def asThrough: Through[A, B] = new Through[A, B](writer)
  }
  implicit class Tuple2ToThrough[A <: Warn, B](tuple2: (Seq[A], B)) {
    def asThrough: Through[A, B] = Through.warning(tuple2._1, tuple2._2)
  }

  /** partitionMap などで左側が入れ子になったタプルを平坦化しつつ Through に変換する */
  implicit class NestedWarnsTuple2ToThrough[A <: Warn, B](tuple2: (Seq[Seq[A]], B)) {
    def asThroughWithFlatten: Through[A, B] = Through.warning(tuple2._1.flatten, tuple2._2)
  }
  def warning[A <: Warn, B](warns: Seq[A], result: B): Through[A, B] = new Through[A, B](Writer.apply(warns, result))
  def perfect[A <: Warn, B](result: B): Through[A, B] = new Through[A, B](Writer.apply(List.empty, result))
}

Throgh.scala

package utils

/**
  * 警告 (≠例外) を取り扱うためのクラス。
  * [[Through]] クラスと組み合わせて利用することを推奨。
  * @param message 警告内容のメッセージ
  */
class Warn(val message: String) {
  // Thread.currentThread().getStackTrace よりも早いスタックトレース生成手段として利用している
  // `Throwable` をこれ以外の用途に流用しないこと。
  private val stackTraceElement = (new Throwable).getStackTrace.toSeq.tail

  /**
    * この警告が生成されるまでのスタックトレースを返す
    */
  final def getStackTraceElement: Seq[StackTraceElement] = stackTraceElement

  /**
    * エラーメッセージを文字列化する。
    */
  override def toString: String = getClass.getName + ": " + message

  override def equals(obj: Any): Boolean = obj match {
    case warn: Warn =>
      warn.message == this.message &&
      warn.stackTraceElement == this.stackTraceElement
    case _ => false
  }

  override def hashCode(): Int = {
    stackTraceElement.foldLeft(message.hashCode) {
      case (acc: Int, x: StackTraceElement) => acc * 31 + x.hashCode()
    }
  }
}

object Warn {
  def apply(message: String): Warn = new Warn(message)
}

Warn.scala

Through を使ってみる

以下のように Through クラスを利用することで、スマートにログを処理しつつ値の取得ができるようになりました。

def getPermission(user: String): Through[Warn, List[Permission]] = {
  UsingEnforcer { enforcer =>
    enforcer
      .getPermissionsForUser(user)
      .asScala.toList
      .map(_.asScala.toList)
      .partitionMap {
        case _ :: feature :: Nil =>
          Feature.resolve(feature) match {
            // feature が存在すれば権限を
            case Some(feature) => Right(Permission(feature))
            // 不正なら警告のオブジェクトを返す
            case None => Left(List(Warn("存在しない機能です: " + feature)))
          }
      }
  }.asThroughWithFlatten
}

val permissions = getPermission(“admin”)
  // 警告をすべてログ出力しつつ値を取得する
  .getWith { list => list.foreach(logger.warn(_.toString)) }

まとめ

Writer モナドを利用することでログに利用するデータを簡単に扱うことができました。また、それをラップして警告処理用のクラスを作ったことでさらにユースケースに即した型を作成することができました。

Cats にあるようなモナドやそれをラップしたクラスを利用することでコードの関心外の処理をうまく実装することができたので、今後もこのような取り組みを続けていきたいです。

icon
javakky
決済周りの改善を中心に働いている2021年入社エンジニア。その名の通りJavaが好きなことで有名(?)で、最近はScalaを使える部署へ入ったらしい。