Slick — это не только фамилия одной из величайших солисток всех времён, но и название популярного Scala-фреймворка для работы с базами данных. Этот фреймворк исповедует «функционально-реляционный маппинг», реализует реактивные паттерны и обладает официальной поддержкой Lightbend. Однако отзывы разработчиков о нём, прямо скажем, смешанные — многие считают его неоправданно сложным, и это отчасти обоснованно. В этой статье я поделюсь своими впечатлениями о том, на что стоит обратить внимание при его использовании начинающему Scala-разработчику, чтобы в процессе написания запросов случайно не открыть портал в ад.

Фреймворк Slick, как это часто случается в мире Scala, сравнительно недавно пережил существенный редизайн — версия 3 была заточена под реактивность и сильно поменяла API, сделав его ещё более функциональным, чем прежде — и теперь большое количество статей и ответов на StackOverflow, рассчитанных на версию 2, стало неактуальным. Документация на фреймворк достаточно лаконичная и представляет собой скорее список примеров; концептуальные вещи (в частности, активное использование монад) в ней объясняются достаточно поверхностно. Предполагается, что многие аспекты функционального программирования на Scala и продвинутые фичи языка разработчику уже хорошо известны.

Результатом стали подобные вопросы на StackOverflow, за которые мне теперь немного стыдно: там я бился над некомпилирующимся кодом, потому что не понимал архитектуры фреймворка и тех монадических паттернов, которые в нём заложены. Об этих паттернах и их применении в Slick мне и хотелось бы рассказать в этой статье: возможно, кому-то они сберегут многие часы мучений в попытках написать что-то более сложное, чем простейший запрос.

Монады и построитель запросов


Одним из важных компонентов любой типобезопасной библиотеки для работы с базами данных является построитель запросов, который позволяет из типизированного кода на языке программирования сформировать нетипизированную строку на языке SQL. Вот пример построения запроса с использованием Slick, взятый из документации, из раздела про «монадические джойны»:

val monadicInnerJoin = for {
  c <- coffees
  s <- suppliers if c.supID === s.id
} yield (c.name, s.name)
// compiles to SQL:
//   select x2."COF_NAME", x3."SUP_NAME"
//     from "COFFEES" x2, "SUPPLIERS" x3
//     where x2."SUP_ID" = x3."SUP_ID"

Признаюсь, для новичка в Scala это выглядело довольно странно. Если долго медитировать на этот код, то можно заметить соответствия между этой хитрой синтаксической конструкцией и приведённым ниже SQL-запросом, в который она трансформируется. Вроде что-то становится понятно: справа от стрелочек таблицы, слева — алиасы, после if — условие, в yield — поля, выбранные для проекции. Выглядит как SQL-запрос, вывернутый наизнанку. Но почему построитель реализован именно так? При чём тут вообще for? Разве здесь есть какая-то итерация по содержимому таблиц? Ведь в этот момент мы ещё не исполняем запрос, а только строим его.

Без понимания того, как эта конструкция работает, конечно, можно привыкнуть к такому синтаксису и клепать подобные запросы по аналогии. Но при попытке написать что-то более сложное мы рискуем натолкнуться на стену непонимания и потратить кучу времени, проклиная компилятор на чём свет стоит, как это было и со мной в своё время. Чтобы понять, что скрыто за этой магией, и почему построитель запросов реализован именно так, придётся сделать небольшое лирическое отступление про for-включения и монады.

Монады


Что характерно, в книге Мартина Одерски «Programming in Scala» слово «монада» употребляется в одном-единственном месте — как раз в самом конце главы про for-включение, как бы между делом. Большая часть этой главы — описание того, как можно пользоваться синтаксической конструкцией for для итерации по коллекции, нескольким коллекциям, для фильтрации. И лишь в самом конце говорится о том, что есть такая штука как «монада», с которой тоже удобно работать с помощью for-включения, но подробного объяснения того, что это и зачем, не даётся. Между тем, использование for-включения для оперирования монадами является весьма эффектным и одновременно непонятным синтаксическим конструктом для взгляда новичка.

Не буду приводить здесь полноценный туториал по монадам, тем более, что их существует огромное количество, и их авторы объяснят тему лучше меня. Могу порекомендовать неплохое видео, объясняющее эту концепцию как раз на языке Scala. Для целей данной статьи будем считать, что монада — это параметризованный тип, нечто вроде функциональной обёртки, имеющей две основные операции с определёнными свойствами:

  • операция return — заворачивает (или «поднимает», «lifts») значение в некоторый контекст, представляемый этим типом;
  • операция bind — выполняет некоторую трансформирующую функцию над значением в этом контексте.

