Perl Hackers Hub

第71回ISUCONの実装から最近のPerlを学ぶ ―わかりやすく変更しやすいコードを実現する考え方と方法(2)

前回の(1)こちらから。

JSONエンコードをわかりやすく変更しやすくする

ISUCON11のJSONエンコードでは、Cpanel::JSON::XSのバージョン4.26を利用しました。次のコードは、文字列としてJSONエンコードしたいならばJSON_TYPE_STRINGを、整数値ならばJSON_TYPE_INTを、真偽値ならばJSON_TYPE_BOOLを指定し、Cpanel::JSON::XS::Typeで型を明示している例です。

use Cpanel::JSON::XS;
use Cpanel::JSON::XS::Type;

# どうエンコードしたいか第二引数に指定できる
encode_json({
  name => "foo",
  age  => 10,
  flag => 1
}, {
  name => JSON_TYPE_STRING,
  age  => JSON_TYPE_INT,
  flag => JSON_TYPE_BOOL,
});
# => {"name":"foo","age":10,"flag":true}

以降では、JSONエンコードを巡る罠や、これまでISUCONで利用されてきたJSON::Typesとの違いに触れ、Cpanel::JSON::XS::Typeの利点を説明します。

JSONエンコードの罠たち

PerlのJSONエンコードでは、Perlの柔軟さゆえ罠がいくつかあり、これらの罠はCpanel::JSON::XS::TypeJSON::Typesといったモジュールの開発動機になります。

数値が、意図せず文字列になる罠

複数のプログラミング言語を使うとき、JSONの解釈の違いが問題になることがあります。たとえば、ISUCONでWebサービスのチューニング後も仕様を満たしているかを確認する際、JSONの型が異なれば不正となります。

具体的な例で説明すると、数値の12を1つ持つ配列をJSONエンコードすれば、通常は期待どおり[12]となりますが、次のコードのように(2)で出力をすると、JSON::XSのバージョン4.03のエンコードの結果は、意図せず["12"]となり12は文字列扱いされます。

use JSON::XS;

my $num = 12;  …(1)
warn "[DEBUG] num: $num \n";  …(2)
encode_json([$num]); # => ["12"]

この原因は、本連載第16回Perl内部構造の深遠に迫るで丁寧に解説されています。簡単にまとめると、(1)ではPerlの内部的には整数値IVInteger Valueだった$num(2)で文字列コンテキストとして評価され、$numは文字列PVPointer Valueと整数値IVの両方の性質を持つPVIVとなります。ここで問題なのが、もともとが数値なのか文字列なのかを判断できなくなることです。どちらの型でエンコードすべきかは、開発者の判断になります。この事情からJSON::XSでは、整数値のフラグが立っていても文字列のフラグが立っていれば文字列として扱うため、["12"]となります。一方、Cpanel::JSON::XSJSON::PPのバージョン2.91_01以降では数値として扱い、[12]となります。いずれにしろ、期待どおりにJSONエンコードされない不安を抱えます。

Perlで偽となる値が、JSONで偽とならない罠

1==0!!0をJSONエンコードした場合、falseとなることを期待しますが、次のコードが示すとおり現状そうはなりません。falseとエンコードしたい場合、\0(0のリファレンス)JSON::XS::falseなどを利用する必要があります。

encode_json([1 == 0]);          # [""]
encode_json([!!0]);             # [""]
encode_json([\0]);              # [false]
encode_json([JSON::XS::false]); # [false]

現在開発版であるPerl 5.35.4からは、Perlの判定式の結果を変数に代入しても保持するようになるため、JSONのシリアライズがより直感的になりそうです。

罠をシンプルに解決するJSON::Types

過去のISUCONで利用されていたJSON::Typesは、次のコードのとおり、数値が欲しければnumber関数を、真偽値が欲しければbool関数を挟み、JSONエンコードの型を指定し、正しくエンコードする手段を簡潔に提供します。

use JSON::Types;

my $num = 12;
warn "[DEBUG] num: $num \n";
encode_json([number $num]); # => [12]
encode_json([bool !!0]); #    => [false]

中身はシンプルで、number $numであれば$num + 0と等価で、bool !!1であれば!!1 ? \1 : \0と等価で、期待の型でエンコードされる変換を行っています。

JSON::Typesの弱点

JSON::Typesは簡潔で使いやすいユーティリティですが、いくつか弱点があると感じます。

