Skip to content

Commit dff2c6a

Browse files
authored
NameValueCollection.ToQueryString performance optimisation (#4952)
1 parent 95ea595 commit dff2c6a

File tree

3 files changed

+93
-13
lines changed

3 files changed

+93
-13
lines changed

src/Elasticsearch.Net/Elasticsearch.Net.csproj

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
<PackageReference Include="Microsoft.CSharp" Version="4.6.0" />
2424
<PackageReference Include="System.Buffers" Version="4.5.0" />
2525
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="4.5.1" />
26+
27+
<PackageReference Condition="'$(TargetFramework)' != 'netstandard2.1'" Include="System.Memory" Version="4.5.0" />
2628

2729
<PackageReference Condition="'$(TargetFramework)' == 'netstandard2.0'" Include="System.Reflection.Emit" Version="4.3.0" />
2830
<PackageReference Condition="'$(TargetFramework)' == 'netstandard2.0'" Include="System.Reflection.Emit.Lightweight" Version="4.3.0" />

src/Elasticsearch.Net/Extensions/NameValueCollectionExtensions.cs

+44-13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information
44

55
using System;
6+
using System.Buffers;
67
using System.Collections.Generic;
78
using System.Collections.Specialized;
89
using System.Linq;
@@ -12,28 +13,58 @@ namespace Elasticsearch.Net.Extensions
1213
{
1314
internal static class NameValueCollectionExtensions
1415
{
16+
private const int MaxCharsOnStack = 256; // 512 bytes
17+
1518
internal static string ToQueryString(this NameValueCollection nv)
1619
{
1720
if (nv == null || nv.AllKeys.Length == 0) return string.Empty;
1821

19-
// initialize with capacity for number of key/values with length 5 each
20-
var builder = new StringBuilder("?", nv.AllKeys.Length * 2 * 5);
21-
for (var i = 0; i < nv.AllKeys.Length; i++)
22+
var maxLength = 1 + nv.AllKeys.Length - 1; // account for '?', and any required '&' chars
23+
foreach (var key in nv.AllKeys)
2224
{
23-
if (i != 0)
24-
builder.Append("&");
25+
var bytes = Encoding.UTF8.GetByteCount(key) + Encoding.UTF8.GetByteCount(nv[key] ?? string.Empty);
26+
var maxEncodedSize = bytes * 3; // worst case, assume all bytes are URL escaped to 3 chars
27+
maxLength += 1 + maxEncodedSize; // '=' + encoded chars
28+
}
2529

26-
var key = nv.AllKeys[i];
27-
builder.Append(Uri.EscapeDataString(key));
28-
var value = nv[key];
29-
if (!value.IsNullOrEmpty())
30+
// prefer stack allocated array for short lengths
31+
// note: renting for larger lengths is slightly more efficient since no zeroing occurs
32+
char[] rentedFromPool = null;
33+
var buffer = maxLength > MaxCharsOnStack
34+
? rentedFromPool = ArrayPool<char>.Shared.Rent(maxLength)
35+
: stackalloc char[maxLength];
36+
37+
try
38+
{
39+
var position = 0;
40+
buffer[position++] = '?';
41+
42+
foreach (var key in nv.AllKeys)
3043
{
31-
builder.Append("=");
32-
builder.Append(Uri.EscapeDataString(nv[key]));
44+
if (position != 1)
45+
buffer[position++] = '&';
46+
47+
var escapedKey = Uri.EscapeDataString(key);
48+
escapedKey.AsSpan().CopyTo(buffer.Slice(position));
49+
position += escapedKey.Length;
50+
51+
var value = nv[key];
52+
53+
if (value.IsNullOrEmpty()) continue;
54+
55+
buffer[position++] = '=';
56+
var escapedValue = Uri.EscapeDataString(value);
57+
escapedValue.AsSpan().CopyTo(buffer.Slice(position));
58+
position += escapedValue.Length;
3359
}
34-
}
3560

36-
return builder.ToString();
61+
return buffer.Slice(0, position).ToString();
62+
}
63+
finally
64+
{
65+
if (rentedFromPool is object)
66+
ArrayPool<char>.Shared.Return(rentedFromPool, clearArray: false);
67+
}
3768
}
3869

3970
internal static void UpdateFromDictionary(this NameValueCollection queryString, Dictionary<string, object> queryStringUpdates,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System.Collections.Generic;
2+
using System.Collections.Specialized;
3+
using Elasticsearch.Net.Extensions;
4+
using FluentAssertions;
5+
using Xunit;
6+
7+
namespace Tests.Extensions
8+
{
9+
public class NameValueCollectionExtensionsTests
10+
{
11+
[Theory]
12+
[MemberData(nameof(QueryStringTestData))]
13+
public void ToQueryString_ReturnsExpectedString(NameValueCollection nvc, string expected) => nvc.ToQueryString().Should().Be(expected);
14+
15+
public static IEnumerable<object[]> QueryStringTestData =>
16+
new List<object[]>
17+
{
18+
new object[] { new NameValueCollection
19+
{
20+
{ "q", "title:\"The Right Way\" AND mod_date:[20020101 TO 20030101]" },
21+
{ "from", "10000" },
22+
{ "request_cache", bool.TrueString },
23+
{ "size", "100" }
24+
}, "?q=title%3A%22The%20Right%20Way%22%20AND%20mod_date%3A%5B20020101%20TO%2020030101%5D&from=10000&request_cache=True&size=100" },
25+
26+
new object[] { new NameValueCollection
27+
{
28+
{ "q", "name:john~1 AND (age:[30 TO 40} OR surname:K*) AND -city" },
29+
}, "?q=name%3Ajohn~1%20AND%20%28age%3A%5B30%20TO%2040%7D%20OR%20surname%3AK%2A%29%20AND%20-city" },
30+
31+
new object[] { new NameValueCollection
32+
{
33+
{ "q", null },
34+
}, "?q" },
35+
36+
new object[] { new NameValueCollection
37+
{
38+
{ "emoji", "😅"}
39+
}, "?emoji=%F0%9F%98%85" },
40+
41+
new object[] { new NameValueCollection
42+
{
43+
{ "€", "€"}
44+
}, "?%E2%82%AC=%E2%82%AC" }
45+
};
46+
}
47+
}

0 commit comments

Comments
 (0)