С точки зрения авторов языка Scala, в ООП операция return по сути реализуется конструктором экземпляра, принимающим значение (конструктор как раз позволяет «завернуть» переданное значение в объект), а операции bind соответствует метод flatMap. На самом деле монады в Scala — это не совсем монады в понимании классических функциональных языков типа Haskell, а, скорее, «монады по-одерски». И хотя в классических книгах по Scala избегают термина «монада», и даже в стандартной библиотеке вы с трудом найдёте упоминание этого слова, разработчики Slick не стесняются использовать его в документации и коде, полагая, что читателю уже известно, что это такое.

for-включения


На самом деле for-comprehension— это, конечно, не цикл, и ключевое слово for может поначалу сбить с толку. Кстати, я пытался разобраться, как же переводится на русский язык «for-comprehension» — варианты есть, а общепринятого нет. Некоторую полемику на эту тему можно почитать тут, тут и тут.

Я остановился на термине «for-включение», потому что оно обычно описывает включение элементов в выходное множество по определённым правилам. Хотя, если рассматривать for-comprehension как monadic comprehension, то такой перевод становится не столь очевиден. Ввиду небольшого количества литературы по ФП и теории категорий на русском языке, термин на текущий момент не устоялся.

Ирония в том, что, по мнению авторов «Programming in Scala», одна из наилучших областей применения for-включения — это комбинаторные головоломки:



Всё это замечательно и полезно, но как насчёт реальных кейсов применения?

Оказывается, мощь паттерна монады, особенно в сочетании с for-включением, заключается в том, что он позволяет выполнять высокоуровневую композицию отдельных действий в достаточно сложном контексте, иначе говоря, строить из маленьких кубиков (операций bind/flatMap) более сложные конструкции. Синтаксис for-включения даёт возможность выстраивать в последовательную цепочку такие действия, которые на самом деле нельзя выполнить последовательно. Обычно сложность их выполнения заключается в наличии какого-то сложного контекста. Например, одна из часто используемых монад в Scala — это List:

  // списки
  val people = List("Воронин", "Гейгер", "Убуката")
  val positions = List("мусорщик", "следователь", "редактор")

  // декартово произведение списков с использованием for-включения:
  val peoplePositions = for {
    person <- people
    position <- positions
  } yield s"$person, $position"

С помощью for-включения над отдельными экземплярами монады List можно выполнять декартово произведение, т.е. композицию списков. Монада при этом скрывает от нас сложность контекста (итерацию по множеству значений).

На деле же for-включение — это просто синтаксический сахар с строго определёнными правилами преобразования. В частности, все стрелочки, кроме последней, превращаются в вызовы flatMap у идентификаторов справа, а последняя стрелочка — в вызов map. Идентификаторы слева при этом трансформируются в аргументы функций для методов flatMap, а содержимое yield — это то, что возвращается из последней функции.

Поэтому можно записать то же самое и с использованием прямого вызова методов flatMap и map, но выглядит это несколько менее наглядно, особенно если размеры и вложенность этих конструкций будут в несколько раз больше:

  // декартово произведение списков прямым вызовом flatMap и map:
  val peoplePositions2 = people.flatMap {person =>
    positions.map { position =>
      s"$person, $position"
    }
  }

Аналогично, монадическая реализация Future позволяет выстраивать действия над значениями в цепочки, скрывая от нас сложность контекста (асинхронность выполнения действий и тот факт, что вычисление значений отложено):

  // первая футура формирует и возвращает строку
  def getFuture1 = Future {
    "1337"
  }

  // вторая футура из строки делает число
  def getFuture2(string: String) = Future {
    string.toInt
  }

  // комбинированная футура, созданная с использованием for-включения
  val composedFuture = for {
    result1 <- getFuture1
    result2 <- getFuture2(result1)
  } yield result2

Если нам нужно передать в футуру параметр, это можно сделать с помощью замыкания, как показано выше: заворачиваем футуру в функцию с аргументом и используем аргумент внутри футуры. Благодаря этому можно будет связывать отдельные футуры друг с другом. Соответственно, «десахарированный» код будет выглядеть как множество вложенных вызовов flatMap, завершающихся вызовом map:

  // комбинированная футура, созданная с использованием flatMap и map
  val composedFuture2 = getFuture1.flatMap { result1 =>
    getFuture2(result1).map { result2 =>
      result2
    }
  }

for-включение, монады и построение запросов


Итак, операция flatMap является средством композиции монадических объектов, или построения сложных структур из простых кирпичиков. Что же касается языка SQL, то там тоже есть средство для композиции — это предложение JOIN. Если теперь вернуться к for-включению и его использованию для построения запросов, то становится очевидным, что flatMap и JOIN имеют много общего, и отображение одного на другое вполне осмысленно и разумно. Посмотрим ещё раз на пример построения запроса с внутренним джойном, который приводился в начале статьи. Теперь идея, заложенная в такой синтаксис, должна стать несколько понятнее:

