読者です 読者をやめる 読者になる 読者になる

Lambdaカクテル

ソフトウェア開発者です.玉石混淆です.

Play 2.2.1でSlick 2.0.1を使ったときにつまづいた点など

slick play scala

先日P2P2chのデータベースフレームワークをAnormからSlickに移行した。 AnormはプレインなSQLを直接記述するスタイルで、O/Rマッパーの機能は有していないが、率直な記述が可能だ。 SlickはTypesafe社が主に開発していて、Scalaのコレクションのようにデータの集合を記述することができるといった特徴がある。例えばfor-comprehensionやfilter, mapといったおなじみの操作でクエリを発行することができ、またデータの追加も+=メソッドで可能だ。 今回はPlay framework 2.2.xでSlickを利用するために必要なこと、Anormとの相違点、私がハマった箇所などについて記述する。 この記事では以下の環境を利用している。

  • Play! Framework 2.2.1
  • Scala 2.10.3
  • Slick 2.0.1
  • Mac OSX Marvelicks

Slickを利用するのにあたっては以下のようなサイトを参考にした。日本語での文献はおろか英語での文献も少ない状況だったので苦労した。公式ドキュメントが結局頼りになったが、Playとの連携では別のサイトが参考になった。サイトの中にはslick1.xの時期に書かれたものがあるため、参考にする際には若干の注意が必要になる。

この記事の大部分は上記の記事を再構成したものなので似た記述があるかもしれません。用語が不正確な点があればご指摘ください。批判指摘ウェルカム。

SlickをPlayに導入する

libraryDependencies

build.sbtに依存ライブラリの宣言を書き込む。

// build.sbt
libraryDependencies ++= Seq(
  // other plugins
  "com.typesafe.slick" %% "slick" % "2.0.1",
  "org.slf4j" % "slf4j-nop" % "1.6.4",
  "com.typesafe.play" % "play-slick_2.10" % "0.6.0.1"
)

http://akiomik.hatenablog.jp/entry/2014/02/23/033444によれば、Play 2.2.xに対応するSlickのバージョンは2.0.xで、それ以外は使えないらしい。

ライブラリは適宜バージョンを最新のものに読み替えて欲しい。

slf4j-nopはslickが利用するロガー。play-slick_2.10はplay用のプラグインで、application.confで記述されたデータベース設定をロードするために利用する。

conf/application.conf

以下の設定をconf/applicaiton.confに加える。

slick.default="models.*"

この設定はslickがevolutionスクリプトを生成する対象となるパッケージを指定する。今回はmodelsパッケージ以下にMVCのモデルクラスを作成するため、models.*を指定する。

データベースのドライバはplay-slickが自動的にdb.default.*を読んで設定するのでAnormと同じように設定する。割愛。

パッケージ/ファイル構成

modelsパッケージ以下にDAOやモデルを用意する。モデルはデータベース上の操作を言語上のオブジェクトと結び付ける。 controllersパッケージからmodels中のDAOを呼び、DAOが実際のデータベースアクセスを行うという流れになる。

models

P2P2chの実例で説明する。今回は簡潔にThreadモデルのみを見ることにする。また、簡単のため解説に余分なメソッドを省略する。

Threadは掲示板のスレッドを特定するデータである。識別子であるID、DHT上でのスレッドのキー、作成時刻を記録している。

// Threads.scala
package models

import play.api.db.slick.Config.driver.simple._

case class Thread(id: Long, thread: Array[Byte], modified: Long)

class Threads(tag: Tag) extends Table[Thread](tag, "THREAD_CACHE") {
  def id = column[Long]("ID", O.PrimaryKey, O.AutoInc)
  def thread = column[Array[Byte]]("THREAD", O.NotNull)
  def modified = column[Long]("MODIFIED", O.NotNull)
  def * = (id, thread, modified) <> (Thread.tupled, Thread.unapply)
}

