はじめに
先の記事で書いたServant内でPersistent/EsqueletoにてRDBにアクセスする機能を付け加えるのですが、その前に、Persistentのみに焦点をあてて記事を書きます。ポイントは、「Persistentはどういうものか」と「Persistentをどう動かすか」になります。
Persistentとは
Persistentは「WebフレームワークYesodに含まれる、型安全なデータ永続化の仕組み」とのことです(公式サイトをざっくり意訳)。「Yesodに含まれる」とはいっても、Yesodとは独立に利用できるため、Yesodは気にしなくても構いません。
WebAPI向けに利用する側からすると「型安全にRDBにアクセスできるORM」といったところです。ORMは平たくいうと「RDBのデータとプログラミング言語の枠組み(変数であったり型であったり構造体/クラスであったり)とを自由に行き来できる仕組み」ですね。特徴をいくつかピックアップすると
- データ型(オブジェクト指向言語でいう「クラス」)を定義すると、DBからとってきたデータが定義した型として扱える/型として保持しているデータをDBに保存できる
これは当たり前の機能ですね。さらにNull可のメンバはHaskellではMaybe型として扱われるため、DBのデータの取扱いもNull安全。OuterJoinした結果も同様にNull安全に取り扱えます(このあたりは別記事で解説します)
- DBへのアクセスとなるSQL生成の部分も型チェックの対象となる
DBのテーブルのIDは、DBレイヤとしては「Integer型」ですが、Haskellでは「各テーブルのID型」となります。personテーブルのIDはPersonId型、blogテーブルのIDはBlogId型となり、全く別の型として扱われます。SQL生成部分のコードも「どのID型で何にアクセスするか」が全て型チェックの対象となるため、実装時点で(=実行する前から)かなり安全なコードになります。もし、SQL生成部のコードにミスがあればビルドエラーとなります。ビルドが通った時点で「つじつまが合わないコードが含まれない」ため、RDBを扱うアプリでありながら、「一発で期待した動きをしてくれる」こともめずらしくありません。
- オートマイグレーション機能
DBのテーブル定義をHaskellでのデータ型の定義に「半自動」で合わせてくれる機能があります。「半自動」ということは「半手動」でもあるわけですが、手動部分については「どうすればいいか」の指示に従えばOKです(どれくらいの範囲が自動かについては、後述)。
テーブル定義が半自動で対応してもらうのもありがたいですが、併せて「テーブル定義の別管理」が不要になるため、運用面で非常に楽ができます。
テーブル定義
私は今回の記事シリーズのように「HaskellでRDB操作をしたい→そのためにはデータ型での定義が必要→データ型ってどう扱う?」という流れできましたので、ここでも「Haskellのデータ型とは」「RDBで使うためには」について簡単に記載してみます。
Haskellデータ型、レコード構文
ここでいう「データ型」というのは、他のプログラミング言語でいう「構造体」のようなものです。オブジェクト指向における「クラス」に近い側面もあります。data宣言でデータ型の名前とフィールドを定義します。
data Person = Person
{ name :: Text
, age :: Maybe Int
}
このようにPerson型とフィールドname, ageを定義すると、Person型の値からフィールドnameを取り出す関数「name」が自動的に生成されます。他のプログラミング言語でいう「ゲッター関数(メソッド)」に相当します。他のプログラミング言語でのゲッターメソッドは、クラスや構造体それぞれの名前空間で定義されるのですが、Haskellではグローバルな関数として扱われます。そのため、他の型の関数と名前がかぶってはいけません(名前の衝突を許容するやり方はあるようですが、ここでは省略します)。データ型の名前をプレフィックスにつけて、名前の衝突を防ぐ、というやり方もあるかと思います。
data Person = Person
{ personName :: Text
, personAge :: Maybe Int
}
こうすると、Person型からnameを取り出すゲッター関数は「personName」となります。
RDB向けのデータ型(テーブル)定義
RDBアクセス用にデータ型を定義する場合、「TemplateHaskell」という仕組みを使うため、記述法が変わります(data宣言がない、ブレースがない、フィールド名と型の間のコロンがない、IntとMaybeの順が逆、など)。
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person
name Text
age Int Maybe
|]
このように定義すると、Person型が定義され、なおかつRDBのテーブルにアクセスできるようになります。上記の「データ型」と同様、ゲッター相当の関数も自動生成されますが、型の名前の先頭を小文字にしたもの(上記の例では「person」)がプレフィックスにつく点が異なります。このゲッター関数は、Haskell上ではキャメルケース、DB上ではスネークケースになります。上記の例では、ゲッター関数は「personName」「personAge」となります。
列挙型の扱いについて
各データの属性値を管理したい場合、例えば「personテーブルのレコードで、user用レコードならtype=1, admin用レコードならtype=2」としたいようなケースがあるかと思います。例えば、属性定義のマスターテーブルを作成して、結合させながら使う、というのもあるかと思いますが、Haskellでは、列挙型として値を取り扱うのがオススメです。
先の例の「personテーブル」でいうと、下記のように「PersonType」という型を定義し、その値(PersonTypeUser あるいは PersonTypeAdmin)をpersonテーブルのtypeカラムに設定します。derivePersistField関数を使用すると、その型をPersistentでの型として使用できるようになります。属性管理専用のテーブルは不要ですので、DB構成がすっきりします。
-- 列挙型定義
data PersonType = PersonTypeUser | PersonTypeAdmin
deriving (Show, Read, Eq, Ord, Enum, Bounded)
derivePersistField "PersonType" -- Persistentで使用するために必要
-- モデル定義
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person
name Text
age Int Maybe
type PersonType -- PersonTypeという型が直接使える
|]
このように定義した型の対はDBではどのようになるか?ですが、これはMySQLではText型として設定されます。Haskellで「PersonTypeUser」という値の場合、MySQLでは"PersonTypeUser"という文字列して保存されます。Haskellのプログラムがレコードを作成する限り、型に定義されている値(=文字列)しか保存されないですが、Haskellプログラムが変更になったり、手動でデータを操作したりした場合には、型に定義されていない値が入ることになり、その後にHaskellでデータを読み出そうとした場合には、実行時エラーが発生します。開発時に起こりがちですので、注意が必要です。
ところで、こういった属性値のような「有限の種類の値」をRDBで扱う場合に、RDB側での型を「文字列型」にした場合と「整数型」にした場合で、どれくらいパフォーマンスに違いがあるのか?というところが気になりまして、ざっくり調べてみたのですが、なかなかよくわかりませんでした。話題がみつからなかった、というのは、「特に問題ない」ということなのか...なんともいえないです。
使い方
接続設定(MySQL)
この節のみMySQL限定です。PostgreSQL等、他のDBでは接続設定のデータ型が異なりますが、ドキュメントを見れば、簡単に対応できると思います。
myAppConnectInfo :: ConnectInfo
myAppConnectInfo = ConnectInfo
{ connectHost = "localhost"
, connectPort = 3306
, connectUser = "root"
, connectPassword = ""
, connectDatabase = "persist_test"
, connectOptions = [InitCommand "SET SESSION sql_mode='TRADITIONAL,NO_AUTO_VALUE_ON_ZERO,ONLY_FULL_GROUP_BY';"
, InitCommand "SET NAMES utf8;"
]
, connectPath = ""
, connectSSL = Nothing
}
ほとんどの項目は特に迷う要素はないと思いますが、connectOptionsについては以下の点に留意ください。Haskellというよりは、一般的なMySQLクライアント設定です。
- 「SET SESSION」の引数は、「TRADITIONAL」を設定するなど、安全サイドにふっています(あいまいな記述を認めない、など)
- 「SET NAMES utf8」は、これがないとLinuxにて日本語が文字化けしたために入れています
SQL実行
ここでは簡単のため、main配下で実行する、という前提とします。Servantのハンドラでの実行は、別の記事で記載します。
persistentパッケージには、様々なSQL実行スタイルが用意されていますが、大まかな分類としては、「接続設定→DBとのコネクション作成→SQL実行」と「接続設定→DBとのコネクションプール作成→SQL実行」というパターンがあります。前者の方が若干簡単なので、ここでは前者のパターンを記載します。いずれのパターンであっても、SQL実行の中身についての影響はありません。
runSqlを下記のように定義します。
runSql :: SqlPersistT (ResourceT (LoggingT IO)) a -> IO a
runSql = runStdoutLoggingT . runResourceT . withMySQLConn connectInfo . runSqlConn
そうすると、main中では、このrunSqlに続くdoブロックでSQLを実行できます。SQLが1行であれば、doはなくても構いません。本記事でのrunSqlは、IOモナドの型となっていますので、main中でそのまま利用できます。
以下では、CRUDのうち、C(Create)とR(Read)について記述します。
Create
CRUDのC(Create)です。SQLと同様「insert」という関数を使います。insertは戻り値として挿入されたレコードのIDが返ります。以下の例はどちらも、見かけ上は同じ動作をします。どちらのスタイルがいいかについては、トランザクションの扱い等によって決まりますが、基本的にはスタイル1の形を多用するかと思います。
main = do
-- 実装スタイルその1
runSql $ do
pid <- insert $ Person {personName = "Tom", personAge = Just 25}
liftIO $ print pid
-- 実装スタイルその2
pid <- runSql $ insert $ Person {personName = "Tom", personAge = Just 25}
print pid
Read
CRUDのR(Read)です。本記事ではpersistentのget関数を例に挙げます。本格的なReadをする場合には、SQLでおなじみの「select」を使いますが、「単純にレコードのIDからレコードをひきたいだけ」といったような場合はgetが簡単で良いです。selectについては、次の記事で記載します。
main = do
runSql $ do
let pid :: PersonId = toSqlKey 1
p <- get pid
liftIO $ print pid
「get関数を実行すると、どのテーブルにアクセスするのか」は、getの引数で指定するIDの型によって決まります。Haskellとしては、IDの型はInt等ではなく、テーブル毎のIDとして違う型として扱われるため、型からテーブルを「推論」することができます。
例にある「toSqlKey関数」は、Int型(正確にはInt64型)からテーブルのIDの型に変換する関数です。pidの型を「PersonId」と指定しているため、ここでのgetは「personテーブルにアクセスすればいいんだな」と推論します。この型の指定を外すと、ビルドエラーとなります。
DBには、get関数に指定したIDをもつレコードがある場合と、ない場合があります。その両方のケースを扱うため、get関数を実行して取得できる値(上記の例では「p」に入っている値)は、Maybe型となります。上記の例では「Maybe Person」という型です。値がある場合には、printの結果として「Just (Personレコードの中身)」が、ない場合には「Nothing」と表示されます。
今回は、Maybe型をひっくるめてprintしていますが、「値がある場合にその値を使って何かをする」ときには、caseを使った場合分けなどの実装が必要です。
main = do
runSql $ do
let pid = toSqlKey 1
p <- get pid -- ここでビルドエラー発生
liftIO $ print pid
selectを使ったレコードの取得は次の記事で記載します。
オートマイグレーション
doMigration :: IO ()
doMigration = runNoLoggingT . runResourceT . withMySQLConn connectInfo . runReaderT . runMigration $ migrateAll
このような関数を定義しておくと、main中に「doMigration」と書くだけで、上述した「半自動のマイグレーション」が走ります。ここにある「migrateAll」は、テーブル定義のshareの行に書いてある「"migrateAll"」です。
自動/手動の区分は概ね下記の通りです。
-
自動で動く
- テーブル新規作成
- カラム追加(ただし、デフォルト値の対応は含まれない)
- 外部キー制約設定、プライマリーキー設定、オートインクリメント設定など
-
自動では動かず、エラーメッセージが出て終了する(=手動での対応が必要なもの)
- カラム削除
-
エラーメッセージも出ない(=対応してもいいが、しなくてもいいもの)
-
テーブル削除
不要になったテーブルは、自動では削除されませんが、あっても問題はないためか、「削除してください」というようなエラーメッセージも出ません。もちろん、いらないものは消したほうがいいかとは思いますので、手動でdrop tableしましょう。
カラム名変更は、Persistentとしては「カラム削除+カラム追加」として扱われます。当然自動の対応はありません。該当するカラムにデータが存在する場合には、マイグレーションを実行する前にalter table等で手動対応する方が手間は少ないと思います。
カラム追加の際に、レコードが既に存在する場合でも、そのレコードに対するデフォルト値の設定等はありません。そのような際には、マイグレーション後に手動で対応する必要があります。
まとめ
本記事では、Servantを含まない形でPersistentを使用することについて、概要を書いてみました。いろんなことを書いていますが、書いてない話の方が圧倒的に多いです。ここでかいていないテーマのうち、WebAPIとして使いがちなテクニックについては、後の記事で書いていく予定です。