WebAPIを叩くと、レスポンスがJSON形式の文字列で返ってくることがあります。 ほとんどの場合そのまま文字列で扱うよりも、ライブラリを使ってプログラミング言語がサポートする辞書型に変換するのではないかと思います。

単純なJSONの場合は辞書型のままで操作・抽出してもよいかもしれませんが、 複雑なJSONの場合はごちゃごちゃと辞書を弄り回すよりも自分で定義した型にデータをマッピングしたいものです。

今回はJuliaにおけるJSON(から得られるDict )のstructへのマッピング方法について書いてみたいと思います。

Parameters.jlを利用したマッピング

JuliaではネストしていないDictであればマッピングは比較的容易です。例として次のようなJSONを考えます。

{
    "title": "hello",
    "name": "main_window",
    "width": 500,
    "height": 400,
    "show": true
}

まずJSON.jlで文字列からDictに変換します。

julia> s = "{\r\n    \"title\": \"hello\",\r\n    \"name\": \"main_window\",\r\n    \"width\": 500,\r\n    \"height\": 400,\r\n    \"show\": true\r\n}"

julia> println(s)
{
    "title": "hello",
    "name": "main_window",
    "width": 500,
    "height": 400,
    "show": true
}

julia> using JSON

julia> j = JSON.parse(s)
Dict{String,Any} with 5 entries:
  "name"   => "main_window"
  "height" => 400
  "show"   => true
  "title"  => "hello"
  "width"  => 500

ここまではいいと思います。

struct Window
    name::String
    title::String
    show::Bool
    height::UInt64
    width::UInt64
end

上のようなstructにマッピングするのはParameters.jlを付けば比較的容易です。 まずキーを文字列からシンボルに変換する次のような関数を作っておきます。

julia> keytosymbol(x) = Dict(Symbol(k) => v for (k, v) in pairs(x))

julia> keytosymbol(j)
Dict{Symbol,Any} with 5 entries:
  :show   => true
  :name   => "main_window"
  :height => 400
  :title  => "hello"
  :width  => 500

Parameters.jlの@with_kwマクロを使用すると キーワードでコンストラクタが初期化できるようになります。(デフォルト値もサポートされています)

julia> @with_kw struct Window
            name::String
            title::String
            show::Bool
            height::UInt64
            width::UInt64
        end

Pythonでdict型のオブジェクトをキーワード引数としてアンパックして関数に渡すことができる( f(**kwargs) )ように、 Pythonでもキーワード引数にDict{Symbol, T}型のオブジェクトをf(;kwargs...)としてアンパックして渡すことができます。

以上のことから、先ほどの関数を使ってDictのキーをSymbolに変換し、splat operator ...でアンパックすれば、

julia> Window(;keytosymbol(j)...)
Window
  name: String "main_window"
  title: String "hello"
  show: Bool true
  height: UInt64 0x0000000000000190
  width: UInt64 0x00000000000001f4

と無事にマッピングできました。

ネストされている場合

しかしながら、実際にレスポンスとして帰ってくるJSONはしばしばもっと複雑です。

{
    "glossary": {
        "title": "example glossary",
		"GlossDiv": {
            "title": "S",
			"GlossList": {
                "GlossEntry": {
                    "ID": "SGML",
					"SortAs": "SGML",
					"GlossTerm": "Standard Generalized Markup Language",
					"Acronym": "SGML",
					"Abbrev": "ISO 8879:1986",
					"GlossDef": {
                        "para": "A meta-markup language, used to create markup languages such as DocBook.",
						"GlossSeeAlso": ["GML", "XML"]
                    },
					"GlossSee": "markup"
                }
            }
        }
    }
}

上はJSON Exampleから引用したJSONの例です。

using Parameters

@with_kw struct GlossDef
  para::String
  glossseealso::Vector{String}
end

@with_kw struct GlossEntry
  id::String
  sortas::String
  glossterm::String
  acronym::String
  abbrev::String
  glossdef::GlossDef
  glosssee::String
end

@with_kw struct GlossList
  glossentry::GlossEntry
end

@with_kw struct GlossDiv
  title::String
  glosslist::GlossList
end

@with_kw struct Glossary
  title::String
  glossdiv::GlossDiv
end

上のように定義したstructにマッピングするにはどうしたらよいのでしょうか。

もちろん真面目に構成していけばできない事はないのですが、なるべくお手軽に変換したいものです。

julia> s = "{\r\n    \"glossary\": {\r\n        \"title\": \"example glossary\",\r\n\t\t\"GlossDiv\": {\r\n            \"title\": \"S\",\r\n\t\t\t\"GlossList\": {\r\n                \"GlossEntry\": {\r\n                    \"ID\": \"SGML\",\r\n\t\t\t\t\t\"SortAs\": \"SGML\",\r\n\t\t\t\t\t\"GlossTerm\": \"Standard Generalized Markup Language\",\r\n\t\t\t\t\t\"Acronym\": \"SGML\",\r\n\t\t\t\t\t\"Abbrev\": \"ISO 8879:1986\",\r\n\t\t\t\t\t\"GlossDef\": {\r\n                        \"para\": \"A meta-markup language, used to create markup languages such as DocBook.\",\r\n\t\t\t\t\t\t\"GlossSeeAlso\": [\"GML\", \"XML\"]\r\n                    },\r\n\t\t\t\t\t\"GlossSee\": \"markup\"\r\n                }\r\n            }\r\n        }\r\n    }\r\n}"
"{\r\n    \"glossary\": {\r\n        \"title\": \"example glossary\",\r\n\t\t\"GlossDiv\": {\r\n            \"title\": \"S\",\r\n\t\t\t\"GlossList\": {\r\n                \"GlossEntry\": {\r\n                    \"ID\": \"SGML\",\r\n\t\t\t\t\t\"SortAs\": \"SGML\",\r\n\t\t\t\t\t\"GlossTerm\": \"Standard Generalized Markup Language\",\r\n\t\t\t\t\t\"Acronym\": \"SGML\",\r\n\t\t\t\t\t\"Abbrev\": \"ISO 8879:1986\",\r\n\t\t\t\t\t\"GlossDef\": {\r\n                        \"para\": \"A meta-markup language, used to create markup languages such as DocBook.\",\r\n\t\t\t\t\t\t\"GlossSeeAlso\": [\"GML\", \"XML\"]\r\n                    },\r\n\t\t\t\t\t\"GlossSee\": \"markup\"\r\n                }\r\n            }\r\n        }\r\n    }\r\n}"

