Admittedly something small.

ちょっと小さいのはたしかですが。

PureScriptを使ってJSONの型安全な読み書きを自動化する

2016年12月11日 ( 8年前に投稿 )

JSON purescript

TL;DR Version

JSONファイルを読み書きするには、purescript-foreign-genericが便利です。

JSONめんどうくさい

いまゲームを作っているんですが、実行環境ごとに適切な値が異なったり、実際にプレイしてみないと適切な値がわからないパラメータがたくさんあります。それで、コンパイルなしで細かい調節をできるようにしようといろんなパラメータをJSONのオプションファイルにくくり出しました。たとえばこんな感じです。

{
    "loadDistance": 6,
    "fogDensity": 0.005,
    "maximumLoadedChunks": 2197
}

静的型付けの言語であるPureScriptで書いているので、このオプションファイルを読み取って次のような型のデータとして受け取りたいです。

newtype Options = Options {
    loadDistance :: Int,
    fogDensity :: Number,
    maximumLoadedChunks :: Int
}

さて、purescript-affjaxパッケージを使ってオプションファイルをajaxでgetすると、その結果のresponseプロパティはJsonArrayBufferUnitStringForeignDocumentBlobのうちの任意の型で受け取ることができます。JSONを読み取った時はこのうちForeignJsonを使うことになりますが、Foreignを使った場合はこんな感じになります。

readOptions :: Foreign -> F Options
readOptions value = do
    loadDistance <- readProp "loadDistance" value
    fogDensity <- readProp "fogDensity" value
    maximumLoadedChunks <- readProp "maximumLoadedChunks" value
    pure $ Options {
        loadDistance,
        fogDensity,
        maximumLoadedChunks
    }

Foreignpurescript-foreignというパッケージで定義されているデータ型で、PureScriptで外部から何かのデータを受け取った時によく使われます。ForeignはJavaScriptの任意のデータを表していて、readProp関数を使うとForeignなデータからプロパティを読み取ることができますので、readPropでプロパティを読み取っては変数に移し、最後にオブジェクトでまとめて返します。同じような式の繰り返しなので複雑というわけではありませんが、なんかちょっと冗長です。ちなみに、Jsonのほうで受け取ったとしても、readPropの代わりに.?という演算子でプロパティを読み取ることになるだけで、コーディングの手間としてはあんまり変わらなかったりします。

オプションが3つくらいならどうということはないのですが、開発が進むにつれてオプションがもりもりと増えてきてしまい、それに伴って読み取る部分のコードももりもり増えてきてしまいました。

newtype Options = Options {
    loadDistance :: Int,
    fogDensity :: Number,
    maximumLoadedChunks :: Int,
    vertexColorEnabled :: Boolean,
    shadowEnabled :: Boolean,
    shadowDisplayRange :: Int,
    shadowMapSize :: Int,
    skyboxRotationSpeed :: Number,
    enableWaterMaterial :: Boolean,
    chunkUnloadSpeed :: Int,
    jumpVelocity :: Number,
    initialWorldSize :: Int,
    moveSpeed :: Number,
    cameraTargetSpeed :: Number,
    cameraRotationSpeed :: Number,
    cameraZoomSpeed :: Number,
    cameraMaxZ :: Number,
    cameraMinZ :: Number,
    cameraFOV :: Number,
    cameraMinimumRange :: Number,
    cameraMaximumRange :: Number,
    cameraHorizontalSensitivity :: Number,
    cameraVertialSensitivity :: Number,
    pointerHorizontalSensitivity :: Number,
    pointerVerticalSensitivity :: Number,
    landingVelocityLimit :: Number,
    landingDuration :: Int
}