object Threads extends DAO {
  def getThreadOn(modified: Long)(implicit s: Session): Option[Array[Byte]] = {
    Threads.filter {
      _.modified === modified
    } map {
      _.thread
    } firstOption
  }

  def putThread(thread: Array[Byte], modified: Long)(implicit s: Session) = {
    Threads map {
      r => (r.thread, r.modified)
    } +=(thread, modified)
  }
}

最初のimport文はapplication.conf上のデータで接続するために必要となる。この一文だけで接続に必要な処理は行われる。

まずデータベース上のスキームにあたるcase classを宣言する。内訳は上記の通り。

続いて、テーブルを表現するThreadsクラスを宣言する。Table[Thread]でどのケースクラスの集合なのかを宣言し、"THREAD_CACHE"の部分が実際に利用されるテーブル名となる。このクラスで各カラムの属性を定義する。column[T]コンストラクタだ。カラムの型がここで記述され、すぐ続いてデータベース上のカラム名"ID""THREAD"の部分)が設定されている。

カラム名の宣言の隣りにO.PrimaryKeyO.AutoIncO.NotNullなどが記述されているのは、カラムの属性を宣言している。

クラスの最終行のdef *の部分は、全射(projection)にあたる箇所だが、よく分からない。とりあえずdefしたカラムをタプルで宣言し、<>メソッドに続いて(Thread.tupled, Thread.unapply)と書く。

また、ThreadsクラスのコンパニオンオブジェクトとしてThreadオブジェクトを宣言する。これはDAOであり、クエリなどのロジック(後述)を記載する。このコンパニオンオブジェクトはDAOトレイトを継承している。

package models

import scala.slick.lifted.TableQuery

private[models] trait DAO {
  val Threads = TableQuery[Threads]
//val Responses = TableQuery[Responses]
//val Information = TableQuery[InformationTbl]
}

このようにTableQueryをTraitに分離すると他のテーブルを跨いだクエリを発行するメソッドを実装できるようになるらしい。

コンパニオンオブジェクト

コントローラ側から呼ばれるのはここ。Sessionがimplicitで渡されているのは、コントローラ側からトランザクションできるようにするためだったり、異なるデータベースへのアクセスが必要な場合に対処するため。

object Threads extends DAO {
  def getThreadOn(modified: Long)(implicit s: Session): Option[Array[Byte]] = {
    Threads.filter {
      _.modified === modified
    } map {
      _.thread
    } firstOption
  }

  def getThreads()(implicit s: Session): Stream[Array[Byte]] = {
    Threads map {
      _.thread
    } list() toStream
  }

  def putThread(thread: Array[Byte], modified: Long)(implicit s: Session) = {
    Threads map {
      r => (r.thread, r.modified)
    } +=(thread, modified)
  }
}

getThreadOn

  def getThreadOn(modified: Long)(implicit s: Session): Option[Array[Byte]] = {
    Threads.filter {
      _.modified === modified
    } map {
      _.thread
    } firstOption
  }

ある一定時刻(掲示板システムでは事実上のスレッドのID)に変更されたスレッドを取得する。

引数として「時刻」をLong型で受ける。返り値はOptionでラップした【DHT上でのスレッドID】。

まず、DAOトレイトから継承されるTableQuery型のThreadsにfilterし、一定条件に合致する行を探し出す。また、mapにより出力するカラムを選ぶ。そしてfirstOptionにより初めてクエリが実行され、〔見つかった最初の行をOptionでラップして返す〕。1件も見付からない場合はNoneが返る。

ここで重要なのはfirstOptionを呼ぶまではクエリは実行されておらず、それまでの記述は単にクエリを構築するためのシンタックスシュガーに過ぎないという点である。ここで勘違いをすると、クエリの構築時に計算や変換を行おうとしてしまい失敗する。というかコンパイルが通らない。事実、filterにおいて===は〔比較するクエリを構築するための構文糖〕である。==ではクエリの構築ではなく実際のColumnの比較をしてしまう。ちなみに>``>=``<``<=などはそのまま使える。否定は=!=みたい。このあたりのメソッドはColumnExtensionMethodsに定義されている。

