Skip to content

Zero-allocation querystring parser #33840

@SteveSandersonMS

Description

@SteveSandersonMS

Background and Motivation

Currently the primary API for parsing querystrings in ASP.NET Core is QueryHelpers.ParseQuery.

Quiz: How many allocations do you get by calling QueryHelpers.ParseQuery(currentUrl)?
Answer: Obviously it depends on the value of currentUrl, but given the default URL length limit of 8KB, we can get over 3200 allocations from this one call. Maybe more; that's just what I got from a quick experiment.

If you need to build a key/values dictionary from the querystring, having this many allocations is mostly unavoidable. However, there are cases where you are only interested in extracting one value or a subset of values. In these cases, you (a) don't require a dictionary containing everything, and (b) you don't want to pay the costs of URL decoding irrelevant values.

The particular scenario I'm dealing with now is Blazor's querystring enhancements. For this, we already know statically which querystring parameter names a given component is interested in. Typically it will be < 5 parameters. It's undesirable that, on every navigation (which for Blazor Server just means a single SignalR message saying "location changed"), we'd build a dictionary that may also contain thousands of irrelevant key/value pairs if someone is deliberately trying to stress the system. Originally I was going to solve this purely as an internal implementation detail, but @davidfowl has suggested this might be useful as a public API.

Proposed API

An enumerator that operates over ReadOnlySpan<char>, and doesn't attempt to decode the key/value pairs except if you explicitly ask it to.

namespace Microsoft.AspNetCore.WebUtilities
{
    public readonly ref struct QueryStringEnumerable
    {
         public QueryStringEnumerable(ReadOnlySpan<char> queryString);
         public QueryStringEnumerator GetEnumerator();
    }

    public ref struct QueryStringEnumerator
    {
        public QueryStringNameValuePair Current { get; }
        public bool MoveNext();
    }

    public readonly ref struct QueryStringNameValuePair
    {
        public ReadOnlySpan<char> EncodedName { get; }
        public ReadOnlySpan<char> EncodedValue { get; }
        public string DecodeName();
        public string DecodeValue();
    }
}

The way this works is simply splitting the existing parsing logic out of QueryHelpers.ParseQuery, decoupling it from the decoding-and-building-a-dictionary aspect. Of course we'd also retain the existing QueryHelpers.ParseQuery. Its implementation would become much simpler as it just has to use this new enumerator to populate a KeyValueAccumulator.

Implementation

Here's an approximate implementation: main...stevesa/component-params-from-querystring#diff-7e7c52bc7413177d1e2dec2dcaecad3bf0769f9bf7af2aea33e4f55956e4172f. It doesn't have the DecodeName()/DecodeValue() methods but they would be pretty trivial.

Usage Examples

The exact patterns depend on things like whether you want to collect multiple values or stop on the first match, whether you need to decode the keys to match them, whether you want to be case-sensitive, etc.

Reading from the querystring

string? somethingValue = null;
var enumerable = new QueryStringEnumerable(Request.QueryString.Value);
foreach (var pair in enumerable)
{
    // For most keys, we don't need to decode, and most values can be ignored completely
    if (pair.EncodedName.Equals("something", StringComparer.OrdinalIgnoreCase))
    {
        somethingValue = pair.DecodeValue(); // Decoding does allocate, because Uri.UnescapeDataString operates on a string, and you get back a string
        break;
    }
}

Modifying the querystring

If you wanted to get a URL for "current URL except with one extra/modified query param", which will be a common pattern in the Blazor case, then you could build a new URL string by walking the existing URL and just adding/omitting/modifying a single param value. There would be no allocation except for a StringBuilder or similar.

Alternatives

This could just be an internal implementation detail. Blazor could consume this via shared-source.

We might also want to have a deconstructor on QueryStringNameValuePair so that you could do foreach (var (encodedName, encodedValue) in enumerable) but it's unclear this is really beneficial.

Risks

It's possible that people might not understand how EncodedName/EncodedValue differs from the strings they normally receive and could implement buggy logic. However I think this is mostly mitigated by the fact that these are typed as ReadOnlySpan<char>. Less familiar developers will either:

  • Just use QueryHelpers.ParseQuery, because it's more obvious how to consume a dictionary
  • Or, just call DecodeName()/DecodeValue() because they want a string

... so in either case they wouldn't see the encoded data.

Metadata

Metadata

Labels

DoneThis issue has been fixedapi-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-networkingIncludes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions