Post

C#에서 AOT로 NLog사용하기

C#에서 AOT로 NLog사용하기

이전 글 C# AOT 라이브러리 활용에 이어 이번 글에서는 로그 시스템으로 많이 사용하는 NLog를 AOT (Ahead-of-Time)로 빌드하여 라이브러리로 사용하는 법을 알아본다.

실행파일과 같은 폴더에 AppLogs 폴더를 만들고 여기에 연월/날짜/로그타입별파일로 기록하고 디버그 모드로 빌드 후 실행 했을때 DebugViewPP로 실시간 로그를 볼 수 있도록 하였다.

기본 패키지의 리플랙션(Reflection)1을 피해서 작성해야 하므로 기본 사용법과 차이가 있을 수 있다. 참고로 Avalonia+SukiUi+MVVM로 구성된 프로젝트를 AOT로 빌드할 수 있도록 주요 패키지를 하나씩 옮겨보려고 한다.

LogHelper(AOT)
  • 프로젝트(LogHelper) 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<Project Sdk="Microsoft.NET.Sdk">  
    <PropertyGroup>  
        <TargetFramework>net10.0</TargetFramework>  
        <ImplicitUsings>disable</ImplicitUsings>  
        <Nullable>enable</Nullable>  
        <PublishAot>true</PublishAot>  
        <IsAotCompatible>true</IsAotCompatible>  
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>  
        <OptimizationPreference>Speed</OptimizationPreference>  
    </PropertyGroup>  
  
    <ItemGroup>  
        <PackageReference Include="NLog" Version="6.1.1"/>  
        <PackageReference Include="NLog.OutputDebugString" Version="6.1.1"/>  
    </ItemGroup>  
  
    <Target Name="CopyLogHelperDebug" AfterTargets="Publish">  
        <Copy Condition="'$(Configuration)' == 'Debug'"  
              SourceFiles="$(PublishDir)LogHelper.dll"  
              DestinationFiles="$(PublishDir)LogHelper.Debug.dll"  
              SkipUnchangedFiles="true"/>  
        <Copy Condition="'$(Configuration)' == 'Release'"  
              SourceFiles="$(PublishDir)LogHelper.dll"  
              DestinationFiles="$(PublishDir)LogHelper.Release.dll"  
              SkipUnchangedFiles="true"/>  
    </Target>  
