Runner in the High

技術のことをかくこころみ

ElmでCodecを用いてエンコーダ・デコーダの実装をケチる

この記事はFringe81 Advent Calendar 1日目の記事です。

Elmでアプリケーション開発をしているとほぼ100%出現するのがエンコーダ・デコーダの実装。

基本的に両方セットで実装することが多いが、データ構造が共通であればCodecと呼ばれる類の機能を提供するライブラリを用いてエンコーダ・デコーダの実装をケチることができる。

Codecとは

Codecとは簡単に言うとデータ構造が共通なものであればひとつCodec用の関数を定義しておくだけで、エンコーダとデコーダを同時に実装することができる便利なもの。

まず、Elmでは書き込みと読み込みでエンコーダとデコーダが別れているため、基本的に同じデータ構造に対してそれぞれ実装する工数があるかつ実装が別々であるため改修時に実装ミスが起きるなどの可能性がある。 しかしCodecを利用することでエンコーダとデコーダをそれぞれ実装する必要がなくなる上、デコーダやエンコーダにだけtypoがあった!みたいなバグを減らすことができる。

Codec的なパッケージとしていま利用できるものにはminiBill/elm-codecMartinSStewart/elm-serializeのふたつがある。この記事ではそれぞれの違いや使い分けたほうがよさそうなポイントを紹介する。

elm-codec

package.elm-lang.org

最も最初にCodecという概念を誕生させたのはこのライブラリ。

兎にも角にもまずはサンプルコードから。以下のようなレコードに対してCodecを実装してみる。

type Language
    = Japanese
    | English
    | Other String


type alias Model =
    { name : String
    , age : Int
    , lang : Language
    }

Codecの実装はこんな感じになる。

-- codecs


modelCodec : Codec.Codec Model
modelCodec =
    Codec.object Model
        |> Codec.field "name" .name Codec.string
        |> Codec.field "age" .age Codec.int
        |> Codec.field "lang" .lang languageCodec
        |> Codec.buildObject


languageCodec : Codec.Codec Language
languageCodec =
    Codec.custom
        (\jp en other value ->
            case value of
                Japanese ->
                    jp

                English ->
                    en

                Other v ->
                    other v
        )
        |> Codec.variant0 "japanese" Japanese
        |> Codec.variant0 "english" English
        |> Codec.variant1 "other" Other Codec.string
        |> Codec.buildCustom

上記のようにまずレコードに対するCodecを実装した上で、そこに含まれるカスタム型のCodecも実装できる。

基本的に入れ子構造にしてデータ構造を用意でき、使っている感じはデコーダの実装に近いと思われる。パイプラインでバシバシつなげるのもいい感じ。

あとは用意されている関数を用いてValue型にエンコード・デコードできる。使い方は基本これだけ。

-- encode
Codec.encodeValue modelCodec model

-- decode
Codec.decodeValue modelCodec value

ドキュメントを見てもらえればわかるがstring, bool, intなどの基本的な関数は用意されており、ResultやDict, Setなどにも対応している。また、カスタム型も最大8バリアントまで実装されている。

エンコードされるJSONの構造

さて、ここまでくると一体どういうデータ構造のJSONエンコードされるのかが気になってくる。データ構造上はエンコーダとデコーダが共通でも動くものになるはずだ。

というわけで、上記のコードで示されているModelレコードをエンコードすると以下のようなJSONが作成される。

{
   "lang":{
      "tag":"other",
      "args":[
         "unknown"
      ]
   },
   "age":10,
   "name":"seiya"
}

agename は想像通りだが、カスタム型として定義されている lang には若干クセがある。

tag部分にはelm-codecが提供する Codec.variantN 関数に渡されているフィールド名が入り、そしてもしそのバリアントがタグとなるデータを持っている場合にはそれを args というフィールドで保持するという作りになっている。たしかに、こうでもしないとカスタム型はそのままJSONに格納できない。

elm-codecでは上記のようなデータ構造のみがカスタム型に対してマッピングできる作りになっているため、結論JSONのデータ構造がelm-codecによって制限されることになる。エンコーダ・デコーダを個別に定義しなくてよくなるのは楽ではあるが、JSONのデータ構造の柔軟性を失うのは若干痛い。

elm-serialize

package.elm-lang.org

elm-serializeはelm-codecよりも後発のパッケージであり、elm-codecと比較してパフォーマンスや安全性に関する点で違いがある。