readOptions :: Foreign -> F Options
readOptions value = do
    loadDistance <- readProp "loadDistance" value
    fogDensity <- readProp "fogDensity" value
    maximumLoadedChunks <- readProp "maximumLoadedChunks" value
    vertexColorEnabled <- readProp "vertexColorEnabled" value
    shadowEnabled <- readProp "shadowEnabled" value
    shadowDisplayRange <- readProp "shadowDisplayRange" value
    shadowMapSize <- readProp "shadowMapSize" value
    skyboxRotationSpeed <- readProp "skyboxRotationSpeed" value
    enableWaterMaterial <- readProp "enableWaterMaterial" value
    chunkUnloadSpeed <- readProp "chunkUnloadSpeed" value
    jumpVelocity <- readProp "jumpVelocity" value
    initialWorldSize <- readProp "initialWorldSize" value
    moveSpeed <- readProp "moveSpeed" value
    cameraTargetSpeed <- readProp "cameraTargetSpeed" value
    cameraRotationSpeed <- readProp "cameraRotationSpeed" value
    cameraZoomSpeed <- readProp "cameraZoomSpeed" value
    cameraMinZ <- readProp "cameraMinZ" value
    cameraMaxZ <- readProp "cameraMaxZ" value
    cameraFOV <- readProp "cameraFOV" value
    cameraMinimumRange <- readProp "cameraMinimumRange" value
    cameraMaximumRange <- readProp "cameraMaximumRange" value
    cameraHorizontalSensitivity <- readProp "cameraHorizontalSensitivity" value
    cameraVertialSensitivity <- readProp "cameraVertialSensitivity" value
    pointerHorizontalSensitivity <- readProp "pointerHorizontalSensitivity" value
    pointerVerticalSensitivity <- readProp "pointerVerticalSensitivity" value
    landingVelocityLimit <- readProp "landingVelocityLimit" value
    landingDuration <- readProp "landingDuration" value
    pure $ Options {
        loadDistance,
        fogDensity,
        maximumLoadedChunks,
        vertexColorEnabled,
        shadowDisplayRange,
        shadowEnabled,
        shadowMapSize,
        skyboxRotationSpeed,
        enableWaterMaterial,
        chunkUnloadSpeed,
        jumpVelocity,
        initialWorldSize,
        moveSpeed,
        cameraTargetSpeed,
        cameraRotationSpeed,
        cameraZoomSpeed,
        cameraMinZ,
        cameraMaxZ,
        cameraFOV,
        cameraMinimumRange,
        cameraMaximumRange,
        cameraHorizontalSensitivity,
        cameraVertialSensitivity,
        pointerHorizontalSensitivity,
        pointerVerticalSensitivity,
        landingVelocityLimit,
        landingDuration
    }

ぎゃあああああああああああああああああああああ! オプションの名前がコードの中にそれぞれの4回も登場していて、これはなかなかつらいボイラープレイートです。まだ序盤でコレですから、さらに開発が進んだらどんなことになるやら。弱い型付けのJavaScriptならこんな手間はないわけで、こんなことをやっていてはやっぱり強い型付けの言語はめんどうくさいねと言われても仕方ありません。

ジェネリックプログラミングは便利です

これではあんまりなので何かいい方法がないか探したんですが、そういえば最近PureScriptはジェネリックプログラミングに力を入れていることを思い出しました。コンパイル時にデータ型の定義からいろいろなコードを自動的に生成してくれる便利なやつで、Genericというクラスのインスタンスを自動導出できるようになったのです。なお、Genericという名前のクラスはpurescript-genericsパッケージのData.Generic.Genericpurescript-generics-repパッケージのData.Generic.Rep.Genericsクラスの2種類があって微妙に違います。Data.Generic.Genericは古いパッケージで、新しいData.Generic.Rep.Genericsパッケージのほうは古いパッケージに比べて少し制約が強くなり型安全性が向上しています。現在では古いData.Generic.Genericsを使う必要はなく、常に新しい方であるData.Generic.Rep.Genericsを使えばいいみたいです。

Data.Generic.Rep.Genericsを使えば自動的にJSONを読み取ることができるのは見当がつきましたが、きっと誰かがすでにそういうライブラリを作っているはずです。筆者は面倒くさがりなので、なるべくなら自分でライブラリを書きたくありません。探したらpurescript-foreign-genericパッケージがありました。これの使い方は簡単で、まずはderive instanceGenericのインスタンスを自動導出します。

derive instance genericOptions :: Generic Options _

これでreadGenericという関数が使えるようになるので、これを呼び出すだけです。

readOptions :: Foreign -> F Options
readOptions = readGeneric defaultOptions { unwrapSingleConstructors = true }

readOptionsが恐ろしく簡単になりました。今までの苦労はなんだったんだ……。これだけでJSONを完全に型安全に読み取ることができますし、新たなオプションを追加したい時は、もうreadOptions関数をいじる必要はありません。オプションファイルoptions.jsonに値を追加し、Optionsの型にプロパティの定義を追加するだけです。purescript-argonaut、もう要らない子じゃん。

後になって、readOptionsのような専用の関数を与えるのではなく、IsForeignクラスやAsForeignクラスのインスタンスにしたほうが何かと便利なことにも気が付きました。

instance isForeignOptions :: IsForeign Options where
    read = readGeneric defaultOptions { unwrapSingleConstructors = true }

