WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe       

Ruby 用 JSON パーサーを更新、 JSON への変換も追加

以前公開した前バージョンにはたくさんのアドバイス、リンクなどいただきまして、ありがとうございます。少々時間が経ってしまいましたが、あれからいろいろと勉強しまして、 strscan なる便利なライブラリが Ruby の標準ライブラリに含まれていることも知りました。それらをきちんと使えばコードをだいぶシンプルにできそうだったので、思い切って書き直してみました。まだまだ改善の余地はありますが、以前よりもだいぶ「Ruby っぽい」ソースになっているのではないかと思います。ついでにいくつか機能を追加してみました。従来からある機能も含めて、特徴は以下のとおりです。

  • eval などを使わずにまともに解析しているので堅牢である。
  • こちらのページのスペックを(今度こそ^^;)満たしている。サロゲート・ペアにも対応しました。
  • 他のライブラリに依存していないので、簡単に自分のソースに埋め込める。
  • UTF-8 の妥当性チェックを行うので、アプリケーションの脆弱性防止にある程度の効果がある(かもしれない)。
  • JSON のパースだけでなく、 Ruby オブジェクトから JSON 文字列することも生成も可能。

近いうちに RubyForge にスニペットとして登録しようと思いますが、一足先にこの場で公開します。もし旧バージョンをお使いの方がおられましたら、ぜひ差し替えてくださいませ。

※ RubyForge に登録しました。ただしコメントがへたれ英語なので、本文にリンクがある日本語コメント版の方がおすすめです。

ソースコード

それでは、まずはソースです。 JsonParser が従来の JSON 文字列を Ruby の配列 or ハッシュに変換するクラス、新たに追加した JsonBuilder がその逆を行うクラスです。通常は単にソースに挿入するだけで使えるようになるはずです。

# -*- coding: utf-8 -*-

# = Simple JSON parser & builder
#
# Author::  Chihiro Ito
# License:: Public domain (unlike other files)
# Support:: http://groups.google.com/group/webos-goodies/
# Version:: 1.09
#
# シンプルな JSON 処理クラスです。 JsonParser は JSON 文字列を
# 通常の配列・ハッシュに変換し、 JsonBuilder はその逆を行います。
# これらのクラスは JSON 標準への準拠と信頼性・安定性を意図して
# 制作されています。とくに JsonParser クラスには UTF-8 の正当性
# 検査機能があり、一部のセキュリティー攻撃を防ぐことができます。

require 'strscan'
require 'json' if RUBY_VERSION >= '1.9.0'