</Project>
  • 소스 AOT(LogHelper.cs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
using NLog;
using NLog.Config;
using NLog.Targets;
using NLog.Targets.Wrappers;
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace LogHelper;

public static class LogHelper
{
    private static readonly Logger? _logger;

    private static readonly LogLevel[] _nLogLevels =
    [
        LogLevel.Trace,
        LogLevel.Info,
        LogLevel.Warn,
        LogLevel.Error,
        LogLevel.Fatal,
        LogLevel.Debug
    ];

    static LogHelper()
    {
        ISetupBuilder logFactory = LogManager.Setup().LoadConfiguration(builder =>
        {
            LoggingConfiguration config = builder.Configuration;
            const string layout =
                @"[${date:format=HH\:mm\:ss} ${level:uppercase=true:padding=5} : PID ${processid:Padding=5}] ${message} (${event-properties:File}::${event-properties:Member}[${event-properties:Line}][${threadid}])";

            builder.ForLogger().FilterMinLevel(LogLevel.Trace);
#if DEBUG
            OutputDebugStringTarget debugTarget = new("debug") { Layout = layout };
            LoggingRule debugRule = new("*", LogLevel.Trace, LogLevel.Fatal, debugTarget) { Final = false };
            config.LoggingRules.Add(debugRule);
#endif
            FileTarget fileTarget = new("FileTarget")
            {
                FileName = "${basedir}/AppLogs/${date:format=yyyyMM}/${date:format=dd}/${level}_${date:format=yyyyMMdd}.txt",
                Layout = layout,
                KeepFileOpen = true,
                MaxArchiveDays = 90
                // 선택사항
                ArchiveFileName = "${basedir}/AppLogs/${date:format=yyyyMM}/${date:format=dd}/${level}_${date:format=yyyyMMdd}.{#}.txt",
                ArchiveAboveSize = 10485760, // 10MB마다 새 파일 생성
                ArchiveEvery = FileArchivePeriod.Day
            };

            AsyncTargetWrapper asyncWrapper = new(fileTarget)
            {
                QueueLimit = 10000,
                OverflowAction = AsyncTargetWrapperOverflowAction.Discard,
                TimeToSleepBetweenBatches = 0
            };

            LoggingRule rule = new("*", LogLevel.Trace, LogLevel.Fatal, asyncWrapper) { Final = true };
            config.LoggingRules.Add(rule);
        });
        
        _logger = logFactory.GetLogger("NativeLogger");
    }

    [UnmanagedCallersOnly(EntryPoint = "LogWrite", CallConvs = [typeof(CallConvCdecl)])]
    public static void NativeLog_Write(int level, IntPtr msgPtr, IntPtr fileNamePtr, IntPtr memberPtr, int line)
    {
        try
        {
            LogLevel nLevel = (uint)level < (uint)_nLogLevels.Length ? _nLogLevels[level] : LogLevel.Trace;
            
            if (_logger != null && !_logger.IsEnabled(nLevel))
            {
                return;
            }

            if (msgPtr == IntPtr.Zero)
            {
                return;
            }
            
            string? msg = Marshal.PtrToStringUTF8(msgPtr);

            if (string.IsNullOrWhiteSpace(msg))
            {
                return;
            }

            string member = memberPtr != IntPtr.Zero ? Marshal.PtrToStringUTF8(memberPtr) ?? "Unknown" : "Unknown";
            string file = fileNamePtr != IntPtr.Zero ? GetFileNameWithoutExtension(fileNamePtr) : "Unknown";

            LogEventInfo logEvent = LogEventInfo.Create(nLevel, _logger?.Name, msg);
            logEvent.Properties["Member"] = member;
            logEvent.Properties["Line"] = line;
            logEvent.Properties["File"] = file;

            _logger?.Log(logEvent);
        }
        catch
        {
            // Native 영역으로 예외가 전파되지 않도록 차단 (Access Violation 방지)
        }
    }
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static unsafe string GetFileNameWithoutExtension(IntPtr ptr)
    {
        ReadOnlySpan<byte> utf8Span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)ptr);
        
        int lastSlash = utf8Span.LastIndexOf((byte)'\\');

        if (lastSlash == -1)
        {
            lastSlash = utf8Span.LastIndexOf((byte)'/');
        }

        if (lastSlash >= 0)
        {
            utf8Span = utf8Span[(lastSlash + 1)..];
        }
        
        int dot = utf8Span.LastIndexOf((byte)'.');

        if (dot >= 0)
        {
            utf8Span = utf8Span[..dot];
        }
        
        return System.Text.Encoding.UTF8.GetString(utf8Span);
    }
    
    [UnmanagedCallersOnly(EntryPoint = "LogShutdown")]
    public static void NativeLog_Shutdown()
    {
        try
        {
            LogManager.Flush(TimeSpan.FromSeconds(2));
            LogManager.Shutdown();
        }
        catch
        {
            // Native 영역으로 예외가 전파되지 않도록 차단
        }
    }
}

dotnet publish -r win-x64 -c Release, dotnet publish -r win-x64 -c DebugLogHelper.csproj 프로젝트 안에서 실행한 후 LogHelper.Release.dll, LogHelper.Debug.dll을 publish 폴더에서 복사하여 메인 프로그램에서 사용한다.

메인프로그램(콘솔)
  • 프로젝트 구성(NativeTest)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Project Sdk="Microsoft.NET.Sdk">  
    <PropertyGroup>  
        <OutputType>Exe</OutputType>  
        <TargetFramework>net10.0</TargetFramework>  
        <ImplicitUsings>disable</ImplicitUsings>  
        <Nullable>enable</Nullable>  
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>  
    </PropertyGroup>
    
    <!-- aot가 아닌 콘솔에서 테스트용도, aot만 불러오면 필요없음 -->  
    <ItemGroup>  
      <PackageReference Include="NLog" Version="6.1.1" />  
      <PackageReference Include="NLog.OutputDebugString" Version="6.1.1" />  
    </ItemGroup>  