val monadicInnerJoin = for {
  c <- coffees
  s <- suppliers if c.supID === s.id
} yield (c.name, s.name)

Но вот одна из шероховатостей такого подхода: в SQL есть ещё левые и правые джойны, и эти особенности на монадическое включение ложатся не очень хорошо: какие-либо синтаксические средства, позволяющие выразить подобные типы джойнов, в for-включении отсутствуют, и для левых и правых джойнов предлагается пользоваться альтернативным синтаксисом — аппликативными джойнами. В этом, кстати, заключается большая и серьёзная проблема многих подходов в Scala, когда сложные концепции моделируются средствами языка — любые средства языка имеют ограничения, в которые эта концепция рано или поздно упирается. Но об этой особенности Scala — как-нибудь в другой раз.

Мало того, в Slick монады используются аж на двух уровнях — в конструкторе запросов (как отдельные компоненты запроса, которые можно объединять) и при композиции действий с базой данных (их можно объединять в комплексные действия, которые затем завернуть в транзакцию). Честно говоря, это поначалу доставляло мне немало проблем, потому что с помощью for-включения можно объединять как монадические запросы, так и монадические действия, и я долго «намётывал глаз», пока не научился в коде отличать одну монаду от другой. Монадические действия — это как раз тема следующей главы…

Монады и композиция действий с базой данных


Довольно теории, приступим к хардкору. Попробуем написать на Slick что-нибудь более полезное, чем простой запрос. Начнём опять-таки с запроса с внутренним джойном:

  val monadicInnerJoin = for {
    ph <- phones
    pe <- persons if ph.personId === pe.id
  } yield (pe.name, ph.number)

Из атрибута result получившегося значения можно извлечь объект типа DBIOAction — ещё одну монаду, но уже предназначенную для композиции отдельных действий, выполняемых с базами данных.

// делаем из запроса DBIO-действие
val action1 = monadicInnerJoin.result

Любое действие, в том числе и композитное, можно выполнить в рамках транзакции:

val transactionalAction1 = action1.transactionally

Но как быть, если нам нужно завернуть в транзакцию несколько отдельных действий, некоторые из которых вообще не связаны с базой данных? В этом нам поможет метод DBIO.successful:

// делаем DBIO-действие из какой-то произвольной функции
val action2 = DBIO.successful {
  println("Делаем что-то между запросами в транзакции...")
}

Кстати, если завернуть создание action в функцию с аргументом, можно, как и в случае с футурами выше, параметризовать это действие, но мы не будем этого делать. Вместо этого просто добавим в микс ещё парочку DBIO-действий по вставке данных в таблицы и скомпонуем всё это в композитное действие с помощью for-включения:

// ещё парочка DBIO-действий...
val action3 = persons += (1, "Grace")
val action4 = phones += (1, 1, "+1 (800) FUC-KYOU")

// делаем композитное действие из всех четырёх действий
val compositeAction = for {
  result <- action1
  _ <- action2
  personCount <- action3
  phoneCount <- action4
} yield personCount + phoneCount

Обратите внимание — если результат действия нас не интересует (оно выполняется ради побочного эффекта), то слева от стрелочки можно поставить символ подчёркивания. Теперь завернём композитное действие в транзакцию и создадим на основе него футуру:

// заворачиваем композитное действие в транзакцию и делаем из него футуру
val actionFuture = db.run(compositeAction.transactionally)

Ну и наконец скомпонуем эту футуру с другой футурой с помощью всемогущего for и дождёмся её выполнения с помощью Await.result (кстати, такой подход годится только для тестов, не повторяйте это в продакшне — используйте сквозную асинхронность):

val databaseFuture = for {
  i <- actionFuture
  _ <- Future {
    println(s"Вставлено записей: $i")
  }
} yield ()

Await.result(databaseFuture, 1 second)

Вот так всё просто.

Заключение


Монады и синтаксис for-включения часто используются в различных Scala-библиотеках для построения больших конструкций из маленьких кирпичиков. В одном только Slick их можно использовать как минимум в трёх разных местах — для сборки таблиц в запрос, сборки действий в одно большое действие и сборки футур в одну большую футуру. Пониманию философии Slick и облегчению работы с ним очень способствует понимание того, как работает for-включение, что такое монады, и как for-включение облегчает работу с монадами.

Надеюсь, эта статья поможет новичкам в Scala и Slick не отчаяться и обуздать всю мощь этого фреймворка. Исходный код к статье доступен на GitHub.

