エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Elixirの構造体をJSON形式でシリアライズしたりデシリアライズしたりする

Programming Elixir でも紹介されている JSON を扱うパッケージ poison を利用して、構造体を文字列にシリアライズしたり、文字列からでシリアライズしたりします。

プロジェクトを用意する

mix new でプロジェクトを作成します。

$ mix new book_shelf
$ cd book_shelf

モジュール BookShelf.Book を追加します。

$ mkdir lib/book_shelf
$ touch lib/book_shelf/book.ex
# lib/book_shelf/book.ex
defmodule BookShelf.Book do
  defstruct [:title, :isbn, :price, :bought_at]
end

モジュール BookShelf を編集します。

# lib/book_shelf.ex
defmodule BookShelf do
  defstruct [:books]
end

確認します。

$ iex -S mix
iex(1)> alias BookShelf.Book
BookShelf.Book
iex(2)> shelf = %BookShelf{books: [%Book{title: "海洋生命5億年史", isbn: "9784163908748", price: 1620, bought_at: ~N[2018-09-11 00:00:00]}]}
%BookShelf{
  books: [
    %BookShelf.Book{
      bought_at: ~N[2018-09-11 00:00:00],
      isbn: "9784163908748",
      price: 1620,
      title: "海洋生命5億年史"
    }
  ]
}

poison で JSON へ変換したり、JSON から変換したりする

JSON を扱うパッケージ poison を利用します。

バージョンを確認します。

$ mix hex.info poison
An incredibly fast, pure Elixir JSON library

Config: {:poison, "~> 4.0"}
Releases: 4.0.1, 4.0.0, 3.1.0, 3.0.0, 2.2.0, 2.1.0, 2.0.1, 2.0.0, ...

mix.exs を編集して、依存パッケージに poison を追加します。

  defp deps do
    [
      {:poison, "~> 4.0"}
    ]
  end

パッケージを取得します。

$ mix deps.get

先ほど確認した構造体をエンコードしてみます。

$ iex -S mix
iex(1)> alias BookShelf.Book
BookShelf.Book
iex(2)> shelf = %BookShelf{books: [%Book{title: "海洋生命5億年史", isbn: "9784163908748", price: 1620, bought_at: ~N[2018-09-11 00:00:00]}]}
%BookShelf{
  books: [
    %BookShelf.Book{
      bought_at: ~N[2018-09-11 00:00:00],
      isbn: "9784163908748",
      price: 1620,
      title: "海洋生命5億年史"
    }
  ]
}
iex(3)> Poison.encode(shelf)
{:ok,
 "{\"books\":[{\"title\":\"海洋生命5億年史\",\"price\":1620,\"isbn\":\"9784163908748\",\"bought_at\":\"2018-09-11T00:00:00\"}]}"}

エンコードした文字列をデコードしてみます。

iex(4)> {:ok, json} = Poison.encode(shelf)
{:ok,
 "{\"books\":[{\"title\":\"海洋生命5億年史\",\"price\":1620,\"isbn\":\"9784163908748\",\"bought_at\":\"2018-09-11T00:00:00\"}]}"}
iex(5)> Poison.decode(json)
{:ok,
 %{
   "books" => [
     %{
       "bought_at" => "2018-09-11T00:00:00",
       "isbn" => "9784163908748",
       "price" => 1620,
       "title" => "海洋生命5億年史"
     }
   ]
 }}

JSON の文字列には構造体の情報が含まれていないので、Poison.encode/2 でデコードするとキーを文字列としたマップが返ります。

デコードする構造体を指定する

Poison.encode/2 の第二引数でオプションの :as で構造体を指定します。

iex(6)> Poison.decode(json, as: %BookShelf{})
{:ok,
 %BookShelf{
   books: [
     %{
       "bought_at" => "2018-09-11T00:00:00",
       "isbn" => "9784163908748",
       "price" => 1620,
       "title" => "海洋生命5億年史"
     }
   ]
 }}