</Project>
  • 메인프로그램 소스(Program.cs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System;  
using System.Threading.Tasks;  
  
namespace NativeTest;  
  
internal class Program  
{  
    private static async Task Main()  
    {  
        LogHelper.Info("info");  
        LogHelper.Warn("Warn");  
        LogHelper.Error("Error");  
        LogHelper.Fatal("Fatal");  
        LogHelper.Debug("Debug");  
        LogHelper.Trace("Trace");  
              
        Console.WriteLine("헬로우월드");  
        Console.WriteLine("종료 중...");  
          
        bool isLogClose = await Task.Run(()=>  
        {  
            LogHelper.Shutdown();  
            return true;  
        });  
          
        Console.WriteLine(isLogClose ? "로그 Flush 완료" : "로그 Flush 에러");  
          
        Console.WriteLine("프로그램 종료");  
    }  
}
  • 콘솔에서 사용하는 Helper클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace NativeTest;

public partial class LogHelper
{
#if DEBUG
    private const string DLL_NAME = "LogHelper.Debug.dll";
#else
    private const string DLL_NAME = "LogHelper.Release.dll";
#endif

    private static readonly bool _isNativeLoaded;

    static LogHelper()
    {
        _isNativeLoaded = NativeLibrary.TryLoad(DLL_NAME, Assembly.GetExecutingAssembly(), null, out _);
    }

    private enum LogLevel
    {
        TRACE = 0,
        INFO = 1,
        WARN = 2,
        ERROR = 3,
        FATAL = 4,
        DEBUG = 5
    }

    [LibraryImport(DLL_NAME, EntryPoint = "LogWrite", StringMarshalling = StringMarshalling.Utf8)]
    private static partial void LogWrite(int level, string msg, string file, string member, int line);

    [LibraryImport(DLL_NAME, EntryPoint = "LogShutdown")]
    private static partial void LogShutdown();

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static void Write(LogLevel level, string msg, string file, string member, int line)
    {
        if (!_isNativeLoaded)
        {
            return;
        }

        LogWrite((int)level, msg, file, member, line);
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void Trace(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
        => Write(LogLevel.TRACE, msg, f, m, l);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void Info(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
        => Write(LogLevel.INFO, msg, f, m, l);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void Warn(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
        => Write(LogLevel.WARN, msg, f, m, l);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void Error(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
        => Write(LogLevel.ERROR, msg, f, m, l);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void Fatal(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
        => Write(LogLevel.FATAL, msg, f, m, l);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static void Debug(string msg, [CallerFilePath] string f = "", [CallerMemberName] string m = "", [CallerLineNumber] int l = 0)
        => Write(LogLevel.DEBUG, msg, f, m, l);

    public static void Shutdown()
    {
        if (!_isNativeLoaded)
        {
            return;
        }

        try
        {
            LogShutdown();
        }
        catch
        {
            // 종료 시의 예외는 무시해도 안전한 경우가 많음
        }
    }
}

위의 소스는 AOT의 LogHelper가 아니고 콘솔에서 라이브러리를 사용하기 위한 헬퍼클래스 이다. 이름이 같으므로 혼동하지 말 것.

AOT없이 않고 직접 NLog 사용하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
using NLog;
using NLog.Config;
using NLog.Targets;
using NLog.Targets.Wrappers;
using System;
using System.Runtime.CompilerServices;

namespace NativeTest;

public static class LogTest
{
    private static readonly Logger _logger;
    private const string EX = " >> ";

    static LogTest()
    {
        LoggingConfiguration config = new();

        const string common_layout =
            @"[${date:format=HH\:mm\:ss} ${level:uppercase=true:padding=5} : PID ${processid:Padding=5}] ${message}${onexception:${exception:format=message}} (${callsite:className=true:includeNamespace=false:methodName=true}[${callsite-linenumber}][${threadid}])";

        const string log_folder = "${basedir}/AppLogs/${date:format=yyyyMM}/${date:format=dd}";

        FileTarget fileTarget = new("FileTarget")
        {
            FileName = log_folder + "/${level}_${date:format=yyyyMMdd}.txt",
            ArchiveFileName = log_folder + "/${level}_${date:format=yyyyMMdd}.{#}.txt",
            Layout = common_layout,
            Encoding = System.Text.Encoding.UTF8,
            KeepFileOpen = true,
            OpenFileCacheTimeout = 30,
            AutoFlush = false,
            BufferSize = 65536,
            ArchiveEvery = FileArchivePeriod.Day,
            MaxArchiveDays = 90,
            ArchiveAboveSize = 10485760
        };

        AsyncTargetWrapper asyncWrapper = new(fileTarget, 10000, AsyncTargetWrapperOverflowAction.Grow)
        {
            TimeToSleepBetweenBatches = 0
        };

        config.AddRule(LogLevel.Trace, LogLevel.Fatal, asyncWrapper);

#if DEBUG
        OutputDebugStringTarget debugOutput = new("debugOutput")
        {
            Layout = common_layout
        };
        AsyncTargetWrapper asyncDebug = new(debugOutput, 5000, AsyncTargetWrapperOverflowAction.Discard);
        config.AddRule(LogLevel.Trace, LogLevel.Fatal, asyncDebug);
#endif

        LogManager.Configuration = config;
        _logger = LogManager.GetCurrentClassLogger();
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void WriteLog(LogLevel level, string message, Exception? ex = null)
    {
        if (!_logger.IsEnabled(level))
        {
            return;
        }

        try
        {
            LogEventInfo logEvent = new(level, _logger.Name, message)
            {
                Exception = ex
            };

            _logger.Log(typeof(LogTest), logEvent);
        }
        catch
        {
            // ignored
        }
    }

    public static void Trace(string msg) => WriteLog(LogLevel.Trace, msg);

    public static void Debug(string msg) => WriteLog(LogLevel.Debug, msg);

    public static void Info(string msg) => WriteLog(LogLevel.Info, msg);

    public static void Warn(string msg) => WriteLog(LogLevel.Warn, msg);

    public static void Error(string msg) => WriteLog(LogLevel.Error, msg);

    public static void Error(Exception ex, string msg) => WriteLog(LogLevel.Error, msg + EX, ex);

    public static void Fatal(string msg) => WriteLog(LogLevel.Fatal, msg);

    public static void Fatal(Exception ex, string msg) => WriteLog(LogLevel.Fatal, msg + EX, ex);

    public static void Shutdown()
    {
        try
        {
            LogManager.Flush(TimeSpan.FromSeconds(2));
            LogManager.Shutdown();
        }
        catch
        {
            // ignored
        }
    }
}

위의 소스는 AOT가 아닌 프로젝트에서 바로 NLog를 사용할 때 필요한 소스코드이고 LogTest.Error("Error"); 또는 LogTest.Error(ex, "Error"); 형태로 필요한 곳에서 바로 사용한다. 즉, AOT용 하나, 메인에서 바로 사용하는 파일 하나 이렇게 2개로 예제를 작성한 것이다.

애플케애션 설정(App.axml.cs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public partial class App : Application  
{  
    protected override void OnStartup(StartupEventArgs e)  
    {  
        base.OnStartup(e);  
  
        // UI 스레드 예외  
        this.DispatcherUnhandledException += (s, args) =>  
        {  
            LogHelper.Critical($"[UI Error] {args.Exception.Message}\n{args.Exception.StackTrace}");  
            args.Handled = true; // 에러메시지 후 앱은 실행  
        };  
          
        //작업 스레드 및 도메인 예외(task.run)  
        AppDomain.CurrentDomain.UnhandledException += (s, args) =>  
        {  
            if (args.ExceptionObject is Exception ex)  
            {  
                LogHelper.Fatal($"[Fatal Error] {ex.Message}\n{ex.StackTrace}");  
            }  
  
            LogHelper.Shutdown();  
        };  
        
        // 비동기 Task 예외(await없는 task)        
        TaskScheduler.UnobservedTaskException += (s, args) =>  
        {  
            LogHelper.Error($"[Async Error] {args.Exception.Message}");  
            args.SetObserved(); // 앱 종료 방지  
        };  
    }  
	
	// private static async void OnClosing(object? sender, CancelEventArgs e)
    protected override void OnExit(ExitEventArgs e)  
    {  
        LogHelper.Shutdown();  
        base.OnExit(e);  
    }  
}

이 부분은 프로그램 만들 때 필수로 처리해야 하는 코드의 예이다.

Reference
  1. C#에서 리플랙션(Reflection)은 실행 중(Runtime)에 객체의 형식(Type), 메서드, 필드, 프로퍼티 등의 메타데이터를 조사하거나 조작할 수 있게 해주는 기능인데 컴파일 시점에 실행될 코드를 예측할 수 없어서 AOT에서는 피해야 하며 소스 생성기(Source Generators)를 통하여 빌드될 수 있도록 해야 한다. 주로 partial 키워드와 필요한 객체 위에 Attribute를 사용한다.
This post is licensed under CC BY 4.0 by the author.