如何在 Ruby 2 中移除无效字节并保留有效 UTF-8
结论
将字符串标记为 UTF-8,再以 UTF-16LE 为中间编码进行转码。转成 UTF-16LE 时,Ruby 会校验输入的 UTF-8 序列,并用空字符串替换无效部分,然后再将结果转回 UTF-8。
这种方法不依赖 String#scrub,适用于需要同时兼容 Ruby 1.9.3 和 Ruby 2.6.10 的代码:
def remove_invalid_utf8(string)
utf8 = string.dup
utf8.force_encoding(Encoding::UTF_8)
cleaned = utf8.encode(
Encoding::UTF_16LE,
Encoding::UTF_8,
invalid: :replace,
undef: :replace,
replace: ''
)
cleaned.encode(Encoding::UTF_8)
end
使用示例
源文件编码、IRB 或 JRuby 对字符串字面量的处理可能影响测试,因此可以直接用字节构造字符串:
bytes = [76, 246, 115, 195, 169, 228, 190, 141]
str = bytes.pack('C*')
str.force_encoding(Encoding::UTF_8)
cleaned = remove_invalid_utf8(str)
p cleaned
p cleaned.bytes.to_a
p cleaned.valid_encoding?
预期结果:
"Lsé侍"
[76, 115, 195, 169, 228, 190, 141]
true
十进制字节 246,即十六进制的 F6,会被删除。é 对应的 C3 A9 和 侍 对应的 E4 BE 8D 则会保留。
为什么直接调用 encode('UTF-8') 可能无效
如果字符串已经标记为 UTF-8,目标编码也是 UTF-8,Ruby 的某些版本或实现可能不会真正执行转码。这样一来,invalid: :replace 就没有机会处理无效序列:
str.encode('UTF-8', invalid: :replace, replace: '')
使用 UTF-16LE 作为中间编码,可以让 Ruby 实际解析并校验输入的 UTF-8 序列:
可能包含错误的 UTF-8
↓ 校验并删除无效序列
UTF-16LE
↓
UTF-8
这里使用明确指定字节序的 UTF-16LE,可以省去 UTF-16 可能涉及的字节序标记处理。
从文件读取时的写法
如果文件本应是 UTF-8 编码,但可能混入错误字节,可以先以二进制方式读取,再明确标记为 UTF-8:
str = File.open('./errf.txt', 'rb') { |file| file.read }
str.force_encoding(Encoding::UTF_8)
cleaned = remove_invalid_utf8(str)
p cleaned.bytes.to_a
p cleaned.valid_encoding?
force_encoding 只会修改字符串的编码标签,不会转换、删除或重排字节。这里应将字符串标记为 Encoding::UTF_8,不能先标记为 ASCII 再转码,否则 é 和 侍 的多字节序列也会被视为无法转换的数据。
Ruby 2.x 的简化写法
较新的 Ruby 2.x 提供了 String#scrub,可以直接删除无效的 UTF-8 序列:
cleaned = str.scrub('')
Ruby 1.9.3 没有这个方法。如果同一份代码必须同时支持 Ruby 1.9.3 和 2.6.10,可以使用前面的 UTF-16LE 中间转码方案,也可以先判断当前环境是否支持 String#scrub:
def remove_invalid_utf8(string)
utf8 = string.dup
utf8.force_encoding(Encoding::UTF_8)
return utf8.scrub('') if utf8.respond_to?(:scrub)
cleaned = utf8.encode(
Encoding::UTF_16LE,
Encoding::UTF_8,
invalid: :replace,
undef: :replace,
replace: ''
)
cleaned.encode(Encoding::UTF_8)
end
这段代码按 UTF-8 的编码规则删除无效序列,并非根据固定的“非法字节列表”逐字节过滤。一个字节是否合法,取决于它所在的位置以及与前后字节的组合,因此字节黑名单无法覆盖所有情况。
备注:内容仅供参考。
