はじめに
WebAPIプログラムから見た視点として、RDB側の型はバックエンド側の話題になります。逆側のフロントエンド側はAPIインタフェース(APIアクセスの引数および戻り値)の型となります。本記事では、APIインタフェースでの型定義および型変換の前半として、API戻り値について説明します。
WebAPIの戻り値と型
本記事シリーズでは、基本的に「WebAPIの戻り値がJSON形式である」という前提とします。理由はJSONが「JavaScriptで扱うのに一番お手軽で便利」「Servantで標準でサポートされている」というところです。一部のケースでは、XML等の他の形式が必要となる場面もあるかと思いますが、そういったケースの話は別の記事で紹介します。
Servantでは、URL定義とAPIハンドラの両方で、APIの戻り値の型を規定します。下記の例では、「GET /api/person」というAPIの戻り値が「ApiPerson型をJSON形式にしたもの」と規定されます。
-- Main.hsでのURL定義。行末の「Get 'JSON] ApiPerson」は「ApiPersonをJSON形式で返す」を規定するもの
type MyAppAPI' = "person" :> Capture "person_id" PersonId :> Get '[JSON] ApiPerson
-- Main.hsでのAPIハンドラ登録。URL定義での規定とAPIハンドラの型が一致する必要がある
myAppServer :: MyAppServer MyAppAPI
myAppServer = getPerson
-- Handler1.hsでのAPIハンドラの実体。APIハンドラ関数の戻り値の型もApiPersonとなっている
getPerson :: PersonId -> MyAppHandler ApiPerson
getPerson pid = errorHandler $ runSql $ getPerson' pid
Haskellコードでこのように実装する、ということは、「ビルド時点でAPIの戻り値の型が決まる。ランタイムではAPI戻り値の型は変えられない」ということでもあります。型が固定というのは自由度が小さいというデメリットがある反面、戻り値の扱いで意図しない動作(要はバグ)が起こりづらいというメリットもあります。また、「型が変えられない」とはいっても、型の中のメンバについては多少の自由度があります。
モデル定義によって使える型
特にAPI戻り値用の型定義をしなくても、モデルで定義した型についてはAPI戻り値としても使えます。お試し用としては使える場合があります。
モデルで定義した型
前の記事でRDB用にモデル(PersonやBlogPost等)を定義したのですが、その定義で「json」というキーワードをモデル名の横に加えると、APIの戻り値としても使用できます。
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
Person json -- API戻り値の型として使う場合は「json」が必要
name Text
age Int Maybe
type PersonType
deriving Show
|]
下記はPersonIdに対応するPersonを返すAPIハンドラです。戻り値の型が「MyAppHandler Person」、URL登録では「Get '[JSON] Person」となっているので、WebAPIとしてはPerson型をJSONに変換したものが返ります。
-- APIハンドラ(Handler1.hs)
getPerson :: PersonId -> MyAppHandler Person
getPerson pid = errorHandler $ runSql $ do
-- maybe_personは「Maybe Person」型
maybe_person <- get pid
-- personはJustの中身。getの戻り値がない(=Nothing)場合には、APIエラーを返す
person <- fromJustWithError (err404, "No such person ID") maybe_person
-- personはPerson型なので、そのままreturnしてOK
return person
-- ハンドラ登録(Main.hs)
myAppServer = getPerson
-- URL登録
type MyAppAPI' = "person" :> Capture "person_id" PersonId :> Get '[JSON] Person
以下がJSON化された戻り値です。
{"age":31
,"name":"Tom"
,"type":"PersonTypeUser"}
Person型に限らず、Persistentで定義するモデルにはidの値がありません。idを含めて返す場合はEntity型を使います。
Entity a 型
APIハンドラの型を「Person」ではなく「Entity Person」とすると、Person型にはないidも含めたJSONを返してくれるようになります。Person単独で使う場合よりも、若干有用度が上がります。この場合もモデル定義のところで「json」のキーワードを入れておく必要があります。
-- APIハンドラ(Handler1.hs)
getPerson :: PersonId -> MyAppHandler (Entity Person)
getPerson pid = errorHandler $ runSql $ do
-- person_listは[Entity Person](Entity Personのリスト)型
person_list <- select $ from $ \p -> do
where_ $ p ^. PersonId ==. val pid
return p
-- リストの先頭を返す(selectの戻り値がない=リストが空、の場合はAPIエラーを返す)
person <- headWithError (err404, "No such person ID") person_list
-- personはEntity Person型なので、そのままreturnしてOK
return person
-- ハンドラ登録(Main.hs)
myAppServer = getPerson
-- URL登録
type MyAppAPI' = "person" :> Capture "person_id" PersonId :> Get '[JSON] (Entity Person)
以下がJSON化された戻り値です。
{"age":31
,"name":"Tom"
,"id":2
,"type":"PersonTypeUser"}
API戻り値用の型定義
狙い
実際のWebAPIでは、RDBのテーブルの中身をそのまま一切加工せずにWebAPIの戻り値に渡すことはないでしょう。いくつかのカラムはそのまま渡すことはあっても、一部のカラムは隠蔽したり加工したりするかと思います。そのため、モデルで定義した型をWebAPI戻り値の型として使うのは実用性がないです。
とはいっても、モデルで定義した型とWebAPIで返す型は「似ている」ことが多いです。そのため、一般的なプログラミング言語では、RDBから取ってきた値を「ちょこちょこっと加工」してWebAPIの戻り値にする、というのもよくやる実装でしょう。
Haskellは「静的型付けの関数型言語」ですので、型を動的に追加したり削除したりする、というアプローチは取られません。今回の話題の場合でも「API戻り値専用の型を定義して、モデルの型から変換する」という方向になるでしょう。ここでの変換関数はボイラープレート(機械的で退屈な実装)となりがちです。HaskellではTemplateHaskell等、ボイラープレートを避けてくれるような仕組みもありますが、Servantではまだまだそのような支援が足りない状況ですので、多少のボイラープレートは書かないといけません。面倒といえば面倒なのですが、逆にこの変換関数さえできてしまえば、「ロジック」と「型の変換」を分離できるため、プログラムの見通しがとてもよくなります。実際、型が動的なプログラミング言語では、上述したような「ちょこちょこっと加工」するところでバグも起きがちなのでは、と思いますので、Haskellでの型変換の手間が悪い話ばかりではありません。
WebAPI戻り値用の型定義の例
能書きはこれくらいにして、WebAPI戻り値用の型定義の例を挙げていきましょう。
/api/personの戻り値の型として「JSONに変換することを前提としたApiPerson型」を定義する、とします。API用とはいっても、定義部分はHaskellの標準的なデータ型定義と全く同じです。以前の記事で書いたように、メンバ関数の衝突を防ぐために、データ型のメンバーにプレフィックス(この例でいうと「apiPerson」)をつけています。
-- WebAPI戻り値定義
-- 必要なGHC拡張拡張
{-# LANGUAGE TemplateHaskell #-}
data ApiPerson = ApiPerson
{ apiPersonId :: PersonId
, apiPersonName :: Text
, apiPersonAge :: Maybe Int
, apiPersonPersonType :: PersonType
} deriving (Generic, Show)
-- ApiPerson型をToJSONクラスのインスタンスにする
deriveToJSON defaultOptions {fieldLabelModifier = snakeKey 9} ''ApiPerson
この型をJSON変換できるようにするために、ApiPerson型のToJSONクラスのインスタンスにします。ここでいう(型)クラスとかインスタンスはオブジェクト指向言語でのそれらとは違い、ざっくり言うと「型をまとめたもの」が型クラス、その型クラスに属する型を型クラスのインスタンスといいます。ここでの「ToJSONクラスのインスタンスにする」というのは、型をToJSONクラスに属する=JSON化できる型にする、という意味になります。オブジェクト指向言語でいうと「インターフェース」に近い概念かと思います。
話が長くなりましたが、ApiPersonの型定義にあわせて「deriveToJSON」という行を実装すると、それだけで自動的にToJSONクラスのインスタンスになってくれます。これが使える条件はいくつかあって、
- ソースにGHC拡張オプション
{-# LANGUAGE TemplateHaskell #-}
を書く - ApiPersonの各メンバの型がToJSONクラスのインスタンスである
1つ目の条件は「書けば終わり」なので簡単なのですが、問題は2つ目です。基本的な型(Text, Int等)は既にToJSONクラスのインスタンスになっていますので、心配不要です。PersonTypeのような列挙型ですが、これも型の定義のところで「dervieToJSON」しておけばOKです。
data PersonType = PersonTypeUser | PersonTypeAdmin
deriving (Show, Read, Eq, Ord, Enum, Bounded)
-- ToJSONインスタンスのクラスにする
deriveToJSON defaultOptions ''PersonType
ApiPersonの中に、別の型を入れ子で入れたいこともあるかと思います。その場合も、入れ子で入れたい型を同じやり方でToJSONクラスのインスタンスにしてあげればOKです。列挙型にせよ自分が作成した型にせよ、ToJSONクラスのインスタンスにするのを忘れた時は、ビルド時に「ToJSONインスタンスじゃないよ」というエラーメッセージが出ますので対応可能です。
dervieToJSONに戻ります。ここには「defaultOptions {fieldLabelModifier =...」という引数があります。これはJSON化する時のオプションを指定できます。いくつか利用できますが、よく使いがちなのは以下の2つです。
- fieldLabelModifier : JSONのメンバを作成する時のフィルタを指定できる
- omitNothingFields : 値がNothingの場合にメンバを省略するようにできる
JSONキーのカスタマイズ
この節では、fieldLabelModifierの説明をします。ここには、元の型(例でいうとApiPerson)のメンバ関数名(「apiPersonName」など)をJSONのキー名にする関数を指定します。例えば、全文字を大文字にする関数「upperCaseAllChars」というのがあったとしますと
-- 全文字を大文字に変換する
upperCaseAllChars :: String -> String
「fieldLabelModifier = upperCaseAllChars」としてやると、先のapPersonNameは、JSONでのメンバでは「APPPERSONNAME」となります。
上記のApiPersonの値は、そのままJSON化すると、下記のようなJSONの値になります。
{"apiPersonId" : 3
,"apiPersonName" : "Tom"
,"apiPersonAge" : 23
,"apiPersonPersonType" : "PersonTypeUser" }
プレフィックスの「apiPerson」が邪魔ですね。fieldLabelModifierにdrop関数を使うと、JSONキーのプレフィックスを外すことができます。drop関数は
--- drop 「先頭から抜きたい文字数」「元文字列」→「先頭から文字が削除された文字列」
drop :: Int -> String -> String
という型ですので、「drop 9」とやると、「文字列の先頭から9文字を削除したものを返す」関数となります。deviveToJSONで「fieldLabelModifier = drop 9」としてやると、JSONは下記のようになります。
{"Id" : 3
,"Name" : "Tom"
,"Age" : 23
,"PersonType" : "PersonTypeUser" }
だいぶすっきりしました。これでもいいのですが、もし「JSONのキーはスネークケースにしたい」ということでしたら、もうちょっと加工したくなります。下記のように、「先頭からN文字を削除してスネークケースに変換する」という関数を用意すれば(casesパッケージを使用しています)
import Cases (snakify)
import Data.Text as T (pack, unpack)
-- 文字列の先頭から指定した文字数を削除して、残りをスネークケースにする
snakeKey :: Int -> String -> String
snakeKey drop_count = T.unpack . snakify . T.pack . drop drop_count
deviveToJSONで「fieldLabelModifier = snakeKey 9」とすることで、JSONは下記のようになります。
{"id" : 3
,"name" : "Tom"
,"age" : 23
,"person_type" : "PersonTypeUser" }
すっきりしましたね。JSONのキーをキャメルケースにするかスネークケースにするかは好みの問題でもあるので、このあたりは「自分のスタイルにあわせた実装」ができればいいかと思います。
念のため、「JSONキーをキャメルケースにしたいが、先頭は小文字にしたい」ということでしたら、下記の関数を使うのがいいかと思います。使い方はdropやsnakeKeyと同じです。
import Data.Char (toLower)
-- 文字列の先頭から指定した文字数を削除して、残りの文字列の先頭の文字を小文字にする
camelKey :: Int -> String -> String
camelKey dropcount msg = case msg_body of
msg_body_1 : msg_body_rest -> toLower msg_body_1 : msg_body_rest
[] -> []
where
msg_body = drop dropcount msg
1つ注意点がありまして、snakeKeyの中で使っているsnakifyという関数は数字も単語区切りと判定します。そのため「apiPersonMemo1」というメンバを作った場合、JSONのキーは「memo_1」となります。個人的には、ここは「memo1」となるべきだろう、と思いますが、対応するのも面倒なので、API向けの型には数字を入れないようにしています。
あと、根本的な疑問として「何故プログラマがプレフィックの文字数を数えてそれをコードに埋め込まないといけないのか」があるかと思います。その気になれば、こういった実装も自動化できるはずでしょうが、その努力をするくらいなら、Persistentのように「メンバ関数には型名がプレフィックスで自動でつき、JSONではスネークケースとなる」ことが自動できるようにすべきでしょうし、実際にできるはずです。(自分が)TemplateHaskellのスキルを上げられればきっと...
APIの型とJSONの値
APIの型の種類とJSONの値の関係として、注意するところは下記の通りです。
- Maybeがつく型(Maybe Int、など)は、「Just a」の場合はaが値に、Nothingの場合はnullが値となります(後述するomitNothingFieldsを指定しない場合)。
- 列挙型のデータ型は、そのまま文字列となります("PersonTypeUser"、"PersonTypeAdmin"、など)
WebAPI戻り値用の型への型変換
型変換は機械的なだけで、基本的にシンプルです。変換はただの関数で実装しています。特に型クラス等の仕組み等は使っていません(使うと、よりいいことがあるかも、ですが)。
-- 「PersonIdとPerson」を「ApiPerson」に変換する
toApiPerson :: PersonId -> Person -> ApiPerson
toApiPerson pid p =
ApiPerson {apiPersonId = pid
, apiPersonName = personName p
, apiPersonAge = personAge p
, apiPersonPersonType = personType p}
「Entity Person」→「ApiPerson」変換する関数も用意しておくと、Esqueletoとの組み合わせで楽ができます。関数名の「FE」は「From Entity」のつもりです。やっていることは、Entity(IDとバリューのペア)をばらけさせているだけです。
toApiPersonFE :: Entity Person -> ApiPerson
toApiPersonFE (Entity pid p) = toApiPerson pid p
WebAPI戻り値のカスタマイズ
一部のJSONキーを省略する
ここでは、先にあった「deviveToJSONで設定できるomitNothingFields」について説明します。omtNothingFields=Trueとすると、「Maybe a 型」で値がNothingの場合には、JSONからキーごと削除されます。下記は、omitNothingFielsを指定しない(デフォルト)とした時に、RDBでの値がNullが含まれる場合のJSONの値です。ageのところが「null」となっています。
{"id":2
,"name":"Tom"
,"age":null
,"person_type":"PersonTypeUser"}
次は、omitNothingFields=Trueとした場合のJSONの値です。キー「age」自体がなくなっています。
{"id":2
,"name":"Tom"
,"person_type":"PersonTypeUser"}
さて、これを利用すると「全てのメンバをMaybe型としてやれば、全てのメンバの有無を実行時に制御できるのでは」と思うのではないかと思います。試しにApiPersonの各メンバをMaybe型にします。既にMaybe型になっているものは「Maybe (Maybe a)」となります。
data ApiPerson = ApiPerson
{ apiPersonId :: Maybe PersonId
, apiPersonName :: Maybe Text
, apiPersonAge :: Maybe (Maybe Int)
, apiPersonPersonType :: Maybe PersonType
} deriving (Generic, Show)
deriveToJSON defaultOptions {fieldLabelModifier = snakeKey 9, omitNothingFields=True} ''ApiPerson
toApiPerson :: PersonId -> Person -> ApiPerson
toApiPerson pid p = ApiPerson
{apiPersonId = Just pid
, apiPersonName = Just $ personName p
, apiPersonAge = Just $ personAge p
, apiPersonPersonType = Just $ personType p}
toApiPerson関数では、全ての値を「Just」にしています。これは「全てのメンバをJSONに含める」ということを意味します。結果は以下の通りです。omitNothingFieldsなしの時と同じ結果となっています。
{"id":2
,"name":"Tom"
,"age":null
,"person_type":"PersonTypeUser"}
もちろん、toApiPersonの中身のJustをNothingに変えると、JSONの値からは削除されます。Maybe (Maybe a)としたものは、値によって以下の動作となります。
- Nothing : JSONからキーと値が削除される
- Just Nothing : JSONにキーが含まれ、値がnullとなる
- Just (Just a) : JSONにキーが含まれ、値がaとなる
というわけで、「キーを含める/含めない」と「値をnullにする/null以外の値にする」を全て制御可能となります。
タイムゾーンと時刻の取扱い
前の記事で書いたタイムゾーンと時刻の取扱について書きます。
以下では、Persistentを使いつつ、日付の切り替わりを日本時間と同じようにするために、「Haskellでの型はUTCTimeとして扱うが、実態としてはJSTの時刻を扱う」という若干トリッキーなアプローチをする場合の話です。
この前提では、日本時間の0時は、Haskellとしては「UTCの0時」として保持します。RDBには「0時」として認識されつつ、(これも前提である)タイムゾーンがJSTですので、「JSTの0時」として記録されますから、辻褄はあいます。問題は、「API出力フォーマット」と「現在時刻とのずれ」となります。
API出力フォーマット
先に断っておくと、この問題は「UI側のプログラミング言語(フレームワークやライブラリを含む)」と「HaskellでのJSON出力フォーマット」との組み合わせの問題である、ということです。本記事では、「UI側ではJavaScriptでtoLocaleDateString()を使って文字列をDateオブジェクトに変換する」という前提とします。もちろん、時刻パースのライブラリが異なると、ここで書いた話も変わってきます。
Haskellの話に戻ると、タイムゾーンの観点からは時刻を表す型は
- UTCTime
- LocalTime
- ZonedTime
の3つがあります。今回、ApiPersonにこの3つの型のメンバを追加して、「UTCでの2018/1/4 00:00:00」をそれぞれの型でJSONとして出力してみました。コードは以下の通りです。
-- デモ用にメンバを追加(下3つ分)
data ApiPerson = ApiPerson
{ apiPersonId :: PersonId
, apiPersonName :: Text
, apiPersonAge :: Maybe Int
, apiPersonPersonType :: PersonType
, apiPersonUtcTime :: UTCTime -- デモ用に追加(UTCTime)
, apiPersonLocalTime :: LocalTime -- デモ用に追加(LocalTime)
, apiPersonZonedTime :: ZonedTime -- デモ用に追加(ZoneTime)
} deriving (Generic, Show)
deriveToJSON defaultOptions {fieldLabelModifier = drop 9} ''ApiPerson
-- デモ用に修正
toApiPerson :: PersonId -> Person -> ApiPerson
toApiPerson pid p = ApiPerson
{apiPersonId = pid
, apiPersonName = personName p
, apiPersonAge = personAge p
, apiPersonPersonType = personType p
, apiPersonUtcTime = utc_time
, apiPersonLocalTime = local_time
, apiPersonZonedTime = zoned_time}
where
utc_time = UTCTime (fromGregorian 2018 1 4) 0 -- 2018/1/4 00:00:00 (UTC)
local_time = utcToLocalTime timeZone utc_time -- timeZone=JSTとしてLocalTimeに
zoned_time = utcToZonedTime timeZone utc_time -- timeZone=JSTとしてZoneTimeに
-- 時差を定義(+9時間)
timeZoneHours :: Int
timeZoneHours = 9
-- タイムゾーンを定義(JST)
timeZone :: TimeZone
timeZone = TimeZone (60 * timeZoneHours) False "JST"
結果は下記の通りです。LocalTimeはUTCでないにも関わらず、タイムゾーン情報がありません。そのため、パーサが何であっても正しくパースするのは不可能です。UTCTimeは末尾のZがあり、正しくパースしてくれそうですが、うまくいきませんでした。ミリ秒の表示がないせいでしょうか。結果として、JavaScriptで正しくパースしてもらえたのはZonedTimeのみでした。
{"Id":1
,"name":"Tom"
,"age":31
,"type":"PersonTypeUser"
,"utc_time":"2018-01-04T00:00:00Z"
,"local_time":"2018-01-04T09:00:00"
,"zoned_time":"2018-01-04T09:00:00+09:00"}
ZonedTimeを使う、という方針は決まったのですが、JSONで出力する際には、「日本時刻0時」として内部的に持っている「UTCの0時」を9時間引いて「UTCの-9時」とした上でutcToZonedTimeでJSTに変換し、その結果として「JSTの0時」にする必要があります。これを行う関数が下記の「toLocalTime」です。timeZoneは上のデモコードで掲載したものと同じです。
-- Haskellの内部表現からAPI出力への変換
toLocalTime :: UTCTime -> ZonedTime
toLocalTime = utcToZonedTime timeZone . addUTCTime ((-3600) * fromIntegral timeZoneHours)
toLocalTime関数の使い方ですが、例えばモデルのPersonに「createTime」というメンバがあったとして、ApiPersonにも同じメンバがあるとします。最初のポイントは「モデルはUTCTime、API戻り値ではZonedTimeにする」ということです。
Person json
name Text
age Int Maybe
type PersonType
createTime UTCTime -- モデルではUTCTimeとする
deriving Show
data ApiPerson = ApiPerson
{ apiPersonId :: PersonId
, apiPersonName :: Text
, apiPersonAge :: Maybe Int
, apiPersonPersonType :: PersonType
, apiPersonCreateTime :: ZonedTime -- API側はZonedTimeとする
} deriving (Generic, Show)
deriveToJSON defaultOptions {fieldLabelModifier = snakeKey 9} ''ApiPerson
その上で、モデルからAPI戻り値への変換関数toApiPersonでtoLocalTimeを使用します。API側の型をZonedTimeにさえしておけば、toLocalTimeを入れ忘れても、型が合わないことによりビルドエラーが出ますのでわかります。
toApiPerson :: PersonId -> Person -> ApiPerson
toApiPerson pid p = ApiPerson
{apiPersonId = pid
, apiPersonName = personName p
, apiPersonAge = personAge p
, apiPersonPersonType = personType p
-- UTCTime(9時間ずれ)から、ZonedTimeに変換する
, apiPersonCreateTime = toLocalTime $ personCreateTime p
}
現在時刻の取扱い
今回のアプローチは、Haskell内部として「意図的に時刻をずらして」います。そのため、現在時刻を取得する時には、このずれに対応する必要があります。このような対応をした時刻取得関数としては、下記のようになります。getCurrentTimeの取得値に単純に9時間を足しているだけです。getCurrentTimeはIOアクションなので、その結果に時間を足して返すために、<$>を使っています。
getLocalTime :: IO UTCTime
getLocalTime = addUTCTime (3600 * fromIntegral timeZoneHours) <$> getCurrentTime
このgetLocalTime関数の型はgetCurrentTimeの型と同じですので、使い方も同じです。APIハンドラ関数の中だとlifIOを使って
-- APIハンドラ関数の中の場合
local_time <- liftIO getLocalTime
とすると内部表現用に9時間ずれた現在時刻が返ります(日本時間0時にこれを実行すると、内部表現としては「UTCの0時」が返ります)。
まとめ
ServantではJSONエンコードが標準でサポートされているため、APIの戻り値をJSONで固定的な型として返す限り、特別な対応がなくても期待した動作が実装できるでしょう。HaskellではAPI戻り値用の型定義や型変換をする必要がありますが、プログラムの保守性等を考えると、それをするだけの価値はあるかと思います。
次回はAPIインタフェース側の後半として、APIの入力値(URLのクエリー文字列、リクエストボディ)を扱います。