Знакомьтесь — ORM для Scala под названием Slick

24 ноября 2014

Помните, как мы учились писать сайтики при помощи Play Framework? Вы, конечно же, обратили внимание, что вопрос работы с какой-либо СУБД был оставлен в стороне. Пришло время исправить эту вопиющую несправедливость!

Чего-чего, а ORM в мире Scala хоть отбавляй. Притом, ситуация с этими ORM быстро меняется. В книжках по Play, вышедших всего лишь год или около того назад, рекомендуется использовать либо Anorm, либо Squeryl. При этом Anorm — это и не ORM вовсе, а просто такая обертка для написания запросов на обычном SQL. Вот Squeryl является полноценным ORM’ом. Еще есть какие-то Ebean и SORM. В общем, любой найдет себе ORM по вкусу. Однако на момент написания этих строк, конкретно для Scala и Play Framework, насколько я могу судить, Typesafe рекомендует использовать Slick.

Как я уже отмечал, в мире Scala вещи очень быстро меняются. Когда я изучал Slick, то натыкался на десятки устаревших туториалов, примеры из которых либо не компилировались, либо компилировались, но не работали. Также к моменту, когда вы будете читать эту заметку, на смену Slick может прийти более совершенный ORM. Поэтому заклинаю вас, прежде, чем следовать чему-то, описанному в этой заметке, внимательно изучите сайт Typesafe, а также спросите у знакомых программистов на Scala или на StackOverflow, чем в это время суток модно ходить в базы данных при программировании на Scala + Play.

Итак, чтобы прикрутить Slick к своему проекту, открываем build.sbt и прописываем в libraryDependencies следующие зависимости:

"joda-time" % "joda-time" % "2.4",
"org.joda" % "joda-convert" % "1.6",
"mysql" % "mysql-connector-java" % "5.1.32",
"com.typesafe.slick" % "slick_2.11" % "2.1.0",
"com.github.tototoshi" %% "slick-joda-mapper" % "1.2.0",
"com.typesafe.play" %% "play-slick" % "0.8.0",

Здесь и далее предполагается, что в качестве СУБД вы выбрали либо MySQL, либо MariaDB. Если это не так, соответствующие коннекторы и тп нужно модифицировать очевидным способом. Примите во внимание, что к моменту, когда вы будете читать эту заметку, могут появиться более свежие версии библиотек.

Далее прописываем в application.conf:

slick.default="models.*"
logger.scala.slick.jdbc.JdbcBackend.statement=DEBUG
db.default.driver=com.mysql.jdbc.Driver
db.default.url="jdbc:mysql://localhost/dbname?user=aaa&password=bbb"

Убедитесь, что после создания базы данных dbname вы не забыли сказать:

ALTER DATABASE ... DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;

Рассмотрим, как при помощи Slick определить схему базы данных для простенького форума. Создадим файл app/models/Tables.scala и напишем в нем:

package models

import org.joda.time.DateTime
import play.api.db.slick.Config.driver.simple._
import com.github.tototoshi.slick.JdbcJodaSupport._

object T {
  val users = TableQuery[UsersTable]
  val topics = TableQuery[TopicsTable]
  val comments = TableQuery[CommentsTable]
}

case class User(id: Long = 0, login: String, email: String,
                password: String, salt: String, created: DateTime)
case class Topic(id: Long = 0, author: Long, title: String,
                 text: String, created: DateTime)
case class Comment(id: Long = 0, theme: Long, author: Long,
                   text: String, created: DateTime)

class UsersTable(tag: Tag) extends Table[User](tag, "users") {
  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def login = column[String]("login", O.NotNull)
  def email = column[String]("email", O.NotNull)
  def password = column[String]("password", O.NotNull)
  def salt = column[String]("salt", O.NotNull)
  def created = column[DateTime]("created", O.NotNull,
                                            O.DBType("datetime"))

  def * = (id, login, email, password, salt, created) <>
          (User.tupled, User.unapply)

  def login_idx = index("users_login_idx", login, unique = true)
  def email_idx = index("users_email_idx", email, unique = true)
}

class TopicsTable(tag: Tag) extends Table[Topic](tag, "topics") {
  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def author = column[Long]("author", O.NotNull)
  def title = column[String]("title", O.NotNull)
  def text = column[String]("text", O.NotNull, O.DBType("text"))
  def created = column[DateTime]("created", O.NotNull,
                                 O.DBType("datetime"))

  def * = (id, author, title, text, created) <>
          (Topic.tupled, Topic.unapply)

  def author_fk = foreignKey("topics_author_fk", author, T.users)(
                               _.id,
                               onUpdate=ForeignKeyAction.Restrict,
                               onDelete=ForeignKeyAction.Cascade)
  def created_idx = index("topics_created_idx", created)
}

class CommentsTable(tag: Tag) extends Table[Comment](tag, "comments") {
  def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def topic = column[Long]("topic", O.NotNull)
  def author = column[Long]("author")
  def text = column[String]("text", O.NotNull, O.DBType("text"))
  def created = column[DateTime]("created", O.NotNull,
                                 O.DBType("datetime"))

  def * = (id, topic, author, text, created) <>
          (Comment.tupled, Comment.unapply)

  def author_fk = foreignKey("comments_author_fk", author, T.users)(
                               _.id,
                               onUpdate=ForeignKeyAction.Restrict,
                               onDelete=ForeignKeyAction.Cascade)
  def theme_fk = foreignKey("comments_topic_fk", topic, T.topics)(
                               _.id,
                               onUpdate=ForeignKeyAction.Restrict,
                               onDelete=ForeignKeyAction.Cascade)

  def created_idx = index("comments_topic_created_idx", (topic,
                                                         created))
}

Как видите, Slick предоставляет мощный DSL для определения таблиц и связей между ними. Все, что можно сделать с помощью CREATE TABLE ... можно сделать и здесь. При первом запуске приложения Slick автоматически создаст все таблицы, никакой предварительной инициализации не требуется.

Чтобы делать запросы к БД, в контроллере прописываем следующие импорты:

import models._
import play.api.db.slick._
import org.joda.time._
import scala.slick.driver.MySQLDriver.simple._
import com.github.tototoshi.slick.MySQLJodaSupport._

Вместо какого-нибудь:

def page(page: Long) = Action {
// ...
}

… пишем:

def page(page: Long) = DBAction { implicit rs =>
// ...
}

Теперь рассмотрим некоторые примеры запросов.

Получение числа топиков в БД:

// SELECT count(*) ...
val res : Int = T.topics.length.run

Получение списка топиков на заданной странице с информацией об их авторах:

// SELECT ... LEFT JOIN ... ORDER BY ... LIMIT ...
val topics = T.topics.sortBy(_.created.desc)
                     .drop((page - 1)*topicsPerPage)
                     .take(topicsPerPage)
val topicsSeq = (topics leftJoin T.users on (_.author === _.id)).map {
  case (t, u) => (t.id, t.title, u.login, t.created)
}.list

Получение информации об одном топике по topicId:

// SELECT ... LEFT JOIN ... WHERE ...
(T.topics.filter(_.id === topicId) leftJoin T.users on
  (_.author === _.id)).map {
    case (t, u) => (t.id, t.title, u.login, t.text, t.created)
}.run.headOption

Выборка комментариев к соответствующему топику:

// SELECT ... LEFT JOIN ... WHERE ...
val commTmp = T.comments.filter(_.topic === topicId)
                        .sortBy(_.created.asc)
val comments = (commTmp leftJoin T.users on (_.author === _.id)).map {
                 case (c, u) => (c.id, c.text, u.login, c.created)
               }.list

Наконец, для сильных духом, пример с UNION и GROUP BY:

def sitemapXml() = DBAction { implicit rs =>
  val indexUpdateTime = T.topics.map(_.created)
                                .max.run.getOrElse(new DateTime())

  val topics1 = T.topics.map { r => (r.id, r.created) }
  val topics2 = T.comments.map { r => (r.topic, r.created) }
  val allTopics = topics1 union topics2
  val groupedTopics = allTopics.groupBy(t => t._1).map {
    case (topicId, group) => (topicId, group.map(_._2).max)
  }
  // где-то при группировке Slick превращает created из DateTime
  // в Option[DateTime], поэтому здесь сделан map
  val sortedPagesInfo = groupedTopics.sortBy(_._2.desc).take(50)
                                     .list.map {
    case (topicId, optCreated) => (topicId, optCreated.get)
  }

  Ok(views.xml.sitemap(indexUpdateTime, sortedPagesInfo))
}

Хорошо, мы убедились в выразительной мощи Slick. Но что же на счет эффективности? С одной стороны, генерируемые запросы получаются не очень похожими на те, что были бы написаны руками. Все запросы будут выводиться в консоль во время работы приложения. Для этого ранее мы и прописали в конфиг строчку:

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

Запросы, несмотря на то, что выглядят они страшно, выполняются вполне себе быстро. Желательно, конечно, чтобы приложение писало, скажем, в Graphite, запросы для генерации каких страниц сколько времени выполняются. Ну или хотя бы чтобы в СУБД был включен лог медленных запросов.

Больше примеров и информации по Slick вы можете найти в его официальной документации.

ORM вообще, и Slick в частности, дают программисту кучу удобняшек, в том числе строгую статическую типизацию, по сути — кучу готовых функций типа createUser, updateComment и так далее, а также автоматические миграции схемы БД. Платить за это приходится тем, что некоторые запросы могут выполняться менее эффективно, чем если бы мы написали их руками. Но специально для этого случая в Slick и предусмотрена возможность писать запросы вручную. Как по мне, даже при самом худшем раскладе, как минимум, мы абсолютно ничего не теряем. А значит нет никаких причин не использовать ORM в своих проектах.

Вы согласны?

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.