# = Simple JSON parser
#
# このクラスは JSON 文字列を配列やハッシュに変換します。
# *json_str* が JSON 形式の文字列であれば、以下のようにして
# 変換できます。
#
#   ruby_obj = JsonParser.new.parse(json_str)
#
# デフォルトでは、 *json_str* が不正な UTF-8 シーケンスを含んでいると
# 例外が発生します。この挙動は任意のユニコード文字で置き換えるように
# 変更することも可能です。詳細は以下を参照してください。
class JsonParser

  #:stopdoc:
  RUBY19             = RUBY_VERSION >= '1.9.0'
  Debug              = false
  Name               = 'JsonParser'
  ERR_IllegalSyntax  = "[#{Name}] Syntax error"
  ERR_IllegalUnicode = "[#{Name}] Illegal unicode sequence"

  StringRegex = /\s*"((?:\\.|[^"\\])*)"/n
  ValueRegex  = /\s*(?:
		(true)|(false)|(null)|            # 1:true, 2:false, 3:null
		(?:\"((?:\\.|[^\"\\])*)\")|       # 4:String
		([-+]?\d+\.\d+(?:[eE][-+]?\d+)?)| # 5:Float
		([-+]?\d+)|                       # 6:Integer
		(\{)|(\[))/xn                     # 7:Hash, 8:Array
  #:startdoc:

  # JsonParser のインスタンスを新規作成します。 *options* には以下の値を指定できます。
  # [:validation]
  #     false にすると、 UTF-8 検証機能が無効になります。デフォルトは true です。
  # [:surrogate]
  #     false にすると、サロゲートペアのサポートが無効になります。デフォルトは true です。
  # [:malformed_chr]
  #     JSON 文字列に含まれる不正なシーケンスはこの値で置き換えられます。
  #     nil を設定すると、置き換える代わりに例外を投げます。
  #     デフォルトは nil です。
  # [:compatible]
  #     true にすると、 Ruby 1.9 の JSON モジュールを使わなくなります。デフォルトは false です。
  def initialize(options = {})
    @default_validation    = options.has_key?(:validation)    ? options[:validation]    : true
    @default_surrogate     = options.has_key?(:surrogate)     ? options[:surrogate]     : true
    @default_malformed_chr = options.has_key?(:malformed_chr) ? options[:malformed_chr] : nil
    @default_compatible    = options.has_key?(:compatible)    ? options[:compatible]    : false
  end

  # *str* を配列・ハッシュに変換します。
  # [str]
  #     JSON 形式の文字列です。 UTF-8 エンコーディングでなければなりません。
  # [options]
  #     new と同じです。
  def parse(str, options = {})
    @enable_validation = options.has_key?(:validation)    ? options[:validation]    : @default_validation
    @enable_surrogate  = options.has_key?(:surrogate)     ? options[:surrogate]     : @default_surrogate
    @malformed_chr     = options.has_key?(:malformed_chr) ? options[:malformed_chr] : @default_malformed_chr
    @compatible        = options.has_key?(:compatible)    ? options[:compatible]    : @default_compatible
    @malformed_chr = @malformed_chr[0].ord if String === @malformed_chr
    if RUBY19
      str = (str.encode('UTF-8') rescue str.dup)
      if @enable_validation && !@malformed_chr
        raise err_msg(ERR_IllegalUnicode) unless str.valid_encoding?
        @enable_validation = false
      end
      if !@enable_validation && @enable_surrogate && !@malformed_chr && !@compatible
        begin
          return JSON.parse(str, :max_nesting => false)
        rescue JSON::JSONError => e
          exception = RuntimeError.new(e.message)
          exception.set_backtrace(e.backtrace)
          raise exception
        end
      end
      str.force_encoding('ASCII-8BIT')
    end
    @scanner = StringScanner.new(str)
    obj = case get_symbol[0]
          when ?{ then parse_hash
          when ?[ then parse_array
          else         raise err_msg(ERR_IllegalSyntax)
          end
    @scanner = nil
    obj
  end

  private #---------------------------------------------------------

  def validate_string(str, malformed_chr = nil)
    code  = 0
    rest  = 0
    range = nil
    ucs   = []
    str.each_byte do |c|
      if rest <= 0
        case c
        when 0x01..0x7f then rest = 0 ; ucs << c
        when 0xc0..0xdf then rest = 1 ; code = c & 0x1f ; range = 0x00080..0x0007ff
        when 0xe0..0xef then rest = 2 ; code = c & 0x0f ; range = 0x00800..0x00ffff
        when 0xf0..0xf7 then rest = 3 ; code = c & 0x07 ; range = 0x10000..0x10ffff
        else                 ucs << handle_malformed_chr(malformed_chr)
        end
      elsif (0x80..0xbf) === c
        code = (code << 6) | (c & 0x3f)
        if (rest -= 1) <= 0
          if !(range === code) || (0xd800..0xdfff) === code
            code = handle_malformed_chr(malformed_chr)
          end
          ucs << code
        end
      else
        ucs << handle_malformed_chr(malformed_chr)
        rest = 0
      end
    end
    ucs << handle_malformed_chr(malformed_chr) if rest > 0
    ucs.pack('U*')
  end

  def handle_malformed_chr(chr)
    raise err_msg(ERR_IllegalUnicode) unless chr
    chr
  end

  def err_msg(err)
    err + (Debug ? " #{@scanner.string[[0, @scanner.pos - 8].max,16].inspect}" : "")
  end

  def unescape_string(str)
    str = str.gsub(/\\(["\\\/bfnrt])/n) do
      $1.tr('"\\/bfnrt', "\"\\/\b\f\n\r\t")
    end.gsub(/(\\u[0-9a-fA-F]{4})+/n) do |matched|
      seq = matched.scan(/\\u([0-9a-fA-F]{4})/n).flatten.map { |c| c.hex }
      if @enable_surrogate
        seq.each_index do |index|
          if seq[index] && (0xd800..0xdbff) === seq[index]
            n = index + 1
            raise err_msg(ERR_IllegalUnicode) unless seq[n] && 0xdc00..0xdfff === seq[n]
            seq[index] = 0x10000 + ((seq[index] & 0x03ff) << 10) + (seq[n] & 0x03ff)
            seq[n] = nil
          end
        end.compact!
      end
      seq.pack('U*')
    end
    str = validate_string(str, @malformed_chr) if @enable_validation
    RUBY19 ? str.force_encoding('UTF-8') : str
  end

  def get_symbol
    raise err_msg(ERR_IllegalSyntax) unless @scanner.scan(/\s*(.)/n)
    @scanner[1]
  end

  def peek_symbol
    @scanner.match?(/\s*(.)/n) ? @scanner[1] : nil
  end

  def parse_string
    raise err_msg(ERR_IllegalSyntax) unless @scanner.scan(StringRegex)
    unescape_string(@scanner[1])
  end

  def parse_value
    raise err_msg(ERR_IllegalSyntax) unless @scanner.scan(ValueRegex)
    case
    when @scanner[1] then true
    when @scanner[2] then false
    when @scanner[3] then nil
    when @scanner[4] then unescape_string(@scanner[4])
    when @scanner[5] then @scanner[5].to_f
    when @scanner[6] then @scanner[6].to_i
    when @scanner[7] then parse_hash
    when @scanner[8] then parse_array
    else                  raise err_msg(ERR_IllegalSyntax)
    end
  end

  def parse_hash
    obj = {}
    if peek_symbol[0] == ?} then get_symbol ; return obj ; end
    while true
      index = parse_string
      raise err_msg(ERR_IllegalSyntax) unless get_symbol[0] == ?:
      value = parse_value
      obj[index] = value
      case get_symbol[0]
      when ?} then return obj
      when ?, then next
      else         raise err_msg(ERR_IllegalSyntax)
      end
    end
  end

  def parse_array
    obj = []
    if peek_symbol[0] == ?] then get_symbol ; return obj ; end
    while true
      obj << parse_value
      case get_symbol[0]
      when ?] then return obj
      when ?, then next
      else         raise err_msg(ERR_IllegalSyntax)
      end
    end
  end

end

# = Simple JSON builder
#
# Ruby オブジェクトを JSON 文字列に変換するクラスです。 *ruby_obj* を
# 以下のようにして変換できます。
#
#   json_str = JsonBuilder.new.build(ruby_obj)
#
# *ruby_obj* は以下の条件を満たさねばなりません。
#
# - to_s メソッドをサポートしているか、もしくは配列・ハッシュ・ nil のいずれかでなければなりません。
# - ハッシュの全てのキーは to_s メソッドをサポートしていなければなりません。
# - 配列・ハッシュのすべての値は上記の条件を満たしていなければなりません。
#
# もし *ruby_obj* が配列・ハッシュのいずれでもない場合、 1 要素の配列に変換されます。
class JsonBuilder

  #:stopdoc:
  RUBY19             = RUBY_VERSION >= '1.9.0'
  Name               = 'JsonBuilder'
  ERR_NestIsTooDeep  = "[#{Name}] Array / Hash nested too deep."
  ERR_NaN            = "[#{Name}] NaN and Infinite are not permitted in JSON."
  #:startdoc:

  # JsonBuilder のインスタンスを新規作成します。 *options* には以下の値を指定できます。
  # [:max_nest]
  #     もし配列・ハッシュのネストがこの値を超えた場合、例外が発生します。
  #     デフォルトは 64 です。
  # [:nan]
  #     すべての NaN はこの値で置き換えられます。もし nil もしくは false であれば
  #     代わりに例外が発生します。デフォルトは nil です。
  def initialize(options = {})
    @default_max_nest = options.has_key?(:max_nest) ? options[:max_nest] : 19
    @default_nan      = options.has_key?(:nan)      ? options[:nan]      : nil
  end

  # *obj* を JSON 形式の文字列に変換します。
  # [obj]
  #     Ruby オブジェクトです。前述の条件を満たしていなければなりません。
  # [options]
  #     new と同じです。
  def build(obj, options = {})
    @max_nest = options.has_key?(:max_nest) ? options[:max_nest] : @default_max_nest
    @nan      = options.has_key?(:nan)      ? options[:nan]      : @default_nan
    if RUBY19 && !@nan
      begin
        JSON.generate(obj, :max_nesting => @max_nest, :check_circular => false)
      rescue JSON::JSONError => e
        exception = RuntimeError.new(e.message)
        exception.set_backtrace(e.backtrace)
        raise exception
      end
    else
      case obj
      when Array then build_array(obj, 0)
      when Hash  then build_object(obj, 0)
      else            build_array([obj], 0)
      end
    end
  end

  private #---------------------------------------------------------

  ESCAPE_CONVERSION = {
    '"'    => '\"', '\\'   => '\\\\', '/'    => '\/', "\x08" => '\b',
    "\x0c" => '\f', "\x0a" => '\n',   "\x0d" => '\r', "\x09" => '\t'
  }
  if RUBY19
    def escape(str)
      str = str.to_s.encode('UTF-8')
      str.force_encoding('ASCII-8BIT')
      str = str.gsub(/[^\x20-\x21\x23-\x2e\x30-\x5b\x5d-\xff]/n) do |chr|
        escaped = ESCAPE_CONVERSION[chr]
        escaped = sprintf("\\u%04X", chr[0].ord) unless escaped
        escaped
      end
      str.force_encoding('UTF-8')
      "\"#{str}\""
    end
  else
    def escape(str)
      str = str.gsub(/[^\x20-\x21\x23-\x2e\x30-\x5b\x5d-\xff]/n) do |chr|
        escaped = ESCAPE_CONVERSION[chr]
        escaped = sprintf("\\u%04x", chr[0]) unless escaped
        escaped
      end
      "\"#{str}\""
    end
  end

  def build_value(obj, level)
    case obj
    when Integer, TrueClass, FalseClass then obj.to_s
    when Float    then raise ERR_NaN unless obj.finite? || (obj = @nan) ; obj.to_s
    when NilClass then 'null'
    when Array    then build_array(obj, level + 1)
    when Hash     then build_object(obj, level + 1)
    else               escape(obj)
    end
  end

  def build_array(obj, level)
    raise ERR_NestIsTooDeep if level >= @max_nest
    '[' + obj.map { |item| build_value(item, level) }.join(',') + ']'
  end

  def build_object(obj, level)
    raise ERR_NestIsTooDeep if level >= @max_nest
    '{' + obj.map do |item|
      "#{build_value(item[0].to_s,level)}:#{build_value(item[1],level)}"
    end.join(',') + '}'
  end

end

ダウンロードは以下の場所から。都合によりファイル名が "SimpleJson_jp.rb" になっています。

http://ruby-webapi.googlecode.com/svn/trunk/misc/S...

JsonParser の使い方

JsonParser クラスは JSON 文字列を Ruby の配列やハッシュに変換するシンプルな JSON パーサーです。基本的には以下のように使います。

parser = JsonParser.new
ruby_obj = parser.parse(json_string)

これで、 json_string に格納された JSON 文字列を解析し、配列もしくはハッシュとして返します。 json_string は UTF-8 エンコーディングを前提にしていますので、他の文字コードの場合はあらかじめ UTF-8 に変換しておいてください。また、処理中にエラーが発生すると例外を投げますので、クリティカルな用途に使う場合は例外のハンドリングを忘れないでください。

コンストラクタ

JsonParser クラスのコンストラクタは以下の書式になります。

JsonParser.new(option = {})

option はハッシュで、以下の要素を持つことができます。

要素名(Symbol型)機能デフォルト
:validationfalse なら UTF-8 の検証を行わないtrue
:surrogatefalse ならサロゲート・ペアを処理しないtrue
:malformed_chr不正な UTF-8 シーケンスを置き換える文字nil

validation などによる速度低下が気になる場合、%u 記法を用いて無理やりユニコード以外の文字コードを表現している場合などは検証機能やサロゲート・ペアの変換機能が邪魔になりますので、このオプションで無効にしてください。

parse メソッド

parse メソッドは実際に JSON 文字列を Ruby の配列・ハッシュに変換します。書式は以下のようになります。

def parser(str, option = {})
str
変換元の JSON 文字列です。 UTF-8 エンコーディングを前提にしています。
option
コンストラクタの引数と同じハッシュです。コンストラクタで指定したオプションを上書きできます。

戻り値は JSON を変換した配列もしくはハッシュです。

JsonBuilder の使い方

JsonBuilder は Ruby のオブジェクトを JSON 文字列にシリアライズするクラスです。基本的な使い方は以下のようになります。

builder = JsonBuilder.new
json_string = builder.build(ruby_obj)

ただし、どのような Ruby オブジェクトでもシリアライズできるわけではなく、以下の条件を満たさなくてはなりません。

  • TrueClass, FalseClass, NilClass, Numeric, String, Array, Hash のいずれかのクラスのインスタンス、もしくは to_s メソッドで String に変換できるオブジェクトであること。
  • Array, Hash の各要素も上記の条件に従うこと。
  • Hash のインデックスは String インスタンス、もしくは to_s メソッドで String に変換できるオブジェクトであること。

build メソッドの引数が Array でも Hash でもない場合、単一要素の配列に変換されます。また、こちらもエラーが発生すると例外を投げるので、ハンドリングを忘れないようにしてください。オプション引数などはとくにないので、詳細は省略します。上記のサンプルがすべてです(^^;

UTF-8 検証機能の詳細

今回のバージョンから、新たに UTF-8 文字列の妥当性チェックを行う機構が実装されています。具体的なチェック内容は以下のとおりです。

  • マルチバイトコードが途中で途切れていないか。
  • 0x80~0xbf (マルチバイトの 2byte 以降に使われるコード)がいきなり出現していないか。
  • UTF-16 に変換できない領域が使われていないか。
  • 不必要に長い不正なコードが使われていないか(ASCII コードをマルチバイトで表現しているなど)。
  • サロゲートペア用の領域が使われていないか。
  • null 文字が含まれていないか。

これらのチェックに引っかかると、その文字を malformed_chr オプションで指定した文字に置き換えます(オプションが指定されていないか nil の場合は例外を投げます)。これにより、不正な UTF-8 シーケンスによるエスケープ抜けや区切り文字の読み飛ばしなどの危険がだいぶ減るのではないかと思います。ただし、単純にエンコーディングの規則上あり得ないコードを弾くだけなので、必ずしも文字が定義されていることを保証するわけではありません。あらかじめご了承ください。

上記の検証内容はとりあえず私が思いつく範囲で実装したものなので、ご意見をお待ちしています。個人的には例外を投げずに空白などに置き換えるだけに留めたほうが良いかなどが迷いどころです。その他、抜けや不都合がありましたら、ぜひお知らせください。

ライセンスについて

前回は基本自由だけど必要があれば New BSD / Ruby ライセンス / LGPL のいずれかなんていう曖昧な感じでしたが、余計に混乱する気がしてきたので、パブリックドメインということで一本化しようと思います。要するに好きに使ってください。著作者名の表示などもなくてかまいません。その気になれば一日で作れる代物ですから(笑)。日本では本当の意味でのパブリックドメインはあり得ないとかなんとかあるらしいですが、ま、固いことは言わない方向で。

その代わりというわけではありませんが、当然ながら無保証ですので、使用した結果に関してはすべて自己責任でお願いします。できるだけサポートはしようと思いますが、限界はあります。

このあたりご了承いただいた上で、ご自由にお使いください。

ご意見・ご要望・バグ報告などは Google グループへ

今回から、サポート用に Google グループを設けました。感想などは blog へのコメントでかまいませんが、ご意見やご質問、バグ報告などはこちらを使っていただけると嬉しいです。できるだけ情報集約しておきたいためです。 Google アカウントがあればどなたでも書き込めますので、どしどし投稿してください。

Google グループ Beta
WebOS Goodiesに参加
メール アドレス:

また、今後の細かいバージョンアップなどの情報もグループで配信する予定です。 JsonParser, JsonBuilder クラスをご利用になる方は、ぜひグループへの参加、もしくはフィードの購読をお勧めします。

それでは、パワーアップした JsonParser クラスおよび新規追加の JsonBuilder クラス、ぜひご活用ください!


2007/2/27
JsonParser クラスのオプションのインデックスを文字列から Symbol 型に変更しました。後で直そうと思ってすっかり忘れていました。たいへん申し訳ありません。
2007/3/2
空配列がパースできないなど、いくつかのバグがありました(Rubricks の Shouta さん、ご報告ありがとうございます)。ソースコードを修正版に差し替えました。
2007/3/3
旧バージョンの記事で PuniPuni さんからいただいたコメントを参考に、正規表現の文字コード指定(u ではなく n にしました。もともとバイト列のつもりで作っていたので・・・^^ゞ)、 Unicode の上限の修正を行いました。 ^ と \A については、 strscan 移行時にそもそも ^ がなくなっていました。その他、試験的に JsonBuilder に配列・ハッシュのネストレベルの制限を設けました。
2007/3/21
validate_string のバグをいくつか修正しました。
2007/8/7
さらにいくつかのバグを修正しました。さらに日本語コメントを追加してコードなにがしにアップ。これに伴い、記事にソースを掲載するのはやめることにしました。ご了承ください。
2007/8/17
コードなにがしガジェットを利用して、ソースの掲載を復活させました。
2010/2/13
Ruby 1.9 ではできるだけ標準の JSON モジュールを使うなどの改善を施しました。また、コードなにがしが不安定なので、ソースを Google Code Project Hosting に移しました。
関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles