Accord
ScalaのためのDSLベースのバリデーションライブラリ。直感的な構文と強力な型安全性を提供
import { Tabs, TabItem } from "@/components/ui/Tabs"; import { Alert, AlertDescription } from "@/components/ui/Alert";
概要
AccordはScalaのための強力なバリデーションライブラリで、DSL(Domain Specific Language)を使用して直感的かつ型安全なバリデーションルールを定義できます。Wixによって開発され、Scalaの型システムを最大限に活用して、コンパイル時の安全性と実行時の柔軟性を両立しています。
主な特徴
- DSLベースの直感的な構文: 自然言語に近い形でバリデーションルールを記述
- 型安全性: Scalaの型システムを活用した安全なバリデーション
- コンビネータ: 複雑なバリデーションルールを簡単に組み合わせ可能
- エラーメッセージのカスタマイズ: 詳細なエラー情報の提供
- ケースクラスとの統合: Scalaのケースクラスと自然に統合
インストール
基本的な使い方
インポート
import com.wix.accord._
import com.wix.accord.dsl._
シンプルなバリデーション
case class Person(name: String, age: Int)
implicit val personValidator = validator[Person] { p =>
p.name is notEmpty
p.age is between(0, 120)
}
// バリデーションの実行
val person = Person("Alice", 25)
val result = validate(person)
result match {
case Success => println("Valid person")
case Failure(violations) =>
violations.foreach(v => println(s"${v.path}: ${v.constraint}"))
}
DSLベースのバリデーション
基本的なバリデータ
case class User(
username: String,
email: String,
age: Int,
score: Double
)
implicit val userValidator = validator[User] { u =>
u.username is notEmpty
u.username has size > 3
u.email is notEmpty
u.email matches """^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})$"""
u.age is between(18, 100)
u.score is between(0.0, 100.0)
}
ネストしたオブジェクトのバリデーション
case class Address(
street: String,
city: String,
zipCode: String
)
case class Customer(
name: String,
email: String,
address: Address
)
implicit val addressValidator = validator[Address] { a =>
a.street is notEmpty
a.city is notEmpty
a.zipCode matches """^\d{3}-\d{4}$"""
}
implicit val customerValidator = validator[Customer] { c =>
c.name is notEmpty
c.email matches """^[^\s@]+@[^\s@]+\.[^\s@]+$"""
c.address is valid // ネストしたバリデータを使用
}
コンビネータとバリデータ
条件付きバリデーション
case class Order(
id: String,
status: String,
paymentMethod: Option[String],
deliveryDate: Option[String]
)
implicit val orderValidator = validator[Order] { o =>
o.id is notEmpty
o.status is in("pending", "confirmed", "shipped", "delivered")
// statusがconfirmed以上の場合、paymentMethodが必須
if (o.status != "pending") {
o.paymentMethod is notEmpty
}
// statusがshippedの場合、deliveryDateが必須
if (o.status == "shipped") {
o.deliveryDate is notEmpty
}
}
コレクションのバリデーション
case class Team(
name: String,
members: List[Person],
scores: Set[Int]
)
implicit val teamValidator = validator[Team] { t =>
t.name is notEmpty
t.members is notEmpty
t.members.each is valid // 各メンバーをバリデート
t.members has size <= 10
t.scores.each is between(0, 100)
}
カスタムバリデータ
単純なカスタムバリデータ
// パスワード強度のバリデータ
def strongPassword: Validator[String] = new Validator[String] {
def apply(password: String): Result = {
val hasUpperCase = password.exists(_.isUpper)
val hasLowerCase = password.exists(_.isLower)
val hasDigit = password.exists(_.isDigit)
val hasSpecial = password.exists(c => "!@#$%^&*".contains(c))
val isLongEnough = password.length >= 8
if (hasUpperCase && hasLowerCase && hasDigit && hasSpecial && isLongEnough) {
Success
} else {
Failure(Set(RuleViolation(
password,
"must contain uppercase, lowercase, digit, special character and be at least 8 characters",
None
)))
}
}
}
case class Account(username: String, password: String)
implicit val accountValidator = validator[Account] { a =>
a.username is notEmpty
a.password is strongPassword
}
パラメータ付きカスタムバリデータ
// 日本の郵便番号バリデータ
def japaneseZipCode: Validator[String] =
validator[String](_ matches """^\d{3}-\d{4}$""")
// 電話番号バリデータ(国コード付き)
def phoneNumber(countryCode: String): Validator[String] = new Validator[String] {
def apply(phone: String): Result = {
val pattern = countryCode match {
case "JP" => """^0\d{1,4}-\d{1,4}-\d{4}$"""
case "US" => """^\d{3}-\d{3}-\d{4}$"""
case _ => """^\+?\d{10,15}$"""
}
if (phone.matches(pattern)) Success
else Failure(Set(RuleViolation(
phone,
s"must be a valid $countryCode phone number",
None
)))
}
}
エラーハンドリング
詳細なエラー情報の取得
case class Registration(
username: String,
email: String,
password: String,
age: Int
)
implicit val registrationValidator = validator[Registration] { r =>
r.username as "ユーザー名" is notEmpty
r.username as "ユーザー名" has size >= 4
r.email as "メールアドレス" matches """^[^\s@]+@[^\s@]+\.[^\s@]+$"""
r.password as "パスワード" has size >= 8
r.age as "年齢" is >= 18
}
val invalidReg = Registration("abc", "invalid-email", "short", 15)
val result = validate(invalidReg)
result match {
case Success => println("登録成功")
case Failure(violations) =>
violations.foreach { violation =>
println(s"${violation.path}: ${violation.constraint}")
}
}
エラーメッセージのカスタマイズ
implicit val customMessageValidator = validator[Person] { p =>
p.name as "名前" should notEmpty as "名前は必須項目です"
p.age as "年齢" should (be >= 20) as "20歳以上である必要があります"
}
実践的な例
APIリクエストのバリデーション
case class CreateUserRequest(
username: String,
email: String,
password: String,
profile: UserProfile
)
case class UserProfile(
firstName: String,
lastName: String,
bio: Option[String],
tags: List[String]
)
implicit val userProfileValidator = validator[UserProfile] { p =>
p.firstName is notEmpty
p.lastName is notEmpty
p.bio.each has size <= 500
p.tags has size <= 10
p.tags.each has size between(1, 20)
}
implicit val createUserRequestValidator = validator[CreateUserRequest] { r =>
r.username has size between(4, 20)
r.username matches """^[a-zA-Z0-9_]+$"""
r.email matches """^[^\s@]+@[^\s@]+\.[^\s@]+$"""
r.password has size >= 8
r.profile is valid
}
// Play FrameworkやAkka HTTPでの使用例
def createUser(request: CreateUserRequest): Future[Either[String, User]] = {
validate(request) match {
case Success =>
// ユーザー作成処理
Future.successful(Right(User(request.username, request.email)))
case Failure(violations) =>
val errors = violations.map(v => s"${v.path}: ${v.constraint}").mkString(", ")
Future.successful(Left(s"Validation failed: $errors"))
}
}
ドメインモデルのバリデーション
// 商品在庫管理システム
case class Product(
id: String,
name: String,
price: BigDecimal,
stock: Int,
categories: Set[String]
)
case class Inventory(
products: List[Product],
lastUpdated: java.time.Instant
)
implicit val productValidator = validator[Product] { p =>
p.id matches """^PRD-\d{6}$"""
p.name is notEmpty
p.price is > BigDecimal(0)
p.stock is >= 0
p.categories is notEmpty
p.categories has size <= 5
}
implicit val inventoryValidator = validator[Inventory] { inv =>
inv.products is notEmpty
inv.products.each is valid
// 重複する商品IDがないことを確認
inv.products.map(_.id).distinct.size is equalTo(inv.products.size)
}
ベストプラクティス
1. バリデータの再利用
// 共通バリデータの定義
object CommonValidators {
val emailValidator = validator[String](_ matches """^[^\s@]+@[^\s@]+\.[^\s@]+$""")
val nonEmptyString = validator[String](_ is notEmpty)
val positiveNumber = validator[Int](_ is > 0)
}
// 使用例
case class Contact(name: String, email: String, age: Int)
implicit val contactValidator = validator[Contact] { c =>
c.name is nonEmptyString
c.email is emailValidator
c.age is positiveNumber
}
2. エラーメッセージの国際化
trait ValidationMessages {
def required(field: String): String
def invalidFormat(field: String): String
def tooShort(field: String, min: Int): String
}
object JapaneseMessages extends ValidationMessages {
def required(field: String) = s"${field}は必須項目です"
def invalidFormat(field: String) = s"${field}の形式が正しくありません"
def tooShort(field: String, min: Int) = s"${field}は${min}文字以上である必要があります"
}
3. テスト駆動開発
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class PersonValidatorSpec extends AnyFlatSpec with Matchers {
"PersonValidator" should "accept valid person" in {
val validPerson = Person("Alice", 25)
validate(validPerson) shouldBe Success
}
it should "reject person with empty name" in {
val invalidPerson = Person("", 25)
validate(invalidPerson) match {
case Failure(violations) =>
violations.exists(_.path.toString.contains("name")) shouldBe true
case Success => fail("Should have failed")
}
}
}
まとめ
Accordは、Scalaアプリケーションにおいて型安全で表現力豊かなバリデーションを実現する優れたライブラリです。DSLベースの直感的な構文により、複雑なバリデーションルールも読みやすく保守しやすいコードで表現できます。
Accordが適している場面
- ドメインモデルの検証が必要なアプリケーション
- RESTful APIのリクエスト/レスポンスバリデーション
- フォームデータの検証
- 設定ファイルの検証
- ビジネスルールの実装
Scalaの型システムとAccordのDSLを組み合わせることで、堅牢で保守性の高いアプリケーションを構築できます。