imog

主にUnityとかの事を書いています

Unity LoggingでSinkを自作する

概要

2/29日にUnityプログラミング・バイブル R6号が出ます。 この本でUnity Loggingの使い方について執筆させていただきました。ぜひみんな買ってね。

Unityプログラミング・バイブル R6号

この本ではUnity Loggingについて執筆していましたが、ページ数の都合上いくつか削除した項目が存在します。 今回はその中で、自作Sinkの作り方についてちょっと記載していきます。

docs.unity3d.com

自分でSinkを作りたいというときに参考になれば幸いです。

ちなみにBurstの説明はそんなにないです。

Sinkを自作する

Sinkは用意された出力先だけでも強力ですが自作することもできます。ただし、自作する場合は以下の知識が必要です。

  • unsafeコードの理解
  • Burstの理解

本章では上記の詳細な解説は省略します。

Logの個数をカウントするSinkを自作してみる

今回は、簡単な例として「Logの個数をカウントするSink」を実装してみましょう。最終的な呼び出しは以下のようになります。

using Unity.Logging;
using Unity.Logging.Sinks;
using UnityEngine;

namespace LoggingSample.Sinks
{
    public class CustomSinkSample : MonoBehaviour
    {
        private void Start()
        {
            var config = new LoggerConfig()
                .SyncMode.FullSync()           // 非同期だとすぐに出力されないので同期
                .WriteTo.UnityEditorConsole()  // 動作確認のためにUnityEditorConsoleにも出力
                .WriteTo.CountLogger();
            Log.Logger = new Unity.Logging.Logger(config);
            Log.Info("foo");
            Log.Info("bar");
            Log.Info(CountSinkSystem.LogCounter); // => 2
        }
    }
}

SinkSystemBaseとSinkConfiguration

自作Sinkを作る場合、以下の3つの実装が必要です。

  • SinkConfigurationを継承したクラスの実装
  • SinkSystemBaseを継承したクラスの実装
  • 呼び出すための拡張メソッドの実装

Sinkは実際にログを受け取ったときの処理を行うSinkSystemBaseと、SinkSystemBaseの設定及びインスタンスの生成を行うSinkConfigurationの2つで構成されています。今回はCountSinkSystemという名前で実装してみましょう。

まずはConfigurationクラスの作成です。

using Unity.Collections;
using Unity.Logging;
using Unity.Logging.Sinks;

namespace LoggingSample.Sinks
{
    public class Configure : SinkConfiguration
    {
        public Configure(LoggerWriterConfig writeTo, FormatterStruct formatter, bool? captureStackTraceOverride = null, LogLevel? minLevelOverride = null, FixedString512Bytes? outputTemplateOverride = null)
            : base(writeTo, formatter, captureStackTraceOverride, minLevelOverride, outputTemplateOverride)
        {
        }

        public override SinkSystemBase CreateSinkInstance(Logger logger)
        {
            // CountSinkSystemのインスタンスを作成する
            return CreateAndInitializeSinkInstance<CountSinkSystem>(logger, this);
        }
    }
}

ConfigureでやるべきことはSinkSystemインスタンスの生成です。これは CreateSinkInstanceをオーバーライドする必要があります。インスタンスの生成は直接行うのではなく、baseに実装されている CreateAndInitializeSinkInstance<T>を呼び出します。今回はここに自作SinkであるCountSinkSystemを指定します。

次にCountSinkSystem本体を実装します。コンパイルが通る最低限の状態にすると以下のとおりです。

using System;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Logging;
using Unity.Logging.Sinks;

namespace LoggingSample.Sinks
{
    // SinkSystemBaseを継承する場合BurstCompile対応が必須になる
    [BurstCompile]
    public class CountSinkSystem : SinkSystemBase
    {
        public static int LogCounter;

        public class Configure : SinkConfiguration
        {
            public Configure(LoggerWriterConfig writeTo, FormatterStruct formatter, bool? captureStackTraceOverride = null, LogLevel? minLevelOverride = null, FixedString512Bytes? outputTemplateOverride = null)
                : base(writeTo, formatter, captureStackTraceOverride, minLevelOverride, outputTemplateOverride)
            {
            }

            public override SinkSystemBase CreateSinkInstance(Logger logger)
            {
                // CountSinkSystemのインスタンスを作成する
                return CreateAndInitializeSinkInstance<CountSinkSystem>(logger, this);
            }
        }

        public override LogController.SinkStruct ToSinkStruct()
        {
            var s = base.ToSinkStruct();
            // ログ取得時のデリゲートを設定する
            s.OnLogMessageEmit = new OnLogMessageEmitDelegate(OnLogMessageEmitFunc);
            return s;
        }

