8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Haskell・Servant+Persistent/Esqueletoで作る実用WebAPI (2) Persistent

Last updated at Posted at 2017-12-29

はじめに

先の記事で書いた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として使いがちなテクニックについては、後の記事で書いていく予定です。

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?