オプションの指定なしの場合は %{"books" => ... } というようにキーが文字列のマップとしてデコードされましたが、オプションで構造体を指定すると %BookShelf{books: ... } というように構造体としてデコードされます。 オプションで指定する構造体を入れ子で指定すると子要素も構造体としてデコードしてくれます。

iex(7)> Poison.decode(json, as: %BookShelf{books: [%Book{}]})
{:ok,
 %BookShelf{
   books: [
     %BookShelf.Book{
       bought_at: "2018-09-11T00:00:00",
       isbn: "9784163908748",
       price: 1620,
       title: "海洋生命5億年史"
     }
   ]
 }}

ここでは alias を設定しているので %Book{} と指定していますが、alias がないばあいは %BookShelf.Book{} と指定する必要があります。

要素を変換する

Kernel で定義されている update_in/2関数(正確にはマクロ)を利用するとマップの構造をしたデータの要素を変換することができます。

iex(1)> data = %{a: %{b: 123}}
%{a: %{b: 123}}
iex(2)> update_in(data, [:a, :b], & &1 * 2)
%{a: %{b: 246}}

同じように update_in/2 を利用して Bookbought_at を変換しようとするとエラーになります。

iex(1)> alias BookShelf.Book                                                                       
BookShelf.Book
iex(2)> book = %Book{title: "海洋生命5億年史", isbn: "9784163908748", price: 1620, bought_at: ~N[2018-09-11 00:00:00]}
%BookShelf.Book{
  bought_at: ~N[2018-09-11 00:00:00],
  isbn: "9784163908748",
  price: 1620,
  title: "海洋生命5億年史"
}
iex(3)> book = book |> Poison.encode!() |> Poison.decode!(as: %Book{})
%BookShelf.Book{
  bought_at: "2018-09-11T00:00:00",
  isbn: "9784163908748",
  price: 1620,
  title: "海洋生命5億年史"
}
iex(4)> update_in(book, [:bought_at], &NaiveDateTime.from_iso8601!/1)
** (UndefinedFunctionError) function BookShelf.Book.get_and_update/3 is undefined (BookShelf.Book does not implement the Access behaviour)
    (book_shelf) BookShelf.Book.get_and_update(%BookShelf.Book{bought_at: "2018-09-11T00:00:00", isbn: "9784163908748", price: 1620, title: "海洋生命5億年史"}, :bought_at, #Function<19.9473146/1 in Kernel.update_in/3>)
    (elixir) lib/access.ex:370: Access.get_and_update/3
    (elixir) lib/kernel.ex:2136: Kernel.update_in/3

エラーメッセージにあるように Accesssget_and_update/3 を実装する必要があります。

BookShelf.Book を次のように書き換えます。

defmodule BookShelf.Book do
  defstruct [:title, :isbn, :price, :bought_at]

  @behaviour Access

  alias BookShelf.Book

  def get_and_update(%Book{} = book, key, function) do
    {:ok,  value} = Map.fetch(book, key)
    {get_value, new_value} = function.(value)
    {get_value, %{book | key => new_value}}
  end
end

Accesss では 3 つの関数が定義されていて、本当は 3 つとも実装する必要があるのですが、今回は get_and_update/3 のみ実装しています。 起動時に実装されていないと警告が出ますがここでは無視して先に進みます。

$ iex -S mix

warning: function fetch/2 required by behaviour Access is not implemented (in module BookShelf.Book)
  lib/book_shelf/book.ex:1

warning: function pop/2 required by behaviour Access is not implemented (in module BookShelf.Book)
  lib/book_shelf/book.ex:1
iex(1)> alias BookShelf.Book
BookShelf.Book
iex(2)> book = %Book{title: "海洋生命5億年史", isbn: "9784163908748", price: 1620, bought_at: ~N[2018-09-11 00:00:00]} |> Poison.encode!() |> Poison.decode!(as: %Book{})
%BookShelf.Book{
  bought_at: "2018-09-11T00:00:00",
  isbn: "9784163908748",
  price: 1620,
  title: "海洋生命5億年史"
}
iex(3)> update_in(book, [:bought_at], &NaiveDateTime.from_iso8601!/1)
%BookShelf.Book{
  bought_at: ~N[2018-09-11 00:00:00],
  isbn: "9784163908748",
  price: 1620,
  title: "海洋生命5億年史"
}

update_in/3NaiveDateTime.from_iso8601!/1 を使って bought_at の値を変換することができました。

BookShelf も同じように get_and_update/3 を実装します。

defmodule BookShelf do
  defstruct [:books]

  @behaviour Access

  def get_and_update(%BookShelf{} = book_shelf, key, function) do
    {:ok,  value} = Map.fetch(book_shelf, key)
    {get_value, new_value} = function.(value)
    {get_value, %{book_shelf | key => new_value}}
  end
end
iex(1)> alias BookShelf.Book
BookShelf.Book
iex(2)> shelf = %BookShelf{books: [%Book{title: "海洋生命5億年史", isbn: "9784163908748", price: 1620, bought_at: ~N[2018-09-11 00:00:00]}]} |> Poison.encode!() |> Poison.decode!(as: %BookShelf{books: [%Book{}]})
%BookShelf{
  books: [
    %BookShelf.Book{
      bought_at: "2018-09-11T00:00:00",
      isbn: "9784163908748",
      price: 1620,
      title: "海洋生命5億年史"
    }
  ]
}

ここで変換したい要素 bought_at の位置を指定するばあいは、

  1. BookShelfbooks の要素
  2. その配列の要素である Book
  3. その Bookbought_at

というように途中に配列の指定が必要になります。

配列のすべての要素を指定するには Access.all/0 関数を利用します。

iex(3)> update_in(shelf, [:books, Access.all(), :bought_at], &NaiveDateTime.from_iso8601!/1)
%BookShelf{
  books: [
    %BookShelf.Book{
      bought_at: ~N[2018-09-11 00:00:00],
      isbn: "9784163908748",
      price: 1620,
      title: "海洋生命5億年史"
    }
  ]
}

関数にまとめる

BookShelfserialize/1de serialize/1 を定義します。

defmodule BookShelf do
  defstruct [:books]

  @behaviour Access

  def get_and_update(%BookShelf{} = book_shelf, key, function) do
    {:ok,  value} = Map.fetch(book_shelf, key)
    {get_value, new_value} = function.(value)
    {get_value, %{book_shelf | key => new_value}}
  end

  def serialize(%BookShelf{} = book_shelf) do
    Poison.encode(book_shelf)
  end

  def deserialize(json) when is_binary(json) do
    {:ok, book_shelf} = Poison.decode(json, as: %BookShelf{books: [%BookShelf.Book{}]})
    {:ok, update_in(book_shelf, [:books, Access.all(), :bought_at], &NaiveDateTime.from_iso8601!/1)}
  end
end
iex(1)> alias BookShelf.Book
BookShelf.Book
iex(2)> shelf = %BookShelf{books: [%Book{title: "海洋生命5億年史", isbn: "9784163908748", price: 1620, bought_at: ~N[2018-09-11 00:00:00]}]} 
%BookShelf{
  books: [
    %BookShelf.Book{
      bought_at: ~N[2018-09-11 00:00:00],
      isbn: "9784163908748",
      price: 1620,
      title: "海洋生命5億年史"
    }
  ]
}

シリアライズ

iex(3)> {:ok, json} = shelf |> BookShelf.serialize()
{:ok,
 "{\"books\":[{\"title\":\"海洋生命5億年史\",\"price\":1620,\"isbn\":\"9784163908748\",\"bought_at\":\"2018-09-11T00:00:00\"}]}"}

シリアライズ

iex(4)> json |> BookShelf.deserialize()
{:ok,
 %BookShelf{
   books: [
     %BookShelf.Book{
       bought_at: ~N[2018-09-11 00:00:00],
       isbn: "9784163908748",
       price: 1620,
       title: "海洋生命5億年史"
     }
   ]
 }}

いつか読むはずっと読まない:サメ帝国の逆襲

詳しくは著者のブログの記事を参照。

予想以上に幅広く扱っていて大変満足でした。

海洋生命5億年史 サメ帝国の逆襲

海洋生命5億年史 サメ帝国の逆襲