        public override void Initialize(Logger logger, SinkConfiguration systemConfig)
        {
            base.Initialize(logger, systemConfig);
        }

        /// <summary>
        /// ログメッセージが発行されたときに呼ばれるコールバック
        /// </summary>
        [BurstCompile]
        [AOT.MonoPInvokeCallback(typeof(OnLogMessageEmitDelegate.Delegate))]
        internal static void OnLogMessageEmitFunc(in LogMessage logEvent, ref FixedString512Bytes outTemplate, ref UnsafeText messageBuffer, IntPtr memoryManager, IntPtr userData, Allocator allocator)
        {
        }
    }
}

先程定義したConfigureCountSinkSystemのインナークラスにしています。これは、File SinkやJson Sinkなどデフォルトで用意されているSinkの実装方法に合わせているためです。

SinkSystemの実装にあたりやらなければならないことは以下の3つです。

  • クラスをBurstCompileに対応させる
  • ToSinkStructでログ取得のメッセージを受け取れるようにする
  • ログメッセージを受け取った際のデリゲートを実装する

大前提としてSinkはBurstCompilerによる最適化が行われているため、自作する場合もBurstCompile対応が必要です。そのためCountSinkSystemに[BurstCompile]属性を付与しています。

ToSinkStruct()はLogger側で扱うSinkに関する構造体SinkStructを返すメソッドです。ここで生成する構造体にはログを流すためのデリゲートOnLogMessageEmitが用意されており、ここに任意のメソッドを渡すことで実際のSink側の処理を実現できます。今回だと「CountSinkSystem.LogCounterを加算する」というのが詳細なロジックになります。

OnLogMessageEmitFuncはログ情報を受け取って実際の処理を行うデリゲートです。

[BurstCompile]
[AOT.MonoPInvokeCallback(typeof(OnLogMessageEmitDelegate.Delegate))]
internal static void OnLogMessageEmitFunc(in LogMessage logEvent, ref FixedString512Bytes outTemplate, ref UnsafeText messageBuffer, IntPtr memoryManager, IntPtr userData, Allocator allocator)
{
}

ここで加算処理を行えると良いのですが、BurstCompileを行う場合readonlyではないstatic変数にアクセスができないためLogCounterはこのメソッド内では直接更新ができません。

つまり、Burstの世界だけだとできることが限られてしまうので、このメソッドから先はManaged C#の世界と繋いで処理を委譲させることになります。Unity Loggingパッケージは内部で Burst2ManagedCallというクラスを定義しており、BurstとManaged C#をFunctionPointerで繋いでいます。このクラスはinternalで外部からアクセスできないので、実装を参考にして定義します。

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Unity.Burst;

namespace LoggingSample.Sinks
{
    /// <summary>
    /// BurstからManagedコードを呼び出すためのデリゲートを管理するクラス
    /// </summary>
    internal static class Burst2ManagedCall<T, Key>
    {
        private static T _delegate;
        private static readonly SharedStatic<FunctionPointer<T>> _sharedStatic = SharedStatic<FunctionPointer<T>>.GetOrCreate<FunctionPointer<T>, Key>(16);
        public static bool IsCreated => _sharedStatic.Data.IsCreated;

        public static void Init(T @delegate)
        {
            CheckIsNotCreated();
            _delegate = @delegate;
            _sharedStatic.Data = new FunctionPointer<T>(Marshal.GetFunctionPointerForDelegate(_delegate));
        }

        public static ref FunctionPointer<T> Ptr()
        {
            CheckIsCreated();
            return ref _sharedStatic.Data;
        }

        [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS"), Conditional("UNITY_DOTS_DEBUG")]
        private static void CheckIsCreated()
        {
            if (IsCreated == false)
                throw new InvalidOperationException("Burst2ManagedCall was NOT created!");
        }

        [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS"), Conditional("UNITY_DOTS_DEBUG")]
        private static void CheckIsNotCreated()
        {
            if (IsCreated)
                throw new InvalidOperationException("Burst2ManagedCall was already created!");
        }
    }
}

次に、カウントを加算するだけのクラス CountLogWrapperを定義します。

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Logging;
using Unity.Logging.Sinks;

namespace LoggingSample.Sinks
{
    /// <summary>
    /// ログが出力された回数をカウントするクラス
    /// </summary>
    internal static class CountLogWrapper
    {
        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        internal delegate void WriteDelegate();

        private struct CountLogWrapperKey
        {
        }

        private static bool _initialized;

