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.