Pull to refresh

SQL доступ к РСУБД посредством ScalikeJDBC

Reading time 7 min
Views 6.9K
imageЕсть библиотека, облегчающая использование SQL в Scala-программах, упоминания о которой на хабре я не нашел. Эту несправедливость я и хотел бы исправить. Речь пойдет о ScalikeJDBC.

Главным конкурентом SkalikeJDBC является Anorm – библиотека от Play, решающая ровно те же задачи удобного общения с РСУБД посредством чистого (без примесей ORM) SQL. Однако Anorm глубоко погряз в Play, и использование его в проектах не связанных с Play может быть затруднительным. Ждать, когда оно окажется затруднительным и для меня, я не стал. Услышав о SkalikeJDBC я, практически сразу, решил его опробовать. Результатами этой аппробации в виде небольшого демо приложения я и буду делиться в этой статье, чуть ниже.

Перед тем, как перейти к примеру использования библиотеки, стоит заметить, что поддерживается и протестированна работа со следующими СУБД:
  • PostgreSQL
  • MySQL
  • H2 Database Engine
  • HSQLDB

А оставшиеся (Oracle, MS SQL Server, DB2, Informix, SQLite, тыщи их) также должны работать, ибо все общение c СУБД идет через стандартный JDBC. Однако их тестирование не производитстя, что может навлечь уныние на корпоративного заказчика.

Пример приложения


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

Далее я приведу пример простого приложения, использующего SkalikeJDBC для доступа к Postgresql. Покажу, как можно его сконфигурировать с помощью Typesafe Config, создать таблицу в БД, делать CRUD-запросы к этой таблице и преобразовывать результаты Read-запросов в Scala-объекты. Я буду намеренно упускать многие варианты конфигурирования (без применения Typesafe Config) и применения библиотеки, чтобы остаться кратким и обеспечить быстрый старт. Полное описание возможностей доступно в удобной и достаточно короткой документации, а так же в Wiki на github.

Приложение будет использовать SBT для сборки и управления зависимостями, так что создаем в корне пустого проекта файл build.sbt следующего содержания:

name := "scalike-demo"

version := "0.0"

scalaVersion := "2.11.6"

val scalikejdbcV = "2.2.5"

libraryDependencies ++= Seq(
  "org.postgresql"    %   "postgresql"          % "9.4-1201-jdbc41",
  "org.scalikejdbc"   %%  "scalikejdbc"         % scalikejdbcV,
  "org.scalikejdbc"   %%  "scalikejdbc-config"  % scalikejdbcV
)

В нем объявлены следующие зависимости:
  • postgresql – jdbc драйвер postgres
  • scalikejdbc – собственно библиотека SkalikeJDBC
  • scalikejdbc-config – модуль поддержки Typesafe Config для конфигурирования соединения с СУБД

В качестве СУБД будем использовать локальную Postgresql на стандартном (5432) порту. В ней уже имеется пользователь pguser с паролем securepassword и полным доступом к базе данных demo_db.

В этом случае создаем файл конфигурации src/main/resources/application.conf следующего содержания:

db {
  demo_db {
    driver = org.postgresql.Driver
    url = "jdbc:postgresql://localhost:5432/demo_db"
    user = pguser
    password = securepassword

    poolInitialSize=10
    poolMaxSize=20
    connectionTimeoutMillis=1000
    poolValidationQuery="select 1 as one"
    poolFactoryName="commons-dbcp"
  }
}

Мы могли бы ограничиться первыми четырьмя параметрами, тогда применились бы настройки пула соединений по-умолчанию.

Далее создадим пакет demo в папке src/main/scala, куда и поместим весь scala-код.

DemoApp.scala

Начнем с главного запускаемого объекта:

package demo
import scalikejdbc.config.DBs
object DemoApp extends App {
  DBs.setup('demo_db)
}

Единственная строчка внутри объекта – указание считать настройки доступа к базе demo_db из файлов конфигурации. Объект DBs будет искать все подходящие ключи конфигурации ( driver, url, user, password, ...) в узле db.demo_db во всех файлах конфигурации прочитанных Typesafe Config. Typesafe Config, по конвенции, автоматически читает application.conf находящийся в classpath приложения.

Результатом будет сконфигурированный ConnectionPool к БД.

DbConnected.scala

Далее создадим трейт, в котором инкапсулируем получение коннекта к БД из пула

package demo
import java.sql.Connection
import scalikejdbc.{ConnectionPool, DB, DBSession}
import scalikejdbc._

trait DbConnected {
  def connectionFromPool : Connection = ConnectionPool.borrow('demo_db) // (1)
  def dbFromPool : DB = DB(connectionFromPool)				// (2)				
  def insideLocalTx[A](sqlRequest: DBSession => A): A = {		// (3)
    using(dbFromPool) { db =>
      db localTx { session =>
        sqlRequest(session)
      }
    }
  }

  def insideReadOnly[A](sqlRequest: DBSession => A): A = {		// (4)
    using(dbFromPool) { db =>
      db readOnly { session =>
        sqlRequest(session)
      }
    }
  }
}

В (1) мы получаем соединение(java.sql.Connection) из созданного и сконфигурированного в прошлом шаге пула.
В (2) мы оборачиваем полученное соединение в удобный для scalikeJDBC объект доступа к БД (Basic Database Accessor).
В (3) и (4) мы создаем удобные нам обертки для выполнения SQL-запросов. (3) – для запросов на изменение, (4) – для запросов на чтение. Можно было бы обойтись и без них, но тогда нам везде приходилось бы писать:

def delete(userId: Long) = {
  using(dbFromPool) { db =>
    db localTx { implicit session =>
      sql"DELETE FROM t_users WHERE id = ${userId}".execute().apply()
    }
  }
}

вместо:

def delete(userId: Long) = {
  insideLocalTx { implicit session =>
    sql"DELETE FROM t_users WHERE id = ${userId}".execute().apply()
  }
}

, a DRY еще никто не отменял.

Разберемся подробнее, что же происходит в пунктах (3) и (4):

using(dbFromPool)- позволяет обернуть открытие и закрытие коннекта к БД в один запрос. Без этого потребовалось бы открывать (val db = ThreadLocalDB.create(connectionFromPool)) и не забывать закрывать (db.close()) соединения самостоятельно.

db.localTx – создает блокирующую транзакцию, внутри которой выполняеются запросы. Если внутри блока произойдет исключение транзакция откатится. Подробнее.

db.readOnly – исполняет запросы в режиме чтения. Подробнее.

Данный трейт мы можем использовать в наших DAO-классах, коих в нашем учебном приложении будет ровно 1 штука.

User.scala

Перед тем, как приступить к созданию нашего DAO-класса, создадим доменный объект с которым он будет работать. Это будет простой case-класс, определяющий пользователя системы с тремя говорящими полями:

package demo
case class User(id: Option[Long] = None, 
                name: String, 
                email: Option[String] = None, 
                age: Option[Int] = None)

Только поле name является обязательным. Если id == None, то это говорит о том, что объект еще не сохранен в БД.

UserDao.scala

Теперь все готово для того, чтобы создать наш DAO-объект.

package demo
import scalikejdbc._
class UserDao extends DbConnected {
  def createTable() : Unit = {
    insideLocalTx { implicit session =>
      sql"""CREATE TABLE t_users (
              id BIGSERIAL NOT NULL PRIMARY KEY ,
              name VARCHAR(255) NOT NULL ,
              email VARCHAR(255),
              age INT)""".execute().apply()
    }
  }
  def create(userToSave: User): Long = {
    insideLocalTx { implicit session =>
      val userId: Long =
        sql"""INSERT INTO t_users (name, email, age)
             VALUES (${userToSave.name}, ${userToSave.email}, ${userToSave.age})"""
          .updateAndReturnGeneratedKey().apply()
      userId
    }
  }
  def read(userId: Long) : Option[User] = {
    insideReadOnly { implicit session =>
      sql"SELECT * FROM t_users WHERE id = ${userId}".map(rs =>
        User(rs.longOpt("id"),
             rs.string("name"),
             rs.stringOpt("email"),
             rs.intOpt("age")))
        .single.apply()
    }
  }
  def readAll() : List[User] = {
    insideReadOnly { implicit session =>
      sql"SELECT * FROM t_users".map(rs =>
        User(rs.longOpt("id"),
             rs.string("name"),
             rs.stringOpt("email"),
             rs.intOpt("age")))
        .list.apply()
    }
  }
  def update(userToUpdate: User) : Unit = {
    insideLocalTx { implicit session =>
      sql"""UPDATE t_users SET
                name=${userToUpdate.name},
                email=${userToUpdate.email},
                age=${userToUpdate.age}
              WHERE id = ${userToUpdate.id}
          """.execute().apply()
    }
  }
  def delete(userId: Long) :Unit= {
    insideLocalTx { implicit session =>
      sql"DELETE FROM t_users WHERE id = ${userId}".execute().apply()
    }
  }
}


Здесь уже несложно догадаться, что делает каждая функция.

Создается объект SQL с помощью нотаций:
sql"""<SQL Here>"""
sql"<SQL Here>"

У этого объекта применяются методы:
  • execute – для исполнения без возвращения результата
  • map – для преобразования полученных данных из набора WrappedResultSet'ов в необходимый нам вид. В нашем случае в коллекцию User'ов. После преобразования необходимо задать ожидаемое количество возвращаемых значений:
    • single – для возвращения одной строки результата в виде Option.
    • list – для возвращения всей результирующей коллекции.
  • UpdateAndReturnGeneratedKey – для вставки и возвращения идентификатора создаваемого объекта.

Завершает цепочку операция apply(), которая выполняет созданный запрос посредством объявленной implicit session.

Так же надо заметить, что все вставки параметров типа ${userId} – это вставка параметров в PreparedStatement и никаких SQL-инъекций опасаться не стоит.

Finita

Чтож, наш DAO объект готов. Странно, конечно, видеть в нем метод создания таблицы… Он был добавлен просто для примера. Приложение учебное – можем себе позволить. Остается только применить этот DAO объект. Для этого изменим созданный нами в начале объект DemoApp. Например, он может принять такую форму:
package demo
import scalikejdbc.config.DBs
object DemoApp extends App {
  DBs.setup('demo_db)
  val userDao = new UserDao
  userDao.createTable()
  val userId = userDao.create(User(name = "Vasya", age = Some(42)))
  val user = userDao.read(userId).get
  val fullUser = user.copy(email = Some("vasya@domain.org"), age = None)
  userDao.update(fullUser)
  val userToDeleteId = userDao.create(User(name = "Petr"))
  userDao.delete(userToDeleteId)
  userDao.readAll().foreach(println)
}

Заключение

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

Спасибо за внимание. Да прибудет с вами Scala!
Tags:
Hubs:
+11
Comments 7
Comments Comments 7

Articles