1
3

[C#] TOMLパーサー/シリアライザーライブラリ CsToml で使用している機能

Last updated at Posted at 2024-09-16

はじめに

C#の新しいバージョンでの記法や.NET Coreおよび.NET5以降で追加された機能をあまり使ってこなかったので、学習も兼ねてこれらを使用したライブラリを作成しようと思ったのがきっかけになり、高速かつメモリアロケーションを抑えたTOMLパーサー/シリアライザーライブラリCsTomlを作成しました。
本記事では、(基本的な内容も含めて)CsTomlの実装時に使用した機能やテクニックの一部を紹介します。

TOMLとは

TOMLは以下のようなものを目指した設定ファイル形式になります。
詳細仕様は引用先のTOML公式サイトに記載されているので、こちらを確認してもらえばと思います。

TOML は、明白なセマンティクスによって読みやすい最小限の設定ファイルフォーマットとなることを目的につくられました。TOML は、ハッシュテーブルに一義的に対応するように設計されていて、さまざまな言語のデータ構造に展開できます。
TOML v1.0.0(日本語)より引用

CsToml(TOMLパーサー/シリアライザー ライブラリ)

TOML v1.0.0をサポートしているTOMLパーサー/シリアライザー ライブラリになります。
.NET 8 および C# 12で実装しており、またSystem.Buffersで定義されたAPIなどを用いていることで、高速かつメモリアロケーションをなるべく減らしていることで、高パフォーマンスを実現しています。
またパース/デシリアライズ時には、stringではなくReadOnlySpan<byte>ReadOnlySequence<byte>を入力値に採用しています。

ReadOnlySpan<byte> tomlText = @"
Key = ""value""
Number = 123
Array = [1, 2, 3]

[Table]
Key = ""value""
Number = 123
"u8;

// UTF8(ReadOnlySpan<byte>)から解析する
var document = CsTomlSerializer.Deserialize<TomlDocument>(tomlText);

UTF-8 の文字列リテラル

C# 11からu8サフィックスをつけることでUTF8文字列として作成することが可能になりました。
TOMLは、文字コードがUTF8と決まっているため、stringで読み込むよりもReadOnlySpan<byte>として読み込み後にパースできたほうが効率的ではないかと思いましたが、stringと同じように作成できないので、利便性が悪くなると思い、開発当初はどうするか悩んでいました。
この機能によって、stringEncoding.UTF8.GetString等でデコードすることなく、C#で簡単にUTF8文字列が使用できるようになったため、CsTomlではReadOnlySpan<byte>ReadOnlySequence<byte>を入力値として採用しました。
※ この機能を見つけたときにTOMLパーサー/シリアライザーを作成しようと決意しました。

// u8をつけることでUTF8(ReadOnlySpan<byte>)として作成可能
ReadOnlySpan<byte> tomlText = @"
Key = ""value""
Number = 123
Array = [1, 2, 3]

[Table]
Key = ""value""
Number = 123
"u8;

ArrayPool<T>.Shared

.NETで用意されている配列を再利用できるバッファリソースプールになります。
CsTomlでは、書き込み用の作業バッファ管理クラスであるArrayPoolBufferWriterで使用しています。
TOMLフォーマットの文字列(UTF8文字列)からキーや値をパースする際、基本的には切り出したReadOnlySpan<byte>を直接パースすることでメモリアロケーションを減らしていますが、いくつかのケースでは難しい時がありました。
例えば、TOMLの基本文字列では以下のようなエスケープシーケンスが使用できます。切り出したReadOnlySpan<byte>にこれらのエスケープシーケンスが含まれている場合、そのままパースすることができないため、変換する必要がありました。

# TOMLの文字列のエスケープシーケンス
\b         - backspace       (U+0008)
\t         - tab             (U+0009)
\n         - linefeed        (U+000A)
\f         - form feed       (U+000C)
\r         - carriage return (U+000D)
\"         - quote           (U+0022)
\\         - backslash       (U+005C)
\uXXXX     - unicode         (U+XXXX)
\UXXXXXXXX - unicode         (U+XXXXXXXX)

TOML v1.0.0 文字列より引用

他にもTOMLフォーマットの文字列をReadOnlySpan<byte>ではなくbyteシーケンス(SequenceReader<byte>とほぼ同等のもの)として保持しているため、ReadOnlySpan<byte>として切り出せない場合がありました。
その場合には、書き込み用バッファに1byteずつ書き込みした後に、バッファ経由で値にパースするといった形でなるべく新規配列を作成しないようにすることで、メモリアロケーションを減らすようにしています。

// CsTomlReader.ReadBool:ブール値のパース処理

internal TomlBoolean ReadBool(bool predictedValue)
{
    var length = predictedValue ? 4 : 5;
    TomlBoolean value = default!;

    // ReadOnlySpan<byte>として切り出す。
    if (sequenceReader.TryFullSpan(length, out var bytes))
    {
        value = TomlBoolean.Parse(bytes);
    }
    else
    {
        // 切り出せない場合には、RecycleArrayPoolBufferWriter<byte>.Rentから得られた
        // ArrayPoolBufferWriterに1byteずつ書き込み、書き込みしたバッファ経由でパースする。
        var bufferWriter = RecycleArrayPoolBufferWriter<byte>.Rent();
        try
        {
            if (sequenceReader.TryGetbytes(length, bufferWriter))
            {
                value = TomlBoolean.Parse(bufferWriter.WrittenSpan);
            }
            else
            {
                ExceptionHelper.ThrowEndOfFileReached();
            }
        }
        finally
        {
            RecycleArrayPoolBufferWriter<byte>.Return(bufferWriter);
        }
    }

    ...
}

IUtf8SpanParsable<T>.TryParse

.NET8にてReadOnlySpan<byte>から値への変換を可能にしたIUtf8SpanParsable<T>インタフェースが追加されました。
CsTomlでは、TomlFloat.Parse等でdoubleにパースする際に使用しています。
doubleにもこのインターフェイスは実装されているため、自前でロジックを組みことなくdoubleに変換することができて、さらに直接変換することによりメモリアロケーションも減らすことができました。

    public static TomlFloat Parse(ReadOnlySpan<byte> bytes)
    {
...
        // UTF8から直接doubleに変換を試みます。
        if (double.TryParse(bytes, out var value))
        {
            return new TomlFloat(value);
        }

        ExceptionHelper.ThrowIncorrectTomlFloatFormat();
        return default!;
    }

整数の2進数、8進数、16進数の判別方法

TOMLではプレフィックスにそれぞれ0b0o0xがついている場合、2進数、8進数、16進数の整数として表現できます。
ReadOnlySpan<byte>.SequenceEqualの同値比較でも問題ないですが、プレフィックスがそれぞれ決まっているので、ReadOnlySpan<byte>から直接shortに変換し、数値比較で判定するようにしています。

// TomlInteger.Parse:ReadOnlySpan<byte>からTomlIntegerへパースする処理

    public static TomlInteger Parse(ReadOnlySpan<byte> bytes)
    {
        // hexadecimal, octal, or binary
        if (bytes.Length > 2)
        {
            var prefix = Unsafe.ReadUnaligned<short>(ref MemoryMarshal.GetReference<byte>(bytes));
            switch (prefix)
            {
                case 25136: //0b:binary
                    return TomlInteger.Create(ParseBinary(bytes[2..]));
                case 28464: //0o:octal
                    return TomlInteger.Create(ParseOctal(bytes[2..]));
                case 30768: //0x:hexadecimal
                    return TomlInteger.Create(ParseHex(bytes[2..]));
            }
        }

        // decimal
    }

この判定手法は、R3MessagePackなどのハイパフォーマンスなライブラリを開発されているneueccさんがCEDEC 2023 モダンハイパフォーマンスC# 2023 Editionで紹介された方法を流用しています。
こちらを含めてneueccさんが発表されている内容は興味深く勉強になるので、ぜひ確認することをお勧めします!

一部TOML値のキャッシュ

CsTomlでは、TomlValueクラスを継承したクラス(TomlIntegerTomlBoolean)に値を保持しています。
基本的にReadOnlySpan<byte>から値にパースした後、これらのクラスにラップする形で作成していますが、ブール値や一部の整数(-1から8)については、新規作成ではなくキャッシュされたTomlIntegerTomlBooleanを割り当てることで、インスタンス作成のコストなどを回避しています。

// TomlIntegerのキャッシュ処理

internal sealed partial class TomlInteger : TomlValue
{
    internal static readonly TomlInteger[] cache = CreateCacheValue();

    private static TomlInteger[] CreateCacheValue()
    {
        // -1から8のTomlIntegerを作成する
        var intCacheValues = new TomlInteger[10];
        for (int i = 0; i < intCacheValues.Length; i++)
        {
            intCacheValues[i] = new TomlInteger(i - 1);
        }

        return intCacheValues;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static TomlInteger Create(long value)
    {
        // -1から8の場合には、キャッシュを返却する
        if ((ulong)(value + 1) < (ulong)cache.Length)
        {
            return cache[value + 1];
        }
        return new TomlInteger(value);
    }

    ...
}

一部の値のみキャッシュを返す手法は、.NET6から変更されたTask.FromResult<TResult>の破壊的変更を知ったときに、インスタンス作成のコスト削減に活用できると思い、CsTomlでも同じ手法を使用しています。

あとがき

最新機能ではない機能も紹介していますが、私自身はCsTomlを実装するまであまり使用したことがないもしくは知らなかった機能がほとんどでした。
上記以外にも色々な機能等を使用していますが、今回はこれくらいで終わろうと思います。
何か間違いなどあれば、コメントで情報提供よろしくお願いします。
CsTomlについても、興味があればぜひ使用していただければと思います。

参考・引用文献

  1. TOML v1.0.0(日本語)
  2. UTF-8 の文字列リテラル
  3. ArrayPool.Shared プロパティ
  4. IUtf8SpanParsable インターフェイス
  5. CEDEC 2023 モダンハイパフォーマンスC# 2023 Edition
  6. Task.FromResult でシングルトンを返すことができる
1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3