某ウェブサービスのデータをAPI経由でRから取ってくるkntnrというパッケージをつくりました。
このパッケージを作るのは、実は2年間に3度くらい作りかけてはあきらめて、というのを繰り返してきました。というのも、JSONをdata.frameに変換するのが大変すぎて、少し前の私ではカラテが足りなかったからです。今もそんなにきれいに扱えるわけではないんですが、この悪戦苦闘がひょっとして誰かの参考になるかもしれないと思ってブログに書き残しておきます。(パッケージの紹介については会社ブログに書いたのが後日公開されます)
例1: 単一のレコード
まずは簡単な例として以下のJSONを、
{ "field1": { "type": "SINGLE_LINE_TEXT", "value": "テスト" }, "field2": { "type": "DATE", "value": "2017-01-01" }, "field3": { "type": "NUMBER", "value": "10" } }
以下のlistに変換する方法を考えてみましょう。
list(field1 = "テスト", field2 = "DATE", field3 = 10)
ひとつのフィールドにはtype
とvalue
があります。type
はそのフィールドの型を示しています。これによって、value
をどう変換するかが決まります。上の例だと、
SINGLE_LINE_TEXT
:character
に変換DATE
:Date
に変換NUMBER
:numeric
に変換
となります。まずはcharacter
として読み込まれるので、SINGLE_LINE_TEXT
については変換する必要はありません。DATE
はas.Date()
を、NUMBER
はas.numeric()
を使って変換しましょう。
JSONをパースするのにはjsonlite::fromJSON()
を使います。jsonliteは、可能な場合には自動的にlistをdata.frameやベクトルに変換してくれますが、今回は細かい挙動も自分でコントロールしたいのでsimplifyVector
引数にFALSE
を指定します。
l1 <- jsonlite::fromJSON("example1.json", simplifyVector = FALSE)
type
によって処理を変えるにはswitch()
を使います(switch
は文字列のマッチングしかできないので、もう少し複雑なパターン分けをしたい場合はpurrr::when()
を使いましょう)。具体的には以下のようなコードになるでしょう。
parse_field <- function(x) { switch(x$type, DATE = as.Date(x$value, origin = "1970-01-01"), NUMBER = as.numeric(x$value), x$value)} ) parse_field(l1[[1]]) #> [1] "テスト" parse_field(l1[[2]]) #> [1] "2017-01-01" parse_field(l1[[3]]) #> [1] 10
これをlapply()
やpurrr::map()
を使ってl1
の各要素に適用すると、求めるリストが出来上がります。
l1_converted <- purrr::map(l1, parse_field) l1_converted #> $field1 #> [1] "テスト" #> #> $field2 #> [1] "2017-01-01" #> #> $field3 #> [1] 10
これにtibble::as_tibble()
などを使うと、data.frame(tibble)に変換できます。
tibble::as_tibble(l1_converted)
例2: 複数のレコード
次に、上のレコードが複数ある場合を考えます。具体的には以下のようなJSONをdata.frameに変換します。
[ { "field1": { "type": "SINGLE_LINE_TEXT", "value": "テスト" }, "field2": { "type": "DATE", "value": "2017-01-01" }, "field3": { "type": "NUMBER", "value": "10" } }, { "field1": { "type": "SINGLE_LINE_TEXT", "value": "テステス" }, "field2": { "type": "DATE", "value": "2016-12-31" }, "field3": { "type": "NUMBER", "value": "100" } } ]
まずはJSONを読み込みましょう。
l2 <- jsonlite::fromJSON("example2.json", simplifyVector = FALSE)
それぞれのレコードをパースする処理は先ほどと同じです。1つ目を取り出してpurrr::map()
とparse_field
を使えば、先ほどと同じ結果になっていることがわかります。
purrr::map(l2[[1]], parse_field) #> $field1 #> [1] "テスト" #> #> $field2 #> [1] "2017-01-01" #> #> $field3 #> [1] 10
つまり、この処理をl2
の各要素にpurrr::map()
で適用すれば求める結果に近づきます。
l2_converted <- purrr::map(l2, ~ purrr::map(., parse_field)) #> [[1]] #> [[1]]$field1 #> [1] "テスト" #> #> [[1]]$field2 #> [1] "2017-01-01" #> #> [[1]]$field3 #> [1] 10 #> #> #> [[2]] #> [[2]]$field1 #> [1] "テステス" #> #> [[2]]$field2 #> [1] "2016-12-31" #> #> [[2]]$field3 #> [1] 100
分かりづらければ、以下のようにparse_record()
という関数を定義してもいいかもしれません。
parse_record <- function(x) { purrr::map(x, parse_field) } purrr::map(l2, parse_record)
このリストをdata.frameに変換するには、dplyr::bind_rows()
か、または上でpurrr::map()
の代わりにpurrr::map_df()
を使います。
dplyr::bind_rows(l2_converted) #> # A tibble: 2 × 3 #> field1 field2 field3 #> <chr> <date> <dbl> #> 1 テスト 2017-01-01 10 #> 2 テステス 2016-12-31 100
# これでも同じ結果 purrr::map_df(l2, ~ purrr::map(., parse_field))
目的のdata.frameができました。
例3: 複数の値を含むフィールド
さて、ここまでは簡単でした。なぜかというと、各フィールドにあるのは単一の値だったためです。しかし、こんなフィールドもあります。
{ "type": "CHECK_BOX", "value": [ "sample1", "sample2" ] }
これを上と同じやり方で処理するとどうなるか、やってみましょう。例として以下のJSONを考えます。
[ { "field1": { "type": "SINGLE_LINE_TEXT", "value": "テスト" }, "field4": { "type": "CHECK_BOX", "value": [ "sample1", "sample2" ] } }, { "field1": { "type": "SINGLE_LINE_TEXT", "value": "テステス" }, "field4": { "type": "CHECK_BOX", "value": [ "sample3" ] } } ]
これを上と同じやり方で処理すると、こうなります。
l3 <- jsonlite::fromJSON("example3.json", simplifyVector = FALSE) l3_converted <- purrr::map(l3, ~ purrr::map(., parse_field)) dplyr::bind_rows(l3_converted) #> Error in eval(substitute(expr), envir, enclos) : #> incompatible sizes (1 != 2)
エラーになりました。これは、field4
に複数の要素が入っているためです。
l3_converted[[1]] $field1 [1] "テスト" $field4 $field4[[1]] [1] "sample1" $field4[[2]] [1] "sample2"
実は、これはウェブAPIではよくあるパターンです。これに対処する方法は、複数の要素を持つカラムはlistに詰め込むことです。list columnの扱いについては以下の「Data Rectangling」というスライドが詳しいです。
具体的には、先ほどのparse_field()
関数を修正して、CHECK_BOX
はlist
で包むようにします。その前に、JSONの配列もリストになっている(fromJSON()
にsimplifyVector = FALSE
を指定したため)ので、unlist()
とかpurrr::flatten_chr()
でcharacterのベクトルにする必要がある点にも注意してください。
parse_field <- function(x) { switch(x$type, DATE = as.Date(x$value, origin = "1970-01-01"), NUMBER = as.numeric(x$value), CHECK_BOX = list(purrr::flatten_chr(x$value)), x$value) }
これを使うと、以下のようにdata.frameを得ることができました。
l3_converted <- purrr::map(l3, ~ purrr::map(., parse_field)) dplyr::bind_rows(l3_converted) #> # A tibble: 2 × 2 #> field1 field4 #> <chr> <list> #> 1 テスト <chr [2]> #> 2 テステス <chr [1]>
ちなみに、listのカラムはtidyr::unnest()
を使うと展開することができます。こんな感じで、あとで変形させることはdplyr/tidyr/purrrあたりにツールが充実しているので、とりあえずlistに詰め込みましょう、ということです。
d <- dplyr::bind_rows(l3_converted) tidyr::unnest(d, field4) #> # A tibble: 3 × 2 #> field1 field4 #> <chr> <chr> #> 1 テスト sample1 #> 2 テスト sample2 #> 3 テステス sample3
例4: サブテーブル
この某APIには、さらに強敵がいます。サブテーブルという型です。こういうやつです。
{ "type": "SUBTABLE", "value": [ { "id": "1", "value": { "field11": { "type": "SINGLE_LINE_TEXT", "value": "" }, "field2": { "type": "NUMBER", "value": "1000" } } }, { "id": "2", "value": { "field1": { "type": "SINGLE_LINE_TEXT", "value": "shibuyeeeeeaaaaahhhhh" }, "field2": { "type": "NUMBER", "value": "2000" } } } ] }
なんということでしょう。value
に複数のレコードがさらに入っています。
少し注意が必要なのは、先ほどまでの例のJSONとは少し形式が違っていて、各レコードにid
とvalue
という要素があります。このうち、value
だけを取り出してパースする必要があります。
parse_subtable <- function(x) { d <- purrr::map(x, ~purrr::map(.$value, parse_field)) dplyr::bind_rows(d) }
そして、parse_field()
関数を修正して、SUBTABLE
の時にはこの関数を使うようにします。CHECK_BOX
のときと同じくlistでラップするのも忘れずに。
parse_field <- function(x) { switch(x$type, DATE = as.Date(x$value, origin = "1970-01-01"), NUMBER = as.numeric(x$value), CHECK_BOX = list(purrr::flatten_chr(x$value)), SUBTABLE = list(parse_subtable(x$value)), x$value) }
いよいよこのJSONをパースしてみます。
[ { "field1": { "type": "SINGLE_LINE_TEXT", "value": "テスト" }, "field5": { "type": "SUBTABLE", "value": [ { "id": "1", "value": { "field11": { "type": "SINGLE_LINE_TEXT", "value": "" }, "field12": { "type": "NUMBER", "value": "1000" } } }, { "id": "2", "value": { "field11": { "type": "SINGLE_LINE_TEXT", "value": "shibuyeeeeeaaaaahhhhh" }, "field12": { "type": "NUMBER", "value": "2000" } } } ] } }, { "field1": { "type": "SINGLE_LINE_TEXT", "value": "テステス" }, "field5": { "type": "SUBTABLE", "value": [ { "id": "4", "value": { "field11": { "type": "SINGLE_LINE_TEXT", "value": "a" }, "field12": { "type": "NUMBER", "value": "0" } } } ] } } ]
長い...。ではやってみます。
l4 <- jsonlite::fromJSON("example4.json", simplifyVector = FALSE) l4_converted <- purrr::map(l4, ~ purrr::map(., parse_field)) dplyr::bind_rows(l4_converted) #> # A tibble: 2 × 2 #> field1 field5 #> <chr> <list> #> 1 テスト <tibble [2 × 2]> #> 2 テステス <tibble [1 × 2]>
ちゃんとパースできました。先ほどと同じくtidyr::unnest()
することもできます。
d <- dplyr::bind_rows(l4_converted) tidyr::unnest(d, field5) # A tibble: 3 × 3 #> field1 field11 field12 #> <chr> <chr> <dbl> #> 1 テスト 1000 #> 2 テスト shibuyeeeeeaaaaahhhhh 2000 #> 3 テステス a 0
NULL
の扱いに注意
ときたま、
{ "type": "DATE", "value": null }
みたいに、NULL
が入ってくることがあります。これは厄介です。NULL
の扱いは関数によって異なりますが、以下のようにエラーになることが多いです。
as.Date(NULL, origin = "1970-01-01") #> Error in as.Date.default(NULL, origin = "1970-01-01") : #> 'NULL' からクラス “Date” へ変換は定義されていません
こういうときには、purrrパッケージの%||%
演算子が便利です(ほかのパッケージにもあるやつだと思います)。
... DATE = as.Date(x$value %||% NA, origin = "1970-01-01"), ...
効率化のためにはtranspose()
これまでのコードだと、フィールドを列方向にひとつひとつ処理していますが、これはあまり効率的ではありません。以下の記事に書きましたが、速度を求めるのであれば一度purrr::transpose()
してから処理を行う方がいいでしょう。
例えば、以下のようにfield
という1つのフィールドのレコードが3つ並んでいるJSONを考えます。
[ { "field": { "type": "NUMBER", "value": "1" } }, { "field": { "type": "NUMBER", "value": "2" } }, { "field": { "type": "NUMBER", "value": "3" } } ]
これをtranspose()
してみます。列ごとにまとめることができます。
l5 <- jsonlite::fromJSON("example5.json", simplifyVector = FALSE) show_json <- function(x) jsonlite::prettify(jsonlite::toJSON(x, auto_unbox = TRUE)) show_json(purrr::transpose(l5)) #> { #> "field": [ #> { #> "type": "NUMBER", #> "value": "1" #> }, #> { #> "type": "NUMBER", #> "value": "2" #> }, #> { #> "type": "NUMBER", #> "value": "3" #> } #> ] #> }
しかし、type
とvalue
に分かれているので、まだちょっと処理しづらいです。もう一つ下の階層までtranspose()
してみましょう。
show_json(purrr::map(purrr::transpose(l5), purrr::transpose)) #> { #> "field": { #> "type": [ #> "NUMBER", #> "NUMBER", #> "NUMBER" #> ], #> "value": [ #> "1", #> "2", #> "3" #> ] #> } #> }
この形に直した後は、以下のように列方向にパースする関数を使うことができます。
parse_col <- function(x) { # type はすべて同じはずなので1つだけ見ればいい type <- x$type[[1]] switch(type, NUMBER = as.numeric(purrr::flatten_chr(x$value)), ... purrr::flatten_chr(x$value)) } l5_trans <- purrr::map(purrr::transpose(l5), purrr::transpose) tibble::as_tibble(purrr::map(l5_trans, parse_col)) #> # A tibble: 3 × 1 #> field #> <dbl> #> 1 1 #> 2 2 #> 3 3
ただし、この例では...
で省略しましたが、CHECK_BOX
やSUBTABLE
のようにatomicではない列に関しては要素をひとつづつ処理する必要があります(purrr::flatten_chr()
できない)。興味があればkntnrのコードを覗いてみてください。
ちなみに...
これは某ウェブサービスがスキーマ可変のデータベース的なものなのでこういうJSONが返ってくるんですが、一般的にはフィールドごとに型を明示する必要はないはずです。もしJSONがこういう形のものであったなら、
[ { "field1": 1, "field2": "a", "field3": [ "test1", "test2", "test3" ] }, { "field1": 0, "field2": "b", "field3": [ "test1" ] } ]
simplifyVector = FALSE
にしてこんなに自前で頑張らなくても、jsonliteがだいたいいい感じにdata.frameにしてくれます。何も考えずにfromJSON()
しましょう。
jsonlite::fromJSON("example6.json") #> field1 field2 field3 #> 1 1 a test1, test2, test3 #> 2 0 b test1
なんてお手軽...