Calypso: Схема данных MongoDB на Scala

eff3326e2f07ac4546b807f20ddc5f3f

Введение

Чтобы применять Domain-Driven Design, DDD Aggregate и Transactional outbox на MongoDB, наша команда создала open source библиотеку calypso для работы с BSON.

Публикация для тех, кто стремится к современным практикам разработки и разделяет наше влечение к Scala 3.

Готовы к открытиям? Добро пожаловать в мир функционального программирования и надёжной работы с schema-on-read.

План

  • моделирование предметной области, используя ADT;

  • конвертация модели предметной области в BSON;

  • работа с библиотекой calypso на Scala 3;

  • эволюция схемы данных MongoDB;

  • практики работы с schema-on-read.

Как обнаружить полезную модель?

«All models are wrong, some are useful.» George Box

Автоматизация бизнес-процессов начинается с модели. Схема хранения, паттерны проектирования и декомпозиция компонентов тоже важны, но если модель неудачная, понятное решение вряд ли получится.

На примере игрушечной задачи займёмся моделированием. Задача — деактивация пользователя с обязательной фиксацией времени, когда это случилось.

import java.time.Instant

case class Person(id: Int, deactivated: Boolean, deactivatedAt: Option[Instant])

Эта модель решает задачу, можно выразить как активного пользователя, так и деактивированного, с указанием времени. Такое представление с недостатком, существует способ создать пользователя в некорректном состоянии.

Person(1, deactivated = true, None)

Не указано время деактивации. Можно отследить это на code review и написать модульные тесты, но существует способ изящнее.

«Making Invalid State Unrepresentable.» Yaron Minsky

Выразим статус пользователя через enum Scala 3.

enum Status:
  case Active
  case Deactivated(t: Instant)

case class Person(id: Int, status: Status)

Такой подход исключает некоторые ошибки на уровне типов и делает невозможным тестирование этих сценариев. В отличие от первой модели, нельзя деактивировать пользователя, не указав время.

Person(1, Status.Active)
Person(1, Status.Deactivated(Instant.EPOCH))

Пример выше — использование алгебраических типов данных для моделирования. Представить композицию атрибутов через «и» можно во многих языках программирования, это struct, class или tuple. В общем виде называется product. Напротив, выражение идеи «или», которая используется для вариантов Active и Deactivated — редкость. В Java и Kotlin это sealed class, в Scala 2 sealed trait. Scala 3 предлагает enum, ясно выражая идею. Ещё используются термины coproduct, sum type или tagged union.

Важно понять отсюда, что использование алгебраических типов данных делает стройную однозначную модель, исключая часть ошибок. Роль модульных тестов выполняет компилятор, не позволяя выразить ошибки в коде.

Паттерны функционального программирования позволяют выразить часть логики на уровне типов. Как сохранить эти преимущества при переходе к модели хранения?

Как такую модель сохранить в базу данных?

Мы выбрали MongoDB по нескольким причинам:

  • отсутствие реляционной модели избавляет от ORM;

  • Change Streams подходит для реализации Transactional outbox;

  • горизонтально масштабируется.

Основной сценарий использования MongoDB в нашей команде — хранение состояния сущностей доменной области, например Person из примеров выше. Если упростить — от хранилища нужны операции чтения и записи по ключу, преобразования над состоянием происходят в приложении, состояние не меняется частично, только целиком.

BSON

Модель данных MongoDB похожа на JSON, но работает с бинарными данными. Сериализованное значение Person в BSON:

{"id": 1, "status": {"tag": "Deactivated", value: {"t": 0}}}

BSON представляет типы данных вроде Int, String, Array, Object. Это богатый инструментарий, но не настолько выразительный как типы модели предметной области. Потребуются преобразования, чтобы получить BSON из типов Scala, таких как List, case class или enum. Библиотеки MongoDB Scala Driver, ReactiveMongo, MongoLess, shapeless-reactivemongo, Pure BSON, medeia и circe-bson решают задачу. Но не обладают значимыми для нашей команды качествами одновременно:

  • удобный интерфейс для кодирования case class и enum;

  • отсутствие compile-time и runtime рефлексии;

  • поддержка бинарных данных.

В прошлой главе обнаружили преимущества алгебраических данных при моделировании, из этого последует интенсивное использование enum в коде. Нужен удобный способ кодировать такие структуры в BSON.