julia> j = JSON.parse(s)["glossary"]
Dict{String,Any} with 2 entries:
  "title"    => "example glossary"
  "GlossDiv" => Dict{String,Any}("title"=>"S","GlossList"=>Dict{String,Any}("GlossEntry"=>Dict{String,Any}("GlossSee"

キーを小文字に変換

まずJSONのキーを小文字にしましょう。再帰的に書くと次のようになります。

julia> lowercasekeys(x) = x

julia> lowercasekeys(x::AbstractDict) = Dict(lowercase(k) => lowercasekeys(v) for (k, v) in pairs(x))

julia> j2 = lowercasekeys(j)
Dict{String,Any} with 2 entries:
  "glossdiv" => Dict{String,Any}("title"=>"S","glosslist"=>Dict{String,Dict{String,Any}}("glossentry"=>Dict("sortas"=>"SGML","abbrev"=>"ISO 8879:1986","id"
  "title"    => "example glossary"

以前と同じような方法では、DictGlossDivconvertできないので失敗します。

julia> Glossary(;keytosymbol(j2)...)
ERROR: MethodError: Cannot `convert` an object of type Dict{String,Any} to an object of type GlossDiv
Closest candidates are:
  convert(::Type{T}, ::T) where T at essentials.jl:171
  GlossDiv(::Any, ::Any) at /root/.julia/packages/Parameters/CVyBv/src/Parameters.jl:480
Stacktrace:
 [1] Glossary(::String, ::Dict{String,Any}) at /root/.julia/packages/Parameters/CVyBv/src/Parameters.jl:480
 [2] Glossary(; title::String, glossdiv::Dict{String,Any}) at /root/.julia/packages/Parameters/CVyBv/src/Parameters.jl:468
 [3] top-level scope at REPL[31]:1

関数の形を考えてみる

変換する関数は大まかにこんな感じになるはずです。(適当)

function convertdict(T::Type, d::AbstractDict)
    kwargs = Dict{Symbol, Any}()
    for (k, v) in pairs(d)
        symk = Symbol(k)
        kwargs[symk] = (変換が必要な時は変換する)
    end
    return T(;kwargs...)
end

「変換が必要な時は変換する」という部分を考えましょう。

変換先の型を判別

変換がいつ必要になるかはメンバー変数の型を見て判別することにします。 ちなみに、メンバー変数の型はCore.fieldtypeで取得できます。

julia> fieldtype(GlossDef, :para)
String

julia> fieldtype(GlossDef, :glossseealso)
Array{String,1}

Column: fieldtype(T, k)の型, Row: vの型 としたとき、下のようにディスパッチさせることにします。

Union{S, Nothing} Vector{S} Dict それ以外
Vector convertdict(S, v) convertdict.(S, v)
Dict convertdict(S, v) v マッピングを実施
それ以外 convertdict(S, v) v

convertdictの構成

あとは先ほどの表に合わせて関数を整えていきます。

convertdict(::Type, x) = x
convertdict(::Type{Union{T, Nothing}}, x) where T = convertdict(T, x)
convertdict(::Type{Union{T, Nothing}}, d::AbstractDict) where T = convertdict(T, d)
convertdict(::Type{T}, v::AbstractVector) where T <: AbstractVector = convertdict.(eltype(T), v)
convertdict(::Type{T}, d::AbstractDict) where T <: AbstractDict = d

function convertdict(T::Type, d::AbstractDict)
    kwargs = Dict{Symbol, Any}()
    for (k, v) in pairs(d)
        symk = Symbol(k)
        kwargs[symk] = convertdict(fieldtype(T, symk), v)
    end
    return T(;kwargs...)
end

思ったよりシンプルですね。3行目の

convertdict(::Type{Union{T, Nothing}}, d::AbstractDict) where T = convertdict(T, d)

は2行目に含まれているので必要ないように見えるかもしれませんが、これがないと convertdict(::Type{Union{T, Nothing}}, d::AbstractDict)convertdict(::Type{Union{T, Nothing}}, x)convertdict(T::Type, d::AbstractDict) のどちらにディスパッチしてよいか曖昧になってしまうので、必要な定義です。

さてさて、

julia> glossary = convertdict(Glossary, j2)
Glossary
  title: String "example glossary"
  glossdiv: GlossDiv

julia> glossary.glossdiv
GlossDiv
  title: String "S"
  glosslist: GlossList

julia> glossary.glossdiv.glosslist
GlossList
  glossentry: GlossEntry

julia> glossary.glossdiv.glosslist.glossentry
GlossEntry
  id: String "SGML"
  sortas: String "SGML"
  glossterm: String "Standard Generalized Markup Language"
  acronym: String "SGML"
  abbrev: String "ISO 8879:1986"
  glossdef: GlossDef
  glosssee: String "markup"

julia> glossary.glossdiv.glosslist.glossentry.glossdef
GlossDef
  para: String "A meta-markup language, used to create markup languages such as DocBook."
  glossseealso: Array{String}((2,))

julia> glossary.glossdiv.glosslist.glossentry.glossdef.glossseealso
2-element Array{String,1}:
 "GML"
 "XML"

一発でマッピングできました。

もう一例やってみます。

{"menu": {
    "header": "SVG Viewer",
    "items": [
        {"id": "Open"},
        {"id": "OpenNew", "label": "Open New"},
        null,
        {"id": "ZoomIn", "label": "Zoom In"},
        {"id": "ZoomOut", "label": "Zoom Out"},
        {"id": "OriginalView", "label": "Original View"},
        null,
        {"id": "Quality"},
        {"id": "Pause"},
        {"id": "Mute"},
        null,
        {"id": "Find", "label": "Find..."},
        {"id": "FindAgain", "label": "Find Again"},
        {"id": "Copy"},
        {"id": "CopyAgain", "label": "Copy Again"},
        {"id": "CopySVG", "label": "Copy SVG"},
        {"id": "ViewSVG", "label": "View SVG"},
        {"id": "ViewSource", "label": "View Source"},
        {"id": "SaveAs", "label": "Save As"},
        null,
        {"id": "Help"},
        {"id": "About", "label": "About Adobe CVG Viewer..."}
    ]
}}

まずは読み込んでみます。

julia> s2 = "{\"menu\": {\r\n    \"header\": \"SVG Viewer\",\r\n    \"items\": [\r\n        {\"id\": \"Open\"},\r\n        {\"id\": \"OpenNew\", \"label\": \"Open New\"},\r\n        null,\r\n        {\"id\": \"ZoomIn\", \"label\": \"Zoom In\"},\r\n        {\"id\": \"ZoomOut\", \"label\": \"Zoom Out\"},\r\n        {\"id\": \"OriginalView\", \"label\": \"Original View\"},\r\n        null,\r\n        {\"id\": \"Quality\"},\r\n        {\"id\": \"Pause\"},\r\n        {\"id\": \"Mute\"},\r\n        null,\r\n        {\"id\": \"Find\", \"label\": \"Find...\"},\r\n        {\"id\": \"FindAgain\", \"label\": \"Find Again\"},\r\n        {\"id\": \"Copy\"},\r\n        {\"id\": \"CopyAgain\", \"label\": \"Copy Again\"},\r\n        {\"id\": \"CopySVG\", \"label\": \"Copy SVG\"},\r\n        {\"id\": \"ViewSVG\", \"label\": \"View SVG\"},\r\n        {\"id\": \"ViewSource\", \"label\": \"View Source\"},\r\n        {\"id\": \"SaveAs\", \"label\": \"Save As\"},\r\n        null,\r\n        {\"id\": \"Help\"},\r\n        {\"id\": \"About\", \"label\": \"About Adobe CVG Viewer...\"}\r\n    ]\r\n}}"

julia> println(s2)
{"menu": {
    "header": "SVG Viewer",
    "items": [
        {"id": "Open"},
        {"id": "OpenNew", "label": "Open New"},
        null,
        {"id": "ZoomIn", "label": "Zoom In"},
        {"id": "ZoomOut", "label": "Zoom Out"},
        {"id": "OriginalView", "label": "Original View"},
        null,
        {"id": "Quality"},
        {"id": "Pause"},
        {"id": "Mute"},
        null,
        {"id": "Find", "label": "Find..."},
        {"id": "FindAgain", "label": "Find Again"},
        {"id": "Copy"},
        {"id": "CopyAgain", "label": "Copy Again"},
        {"id": "CopySVG", "label": "Copy SVG"},
        {"id": "ViewSVG", "label": "View SVG"},
        {"id": "ViewSource", "label": "View Source"},
        {"id": "SaveAs", "label": "Save As"},
        null,
        {"id": "Help"},
        {"id": "About", "label": "About Adobe CVG Viewer..."}
    ]
}}

julia> j3 = JSON.parse(s2)["menu"]
Dict{String,Any} with 2 entries:
  "items"  => Any[Dict{String,Any}("id"=>"Open"), Dict{String,Any}("label"=>"Open New","id"=>"OpenNew"), nothing, Dict{String,Any}("label"=>"Zoom In","id"=…
  "header" => "SVG Viewer"

nullnothingにマッピングされています。

julia> @with_kw struct Item
         id::String
         label::Union{String, Nothing} = nothing
       end

julia> @with_kw struct Menu
         header::String
         items::Vector{Union{Item, Nothing}}
       end

上のように定義した場合でも、

julia> menu = convertdict(Menu, j3)
Menu
  header: String "SVG Viewer"
  items: Array{Union{Nothing, Item}}((22,))

julia> menu.items
22-element Array{Union{Nothing, Item},1}:
 Item
  id: String "Open"
  label: Nothing nothing

 Item
  id: String "OpenNew"
  label: String "Open New"

 nothing
 Item
  id: String "ZoomIn"
  label: String "Zoom In"

 Item
  id: String "ZoomOut"
  label: String "Zoom Out"

 Item
  id: String "OriginalView"
  label: String "Original View"

 nothing
 Item
  id: String "Quality"
  label: Nothing nothing

 Item
  id: String "Pause"
  label: Nothing nothing

 Item
  id: String "Mute"
  label: Nothing nothing

 
 Item
  id: String "Copy"
  label: Nothing nothing

 Item
  id: String "CopyAgain"
  label: String "Copy Again"

 Item
  id: String "CopySVG"
  label: String "Copy SVG"

 Item
  id: String "ViewSVG"
  label: String "View SVG"

 Item
  id: String "ViewSource"
  label: String "View Source"

 Item
  id: String "SaveAs"
  label: String "Save As"

 nothing
 Item
  id: String "Help"
  label: Nothing nothing

 Item
  id: String "About"
  label: String "About Adobe CVG Viewer..."

問題なくnothingVectorUnionの組み合わせを処理できています。

パッケージ

パッケージとするほどのものでもないかもしれませんが、一応JuliaRegistries/Generalに登録してあります。 ] add StructMapping でお使いください。

https://github.com/matsueushi/StructMapping.jl