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型) | 機能 | デフォルト |
---|---|---|
:validation | false なら UTF-8 の検証を行わない | true |
:surrogate | false ならサロゲート・ペアを処理しない | 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 アカウントがあればどなたでも書き込めますので、どしどし投稿してください。
また、今後の細かいバージョンアップなどの情報もグループで配信する予定です。 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 に移しました。
詳しくはこちらの記事をどうぞ!
この記事にコメントする