StringValuesクラスの内部の簡単な解説
※この記事は以下の記事の日本語訳です。
https://andrewlock.net/a-brief-look-at-stringvalues/
重複する HTTP ヘッダー
この記事では、ASP.NET Core のコアとなる型のひとつである StringValues について簡単に見ていきます。フレームワーク内でどこに使われているのか、何のために使われているのか、どのように実装されているのか、そしてなぜそうなっているのかを簡単にご紹介します。
ASP.NET Core で開発していると、とくに HTTP ヘッダーを扱うときに StringValues を見かけたことがあるかもしれません。
HTTP の仕様上、一部のヘッダーについては同じヘッダー名を複数回指定できる場合があります(RFC 2616 の該当部分 を参照):
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.
ここで、ヘッダー名と値の関係を考えると、HTTP リクエスト (あるいはレスポンス) 上では、あるヘッダー名に対して値が 0 個、1 個、複数個のいずれもあり得ることになります。
たとえば:
GET / HTTP/1.1
Host: localhost:5000
# (MyHeader がない例)
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 チームの立場としては、こういったヘッダーの格納や処理にどう対応するかが問題になります。
配列を使った単純な実装
まず単純な方法として、ヘッダー名に対して常に配列 (string[]) を使う実装が考えられます。たとえば疑似コードで言うと
public class Headers : Dictionary<string, string[]>
{
}
こうすれば、ヘッダー名に対して値が 0 個 ([])、1 個 (["val1"])、複数個 (["val1", "val2"]) でも簡単に扱えます。次のように使えますね
Headers headers = new(); // HTTP リクエストから自動で埋め込まれる想定
string[] values = headers["MyHeader"] ?? [];
この実装の良い点は「ヘッダーに複数の値がある」可能性が隠されず、明確に表現されることです。
しかし、いくつか問題点があります:
- 大抵の場合、実際には単一の値しか持たないヘッダーが多いのに、毎回配列を扱うオーバーヘッドがある。
- 1 つの文字列しかない場合でも配列を生成するため、余計なメモリアロケーションが増えてパフォーマンスに影響する。
従来の ASP.NET (System.Web) では、NameValueCollection を使うことでこの問題を回避しようとしていました。
NameValueCollection は外から見ると 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); // "val1,val2" と出力
これ自体は使い勝手が良いのですが、内部で string[](正確には ArrayList)を常に保持しているため、単一値の場合でも余計なアロケーションが発生してしまいます。また、GetValues() を呼ぶたびに新たな配列が生成されるため、さらにメモリを消費します。
解決策: StringValues
理想としては、
- 値が 1 つだけのときには
stringとして保持し、不要な配列を作らない - 複数の値がある場合には
string[]として保持する - できれば格納も取得も追加のメモリアロケーションなしで済ませたい
ということになります。これを解決するのが ASP.NET Core にある StringValues です。
.NET ランタイムの実装を見ると、StringValues は readonly struct で、
Represents zero/null, one, or many strings in an efficient way.
と書かれています。内部的には、単一の object? フィールドを持ち、そこに
null(値が 0 個)string(値が 1 個)string[](値が複数個)
のいずれかを格納する仕組みになっています。
以前のバージョンではstringとstring[]を別々のフィールドで保持していましたが、この PR によって単一フィールドにまとまりました。これによりstructがポインタサイズ 1 個におさまるので、さまざまなパフォーマンス上の利点があります(Issue 参照)。
StringValues は readonly struct なので、そのものはヒープではなくスタックに割り当てられます。(ただし、使用方法によっては ボクシング が起きる場合があるため要注意です。)
ユーザーから見たとき、StringValues は string と string[] 両方の顔を持つように設計されています。たとえば IsNullOrEmpty() のような string 的メソッドを持つ一方で、コレクション操作用のインターフェイス (IList<string?>, IReadOnlyList<string?> など) も実装しています。
生成はコンストラクタを使って以下のようにできます
public readonly struct StringValues
{
private readonly object? _values;
public StringValues(string? value)
{
_values = value;
}
public StringValues(string?[]? values)
{
_values = values;
}
}
値の取り出し方も、シナリオに応じていくつか選べます。
たとえば「値が一つだけ」の場合は、暗黙的に string にキャストする、あるいは ToString() を呼ぶなどして直接取得できます
StringValues value;
// 値が 1 つなら暗黙的に string にキャスト可能
if (value.Count == 1)
{
string extracted = value; // もしくは value.ToString()
}
複数ある可能性があるなら foreach で列挙すれば安全です
StringValues value;
foreach (string str in value)
{
// 値が複数あるかもしれない
}
このとき列挙の実装は、内部の _values が単一の string ならそれを一回だけ返し、string[] なら配列を順番に返すようになっています。
ToArray() もありますが、単一文字列を配列化するときも配列が生成されるため、性能面で避けることが推奨される場合があります。StringValues の実装の裏側
StringValues は内部でobject? による分岐を多用しています。たとえば IsNullOrEmpty() の実装は以下のように、_values が null、string、string[] のどれかかを判定し、キャストするときに Unsafe.As<>() を用いています。
public static bool IsNullOrEmpty(StringValues value)
{
// 先にローカル変数に退避して、StringValues がメモリ上で上書きされても問題ないようにしている
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
{
// data が配列でも null でもないなら、残る可能性は 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
{
// 残る可能性は string[] だけ
return Unsafe.As<string?[]>(value).Length;
}
}
}
値をすべて文字列にまとめる GetStringValue() という非公開メソッドでは、単一文字列と配列を判別してから配列の場合は string.Create() を使ってカンマ区切りの文字列を生成しています。
private string? GetStringValue()
{
// ローカル変数に退避
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;
}
// 残るパターンは string[] のみ
string?[] values = Unsafe.As<string?[]>(value);
return values.Length switch
{
0 => null,
1 => values[0],
_ => GetJoinedStringValueFromArray(values),
};
}
static string GetJoinedStringValueFromArray(string?[] values)
{
// まず出来上がりの長さを計算
int length = 0;
for (int i = 0; i < values.Length; i++)
{
string? val = values[i];
// null や空文字列はスキップ
if (val != null && val.Length > 0)
{
if (length > 0)
{
length++; // カンマ区切り用
}
length += val.Length;
}
}
// 最終的な文字列を作成 (カンマ区切り)
return string.Create(length, values, (span, strings) =>
{
int offset = 0;
for (int i = 0; i < strings.Length; i++)
{
string? v = strings[i];
if (v != null && v.Length > 0)
{
if (offset > 0)
{
span[offset] = ',';
offset++;
}
v.AsSpan().CopyTo(span.Slice(offset));
offset += v.Length;
}
}
});
}
}
このように StringValues は、「多数のヘッダー値も単一の値も」、余分なアロケーションを最小限に抑えながら扱えるように設計されています。