From bf839f78293923425577bb6431de049a089d3a64 Mon Sep 17 00:00:00 2001 From: Jan Friedrich Date: Wed, 5 Aug 2026 23:06:59 +0200 Subject: [PATCH] make log4net usable from a PublishAOT build (#306) Native AOT broke log4net in three ways (#233). Assembly.GetCallingAssembly() throws PlatformNotSupportedException there, so every overload that resolves the repository from the caller failed - LogManager.GetLogger(Type) among them. Guard the 18 call sites with CallerAssembly.IsSupported, a flag probed once, and fall back to the entry assembly when the runtime does not implement the call. The call itself has to stay in the public method whose caller is wanted, so it cannot be moved into the helper. SystemInfo.GetAppSetting() then reported a caught failure on every lookup, because a trimmed System.Configuration cannot initialize. Tell that apart from a configuration file that does not parse - Native AOT surfaces both as a ConfigurationErrorsException, so only the inner exception distinguishes them - and treat a missing configuration system as a property of the runtime rather than a fault: log it at debug level and let environment variables stand in for the config file, as they already do on Android. A malformed config file is still reported as an error and still yields no setting. Finally the trimmer removed the constructors of everything log4net creates reflectively, so no repository, pattern converter or locking model could be instantiated. Annotate that flow with DynamicallyAccessedMembers - polyfilled here, because the trimmer matches it by name and neither target framework declares it - and hold the built-in converters in a Dictionary of ConverterInfo rather than of Type, since a Type placed in a collection loses its annotation. The registries are now built through a generic method whose new() constraint states the same requirement structurally, so a converter without a public parameterless constructor fails to compile instead of failing in a trimmed build. Configuration still has to be done in code: XmlConfigurator names its types in strings and cannot work once they have been trimmed. Document that, and the fact that loggers from non-entry assemblies land in the entry assembly's repository, on a new Native AOT page in the manual. --- .../306-usable-from-a-publishaot-build.xml | 14 ++ src/log4net.Tests/Util/CallerAssemblyTest.cs | 76 +++++++ src/log4net.Tests/Util/SystemInfoTest.cs | 100 +++++++++ src/log4net.Tests/log4net.Tests.csproj | 1 + src/log4net/Appender/FileAppender.cs | 12 +- src/log4net/Config/BasicConfigurator.cs | 5 +- src/log4net/Config/RepositoryAttribute.cs | 2 + src/log4net/Config/XmlConfigurator.cs | 14 +- src/log4net/Core/DefaultRepositorySelector.cs | 21 +- src/log4net/Core/IRepositorySelector.cs | 7 +- src/log4net/Core/LoggerManager.cs | 7 +- .../DynamicallyAccessedMemberTypes.cs | 53 +++++ .../DynamicallyAccessedMembersAttribute.cs | 49 +++++ src/log4net/Layout/PatternLayout.cs | 137 ++++++------ src/log4net/LogManager.cs | 68 ++++-- src/log4net/Util/CallerAssembly.cs | 74 +++++++ src/log4net/Util/ConverterInfo.cs | 2 + src/log4net/Util/PatternString.cs | 73 ++++--- src/log4net/Util/SystemInfo.cs | 83 +++++++- .../Util/TypeConverters/ConverterRegistry.cs | 7 +- src/site/antora/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/manual/configuration.adoc | 7 + .../modules/ROOT/pages/manual/native-aot.adoc | 196 ++++++++++++++++++ 23 files changed, 861 insertions(+), 148 deletions(-) create mode 100644 src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml create mode 100644 src/log4net.Tests/Util/CallerAssemblyTest.cs create mode 100644 src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs create mode 100644 src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs create mode 100644 src/log4net/Util/CallerAssembly.cs create mode 100644 src/site/antora/modules/ROOT/pages/manual/native-aot.adoc diff --git a/src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml b/src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml new file mode 100644 index 000000000..62c79d4a0 --- /dev/null +++ b/src/changelog/3.4.0/306-usable-from-a-publishaot-build.xml @@ -0,0 +1,14 @@ + + + + + Make log4net usable from a `PublishAot` build, where `LogManager.GetLogger()` + used to throw `PlatformNotSupportedException` from `Assembly.GetCallingAssembly()`, and where + repositories and pattern converters were left without a constructor by the trimmer. Configuration + has to be done in code - see the new + https://logging.apache.org/log4net/latest/manual/native-aot.html[Native AOT and trimming] page + (reported by @vpenades, implemented by @FreeAndNil in https://github.com/apache/logging-log4net/pull/306[#306]) + diff --git a/src/log4net.Tests/Util/CallerAssemblyTest.cs b/src/log4net.Tests/Util/CallerAssemblyTest.cs new file mode 100644 index 000000000..cfc134696 --- /dev/null +++ b/src/log4net.Tests/Util/CallerAssemblyTest.cs @@ -0,0 +1,76 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +using System.Reflection; +using System.Runtime.CompilerServices; +using log4net.Util; +using NUnit.Framework; + +namespace log4net.Tests.Util; + +/// +/// Tests for , the guard that keeps the +/// based overloads usable under Native AOT. +/// +/// +/// +/// The AOT half of the behaviour cannot be covered here - these tests always run on a JIT +/// runtime, where is and +/// is never consulted. What they do cover is that the +/// guard stays inert on a JIT runtime, so that no call site silently starts attributing +/// loggers to the entry assembly instead of the caller. +/// +/// +[TestFixture] +public class CallerAssemblyTest +{ + /// + /// The probe recognises a runtime that does implement + /// , so the guard stays out of the way everywhere + /// except Native AOT. A false negative here would silently move every logger to the entry + /// assembly's repository. + /// + [Test] + public void IsSupportedOnAJitRuntime() => Assert.That(CallerAssembly.IsSupported, Is.True); + + /// + /// There is always a replacement assembly to attribute a call to, even though the entry + /// assembly is in a host without a managed entry point. + /// + [Test] + public void FallbackIsAvailable() => Assert.That(CallerAssembly.Fallback, Is.Not.Null); + + /// + /// The guard has to leave in the method whose + /// caller is wanted, so a call from this assembly still resolves to this assembly. + /// + [Test] + public void GuardedCallStillReportsTheCallersAssembly() + => Assert.That(GuardedCallingAssembly(), Is.SameAs(typeof(CallerAssemblyTest).Assembly)); + + /// + /// Stands in for a public log4net entry point. Inlining is suppressed because it would + /// hand a different frame - the same effect + /// that makes the release build of unable to assert on an + /// exact assembly. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static Assembly GuardedCallingAssembly() + => CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; +} diff --git a/src/log4net.Tests/Util/SystemInfoTest.cs b/src/log4net.Tests/Util/SystemInfoTest.cs index 9b9067c91..c24914ff1 100644 --- a/src/log4net.Tests/Util/SystemInfoTest.cs +++ b/src/log4net.Tests/Util/SystemInfoTest.cs @@ -23,6 +23,8 @@ using NUnit.Framework; +using System.Configuration; +using System.IO; using System.Linq.Expressions; using System.Reflection; @@ -171,4 +173,102 @@ public void EqualsIgnoringCase_DifferentStrings_false() [Platform(Include = "Win,Linux,MacOsX")] public void IsAndoid() => Assert.That(typeof(SystemInfo).GetProperty("IsAndroid", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null), Is.False); + + /// + /// falls back to environment variables once the + /// configuration system has failed - which is what happens under Native AOT, where + /// System.Configuration is trimmed away. + /// + /// + /// + /// That failure cannot be provoked on a JIT runtime, so the latch that records it is flipped + /// directly, the same way reaches a non-public member. The environment + /// must stay untouched while the configuration system still works, otherwise a malformed + /// app.config would silently change where every setting comes from. + /// + /// + [Test] + [NonParallelizable] + public void GetAppSettingFallsBackToTheEnvironmentOnceConfigurationIsUnavailable() + { + const string Key = "log4net.Tests.AppSettingFallback"; + const string Value = "from-the-environment"; + + FieldInfo latch = AppSettingsUnavailableLatch(); + bool originalLatch = (bool)latch.GetValue(null)!; + Environment.SetEnvironmentVariable(Key, Value); + try + { + latch.SetValue(null, false); + Assert.That(SystemInfo.GetAppSetting(Key), Is.Null); + + latch.SetValue(null, true); + Assert.That(SystemInfo.GetAppSetting(Key), Is.EqualTo(Value)); + } + finally + { + latch.SetValue(null, originalLatch); + Environment.SetEnvironmentVariable(Key, null); + } + } + + /// + /// A key that is missing from the environment as well reads as , so the + /// fallback leaves callers with the same "no such setting" answer they get from a working + /// configuration system. + /// + [Test] + [NonParallelizable] + public void GetAppSettingReturnsNullForAnUnsetEnvironmentVariable() + { + FieldInfo latch = AppSettingsUnavailableLatch(); + bool originalLatch = (bool)latch.GetValue(null)!; + try + { + latch.SetValue(null, true); + Assert.That(SystemInfo.GetAppSetting("log4net.Tests.NoSuchSettingAnywhere"), Is.Null); + } + finally + { + latch.SetValue(null, originalLatch); + } + } + + /// + /// A configuration file that does not parse is reported, not routed to the environment - the + /// behaviour on every runtime that has a working configuration system is unchanged. + /// + [Test] + public void MalformedConfigurationIsNotTreatedAsAMissingConfigurationSystem() + => Assert.That(IsMissingConfigurationSystem(new ConfigurationErrorsException("malformed")), Is.False); + + /// + /// Native AOT surfaces a trimmed configuration system as a , + /// the same type a malformed file produces, so only the inner exception tells them apart. + /// + [Test] + public void TrimmedConfigurationSystemIsRecognisedThroughTheInnerException() + => Assert.That(IsMissingConfigurationSystem( + new ConfigurationErrorsException("Configuration system failed to initialize", + new MissingMethodException("No parameterless constructor defined for type 'System.Configuration.ClientConfigurationHost'."))), + Is.True); + + /// + /// A deployment without the System.Configuration.ConfigurationManager assembly fails on the + /// outermost exception rather than an inner one. + /// + [Test] + public void MissingConfigurationAssemblyIsRecognised() + => Assert.That(IsMissingConfigurationSystem(new FileNotFoundException("System.Configuration.ConfigurationManager")), Is.True); + + private static bool IsMissingConfigurationSystem(Exception exception) + { + MethodInfo method = typeof(SystemInfo).GetMethod("IsMissingConfigurationSystem", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SystemInfo.IsMissingConfigurationSystem no longer exists - update this test along with it."); + return (bool)method.Invoke(null, [exception])!; + } + + private static FieldInfo AppSettingsUnavailableLatch() + => typeof(SystemInfo).GetField("_configurationSystemUnavailable", BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SystemInfo._configurationSystemUnavailable no longer exists - update this test along with it."); } \ No newline at end of file diff --git a/src/log4net.Tests/log4net.Tests.csproj b/src/log4net.Tests/log4net.Tests.csproj index 1ce53b952..f9668c7a3 100644 --- a/src/log4net.Tests/log4net.Tests.csproj +++ b/src/log4net.Tests/log4net.Tests.csproj @@ -19,6 +19,7 @@ quackers + diff --git a/src/log4net/Appender/FileAppender.cs b/src/log4net/Appender/FileAppender.cs index 5b444afd2..993e2974e 100644 --- a/src/log4net/Appender/FileAppender.cs +++ b/src/log4net/Appender/FileAppender.cs @@ -19,6 +19,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.Serialization; using System.Text; @@ -838,14 +839,21 @@ public override void OnClose() /// /// Default locking model (when no locking model was configured) /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] private static Type _defaultLockingModelType = typeof(ExclusiveLock); /// /// Specify default locking model /// /// Type of LockingModel - public static void SetDefaultLockingModelType() - where TLockingModel : LockingModelBase + /// + /// + /// The locking model is created with , so the + /// new() constraint is what keeps its constructor alive in a trimmed or Native AOT build. + /// + /// + public static void SetDefaultLockingModelType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TLockingModel>() + where TLockingModel : LockingModelBase, new() => _defaultLockingModelType = typeof(TLockingModel); /// diff --git a/src/log4net/Config/BasicConfigurator.cs b/src/log4net/Config/BasicConfigurator.cs index ea7dfa684..2af06e672 100644 --- a/src/log4net/Config/BasicConfigurator.cs +++ b/src/log4net/Config/BasicConfigurator.cs @@ -73,7 +73,8 @@ public static class BasicConfigurator /// layout style. /// /// - public static ICollection Configure() => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); + public static ICollection Configure() + => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); /// /// Initializes the log4net system using the specified appenders. @@ -88,7 +89,7 @@ public static ICollection Configure(params IAppender[] appenders) { List configurationMessages = new(); - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { diff --git a/src/log4net/Config/RepositoryAttribute.cs b/src/log4net/Config/RepositoryAttribute.cs index fd5c6308e..5cba2d6ec 100644 --- a/src/log4net/Config/RepositoryAttribute.cs +++ b/src/log4net/Config/RepositoryAttribute.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; namespace log4net.Config; @@ -104,5 +105,6 @@ public RepositoryAttribute() /// repository. /// /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public Type? RepositoryType { get; set; } } \ No newline at end of file diff --git a/src/log4net/Config/XmlConfigurator.cs b/src/log4net/Config/XmlConfigurator.cs index fa060cb1d..e7ffd2db5 100644 --- a/src/log4net/Config/XmlConfigurator.cs +++ b/src/log4net/Config/XmlConfigurator.cs @@ -140,7 +140,7 @@ private static void InternalConfigure(ILoggerRepository repository, Func /// public static ICollection Configure() - => Configure(LogManager.GetRepository(Assembly.GetCallingAssembly())); + => Configure(LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback)); /// /// Configures log4net using a log4net element @@ -156,7 +156,7 @@ public static ICollection Configure(XmlElement element) { List configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { @@ -222,9 +222,11 @@ public static ICollection Configure(FileInfo configFile) { List configurationMessages = []; + Assembly repositoryAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; + using (new LogLog.LogReceivedAdapter(configurationMessages)) { - InternalConfigure(LogManager.GetRepository(Assembly.GetCallingAssembly()), configFile); + InternalConfigure(LogManager.GetRepository(repositoryAssembly), configFile); } return configurationMessages; @@ -248,7 +250,7 @@ public static ICollection Configure(Uri configUri) { List configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { InternalConfigure(repository, configUri); @@ -277,7 +279,7 @@ public static ICollection Configure(Stream configStream) { List configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { InternalConfigure(repository, configStream); @@ -644,7 +646,7 @@ public static ICollection ConfigureAndWatch(FileInfo configFile) { List configurationMessages = []; - ILoggerRepository repository = LogManager.GetRepository(Assembly.GetCallingAssembly()); + ILoggerRepository repository = LogManager.GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); using (new LogLog.LogReceivedAdapter(configurationMessages)) { diff --git a/src/log4net/Core/DefaultRepositorySelector.cs b/src/log4net/Core/DefaultRepositorySelector.cs index a14d6c820..406f11529 100644 --- a/src/log4net/Core/DefaultRepositorySelector.cs +++ b/src/log4net/Core/DefaultRepositorySelector.cs @@ -21,6 +21,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using log4net.Config; @@ -71,12 +72,13 @@ public class DefaultRepositorySelector : IRepositorySelector /// /// is . /// does not implement . - public DefaultRepositorySelector(Type defaultRepositoryType) + public DefaultRepositorySelector([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type defaultRepositoryType) { // Check that the type is a repository if (!typeof(ILoggerRepository).IsAssignableFrom(defaultRepositoryType.EnsureNotNull())) { - throw SystemInfo.CreateArgumentOutOfRangeException("defaultRepositoryType", defaultRepositoryType, $"Parameter: defaultRepositoryType, Value: [{defaultRepositoryType}] out of range. Argument must implement the ILoggerRepository interface"); + throw SystemInfo.CreateArgumentOutOfRangeException("defaultRepositoryType", defaultRepositoryType, + $"Parameter: defaultRepositoryType, Value: [{defaultRepositoryType}] out of range. Argument must implement the ILoggerRepository interface"); } this._defaultRepositoryType = defaultRepositoryType; @@ -175,7 +177,8 @@ public ILoggerRepository GetRepository(string repositoryName) /// /// /// is . - public ILoggerRepository CreateRepository(Assembly assembly, Type repositoryType) + public ILoggerRepository CreateRepository(Assembly assembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) => CreateRepository(assembly, repositoryType, DefaultRepositoryName, true); /// @@ -216,7 +219,9 @@ public ILoggerRepository CreateRepository(Assembly assembly, Type repositoryType /// /// /// is . - public ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type? repositoryType, string repositoryName, bool readAssemblyAttributes) + public ILoggerRepository CreateRepository(Assembly repositoryAssembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type? repositoryType, + string repositoryName, bool readAssemblyAttributes) { repositoryAssembly.EnsureNotNull(); @@ -305,7 +310,8 @@ public ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type? rep /// /// is . /// already exists. - public ILoggerRepository CreateRepository(string repositoryName, Type? repositoryType) + public ILoggerRepository CreateRepository(string repositoryName, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type? repositoryType) { repositoryName.EnsureNotNull(); @@ -479,7 +485,8 @@ protected virtual void OnLoggerRepositoryCreatedEvent(ILoggerRepository reposito /// in/out param to hold the repository name to use for the assembly, caller should set this to the default value before calling. /// in/out param to hold the type of the repository to create for the assembly, caller should set this to the default value before calling. /// is . - private void GetInfoForAssembly(Assembly assembly, ref string repositoryName, ref Type repositoryType) + private void GetInfoForAssembly(Assembly assembly, ref string repositoryName, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] ref Type repositoryType) { assembly.EnsureNotNull(); @@ -735,5 +742,7 @@ private void LoadAliases(Assembly assembly, ILoggerRepository repository) private readonly Dictionary _name2Repository = new(StringComparer.Ordinal); private readonly Dictionary _assembly2Repository = []; private readonly Dictionary _alias2Repository = new(StringComparer.Ordinal); + + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] private readonly Type _defaultRepositoryType; } \ No newline at end of file diff --git a/src/log4net/Core/IRepositorySelector.cs b/src/log4net/Core/IRepositorySelector.cs index 2ac3d52d8..30b85186d 100644 --- a/src/log4net/Core/IRepositorySelector.cs +++ b/src/log4net/Core/IRepositorySelector.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using log4net.Repository; @@ -127,7 +128,8 @@ public interface IRepositorySelector /// this association. /// /// - ILoggerRepository CreateRepository(Assembly assembly, Type repositoryType); + ILoggerRepository CreateRepository(Assembly assembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType); /// /// Creates a new repository with the name specified. @@ -142,7 +144,8 @@ public interface IRepositorySelector /// same name will return the same repository instance. /// /// - ILoggerRepository CreateRepository(string repositoryName, Type? repositoryType); + ILoggerRepository CreateRepository(string repositoryName, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type? repositoryType); /// /// Test if a named repository exists diff --git a/src/log4net/Core/LoggerManager.cs b/src/log4net/Core/LoggerManager.cs index 3ef057959..29176c800 100644 --- a/src/log4net/Core/LoggerManager.cs +++ b/src/log4net/Core/LoggerManager.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text; using log4net.Util; @@ -466,7 +467,8 @@ public static ILoggerRepository CreateRepository(string repository) /// /// /// The specified repository already exists. - public static ILoggerRepository CreateRepository(string repository, Type repositoryType) + public static ILoggerRepository CreateRepository(string repository, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) => RepositorySelector.CreateRepository(repository.EnsureNotNull(), repositoryType.EnsureNotNull()); /// @@ -484,7 +486,8 @@ public static ILoggerRepository CreateRepository(string repository, Type reposit /// same assembly specified will return the same repository instance. /// /// - public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type repositoryType) + public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) => RepositorySelector.CreateRepository(repositoryAssembly.EnsureNotNull(), repositoryType.EnsureNotNull()); /// diff --git a/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs new file mode 100644 index 000000000..54cda914c --- /dev/null +++ b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs @@ -0,0 +1,53 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +// inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/DynamicallyAccessedMemberTypes.cs + +#if !NET6_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Specifies the types of members that are dynamically accessed. +/// +/// +/// +/// The trimmer matches this type by its full name rather than by identity, so the values have to +/// keep the numbering the framework uses. Only the members log4net annotates with are declared; +/// add further ones from the runtime source above as they are needed. +/// +/// +[Flags] +internal enum DynamicallyAccessedMemberTypes +{ + /// + /// Specifies no members. + /// + None = 0, + + /// + /// Specifies the default, parameterless public constructor. + /// + PublicParameterlessConstructor = 0x0001, + + /// + /// Specifies all public constructors. + /// + PublicConstructors = 0x0002 | PublicParameterlessConstructor, +} +#endif diff --git a/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs new file mode 100644 index 000000000..421f56ca7 --- /dev/null +++ b/src/log4net/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs @@ -0,0 +1,49 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +// inspired by https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/CodeAnalysis/DynamicallyAccessedMembersAttribute.cs + +#if !NET6_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +/// +/// States which members of a are accessed dynamically, so that a trimmer keeps +/// them instead of removing them as unused. +/// +/// +/// +/// Neither net462 nor netstandard2.0 declares this attribute, but the trimmer +/// recognizes it by full name, so a library can supply its own and still be understood - which is +/// what lets log4net keep working when a consumer publishes with PublishAot or +/// PublishTrimmed. +/// +/// +/// The members that are dynamically accessed. +[AttributeUsage( + AttributeTargets.Field | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter + | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Method, + Inherited = false)] +internal sealed class DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) : Attribute +{ + /// + /// Gets the members that are dynamically accessed. + /// + public DynamicallyAccessedMemberTypes MemberTypes { get; } = memberTypes; +} +#endif diff --git a/src/log4net/Layout/PatternLayout.cs b/src/log4net/Layout/PatternLayout.cs index 29e64746a..d1bf32cd2 100644 --- a/src/log4net/Layout/PatternLayout.cs +++ b/src/log4net/Layout/PatternLayout.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.IO; @@ -820,84 +821,78 @@ public class PatternLayout : LayoutSkeleton /// This static map is overridden by the converterRegistry instance map /// /// - private static readonly Dictionary _sGlobalRulesRegistry = new(StringComparer.Ordinal) + private static readonly Dictionary _sGlobalRulesRegistry = CreateGlobalRulesRegistry(); + + /// + /// Builds the registry of built-in pattern converters. + /// + /// the built-in rules, keyed by the name used in a conversion pattern + /// + /// + /// The registry holds rather than a bare because a + /// put into a collection loses any + /// annotation, + /// which is what left these converters without a constructor once a Native AOT build had trimmed + /// them. carries the annotation, so it survives the round trip. + /// + /// + /// The new() constraint states the same requirement a second time, structurally: a + /// converter that loses its public parameterless constructor becomes a compile error here rather + /// than a run-time failure that only shows up in a trimmed build. + /// + /// + private static Dictionary CreateGlobalRulesRegistry() { - ["literal"] = typeof(LiteralPatternConverter), - ["newline"] = typeof(NewLinePatternConverter), - ["n"] = typeof(NewLinePatternConverter), + Dictionary rules = new(StringComparer.Ordinal); + + Add("literal"); + Add("newline", "n"); // .NET Standard has no support for ASP.NET #if NET462_OR_GREATER - ["aspnet-cache"] = typeof(AspNetCachePatternConverter), - ["aspnet-context"] = typeof(AspNetContextPatternConverter), - ["aspnet-request"] = typeof(AspNetRequestPatternConverter), - ["aspnet-session"] = typeof(AspNetSessionPatternConverter), + Add("aspnet-cache"); + Add("aspnet-context"); + Add("aspnet-request"); + Add("aspnet-session"); #endif - ["c"] = typeof(LoggerPatternConverter), - ["logger"] = typeof(LoggerPatternConverter), - - ["C"] = typeof(TypeNamePatternConverter), - ["class"] = typeof(TypeNamePatternConverter), - ["type"] = typeof(TypeNamePatternConverter), - - ["d"] = typeof(DatePatternConverter), - ["date"] = typeof(DatePatternConverter), - - ["exception"] = typeof(ExceptionPatternConverter), - - ["F"] = typeof(FileLocationPatternConverter), - ["file"] = typeof(FileLocationPatternConverter), - - ["l"] = typeof(FullLocationPatternConverter), - ["location"] = typeof(FullLocationPatternConverter), - - ["L"] = typeof(LineLocationPatternConverter), - ["line"] = typeof(LineLocationPatternConverter), - - ["m"] = typeof(MessagePatternConverter), - ["message"] = typeof(MessagePatternConverter), - - ["M"] = typeof(MethodLocationPatternConverter), - ["method"] = typeof(MethodLocationPatternConverter), - - ["p"] = typeof(LevelPatternConverter), - ["level"] = typeof(LevelPatternConverter), - - ["P"] = typeof(PropertyPatternConverter), - ["property"] = typeof(PropertyPatternConverter), - ["properties"] = typeof(PropertyPatternConverter), - - ["r"] = typeof(RelativeTimePatternConverter), - ["timestamp"] = typeof(RelativeTimePatternConverter), - - ["stacktrace"] = typeof(StackTracePatternConverter), - ["stacktracedetail"] = typeof(StackTraceDetailPatternConverter), - - ["t"] = typeof(ThreadPatternConverter), - ["thread"] = typeof(ThreadPatternConverter), + Add("c", "logger"); + Add("C", "class", "type"); + Add("d", "date"); + Add("exception"); + Add("F", "file"); + Add("l", "location"); + Add("L", "line"); + Add("m", "message"); + Add("M", "method"); + Add("p", "level"); + Add("r", "timestamp"); + Add("stacktrace"); + Add("stacktracedetail"); + Add("t", "thread"); // For backwards compatibility the NDC patterns - ["x"] = typeof(NdcPatternConverter), - ["ndc"] = typeof(NdcPatternConverter), + Add("x", "ndc"); // For backwards compatibility the MDC patterns just do a property lookup - ["X"] = typeof(PropertyPatternConverter), - ["mdc"] = typeof(PropertyPatternConverter), - - ["a"] = typeof(AppDomainPatternConverter), - ["appdomain"] = typeof(AppDomainPatternConverter), + Add("P", "property", "properties", "X", "mdc"); - ["u"] = typeof(IdentityPatternConverter), - ["identity"] = typeof(IdentityPatternConverter), + Add("a", "appdomain"); + Add("u", "identity"); + Add("utcdate", "utcDate", "UtcDate"); + Add("w", "username"); - ["utcdate"] = typeof(UtcDatePatternConverter), - ["utcDate"] = typeof(UtcDatePatternConverter), - ["UtcDate"] = typeof(UtcDatePatternConverter), + return rules; - ["w"] = typeof(UserNamePatternConverter), - ["username"] = typeof(UserNamePatternConverter), - }; + void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>( + params string[] names) where T : PatternConverter, new() + { + foreach (string name in names) + { + rules[name] = new() { Name = name, Type = typeof(T) }; + } + } + } /// /// the head of the pattern converter chain @@ -983,14 +978,9 @@ protected virtual PatternParser CreatePatternParser(string pattern) PatternParser patternParser = new(pattern); // Add all the builtin patterns - foreach (KeyValuePair entry in _sGlobalRulesRegistry) + foreach (KeyValuePair entry in _sGlobalRulesRegistry) { - ConverterInfo converterInfo = new() - { - Name = entry.Key, - Type = entry.Value - }; - patternParser.PatternConverters[entry.Key] = converterInfo; + patternParser.PatternConverters[entry.Key] = entry.Value; } // Add the instance patterns foreach (KeyValuePair entry in _instanceRulesRegistry) @@ -1093,7 +1083,8 @@ public void AddConverter(ConverterInfo converterInfo) /// type. /// /// - public void AddConverter(string name, Type type) => AddConverter(new() + public void AddConverter(string name, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type) => AddConverter(new() { Name = name.EnsureNotNull(), Type = type.EnsureNotNull() diff --git a/src/log4net/LogManager.cs b/src/log4net/LogManager.cs index cc255a540..36e958b86 100644 --- a/src/log4net/LogManager.cs +++ b/src/log4net/LogManager.cs @@ -71,7 +71,8 @@ public static class LogManager /// /// The fully qualified logger name to look for. /// The logger found, or if no logger could be found. - public static ILog? Exists(string name) => Exists(Assembly.GetCallingAssembly(), name); + public static ILog? Exists(string name) + => Exists(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name); /// Get the currently defined loggers. /// @@ -81,7 +82,8 @@ public static class LogManager /// The root logger is not included in the returned array. /// /// All the defined loggers. - public static ILog[] GetCurrentLoggers() => GetCurrentLoggers(Assembly.GetCallingAssembly()); + public static ILog[] GetCurrentLoggers() + => GetCurrentLoggers(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// Get or create a logger. /// @@ -101,7 +103,8 @@ public static class LogManager /// /// The name of the logger to retrieve. /// The logger with the name specified. - public static ILog GetLogger(string name) => GetLogger(Assembly.GetCallingAssembly(), name); + public static ILog GetLogger(string name) + => GetLogger(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, name); /// /// Returns the named logger if it exists. @@ -119,7 +122,8 @@ public static class LogManager /// The logger found, or if the logger doesn't exist in the specified /// repository. /// - public static ILog? Exists(string repository, string name) => WrapLogger(LoggerManager.Exists(repository, name)); + public static ILog? Exists(string repository, string name) + => WrapLogger(LoggerManager.Exists(repository, name)); /// /// Returns the named logger if it exists. @@ -137,7 +141,8 @@ public static class LogManager /// The logger, or if the logger doesn't exist in the specified /// assembly's repository. /// - public static ILog? Exists(Assembly repositoryAssembly, string name) => WrapLogger(LoggerManager.Exists(repositoryAssembly, name)); + public static ILog? Exists(Assembly repositoryAssembly, string name) + => WrapLogger(LoggerManager.Exists(repositoryAssembly, name)); /// /// Returns all the currently defined loggers in the specified repository. @@ -147,7 +152,8 @@ public static class LogManager /// The root logger is not included in the returned array. /// /// All the defined loggers. - public static ILog[] GetCurrentLoggers(string repository) => WrapLoggers(LoggerManager.GetCurrentLoggers(repository)); + public static ILog[] GetCurrentLoggers(string repository) + => WrapLoggers(LoggerManager.GetCurrentLoggers(repository)); /// /// Returns all the currently defined loggers in the specified assembly's repository. @@ -157,7 +163,8 @@ public static class LogManager /// The root logger is not included in the returned array. /// /// All the defined loggers. - public static ILog[] GetCurrentLoggers(Assembly repositoryAssembly) => WrapLoggers(LoggerManager.GetCurrentLoggers(repositoryAssembly)); + public static ILog[] GetCurrentLoggers(Assembly repositoryAssembly) + => WrapLoggers(LoggerManager.GetCurrentLoggers(repositoryAssembly)); /// /// Retrieves or creates a named logger. @@ -178,7 +185,8 @@ public static class LogManager /// The repository to lookup in. /// The name of the logger to retrieve. /// The logger with the name specified. - public static ILog GetLogger(string repository, string name) => WrapLogger(LoggerManager.GetLogger(repository, name))!; + public static ILog GetLogger(string repository, string name) + => WrapLogger(LoggerManager.GetLogger(repository, name))!; /// /// Retrieves or creates a named logger. @@ -211,7 +219,10 @@ public static ILog GetLogger(Assembly repositoryAssembly, string name) /// The full name of will be used as the name of the logger to retrieve. /// The logger with the name specified. public static ILog GetLogger(Type type) - => GetLogger(Assembly.GetCallingAssembly(), type.EnsureNotNull().FullName!); + => GetLogger(CallerAssembly.IsSupported + ? Assembly.GetCallingAssembly() + : CallerAssembly.Fallback, + type.EnsureNotNull().FullName!); /// /// Shorthand for . @@ -277,7 +288,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// and again to a nested appender. /// /// - public static void ShutdownRepository() => ShutdownRepository(Assembly.GetCallingAssembly()); + public static void ShutdownRepository() + => ShutdownRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// /// Shuts down the repository for the repository specified. @@ -299,7 +311,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The repository to shut down. - public static void ShutdownRepository(string repository) => LoggerManager.ShutdownRepository(repository); + public static void ShutdownRepository(string repository) + => LoggerManager.ShutdownRepository(repository); /// /// Shuts down the repository specified. @@ -323,7 +336,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The assembly to use to look up the repository. - public static void ShutdownRepository(Assembly repositoryAssembly) => LoggerManager.ShutdownRepository(repositoryAssembly); + public static void ShutdownRepository(Assembly repositoryAssembly) + => LoggerManager.ShutdownRepository(repositoryAssembly); /// Reset the configuration of a repository /// @@ -339,7 +353,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// message disabling is set to its default "off" value. /// /// - public static void ResetConfiguration() => ResetConfiguration(Assembly.GetCallingAssembly()); + public static void ResetConfiguration() + => ResetConfiguration(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// /// Resets all values contained in this repository instance to their defaults. @@ -371,7 +386,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The assembly to use to look up the repository to reset. - public static void ResetConfiguration(Assembly repositoryAssembly) => LoggerManager.ResetConfiguration(repositoryAssembly); + public static void ResetConfiguration(Assembly repositoryAssembly) + => LoggerManager.ResetConfiguration(repositoryAssembly); /// Get a logger repository. /// @@ -384,7 +400,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The instance for the default repository. - public static ILoggerRepository GetRepository() => GetRepository(Assembly.GetCallingAssembly()); + public static ILoggerRepository GetRepository() + => GetRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback); /// /// Returns the default instance. @@ -410,7 +427,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// /// /// The assembly to use to look up the repository. - public static ILoggerRepository GetRepository(Assembly repositoryAssembly) => LoggerManager.GetRepository(repositoryAssembly); + public static ILoggerRepository GetRepository(Assembly repositoryAssembly) + => LoggerManager.GetRepository(repositoryAssembly); /// Create a logger repository. /// @@ -427,7 +445,9 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// the same repository instance. /// /// - public static ILoggerRepository CreateRepository(Type repositoryType) => CreateRepository(Assembly.GetCallingAssembly(), repositoryType); + public static ILoggerRepository CreateRepository( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) + => CreateRepository(CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback, repositoryType); /// /// Creates a repository with the specified name. @@ -445,7 +465,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// The name of the repository, this must be unique amongst repositories. /// The created for the repository. /// The specified repository already exists. - public static ILoggerRepository CreateRepository(string repository) => LoggerManager.CreateRepository(repository); + public static ILoggerRepository CreateRepository(string repository) + => LoggerManager.CreateRepository(repository); /// /// Creates a repository with the specified name and repository type. @@ -462,7 +483,9 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// as the for the repository specified. /// The created for the repository. /// The specified repository already exists. - public static ILoggerRepository CreateRepository(string repository, Type repositoryType) => LoggerManager.CreateRepository(repository, repositoryType); + public static ILoggerRepository CreateRepository(string repository, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) + => LoggerManager.CreateRepository(repository, repositoryType); /// /// Creates a repository for the specified assembly and repository type. @@ -479,7 +502,9 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// and has a no arg constructor. An instance of this type will be created to act /// as the for the repository specified. /// The created for the repository. - public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, Type repositoryType) => LoggerManager.CreateRepository(repositoryAssembly, repositoryType); + public static ILoggerRepository CreateRepository(Assembly repositoryAssembly, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type repositoryType) + => LoggerManager.CreateRepository(repositoryAssembly, repositoryType); /// /// Gets the list of currently defined repositories. @@ -499,7 +524,8 @@ public static ILog GetLogger(Assembly repositoryAssembly, Type type) /// if all logging events were flushed successfully, else . public static bool Flush(int millisecondsTimeout) { - if (LoggerManager.GetRepository(Assembly.GetCallingAssembly()) is not IFlushable flushableRepository) + Assembly callerAssembly = CallerAssembly.IsSupported ? Assembly.GetCallingAssembly() : CallerAssembly.Fallback; + if (LoggerManager.GetRepository(callerAssembly) is not IFlushable flushableRepository) { return false; } diff --git a/src/log4net/Util/CallerAssembly.cs b/src/log4net/Util/CallerAssembly.cs new file mode 100644 index 000000000..424a5b1d1 --- /dev/null +++ b/src/log4net/Util/CallerAssembly.cs @@ -0,0 +1,74 @@ +#region Apache License +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to you under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +#endregion + +using System; +using System.Reflection; + +namespace log4net.Util; + +/// +/// Support for the calls that select the +/// of the caller. +/// +/// +/// +/// Native AOT does not implement - it throws +/// unconditionally, because the stack frames it +/// would have to walk no longer exist after compilation. Callers therefore have to test +/// and use instead. +/// +/// +/// The test cannot be hidden behind a helper that calls +/// itself: the calling assembly of such a helper is log4net, not the assembly that called log4net. +/// has to stay in the public method whose caller is +/// wanted, so this class only supplies the flag and the replacement value. +/// +/// +internal static class CallerAssembly +{ + /// + /// Whether works on the current runtime. + /// + internal static bool IsSupported { get; } = Probe(); + + /// + /// The assembly to attribute a call to when is . + /// + /// + /// + /// The entry assembly is the closest available stand-in: an application published with + /// Native AOT is self-contained, so its loggers would almost always have ended up in the + /// entry assembly's repository anyway. Hosts without a managed entry point fall back to + /// log4net itself, which yields the default repository. + /// + /// + internal static Assembly Fallback { get; } = Assembly.GetEntryAssembly() ?? typeof(CallerAssembly).Assembly; + + private static bool Probe() + { + try + { + return Assembly.GetCallingAssembly() is not null; + } + catch (PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/src/log4net/Util/ConverterInfo.cs b/src/log4net/Util/ConverterInfo.cs index 0a122f6ef..421354545 100644 --- a/src/log4net/Util/ConverterInfo.cs +++ b/src/log4net/Util/ConverterInfo.cs @@ -20,6 +20,7 @@ */ using System; +using System.Diagnostics.CodeAnalysis; namespace log4net.Util; @@ -42,6 +43,7 @@ public sealed class ConverterInfo /// /// Gets or sets the type of the converter. The type must extend . /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] public Type? Type { get; set; } /// diff --git a/src/log4net/Util/PatternString.cs b/src/log4net/Util/PatternString.cs index 568b272e6..7978c9566 100644 --- a/src/log4net/Util/PatternString.cs +++ b/src/log4net/Util/PatternString.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using log4net.Util.PatternStringConverters; @@ -258,29 +259,51 @@ public class PatternString : IOptionHandler /// /// Internal map of converter identifiers to converter types. /// - private static readonly Dictionary _sGlobalRulesRegistry = new(StringComparer.Ordinal) + private static readonly Dictionary _sGlobalRulesRegistry = CreateGlobalRulesRegistry(); + + /// + /// Builds the registry of built-in converters. + /// + /// the built-in rules, keyed by the name used in a pattern + /// + /// + /// Holds rather than a bare for the reason given + /// on 's registry: a in a collection loses + /// its trimmer annotation, and these converters are only ever created reflectively. + /// + /// + private static Dictionary CreateGlobalRulesRegistry() { // TODO - have added common variants of casing for utcdate and appsetting. // Wouldn't it be better to use a case-insensitive dictionary? - ["appdomain"] = typeof(AppDomainPatternConverter), - ["appsetting"] = typeof(AppSettingPatternConverter), - ["appSetting"] = typeof(AppSettingPatternConverter), - ["AppSetting"] = typeof(AppSettingPatternConverter), - ["date"] = typeof(DatePatternConverter), - ["env"] = typeof(EnvironmentPatternConverter), - ["envFolderPath"] = typeof(EnvironmentFolderPathPatternConverter), - ["identity"] = typeof(IdentityPatternConverter), - ["literal"] = typeof(LiteralPatternConverter), - ["newline"] = typeof(NewLinePatternConverter), - ["processid"] = typeof(ProcessIdPatternConverter), - ["property"] = typeof(PropertyPatternConverter), - ["random"] = typeof(RandomStringPatternConverter), - ["username"] = typeof(UserNamePatternConverter), - ["utcdate"] = typeof(UtcDatePatternConverter), - ["utcDate"] = typeof(UtcDatePatternConverter), - ["UtcDate"] = typeof(UtcDatePatternConverter), - }; + Dictionary rules = new(StringComparer.Ordinal); + + Add("appdomain"); + Add("appsetting", "appSetting", "AppSetting"); + Add("date"); + Add("env"); + Add("envFolderPath"); + Add("identity"); + Add("literal"); + Add("newline"); + Add("processid"); + Add("property"); + Add("random"); + Add("username"); + Add("utcdate", "utcDate", "UtcDate"); + + return rules; + + void Add<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>( + params string[] names) where T : PatternConverter, new() + { + foreach (string name in names) + { + rules[name] = new() { Name = name, Type = typeof(T) }; + } + } + } /// /// the head of the pattern converter chain @@ -379,14 +402,9 @@ private PatternParser CreatePatternParser(string pattern) PatternParser patternParser = new(pattern); // Add all the builtin patterns - foreach (KeyValuePair entry in _sGlobalRulesRegistry) + foreach (KeyValuePair entry in _sGlobalRulesRegistry) { - ConverterInfo converterInfo = new() - { - Name = entry.Key, - Type = entry.Value - }; - patternParser.PatternConverters.Add(entry.Key, converterInfo); + patternParser.PatternConverters.Add(entry.Key, entry.Value); } // Add the instance patterns foreach (KeyValuePair entry in _instanceRulesRegistry) @@ -461,7 +479,8 @@ public void AddConverter(ConverterInfo converterInfo) /// /// the name of the conversion pattern for this converter /// the type of the converter - public void AddConverter(string name, Type type) + public void AddConverter(string name, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type) { AddConverter(new() { diff --git a/src/log4net/Util/SystemInfo.cs b/src/log4net/Util/SystemInfo.cs index d5b9f9d39..d0c2647c6 100644 --- a/src/log4net/Util/SystemInfo.cs +++ b/src/log4net/Util/SystemInfo.cs @@ -22,6 +22,7 @@ using System.Reflection; using System.IO; using System.Collections; +using System.Runtime.CompilerServices; namespace log4net.Util; @@ -475,7 +476,9 @@ public static string AssemblyFileName(Assembly myAssembly) /// /// public static Type? GetTypeFromString(string typeName, bool throwOnError, bool ignoreCase) - => GetTypeFromString(Assembly.GetCallingAssembly(), typeName, throwOnError, ignoreCase); + => GetTypeFromString(CallerAssembly.IsSupported + ? Assembly.GetCallingAssembly() + : CallerAssembly.Fallback, typeName, throwOnError, ignoreCase); /// /// Loads the type specified in the type string. @@ -680,20 +683,90 @@ public static bool TryParse(string s, out short val) /// the value for the key, or public static string? GetAppSetting(string key) { - if (IsAndroid) - return Environment.GetEnvironmentVariable(key); // Android does not support config files + // Android does not support config files, and neither does a runtime that has trimmed the + // configuration system away. + if (IsAndroid || _configurationSystemUnavailable) + return Environment.GetEnvironmentVariable(key); try { - return ConfigurationManager.AppSettings[key]; + return ReadAppSetting(key); } catch (Exception e) when (!e.IsFatal()) { - // If an exception is thrown here then it looks like the config file does not parse correctly. + if (IsMissingConfigurationSystem(e)) + { + // There is no configuration system to read - Native AOT trims System.Configuration away. + // That is a property of the runtime rather than a fault, so it is not reported as an + // error, and the environment stands in for the config file as it does on Android. + _configurationSystemUnavailable = true; + LogLog.Debug(_declaringType, + "No configuration system on this runtime. Using environment variables for application settings.", e); + return Environment.GetEnvironmentVariable(key); + } + + // The config file itself does not parse. Report it and treat the setting as absent, without + // falling back to the environment - a broken config file must not silently change where + // settings come from. LogLog.Error(_declaringType, "Exception while reading ConfigurationSettings. Check your .config file is well formed XML.", e); } return null; } + /// + /// Determines whether means that there is no configuration system + /// on this runtime, as opposed to a configuration file that does not parse. + /// + /// the exception thrown while reading an application setting + /// if the configuration system itself is unavailable + /// + /// + /// The inner exceptions have to be walked, because Native AOT surfaces this as a + /// - the very type a malformed file produces. What + /// distinguishes it is further down the chain: a for + /// ClientConfigurationHost, whose constructor the trimmer removed. + /// + /// + /// An unrecognized failure is treated as a configuration file problem, which is the safer way + /// round: it is reported rather than silently swallowed. + /// + /// + private static bool IsMissingConfigurationSystem(Exception? exception) + { + for (; exception is not null; exception = exception.InnerException) + { + if (exception is MissingMethodException or TypeLoadException or FileNotFoundException + or PlatformNotSupportedException or NotSupportedException) + { + return true; + } + } + return false; + } + + /// + /// Reads a single application setting. + /// + /// the application settings key to lookup + /// the value for the key, or + /// + /// + /// Separate from , and never inlined into it, so that the failure to + /// resolve itself is raised on entry to this method - inside + /// the caller's try block - rather than on entry to , where nothing + /// would catch it and a would escape the static constructor + /// as a . + /// + /// + /// The package declares a dependency on System.Configuration.ConfigurationManager, so this only + /// arises where the assembly is deployed by other means than the package - it costs one method + /// to keep those deployments running instead of failing at type initialization. + /// + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static string? ReadAppSetting(string key) => ConfigurationManager.AppSettings[key]; + + private static bool _configurationSystemUnavailable; + /// /// Convert a path into a fully qualified local file path. /// diff --git a/src/log4net/Util/TypeConverters/ConverterRegistry.cs b/src/log4net/Util/TypeConverters/ConverterRegistry.cs index 03259584a..af8518966 100644 --- a/src/log4net/Util/TypeConverters/ConverterRegistry.cs +++ b/src/log4net/Util/TypeConverters/ConverterRegistry.cs @@ -18,6 +18,7 @@ #endregion using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Concurrent; namespace log4net.Util.TypeConverters; @@ -82,7 +83,8 @@ public static void AddConverter(Type? destinationType, object? converter) /// /// The type being converted to. /// The type of the type converter to use to convert to the destination type. - public static void AddConverter(Type destinationType, Type converterType) + public static void AddConverter(Type destinationType, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type converterType) => AddConverter(destinationType, CreateConverterInstance(converterType.EnsureNotNull())); /// @@ -187,7 +189,8 @@ public static void AddConverter(Type destinationType, Type converterType) /// and must have a public default (no argument) constructor. /// /// - private static object? CreateConverterInstance(Type converterType) + private static object? CreateConverterInstance( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type converterType) { // Check type is a converter if (typeof(IConvertFrom).IsAssignableFrom(converterType) || typeof(IConvertTo).IsAssignableFrom(converterType)) diff --git a/src/site/antora/modules/ROOT/nav.adoc b/src/site/antora/modules/ROOT/nav.adoc index bf81497b5..8aadc37df 100644 --- a/src/site/antora/modules/ROOT/nav.adoc +++ b/src/site/antora/modules/ROOT/nav.adoc @@ -42,6 +42,7 @@ **** xref:manual/configuration/appenders/udpappender.adoc[] *** xref:manual/configuration/filters.adoc[] *** xref:manual/configuration/layouts.adoc[] +** xref:manual/native-aot.adoc[] ** xref:manual/examples.adoc[] ** xref:manual/faq.adoc[] * xref:features.adoc[] diff --git a/src/site/antora/modules/ROOT/pages/manual/configuration.adoc b/src/site/antora/modules/ROOT/pages/manual/configuration.adoc index 6d300ea6e..4619fd13d 100644 --- a/src/site/antora/modules/ROOT/pages/manual/configuration.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/configuration.adoc @@ -21,6 +21,13 @@ The recommended way to configure log4net is through a configuration file. This section explains the structure of a configuration file and how log4net processes it. +[NOTE] +==== +Configuration files cannot be used in an application published with `PublishAot`, because the types +they name are removed by the trimmer. +See xref:manual/native-aot.adoc[] for how to configure log4net in code instead. +==== + [source,csharp] ---- using Animals.Carnivora; diff --git a/src/site/antora/modules/ROOT/pages/manual/native-aot.adoc b/src/site/antora/modules/ROOT/pages/manual/native-aot.adoc new file mode 100644 index 000000000..ff101412f --- /dev/null +++ b/src/site/antora/modules/ROOT/pages/manual/native-aot.adoc @@ -0,0 +1,196 @@ +//// + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +//// + +[#native-aot] += Native AOT and trimming + +log4net can be used from an application published with `PublishAot` or `PublishTrimmed`, with one +important restriction: **you have to configure log4net in code.** + +A Native AOT application is compiled ahead of time and trimmed, so any type that is only ever named +in a string is removed from the build. That is exactly how XML configuration works, which is why it +cannot be supported. + +[#configuring] +== Configuring in code + +Build the appenders and layouts yourself and hand them to +xref:manual/configuration.adoc[`BasicConfigurator`]. +Because you construct them with `new`, the compiler sees them and keeps them: + +[source,csharp] +---- +using log4net; +using log4net.Appender; +using log4net.Config; +using log4net.Core; +using log4net.Layout; + +ConsoleAppender appender = new() +{ + Layout = new PatternLayout("%level %logger - %message%newline"), + Threshold = Level.All, +}; +appender.ActivateOptions(); +BasicConfigurator.Configure(appender); + +ILog log = LogManager.GetLogger(typeof(Program)); +log.Info("Hello from Native AOT."); +---- + +Conversion patterns work as usual. The built-in pattern converters are resolved by name at run time, +but log4net declares them in a way the trimmer understands, so they are preserved for you. + +A custom converter is preserved as long as you register it by type: + +[source,csharp] +---- +PatternLayout layout = new(); +layout.AddConverter("mine", typeof(MyPatternConverter)); +layout.ConversionPattern = "%mine %message%newline"; +layout.ActivateOptions(); +---- + +[#levels-from-a-file] +== Reading levels from a configuration file + +log4net cannot read a configuration file under Native AOT, but *your application can*, and levels +can be set at any time through the API. That covers the common case of wanting to change verbosity +without rebuilding, without needing XML configuration. + +Put the levels wherever your application already keeps its settings: + +[source,json] +---- +{ + "Logging": { + "Default": "WARN", + "Loggers": { + "Noisy.Component": "ERROR", + "Important.Component": "DEBUG" + } + } +} +---- + +Read them yourself and apply them to the repository: + +[source,csharp] +---- +using System.Text.Json; +using log4net.Core; +using log4net.Repository.Hierarchy; + +Hierarchy hierarchy = (Hierarchy)LogManager.GetRepository(); + +using JsonDocument document = JsonDocument.Parse( + File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))); +JsonElement logging = document.RootElement.GetProperty("Logging"); + +// the level of the root logger, inherited by every logger that has none of its own +if (hierarchy.LevelMap[logging.GetProperty("Default").GetString()!] is Level rootLevel) +{ + hierarchy.Root.Level = rootLevel; +} + +// and levels for individual loggers +foreach (JsonProperty entry in logging.GetProperty("Loggers").EnumerateObject()) +{ + if (hierarchy.LevelMap[entry.Value.GetString()!] is Level loggerLevel) + { + ((Logger)hierarchy.GetLogger(entry.Name)).Level = loggerLevel; + } +} +---- + +With the settings above, `Important.Component` logs from `DEBUG` upwards, `Noisy.Component` only +`ERROR` and above, and every other logger inherits `WARN` from the root. + +[TIP] +==== +`LevelMap` returns `null` for a name it does not know, which is why both lookups are written as +`is Level`. An unrecognised name in the file then leaves the level unchanged rather than throwing. +Custom levels registered with `hierarchy.LevelMap.Add(...)` can be named in the file too. +==== + +[NOTE] +==== +`JsonDocument` is used here because it parses without reflection and is safe to trim. +`JsonSerializer.Deserialize()` is not, unless you generate a `JsonSerializerContext` for your +settings type. Any other format your application can already read works just as well - the point is +only that *your* code reads the file, not log4net's. +==== + +Levels can be changed whenever you like, so the same code can be run again to reload the file while +the application is running. + +[#unsupported] +== What does not work + +[IMPORTANT] +==== +xref:manual/configuration.adoc[XML configuration] - `XmlConfigurator.Configure()`, the +`log4net.config` file and the `` section of `app.config` - is **not available** under +Native AOT. Appender, layout and filter types are named as strings there, and the trimmer has no way +to know that they are needed. + +This is about *log4net* reading the file. Your application can still read a file of its own and +apply what it finds - see <>. +==== + +`ConfigurationManager` cannot initialize either, so log4net's own `appSettings` keys are read from +**environment variables** instead. To set them, use the same names you would have used in +`app.config`: + +[source,shell] +---- +log4net.NullText=NULL +log4net.NotAvailableText=N/A +---- + +[#repositories] +== Loggers and repositories + +`Assembly.GetCallingAssembly()` is not implemented by Native AOT. The log4net methods that infer a +repository from their caller - `LogManager.GetLogger(string)`, `LogManager.GetLogger(Type)`, +`LogManager.GetRepository()` and their siblings - therefore fall back to the entry assembly. + +For most applications this changes nothing, because there is a single default repository and both +answers lead to it. It matters only if you use **per-assembly repositories**, for example by placing + +[source,csharp] +---- +[assembly: log4net.Config.Repository("MyRepository")] +---- + +on a library. Under Native AOT that library's loggers are placed in the entry assembly's repository +rather than its own. + +If you depend on this, use the overloads that take the assembly explicitly. They are exact on every +runtime and need no special handling: + +[source,csharp] +---- +ILog log = LogManager.GetLogger(typeof(MyType).Assembly, typeof(MyType)); +---- + +[#warnings] +== Trimming warnings + +Publishing may report `IL3000` for `log4net.Util.SystemInfo.AssemblyLocationInfo`, because +`Assembly.Location` returns an empty string for an assembly embedded in a single-file application. +This affects the `%file`-style location information only; logging itself is unaffected.