Skip to content

HybridCache : simplify AOT usage with default JSON serialization #6475

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -59,4 +61,27 @@ public static IHybridCacheBuilder AddSerializerFactory<
_ = Throw.IfNull(builder).Services.AddSingleton<IHybridCacheSerializerFactory, TImplementation>();
return builder;
}

/// <summary>
/// Register a default <see cref="JsonSerializerOptions"/> for use with JSON serialization.
/// </summary>
/// <returns>The <see cref="IHybridCacheBuilder"/> instance.</returns>
[Experimental(DiagnosticIds.Experiments.HybridCache, UrlFormat = DiagnosticIds.UrlFormat)]
public static IHybridCacheBuilder WithJsonSerializerOptions(this IHybridCacheBuilder builder, JsonSerializerOptions options)
{
_ = Throw.IfNull(builder).Services.AddKeyedSingleton<JsonSerializerOptions>(typeof(IHybridCacheSerializer<>), Throw.IfNull(options));
return builder;
}

/// <summary>
/// Register a <see cref="JsonSerializerOptions"/> for use with JSON serialization of type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type being serialized.</typeparam>
/// <returns>The <see cref="IHybridCacheBuilder"/> instance.</returns>
[Experimental(DiagnosticIds.Experiments.HybridCache, UrlFormat = DiagnosticIds.UrlFormat)]
public static IHybridCacheBuilder WithJsonSerializerOptions<T>(this IHybridCacheBuilder builder, JsonSerializerOptions options)
{
_ = Throw.IfNull(builder).Services.AddKeyedSingleton<JsonSerializerOptions>(typeof(IHybridCacheSerializer<T>), Throw.IfNull(options));
return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

namespace Microsoft.Extensions.Caching.Hybrid.Internal;

[UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Checked at runtime, guidance issued")]
[UnconditionalSuppressMessage("AOT", "IL2070", Justification = "Checked at runtime, guidance issued")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Checked at runtime, guidance issued")]
Comment on lines +15 to +17
Copy link
Member

@eerhardt eerhardt May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be moved even more local to only suppress around the lines of code that need it. Doing it the current way will suppress it for all new code written in this class, which isn't correct.

Also, is there a better Justifcation that can be written here? A little more of a reason why this is OK.

See

for some examples.

internal sealed class DefaultJsonSerializerFactory : IHybridCacheSerializerFactory
{
private readonly IServiceProvider _serviceProvider;
Expand All @@ -34,7 +37,7 @@ public bool TryCreateSerializer<T>([NotNullWhen(true)] out IHybridCacheSerialize

// see if there is a per-type options registered (keyed by the **closed** generic type), otherwise use the default
JsonSerializerOptions options = _serviceProvider.GetKeyedService<JsonSerializerOptions>(typeof(IHybridCacheSerializer<T>)) ?? Options;
if (!options.IncludeFields && ReferenceEquals(options, SystemDefaultJsonOptions) && IsFieldOnlyType(typeof(T)))
if (!options.IncludeFields && IsDefaultJsonOptions(options) && IsFieldOnlyType(typeof(T)))
{
// value-tuples expose fields, not properties; special-case this as a common scenario
options = FieldEnabledJsonOptions;
Expand All @@ -50,11 +53,38 @@ internal static bool IsFieldOnlyType(Type type)
return IsFieldOnlyType(type, ref state) == FieldOnlyResult.FieldOnly;
}

private static bool IsDefaultJsonOptions(JsonSerializerOptions options)
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
// can't be, since we don't use default options for AOT
return false;
}

#pragma warning disable IDE0079 // unnecessary suppression: TFM-dependent
#pragma warning disable IL2026, IL3050 // AOT bits
private static JsonSerializerOptions SystemDefaultJsonOptions => JsonSerializerOptions.Default;
return ReferenceEquals(options, JsonSerializerOptions.Default);
#pragma warning restore IL2026, IL3050
#pragma warning restore IDE0079
}

private static JsonSerializerOptions SystemDefaultJsonOptions
{
get
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
throw new NotSupportedException($"When using AOT, {nameof(JsonSerializerOptions)} with {nameof(JsonSerializerOptions.TypeInfoResolver)} specified must be provided via"
+ $" {nameof(IHybridCacheBuilder)}.{nameof(HybridCacheBuilderExtensions.WithJsonSerializerOptions)}.");
}

#pragma warning disable IDE0079 // unnecessary suppression: TFM-dependent
#pragma warning disable IL2026, IL3050 // AOT bits
return JsonSerializerOptions.Default;
#pragma warning restore IL2026, IL3050
#pragma warning restore IDE0079
}
}

[SuppressMessage("Trimming", "IL2070:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The parameter of method does not have matching annotations.",
Justification = "Custom serializers may be needed for AOT with STJ")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
<DisableMicrosoftExtensionsLoggingSourceGenerator>false</DisableMicrosoftExtensionsLoggingSourceGenerator>
</PropertyGroup>

<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
Comment on lines 24 to +28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">
<IsTrimmable>true</IsTrimmable>
<IsAotCompatible>true</IsAotCompatible>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))">true</IsAotCompatible>

