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 { string =>
getFuture2(string).map { int =>
int
}
}
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.
Автор: