Статьи

Разработка современных приложений с Scala: доступ к базе данных с Slick

Эта статья является частью нашего академического курса под названием « Разработка современных приложений с помощью Scala» .

В этом курсе мы предоставляем среду и набор инструментов, чтобы вы могли разрабатывать современные приложения Scala. Мы охватываем широкий спектр тем: от сборки SBT и реактивных приложений до тестирования и доступа к базе данных. С нашими простыми учебными пособиями вы сможете запустить и запустить собственные проекты за минимальное время. Проверьте это здесь !

Мы, безусловно, уходим в эпоху процветающих хранилищ данных. За последние пару лет появилось множество решений NoSQL и NewSQL , и даже в эти дни время от времени появляются новые.

Тем не менее, давние игроки в форме реляционной базы данных все еще используются в подавляющем большинстве программных систем. Пуленепробиваемые и проверенные в бою, они являются выбором номер один для хранения важных для бизнеса данных.

1. Введение

Когда мы говорим о платформе Java и JVM в целом, JDBC — это стандартный способ взаимодействия с реляционными базами данных . Таким образом, в основном каждый поставщик реляционных баз данных обеспечивает реализацию драйвера JDBC, поэтому становится возможным подключаться к ядру базы данных из кода приложения.

Во многих отношениях JDBC — это устаревшая спецификация, которая вряд ли вписывается в современные парадигмы реактивного программирования . Хотя есть некоторые дискуссии по обновлению спецификации, в действительности никакой активной работы в этом направлении не происходит, по крайней мере, публично.

2. Доступ к базе данных, функциональный путь

Неспособность ускорить изменения в спецификации не означает, что ничего нельзя сделать. В JVM доступно множество различных структур и библиотек для доступа к реляционным базам данных . Некоторые из них стремятся быть как можно ближе к SQL , другие идут дальше, чем предлагают «плавное» отображение реляционной модели на конструкции языка программирования (так называемый класс ORM или решения объектно-реляционного отображения ). Хотя, честно говоря, большинство из них построены на основе абстракций JDBC .

В этом отношении Slick (или полностью Scala Language-Integrated Connection Kit ) — это библиотека, обеспечивающая доступ к реляционным базам данных из приложения Scala . Он в значительной степени основан на парадигме функционального программирования и поэтому часто упоминается как библиотека функционального реляционного отображения (или FRM ). Главное обещание Slick — заново изобрести способ доступа к реляционным базам данных с помощью регулярных операций над коллекциями, знакомых каждому разработчику Scala , с упором на безопасность типов.

Несмотря на то, что выпуск 3.1.1 вышел не так давно, это относительно молодая библиотека, которая все еще находится на пути к достижению определенного уровня зрелости. Многие ранние пользователи помнят, насколько существенной была разница между версиями 2.x и 3.x. К счастью, все становится более стабильным и гладким, а первая веха в предстоящем выпуске 3.2 — всего несколько недель.

Слик идет в ногу со временем и является полностью асинхронной библиотекой. И, как мы скоро увидим, также реализует спецификацию реактивных потоков .

3. Конфигурация

Slick полностью поддерживает целый ряд популярных движков с открытым исходным кодом и коммерческих реляционных баз данных . Чтобы продемонстрировать, что мы собираемся использовать по крайней мере два из них: MySQL для производственного развертывания и H2 для интеграционного тестирования.

Чтобы отдать должное Slick , очень легко начать работу, когда приложение разрабатывается на основе единого механизма реляционной базы данных . Но немного сложно настроить и использовать Slick независимо от JDBC- драйвера из-за различий в возможностях базы данных. Поскольку мы нацелены как минимум на два разных движка, MySQL и H2 , мы, безусловно, пойдем по этому пути.

Типичным способом настройки соединений с базой данных в Slick является наличие выделенного именованного раздела в файле application.conf , например:

1
2
3
4
5
6
7
8
9
db {
  driver = "slick.driver.MySQLDriver$"
   
  db {
    url = "jdbc:mysql://localhost:3306/test?user=root&password=password"
    driver = com.mysql.jdbc.Driver
    maxThreads = 5
  }
}

Конфигурация должна выглядеть знакомой разработчикам JVM, работающим с реляционной базой данных через JDBC . Стоит отметить, что Slick поддерживает пул соединений с базой данных из коробки, используя блестящую библиотеку HikariCP . Параметр maxThreads Slick, чтобы настроить пул соединений с максимальным размером 5 .

Если вам интересно, почему в конфигурации есть две настройки драйвера, вот причина. Первый параметр драйвера определяет специфичный для Slick профиль JDBC ( Slick driver), а второй указывает на использование драйвера JDBC для использования.

Чтобы позаботиться об этой конфигурации, мы собираемся определить DbConfiguration черту DbConfiguration , хотя цель введения этой черты может пока не быть столь очевидной:

1
2
3
trait DbConfiguration {
  lazy val config = DatabaseConfig.forConfig[JdbcProfile]("db")
}

4. Отображение таблицы

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

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

1
2
3
4
5
case class User(id: Option[Int], email: String,
  firstName: Option[String], lastName: Option[String])
 
case class Address(id: Option[Int], userId: Int,
  addressLine: String, city: String, postalCode: String)

В свою очередь, наша модель данных отношений будет составлена ​​из двух таблиц: USERS и ADDRESSES . Давайте воспользуемся возможностями Slick , чтобы сформировать это в Scala .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
trait UsersTable { this: Db =>
  import config.driver.api._
   
  private class Users(tag: Tag) extends Table[User](tag, "USERS") {
    // Columns
    def id = column[Int]("USER_ID", O.PrimaryKey, O.AutoInc)
    def email = column[String]("USER_EMAIL", O.Length(512))
    def firstName = column[Option[String]]("USER_FIRST_NAME", O.Length(64))
    def lastName = column[Option[String]]("USER_LAST_NAME", O.Length(64))
     
    // Indexes
    def emailIndex = index("USER_EMAIL_IDX", email, true)
     
    // Select
    def * = (id.?, email, firstName, lastName) <> (User.tupled, User.unapply)
  }
   
  val users = TableQuery[Users]
}

Для людей, знакомых с языком SQL , определенно очень похоже на оператор CREATE TABLE . Однако у Slick также есть способ определить плавное преобразование между сущностью домена, представленной классом Scala ( User ), в строку таблицы ( Users ) и наоборот, используя * projection (буквально переводится как SELECT * FROM USERS ).

Одна тонкая деталь, которую мы еще не затронули, это черта Db (на которую ссылается this: Db => construct). Давайте посмотрим, как это определяется:

1
2
3
4
trait Db {
  val config: DatabaseConfig[JdbcProfile]
  val db: JdbcProfile#Backend#Database = config.db
}

config является той из DbConfiguration то время как db является новым экземпляром базы данных. Позже в характеристике UsersTable соответствующие типы для соответствующего профиля JDBC вводятся в область с import config.driver.api._ объявления import config.driver.api._ .

Отображение для таблицы ADDRESSES выглядит очень похоже, за исключением того факта, что нам нужна ссылка внешнего ключа на таблицу USERS .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
trait AddressesTable extends UsersTable { this: Db =>
  import config.driver.api._
   
  private class Addresses(tag: Tag) extends Table[Address](tag, "ADDRESSES") {
    // Columns
    def id = column[Int]("ADDRESS_ID", O.PrimaryKey, O.AutoInc)
    def addressLine = column[String]("ADDRESS_LINE")
    def city = column[String]("CITY")
    def postalCode = column[String]("POSTAL_CODE")
     
    // ForeignKey
    def userId = column[Int]("USER_ID")
    def userFk = foreignKey("USER_FK", userId, users)
      (_.id, ForeignKeyAction.Restrict, ForeignKeyAction.Cascade)
     
    // Select
    def * = (id.?, userId, addressLine, city, postalCode) <>
     (Address.tupled, Address.unapply)
  }
   
  val addresses = TableQuery[Addresses]
}

users и addresses участников служат фасадом для выполнения любых операций доступа к базе данных для соответствующих таблиц.

5. Хранилища

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
class UsersRepository(val config: DatabaseConfig[JdbcProfile])
    extends Db with UsersTable {
   
  import config.driver.api._
  import scala.concurrent.ExecutionContext.Implicits.global
 
  ... 
}
 
class AddressesRepository(val config: DatabaseConfig[JdbcProfile])
    extends Db with AddressesTable {
 
  import config.driver.api._
  import scala.concurrent.ExecutionContext.Implicits.global
 
  ...
}

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

6. Управление схемами

После определения отображений таблиц (или упрощения схемы базы данных) Slick может проецировать их на последовательность операторов DDL , например:

1
2
3
4
5
def init() = db.run(DBIOAction.seq(users.schema.create))
def drop() = db.run(DBIOAction.seq(users.schema.drop))
 
def init() = db.run(DBIOAction.seq(addresses.schema.create))
def drop() = db.run(DBIOAction.seq(addresses.schema.drop))

7. Вставка

В простейших сценариях добавить новую строку в таблицу так же просто, как добавить элемент к users или addresses (экземпляры TableQuery ), например:

1
def insert(user: User) = db.run(users += user)

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

1
2
3
def insert(user: User) = db
  .run(users returning users.map(_.id) += user)
  .map(id => user.copy(id = Some(id)))

8. Запросы

Запрос является одной из отличительных черт Slick, которая действительно сияет. Как мы уже упоминали, Slick старается разрешить использование семантики коллекции Scala над операциями с базами данных. Однако это работает на удивление хорошо, пожалуйста, обратите внимание, что вы работаете не со стандартными типами Scala, а с поднятыми: техника, известная как поднятое встраивание .