パッケージ自体の使い心地はelm-codecとさほど変わらない。以下が実装例となる。Modelレコードはelm-codecの例で実装されているものと同じものとする。

-- serializer


modelSerializer : Serialize.Codec a Model
modelSerializer =
    Serialize.record Model
        |> Serialize.field .name Serialize.string
        |> Serialize.field .age Serialize.int
        |> Serialize.field .lang languageSerializer
        |> Serialize.finishRecord


languageSerializer : Serialize.Codec a Language
languageSerializer =
    Serialize.customType
        (\jp en other value ->
            case value of
                Japanese ->
                    jp

                English ->
                    en

                Other v ->
                    other v
        )
        |> Serialize.variant0 Japanese
        |> Serialize.variant0 English
        |> Serialize.variant1 Other Serialize.string
        |> Serialize.finishCustomType

定義した雰囲気もほぼほぼ同じだが、まずひとつelm-codecとの違いとしてelm-serializeはValue型だけではなくelm/bytesへの変換もサポートされている。対象となるデータ構造のサイズに応じてValue型とBytes型を使い分けることができる。

エンコードされるJSONの構造

最も異なるのは生成されるJSONの構造で、こちらはelm-codecよりももっとクセがある。

エンコードすると以下のようなデータになる。

[
   1,
   [
      "seiya",
      10,
      [
         2,
         "unknown"
      ]
   ]
]

elm-serializeはREADMEで「human-readableなフォーマットを求める場合には適さない」と書かれており、生成されるJSONの可読性を犠牲にしてJSONのサイズが小さくなるように工夫されており、そのためこのような形のデータ構造となっている。

また、elm-serializeはパッケージ内で定義されるエンコード・バージョンを生成されるJSONのデータに埋め込んでいる。上記のJSONで言うと配列トップレベルの 1 という数値がそれにあたる。この値はelm-serializeの中で定義されているものになっている。

{-| Possible errors that can occur when decoding.
  - `CustomError` - An error caused by `andThen` returning an Err value.
  - `DataCorrupted` - This most likely will occur if you make breaking changes to your codec and try to decode old data\*. Have a look at `How do I change my codecs and still be able to decode old data?` in the readme for how to avoid introducing breaking changes.
  - `SerializerOutOfDate` - When encoding, this package will include a version number. This makes it possible for me to make improvements to how data gets encoded without introducing breaking changes to your codecs. This error then, says that you're trying to decode data encoded with a newer version of elm-serialize.
\*It's possible for corrupted data to still succeed in decoding (but with nonsense Elm values).
This is because internally we're just encoding Elm values and not storing any kind of structural information.
So if you encoded an Int and then a Float, and then tried decoding it as a Float and then an Int, there's no way for the decoder to know it read the data in the wrong order.
-}
type Error e
    = CustomError e
    | DataCorrupted
    | SerializerOutOfDate


version : Int
version =
    1

https://github.com/MartinSStewart/elm-serialize/blob/1.2.1/src/Serialize.elm#L93-L112

JSONエンコード・バージョンを付与することで、仮に今後elm-serialize自体にbreaking changesがありエンコードされるJSONのデータ構造が変わってデコードエラーが発生したとしても、それがelm-serializeのバージョンアップ起因なのかそれとも純粋にデコード対象のJSONのデータ構造が間違っているのかを型レベルで判別できるようになっている。

このような後方互換性の考慮はelm-codecにはないため、この点はelm-serializeのほうが優れていると言える。

まとめ

エンコードデコーダをそれぞれ定義しなくていいなんて最高だな」と思って触っては見たものの、どちらもある程度のクセはあるため、どういう用途でCodecを使うかによっておそらく使い分けが必要になると思われる。

生成されるJSONのデータ構造をElmアプリケーションの外で触るようであればデータの構造は非常に大事であるのでelm-codecを使うべきだが、LocalStorageに保存するデータなどデータ・サイズに制限があるような環境においてはelm-serializeのほうが適していると言える。

ちなみにだがelm-firestoreにもCodecが実装されていて、データベースやLocalStorageに対するCRUDのようなデータソースの構造が一意に決まるシステムの入出力部分にはCodecのような仕組みを利用するのが適しているということが分かる。このような観点では、常に一意に入出力のデータ構造が決まるとは言えないサーバーサイドとの通信部分でエンコーダ・デコーダが必要になるWebフロントエンドのアプリケーションにおいてはCodecはあまり登場することはないのかもしれない。