简介
在这篇文章中,我会向大家简要介绍一下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 { }
如果我们想要获得给定键(比如MyHeader)的值,那么我们可以像这样获取值:
Headers headers = new(); // 这边只是简单使用了new,但是真实场景应该是通过http request获取
string[] values = headers["MyHeader"] ?? [];
所以,这个API的好处是它不会隐藏同一个header有多个值的事实。
不幸的是,这种简单方法有几个缺点:
- 在绝大多数情况下header都只有一个值,但我们还是得必须处理可能存在多个值的情况,即使这种情况实际上很多余。
- 在数组中存储单个值会增加分配,从而降低性能。
在旧的ASP.NET时代的System.Web中,通过使用NameValueCollection解决了HttpRequest.Headers的问题。这个旧类型的公共API看起来有点像Dictionary
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, IReadOnlyList, IEquatable, IEquatable, IEquatable
{
}
我们可以使用其中一个构造器来创建一个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(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(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(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 im not sure why string.isnullorempty isnt used but seeing as ben adams wrote it im sure theres a good reason if value 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 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[]的方法,减少了内存分配。
这就是今天这篇文章想要跟大家分享的信息,如果有任何问题,欢迎大家留言评论^_^