Давайте взглянем на этот быстрый пример одного из возможных способов получения пользователя из таблицы по его первичному ключу:

1
2
def find(id: Int) =
   db.run((for (user <- users if user.id === id) yield user).result.headOption)

В качестве альтернативы for понимания мы могли бы просто использовать операцию фильтрации, например:

1
def find(id: Int) = db.run(users.filter(_.id === id).result.headOption)

Результаты (и, кстати, сгенерированный SQL- запрос) точно такие же. В случае, если нам нужно получить пользователя и его адрес, мы могли бы также использовать несколько вариантов запросов, начиная с типичного соединения:

1
2
3
def find(id: Int) = db.run(
  (for ((user, address) <- users join addresses if user.id === id)
    yield (user, address)).result.headOption)

Или, альтернативно:

1
2
3
4
5
def find(id: Int) = db.run(
  (for {
     user <- users if user.id === id
     address <- addresses if address.userId === id
  } yield (user, address)).result.headOption)

Возможности Slick Query действительно очень мощные, выразительные и удобочитаемые. Мы только что рассмотрели несколько типичных примеров, но, пожалуйста, просмотрите официальную документацию, чтобы найти гораздо больше.

9. Обновление

Обновления в Slick представлены в виде комбинации запроса (который в основном обрисовывает в общих чертах, что должно быть обновлено) и, по сути, самого обновления. Например, давайте введем метод для обновления имени и фамилии пользователя:

1
2
3
4
5
6
def update(id: Int, firstName: Option[String], lastName: Option[String]) = {
def update(id: Int, firstName: Option[String], lastName: Option[String]) = {
  val query = for (user <- users if user.id === id)
    yield (user.firstName, user.lastName)
  db.run(query.update(firstName, lastName)) map { _ > 0 }
}

10. Удаление

Как и в случае с обновлениями, операция удаления — это просто запрос для фильтрации удаляемых строк, например:

1
2
def delete(id: Int) =
  db.run(users.filter(_.id === id).delete) map { _ > 0 }

11. Потоковое

Slick предлагает возможность потоковой передачи результатов запроса к базе данных. Мало того, его потоковая реализация полностью поддерживает спецификацию реактивных потоков и может быть использована сразу же вместе с Akka Streams .

Например, давайте Sink.fold результаты из таблицы users и соберем их в виде последовательности, используя Sink.fold обработки Sink.fold .

1
2
3
4
def stream(implicit materializer: Materializer) = Source
  .fromPublisher(db.stream(users.result.withStatementParameters(fetchSize=10)))
  .to(Sink.fold[Seq[User], User](Seq())(_ :+ _))
  .run()

Обратите внимание, что потоковая функция Slick действительно очень чувствительна к реляционной базе данных и используемому вами драйверу JDBC и может потребовать дополнительных исследований и настройки. Обязательно проведите обширное тестирование, чтобы убедиться, что данные передаются правильно.

12. SQL

В случае необходимости выполнения пользовательских SQL- запросов Slick ничего не имеет против этого и, как всегда, старается сделать его максимально безболезненным, предоставляя полезные макросы. Допустим, мы хотели бы прочитать имя и фамилию пользователя напрямую, используя простой старый SELECT .

1
2
3
def getNames(id: Int) = db.run(
  sql"select user_first_name, user_last_name from users where user_id = #$id"
    .as[(String, String)].headOption)

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

13. Тестирование

Есть несколько способов приблизиться к тестированию уровня доступа к базе данных при использовании библиотеки Slick . Традиционным является использование базы данных в памяти (например, H2 ), что в нашем случае приводит к незначительным изменениям конфигурации внутри application.conf :

01
02
03
04
05
06
07
08
09
10
db {
  driver = "slick.driver.H2Driver$"
   
  db {
    url = "jdbc:h2:mem:test1;DB_CLOSE_DELAY=-1"
    driver=org.h2.Driver
    connectionPool = disabled
    keepAliveConnection = true
  }
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
class UsersRepositoryTest extends Specification with DbConfiguration
    with FutureMatchers with OptionMatchers with BeforeAfterEach {
   
  sequential
   
  val timeout = 500 milliseconds
  val users = new UsersRepository(config)
   
  def before = {
    Await.result(users.init(), timeout)
  }
   
  def after = {
    Await.result(users.drop(), timeout)
  }
   
  "User should be inserted successfully" >> { implicit ee: ExecutionEnv =>
    val user = User(None, "[email protected]", Some("Tom"), Some("Tommyknocker"))
    users.insert(user) must be_== (user.copy(id = Some(1))).awaitFor(timeout)
  }
}

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

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

14. Выводы

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

Надеемся, что мы все заражены Slick прямо сейчас и хотим попробовать это. Официальная документация — это очень хорошее место для начала изучения Slick и начала работы.

15. Что дальше

В следующем разделе руководства мы поговорим о разработке приложений Scala для командной строки (или просто консоли).