diff --git a/src/Http/Http/perf/Microbenchmarks/QueryCollectionBenchmarks.cs b/src/Http/Http/perf/Microbenchmarks/QueryCollectionBenchmarks.cs new file mode 100644 index 000000000000..cc165ee1e430 --- /dev/null +++ b/src/Http/Http/perf/Microbenchmarks/QueryCollectionBenchmarks.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.WebUtilities; +using static Microsoft.AspNetCore.Http.Features.QueryFeature; + +namespace Microsoft.AspNetCore.Http +{ + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + [CategoriesColumn] + public class QueryCollectionBenchmarks + { + private string _queryString; + private string _singleValue; + private string _singleValueWithPlus; + private string _encoded; + + [IterationSetup] + public void Setup() + { + _queryString = "?key1=value1&key2=value2&key3=value3&key4=&key5="; + _singleValue = "?key1=value1"; + _singleValueWithPlus = "?key1=value1+value2+value3"; + _encoded = "?key1=value%231"; + } + + [Benchmark(Description = "ParseNew")] + [BenchmarkCategory("QueryString")] + public void ParseNew() + { + _ = QueryFeature.ParseNullableQueryInternal(_queryString); + } + + [Benchmark(Description = "ParseNew")] + [BenchmarkCategory("Single")] + public void ParseNewSingle() + { + _ = QueryFeature.ParseNullableQueryInternal(_singleValue); + } + + [Benchmark(Description = "ParseNew")] + [BenchmarkCategory("SingleWithPlus")] + public void ParseNewSingleWithPlus() + { + _ = QueryFeature.ParseNullableQueryInternal(_singleValueWithPlus); + } + + [Benchmark(Description = "ParseNew")] + [BenchmarkCategory("Encoded")] + public void ParseNewEncoded() + { + _ = QueryFeature.ParseNullableQueryInternal(_encoded); + } + + [Benchmark(Description = "QueryHelpersParse")] + [BenchmarkCategory("QueryString")] + public void QueryHelpersParse() + { + _ = QueryHelpers.ParseNullableQuery(_queryString); + } + + [Benchmark(Description = "QueryHelpersParse")] + [BenchmarkCategory("Single")] + public void QueryHelpersParseSingle() + { + _ = QueryHelpers.ParseNullableQuery(_singleValue); + } + + [Benchmark(Description = "QueryHelpersParse")] + [BenchmarkCategory("SingleWithPlus")] + public void QueryHelpersParseSingleWithPlus() + { + _ = QueryHelpers.ParseNullableQuery(_singleValueWithPlus); + } + + [Benchmark(Description = "QueryHelpersParse")] + [BenchmarkCategory("Encoded")] + public void QueryHelpersParseEncoded() + { + _ = QueryHelpers.ParseNullableQuery(_encoded); + } + + [Benchmark] + [BenchmarkCategory("Constructor")] + public void Constructor() + { + var dict = new KvpAccumulator(); + if (dict.HasValues) + { + return; + } + } + } +} diff --git a/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs b/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs index 035a367b1324..41aea5de4e60 100644 --- a/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs +++ b/src/Http/Http/perf/Microbenchmarks/RequestCookieCollectionBenchmarks.cs @@ -19,7 +19,7 @@ public void Setup() [Benchmark] public void Parse_TypicalCookie() { - RequestCookieCollection.Parse(_cookie); + _ = RequestCookieCollection.Parse(_cookie); } } } diff --git a/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs b/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs index 76a8659c2a48..f1bc86c68a75 100644 --- a/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs +++ b/src/Http/Http/perf/Microbenchmarks/RouteValueDictionaryBenchmark.cs @@ -33,9 +33,9 @@ public void Ctor_Values_RouteValueDictionary_EmptyArray() } [Benchmark] - public void Ctor_Values_RouteValueDictionary_Array() + public RouteValueDictionary Ctor_Values_RouteValueDictionary_Array() { - new RouteValueDictionary(_arrayValues); + return new RouteValueDictionary(_arrayValues); } [Benchmark] diff --git a/src/Http/Http/src/Features/QueryFeature.cs b/src/Http/Http/src/Features/QueryFeature.cs index 289f577ee339..bc7b1c7d2cf2 100644 --- a/src/Http/Http/src/Features/QueryFeature.cs +++ b/src/Http/Http/src/Features/QueryFeature.cs @@ -2,7 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using Microsoft.AspNetCore.WebUtilities; +using System.Buffers; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Http.Features { @@ -55,13 +62,9 @@ public IQueryCollection Query { get { - if (_features.Collection == null) + if (_features.Collection is null) { - if (_parsedValues == null) - { - _parsedValues = QueryCollection.Empty; - } - return _parsedValues; + return _parsedValues ?? QueryCollection.Empty; } var current = HttpRequestFeature.QueryString; @@ -69,16 +72,11 @@ public IQueryCollection Query { _original = current; - var result = QueryHelpers.ParseNullableQuery(current); + var result = ParseNullableQueryInternal(current); - if (result == null) - { - _parsedValues = QueryCollection.Empty; - } - else - { - _parsedValues = new QueryCollection(result); - } + _parsedValues = result is not null + ? new QueryCollectionInternal(result) + : QueryCollection.Empty; } return _parsedValues; } @@ -100,5 +98,221 @@ public IQueryCollection Query } } } + + /// + /// Parse a query string into its component key and value parts. + /// + /// The raw query string value, with or without the leading '?'. + /// A collection of parsed keys and values, null if there are no entries. + [SkipLocalsInit] + internal static AdaptiveCapacityDictionary? ParseNullableQueryInternal(string? queryString) + { + if (string.IsNullOrEmpty(queryString) || (queryString.Length == 1 && queryString[0] == '?')) + { + return null; + } + + var accumulator = new KvpAccumulator(); + var query = queryString.AsSpan(); + + if (query[0] == '?') + { + query = query[1..]; + } + + while (!query.IsEmpty) + { + var delimiterIndex = query.IndexOf('&'); + + var querySegment = delimiterIndex >= 0 + ? query.Slice(0, delimiterIndex) + : query; + + var equalIndex = querySegment.IndexOf('='); + + if (equalIndex >= 0) + { + var name = SpanHelper.ReplacePlusWithSpace(querySegment.Slice(0, equalIndex)); + var value = SpanHelper.ReplacePlusWithSpace(querySegment.Slice(equalIndex + 1)); + + accumulator.Append( + Uri.UnescapeDataString(name), + Uri.UnescapeDataString(value)); + } + else + { + if (!querySegment.IsEmpty) + { + accumulator.Append(querySegment); + } + } + + if (delimiterIndex < 0) + { + break; + } + + query = query.Slice(delimiterIndex + 1); + } + + return accumulator.HasValues + ? accumulator.GetResults() + : null; + } + + internal struct KvpAccumulator + { + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + private AdaptiveCapacityDictionary _accumulator; + private AdaptiveCapacityDictionary> _expandingAccumulator; + + public void Append(ReadOnlySpan key, ReadOnlySpan value = default) + => Append(key.ToString(), value.IsEmpty ? string.Empty : value.ToString()); + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public void Append(string key, string value) + { + if (_accumulator is null) + { + _accumulator = new AdaptiveCapacityDictionary(StringComparer.OrdinalIgnoreCase); + } + + if (!_accumulator.TryGetValue(key, out var values)) + { + // First value for this key + _accumulator[key] = new StringValues(value); + } + else + { + AppendToExpandingAccumulator(key, value, values); + } + + ValueCount++; + } + + private void AppendToExpandingAccumulator(string key, string value, StringValues values) + { + // When there are some values for the same key, so switch to expanding accumulator, and + // add a zero count marker in the accumulator to indicate that switch. + + if (values.Count != 0) + { + _accumulator[key] = default; + + if (_expandingAccumulator is null) + { + _expandingAccumulator = new AdaptiveCapacityDictionary>(capacity: 5, StringComparer.OrdinalIgnoreCase); + } + + // Already 2 (1 existing + the new one) entries so use List's expansion mechanism for more + var list = new List(); + + list.AddRange(values); + list.Add(value); + + _expandingAccumulator[key] = list; + } + else + { + // The marker indicates we are in the expanding accumulator, so just append to the list. + _expandingAccumulator[key].Add(value); + } + } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public bool HasValues => ValueCount > 0; + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public int KeyCount => _accumulator?.Count ?? 0; + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public int ValueCount { get; private set; } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public AdaptiveCapacityDictionary GetResults() + { + if (_expandingAccumulator != null) + { + // Coalesce count 3+ multi-value entries into _accumulator dictionary + foreach (var entry in _expandingAccumulator) + { + _accumulator[entry.Key] = new StringValues(entry.Value.ToArray()); + } + } + + return _accumulator ?? new AdaptiveCapacityDictionary(0, StringComparer.OrdinalIgnoreCase); + } + } + + private static class SpanHelper + { + private static readonly SpanAction s_replacePlusWithSpace = ReplacePlusWithSpaceCore; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static unsafe string ReplacePlusWithSpace(ReadOnlySpan span) + { + fixed (char* ptr = &MemoryMarshal.GetReference(span)) + { + return string.Create(span.Length, (IntPtr)ptr, s_replacePlusWithSpace); + } + } + + private static unsafe void ReplacePlusWithSpaceCore(Span buffer, IntPtr state) + { + fixed (char* ptr = &MemoryMarshal.GetReference(buffer)) + { + var input = (ushort*)state.ToPointer(); + var output = (ushort*)ptr; + + var i = (nint)0; + var n = (nint)(uint)buffer.Length; + + if (Sse41.IsSupported && n >= Vector128.Count) + { + var vecPlus = Vector128.Create((ushort)'+'); + var vecSpace = Vector128.Create((ushort)' '); + + do + { + var vec = Sse2.LoadVector128(input + i); + var mask = Sse2.CompareEqual(vec, vecPlus); + var res = Sse41.BlendVariable(vec, vecSpace, mask); + Sse2.Store(output + i, res); + i += Vector128.Count; + } while (i <= n - Vector128.Count); + } + + for (; i < n; ++i) + { + if (input[i] != '+') + { + output[i] = input[i]; + } + else + { + output[i] = ' '; + } + } + } + } + } } } diff --git a/src/Http/Http/src/QueryCollectionInternal.cs b/src/Http/Http/src/QueryCollectionInternal.cs new file mode 100644 index 000000000000..45b40fe5feb1 --- /dev/null +++ b/src/Http/Http/src/QueryCollectionInternal.cs @@ -0,0 +1,129 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections; +using System.Collections.Generic; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Http +{ + /// + /// The HttpRequest query string collection + /// + internal class QueryCollectionInternal : IQueryCollection + { + private AdaptiveCapacityDictionary Store { get; } + + /// + /// Initializes a new instance of . + /// + /// The backing store. + internal QueryCollectionInternal(AdaptiveCapacityDictionary store) + { + Store = store; + } + + /// + /// Gets the associated set of values from the collection. + /// + /// The key name. + /// the associated value from the collection as a StringValues or StringValues.Empty if the key is not present. + public StringValues this[string key] => TryGetValue(key, out var value) ? value : StringValues.Empty; + + /// + /// Gets the number of elements contained in the ;. + /// + /// The number of elements contained in the . + public int Count => Store.Count; + + /// + /// Gets the collection of query names in this instance. + /// + public ICollection Keys => Store.Keys; + + /// + /// Determines whether the contains a specific key. + /// + /// The key. + /// true if the contains a specific key; otherwise, false. + public bool ContainsKey(string key) => Store.ContainsKey(key); + + /// + /// Retrieves a value from the collection. + /// + /// The key. + /// The value. + /// true if the contains the key; otherwise, false. + public bool TryGetValue(string key, out StringValues value) => Store.TryGetValue(key, out value); + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// An object that can be used to iterate through the collection. + public Enumerator GetEnumerator() => new Enumerator(Store.GetEnumerator()); + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// An object that can be used to iterate through the collection. + IEnumerator> IEnumerable>.GetEnumerator() + => Store.GetEnumerator(); + + /// + /// Returns an enumerator that iterates through a collection. + /// + /// An object that can be used to iterate through the collection. + IEnumerator IEnumerable.GetEnumerator() => Store.GetEnumerator(); + + /// + /// Enumerates a . + /// + public struct Enumerator : IEnumerator> + { + // Do NOT make this readonly, or MoveNext will not work + private AdaptiveCapacityDictionary.Enumerator _dictionaryEnumerator; + private bool _notEmpty; + + internal Enumerator(AdaptiveCapacityDictionary.Enumerator dictionaryEnumerator) + { + _dictionaryEnumerator = dictionaryEnumerator; + _notEmpty = true; + } + + /// + /// Advances the enumerator to the next element of the . + /// + /// if the enumerator was successfully advanced to the next element; + /// if the enumerator has passed the end of the collection. + public bool MoveNext() + { + if (_notEmpty) + { + return _dictionaryEnumerator.MoveNext(); + } + return false; + } + + /// + /// Gets the element at the current position of the enumerator. + /// + public KeyValuePair Current => _notEmpty ? _dictionaryEnumerator.Current : default; + + /// + public void Dispose() + { + } + + object IEnumerator.Current => Current; + + void IEnumerator.Reset() + { + if (_notEmpty) + { + ((IEnumerator)_dictionaryEnumerator).Reset(); + } + } + } + } +} diff --git a/src/Http/Http/test/Features/QueryFeatureTests.cs b/src/Http/Http/test/Features/QueryFeatureTests.cs index e43e3ce7a976..04a0a7c8c462 100644 --- a/src/Http/Http/test/Features/QueryFeatureTests.cs +++ b/src/Http/Http/test/Features/QueryFeatureTests.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Linq; using Xunit; namespace Microsoft.AspNetCore.Http.Features @@ -12,9 +13,7 @@ public void QueryReturnsParsedQueryCollection() { // Arrange var features = new FeatureCollection(); - var request = new HttpRequestFeature(); - request.QueryString = "foo=bar"; - features[typeof(IHttpRequestFeature)] = request; + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = "foo=bar" }; var provider = new QueryFeature(features); @@ -25,6 +24,23 @@ public void QueryReturnsParsedQueryCollection() Assert.Equal("bar", queryCollection["foo"]); } + [Theory] + [InlineData("?key1=value1&key2=value2")] + [InlineData("key1=value1&key2=value2")] + public void ParseQueryWithUniqueKeysWorks(string queryString) + { + var features = new FeatureCollection(); + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = queryString }; + + var provider = new QueryFeature(features); + + var queryCollection = provider.Query; + + Assert.Equal(2, queryCollection.Count); + Assert.Equal("value1", queryCollection["key1"].FirstOrDefault()); + Assert.Equal("value2", queryCollection["key2"].FirstOrDefault()); + } + [Theory] [InlineData("?q", "q")] [InlineData("?q&", "q")] @@ -34,9 +50,7 @@ public void QueryReturnsParsedQueryCollection() public void KeyWithoutValuesAddedToQueryCollection(string queryString, string emptyParam) { var features = new FeatureCollection(); - var request = new HttpRequestFeature(); - request.QueryString = queryString; - features[typeof(IHttpRequestFeature)] = request; + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = queryString }; var provider = new QueryFeature(features); @@ -53,9 +67,7 @@ public void KeyWithoutValuesAddedToQueryCollection(string queryString, string em public void EmptyKeysNotAddedToQueryCollection(string queryString) { var features = new FeatureCollection(); - var request = new HttpRequestFeature(); - request.QueryString = queryString; - features[typeof(IHttpRequestFeature)] = request; + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = queryString }; var provider = new QueryFeature(features); @@ -63,5 +75,80 @@ public void EmptyKeysNotAddedToQueryCollection(string queryString) Assert.Equal(0, queryCollection.Count); } + + [Fact] + public void ParseQueryWithEmptyKeyWorks() + { + var features = new FeatureCollection(); + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = "?=value1&=" }; + + var provider = new QueryFeature(features); + + var queryCollection = provider.Query; + + Assert.Single(queryCollection); + Assert.Equal(new[] { "value1", "" }, queryCollection[""]); + } + + [Fact] + public void ParseQueryWithDuplicateKeysGroups() + { + var features = new FeatureCollection(); + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = "?key1=valueA&key2=valueB&key1=valueC" }; + + var provider = new QueryFeature(features); + + var queryCollection = provider.Query; + + Assert.Equal(2, queryCollection.Count); + Assert.Equal(new[] { "valueA", "valueC" }, queryCollection["key1"]); + Assert.Equal("valueB", queryCollection["key2"].FirstOrDefault()); + } + + [Fact] + public void ParseQueryWithThreefoldKeysGroups() + { + var features = new FeatureCollection(); + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = "?key1=valueA&key2=valueB&key1=valueC&key1=valueD" }; + + var provider = new QueryFeature(features); + + var queryCollection = provider.Query; + + Assert.Equal(2, queryCollection.Count); + Assert.Equal(new[] { "valueA", "valueC", "valueD" }, queryCollection["key1"]); + Assert.Equal("valueB", queryCollection["key2"].FirstOrDefault()); + } + + [Fact] + public void ParseQueryWithEmptyValuesWorks() + { + var features = new FeatureCollection(); + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = "?key1=&key2=" }; + + var provider = new QueryFeature(features); + + var queryCollection = provider.Query; + + Assert.Equal(2, queryCollection.Count); + Assert.Equal(string.Empty, queryCollection["key1"].FirstOrDefault()); + Assert.Equal(string.Empty, queryCollection["key2"].FirstOrDefault()); + } + + [Theory] + [InlineData("?")] + [InlineData("")] + [InlineData(null)] + public void ParseEmptyOrNullQueryWorks(string queryString) + { + var features = new FeatureCollection(); + features[typeof(IHttpRequestFeature)] = new HttpRequestFeature { QueryString = queryString }; + + var provider = new QueryFeature(features); + + var queryCollection = provider.Query; + + Assert.Empty(queryCollection); + } } } diff --git a/src/Shared/Dictionary/AdaptiveCapacityDictionary.cs b/src/Shared/Dictionary/AdaptiveCapacityDictionary.cs index d039c4ffbbbd..3d2c208cd120 100644 --- a/src/Shared/Dictionary/AdaptiveCapacityDictionary.cs +++ b/src/Shared/Dictionary/AdaptiveCapacityDictionary.cs @@ -22,7 +22,7 @@ internal class AdaptiveCapacityDictionary : IDictionary[]? _arrayStorage; private int _count; internal Dictionary? _dictionaryStorage; - private IEqualityComparer _comparer; + private readonly IEqualityComparer _comparer; /// /// Creates an empty . @@ -621,7 +621,6 @@ private bool TryFindItem(TKey key, out TValue? value) [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ContainsKeyArray(TKey key) => TryFindItem(key, out _); - /// public struct Enumerator : IEnumerator> {