【问题标题】:How do I write a float like "82.0" with the ".0" intact, using Utf8JsonWriter?如何使用 Utf8JsonWriter 编写像“82.0”这样的浮点数,而“.0”完好无损?
【发布时间】:2021-05-24 07:24:33
【问题描述】:

我一直在努力使用 Utf8JsonWriter 编写诸如 82.0 之类的双精度。

默认情况下,WriteNumberValue 方法采用双精度并为我格式化,格式(这是标准的“G”格式)省略了“.0”后缀。我找不到控制它的方法。

按照设计,我似乎不能只将原始字符串写入 Utf8JsonWriter,但我找到了一种解决方法:创建一个 JsonElement 并调用 JsonElement.WriteTo`。这个calls 一个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)
}

无论如何我都需要格式化一个双精度,但解析它,创建一个 jsonDocument 和一个 JsonElement,只是为了能够找到一种调用受保护方法的方法,似乎真的很浪费。但是,它确实有效(我用 F# 编写并翻译成 C#,如果我在语法中出现错误,请道歉)。

有没有更好的方法?想到的一些潜在解决方案(我是 dotnet 新手,所以我不确定这里有什么可能):

  • 有没有办法直接访问private API?我认为子类化 Utf8Writer 可能有效,但它是一个密封类。
  • 我可以直接实例化一个 JsonElement 而不需要整个解析过程吗?

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

【问题讨论】:

  • 必须尝试编写自定义转换器?但是,这个json的接收方在省略小数位时遇到了这么多麻烦,这似乎很奇怪
  • 听起来像是一个 x-y 问题 - 为什么需要这种精度?如果它那么重要,为什么不将其作为字符串传输?
  • 数据的消费者将82.0和82视为不同语义的不同值,此时无法更改。
  • 如果你改用Decimal 是否有效?由于Decimal 以不同方式存储 82.0 和 82。
  • 别忘了处理掉JsonDocument

标签: c# .net system.text.json


【解决方案1】:

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

  • 当以固定格式格式化double 值时,当值是整数时,必须附加.0 小数部分。

  • 以指数格式格式化时没有变化。

  • 格式化诸如double.PositiveInfinity之类的非有限双精度时没有变化。

  • 不需要支持JsonNumberHandling选项WriteAsStringAllowReadingFromString

  • 没有对JsonDocument的中间解析。

在这种情况下,正如comments 中的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://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 的位数。为了提高性能,我使用Utf8FormatterUtf8Parser,但是在早于.NET Core 3.0 的框架中,这可能会丢失精度,而应该使用常规的string 格式化和解析。详情见the code comments for Utf8JsonWriter.WriteValues.Double.cs

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

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

  • 您问,我可以直接实例化一个 JsonElement 而不需要整个解析过程吗?

    这在 .NET 5 中是不可能的。如其 source code 所示,JsonElement 结构仅包含对其父 JsonDocument _parent 的引用以及指示元素在文档中的位置索引.

    事实上,在 .NET 5 中,当您使用 JsonSerializer.Deserialize&lt;JsonElement&gt;(string) 反序列化为 JsonElement 时,内部 JsonElementConverter 会将传入的 JSON 读入临时的 JsonDocument,克隆其 RootElement,然后处理文档并返回克隆。

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

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

  • value &lt; MaxPreciselyRepresentedIntValue 的特殊情况旨在通过尽可能避免与文本表示的任何往返来最大限度地提高性能。

    但是,我实际上并没有进行分析以确认这比进行文本往返更快。

演示小提琴here,其中包括一些单元测试,断言转换器为各种整数double 值生成与Json.NET 相同的输出,因为Json.NET 在序列化这些值时总是附加.0

【讨论】:

  • 惊人的答案!
  • isFinite 条件的else 块中发生了什么?
  • @PaulBiggar - 我在dotnetfiddle.net/7pwvaT 的小提琴中添加了一些额外的测试用例,发现 2^49 和 2^53 之间的整数值与 Json.NET 不一致,所以我调整了 @ 的值987654383@.
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-06-17
  • 2011-04-24
  • 1970-01-01
相关资源
最近更新 更多