如何使用 Utf8JsonWriter 编写一个像“82.0”这样的浮点数,而“.0”完好无损?

How do I write a float like "82.0" with the ".0" intact, using Utf8JsonWriter?

我一直在努力使用 Utf8JsonWriter 编写诸如 82.0 之类的替身。

默认情况下,方法WriteNumberValue方法取一个double并为我格式化,格式(这是标准的'G'格式)省略了“.0”后缀。我找不到控制它的方法。

按照设计,我似乎不能只将原始字符串写入 Utf8JsonWriter,但我找到了一个解决方法:创建 JsonElement 并调用 JsonElement.WriteTo`。这 calls a private method in 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 的新手,所以我不确定这里有什么可能):

至于为什么这是必要的:我需要强制写入整数值并附加 .0 因为我需要与之交互的非常具体的格式,它区分整数和浮点数-点 JSON 值。 (我接受指数格式,因为它显然是一个浮点数)。

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

  • 以固定格式格式化 double 值时,如果值为整数,则必须附加 .0 小数部分。

  • 以指数格式格式化时无变化。

  • 格式化非有限双打时没有变化,例如 double.PositiveInfinity

  • 不需要支持 JsonNumberHandling 选项 WriteAsStringAllowReadingFromString

  • 没有中间解析到 JsonDocument

在这种情况下,如 mjwills in 所建议,您可以将 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  
                // And also: https://docs.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 不同)保留尾随零,如 documentation remarks.

    中所述
  • 无条件地将 double 转换为 decimal 可以 lose precision 获得大值,所以只需做

    writer.WriteNumberValue(0.0m + (decimal)value);
    

    不建议强制使用最小位数。 (例如,序列化 9999999999999992 将导致 9999999999999990.0 而不是 9999999999999992.0。)

    但是,根据维基百科页面 Double-precision floating-point format: Precision limitations on integer values,从 −2^53 到 2^53 的整数可以精确表示为 double,因此转换为十进制并强制使用最少的数字可用于该范围内的值。

  • 除此之外,除了从一些文本表示中解析它之外,没有办法在运行时直接设置 .Net decimal 的位数。对于性能,我使用 Utf8Formatter and Utf8Parser, however in frameworks earlier than .NET Core 3.0 this might lose precision, and regular string formatting and parsing should be used instead. For details see the code comments for Utf8JsonWriter.WriteValues.Double.cs.

  • 你问,有没有办法直接访问私有API?

    您始终可以使用反射来调用私有方法,如 How do I use reflection to invoke a private method?, however this is not recommended as internal methods can be changed at any time, thereby breaking your implementation. Beyond that there is no public API to write "raw" JSON directly, other than parsing it to a JsonDocument then writing that. I had to use the same trick in my answer to 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,然后处理文档和 returns 克隆。

  • 在您的原始转换器中,f.ToString("0.0################") 在使用逗号作为小数点分隔符的区域设置中将无法正常工作。您需要改用不变的语言环境:

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

  • value < MaxPreciselyRepresentedIntValue 的特殊情况旨在通过尽可能避免任何往返文本表示来最大化性能。

    不过,我还没有实际分析来确认这比进行文本往返更快。

Demo fiddle here 其中包括一些单元测试,断言转换器生成与 Json.NET 相同的输出,适用于范围广泛的整数 double 值,如 Json.NET 在序列化这些时总是附加一个 .0