1つ目は、JSON::Typesnumber関数で数値を期待する処理をしたあとに、もし文字列評価すれば、期待と異なる文字列になることです。

2つ目は、意図しないJSONエンコードが発生したとき、原因の追跡は骨が折れることです。次のコードは、この2つの問題が複合した例です。number $row->{id}したあとに、速度改善目的で$cache->{$row->{id}}とキャッシュを取り出すコードです。

my $row   = { id => 123 };
my $cache = { 123 => "hello" };

$row->{id} = number $row->{id};
$row->{value} = $cache->{$row->{id}};

encode_json($row);
# {"id":"123","value":"hello"}

これのJSONエンコードの結果は、idが意図せず"123"と文字列になります。原因は、ハッシュリファレンスのキーが文字列コンテキストで評価され、$row->{id}IVからPVIVになったためです。このコードであれば、$cache->{$row->{id}}を疑えるかもしれませんが、明確に理解するにはコンテキストの知識が必要です。これは気付きにくい不具合です[1]⁠。

3つ目は、ネストされる複雑な構造をJSONエンコードするとき、コードが複雑になることです。次のコードでは、$detail->{id}numberで変換するために二重ループしています。また、@json_itemsの構造、つまりどんなJSONが得られるのかがわかりにくいです。そのため、正しい変更を行えたか確認しづらいです。このコードはもっと簡潔に書けるかもしれませんが、JSONエンコードを頑張るよりほかを頑張りたいところです。

use JSON::XS;
use JSON::Types;

my @json_items;
for my $item ($items->@*) {
  my @json_details;
  for my $detail ($item->{details}->@*) {
    push @json_details => {
      id        => number $detail->{id},
      timestamp => number $detail->{timestamp},
    }
  }

  push @json_items => {
    name => string $item->{name},
    details => \@json_details,
  }
}

encode_json(\@json_items);

Cpanel::JSON::XS::TypeでJSONエンコードの罠とおさらば

ISUCON11のPerl実装では、JSONエンコードを正確にしつつ、複雑な構造でもデバッグしやすくするため、Cpanel::JSON::XS::Typeを利用しました。次のコードは、期待するJSONの型を宣言し、JSONエンコードしています。

# 期待するJSONの構造を宣言
use constant ItemDetail => {
  id        => JSON_TYPE_INT,
  timestamp => JSON_TYPE_INT,
};

# Itemは、ItemDetailのリストをネストしている
use constant Item => {
  name => JSON_TYPE_STRING,
  details => json_type_arrayof(ItemDetail),
};

# 取得してきたデータ($items)を
# 型と一緒に渡してJSONエンコードする
encode_json(
  $items,
  json_type_arrayof(Item)
);

このコードは、JSON::Typesの弱点を克服します。

まず、encode_jsonする際、第二引数に期待するJSONの構造を渡しています。そのため、意図せぬ型変換が挟まれる余地はありません。また、複雑なネスト構造があっても、上記のコードのItemDetailItemのような部品ごとに構造を書けます。

さらに、期待するJSON構造が明示されることで、正しく動作するか判断しやすくなり、変更しやすくなります。たとえば、次のコードのように、キーの名前を打ち間違えるとエラーが出ます。

encode_json({ boo => 1.0 }, { foo => JSON_TYPE_FLOAT });
# => ERROR: no type was specified for hash key 'boo'

まとめ

本稿では、最初にわかりやすさや変更しやすさの意味を掘り下げました。わかりやすさは意味を理解するまでの認知の円滑さと定義し、効果的に意味を理解するための方法を解説しました。また、わかりやすさだけでは十分な品質ではなく、変更に備えた設計が必要と書きました。

次に、ISUCON11の参考実装で利用した内容を例にして、Perlの4つ新機能が不要な罠を回避し、コードをわかりやすくすると紹介しました。

最後に、PerlでのJSONエンコードでありがちな罠とその回避方法を解説しました。Cpanel::JSON::XS::Typeで期待するJSON構造を明示することで、変更しやすさにもつながります。

開発の事情でPerlのバージョンを変更することが難しかったり、TMTOWTDIThere's More Then One Way To Do Itやり方は一つじゃない)というPerlのモットーのとおり、ほかにも良い書き方はあると思いますが、今回の記事が、わかりやすさ、変更のしやすさを考えるきっかけになれば幸いです。

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