用Ruby实现了一个非常有趣且高级的概念:多线程双向 JSON 解析器(Bi-Directional Onion Parser)。
它的核心目标是通过并行处理来提高大文件 JSON 的解析速度。它将文件一分为二,前半部分用"正向解析器"读取,后半部分用"逆向解析器"倒着读取,最后将两部分的结果在中间的"切断点"进行无缝合并。
下面是各个组件的详细原理解析:
一. 核心架构思想
通常 JSON 解析是线性的(从头读到尾)。但对于巨大的文件(例如几百 MB 或 GB),单线程是瓶颈。这个解析器的策略是:
- ForwardParser(前锋):从文件头(Byte 0)读到文件中间(Byte N/2)。
- ReverseParser(后卫):从文件尾(Byte Max)读到文件中间(Byte N/2)。
- OnionBiDirectionalParser(指挥官):负责调度线程,并在中间点将两个"残缺"的解析结果缝合在一起。
二. ForwardParser(正向解析器)
这是一个基于状态机的流式解析器。
- 工作方式:它像标准的 JSON 解析器一样逐字符读取。
- 特殊之处 :
- 它不需要解析完整个 JSON。当它读到指定的文件中间位置时,它会停止。
- 它会保留解析状态 :
stack:当前嵌套的层级(比如正在解析哪个数组或对象)。current_container:当前正在填充的数据结构。pending_key:如果正在解析 Hash,记录下当前等待值的 Key。
- raw_incomplete :这是最关键的属性。当流结束时,最后剩下的无法构成完整 Token 的字符串(例如
"hello wor)会被保存在这里,等待与后半部分拼接。
三. ReverseParser(逆向解析器)
这是整个代码中最具创新性的部分。解析 JSON 通常需要知道上下文(比如"这是数组的第一个元素吗?"),但逆向解析是从 } 或 ] 开始倒着推导。
- 工作方式 :从后往前读字符。
- 遇到
}:调用parse_object,开始倒着收集键值对。 - 遇到
]:调用parse_array,倒着收集元素。 - 遇到
":调用parse_string,向前寻找开头的引号。
- 遇到
- 难点与解决方案(ANON) :
- 当逆向解析器读到中间切分点时,数据会被截断。例如
value"},它解析出了字符串value,但不知道这个值的 Key 是什么(因为 Key 在前半部分)。 - 为了解决这个问题,它引入了
ANON("ANON") 常量。 - 当它解析到一个孤立的值,或者无法确定其父级关系时,它会将这个值包裹在一个带有
ANON键的 Hash 中。这相当于告诉合并器:"这里有个东西,但我不知道它的名字,它是断裂点。"
- 当逆向解析器读到中间切分点时,数据会被截断。例如
四. OnionBiDirectionalParser(洋葱合并器)
这是逻辑最复杂的地方,负责将两头对接。
三种合并
- Hash with pending key :断在对象的值上(如
{"val": "xxx...) - Array:断在数组元素上
- Hash without pending key:断在对象的键上
合并流程 (merge 方法):
-
提取前缀 :查看正向解析器剩下的
raw_incomplete。- 比如前半部分读到了
{"message": "Hello Wor。 raw_incomplete就是"Hello Wor。- 提取出字符串前缀
Hello Wor。
- 比如前半部分读到了
-
寻找最内层 :查看逆向解析器的结果
rev.result。- 逆向结果可能是
{"__ANON__": {"__ANON__": "ld"}}(因为是倒着构建的,最深层的ANON通常对应切分点)。 - 它会剥离外层的
ANON包装,找到最核心的那个值(这里是ld)。
- 逆向结果可能是
-
缝合伤口:
- 将前缀
Hello Wor和后缀ld拼接成Hello World。 - 填充状态 :检查正向解析器的状态。
- 如果正向解析器停在 Hash 的 Value 处(有
pending_key),则将拼接好的值赋给这个 Key。 - 如果停在 Array 中,则将拼接好的值
push进数组。
- 如果正向解析器停在 Hash 的 Value 处(有
- 将前缀
-
合并剩余结构:
- 逆向解析器除了切断点的值,还可能解析出了属于同一个对象的其他键值对。
- 代码会遍历逆向结果的剩余部分(
top_keys),将它们合并到正向解析器的root对象中。
五. 举例演示
假设文件内容是:
javascript
{"id": 123, "text": "Hello World", "status": true}
文件在 Hello Wor 和 ld 之间被切分。
线程 1 (Forward):
- 解析了
{"id": 123, - 读到了 Key
"text"。 - 读到了前缀
"Hello Wor。 - 状态 :
current_container:{"id": 123}pending_key:"text"raw_incomplete:"Hello Wor
线程 2 (Reverse):
- 倒着读了
}(对象结束)。 - 倒着读了
true(Value),"status"(Key)。 - 倒着读了 l
d"。 - 结果 :
- 它不知道
ld属于哪个 Key(因为 Key 在前半部分)。 - 生成结构(简化版):
{"status": true, "__ANON__": "ld"}。
- 它不知道
合并 (Merge):
- Forward 说:"我剩个
Hello Wor,而且我正等着给text赋值。" - Reverse 说:"我最里面的断头数据是
ld,但我还有个完整的status: true。" - 合并器操作:
- 拼接:
Hello Wor+ld=Hello World。 - 赋值:
container["text"] = "Hello World"。 - 追加:把 Reverse 里的
status: true合并进container。
- 拼接:
- 最终结果 :
{"id": 123, "text": "Hello World", "status": true}。
六. 总结
这段代码是一个非常硬核的工程实现。
- 优点:充分利用多核 CPU,对于包含长字符串或大数组的巨型 JSON 文件,理论解析速度可以翻倍。
- 技术栈 :涉及手写词法分析(Lexing)、状态机(State Machine)、递归下降(尽管是倒着的)、多线程同步和复杂的数据结构合并算法。
七. 完整代码
ruby
# frozen_string_literal: true
require 'thread'
require 'json'
# ==========================================
# 正向解析器 (流式)
# ==========================================
class ForwardParser
attr_reader :root, :raw_incomplete, :pending_key
attr_reader :stack, :current_container
WHITESPACE = " \t\n\r"
NUM_CHARS = '0123456789.eE+-'
def initialize
@root = nil
@stack = []
@current_container = nil
@pending_key = nil
@state = :value
@buffer = String.new(capacity: 4096)
end
def parse_chunk(chunk)
@buffer << chunk
end
def finalize
@raw_incomplete = parse_internal(@buffer)
@root
end
private
def parse_internal(data)
i = 0
len = data.length
while i < len
c = data[i]
if WHITESPACE.include?(c)
i += 1
next
end
case c
when '{'
push_container({}, :key)
when '['
push_container([], :value)
when '}', ']'
pop_container
when ':'
@state = :value
when ','
@state = @current_container.is_a?(Hash) ? :key : :value
when '"'
e = find_str_end(data, i + 1, len)
if e
handle_string(unescape(data, i + 1, e))
i = e
else
return data[i..-1]
end
when '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
e = i + 1
while e < len
break unless NUM_CHARS.include?(data[e])
e += 1
end
if e == len
return data[i..-1]
end
attach_value(parse_num(data[i...e]))
i = e - 1
when 't'
if i + 4 <= len && data[i, 4] == 'true'
attach_value(true)
i += 3
else
return data[i..-1]
end
when 'f'
if i + 5 <= len && data[i, 5] == 'false'
attach_value(false)
i += 4
else
return data[i..-1]
end
when 'n'
if i + 4 <= len && data[i, 4] == 'null'
attach_value(nil)
i += 3
else
return data[i..-1]
end
end
i += 1
end
""
end
def push_container(cont, state)
attach_value(cont)
@stack.push({ container: @current_container, key: @pending_key })
@current_container = cont
@pending_key = nil
@state = state
end
def pop_container
frame = @stack.pop
if frame
@current_container = frame[:container]
@pending_key = frame[:key]
end
@state = :comma
end
def handle_string(str)
if @state == :key
@pending_key = str
@state = :colon
else
attach_value(str)
@state = :comma
end
end
def attach_value(val)
if @root.nil?
@root = val
@current_container = val if val.is_a?(Hash) || val.is_a?(Array)
elsif @current_container.is_a?(Hash) && @pending_key
@current_container[@pending_key] = val
@pending_key = nil
elsif @current_container.is_a?(Array)
@current_container << val
end
end
def find_str_end(data, start, len)
i = start
while i < len
c = data[i]
return i if c == '"'
i += (c == '\\' ? 2 : 1)
end
nil
end
def parse_num(s)
return 0 if s.nil? || s.empty?
s.include?('.') || s.include?('e') || s.include?('E') ? s.to_f : s.to_i
end
def unescape(data, from, to)
s = data[from...to]
return s unless s.include?('\\')
s.gsub('\\\\', "\x00").gsub('\\"', '"')
.gsub('\\n', "\n").gsub('\\r', "\r").gsub('\\t', "\t").gsub("\x00", '\\')
end
end
# ==========================================
# 逆向解析器 (流式,内存高效)
# ==========================================
class ReverseParser
ANON = "__ANON__"
WHITESPACE = " \t\n\r"
NUM_CHARS = '0123456789.eE+-'
attr_reader :result, :raw_incomplete
def initialize(debug: false)
@debug = debug
@result = nil
@stack = []
@buffer = ""
@pending_val = nil
end
def parse_chunk(chunk)
@buffer = chunk + @buffer
process_buffer
end
def finalize
@raw_incomplete = @buffer
current = @raw_incomplete
if @pending_val
current = { ANON => { key: ANON, value: @pending_val } }
end
if @result
if @result.is_a?(Array)
@result.unshift(current)
current = { ANON => @result }
elsif @result.is_a?(Hash)
@result[ANON] = current
current = @result
end
end
while (frame = @stack.pop)
parent = frame[:container]
if parent.is_a?(Array)
parent.unshift(current)
current = { ANON => parent }
elsif parent.is_a?(Hash)
parent[ANON] = current
current = parent
end
end
@result = current.is_a?(String) ? { ANON => current } : current
end
private
def process_buffer
loop do
len = @buffer.length
return if len == 0
pos = len - 1
while pos >= 0 && WHITESPACE.include?(@buffer[pos])
pos -= 1
end
break if pos < 0
if pos < len - 1
@buffer = @buffer[0..pos]
len = pos + 1
end
c = @buffer[pos]
case c
when '}', ']'
container = (c == '}') ? {} : []
push_stack(container)
@buffer = @buffer[0...pos]
when '{', '['
expected_type = (c == '{') ? Hash : Array
if @result.is_a?(expected_type)
closed_container = @result
pop_stack
attach_value(closed_container)
@buffer = @buffer[0...pos]
else
break
end
when ':', ','
@buffer = @buffer[0...pos]
when '"'
val, start_idx = parse_string(pos)
if val == :incomplete
break
else
attach_value(val)
@buffer = @buffer[0...start_idx]
end
else
val, start_idx = parse_primitive(pos)
if val == :incomplete
break
else
attach_value(val)
@buffer = @buffer[0...start_idx]
end
end
end
end
def push_stack(container)
@stack.push({ container: @result, pending_val: @pending_val })
@result = container
@pending_val = nil
end
def pop_stack
frame = @stack.pop
if frame
@result = frame[:container]
@pending_val = frame[:pending_val]
else
@result = nil
end
end
def attach_value(val)
if @result.is_a?(Array)
@result.unshift(val)
elsif @result.is_a?(Hash)
if @pending_val.nil?
@pending_val = val
else
key = val
@result[key] = @pending_val
@pending_val = nil
end
else
@result = val
end
end
def parse_string(end_pos)
i = end_pos - 1
while i >= 0
if @buffer[i] == '"'
esc = 0
j = i - 1
while j >= 0 && @buffer[j] == '\\'
esc += 1
j -= 1
end
if esc.even?
str = unescape(@buffer[(i+1)...end_pos])
return [str, i]
end
end
i -= 1
end
[:incomplete, end_pos]
end
def parse_primitive(end_pos)
i = end_pos
while i >= 0
c = @buffer[i]
is_valid = NUM_CHARS.include?(c) || "truefalsenull".include?(c)
break unless is_valid
i -= 1
end
if i < 0
return [:incomplete, end_pos]
end
start_idx = i + 1
s = @buffer[start_idx..end_pos]
val = nil
case s
when 'true' then val = true
when 'false' then val = false
when 'null' then val = nil
else
if s =~ /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
val = s.include?('.') || s.include?('e') || s.include?('E') ? s.to_f : s.to_i
else
return [:incomplete, end_pos]
end
end
[val, start_idx]
end
def unescape(s)
return s unless s.include?('\\')
s.gsub('\\\\', "\x00").gsub('\\"', '"')
.gsub('\\n', "\n").gsub('\\r', "\r").gsub('\\t', "\t").gsub("\x00", '\\')
end
end
# ==========================================
# 洋葱式双向解析器
# ==========================================
class OnionBiDirectionalParser
ANON = ReverseParser::ANON
def initialize(filepath, chunk_size: 8192, debug: false)
@filepath = filepath
@file_size = File.size(filepath)
@chunk_size = chunk_size
@debug = debug
end
def parse
return parse_single_stream if @file_size <= @chunk_size
mid = @file_size / 2
fwd = ForwardParser.new
rev = ReverseParser.new(debug: @debug)
t1 = Thread.new { stream_forward(fwd, 0, mid) }
t2 = Thread.new { stream_reverse(rev, mid, @file_size) }
t1.join
t2.join
log "=== 合并开始 ==="
log "正向incomplete: #{fwd.raw_incomplete.inspect}"
log "逆向incomplete: #{rev.raw_incomplete.inspect}"
merge(fwd, rev)
end
private
def stream_forward(parser, from, to)
File.open(@filepath, 'rb') do |f|
f.seek(from)
remaining = to - from
while remaining > 0
chunk = f.read([remaining, @chunk_size].min)
break unless chunk && !chunk.empty?
parser.parse_chunk(chunk)
remaining -= chunk.length
end
end
parser.finalize
end
def stream_reverse(parser, from, to)
File.open(@filepath, 'rb') do |f|
pos = to
while pos > from
read_size = [pos - from, @chunk_size].min
start_pos = pos - read_size
f.seek(start_pos)
chunk = f.read(read_size)
parser.parse_chunk(chunk)
pos -= read_size
end
end
parser.finalize
end
def parse_single_stream
fwd = ForwardParser.new
File.open(@filepath, 'rb') do |f|
while (chunk = f.read(@chunk_size))
fwd.parse_chunk(chunk)
end
end
fwd.finalize
end
def merge(fwd_parser, rev_parser)
fwd_root = fwd_parser.root
rev_root = rev_parser.result
fwd_current = fwd_parser.current_container
fwd_pending = fwd_parser.pending_key
fwd_stack = fwd_parser.stack
fwd_incomplete = fwd_parser.raw_incomplete || ""
return fwd_root if rev_root.nil?
return clean_anon!(rev_root) if fwd_root.nil?
string_prefix = extract_string_prefix(fwd_incomplete)
log "字符串前缀: #{string_prefix.inspect}"
innermost, top_keys = find_innermost(rev_root)
log "最内层: #{innermost.inspect[0..200]}"
log "正向栈深度: #{fwd_stack.length}"
# 获取逆向解析的完整数组(如果存在)
reverse_array = find_complete_array(rev_root)
log "逆向数组: #{reverse_array ? "长度#{reverse_array.length}" : "无"}"
merged_value = nil
remaining_siblings = {}
# 计算合并后的值
if innermost.is_a?(Hash) && innermost.key?(ANON)
core = innermost[ANON]
remaining_siblings = innermost.reject { |k, _| k == ANON }
if core.is_a?(Hash) && core.key?(:key)
suffix = strip_trailing_quote(core[:key], string_prefix)
full_key = string_prefix + suffix
merged_value = { full_key => core[:value] }
else
if string_prefix.empty?
raw_val = fwd_incomplete + core.to_s
merged_value = try_parse_primitive(raw_val)
else
suffix = strip_trailing_quote(core, string_prefix)
merged_value = string_prefix + suffix
end
end
elsif innermost.is_a?(String)
if string_prefix.empty?
raw_val = fwd_incomplete + innermost
merged_value = try_parse_primitive(raw_val)
else
suffix = strip_trailing_quote(innermost, string_prefix)
merged_value = string_prefix + suffix
end
else
merged_value = innermost
end
log "合并值: #{merged_value.inspect[0..100]}"
# 填充到正向结构
if fwd_pending && fwd_current.is_a?(Hash)
log "场景: Hash with pending key"
fwd_current[fwd_pending] = merged_value
remaining_siblings.each { |k, v| fwd_current[k] = clean_anon!(v) }
# 关键修复:检查这个 Hash 是否在数组中
# 如果是,需要将逆向数组的剩余元素添加到父数组
parent_array = find_parent_array(fwd_stack, fwd_current)
if parent_array && reverse_array && reverse_array.length > 1
log "Hash 在数组中,添加数组后续元素"
reverse_array[1..-1].each do |item|
parent_array << clean_anon!(item)
end
log "父数组最终长度: #{parent_array.length}"
end
elsif fwd_current.is_a?(Array)
log "场景: Array 填充"
fwd_current << merged_value
log "添加合并值后数组长度: #{fwd_current.length}"
# 处理逆向数组的其余元素
if reverse_array && reverse_array.is_a?(Array) && reverse_array.length > 1
log "添加逆向数组元素: #{reverse_array.length - 1} 个"
reverse_array[1..-1].each do |item|
fwd_current << clean_anon!(item)
end
log "数组最终长度: #{fwd_current.length}"
end
elsif fwd_current.is_a?(Hash)
log "场景: Hash without pending key"
if merged_value.is_a?(Hash)
merged_value.each { |k, v| fwd_current[k] = clean_anon!(v) }
end
remaining_siblings.each { |k, v| fwd_current[k] = clean_anon!(v) }
end
# 合并顶层其他键
if fwd_root.is_a?(Hash)
top_keys.each do |k, v|
fwd_root[k] = clean_anon!(v) unless fwd_root.key?(k)
end
end
fwd_root
end
# 查找包含当前容器的父数组
# 这是解决大文件测试问题的关键方法
def find_parent_array(stack, current_container)
return nil if stack.empty?
# 从栈顶向下查找包含 current_container 的数组
stack.reverse_each do |frame|
container = frame[:container]
return container if container.is_a?(Array) && container.include?(current_container)
end
nil
end
# 查找逆向解析中的完整数组(包含所有元素)
def find_complete_array(obj)
return nil unless obj.is_a?(Hash)
curr = obj
while curr.is_a?(Hash) && curr.key?(ANON)
anon_val = curr[ANON]
return anon_val if anon_val.is_a?(Array)
curr = anon_val
end
nil
end
def try_parse_primitive(str)
return str unless str.is_a?(String)
return str if str.strip.empty?
case str.strip
when 'true' then true
when 'false' then false
when 'null' then nil
else
if str =~ /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/
str.include?('.') || str.include?('e') || str.include?('E') ? str.to_f : str.to_i
else
str
end
end
end
def strip_trailing_quote(str, prefix)
return str if prefix.nil? || prefix.empty?
return str unless str.is_a?(String)
str.end_with?('"') ? str[0...-1] : str
end
def extract_string_prefix(incomplete)
return "" if incomplete.nil? || incomplete.empty?
if incomplete =~ /:\s*"([^"]*)$/
$1
elsif incomplete =~ /"([^"]*)$/
$1
else
""
end
end
def find_innermost(obj)
top_keys = {}
current = obj
while current.is_a?(Hash) && current.key?(ANON)
current.each do |k, v|
top_keys[k] = v unless k == ANON
end
current = current[ANON]
end
if current.is_a?(Array) && current.any?
first = current.first
if first.is_a?(Hash) && first.key?(ANON)
inner, extra = find_innermost(first)
return [inner, top_keys.merge(extra)]
else
return [first, top_keys]
end
end
if current.is_a?(Hash) && !current.key?(ANON)
current.each do |k, v|
top_keys[k] = v unless k == ANON
end
end
[current, top_keys]
end
def clean_anon!(obj)
case obj
when Hash
if obj.key?(ANON)
anon_val = obj.delete(ANON)
return clean_anon!(anon_val) if obj.empty?
end
obj.transform_values! { |v| clean_anon!(v) }
when Array
obj.map! { |v| clean_anon!(v) }
end
obj
end
def log(msg)
puts "[Onion] #{msg}" if @debug
end
end
# ==========================================
# 测试用例
# ==========================================
if __FILE__ == $0
def test(name, json, chunk_size: 64, debug: false)
file = "test_#{name}.json"
File.write(file, json)
expected = JSON.parse(json)
begin
result = OnionBiDirectionalParser.new(file, chunk_size: chunk_size, debug: debug).parse
rescue => e
puts "#{name}: 💥 Error: #{e.message}"
puts e.backtrace[0..5].join("\n")
File.delete(file) rescue nil
return false
end
ok = result == expected
print "#{name}: #{ok ? '✅' : '❌'}"
puts
File.delete(file) rescue nil
ok
end
puts "=" * 50
puts "洋葱式双向 JSON 解析器测试"
puts "=" * 50
puts "\n基础测试"
r = []
r << test("t1", '{"a": 1, "b": 2, "c": 3}')
r << test("t2", '{"x": {"y": 1}, "z": 2}')
r << test("t3", '[1, 2, 3, 4, 5]')
r << test("t4", '{"arr": [1, 2, 3], "obj": {"k": "v"}}')
puts "\n边界测试"
r << test("str1", '{"key": "hello world"}', chunk_size: 10)
r << test("num1", '{"a": 12345}', chunk_size: 5)
r << test("num2", '{"a": 12345}', chunk_size: 7)
puts "\n大文件测试"
large = {"data" => (1..100).map { |i| {"id" => i, "value" => "item_#{i}" * 5} }}
r << test("large_100", JSON.generate(large), chunk_size: 256)
puts "\n大文件测试 (不同 chunk_size)"
large = {"data" => (1..1000).map { |i| {"id" => i, "value" => "item_#{i}" * 10} }}
large_json = JSON.generate(large)
[64, 256, 1024, 4096].each do |cs|
r << test("large_#{cs}", large_json, chunk_size: cs)
end
puts "\n总结: #{r.count(true)}/#{r.length}"
end