Сравнительный анализ Slick 2 и Slick 3

Comparative analysis of Slick 2 and Slick 3

Что такое Slick?

Это библиотека для Scala, которая позволяет создавать и выполнять запросы к базе данных. Для работы в связке Scala + Play Framework, Typesafe рекомендует использовать именно Slick. Идея в том, что с хранимыми данными разработчик работает так, словно он использует Scala-коллекции, что для любителей Scala является большим плюсом. Пишешь код на Scala, не отрываясь на написание SQL-кода.

Slick 3

В конце апреля 2015 года свет увидел Slick 3. Список особенностей и нововведений в новой версии можно просмотреть на официальном сайте. Сегодня речь пойдет о различиях, с которыми мы столкнулись в процессе перевода нашего проекта со Slick 2 на Slick 3.

Есть ли изменения в предоставляемом DSL для определения таблиц и связей между ними? Мы столкнулись лишь с тем, что теперь отсутствует необходимость отмечать поле такими ColumnOption как NotNull или Nullable, так как допустимость пустых значений определяется из типа поля Option или non-Option. В остальном же схема описания моделей таблиц осталась прежней. Пример описания таблицы с данными о пользователях:

case class User(id: Option[Long], login: String, name: String, email: String)

class UsersTable(tag: Tag) extends Table[User](tag, "users") {

  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)

  def login = column[String]("login")

  def ucLogin = index("uc_login", login, unique = true)

  def name = column[String]("name")

  def email = column[String]("email")

  def * = (id.?, login, name, email) <> (User.tupled, User.unapply)
}

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

val users = TableQuery[UsersTable]

Переходим к интересному. Начиная с версии 3, представлено совершенно новое Database I/O Action API для составления и выполнения действий с базой данных. Выполнение теперь асинхронно. Старое API (Invoker и Executor для блокирующего выполнения вызовов к базе данных), объявлено устаревшим. С версии 3.0 Slick становится известен a.k.a "Reactive Slick".

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

Данные DBIOAction могут быть скомбинированы из более мелких action, которые всегда выполняются строго последовательно и в одной сессии базы данных. В результате выполнения DBIOAction может быть получен fully materialized результат или streaming data.

Перейдём к примерам.

Запрос на выбор записей

Допустим, нам необходимо получить пользователя по логину. В Slick 2 этот запрос выглядит так:

def getByLogin(login: String): Option[User] = DB.withSession { implicit session =>
    users.filter(_.login === login).firstOption
 }

В Slick 2 запросы выполняются с использованием методов, описанных в трейте Invoker. Каждый метод, который выполняет запрос, принимает неявным параметром Session. В данном случае - это метод firstOption, но вы всегда можете указать его явно firstOption(session).

В Slick 3 этот запрос выглядит следующим образом:

def getByLogin(login: String): Future[Option[User]] = {
    val query = users.filter(_.login === login)
    val action = query.result.headOption
    db.run(action)
 }

Как видим в примере со Slick 3, метод разделён на 3 части (query, action, result):

  • собственно запрос Query, определяющий структуру запроса к базе данных;
  • получение Action. Данный Action может быть выполнен непосредственно, либо быть скомбинирован с другими Action;
  • выполнение Action и возврат Future с результатом запроса.

В новой версии Slick методы first и firstOption были переименованы в head и headOption соответственно, которые согласуются с именами в Scala Collections API.

Запрос на добавление записи

С точки зрения синтаксиса использования различия минимальны.

Slick 2:

  def save(user: User): Long = DB.withSession { implicit session =>
    (users returning users.map(_.id)) += user

Slick 3:

 def save(user: User): Future[Long] = {
    val action = (users returning users.map(_.id)) += user
    db.run(action)
  }

Запрос на добавление связанных записей

Предположим, нам необходимо добавить запись о пользователе и связать его с профилем на Facebook. Операция состоит из двух частей: сначала сохраняем пользователя, а потом - данные профиля Facebook с привязкой к созданному аккаунту.

Как это было реализовано в Slick2:

 def create(login: String, userName: String)(implicit session: Session): Long = { 
    val userId = userDao.create(None, login, userName)
    (facebookUsers returning facebookUsers.map(_.id)) += FacebookUser(None, userId, userName)
  }

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

Реализация в Slick3:

def create(id: String, userName: String): Future[Long] = {
    val action = (for {
      userId <- (users returning users.map(_.id)) += User(None, login, userName)
      facebookUserId <- (facebookUsers returning facebookUsers.map(_.id)) += FacebookUser(None, userId, userName)
    } yield facebookUserId).transactionally

    db.run(action)
  }

Здесь в запросе выполняется DBIOAction, состоящий из двух меньших actions. При выполнении запроса используется комбинатор transactionally, принудительно включающий использование транзакции. Это гарантирует, что внешний DBIOAction будет выполнен успешно или неудачно в атомарной форме. Т.е. если запись о пользователе была сохранена успешно, но при сохранении информации о профиле Facebook возникла ошибка, то обе операции не будут выполнены и внешний DBIOAction будет выполнен неудачно. При этом фактическая транзакция на уровне базы данных будет откачена назад и никакие данные не сохранятся.

Запрос на выбор уникальных данных

Допустим, нам необходимо выбрать имена пользователей без повторений. Первое, что приходит в голову — скорее всего в Slick должен быть метод distinct, который компилируется в оператор DISTINCT запроса SQL. Но в Slick 2 этого метода нет, что приводит к написанию такого кода:

 def listUserName: Seq[String] = DB.withSession { implicit session =>
    users.groupBy(_.name).map(_._1).list
  }

В результате такого запроса выбираются (select) все данные из таблицы, а distinct выполняется уже на стороне клиентского кода, операцией map.

В Slick 3.1 были введены методы distinct и dictinctOn и реализация той же задачи выглядит следующим образом:

 def listUserName: Future[Seq[String]] = {
    val query = users.map(_.name ).distinct
    val action = query.result
    db.run(action)
  }

При использовании distinct.length и distinctOn(<field>).length для получения количества уникальных записей для H2 базы обнаружилась ошибка. По данному вопросу был создан bugreport на GitHub Slick.

Среди нововведений в Slick 3 можно отметить также замену старых методов leftJoin, rightJoin, outerJoin, innerJoin на joinLeft, joinRight, joinFull, join соответственно. Старые операторы join не могли корректно обрабатывать null значения, требуя сложных маппингов в коде. С новыми операторами эта проблема не актуальна, т.к. они мапят одностороннее (left и right outer join) или двустороннее (full outer join) объединение в Option. Метод join используется теперь исключительно для inner join операции.

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

def listByOwner(ownerId: Long): Future[Seq[(Computer, Item)]] = {
    val query = for {
      i <- Items if i.itemHolder_fk === ownerId
      ci <- CompositeItems if ci.item_fk === i.id
      c <- Computers if c.id === ci.itemHolder_fk
    } yield (c, i)
    val action = query.result
    db.run(action)
  }

Также, по словам разработчиков, в Slick версии 3.1 был реализован новый компилятор запросов. Его главная цель заключается в максимальном уходе от использования подзапросов, везде где это возможно. Данное нововведение призвано оптимизировать выполнение запросов к СУБД.

Генерируемые запросы всегда можно просмотреть в консоли, если включить такую опцию в конфигурации:

logger.scala.slick.jdbc.JdbcBackend.statement=DEBUG

Сгенерированные запросы хоть и выглядят иногда страшно (является общей проблемой всех мапперов), тем не менее без особых сложностей поддаются разбору и анализу. Выполняются они довольно быстро — в зависимости от сложности запроса. Во время выполнения запроса неплохо бы сразу писать в log.

Если не устраивает скорость работы конкретного запроса, всегда можно написать их вручную в Slick:

def insertUser(user: User): DBIO[Long] = 
 sqlu"insert into users values(${user.id.get}, ${user.login}, ${user.name}, ${user.email})"

Заключение

В целом, Slick предоставляет разработчику массу полезных и удобных штук для написания sql запросов на Scala. Есть сформировавшееся дружелюбное сообщество и удобная документация, что, несомненно, добавляет плюсов. К тому же, в рассмотрении связки Scala + Play Framework использование библиотеки Slick вполне оправдано: она легко вписывается в структуру приложения, позволяет не размазывать логику работы с базой на другие слои приложения и позволяет мыслить о хранимых данных в терминах скаловских коллекций.

Связаться