.net 如何使用Utf8JsonWriter编写像“82.0”这样的浮点数,其中“.0”保持不变?

xdyibdwo  于 2022-12-01  发布在  .NET
关注(0)|答案(1)|浏览(129)

我一直在努力使用Utf8JsonWriter编写诸如82.0这样的双精度浮点数。
默认情况下,WriteNumberValue方法接受一个双精度浮点数并为我格式化它,而格式(标准的'G'格式)省略了“.0”后缀。我找不到控制它的方法。
从设计上看,我似乎不能只向Utf8JsonWriter写入一个原始字符串,但我找到了一个变通办法:创建一个JsonElement并调用JsonElement.WriteTo '。这将调用Utf8JsonWriter中的一个私有方法并将字符串直接写入其中。
有了这个发现,我做了一个感觉非常笨拙和低效的实现。

open System.Text.Json

void writeFloat(Utf8JsonWriter w, double d) {
  String floatStr = f.ToString("0.0################")
  JsonElement jse = JsonDocument.Parse(floatStr).RootElement
  jse.WriteTo(w)
}

我需要格式化一个double,所以这没什么,但是解析它,创建一个jsonDocument和一个JsonElement,仅仅是为了能够找到一种方法来调用一个受保护的方法,看起来真的很浪费。但是,它确实工作了(我用F#写的,然后翻译成C#,如果我在语法上犯了错误,请道歉)。
是否有更好的方法?我想到了一些可能的解决方案(我是dotnet的新手,所以不确定这里有什么可能):

  • 有没有直接访问私有API的方法?我想子类化Utf8Writer可能会工作,但它是一个密封类。
  • 我是否可以直接示例化JsonElement而无需整个冗长的解析过程?

至于为什么这样做是必要的:我需要强制整数值附加.0,因为我需要与一种非常特殊的格式交互,它区分整数和浮点JSON值。(我可以使用指数格式,因为它显然是浮点数)。

qoefvg9y

qoefvg9y1#

您的要求是创建满足以下条件的JsonConverter<double>

  • 以固定格式格式化double值时,如果该值是整数,则必须附加.0小数部分。
  • 以指数格式设置格式时无变化。
  • 格式化非有限双精度数(如double.PositiveInfinity)时没有更改。
  • 不需要支援JsonNumberHandling选项WriteAsStringAllowReadingFromString
  • 没有对JsonDocument的中间分析。

在**.NET 6和更高版本**中,您可以手动设置双精度浮点数的格式,并使用Utf8JsonWriter.WriteRawValue()将其写出。以下转换器根据需要起作用:

public class DoubleConverter : JsonConverter<double>
{
    const bool skipInputValidation = true; // Set to true to prevent intermediate parsing.  Be careful to ensure your raw JSON is well-formed.

    public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
    {
        Span<byte> utf8bytes = stackalloc byte[33]; // JsonConstants.MaximumFormatDecimalLength + 2, https://github.com/dotnet/runtime/blob/v6.0.11/src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs#L85
        if (!double.IsFinite(value))
            // Utf8JsonWriter does not take into account JsonSerializerOptions.NumberHandling so we have to make a recursive call to serialize
            JsonSerializer.Serialize(writer, value, new JsonSerializerOptions { NumberHandling = options.NumberHandling });
        else if (Utf8Formatter.TryFormat(value, utf8bytes.Slice(0, utf8bytes.Length-2), out var bytesWritten))
        {
            // Check to make sure the value was actually serialized as an integer and not, say, using scientific notation for large values.
            if (IsInteger(utf8bytes, bytesWritten))
            {
                utf8bytes[bytesWritten++] = (byte)'.';
                utf8bytes[bytesWritten++] = (byte)'0';
            }   
            writer.WriteRawValue(utf8bytes.Slice(0, bytesWritten), skipInputValidation);
        }
        else // Buffer was too small?
            writer.WriteNumberValue(value);
    }
    
    static bool IsInteger(Span<byte> utf8bytes, int bytesWritten)
    {
        if (bytesWritten <= 0)
            return false;
        var start = utf8bytes[0] == '-' ? 1 : 0;
        for (var i = start; i < bytesWritten; i++)
            if (!(utf8bytes[i] >= '0' && utf8bytes[i] <= '9'))
                return false;
        return start < bytesWritten;
    }
    
    public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 
        // TODO: Handle "NaN", "Infinity", "-Infinity"
        reader.GetDouble();
}

备注:

  • 为了提高性能,我使用Utf8Formatter将该值格式化为utf8 stackalloc'ed byte数组,然后根据需要添加.0,最后使用skipInputValidation = true写入。这样做应该可以获得最佳性能,因为Utf8JsonWriter被设计为直接写入utf8缓冲区或流,而不是写入utf 16文本写入器,后者的内容随后被编码为utf8。
  • Utf8Formatter会产生区域设置不变的输出,但如果您使用ToString()方法(例如f.ToString("0.0################")),请务必在区域设置不变的情况下执行此操作,如下所示:
f.ToString("0.0################", NumberFormatInfo.InvariantInfo);

这保证了即使在使用逗号的语言环境中也会使用正确的JSON小数分隔符.

  • double.IsFinite(value)检查旨在正确序列化非有限值(如double.PositiveInfinity)。通过实验,我发现Utf8JsonWriter.WriteNumberValue(value)会无条件地抛出这些类型的值,因此在启用JsonNumberHandling.AllowNamedFloatingPointLiterals时,必须调用序列化程序来正确处理它们。

演示小提琴#1
在**.NET 5和更早版本中**Utf8JsonWriter.WriteRawValue()不存在,因此,正如mjwills在注解中所建议的,您可以将double转换为带有所需小数部分的decimal,然后将其写入JSON,如下所示:

public class DoubleConverter : JsonConverter<double>
{
    // 2^49 is the largest power of 2 with fewer than 15 decimal digits.  
    // From experimentation casting to decimal does not lose precision for these values.
    const double MaxPreciselyRepresentedIntValue = (1L<<49);

    public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
    {
        bool written = false;
        // For performance check to see that the incoming double is an integer
        if ((value % 1) == 0)
        {
            if (value < MaxPreciselyRepresentedIntValue && value > -MaxPreciselyRepresentedIntValue)
            {
                writer.WriteNumberValue(0.0m + (decimal)value);
                written = true;
            }
            else
            {
                // Directly casting these larger values from double to decimal seems to result in precision loss, as noted in  https://stackoverflow.com/q/7453900/3744182
                // And also: https://learn.microsoft.com/en-us/dotnet/api/system.convert.todecimal?redirectedfrom=MSDN&view=net-5.0#System_Convert_ToDecimal_System_Double_
                // > The Decimal value returned by Convert.ToDecimal(Double) contains a maximum of 15 significant digits.
                // So if we want the full G17 precision we have to format and parse ourselves.
                //
                // Utf8Formatter and Utf8Parser should give the best performance for this, but, according to MSFT, 
                // on frameworks earlier than .NET Core 3.0 Utf8Formatter does not produce roundtrippable strings.  For details see
                // https://github.com/dotnet/runtime/blob/eb03e0f7bc396736c7ac59cf8f135d7c632860dd/src/libraries/System.Text.Json/src/System/Text/Json/Writer/Utf8JsonWriter.WriteValues.Double.cs#L103
                // You may want format to string and parse in earlier frameworks -- or just use JsonDocument on these earlier versions.
                Span<byte> utf8bytes = stackalloc byte[32];
                if (Utf8Formatter.TryFormat(value, utf8bytes.Slice(0, utf8bytes.Length-2), out var bytesWritten)
                    && IsInteger(utf8bytes, bytesWritten))
                {
                    utf8bytes[bytesWritten++] = (byte)'.';
                    utf8bytes[bytesWritten++] = (byte)'0';
                    if (Utf8Parser.TryParse(utf8bytes.Slice(0, bytesWritten), out decimal d, out var _))
                    {
                        writer.WriteNumberValue(d);
                        written = true;
                    }   
                }
            }
        }
        if (!written)
        {
            if (double.IsFinite(value))
                writer.WriteNumberValue(value);
            else
                // Utf8JsonWriter does not take into account JsonSerializerOptions.NumberHandling so we have to make a recursive call to serialize
                JsonSerializer.Serialize(writer, value, new JsonSerializerOptions { NumberHandling = options.NumberHandling });
        }
    }
    
    static bool IsInteger(Span<byte> utf8bytes, int bytesWritten)
    {
        if (bytesWritten <= 0)
            return false;
        var start = utf8bytes[0] == '-' ? 1 : 0;
        for (var i = start; i < bytesWritten; i++)
            if (!(utf8bytes[i] >= '0' && utf8bytes[i] <= '9'))
                return false;
        return start < bytesWritten;
    }
    
    public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => 
        // TODO: Handle "NaN", "Infinity", "-Infinity"
        reader.GetDouble();
}

备注:

  • 这是因为decimal(与double不同)保留尾随零,如文档备注中所述。
  • 无条件地将double强制转换为decimal可以将lose precision转换为大值,因此只需执行
writer.WriteNumberValue(0.0m + (decimal)value);

不建议强制使用最小位数。(例如,序列化9999999999999992将导致9999999999999990.0而不是9999999999999992.0。)
然而,根据Wikipedia页面的双精度浮点格式:整数值的精度限制,从-2^53到2^53的整数可以精确地表示为double,因此转换为十进制并强制最小位数可以用于该范围内的值。

  • 除此之外,除了从一些文本表示中解析.Net decimal之外,没有办法在运行时直接设置它的位数。为了提高性能,我使用Utf8FormatterUtf8Parser,但是在. NETCore3.0之前的框架中,这可能会失去精度,而应该使用常规的string和解析。有关详细信息,请参阅Utf8JsonWriter.WriteValues.Double.cs的代码注解。
  • 您问,* 是否有直接访问私有API的方法?*

您可以使用反射来调用私有方法,如 * How do I use reflection to invoke a private method? * 所示,但是不建议这样做,因为内部方法可以随时更改,从而破坏您的实现。除此之外,没有公共API可以直接写入“原始”JSON,除非将其解析为JsonDocument,然后写入。我不得不在my answer中使用相同的技巧来写入 * Serialising BigInteger using System.Text.Json *。

  • 您问,* 我可以直接示例化JsonElement而不需要整个冗长的解析过程吗?*

在.NET 5中,这是不可能的。如source code中所示,JsonElement结构只包含对其父级JsonDocument _parent的引用,以及指示元素在文档中位置的位置索引。
事实上,在.NET 5中,当您使用JsonSerializer.Deserialize<JsonElement>(string)反序列化为JsonElement时,JsonElementConverter会在内部将传入的JSON读取到临时JsonDocument中,克隆其RootElement,然后处置文档并返回克隆。

  • value < MaxPreciselyRepresentedIntValue的特殊情况旨在尽可能避免任何到文本表示的往返,从而最大限度地提高性能。

我实际上还没有配置文件来确认这比做一个文本往返更快。
演示fiddle #2 here,其中包括一些单元测试,这些单元测试Assert转换器对于大范围的整数double值生成与Json.NET相同的输出,因为Json.NET在序列化这些值时总是附加一个.0

相关问题