Рефлексия исключает шаблонный код, но рефакторинг, вроде изменения имён полей, ломает чтение существующих данных BSON в Scala.

Подмножество JSON, которое предлагает circe-bson не работает с бинарными данными, хотя у circe отличный интерфейс для кодирования case class.

Чтобы получить всё и сразу, мы разработали библиотеку calypso для type-safe преобразований между BSON и Scala.

calypso

Библиотека представляет абстракции Encoder и Decoder с конструкторами. Ещё комбинаторы для их преобразований. Encoder и Decoder в calypso — способ трансляции между моделью предметной области и моделью хранения MongoDB.

import org.bson.BsonValue

trait Encoder[A]:
  def encode(a: A): BsonValue

Концептуально это функция из A в BsonValue. Посмотрим на новом примере Person, как кодировать case class:

import org.bson.BsonValue
import ru.m2.calypso.Encoder
import ru.m2.calypso.syntax.*

case class Person(id: Int, name: String)
object Person:
  given Encoder[Person] = Encoder.forProduct2("id", "name")(p => (p.id, p.name))

val bson: BsonValue = Person(1, "Алиса").asBson // {"id": 1, "name": "Алиса"}

При описании Encoder явно указаны названия полей id и name, это делается, чтобы изменение названий полей в Scala не повлияло на названия в BSON.

Конструктор forProduct2 говорит о создании Encoder для product, или case class, с двумя атрибутами. Ещё говорят, с арностью два.

Given instances это конструкция Scala 3. В Scala 2 называется implicit val. Идея в представлении каноничного значения Encoder[Person], которое будет автоматически использовано компилятором, например при вызове .asBson на Person.

Encoder это теорема, его имплементация — доказательство. Если существует Encoder для Person, то можно преобразовать значение в BSON. Паттерн с параметризованным интерфейсом и каноничной имплементацией, как в примере выше, называется type class. Позволяет назначить поведение структурам данных, не меняя их имплементацию. Интенсивно используется в Haskell и Scala.

Получить лаконичный интерфейс, вроде forProduct2, позволяет механизм conditional implicits в Scala. Если существует Encoder для Int и String, то компилятор самостоятельно выводит Encoder для их пары. Calypso поддерживает базовые типы и некоторые коллекции из Scala standard library.

Пример кодирования enum:

import org.bson.BsonValue
import ru.m2.calypso.Encoder
import ru.m2.calypso.syntax.*

enum AorB:
  case A(i: Int)
  case B(s: String)

object AorB:
  given Encoder[A] = Encoder.forProduct1("i")(_.i)
  given Encoder[B] = Encoder.forProduct1("s")(_.s)
  given Encoder[AorB] = Encoder.forCoproduct:
    case a: A => "A" -> a.asBson
    case b: B => "B" -> b.asBson

val aBson: BsonValue = (AorB.A(42): AorB).asBson
// {"tag": "A", "value": {"i": 42}}

Не так изящно, как case class, но решение рабочее. Имея Encoder для каждого варианта AorB, это A и B, можем преобразовать их к BSON. Понадобится указать название конструктора при использовании forCoproduct. Это нужно, чтобы различать варианты на этапе декодирования. Пробовали разные интерфейсы для кодирования enum, остановились на этом, оказался самым понятным при чтении. Предлагайте альтернативный способ в комментариях.

Decoder работает похожим образом. Механизм type-directed programming или compile-time derived codecs, в примере выше, даёт надёжное решение без использования макросов или runtime рефлексии.

Как с таким подходом развивать модель, ведь в MongoDB «нет схемы»?

Эволюция схемы данных

Новые сценарии использования меняют модель, у пользователя появилась фамилия:

case class Person(id: Int, name: String, surname: String)

Нужны новые кодеки для поддержки новых атрибутов и совместимость с существующими данными.

import ru.m2.calypso.Decoder

object Person:
  val decodePersonV1: Decoder[Person] =
    Decoder.forProduct2("id", "name")((id, name) => Person(id, name, ""))
  val decodePersonV2: Decoder[Person] =
    Decoder.forProduct3("id", "name", "surname")(Person.apply)

  given Decoder[Person] = decodePersonV2.or(decodePersonV1)

