优秀的编程知识分享平台

网站首页 > 技术文章 正文

初窥C# StringValues

nanyue 2025-01-13 16:35:10 技术文章 2 ℃

简介

在这篇文章中,我会向大家简要介绍一下ASP.NET Core的核心类型之一StringValues。将会探讨StringValues在框架中的使用场景,它的用途,如何实现,以及为什么要这么做。

重复的HTTP头

作为一个ASP.NET Core开发者,我们可能在各种地方遇到过StringValues,尤其是在处理HTTP header的时候。

HTTP的一个特性是,我们可以在某些http header中多次包含相同的键(从规范中可以看出):

Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma.

我们也就不用担心这样做合理不合理了,事实是我们可以这样做,所以ASP.NET Core必须支持它。本质上,这意味着对于请求(或响应)中的每个头名称,我们可以有0个,1个,或多个字符串值:

GET / HTTP/1.1
Host: localhost:5000    # 没有值

GET / HTTP/1.1
Host: localhost:5000
MyHeader: some-value    # 一个值

GET / HTTP/1.1
Host: localhost:5000
MyHeader: some-value    # 多个值
MyHeader: other-value   # 多个值

那么,假设我们在ASP.NET Core团队中,我们需要创建一个“header集合”类型。我们会如何处理呢?

使用数组的简单实现

一个显而易见的解决方案是始终将给定header的所有值存储为一个数组。数组可以轻松处理零([]),一个(["val1"])或更多(["val1", "val2"])的值,而不需要任何复杂性。一个伪实现可能会是这样:

public class Headers : Dictionary<string, string[]> { }

如果我们想要获得给定键(比如MyHeader)的值,那么我们可以像这样获取值:

Headers headers = new(); // 这边只是简单使用了new,但是真实场景应该是通过http request获取
string[] values = headers["MyHeader"] ?? [];

所以,这个API的好处是它不会隐藏同一个header有多个值的事实。

不幸的是,这种简单方法有几个缺点:

  • 在绝大多数情况下header都只有一个值,但我们还是得必须处理可能存在多个值的情况,即使这种情况实际上很多余。
  • 在数组中存储单个值会增加分配,从而降低性能。

在旧的ASP.NET时代的System.Web中,通过使用NameValueCollection解决了HttpRequest.Headers的问题。这个旧类型的公共API看起来有点像Dictionary<string, string>,但实际上它在数组中存储了值,然后在输出时自动组合它们:

using System.Collections.Specialized;

var nvc = new NameValueCollection();

nvc.Add("Accept", "val1");
nvc.Add("Accept", "val2");

var header = nvc["Accept"];
Console.WriteLine(header); // prints "val1,val2"

注意:根据HTTP规范,使用','来连接header上是正确的组合方式。

从使用者的角度看,这个API的好处是,我们不必担心同一个header是否有多个值,因为它们会自动为我们连接在一起,我们总是得到一个单独的字符串。我们也可以使用GetValues()来获取值作为一个string[]。

不过可惜的是,这种方法仍然有几个缺点:

  • 值仍然存储为一个string[](实际上为一个ArrayList),所以即使只有一个值,我们仍然需要额外付出内存分配的代价。
  • 当我们使用GetValues()检索值时,会分配另一个string[]。

最后,使用NameValueCollection,在我们提取它之前,无法知道这个header包含多少个值。所以,我们要么选择“安全”地使用GetValues(),这将会导致额外的string[]内存分配,而通常这都是没有必要的。或者我们也可以使用索引器,但这我们就需要承担多个值被连接成一个单独的字符串的风险。

所有这些额外的分配都带来不必要的浪费,这就是为什么我们需要StringValues。

解决方案-StringValues

好了我们再看来看看什么是我们真正想要的?

  • 当只有一个值时,写入(和读取)一个字符串,这样我们就不会分配一个不必要的额外数组。
  • 当有多个值时,写入(和读取)一个string[]。
  • 写入或检索时不额外分配(如果可能)。

ASP.NET Core对这个问题的解决方案,以及这篇文章的重点,就是StringValues。 StringValues是一个只读的结构类型,正如源代码中所说:

Represents zero/null, one, or many strings in an efficient way.

StringValues存储了一个object?,这个object?可以取以下三个值之一,通过这种方式来实现目标:

  • null(表示0个header值)
  • 字符串(即1个header值)
  • string[](任意数量的header值)

