SoRの性質が強いBtoBアプリケーションでは、「堅く」作ることを求められる箇所がしばしばあります。
Scalaの型安全性が頼もしく感じられるのは、まさにこのような箇所においてです。
「堅く」作るために、私たちがいま注目しているのが refined
と newtype
というライブラリです。
この記事では、refinedとnewtypeを使ってScalaの型安全性をさらに引き出すテクニックを紹介します。
Value Class / Tagged Type
refined + newtypeの話題に入る前に、これまでにどのようなテクニックが使われてきたかを簡単に振り返りましょう。
ここに、SNSのユーザーアカウントを表現するクラスがあります。
1
2
|
case class User(id: String, email: String, age: Int)
val user1 = User("@todokr", "tadokoro@example.com", 29)
|
さて、この素朴な実装にはどんな心配があるでしょう?真っ先に気になるのが、引数をあまりにも簡単に取り違えてしまう恐れがある点ではないでしょうか。
1
2
|
val invalidUser = User("tadokoro@example.com", "@todokr", 29)
println(invalidUser.email) // @todokr
|
idもemailも型は同じ String
なので、渡す値を取り違えても問題なくコンパイルができてしまいます。
この問題に対しては、Value Class や Tagged Type というテクニックがよく知られています。
下記はValue Classを使う例です。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
case class UserId(value: String) extends AnyVal
case class Email(value: String) extends AnyVal
case class Age(value: Int) extends AnyVal
case class User(id: UserId, email: Email, age: Age)
val userId = UserId("@todokr")
val email = Email("tadokoro@example.com")
val age = Age(29)
val user = User(email, userId, age)
// コンパイルエラー😉
// type mismatch;
// found : Email
// required: UserId
|
素のStringやIntをValueClassに変更し、各引数がそれぞれ独自の型を要求するようにしました。渡す値を取り違えるとコンパイルができないようになったので、先のような取り違えはもう起こりません。
これで安心でしょうか?まだ気になる点があります。次の例をご覧ください。
1
2
3
4
|
val userId = UserId("") // 空の文字列
val email = Email("📧 📧 📧 📧 📧") // Eメールとして不正なフォーマットの文字列
val age = Age(-1) // 負数の年齢
val invalidUser = User(email, userId, age) // でたらめなユーザー😢
|
Value Classのvalue自体は素朴な String
や Int
です。Value Classの型で値の「種類」に間違いがないことは保証できても、値の「中身」に間違いがないことは保証できません。
そのため、上の例で見たように、空文字や負の数といった不正な値が入り込む余地があります。
スマートコンストラクタパターン
このような不正な値が入り込むのを防ぐにはどうするのがよいでしょうか?よく使われるテクニックのひとつが、 スマートコンストラクタパターン です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
case class User(id: UserId, email: Email, age: Age)
case class UserId(value: String) extends AnyVal
object UserId {
def apply(rawUserId: String): Option[UserId] = {
Some(rawUserId).filter(isValidUserId).map(new UserId(_))
}
// `@`から始まって3文字以上16文字以内、@adminや@Adminは許容しない
private def isValidUserId(s: String): Boolean =
s.startsWith("@") && s.length >= 3 && s.length <= 16 && !s.matches("(?i)@admin")
}
case class Email(value: String) extends AnyVal
object Email {
def apply(rawEmail: String): Option[Email] =
Some(rawEmail).filter(isValidEmail).map(new Email(_))
// Eメールの簡易的なバリデーションルール
private def isValidEmail(s: String): Boolean =
s.matches("""[a-z0-9]+@[a-z0-9]+\.[a-z0-9]{2,}""")
}
case class Age(value: Int) extends AnyVal
object Age {
def apply(rawAge: Int): Option[Age] =
Some(rawAge).filter(isValidAge).map(new Age(_))
// 年齢は0以上、200未満
private def isValidAge(i: Int): Boolean = i >= 0 && i < 200
}
import cats.data.ValidatedNel
import cats.implicits._
/** 生のパラメータをvalidateしてUserを生成する */
def validateUser(
rawUserId: String,
rawEmail: String,
rawAge: Int
): ValidatedNel[String, User] = (
UserId(rawUserId).toValidNel(s"Invalid UserId: $rawUserId"),
Email(rawEmail).toValidNel(s"Invalid Email: $rawEmail"),
Age(rawAge).toValidNel(s"Invalid Age: $rawAge")
).mapN(User)
|
UserIdやAgeにスマートコンストラクタを導入しました。コンパニオンオブジェクトに定義されたapplyメソッドは渡された値が妥当であればSomeに包んだValue Classを、そうでなければNoneを返すようになっています。
validateUser
メソッドではこれらValue Classのスマートコンストラクタでのインスタンス生成結果と、Catsの .toValidNel
を用いてバリデーションを行っています。
このメソッドはID、メールアドレス、年齢の全てが妥当であれば Valid
に包まれたUserを、そうでなければ Invalid
に包まれた、バリデーションルールを満たさなかった項目についての情報をNonEmptyListとして返します。
1
2
3
4
5
|
println(validateUser("@todokr", "tadokoro@example.com", 29))
// Valid(User(UserId(@todokr),Email(tadokoro@example.com),Age(29)))
println(validateUser("@ADMIN", "tadokoro.example.com", -1))
// Invalid(NonEmptyList(Invalid UserId: @ADMIN, Invalid Email: tadokoro.example.com, Invalid Age: -1))
|
ここまでやればもう万全、と言いたいところですが…あと一点だけ気になることがあります!
バリデーションによって不正な値を防ぐことはできましたが、Value Classのvalueの型は相変わらず String
や Int
のままです。
すなわち、「ユーザーIDは@
から始まって3文字以上16文字以内、@adminや@Adminは許容しない」や「年齢は0以上、200以下」といった役立つ情報が型からは失われてしまいました。
そのため、コードベースが大きくなるにつれて、値が検証済みにもかかわらず、バリデーションと同じような意味のないコードをアプリケーションのあちこちに量産してしまうかもしれません。
1
2
3
4
5
6
7
|
val user = validateUser("@todokr", "tadokoro@example.com", 29)
// ...
// ...
// ... 定義元より遠い、はるか彼方の銀河系で...
if (!user.id.value.startsWith("@")) { // 無駄なチェック😢
handleInvalidUserId(user)
}
|
refined: 値の性質を型で表現する
ならば、「@
から始まって3文字以上16文字以内」などの性質を型で表現できないでしょうか?そうすれば、コードベースが大きくなっても開発者は値の性質を正しく理解できるはずです。
そしてあわよくば、その型を直接バリデーションに使うことができないでしょうか?そうすれば、「どのように」ではなく、「どんな性質か」を記述して宣言的にバリデーションルールを定義できるはずです。
それを実現するために、refined があります。これはRefinement Type(篩型)と呼ばれる「型 + 型の性質を示す述語」という型をScalaで簡単に利用するためのライブラリです。
1
2
3
4
5
|
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.numeric.Positive
val i1: Int Refined Positive = 5
|
Int Refined Positive
の Positive
が「型の性質を示す述語」です。この述語でIntを “refine” しています。
5
というIntは Positive
の性質を満たしているので、このコードは問題なくコンパイルできます。
ではこの性質を満たさない値はどうなるでしょう?
1
2
3
4
5
6
|
val i2: Int Refined Positive = -5
// [error] Predicate failed: (-5 > 0).
// [error] val i2: Int Refined Positive = -5
// [error] ^
// [info] Int(-5) <: eu.timepit.refined.api.Refined[Int,eu.timepit.refined.numeric.Positive]?
// [info] false
|
値が述語の性質を満たさない場合はコンパイルエラーになります。分かりやすいエラーメッセージを出力してくれるのが嬉しいですね。
refinedで定義済みの述語はこちらから確認できます。文字が数値のフォーマットであることを表す Digit
や 文字列の空白文字がトリムされていることを表す Trimmed
、開区間/閉区間の情報込みで数値の範囲を表現する Interval.Open
/ Interval.OpenClosed
/ Interval.ClosedOpen
/ Interval.Closed
などなど、業務アプリケーションで大活躍しそうな述語が揃っています。
先ほどの UserId
を、refinedを使って書き直してみます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import eu.timepit.refined.api._
import eu.timepit.refined.auto._
import eu.timepit.refined.boolean._
import eu.timepit.refined.string._
// `@`から始まって3文字以上16文字以内、@adminや@Adminは許容しない
type UserIdRule = StartsWith["@"] And MinSize[3] And MaxSize[16] And Not[MatchesRegex["(?i)@admin"]]
val userId1: String Refined UserIdRule = "@todokr"
// 以下はコンパイルエラー
val userId2: String Refined UserIdRule = "@admin"
val userId3: String Refined UserIdRule = "todokr"
val userId4: String Refined UserIdRule = "@uryyyyyyyyyyyyyy"
|
StartsWith["@"] And MinSize[3] And ...
を見ると分かるように、述語同士は合成が可能です。
一つ一つの述語は小さくてシンプルですが、組み合わせることで大きくて複雑なルールも簡単に作ることができます。
newtype: オーバーヘッドなしに値をラップする
このように便利なRefinement Typeですが、たまたま同じ型+述語を持つ2種類の値は混同ができてしまいます。
1
2
3
4
5
6
7
8
9
|
type Id = String Refined NonEmpty
type Password = String Refined NonEmpty
case class LoginInfo(id: Id, password: Password)
val id: Id = "myid123"
val password: Password = "Passw0rd!"
val loginInfo = LoginInfo(id = password, password = id) // コンパイルできてしまう😢
|
ならばRefinement TypeをValue Classでラップするのはどうでしょう?これなら混同は起きないはずです!
1
2
3
4
5
6
|
type IdType = String Refined NonEmpty
type PasswordType = String Refined NonEmpty
case class Id(value: IdType) extends AnyVal
case class Password(value: PasswordType) extends AnyVal
case class LoginInfo(id: Id, password: Password)
|
残念ながら、このコードはコンパイルできません。RefinedはAnyValを継承したValue Classであり、Value Classは他のValue Classをラップできないためです。
extends AnyVal
をなくして通常のcase classにするとコンパイルはできますが、Value Classとは異なりBoxing/Unboxingのコストがあるため、この方法は避けたいところです。
ここでの切り札が newtype です。newtypeはランタイムのオーバーヘッドなしに、値をラップするクラスを実現するためのライブラリです。
使い方は簡単で、ラッパークラスを @newtype
アノテーションで修飾するだけです。あとはnewtypeのマクロが仕事をしてくれます。
1
2
3
4
5
6
7
8
9
10
11
12
|
type IdType = String Refined NonEmpty
type PasswordType = String Refined NonEmpty
@newtype case class Id(value: IdType)
@newtype case class Password(value: PasswordType)
case class LoginInfo(id: Id, password: Password)
val invalidId = Id("") // 述語が `NonEmpty` なのでコンパイルエラー
val validId = Id("my1d123")
val password = Password("Passw0rd!")
val loginInfo = LoginInfo(id = password, password = validId) // コンパイルエラー😉
|
これで値の取り違えも防ぐことができました!
Runtime Validation
ここまではコンパイラが誤りを検出してくれる例を見てきました。しかし不正なデータの大半はアプリケーションの外部からやってくるため、コンパイラによる検査では不十分です。
先の例のバリデーションを、refined + newtype でも実装してみます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
// `@`から始まって3文字以上16文字以内、@adminや@Adminは許容しない
type UserIdRule = StartsWith["@"] And MinSize[3] And MaxSize[16] And Not[MatchesRegex["(?i)@admin"]]
// Eメールの簡易的なバリデーションルール
type EmailRule = MatchesRegex["""[a-z0-9]+@[a-z0-9]+\.[a-z0-9]{2,}"""]
// 年齢は0以上、200未満
type AgeRule = Interval.OpenClosed[0, 200]
// Refinement Typeの定義
type UserIdString = String Refined UserIdRule
type EmailString = String Refined EmailRule
type AgeInt = Int Refined AgeRule
@newtype case class UserId(value: UserIdString)
object UserId {
def apply(rawUserId: String): Either[String, UserId] =
refineV[UserIdRule](rawUserId).map(UserId(_))
}
@newtype case class Email(value: EmailString)
object Email {
def apply(rawEmail: String): Either[String, Email] =
refineV[EmailRule](rawEmail).map(Email(_))
}
@newtype case class Age(value: AgeInt)
object Age {
def apply(rawAge: Int): Either[String, Age] =
refineV[AgeRule](rawAge).map(Age(_))
}
case class User(userId: UserId, email: Email, age: Age)
import cats.data.ValidatedNel
import cats.implicits._
/** 生のパラメータをvalidateしてUserを生成する */
def validateUser(rawUserId: String, rawEmail: String, rawAge: Int): ValidatedNel[String, User] = (
UserId(rawUserId).toValidatedNel, // Either[String, UserId] を合成のためにValidatedNelに変換
Email(rawEmail).toValidatedNel,
Age(rawAge).toValidatedNel
).mapN(User)
println(validateUser("@todokr", "tadokoro@example.com", 29))
// Valid(User(UserId(@todokr),Email(tadokoro@example.com),Age(29)))
println(validateUser("@ADMIN", "tadokoro.example.com", -1))
// Invalid(NonEmptyList(Invalid UserId: @ADMIN, Invalid Email: tadokoro.example.com, Invalid Age: -1))
|
先の実装との大きな違いは、スマートコンストラクタの実装を型から自動的に導出している点です。これによってバリデーションルールが宣言的に定義されるようになりました。
もちろん、検査済みの値はRefinement Typeなので、どういった性質の値であるかの情報も失われません。
Work with JSON: Circeと組み合わせる
refinedとnewtypeは各種ライブラリとのインテグレーションも豊富です。まずはJSONライブラリの Circe と組み合わせてみます。
依存ライブラリに circe-refined
を追加し、Refinement Type + newtypeの値と他の値をマッピングするDecoder/Encoderを定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
object CoercibleCirceCodecs {
import io.circe.{Decoder, Encoder, KeyDecoder, KeyEncoder}
import io.circe.refined._
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import io.estatico.newtype.Coercible
import io.estatico.newtype.ops._
import RefineWithNewtypeModels.User
implicit def coercibleDecoder[A: Coercible[B, *], B: Decoder]: Decoder[A] =
Decoder[B].map(_.coerce[A])
implicit def coercibleEncoder[A: Coercible[B, *], B: Encoder]: Encoder[A] =
Encoder[B].contramap(_.repr.asInstanceOf[B])
implicit def coercibleKeyDecoder[A: Coercible[B, *], B: KeyDecoder]: KeyDecoder[A] =
KeyDecoder[B].map(_.coerce[A])
implicit def coercibleKeyEncoder[A: Coercible[B, *], B: KeyEncoder]: KeyEncoder[A] =
KeyEncoder[B].contramap[A](_.repr.asInstanceOf[B])
implicit val userEncoder: Encoder[User] = deriveEncoder[User]
implicit val userDecoder: Decoder[User] = deriveDecoder[User]
}
|
これをimportして、通常通りCirceの decode
でJSONをデコードしてみます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
object WorkWithJsonExample extends App {
import eu.timepit.refined.auto.autoUnwrap // F[T, P]をTにunwrapしてくれる
import io.circe.parser.decode
import CoercibleCirceCodecs._
Iterator
.continually(Console.in.readLine())
.takeWhile(_ != ":quit")
.foreach { rawMessage =>
decode[User](rawMessage) match {
case Left(value) => println(value)
case Right(msg) =>
println(s"userId: ${msg.userId}")
println(s"age: ${msg.age}")
println(s"email: ${msg.email}")
}
println("*" * 30)
println()
}
}
|
今回はコンソールに入力した文字列をデコードするようにしました。コンソールに適当なJSONを打ち込んでみます。
1
2
3
4
5
6
7
8
|
> {"userId": "@todokr", "email": "tadokoro@example.com", "age": 29}
userId: @todokr
age: 29
email: tadokoro@example.com
******************************
> {"userId": "@admin", "email": "", "age": -1}
DecodingFailure(Right predicate of ((("@admin".startsWith("@") && !(6 < 3)...
|
無事にバリデーションができていることが分かります!
Work with DB: Doobieと組み合わせる
DBから取り出した値をrefined + newtypeの値として扱うにはどうしたらよいでしょうか?
doobie
と doobie-refined
を使用して、findメソッドを実装してみます。
まずは Circe と同じように、値をマッピングするPut/Read、そしてEqを定義します。
1
2
3
4
5
6
7
8
9
|
object CoercibleDoobieCodec {
import cats.Eq
import doobie.{Put, Read}
import io.estatico.newtype.Coercible
implicit def coerciblePut[R, N](implicit ev: Coercible[Put[R], Put[N]], R: Put[R]): Put[N] = ev(R)
implicit def coercibleRead[R, N](implicit ev: Coercible[Read[R], Read[N]], R: Read[R]): Read[N] = ev(R)
implicit def coercibleEq[R, N](implicit ev: Coercible[Eq[R], Eq[N]], R: Eq[R]): Eq[N] = ev(R)
}
|
あとはReadやPutの型クラスインスタンスを要求するメソッドから見えるように、上で定義したCodecをimportします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
object WorkWithDbExample extends App {
import doobie.{ExecutionContexts, Transactor}
import doobie.implicits._
import doobie.refined.implicits._
import cats.effect.{Blocker, ContextShift, IO}
implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContexts.synchronous)
val tx: Transactor[IO] = Transactor.fromDriverManager[IO](
driver = "org.postgresql.Driver",
url = "jdbc:postgresql://localhost:9999/test",
user = "root",
pass = "root",
blocker = Blocker.liftExecutionContext(ExecutionContexts.synchronous)
)
final class UserRepository(transactor: Transactor[IO]) {
import CoercibleDoobieCodec._
def findByEmail(email: String): IO[Option[User]] = {
sql"""SELECT user_id, email, age
|FROM users
|WHERE email=$email
|""".stripMargin
.query[User]
.option
.transact(transactor)
}
}
val repository = new UserRepository(tx)
repository.findByEmail("tadokoro@example.com").unsafeRunSync().tap(println)
// Some(User(@todokr,tadokoro@example.com,29))
}
|
これで、DBから取得した値もrefined + newtypeの値として扱えるようになりました!
まとめ: refined + newtypeは「堅い」アプリケーションの心強い味方
refined + newtypeを使うことで、記述量や理解のしやすさなどの開発者体験を犠牲にせずに、強固な型安全性の恩恵を受けることができます。
- refinedによって、とりうる値のルールを型の述語として宣言的に表現できるようになりました。これは空文字や範囲外のInt、空白文字がトリムされていない文字列、特定のフォーマットに沿っていない文字列などで引き起こされるような、ある種のバグの不在を完全に保証することができます。これは「堅く」作りたいアプリケーションにとって重要な利点です。
- バリデーションを通過した値の型はRefinement Typeです。StringやIntを検証した述語の情報を保持しており、役立つ名前(UserIdなど)を付けることができます。これはコードベースが大きくなったアプリケーションにとって有用な性質です。
- 述語同士は合成が可能です。小さくてシンプルなルールを組み合わせることで、大きく複雑なルールを簡単に作り、利用することができます。
- 述語からバリデーションロジックを自動的に導出することができるため、開発者がロジックを実装する必要がありません。
- newtype を使うことで、Refinment TypeのValue Classをパフォーマンスのオーバーヘッドなしに実現できます。
- 各種ライブラリとのインテグレーションも豊富で、JSONのやDBの値との連携も無理なく行うことができます。
大規模で複雑かつ、バグが許されないアプリケーションにとって、refinedとnewtypeは心強い味方になってくれるはずです。
サンプルコードの全体は、このリポジトリから入手できます。
HRMOS EXチームは、BtoBならではの堅いアプリケーションと、全員がいきいきと働ける柔和なチームを作ることに情熱を傾けられるソフトウェアエンジニアを募集しています。
株式会社ビズリーチ ソフトウェアエンジニア
田所 駿佑
HRMOS EXのソフトウェアエンジニア & 開発チームマネジャー 。2015年新卒入社。翔泳社「クローリングハック」共著、「Scalaスケーラブルプログラミング 第4版」監訳メンバー。特技はアンコウの吊るし切り。二児の父👶