Комбинатор «or» перебирает декодеры. Сначала пробуем актуальный, затем старый. Martin Kleppmann предлагает термин schema-on-read, и схема данных в примере выше — Decoder для Person. Сохранение обратной совместимости — необходимый аспект при работе с schema-on-read, в отличие от традиционных реляционных хранилищ, которые гарантируют соответствие данных схеме, делая проверки на этапе записи.

У такого подхода есть плюс: в хранилище могут находиться данные в разных форматах и явная миграция избыточна.

Есть и минус: если структура сильно меняется, может понадобиться дубль Scala модели. Иногда перезапись данных с применением актуального Encoder, если выполняются MQL запросы не только по ключу.

Тестирование

Для проверки корректности схемы данных используем round-trip приём. Идея в том, чтобы закодировать значение и прочитать обратно. Если оно не изменилось, работает как надо. Посмотрим на новом примере с UserId:

import cats.Eq
import ru.m2.calypso.{Decoder, Encoder}

opaque type UserId = Long
object UserId:
  def apply(value: Long): UserId = value

  given Encoder[UserId] = Encoder.given_Encoder_Long
  given Decoder[UserId] = Decoder.given_Decoder_Long
  given Eq[UserId]      = Eq.fromUniversalEquals

Тут используется opaque type, концепт из Scala 3. Это позволяет дать псевдоним примитивному типу, что часто используется при моделировании предметной области. Отказ от использования примитивных типов уменьшает количество ошибок по невнимательности.

def f(userId: Long, timestamp: Long) = ???
def g(userId: UserId, timestamp: Timestamp) = ???

В первом примере можно перепутать аргументы. Во втором проблема решена, к тому же, использование типов предметной области вроде UserId добавляет выразительности коду.

Хитрое описание кодеков — способ обойти ограничения имплементации opaque type. UserId и Long в объекте UserId идентичны, поэтому помогаем компилятору не уходить в бесконечный цикл при поиске given instances. Пробовали разные трюки, остановились на таком. Код тестирования кодеков:

import munit.DisciplineSuite
import org.scalacheck.{Arbitrary, Gen}
import ru.m2.calypso.testing.CodecTests

class CodecSuite extends DisciplineSuite:
  checkAll("Codec[UserId]", CodecTests[UserId].codec)

given Arbitrary[UserId] = Arbitrary(Gen.long.map(UserId.apply))

Тесты получаются ёмкими, логика round-trip находится в CodecTests из модуля calypso-testing. В примере используется property-based подход. Образцы данных со случайными значениями, включая краевые, генерируются при каждом запуске. Это быстрее, чем явно их перечислять. Тест выполняется несколько раз, проверяя несколько значений UserId. Arbitrary — абстракция ScalaCheck, говорит о существуетвовании генератора произвольных UserId, который сделан из генератора Long.

cats, refined

Идею выразительной модели можно развить, используя типы вроде NonEmptyList из cats, и NonEmptyString из библиотеки refined, которая выражает ограничения типа. Например непустые строки или только положительные числа. Это позволяет описать правила предметной области на уровне типов, а ещё получить бесплатную документацию, которую не нужно поддерживать. Любим refined в М2: экономит время и сокращает количество кода. Напишите в комментариях, как вы используете систему типов Scala для моделирования предметной области.

Заключение

Дизайн calypso основан на идеях Tony Morris и Mark Hibberd, которые положили начало argonaut и circe. Используем библиотеку три года в приложениях, которыми люди пользуются каждый день. Поддержите проект на GitHub, открывайте Issue, Pull Request или заходите посмотреть, как делать библиотеки на Scala 3. Исходный код calypso использует только необходимые конструкции языка, чтобы быть понятным.

Использование ADT и refined задаёт новый уровень имплементации Domain-Driven Design, а calypso упрощает работу с schema-on-read и позволяет понятно реализовать DDD Aggregate и Transactional outbox на MongoDB.

Попробуйте calypso в своём проекте на Scala 3 или Scala 2.

Почитать и посмотреть

calypso на GitHub

Исходный код с примерами использования библиотеки

Calypso: Scala-библиотека для удобной работы с BSON на YouTube

Обзор calypso с примерами на Scala 2

«Секреты супермоделей» на YouTube

Подробно про ADT и моделирование предметной области

Algebraic Data Types на YouTube

Погружение в алгебраические типы данных

DDD Aggregate на YouTube

Устройство и предназначение Aggregate

DDDamn good! на YouTube

Про Domain-Driven Design и Transactional outbox

© Habrahabr.ru