🐚

System.Text.Jsonでは改行入りのBase64文字列に対応していない

2023/08/08に公開

環境

  • .NET 7

前提

System.Text.Jsonによるシリアライズ・デシリアライズにおいて、モデル定義にbyte[]がある場合は自動的にBase64文字列形式との変換を行ってくれます。

var json = """
{
    "Id":"123",
    "Image":"AQIDBAU="
}
""";
var model = System.Text.Json.JsonSerializer.Deserialize<Model>(json);
// model.Id: "123"
// model.Image: new byte[]{1, 2, 3, 4, 5}

record Model(
    string Id, 
    byte[] Image);

Base64文字列に改行コードがあるとデシリアライズできない

ところで、Base64文字列には改行を含む場合があります。これはもともとMIMEの基準に由来するようです。ちなみに.NET標準のBase64エンコードでは、デフォルトでは改行無しですが、改行入りもサポートしています。

https://learn.microsoft.com/ja-jp/dotnet/api/system.convert.tobase64string?view=net-7.0

// 76文字を超えると改行が入るので、長めのバイト列とする
var image = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();

Console.WriteLine(Convert.ToBase64String(image, Base64FormattingOptions.InsertLineBreaks));
Console.WriteLine(Convert.ToBase64String(image, Base64FormattingOptions.None));
# InsertLineBreaks
AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4
OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiYw==
# None
AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiYw==

この2種類の文字列双方とも、Convert.FromBase64Stringメソッドによりまたbyte[]に戻すことができます。

では改行コードを含むJSONで、本記事はじめの例のJSONデコードは成功するでしょうか?JSONの文字列フィールドには改行コードを含むことが仕様上できないようなので、エスケープして\\nになった状態で試します。

// raw string literalだとエスケープをしているのかが分かりにくいので、あえて愚直に。
// (→ 右のほうにコメントあり)                                                                                         ↓ ここに改行コードがあります
var json = "{\"Id\":\"123\",\"Image\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4\\nOTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiYw==\"}";
var model = System.Text.Json.JsonSerializer.Deserialize<Model>(json)!;

これは失敗します。

System.Text.Json.JsonException: 'The JSON value could not be converted to Model. Path: $.Image | LineNumber: 0 | BytePositionInLine: 160.'
FormatException: Cannot decode JSON text that is not encoded as valid Base64 to bytes.

私の場合は、\\nが含まれたJSONを返却してくるWebAPIとの通信にてこの問題に当たりました。

Newtonsoft.Json (Json.NET) では成功します

ちなみに。
https://www.newtonsoft.com/json

信頼と実績のJson.NETです。だいたいSystem.Text.JsonでのつまづきはNewtonsoft.Jsonではすぐ解消しますね。

// Newtonsoft.Json 13.0.3 で実験。問題ない。
var json = "{\"Id\":\"123\",\"Image\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4\\nOTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiYw==\"}";
var model = Newtonsoft.Json.JsonConvert.DeserializeObject<Model>(json);

// model.Image: new byte[]{0, 1, 2, 3, ..., 99 }

System.Text.Jsonでの克服方法

今のところフラグ1個で解決、のような方法は見つからなかったので、カスタムコンバータを定義することにします。
https://learn.microsoft.com/ja-jp/dotnet/standard/serialization/system-text-json/converters-how-to?pivots=dotnet-6-0

このような定義にしました。

using System.Text.Json;
using System.Text.Json.Serialization;

class Base64StringConverter : JsonConverter<byte[]>
{
    public override byte[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var imageBase64 = reader.GetString() ?? throw new JsonException("Failed to get the field as string.");
        return Convert.FromBase64String(imageBase64);
    }

    public override void Write(Utf8JsonWriter writer, byte[] value, JsonSerializerOptions options)
    {
        var imageBase64 = Convert.ToBase64String(value);
        writer.WriteStringValue(imageBase64);
    }
}

record Model(
    string Id, 
    [property: JsonConverter(typeof(Base64StringConverter))] byte[] Image);

これで成功するようになります。

var json = "{\"Id\":\"123\",\"Image\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4\\nOTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiYw==\"}";
var model = System.Text.Json.JsonSerializer.Deserialize<Model>(json);

// model.Image: new byte[]{0, 1, 2, 3, ..., 99 }

ただ、カスタムコンバータは何か考慮が漏れているケースがありそうで、かつSystem.Text.Json自体まだ枯れていないのもあって、個人的には業務で本番稼働するコードで使うのは相当躊躇します。おとなしくモデルではbyte[]ではなくstringとして定義し、自分で変換処理を書くのが無難かもしれません。

補足: Convert.FromBase64String自体はエスケープされた改行コードに対応していない

今回の件、System.Text.Jsonを100%責められない点はありまして、それは「エスケープされた」改行コードがあるBase64文字列のデコードは本来失敗するためです。勝手に1文字増やした格好ですからパディングも狂うし、まあそれはそうかなと思います。

var image = Enumerable.Range(0, 100).Select(i => (byte)i).ToArray();
var base64 = Convert.ToBase64String(image, Base64FormattingOptions.InsertLineBreaks);

// 改行コードそれ自体のままであればデコード可能
var decoded1 = Convert.FromBase64String(base64);

// \n を \\n に変えてしまうと失敗
var decoded2 = Convert.FromBase64String(base64.Replace("\r\n", "\n").Replace("\n", "\\n"));
// System.FormatException: 'The input is not a valid Base-64 string as it contains a non-base 64 character, more than two padding characters, or an illegal character among the padding characters.'

JSONシリアライザにおいては、Base64デコードより先に\\n\nに戻してくれる処理が入っているために成功するわけです。先程作成したカスタムコンバータにも\nに戻った状態で渡されてきています。

Discussion