PerlでJSONをとってきてMongoDBに書こうとすると面倒な件

LINEで送る
Pocket

TwitterなんかのJSONを返すAPIからデータを取って、その結果をどこかのDBに格納するというのは大変ありがちな処理です。

まさに最近、そういうことをする必要があって、保存用のDBとしてMongoDBを使う前提でぽちぽちとコードを書いていました。MongoDBはJSONを元にしたデータ形式(BSON1)を使っているので、そのまま突っ込むのに適しているだろうというもくろみです。

ところが。Perlでさくっと書くには書けたのですが、思わぬところで引っかかってしまいました。原因は、Perlが真偽値(boolean)を扱う標準的なリテラルを持っていないこと。もちろん回避できたのですが、ちょっと面倒だったので、ぼやきついでにここに書いておきます。

はまり例

ライブラリは奇をてらわずCPANのJSON(もしくはJSON::XS2)とMongoDB使います。こんな感じでしょうか。(このサンプルではAPIから値を取得する代わりにJSONドキュメントをスクリプト中に書いています)

#!/usr/bin/perl

use strict;
use warnings;

use JSON;
use MongoDB;

my $coll = MongoDB::Connection->new()->get_database('db1')->get_collection('items');

my $original = <<EOD;
    &#91;
        {
            "id": 0,
            "status": true
        },
        {
            "id": 1,
            "status": false
        },
        {
            "id": 2,
            "status": true
        },
        {
            "id": 3,
            "status": false
        }
    &#93;
EOD

my $array = from_json($original);

foreach my $a (@$array){
    $coll->insert($a);
}

単純化していますが、APIの応答に含まれる複数の要素を個別にMongoDBに登録するようなイメージです。実行結果をmongodbのコンソールから実行結果を確認します。

> db.items.find();
{ "_id" : ObjectId("51420ba61d22572366000000"), "status" : BinData(2,"AQAAADE="), "id" : NumberLong(0) }
{ "_id" : ObjectId("51420ba61d22572366000001"), "status" : BinData(2,"AQAAADA="), "id" : NumberLong(1) }
{ "_id" : ObjectId("51420ba61d22572366000002"), "status" : BinData(2,"AQAAADE="), "id" : NumberLong(2) }
{ "_id" : ObjectId("51420ba61d22572366000003"), "status" : BinData(2,"AQAAADA="), "id" : NumberLong(3) }

元々のJSONではtrue/falseだったはずのstatusが、BinData(2,"AQAAADE=")のような謎のバイナリに化けています。

原因

化けたように見えているバイナリは、実はPerlのオブジェクトに対するリファレンスの内部表記がそのままJSONのバイナリ型として変換されてしまったものです。Perl上で$arrayの内容をData::Dumperで表示すると、各レコードのstatusにはJSON::Boolean(JSON::XS::Boolean)オブジェクトが代入されているのがわかります。

$VAR1 = [
          {
            'status' => bless( do{\(my $o = 1)}, 'JSON::XS::Boolean' ),
            'id' => 0
          },
          {
            'status' => bless( do{\(my $o = 0)}, 'JSON::XS::Boolean' ),
            'id' => 1
          },
          {
            'status' => $VAR1->[0]{'status'},
            'id' => 2
          },
          {
            'status' => $VAR1->[1]{'status'},
            'id' => 3
          }
        ];

JSON(JavaScript)では、変数の値としてstring, number, object3, array, true, false, nullが使えます。ところが、Perlは組み込み型でtrue/falseを示す事が出来ないため、JSONをPerlの変数に取り込んだ際に値を表すことが出来ません。

そこで、JSONモジュールではtrue/falseをJSON::Booleanオブジェクトで表します。JSON::Boolean::true、JSON::Boolean::falseはif文などで真偽値と扱われるようにoverloadされているため、普段は特に意識して処理する必要はありません。

JavaScript(JSON) string number object array true false null
Perl組み込み型 scalar hash array (なし) (なし) undef4
Perl(JSONモジュール) scalar hash array JSON::Boolean::true JSON::Boolean::false undef

ところが、JSON::Booleanオブジェクトがbooleanを意味していると知っているのはJSONモジュールだけで、MongoDBモジュールはそれを意識してくれません。ということで、何も考えずに愚直にバイト列としてバイナリにエンコードしてくれます。このバイト列は、Perl実行環境内でリファレンスを管理するための値ですので、値自体に意味はありません。