        internal static void Initialize()
        {
            if (_initialized) return;
            _initialized = true;
            // デリゲートを登録する
            Burst2ManagedCall<WriteDelegate, CountLogWrapperKey>.Init(WriteFunc);
        }

        [AOT.MonoPInvokeCallback(typeof(WriteDelegate))]
        private static void WriteFunc()
        {
            // カウント処理
            CountSinkSystem.LogCounter++;
        }

        public static void Write()
        {
            var ptr = Burst2ManagedCall<WriteDelegate, CountLogWrapperKey>.Ptr();
#if LOGGING_USE_UNMANAGED_DELEGATES
            ((delegate * unmanaged[Cdecl] <LogLevel, byte*, int, void>)ptr.Value)(level, data, length);
#else
            ptr.Invoke();
#endif
        }
    }
}

WriteFuncが実際の加算処理となっており、これをBurst2ManagedCallに登録してWriteメソッド内でFunctionPointerとして取得、及び呼び出すことでBurst上でも呼び出せるようにしています。

あとは外部からCountLogWrapper.Initializeを呼び出して登録をしなければならないので、CountSinkSystem側で登録します。

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Logging;
using Unity.Logging.Sinks;

namespace LoggingSample.Sinks
{
    // SinkSystemBaseを継承する場合BurstCompile対応が必須になる
    [BurstCompile]
    public class CountSinkSystem : SinkSystemBase
    {
        public static int LogCounter;

        public class Configure : SinkConfiguration
        {
            public Configure(LoggerWriterConfig writeTo, FormatterStruct formatter, bool? captureStackTraceOverride = null, LogLevel? minLevelOverride = null, FixedString512Bytes? outputTemplateOverride = null)
                : base(writeTo, formatter, captureStackTraceOverride, minLevelOverride, outputTemplateOverride)
            {
            }

            public override SinkSystemBase CreateSinkInstance(Logger logger)
            {
                // CountSinkSystemのインスタンスを作成する
                return CreateAndInitializeSinkInstance<CountSinkSystem>(logger, this);
            }
        }

        public override LogController.SinkStruct ToSinkStruct()
        {
            var s = base.ToSinkStruct();
            // ログ取得時のデリゲートを設定する
            s.OnLogMessageEmit = new OnLogMessageEmitDelegate(OnLogMessageEmitFunc);
            return s;
        }

        public override void Initialize(Logger logger, SinkConfiguration systemConfig)
        {
            // インスタンスの初期化時にログのカウント回数も初期化する
            CountLogWrapper.Initialize();
            base.Initialize(logger, systemConfig);
        }

        /// <summary>
        /// ログメッセージが発行されたときに呼ばれるコールバック
        /// </summary>
        [BurstCompile]
        [AOT.MonoPInvokeCallback(typeof(OnLogMessageEmitDelegate.Delegate))]
        internal static void OnLogMessageEmitFunc(in LogMessage logEvent, ref FixedString512Bytes outTemplate, ref UnsafeText messageBuffer, IntPtr memoryManager, IntPtr userData, Allocator allocator)
        {
            try
            {
                // BurstCompileに対応させる場合readonlyではないstatic変数にアクセスできないため、
                // CountLogWrapperを経由してカウントする
                CountLogWrapper.Write();
            }
            finally
            {
                messageBuffer.Length = 0;
            }
        }
    }
}

OnLogMessageEmitFuncではWriteを呼び出すだけですが、これでログが流れるたびにカウントされるようになりました。

あとは、このSinkを有効にできるような拡張メソッドCountLoggerを定義します。

using Unity.Logging;
using Unity.Logging.Sinks;

namespace LoggingSample.Sinks
{
    // WriteTo.CountLogger()のように呼び出せるようにするための拡張メソッド
    public static class CountLoggerExtensions
    {
        public static LoggerConfig CountLogger(this LoggerWriterConfig writeTo, LogLevel? minLevel = null)
        {
            return writeTo.AddSinkConfig(new CountSinkSystem.Configure(writeTo, default, false, minLevel));
        }
    }
}

AddSinkConfigはSink設定を追加するメソッドです。ここでConfigureを生成することで新たにSinkを追加できます。 ちなみにSinkの拡張は、対応するExtensionsクラスを作って拡張メソッドを追加していくのが慣例となっています。

最終的なコードは以下の通りです。

using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Logging;
using Unity.Logging.Sinks;


namespace LoggingSample.Sinks
{
    public static class CountLoggerExtensions
    {
        // WriteTo.CountLogger()のように呼び出せるようにするための拡張メソッド
        public static LoggerConfig CountLogger(this LoggerWriterConfig writeTo, LogLevel? minLevel = null)
        {
            return writeTo.AddSinkConfig(new CountSinkSystem.Configure(writeTo, default, false, minLevel));
        }
    }

    // SinkSystemBaseを継承する場合BurstCompile対応が必須になる
    [BurstCompile]
    public class CountSinkSystem : SinkSystemBase
    {
        public static int LogCounter;

        public class Configure : SinkConfiguration
        {
            public Configure(LoggerWriterConfig writeTo, FormatterStruct formatter, bool? captureStackTraceOverride = null, LogLevel? minLevelOverride = null, FixedString512Bytes? outputTemplateOverride = null)
                : base(writeTo, formatter, captureStackTraceOverride, minLevelOverride, outputTemplateOverride)
            {
            }

            public override SinkSystemBase CreateSinkInstance(Logger logger)
            {
                // CountSinkSystemのインスタンスを作成する
                return CreateAndInitializeSinkInstance<CountSinkSystem>(logger, this);
            }
        }

        public override LogController.SinkStruct ToSinkStruct()
        {
            var s = base.ToSinkStruct();
            // ログ取得時のデリゲートを設定する
            s.OnLogMessageEmit = new OnLogMessageEmitDelegate(OnLogMessageEmitFunc);
            return s;
        }

        public override void Initialize(Logger logger, SinkConfiguration systemConfig)
        {
            // インスタンスの初期化時にログのカウント回数も初期化する
            CountLogWrapper.Initialize();
            base.Initialize(logger, systemConfig);
        }

        /// <summary>
        /// ログメッセージが発行されたときに呼ばれるコールバック
        /// </summary>
        [BurstCompile]
        [AOT.MonoPInvokeCallback(typeof(OnLogMessageEmitDelegate.Delegate))]
        internal static void OnLogMessageEmitFunc(in LogMessage logEvent, ref FixedString512Bytes outTemplate, ref UnsafeText messageBuffer, IntPtr memoryManager, IntPtr userData, Allocator allocator)
        {
            try
            {
                // BurstCompileに対応させる場合readonlyではないstatic変数にアクセスできないため、
                // CountLogWrapperを経由してカウントする
                CountLogWrapper.Write();
            }
            finally
            {
                messageBuffer.Length = 0;
            }
        }
    }

    /// <summary>
    /// ログが出力された回数をカウントするクラス
    /// </summary>
    internal static class CountLogWrapper
    {
        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        internal delegate void WriteDelegate();

        private struct CountLogWrapperKey
        {
        }

        private static bool _initialized;

        internal static void Initialize()
        {
            if (_initialized) return;
            _initialized = true;
            // デリゲートを登録する
            Burst2ManagedCall<WriteDelegate, CountLogWrapperKey>.Init(WriteFunc);
        }

        [AOT.MonoPInvokeCallback(typeof(WriteDelegate))]
        private static void WriteFunc()
        {
            // カウント処理
            CountSinkSystem.LogCounter++;
        }

        public static void Write()
        {
            var ptr = Burst2ManagedCall<WriteDelegate, CountLogWrapperKey>.Ptr();
#if LOGGING_USE_UNMANAGED_DELEGATES
            ((delegate * unmanaged[Cdecl] <LogLevel, byte*, int, void>)ptr.Value)(level, data, length);
#else
            ptr.Invoke();
#endif
        }
    }

    /// <summary>
    /// BurstからManagedコードを呼び出すためのデリゲートを管理するクラス
    /// </summary>
    internal static class Burst2ManagedCall<T, Key>
    {
        private static T _delegate;
        private static readonly SharedStatic<FunctionPointer<T>> _sharedStatic = SharedStatic<FunctionPointer<T>>.GetOrCreate<FunctionPointer<T>, Key>(16);
        public static bool IsCreated => _sharedStatic.Data.IsCreated;

        public static void Init(T @delegate)
        {
            CheckIsNotCreated();
            _delegate = @delegate;
            _sharedStatic.Data = new FunctionPointer<T>(Marshal.GetFunctionPointerForDelegate(_delegate));
        }

        public static ref FunctionPointer<T> Ptr()
        {
            CheckIsCreated();
            return ref _sharedStatic.Data;
        }

        [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS"), Conditional("UNITY_DOTS_DEBUG")]
        private static void CheckIsCreated()
        {
            if (IsCreated == false)
                throw new InvalidOperationException("Burst2ManagedCall was NOT created!");
        }

        [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS"), Conditional("UNITY_DOTS_DEBUG")]
        private static void CheckIsNotCreated()
        {
            if (IsCreated)
                throw new InvalidOperationException("Burst2ManagedCall was already created!");
        }
    }
}