Поделиться с друзьями
-->

Комментарии (16)


  1. solver
    14.07.2016 19:13
    +2

    Спасибо за материал. Очень его не хватает по Slick.

    >и дождёмся её выполнения с помощью Await.result(databaseFuture, 1 second)

    Пометьте пожалуйста, что это только для примера. И в реальном проекте это плохая практика.
    А то мне приходилось работать с проектом, где из асинхронного Slick, делали синхронный при помощи Await.result.
    Люди на столько привыкли к хиберу, что не могли понять, как можно с БД работать асинхронно.


    1. forketyfork
      14.07.2016 21:39
      +1

      Спасибо! Отметил.
      Действительно, асинхронные интерфейсы требуют тектонических сдвигов в голове.


  1. napa3um
    14.07.2016 20:08
    +2

    > фамилия одной из величайших солисток всех времён
    Не выдумывайте.


  1. Kane
    14.07.2016 22:56
    +1

    А по-моему, это вполне естественно использовать for для итерации по строчкам таблиц реляционных баз данных. Тем более есть отличная аналогия со списками.


    1. forketyfork
      15.07.2016 06:05

      В том-то и дело, что for используется не для итерации по строчкам базы данных, а для композиции метаданных, т.е. классов, описывающих таблицы. На момент формирования запроса мы ещё не обращаемся к базе, а только лишь формируем SQL-строку.


      1. Kane
        15.07.2016 08:45
        +1

        Так нет же никакой разницы, выполнится запрос сразу или чуть позже. Конечно, итерация происходит не на Scala коде, а в базе.


        1. forketyfork
          15.07.2016 08:58

          Да, в этом мощь абстракции Slick, можно якобы вообще забыть про SQL и представлять, будто этот код выполняется прямо на базе данных. Но это ровно до первой ошибки :) Причём в случае со Slick это будет ошибка компиляции, в содержание которой очень сложно вникнуть, если не понимаешь того, как именно этот код преобразуется в SQL.


          1. solver
            15.07.2016 11:13
            +2

            Справедливости ради «можно якобы вообще забыть про SQL» это девиз всех средств абстрагирования от БД.
            Будь то ОРМ или FRM или еще что.
            Но в Slick, на самом деле не сложно разобраться.
            Главное не делать сразу большие глаза и не кричать с порога «сложнааааааа», как это любят делать многие.
            Поняв основные принципы проблем потом практически не возникает.


  1. zim32
    14.07.2016 23:21
    +6

    Как написать SQL-запрос на Slick и не быть уволеным никогда из этой фирмы больше


    1. forketyfork
      15.07.2016 06:08

      Ну зря вы так :) Для того я и написал эту статью, чтобы поделиться опытом и помочь другим разобраться.


  1. rail-ka
    15.07.2016 13:28
    +1

    У вас (компании) классные иллюстрации! Думал сайт оформлен подобным образом, оказалось нет.
    Тоже долго разбирался со Slick, но когда разобрался, оказалось так быстро и легко работать с ним с базами данных.


    1. forketyfork
      15.07.2016 13:35
      +1

      Спасибо! Иллюстратор — Марья Габышева.
      А сайт в настоящий момент переживает редизайн :)


  1. ShadowsMind
    15.07.2016 15:41

    Спасибо за статью. Тоже в свое время похожим образом проходило мое знакомство со Slick.
    Не так давно наткнулся на альтернативу — Quill (getquill.io). По ощущением от знакомства с технологией в рамках одного вечера — концепция похожая, но проще. Особенно понравилось то, что при компиляции получаем мессейджы о том, какой запрос у нас в итоге сбилдился. Думаю, для простых проектов подойдет лучше чем Slick.


    1. forketyfork
      15.07.2016 15:47

      Интересно, посмотрю на неё. Спасибо!


  1. saksmt
    19.07.2016 14:54

    Вот мне только одно в слике не понятно: зачем он нужен, если он не умеет отображать х-to-many на case-классы? (я имею в виду случай, когда полем case-класса является список)


    Или может это я что-то не так делаю?


    1. forketyfork
      21.07.2016 14:51

      Да, в Slick нельзя так сделать, потому что Slick — это не ORM. Он значительно ближе к реляционной модели, чем ORM. Slick не отображает внешние ключи в ассоциации, для него один кейс-класс — это одна таблица.

      С маппингом ассоциаций сразу возникает целая вереница типичных проблем ORM, с которыми они справляются с весьма неоднозначными результатами, типа навигации по графу объектов, «N+1 select problem», eager/lazy fetches и т.д. В отличие от ORM, Slick даёт больше прозрачности в том, когда и какие запросы он выполняет.

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