回避策

最初に書いた通り、MongoDBのデータ型はJSONが元になっているので、当然booleanは扱えます。ということで、PerlのMongoDBモジュールでもboolean型を扱えるようになっているのですが、JSON::Booleanではなくbooleanモジュールを使うことになっています。

このbooleanモジュール。use boolean;と書くのでプラグマのように見えますが、普通のCPANモジュールです。5.14.2でもバンドルされていない様子。名前空間にtrue, falseがexportされていて、一見そういうリテラルがあるように見えますが、そういう名前の関数です。

ということで、JSONモジュールでPerlの変数に取り込んだデータをMongoDBに渡すためには、このあたりの変換をしてやる必要があります。

たとえば、こんな感じでto_boolean()という処理を作ってみます。なお、冒頭の$MongoDB::BSON::use_boolean = 1;は、MongoDBからPerl変数へ変換する場合にbooleanを使うという指示なので、このサンプルコードの範囲内では特に意味がありません。

#!/usr/bin/perl

use strict;
use warnings;

use boolean;

use JSON;
use MongoDB;
use Data::Dumper;

my $coll = MongoDB::Connection->new()->get_database('db1')->get_collection('items');
$MongoDB::BSON::use_boolean = 1;

my $original = <<EOD;
    &#91;
        {
            "id": 0,
            "status": true
        },
        {
            "id": 1,
            "status": false
        },
        {
            "id": 2,
            "status": true
        },
        {
            "id": 3,
            "status": false
        }
    &#93;
EOD

my $array = from_json($original);
to_boolean($array);

foreach my $a (@$array){
    $coll->insert($a);
}

sub to_boolean{

    my $ref = shift;
    my $type = ref($ref);

    if ($type eq 'JSON::XS::Boolean' || $type eq 'JSON::Boolean'){
        return $ref ? true : false;
    } elsif ($type eq 'ARRAY'){
        foreach my $r (@$ref){
            $r = to_boolean($r);
        }
    } elsif ($type eq 'HASH'){
        foreach my $r (%$ref){
            $r = to_boolean($r);
        }
    }

    return $ref;

}

次のように、無事、MongoDB上でもtrue/falseとして保存されるようになりました。

> db.items.find()
{ "_id" : ObjectId("5142ebc6b60a8caf6d000000"), "status" : true, "id" : NumberLong(0) }
{ "_id" : ObjectId("5142ebc6b60a8caf6d000001"), "status" : false, "id" : NumberLong(1) }
{ "_id" : ObjectId("5142ebc6b60a8caf6d000002"), "status" : true, "id" : NumberLong(2) }
{ "_id" : ObjectId("5142ebc6b60a8caf6d000003"), "status" : false, "id" : NumberLong(3) }

ちなみに、to_boolean()実行後の$arrayはこんな状態です。

$VAR1 = [
          {
            'status' => bless( do{\(my $o = 1)}, 'boolean' ),
            'id' => 0
          },
          {
            'status' => bless( do{\(my $o = 0)}, 'boolean' ),
            'id' => 1
          },
          {
            'status' => $VAR1->[0]{'status'},
            'id' => 2
          },
          {
            'status' => $VAR1->[1]{'status'},
            'id' => 3
          }
        ];

JSON::Booleanもbooleanも同じ事やってるだけなんですけどねぇ……。JSON側でdecode時にJSON::Booleanじゃなくてbooleanを使うようなスイッチとかあればいいんですが。

これに限らず、ライブラリ間でのデータ型の問題は色々引っかかります。この前書いたUTF-8フラグのありなしもそういう事情でしたが。

最後になりましたが、Perlにおける真偽値の扱いはこちらも参考にして下さい。


てな記事をポストしようと思って準備していたところ、なんか面白げなKindle本の案内がTLに流れてきました。目次を見ると「Perlにおけるデータ表現」とかあるので面白そうです。あとで読んでみようと思います。

Perlについて語ろう
Perlについて語ろう

posted with amazlet at 13.03.15
和田裕介 (2013-03-13)
売り上げランキング: 15
  1. Binary JSON []
  2. JSONは、JSON::XSがインストールされていればそちらを。インストールされていなければJSONと一緒に配布されているJSON::PPを使って動作します。JSONとJSON::XSは互換性がある為、以下どちらがでてきても同じものだと思って下さい。 []
  3. 連想配列 []
  4. 未定義値 []