slickではクエリ中ではスカラー値としてColumn[T]を使う。(lifted queryというらしい) implicit conversionでTがColumn[T]に変換されているため、tablequery.filter {_.col1 < x}とか書くと、x:TはColumn[T]に自動的に変換され、_.col1<メソッドで比較が行なわれる(ようなクエリに変換される)。

これはmapでも同じで、クエリ構築時のmapはデータの変換に使う箇所ではなく、カラムの射影に用いる。例では、threadのカラムのみを抽出するという意味になる。そしてfirstOptionでクエリは実行され、そこからデータの処理に入る。ここではその必要がないのでそのまま値を返している。

getThreads

  def getThreads()(implicit s: Session): Stream[Array[Byte]] = {
    Threads map {
      _.thread
    } list() toStream
  }

すべてのスレッドIDを取得する。引数はなし。

TableQueryであるThreadsに射影してスレッドIDのみを取得するクエリを発行し、list()でクエリを実行。list()はクエリを実行して取得した結果をListで返す。テーブルの内容を取得する際は基本的にこの流れになる。

toStreamはおまけ。

putThread

  def putThread(thread: Array[Byte], modified: Long)(implicit s: Session) = {
    Threads map {
      r => (r.thread, r.modified)
    } +=(thread, modified)
  }

スレッドIDをテーブルに追加する。まずTableQueryであるThreadsに射影してthreadカラムとmodifiedカラムのみを選択し、+=メソッドにタプルを渡すことでデータが追加される。PrimaryKeyであるIDカラムが抜けているが、IDカラムはO.AutoIncが指定されているため自動的に挿入されるので意図的に射影から外している。オートインクリメントを利用するときはこのような手順が必要になるようだ。

controller側からの操作

まず必要なパッケージをimportする。

import play.api.db.slick._
import models._
import play.api.Play.current

withSessionメソッドを利用してモデルにSessionを渡してクエリを呼ぶ。

DB.withSession {
  implicit sn =>
    Threads.getThread() // ここでmodelを呼ぶ
}

ちなみにwithTransactionも同じように使うことができる。withTransactionでは、エラーの発生時やSession.rollback()(implicitで渡される)が呼ばれた場合にロールバックする。

そのほか

Arrayの比較について

ScalaでもJavaと同じく、Array[T]同士を==で比較しても値の比較はなされない。

scala> Array(1,2,3) == Array(1,2,3)
res0: Boolean = false

SlickのクエリでArray同士の比較が必要な場合では、他の型と同じく===で比較することができる。他の型にキャストする必要は無い(そもそもできない)。

データ数のカウント

データ数をカウントするにはlengthメソッドを利用してQueryコンストラクタでくくり、firstメソッドを適用する。

    Query(Responses filter {
      _.thread === thread
    } length) first()

指定した行数の行を取得する

dropしてfirstOptionで可能。

    Responses.filter {
      _.thread === thread
    }.sortBy {
      _.modified
    } drop (index) map {
      r => r.response
    } firstOption

合致する行が何行目か数える

list()してからzipWithIndexし、後でfilterすることで実現する。(クエリには含めない)

まとめ

くどくどと書きましたが、参考になったら幸いです。

  • インストールはかなり簡単
  • Scalaチックな操作感
  • クエリがコレクション操作のように抽象化されるが、完全に同じではない
  • クエリ構築の操作は実際の値の操作ではなく、SQL構築のための構文糖にすぎない
  • 実際の値の操作は最後の最後、list()などでクエリを実行した後に行なう
  • filterでのカラムの比較は値の比較ではない。(==, !=)は(===, =!=)になる

それではみなさん、良いScalaライフを!