ZIO Schema

ZIOエコシステムの型安全なスキーマライブラリ。データ構造のスキーマをランタイム値として定義し、バリデーション、シリアライゼーション、マイグレーションを自動化

概要

ZIO Schemaは、データ構造のスキーマを実行時の値として定義することで、分散コンピューティングにおける一般的な問題を解決するScalaライブラリです。コンパイル時の構成要素(データ構造の型)を実行時の構成要素(読み取り、操作、合成可能な値)に変換することで、マクロやリフレクションを使用せずにメタプログラミング機能を提供します。

主な特徴

  • マクロなしのメタプログラミング: リフレクションやマクロを使用しない型安全なアプローチ
  • 自動コーデック生成: JSON、Protobuf、Avro、BSON、MessagePack、Thriftのコーデックを自動生成
  • 型クラス導出: case classやsealed traitから自動導出
  • バリデーション: 型レベルでのデータ整合性ルール定義
  • データマイグレーション: スキーマ進化とETLパイプラインの自動化
  • ZIOエコシステム統合: ZIO、ZIO Flow、ZIO Redis、ZIO SQLなどとシームレス連携

インストール

SBT

val zioSchemaVersion = "1.7.3"

libraryDependencies ++= Seq(
  // コアライブラリ
  "dev.zio" %% "zio-schema" % zioSchemaVersion,
  
  // 自動導出のための依存関係
  "dev.zio" %% "zio-schema-derivation" % zioSchemaVersion,
  "org.scala-lang" % "scala-reflect" % scalaVersion.value % "provided",
  
  // シリアライゼーション形式別コーデック
  "dev.zio" %% "zio-schema-json" % zioSchemaVersion,
  "dev.zio" %% "zio-schema-protobuf" % zioSchemaVersion,
  "dev.zio" %% "zio-schema-avro" % zioSchemaVersion,
  "dev.zio" %% "zio-schema-bson" % zioSchemaVersion,
  "dev.zio" %% "zio-schema-msg-pack" % zioSchemaVersion,
  "dev.zio" %% "zio-schema-thrift" % zioSchemaVersion,
  
  // テスト用
  "dev.zio" %% "zio-schema-zio-test" % zioSchemaVersion % Test
)

Maven

<properties>
  <zio-schema.version>1.7.3</zio-schema.version>
</properties>

<dependencies>
  <dependency>
    <groupId>dev.zio</groupId>
    <artifactId>zio-schema_2.13</artifactId>
    <version>${zio-schema.version}</version>
  </dependency>
  
  <dependency>
    <groupId>dev.zio</groupId>
    <artifactId>zio-schema-derivation_2.13</artifactId>
    <version>${zio-schema.version}</version>
  </dependency>
  
  <dependency>
    <groupId>dev.zio</groupId>
    <artifactId>zio-schema-json_2.13</artifactId>
    <version>${zio-schema.version}</version>
  </dependency>
</dependencies>

基本的な使い方

スキーマの定義と自動導出

import zio._
import zio.schema._

// case classのスキーマ自動導出
case class Person(name: String, age: Int, email: String)

object Person {
  implicit val schema: Schema[Person] = DeriveSchema.gen
}

// sealed traitのスキーマ自動導出
sealed trait Status
case object Active extends Status
case object Inactive extends Status
case class Suspended(reason: String) extends Status

object Status {
  implicit val schema: Schema[Status] = DeriveSchema.gen
}

手動スキーマ定義

import zio.schema._
import zio.schema.Schema._

// プリミティブ型のスキーマ
val stringSchema: Schema[String] = primitive[String]
val intSchema: Schema[Int] = primitive[Int]
val booleanSchema: Schema[Boolean] = primitive[Boolean]

// コレクション型のスキーマ
val listSchema: Schema[List[String]] = list(primitive[String])
val setSchema: Schema[Set[Int]] = set(primitive[Int])
val mapSchema: Schema[Map[String, Int]] = map(primitive[String], primitive[Int])

// 製品型(case class)の手動定義
case class User(id: Int, name: String, active: Boolean)

object User {
  val schema: Schema[User] = Schema.CaseClass3[Int, String, Boolean, User](
    TypeId.parse("User"),
    field01 = Schema.Field("id", primitive[Int], get0 = _.id, set0 = (u, id) => u.copy(id = id)),
    field02 = Schema.Field("name", primitive[String], get0 = _.name, set0 = (u, name) => u.copy(name = name)),
    field03 = Schema.Field("active", primitive[Boolean], get0 = _.active, set0 = (u, active) => u.copy(active = active)),
    construct0 = (id, name, active) => User(id, name, active)
  )
}