在一些早期的实现中,StringValues将string和string[]值存储为单独的字段,但是在这个PR(https://github.com/dotnet/extensions/pull/1283)中,它们被合并到一个单一的object字段中,这使得整个结构只有单指针大小,就如在issue(https://github.com/dotnet/extensions/issues/1290)中讨论的那样,这个改动带来了性能提升。

从用户使用API的角度来看,StringValues有点像string和string[]的缝合怪。它有像IsNullOrEmpty()这样的方法,但它也实现了一系列基于集合的接口和相关方法:

public readonly struct StringValues : IList<string?>, IReadOnlyList<string?>, IEquatable<StringValues>, IEquatable<string?>, IEquatable<string?[]?>
{
}

我们可以使用其中一个构造器来创建一个StringValues对象:

public readonly struct StringValues
{
    private readonly object? _values;
    public StringValues(string? value)
    {
        _values = value;
    }

    public StringValues(string?[]? values)
    {
        _values = values;
    }
}

作为一个只读的结构,StringValues除了包含的字符串或字符串数组外,不需要在堆上分配任何额外的空间。

根据我们的使用场景,我们有不同的方式可以从StringValues实例中提取值。例如,如果我们需要字符串形式的值,我们可以这样做:

StringValues value;
if (value.Count == 1)
{
    // 只有一个值,所以可以隐式转换成string,extracted就等于那个值
    string extracted = value;
}
else
{
    // 当有多个值,自动使用,进行join,就会的到类似"a,b,c",效果和value.ToString() 一样
    string extracted = value;
}

或者,如果我们期望有多个值,或者通常想要安全地枚举所有的值,我们可以简单地使用一个foreach循环:

StringValues value;
foreach (string str in value)
{
    // 处理逻辑
}

StringValues使用一个自定义的结构枚举器,如果它包含一个单一的字符串,就返回_values字段,否则枚举string?[]的值。

我们也可以调用ToArray(),但是这又回到了我们开始的问题,如果我们只有一个字符串值,这将分配内存,所以我们应该避免这样做。

对于StringValues,没有太多需要担心的,但是一些实现细节还是挺有趣的,所以我将在下面和大家一起来看看。

关于String Values的一些实现背后的原理

IsNullOrEmpty的实现体现了StringValues内部使用的一般模式:模式匹配来检查null以及string或string[],然后一旦我们确定_values到底是什么,就使用Unsafe.As<>进行类型转换。

public static bool IsNullOrEmpty(StringValues value)
{
    // Take local copy of _values so type checks remain valid even if the StringValues is overwritten in memory
    object? data = value._values;
    if (data is null)
    {
        return true;
    }
    if (data is string[] values)
    {
        return values.Length switch
        {
            0 => true,
            1 => string.IsNullOrEmpty(values[0]),
            _ => false,
        };
    }
    else
    {
        // Not array, can only be string
        return string.IsNullOrEmpty(Unsafe.As<string>(data));
    }
}

在Count属性中我们也能看到类似的使用:

public int Count
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    get
    {
        if (value is null)
        {
            return 0;
        }
        if (value is string)
        {
            return 1;
        }
        else
        {
            // Not string, not null, can only be string[]
            return Unsafe.As<string?[]>(value).Length;
        }
    }
}

我们要看的最后一个方法是GetStringValue()。这是一个私有方法,被ToString()(以及其他方法)调用,将值转换为string,无论存储的值是什么。string和null的情况很简单,而string[]则展示了一个与性能相关的很好的例子,那就是使用string.Create()。

private string? GetStringValue()
{
    // Take local copy of _values so type checks remain valid even if the StringValues is overwritten in memory
    object? value = _values;
    if (value is string s)
    {
        return s;
    }
    else
    {
        return GetStringValueFromArray(value);
    }

    static string? GetStringValueFromArray(object? value)
    {
        if (value is null)
        {
            return null;
        }

        // value is not null or string, so can only be string[]
        string?[] values = Unsafe.As<string?[]>(value);
        return values.Length switch
        {
            0 => null,
            1 => values[0],
            _ => GetJoinedStringValueFromArray(values),
        };
    }

    static string GetJoinedStringValueFromArray(string?[] values)
    {
        // Calculate final length of the string
        int length = 0;
        for (int i = 0; i < values.Length; i++)
        {
            string? value = values[i];
            // Skip null and empty values
            // I'm not sure why !string.IsNullOrEmpty() isn't used, but seeing
            // as Ben Adams wrote it, I'm sure there's a good reason 
            if (value != null && value.Length > 0)
            {
                if (length > 0)
                {
                    // Add separator
                    length++;
                }

                length += value.Length;
            }
        }

        // Create the new string
        return string.Create(length, values, (span, strings) => {
            int offset = 0;
            // Skip null and empty values
            for (int i = 0; i < strings.Length; i++)
            {
                string? value = strings[i];
                if (value != null && value.Length > 0)
                {
                    if (offset > 0)
                    {
                        // Add separator
                        span[offset] = ',';
                        offset++;
                    }

                    value.AsSpan().CopyTo(span.Slice(offset));
                    offset += value.Length;
                }
            }
        });
    }
}

StringValues是一个很好的例子,展示了 ASP.NET Core 如何在不牺牲 API 使用便利性的前提下,精心优化了性能。我们可以像使用 string 或 string[] 一样,轻松地使用 StringValues。

总结

在这篇文章中,我简要地探讨了如何处理HTTP header有多个值的常见问题。我们讨论了 ASP.NET是如何利用 NameValueCollection类型来解决这个问题的,以及 ASP.NET Core 是如何更优雅地使用 StringValues 来处理它的。最后,我们一同看了看 StringValues 是如何实现的,通过使用一个字段来存储 string 或 string[] 对象,并实现各种集合接口,比起仅仅使用string[]的方法,减少了内存分配。

这就是今天这篇文章想要跟大家分享的信息,如果有任何问题,欢迎大家留言评论^_^

Tags:

最近发表
标签列表