instance asForeignOptions :: AsForeign Options where
    write = toForeignGeneric defaultOptions { unwrapSingleConstructors = true }

これで、コンパイルが通る限りはwriteで確実にJSONへと変換できますし、readでJSONから安全に読み取ることができます。うっかりOptionsの定義を間違えてJSONには変換できないようなデータ、たとえば関数をプロパティにを加えてしまった場合は確実にコンパイルエラーが出ます。JavaScriptでJSON.stringidyとかを通すとJSONに変換できないデータは無視されるので、受け取った側でデータが欠落していてぎょっとすることがたまにありますが、そういうミスもコンパイル時に検知できます。

もっとも、型安全に取り扱えるような堅実な設計のJSONばかりではないでしょうし、numberstringの両方を取りうるようなプロパティを持つJSONもあるでしょう。そういう静的型付けでは扱いにくいJSONを読み取るには、Foreignargonautを使ってプロパティをひとつひとつ丁寧に読み取っていくしかありません。

JSONなWeb APIを叩く

手順は同じようなものですが、GithubのAPIを叩くサンプルも作ってみました。以前の記事のために書いたコードを流用したものです。

  • https://github.com/aratama/example-followbox

簡単に手順をまとめてみます。

1. データ型を定義する

newtype User = User {
    login :: String,
    avatar_url :: String,
    html_url :: String
}

type Users = Array User

2. Genericそのほかのインスタンスを自動導出あるいは自分で定義する

instance isForeignUser :: IsForeign User where
    read = readGeneric defaultOptions { unwrapSingleConstructors = true }

derive instance genericUser :: Generic User _

instance showUser :: Show User where
    show = genericShow

3. Affjaxを使ってWeb APIを叩く非同期な作用を定義する

fetchUsers :: forall eff. Int -> Aff (ajax :: AJAX | eff) Users
fetchUsers since = do
    res <- get $ "https://api.github.com/users?since=" <> show since
    liftEx $ read res.response

liftEx :: forall m e. (MonadError Error m, Show e) => ExceptT e Identity ~> m
liftEx = either (throwError <<< error <<< show) pure <<< runExcept

これだけです。なお、readの失敗を表現する型はExceptTというデータ型なのですが、それに対してAffの内部で使われているエラー表現のerr :: EXCEPTIONはJavaScriptのErrorオブジェクトベースのものなので、実はエラーの内部的な表現が異なります。liftExという補助的な関数は、ExceptTAffへと変換してこのギャップを埋め合わせるためのものです。

また、ネットワークの不調などで失敗することも多いと思いますが、エラーハンドリングをどうするべきかはアプリケーションによってまちまちなので、ここでは特に細かいエラーハンドリングはしていません。

型安全でない方法

なお、型安全でなくていいのならunsafeFromForeignを使うか、あるいはもっとワイルドにunsafeCoerceで型を変換してしまえばOKです。PureScriptのデータ型はJavaScriptのものとちゃんと対応しているので、実行時のデータがどうなっているのかちゃんと理解していれば問題なく動きます。そっちのほうが効率面でも有利です。

さいごに

これでも動的型付けのプログラミング言語ならもっと簡単だと思うかもしれませんが、こういうオプションファイルを作ったらどんなオプションがあるのかドキュメントに書かなくてはいけませんから手間は結局同じことです。というかドキュメントに書いてもどうせ修正を忘れてコードとドキュメントの内容がズレ始めるに決まっているので、静的型付けの言語を使って型の定義として書いたほうがよほどマシです。

PureScriptのジェネリックプログラミングはまだHaskellほど強力ではないものの、最近のバージョンアップでジェネリックプログラミング関連の機能がどんどん追加されていてずいぶん便利になっています。それに伴って型レベルの計算も強力にサポートされるようになり、型レベル計算でマップを実装するというような変態行為が可能なくらいにまでなっているみたいです。

なんか最近ほんとPureScriptのことしか書いていないです。firebaseとかvirtual-domとかWebGL/Babylonjsとか他にもお勧めしたいものはいろいろあるのですが、そっちは誰か他の人が紹介を書いてくれそうなので私はあんまり書く気がおきません。PureScriptはほんとにいいスクリプト言語なので、みんなもっと記事を書いてくれたらいいなと思います。アドベントカレンダーとか作ればよかったでしょうか。TypeScriptのアドベントカレンダーすら過疎っているし、PureScriptではもっと人が集まらないでしょうね……。

参考文献

(この記事は同じ筆者が Qiita に投稿した記事の複製です。オリジナル記事)