バリデーション

基本的なバリデーション

import zio.schema._
import zio.schema.annotation._
import zio.schema.validation._

// バリデーション注釈付きのcase class
case class ValidatedPerson(
  @validate(Validation.minLength(1) && Validation.maxLength(50))
  name: String,
  
  @validate(Validation.between(0, 120))
  age: Int,
  
  @validate(Validation.regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"))
  email: String
)

object ValidatedPerson {
  implicit val schema: Schema[ValidatedPerson] = DeriveSchema.gen
}

// バリデーション実行
val person = ValidatedPerson("", 150, "invalid-email")
val validationResult = ValidatedPerson.schema.validate(person)

validationResult match {
  case chunk if chunk.isEmpty => println("バリデーション成功")
  case errors => 
    println("バリデーションエラー:")
    errors.foreach(error => println(s"  - ${error.message}"))
}

カスタムバリデーション

import zio.schema.validation._

// カスタムバリデーションルール
def positiveNumber[A](implicit num: Numeric[A]): Validation[A] = 
  Validation.custom(value => 
    if (num.gt(value, num.zero)) ValidationResult.succeed(value)
    else ValidationResult.fail("値は正の数である必要があります")
  )

def uniqueElements[A]: Validation[List[A]] = 
  Validation.custom(list => 
    if (list.distinct.length == list.length) ValidationResult.succeed(list)
    else ValidationResult.fail("リストの要素は一意である必要があります")
  )

// カスタムバリデーション付きスキーマ
case class Product(
  @validate(positiveNumber)
  price: BigDecimal,
  
  @validate(uniqueElements)
  tags: List[String]
)

object Product {
  implicit val schema: Schema[Product] = DeriveSchema.gen
}

複合バリデーション

import zio.schema.validation._

case class Order(
  @validate(Validation.minLength(3) && Validation.maxLength(20))
  orderId: String,
  
  @validate(Validation.greaterThan(0))
  quantity: Int,
  
  @validate(Validation.between(1.0, 1000000.0))
  totalAmount: Double,
  
  @validate(Validation.nonEmpty)
  items: List[String]
)

object Order {
  implicit val schema: Schema[Order] = DeriveSchema.gen
  
  // ビジネスロジックレベルのバリデーション
  def validateOrder(order: Order): Either[String, Order] = {
    if (order.totalAmount < order.quantity * 10) {
      Left("合計金額が最小単価を下回っています")
    } else if (order.items.isEmpty) {
      Left("注文アイテムが指定されていません")
    } else {
      Right(order)
    }
  }
}

シリアライゼーション

JSONシリアライゼーション

import zio._
import zio.schema._
import zio.schema.codec._

case class Book(title: String, author: String, isbn: String, pages: Int)

object Book {
  implicit val schema: Schema[Book] = DeriveSchema.gen
  val jsonCodec: JsonCodec[Book] = JsonCodec.schemaBasedBinaryCodec
}

// JSONエンコード・デコード
object JsonExample extends ZIOAppDefault {
  def run = for {
    book <- ZIO.succeed(Book("Scala実践プログラミング", "山田太郎", "978-4-123456-78-9", 350))
    
    // エンコード
    encoded <- ZIO.fromEither(Book.jsonCodec.encode(book))
    jsonString = new String(encoded.toArray)
    _ <- Console.printLine(s"JSON: $jsonString")
    
    // デコード
    decoded <- ZIO.fromEither(Book.jsonCodec.decode(Chunk.fromArray(jsonString.getBytes)))
    _ <- Console.printLine(s"デコード結果: $decoded")
  } yield ()
}

Protobufシリアライゼーション

import zio.schema.codec._

case class Message(id: Long, content: String, timestamp: Long)

object Message {
  implicit val schema: Schema[Message] = DeriveSchema.gen
  val protobufCodec: BinaryCodec[Message] = ProtobufCodec.protobufCodec
}

// Protobufエンコード・デコード
object ProtobufExample extends ZIOAppDefault {
  def run = for {
    message <- ZIO.succeed(Message(1L, "Hello ZIO Schema", System.currentTimeMillis()))
    
    // エンコード
    encoded <- ZIO.fromEither(Message.protobufCodec.encode(message))
    _ <- Console.printLine(s"エンコードサイズ: ${encoded.length} bytes")
    
    // デコード
    decoded <- ZIO.fromEither(Message.protobufCodec.decode(encoded))
    _ <- Console.printLine(s"デコード結果: $decoded")
  } yield ()
}

Avroシリアライゼーション

import zio.schema.codec._

case class User(id: Int, username: String, email: String, createdAt: java.time.Instant)

object User {
  implicit val schema: Schema[User] = DeriveSchema.gen
  val avroCodec: BinaryCodec[User] = AvroCodec.schemaBasedBinaryCodec
}

// Avroスキーマ生成とシリアライゼーション
object AvroExample extends ZIOAppDefault {
  def run = for {
    user <- ZIO.succeed(User(1, "johndoe", "[email protected]", java.time.Instant.now()))
    
    // Avroスキーマの生成
    avroSchema = AvroCodec.encode(User.schema)
    _ <- Console.printLine(s"Avroスキーマ: $avroSchema")
    
    // エンコード
    encoded <- ZIO.fromEither(User.avroCodec.encode(user))
    _ <- Console.printLine(s"エンコードサイズ: ${encoded.length} bytes")
    
    // デコード
    decoded <- ZIO.fromEither(User.avroCodec.decode(encoded))
    _ <- Console.printLine(s"デコード結果: $decoded")
  } yield ()
}

型安全なスキーマ合成

スキーマ変換

import zio.schema._
import zio.schema.Schema._

// スキーマ間の変換
case class PersonV1(name: String, age: Int)
case class PersonV2(firstName: String, lastName: String, age: Int, email: Option[String])

object PersonV1 {
  implicit val schema: Schema[PersonV1] = DeriveSchema.gen
}

object PersonV2 {
  implicit val schema: Schema[PersonV2] = DeriveSchema.gen
  
  // V1からV2への変換関数
  def fromV1(v1: PersonV1, email: Option[String] = None): PersonV2 = {
    val parts = v1.name.split(" ", 2)
    val firstName = parts.headOption.getOrElse("")
    val lastName = if (parts.length > 1) parts(1) else ""
    PersonV2(firstName, lastName, v1.age, email)
  }
}

ネストしたスキーマ

case class Address(
  street: String,
  city: String,
  zipCode: String,
  country: String
)

case class Company(
  name: String,
  address: Address,
  employees: List[Person]
)

object Address {
  implicit val schema: Schema[Address] = DeriveSchema.gen
}

object Company {
  implicit val schema: Schema[Company] = DeriveSchema.gen
}

ZIOエコシステム統合

ZIO HTTPとの統合

import zio._
import zio.http._
import zio.schema._
import zio.schema.codec._

case class CreateUserRequest(name: String, email: String, age: Int)
case class UserResponse(id: Long, name: String, email: String, age: Int, createdAt: String)

object CreateUserRequest {
  implicit val schema: Schema[CreateUserRequest] = DeriveSchema.gen
  val jsonCodec: JsonCodec[CreateUserRequest] = JsonCodec.schemaBasedBinaryCodec
}

object UserResponse {
  implicit val schema: Schema[UserResponse] = DeriveSchema.gen
  val jsonCodec: JsonCodec[UserResponse] = JsonCodec.schemaBasedBinaryCodec
}

// HTTPハンドラーでのスキーマ活用
val userRoutes = Routes(
  Method.POST / "users" -> handler { (req: Request) =>
    for {
      body <- req.body.asChunk
      createRequest <- ZIO.fromEither(CreateUserRequest.jsonCodec.decode(body))
        .mapError(error => Response.badRequest(error.getMessage))
      
      // バリデーション
      validationResult = CreateUserRequest.schema.validate(createRequest)
      _ <- ZIO.when(validationResult.nonEmpty)(
        ZIO.fail(Response.badRequest(validationResult.map(_.message).mkString(", ")))
      )
      
      // ユーザー作成処理(モック)
      user = UserResponse(
        id = scala.util.Random.nextLong(1000),
        name = createRequest.name,
        email = createRequest.email,
        age = createRequest.age,
        createdAt = java.time.Instant.now().toString
      )
      
      // レスポンス
      encoded <- ZIO.fromEither(UserResponse.jsonCodec.encode(user))
      response = Response.json(new String(encoded.toArray))
    } yield response
  }
)

ZIO Configとの統合

import zio._
import zio.config._
import zio.schema._

case class DatabaseConfig(
  host: String,
  port: Int,
  database: String,
  username: String,
  password: String,
  maxConnections: Int
)

object DatabaseConfig {
  implicit val schema: Schema[DatabaseConfig] = DeriveSchema.gen
  
  // ZIO Configでの設定読み込み
  val config: Config[DatabaseConfig] = Config.fromSchema(schema)
}

// 使用例
object ConfigExample extends ZIOAppDefault {
  def run = for {
    config <- ZIO.config(DatabaseConfig.config)
    _ <- Console.printLine(s"データベース接続先: ${config.host}:${config.port}")
  } yield ()
}

マイグレーションとスキーマ進化

バージョン管理

import zio.schema._
import zio.schema.migration._

// バージョン1のスキーマ
case class UserV1(name: String, email: String)

object UserV1 {
  implicit val schema: Schema[UserV1] = DeriveSchema.gen
}

// バージョン2のスキーマ(フィールド追加)
case class UserV2(name: String, email: String, createdAt: java.time.Instant)

object UserV2 {
  implicit val schema: Schema[UserV2] = DeriveSchema.gen
}

// バージョン3のスキーマ(フィールド分割)
case class UserV3(
  firstName: String,
  lastName: String,
  email: String,
  createdAt: java.time.Instant,
  lastUpdated: java.time.Instant
)

object UserV3 {
  implicit val schema: Schema[UserV3] = DeriveSchema.gen
}

// マイグレーション関数
object UserMigration {
  def v1ToV2(v1: UserV1): UserV2 = 
    UserV2(v1.name, v1.email, java.time.Instant.now())
  
  def v2ToV3(v2: UserV2): UserV3 = {
    val parts = v2.name.split(" ", 2)
    UserV3(
      firstName = parts.headOption.getOrElse(""),
      lastName = if (parts.length > 1) parts(1) else "",
      email = v2.email,
      createdAt = v2.createdAt,
      lastUpdated = java.time.Instant.now()
    )
  }
}

データ差分と適用

import zio.schema._
import zio.schema.diff._

// データの差分計算
object DiffExample extends ZIOAppDefault {
  def run = for {
    original <- ZIO.succeed(UserV2("John Doe", "[email protected]", java.time.Instant.parse("2023-01-01T00:00:00Z")))
    updated <- ZIO.succeed(UserV2("John Smith", "[email protected]", java.time.Instant.parse("2023-01-01T00:00:00Z")))
    
    // 差分計算
    diff = Schema.diff(UserV2.schema, original, updated)
    _ <- Console.printLine(s"データ差分: $diff")
    
    // 差分の適用
    patched = Schema.patch(UserV2.schema, original, diff)
    _ <- Console.printLine(s"パッチ適用後: $patched")
  } yield ()
}

実践的な例

マイクロサービス間通信

import zio._
import zio.schema._
import zio.schema.codec._

// イベントスキーマ定義
sealed trait Event
case class UserCreated(userId: Long, name: String, email: String, timestamp: Long) extends Event
case class UserUpdated(userId: Long, changes: Map[String, String], timestamp: Long) extends Event
case class UserDeleted(userId: Long, timestamp: Long) extends Event

object Event {
  implicit val schema: Schema[Event] = DeriveSchema.gen
  val jsonCodec: JsonCodec[Event] = JsonCodec.schemaBasedBinaryCodec
  val protobufCodec: BinaryCodec[Event] = ProtobufCodec.protobufCodec
}

// イベント発行者
class EventPublisher {
  def publishEvent(event: Event): Task[Unit] = for {
    encoded <- ZIO.fromEither(Event.protobufCodec.encode(event))
    _ <- Console.printLine(s"イベント発行: ${encoded.length} bytes")
    // 実際のメッセージキューへの送信処理をここに実装
  } yield ()
}

// イベント消費者
class EventConsumer {
  def processEvent(eventData: Chunk[Byte]): Task[Unit] = for {
    event <- ZIO.fromEither(Event.protobufCodec.decode(eventData))
    _ <- event match {
      case UserCreated(userId, name, email, timestamp) =>
        Console.printLine(s"新規ユーザー: $name ($email)")
      case UserUpdated(userId, changes, timestamp) =>
        Console.printLine(s"ユーザー $userId 更新: ${changes.mkString(", ")}")
      case UserDeleted(userId, timestamp) =>
        Console.printLine(s"ユーザー $userId 削除")
    }
  } yield ()
}

APIドキュメント生成

import zio.schema._
import zio.schema.openapi._

case class Product(
  id: Long,
  name: String,
  description: Option[String],
  price: BigDecimal,
  category: String,
  inStock: Boolean
)

object Product {
  implicit val schema: Schema[Product] = DeriveSchema.gen
  
  // OpenAPIスキーマ生成
  val openApiSchema: openapi.OpenAPI.ReferenceOr.SchemaOrReference = 
    OpenAPIGen.fromZIOSchema(schema)
}

// APIエンドポイント定義
case class CreateProductRequest(
  name: String,
  description: Option[String],
  price: BigDecimal,
  category: String
)

case class ProductListResponse(
  products: List[Product],
  total: Int,
  page: Int,
  limit: Int
)

object CreateProductRequest {
  implicit val schema: Schema[CreateProductRequest] = DeriveSchema.gen
}

object ProductListResponse {
  implicit val schema: Schema[ProductListResponse] = DeriveSchema.gen
}

パフォーマンス考慮事項

スキーマコンパイル時間

// 大きなcase classのスキーマは分割を検討
case class LargeEntity(
  // 50以上のフィールドがある場合
  field1: String,
  field2: Int,
  // ... 多数のフィールド
)

// より良いアプローチ: 関連フィールドをグループ化
case class EntityBasicInfo(name: String, description: String, category: String)
case class EntityMetadata(createdAt: java.time.Instant, updatedAt: java.time.Instant, version: Int)
case class EntityDetails(basicInfo: EntityBasicInfo, metadata: EntityMetadata, /* その他のフィールド */)

シリアライゼーション最適化

// バイナリ形式の選択による最適化
object PerformanceComparison {
  case class Data(id: Long, name: String, values: List[Double])
  
  object Data {
    implicit val schema: Schema[Data] = DeriveSchema.gen
    val jsonCodec: JsonCodec[Data] = JsonCodec.schemaBasedBinaryCodec
    val protobufCodec: BinaryCodec[Data] = ProtobufCodec.protobufCodec
    val avroCodec: BinaryCodec[Data] = AvroCodec.schemaBasedBinaryCodec
  }
  
  def benchmarkSerialization(data: Data): Task[Unit] = for {
    // JSON (可読性重視、サイズ大)
    jsonEncoded <- ZIO.fromEither(Data.jsonCodec.encode(data))
    _ <- Console.printLine(s"JSON サイズ: ${jsonEncoded.length} bytes")
    
    // Protobuf (バランス型、型安全)
    protobufEncoded <- ZIO.fromEither(Data.protobufCodec.encode(data))
    _ <- Console.printLine(s"Protobuf サイズ: ${protobufEncoded.length} bytes")
    
    // Avro (スキーマ進化に最適)
    avroEncoded <- ZIO.fromEither(Data.avroCodec.encode(data))
    _ <- Console.printLine(s"Avro サイズ: ${avroEncoded.length} bytes")
  } yield ()
}

トラブルシューティング

一般的な問題と解決策

// 問題1: 循環参照の解決
case class Department(name: String, manager: Option[Employee], employees: List[Employee])
case class Employee(name: String, department: Option[Department])

// 解決策: lazy val を使用
object Department {
  implicit lazy val schema: Schema[Department] = DeriveSchema.gen
}

object Employee {
  implicit lazy val schema: Schema[Employee] = DeriveSchema.gen
}

// 問題2: 複雑な継承階層
sealed trait Animal
sealed trait Mammal extends Animal
case class Dog(name: String, breed: String) extends Mammal
case class Cat(name: String, indoor: Boolean) extends Mammal
case class Bird(name: String, canFly: Boolean) extends Animal

// 解決策: 段階的なスキーマ定義
object Animal {
  implicit val dogSchema: Schema[Dog] = DeriveSchema.gen
  implicit val catSchema: Schema[Cat] = DeriveSchema.gen
  implicit val birdSchema: Schema[Bird] = DeriveSchema.gen
  implicit val mammalSchema: Schema[Mammal] = DeriveSchema.gen
  implicit val animalSchema: Schema[Animal] = DeriveSchema.gen
}

デバッグとログ

import zio.schema.validation._

// バリデーションエラーの詳細ログ
def validateWithLogging[A](schema: Schema[A], value: A): Task[A] = {
  val errors = schema.validate(value)
  if (errors.isEmpty) {
    ZIO.logInfo(s"バリデーション成功: ${value.getClass.getSimpleName}") *>
    ZIO.succeed(value)
  } else {
    ZIO.logError(s"バリデーションエラー: ${errors.map(_.message).mkString(", ")}") *>
    ZIO.fail(new IllegalArgumentException(errors.map(_.message).mkString(", ")))
  }
}

まとめ

ZIO Schemaは、Scalaにおける型安全なデータ処理とシリアライゼーションのための包括的なソリューションです。リフレクションやマクロに依存せず、ZIOエコシステムとの深い統合により、分散システムやマイクロサービスアーキテクチャにおける複雑なデータ操作を簡潔かつ安全に実現できます。

バリデーション、シリアライゼーション、マイグレーション機能を統一されたAPIで提供することで、開発者は型安全性を保ちながら効率的にアプリケーションを構築できます。特に、スキーマ進化とバージョン管理のサポートにより、長期間にわたって保守可能なシステムの構築が可能になります。