You only need to set IsAotCompatible. It will default IsTrimmable. See https://github.com/dotnet/sdk/blob/fd301ec19c3190b8129b28847d6faed9d26bc0d3/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets#L24

</PropertyGroup>

<PropertyGroup>
<Stage>normal</Stage>
<MinCodeCoverage>82</MinCodeCoverage>
Expand Down
1 change: 1 addition & 0 deletions src/Shared/DiagnosticIds/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal static class Experiments
internal const string DocumentDb = "EXTEXP0011";
internal const string AutoActivation = "EXTEXP0012";
internal const string HttpLogging = "EXTEXP0013";
internal const string HybridCache = "EXTEXP0018";
}

internal static class LoggerMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
<LibraryProjects Include="$(RepoRoot)\src\Libraries\**\*.csproj" />
<!-- https://github.com/Azure/azure-sdk-for-net/issues/47069 -->
<LibraryProjects Remove="$(RepoRoot)\src\Libraries\Microsoft.Extensions.AI.AzureAIInference\Microsoft.Extensions.AI.AzureAIInference.csproj" />
<!-- https://github.com/dotnet/extensions/issues/5624 -->
<LibraryProjects Remove="$(RepoRoot)\src\Libraries\Microsoft.Extensions.Caching.Hybrid\Microsoft.Extensions.Caching.Hybrid.csproj" />
<!-- https://github.com/dotnet/extensions/issues/5623 -->
<LibraryProjects Remove="$(RepoRoot)\src\Libraries\Microsoft.Extensions.Compliance.Redaction\Microsoft.Extensions.Compliance.Redaction.csproj" />
<!-- https://github.com/dotnet/extensions/issues/5625 -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,20 +236,20 @@ public class NodeB<T>
private static T RoundTrip<T>(T value, ReadOnlySpan<byte> expectedBytes, JsonSerializer expectedJsonOptions, JsonSerializer addSerializers = JsonSerializer.None, bool binary = false)
{
var services = new ServiceCollection();
services.AddHybridCache();
var hc = services.AddHybridCache();
JsonSerializerOptions? globalOptions = null;
JsonSerializerOptions? perTypeOptions = null;

if ((addSerializers & JsonSerializer.CustomGlobal) != JsonSerializer.None)
{
globalOptions = new() { IncludeFields = true }; // assume any custom options will serialize the whole type
services.AddKeyedSingleton<JsonSerializerOptions>(typeof(IHybridCacheSerializer<>), globalOptions);
hc.WithJsonSerializerOptions(globalOptions);
}

if ((addSerializers & JsonSerializer.CustomPerType) != JsonSerializer.None)
{
perTypeOptions = new() { IncludeFields = true }; // assume any custom options will serialize the whole type
services.AddKeyedSingleton<JsonSerializerOptions>(typeof(IHybridCacheSerializer<T>), perTypeOptions);
hc.WithJsonSerializerOptions<T>(perTypeOptions);
}

JsonSerializerOptions? expectedOptionsObj = expectedJsonOptions switch
Expand Down
Loading