この記事は、スタートトゥデイ工務店 Advent Calendar 14日目の記事です。
wai(Haskell製のWebアプリケーション規格)に準拠し、warp(wai準拠のHaskell製Webサーバ)上で動作するWebアプリケーションの開発について紹介する。基本性能を際立たせるため、便利なフレームワークはあえて使用しない。
以前に非常に単純なサンプルを紹介した記事の続編となる。
今回は、本格的なWebアプリケーションに欠かせない、データベースとの連携を試してみた。
バックエンドにはMySQLを使用した。
ソースコードは https://github.com/mitsuji/wai-example-mysql にある。
1. サンプルアプリケーションの概要
下記のようなデータを扱うJSON-APIを実装した。
1件のコーディネートに対して、ジャンルが1つ、タグが複数(0..N)紐づくものとする。

また、MySQLのCREATE文は下記のようになる。
CREATE TABLE genre (
genre_id INT NOT NULL AUTO_INCREMENT
,genre_title nvarchar(31) NOT NULL
,PRIMARY KEY (genre_id)
);
CREATE TABLE tag (
tag_id INT NOT NULL AUTO_INCREMENT
,tag_title nvarchar(31) NOT NULL
,PRIMARY KEY (tag_id)
);
CREATE TABLE look (
look_id INT NOT NULL AUTO_INCREMENT
,look_create_dt DATETIME NOT NULL
,look_update_dt DATETIME NOT NULL
,look_title nvarchar(31) NOT NULL
,look_description nvarchar(400) NOT NULL
,look_genre_id INT NOT NULL
,PRIMARY KEY (look_id)
,FOREIGN KEY (look_genre_id) REFERENCES genre (genre_id)
);
CREATE TABLE look_has_tag (
lht_look_id INT NOT NULL
,lht_tag_id INT NOT NULL
,PRIMARY KEY (lht_look_id,lht_tag_id)
,FOREIGN KEY (lht_look_id) REFERENCES look (look_id)
,FOREIGN KEY (lht_tag_id) REFERENCES tag (tag_id)
);
Haskellのデータ定義は下記のようになった。
data Genre = Genre GenreId Title [Look]
deriving (Eq)
data Tag = Tag TagId Title [Look]
deriving (Eq)
data Look = Look { lookId :: LookId
, lookCreateDt :: CreateDT
, lookUpdateDt :: UpdateDT
, lookTitle :: Title
, lookDescription :: Description
, lookGenre :: Genre'
, lookTags :: [Tag']
}
deriving (Eq)
newtype Genre' = Genre' Genre
deriving (Eq)
newtype Tag' = Tag' Tag
deriving (Eq)
2. 実装の方針
下記の方針で実装した。
* mysql-simpleを使用してMySQLのデータをHaskellのデータに変換する。
* aesonを使用してHaskellのデータをJSONに変換する。
* waiを使用してWebのインターフェースを提供する。
また、実用的なサンプルとするため、下記を盛りこんだ。
* トランザクション処理
* コネクションプール
* N対Nのリレーション
3. MySQLのデータをHaskellのデータに変換する。
mysql-simple では QueryResult という型クラスが用意されており、
任意のデータ型をこの型クラスのインスタンスにすることで、
MySQLに投げたクエリの結果をHaskellのデータに変換することができる。
下記のように convertResults を実装すればよい。
instance QueryResults Genre' where
convertResults [fa,fb] [va,vb] = Genre' $ Genre a b []
where !a = convert fa va
!b = convert fb vb
convertResults fs vs = convertError fs vs 2
上記を定義すると、下記のようにクエリを投げることができる。
結果は、関数の型からも分かるように Genre’ のリストとして取り出すことができる。
select_genre :: Connection -> IO [Genre']
select_genre conn =
query_ conn [r|
SELECT
genre_id
,genre_title
FROM genre
ORDER BY genre_id
|]
4. HaskellのデータをJSONに変換する。
aeson では ToJSON という型クラスが用意されており、
任意のデータ型をこの型クラスのインスタンスにすることで、
JSONへの変換を定義することができる。
下記のように toJSON を実装すればよい。
instance ToJSON Tag where
toJSON (Tag id t ls) =
object ["id" .= id
,"title" .= t
,"looks" .= ls
]
ソースコードのプロジェクトにREPLで入ると、
下記のようにaesonの動作を試すことができる。
*Main Data JSON MySQL> AE.encode $ Tag 100 "タグ100" []
"{\"looks\":[],\"id\":100,\"title\":\"\227\130\191\227\130\176\&100\"}"
5. コネクションプール
コネクションプールはresouce-poolというライブラリを使用して実装した。
このライブラリを使うと、DBの接続に限らず任意のリソースのプールを簡単に実装することができるようだ。
アプリケーション起動時に createPool でプールを作っておく。
createPool にはプールにリソースが確保されるときの処理、プールからリソースが開放されるときの処理と、プールのチューニングに関するいくつかのパラメータを設定する。
main :: IO ()
main = do
host:port:_ <- getArgs
cp <- createPool connect close 10 10 10
Warp.runSettings (
Warp.setHost (fromString host) $
Warp.setPort (read port) $
Warp.defaultSettings
) $ routerApp cp
where
connect :: IO MySQL.Connection
connect = MySQL.connect MySQL.defaultConnectInfo {
MySQL.connectHost = "localhost"
,MySQL.connectUser = "wai_exam_admin"
,MySQL.connectPassword = "abcd1234"
,MySQL.connectDatabase = "wai_exam"
}
close = MySQL.close
リソースプールの変数を引き渡しておけば、アプリケーションの任意の箇所で
withResource を使用して、プール内のリソースを使うことができる。
関数の呼び出しが深くなる場合は、リソースプールの共有に
Readerモナドなどを使うとよいだろう。
-- GET /v1/genre
(Right M.GET, [_,_]) -> do
bs <- AE.encode <$> (withResource cp $ \conn -> select_genre conn)
respond $ response200 bs
6. トランザクション処理
トランザクション処理は下記のように withTransaction を使うだけで実現されるようだ。
delete_look :: Connection -> LookId -> IO ()
delete_look conn id =
withTransaction conn $ do
execute conn
"DELETE FROM look_has_tag WHERE lht_look_id = ?" (Only id)
execute conn
"DELETE FROM look WHERE look_id = ?" (Only id)
return ()
7. ルーティング
URLのパスやHTTPメソッドの種類と、評価される関数をどのように紐づけるか。
wai が Text型のリストとしてURLのパスを提供してくれているので、
下記のようにパターンマッチを使ってルーティングを定義することができた。
フレームワークを使った場合ほどではないが、比較的簡潔にできた。
routerApp :: Pool MySQL.Connection -> Wai.Application
routerApp cp req respond = case Wai.pathInfo req of
"v1" : _ -> (v1App cp req respond) `catch` onException -- /v1{var}
_ -> staticApp req respond -- static html/js/css files
where
onException :: SomeException -> IO Wai.ResponseReceived
onException (SomeException e) = respond $ responseNG 5000 "unknown error"
-- onException (SomeException e) = respond $ responseNG 5000 $ displayException e
v1App :: Pool MySQL.Connection -> Wai.Application
v1App cp req respond = case Wai.pathInfo req of
_ : "genre" : _ -> genreApp cp req respond -- /v1/genre{var}
_ : "tag" : _ -> tagApp cp req respond -- /v1/tag{var}
_ : "look" : _ -> lookApp cp req respond -- /v1/look{var}
_ -> staticApp req respond -- static html/js/css files
HTTPメソッドも合わせてパターンマッチすれば、RESTっぽいことも簡単にできる。
genreApp :: Pool MySQL.Connection -> Wai.Application
genreApp cp req respond = case (M.parseMethod (Wai.requestMethod req), Wai.pathInfo req) of
-- POST /v1/genre
(Right M.POST, [_,_]) -> do
ps <- parseForm req
case lookupParam "title" ps of
Nothing -> respond $ responseNG 5101 "invalid title"
Just t -> do
bs <- AE.encode <$> (withResource cp $ \conn -> create_genre conn t)
respond $ response200 bs
-- DELETE /v1/genre/{id}
(Right M.DELETE, [_,_,id]) ->
case readMaybe (T.unpack id) of
Nothing -> respond $ responseNG 5102 "invalid id"
Just id -> do
withResource cp $ \conn -> delete_genre conn id
respond responseOK
8. SQLインジェクション防止機能
mysql-simple では SQLインジェクションを防止するため、query や query_ には
文字列のリテラルでSQL文を渡さなければならないようにしてあるようだ。
文字列結合を使ってSQL文を組み立てることができないため、
条件を任意で指定するSELECT文が少し作りにくかったが、
下記のようにすることで対応できた。
-- [TODO] sanitize param of LIKE phrase
select_look :: Connection -> Maybe Title -> Maybe Description -> Maybe GenreId -> Maybe TagId -> IO [Look]
select_look conn mt md mgid mtid = do
(ft,t) <- case mt of
Nothing -> return (1::Int,"%")
Just t -> return (0::Int,t)
(fd,d) <- case md of
Nothing -> return (1::Int,"%")
Just d -> return (0::Int,d)
(fgid,gid) <- case mgid of
Nothing -> return (1::Int,0)
Just gid -> return (0::Int,gid)
(ftid,tid) <- case mtid of
Nothing -> return (1::Int,0)
Just tid -> return (0::Int,tid)
ls <- query conn [r|
SELECT
look_id
,look_create_dt
,look_update_dt
,look_title
,look_description
,genre_id
,genre_title
FROM look
LEFT OUTER JOIN genre ON look_genre_id = genre_id
WHERE 1 = 1
AND (1=? OR look_title LIKE ?)
AND (1=? OR look_description LIKE ?)
AND (1=? OR look_genre_id = ?)
AND (1=? OR look_id IN
(SELECT lht_look_id FROM look_has_tag WHERE lht_tag_id =?))
ORDER BY look_id
|](ft,t,fd,d,fgid,gid,ftid,tid)
tags <- select_tag_for_looks conn ls
return $ merge_tags tags ls
9. まとめ
- mysql-simple などのライブラリを使えば DBのデータとHaskellのデータのマッピングは簡単。
- aeson を使えば Haskellのデータを JSON化するのは簡単。
- resource-pool を使えばコネクションプールの導入は簡単。
- mysql-simple のトランザクション処理は簡単確実。
- Webアプリケーションのパス/メソッドルーティングは単純なパターンマッチでもある程度可能。
- mysql-simple を使えば SQLインジェクションの危険性が低下。
10. 今後の課題
- mysql-simple 以外のDBライブラリもいろいろ試してみたい。
- 入力のバリデーションをもっとちゃんと実装してみたい。
- ファイルのアップロードも試してみたい。
- フロントエンドを実装して、実際に使ってみたい。
