StructureOfArraysGenerator - C#でSoAを簡単に利用するためのSource Generator

最近はSource Generatorブームが続いていて、去年末に2022年のC# (Incremental) Source Generator開発手法という記事を出しましたが、まずは今年第一弾のSource Generatorライブラリです。

これは何かというと、structure of arrays(SoA)を使いやすくするためのコードを生成するというものです。まずそもそもSoAですが、WikipediaのAoS and SoAという記事によるところ(日本語版はない)、CPUキャッシュを有効活用したりSIMDを適用させやすくなる構造だよ、と。通常C#の配列はarray of structures(AoS)になります。

上の通常の配列がAoSでXYZXYZXYZXYZといったように並んでいる構造ですが、下のStructureOfArraysGeneratorで生成したSoAの配列はXXXXYYYYZZZZという並び順になります。実際にシンプルなパフォーマンステスト(Vector3[10000]に対してYの最大値を求める)によるところ

そのまま書いても2倍、SIMDで書きやすい状態なのでSIMDで処理してしまえば10倍高速化されます。というわけで、パフォーマンスが求められるシチュエーションで非常に有用です。

このライブラリはZigという最近、日本でも注目されている言語(Node.jsの高速な代替として注目されているBunの実装言語)のMultiArrayListにインスパイアされました。Zigの作者 Andrew Kelley氏が講演した A Practical Guide to Applying Data-Oriented Design という素晴らしい講演があるので是非見て欲しいのですが

image

データ指向設計(Data-Oriented Design)はパフォーマンスを飛躍的に改善する魔法なのです。ん、それはどこかで聞いたような……?そう、UnityのDOTSです。Data-Oriented Technology Stackです。ECSです。……。まぁ、そんなわけで全体に導入するにはそうとうガラッと設計を変える必要があるので大変厳しくはあるのですが、講演での実例としてZig自身のコンパイラの事例が出てますが、まぁつまりは徹底的にやれば成果は出ます。

しかしまぁ徹底的にやらず部分的に使っても効果があるのはUnityで Job System + Burst ぐらいでいいじゃん、という気持ちになっていることからも明らかです。というわけで部分的なSoA構造の導入にお使いください、かつ、導入や利用の敷居は全然高くないように設計しました。

MultiArray

NuGetからインストール(Unityの場合はgit参照か.unitypackageで)するとAnalyzerとして参照されます。StructureOfArraysGeneratorは属性も含めて依存はなく全てのコードが生成コードに含まれる(属性はinternal attributeとして吐かれる)ので、不要なライブラリ依存が増えることはありません。

[MultiArray(Type)]を配列的に使いたいreadonly partial structにつけます。

using StructureOfArraysGenerator;

[MultiArray(typeof(Vector3))]
public readonly partial struct Vector3MultiArray
{
}

するとSource Generatorは内部的にはこういうコードを生成します。

partial struct Vector3MultiArray
{
    // constructor
    public Vector3MultiArray(int length)

    // Span<T> properties for Vector3 each fields
    public Span<float> X => ...;
    public Span<float> Y => ...;
    public Span<float> Z => ...;

    // indexer
    public Vector3 this[int index] { get{} set{} }

    // foreach
    public Enumerator GetEnumerator()
}

Structure of Arrays と言ってますが、StructureOfArraysGeneratorは Arrays は生成しません。内部的には単一の byte[] と各開始地点のオフセットのみを持っていて、生成されるプロパティによってSpan<T>のビューを返すという設計になっています。

使い方的には配列のように使えますが、Span<T>の操作、例えばref var item inによるforeachを使うと、より効率的に扱えます。

var array = new Vector3MultiArray(4);

array.X[0] = 10;
array[1] = new Vector3(1.1f, 2.2f, 3.3f);

// multiply Y
foreach (ref var item in v.Y)
{
    item *= 2;
}

// iterate Vector3
foreach (var item in array)
{
    Console.WriteLine($"{item.X}, {item.Y}, {item.Z}");
}

Yに2倍を掛ける処理などは、メモリ領域が連続していることにより、Vector3[]item.Y *= 2 などとして書くよりも高速に処理されます.

他にList<T>のようにAddできるMultiArrayListや、内部的にはbyte[]を持っているだけであることを生かしたMemoryPackでの超高速なシリアライズなどにも対応しています。気になったら是非ReadMeのほうを見てください。

.NET 7 時代のSIMD

.NETはSIMD対応が進んでいて、System.Runtime.Intrinsics.X86によって、直接ハードウェア命令を書くことが出来ます。

しかし、しかしですね、最近は .NET を Arm で動かすことが現実的になってきました。iOSやAndroidでけはなくMacのArm化、そしてAWS GravitonのようなArmサーバーはコスト面でも有利で、選択肢に十分入ります。そこでAvx.Addなんて書いていたらArmで動きません。勿論 System.Runtime.Intrinsics.Arm というクラスも公開されていて、Arm版のSIMDを手書きすることもできるんですが、分岐して似たようなものを二個書けというのか!という話です。

そこで、 .NET 7こそがC# SIMDプログラミングを始めるのに最適である理由 という記事があるのですが、確かに .NET 7 から追加された Vector256.LoadUnsafe がまずめちゃくくちゃイイ!馴染みが深い(?)Unsafeによる ref var T で書けます!そしてExpose cross-platform helpers for Vector64, Vector128, and Vector256により、Vector64/128/256<T>にプラットフォーム抽象化されたSIMD処理が書けるようになりました、やはり .NET 7から。

例えば .NET 7 でint[]のSumのSIMD化を書いてみます。

var array = Enumerable.Range(1, 100).ToArray();

ref var begin = ref MemoryMarshal.GetArrayDataReference(array);
ref var last = ref Unsafe.Add(ref begin, array.Length);

var vectorSum = Vector256<int>.Zero;
ref var current = ref begin;

// Vector256で処理できるだけ処理
ref var to = ref Unsafe.Add(ref begin, array.Length - Vector256<int>.Count);
while (Unsafe.IsAddressLessThan(ref current, ref to))
{
    // 直接足し算できて便利
    vectorSum += Vector256.LoadUnsafe(ref current);
    current = ref Unsafe.Add(ref current, Vector256<int>.Count);
}

// Vector256をintに戻す
 var sum = Vector256.Sum(vectorSum);

// 残りの分は単純処理
while (Unsafe.IsAddressLessThan(ref current, ref last))
{
    sum += current;
    current = ref Unsafe.Add(ref current, 1);
}

Console.WriteLine(sum); // 5050

まぁforがwhileのアドレス処理になっていたり、最後にはみ出た分を処理する必要がありますが、かなり自然にSIMDを扱えているといってもいいんじゃないでしょうか。(Unsafeに慣れていれば)かなり書きやすいです。いいね。

ところで .NET 7からLINQがSIMD対応してるからこんなの書く必要ないでしょ?というと、対応してません。LINQのSIMDはint[]のAverage, int[]のMin, Max, long[]のMin, Maxのみと、かなり限定的です。これは互換性の問題などなどがあり、まぁオマケみたいなものだと思っておきましょう。必要な局面があるなら自分で用意する方が無難です。

ともあれ、.NET 7 からは手書きX86 SIMDはArm対応が漏れやすいので、極力Vectorによって抽象化されたコードで書きましょう、ということになります。どうしてもVectorじゃ書けないところだけ、仕方なく書くという感じですね。

まとめ

反響全然ないだろうなあと想定していましたが、やはり反響全然ないです!まぁでも結構面白いライブラリになったと思うので、是非使ってください。それと、Incremental Source Generatorの作り方がMemoryPackの頃よりも習熟していて、コードがかなり洗練されたものになっているので、Source Generatorの作り方として参照するならMemoryPackのコードよりもこちらのコードのほうがお薦めです。

というわけで、まだまだSource Generatorネタはいっぱいあるので、今年は大量に量産します!

2022年を振り返る

今年はCysharpとしては、(控えめながら)露出があったので、何やってるかわからない、むしろ存在してるんですか?といったところから脱却したのではないでしょうか……?相変わらずホームページはペライチですけれど。そろそろいい加減、更新したい。

大きなところでは プリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しました というわけで、結構長くCygamesと一緒に作っていたゲームがリリースされました。超期間限定だったのでもうプレイできませんが……!技術的な詳細はCygamesのほうから C#によるクライアント/サーバーの開発言語統一がもたらす高効率な開発体制 ~プリコネ!グランドマスターズ開発事例~ という形でCEDECで講演していますが、2022年現在の開発体制としてはかなり先端を走っている、かつ、とてもいい感じに仕上がっています。

MagicOnion採用タイトルも増えていて、特に今年リリースされたタイトルで一番大きなものはメメントモリでしょうか、MagicOnion, MessagePack for C#を採用していただいています

MagicOnionは12/28にv5をリリースしたばかりです!内部アーキテクチャの変更によるパフォーマンス向上や拡張性の確保、そしてMemoryPackへの対応といった、次世代に向けて大きく基盤整理されました。今後もSource Generatorフル対応などが控えています。

OSS関連も、振り返るとかなり充実していました。後半、既存OSSのメンテが滞り気味だったのは来年消化します……!

今年も自分のプログラミング能力の成長を実感できています。技術的に腐り始めたら一瞬!みたいな危機感はあるので、こうして毎年の成長を、ちゃんと対外的にも示し続けられているのはいいことかな。自分はできてると思ってるけど外から見るとやべぇ、みたいなパターンは往々にありますからね、常に実証と共にありたいです。

特にMemoryPackは次世代の基準を打ち立てられたのではないかと思います。GitHub Starも3ヶ月で1300到達、非常に良い感じです。作ってる最中は、私が今これをやりきらなきゃC#は10年遅れてしまうんだ、みたいな気持ちでヒィヒィいいながら書いてましたが、大言壮語な妄想ではなく実際いい感じのものを出せたのではないでしょうか。

Source Generatorの解説を 2022年のC# (Incremental) Source Generator開発手法 として書きましたが、改めてC#にとってSource Generatorはめちゃくちゃ重要なテクノロジーになると、今更ながらに理解しました。いや、2020年の終わりに UnitGenerator を作ってから(これは今も使ってます)、しかしそこまで突き詰めてこなかったんですよね、今の今まで。来年はSource Generator元年ということで、色々な分野で革命的なものを大量に投下したいと思ってます。今なんかアイディアが溢れてるんですよ……!

というわけで来年はいっぱいやることがある!今年に悔いが残るとすれば、Cysharpとして現在、自称革命的なサービス(?)を作ってるんですが、それの進捗があまり良くなかったことですかねえ。原因としては私がOSS関連でフラフラしててプロジェクトマネージャー/プロダクトオーナーとして1ミリも機能いてなかったせいなのですが!反省。PM的な話は昔からずっと反省し続けてるので一向に進歩してないですね……。そこに脳みそ注ぎ込む余力がないのだと言い訳してますが……。

そんなわけで、来年こそは革命的サービスもリリースするので期待していてください。OSS関連も革命的なものをどかどか投下する予定なので、引き続きCysharpは時代の最先端を全力疾走していきます。

2022年(2024年)のC# Incremental Source Generator開発手法

このブログでもSource GeneratorやAnalyzerの開発手法に関しては定期的に触れてきていて、新しめだと

という記事を出していますが、今回 MemoryPack の実装で比較的大規模にSource Generatorを使ってみたことで、より実践的なノウハウが手に入りました。また、開発環境も年々良くなっていることや、Unityのサポート状況も強化されているので、状況を一通りまとめてみようと思いました。Source Generatorは非常に強力で、今後必須の開発技法になるので(少なくとも私はもうIL書きません!)是非、この機会に手を出して頂ければです。

また、オリジナルは2022年状況の話でしたが、Unity関連でアップデートがあったので2024年の状況として、その辺りを書き換えました。

Microsoft.CodeAnalysis.CSharpのバージョン問題

Source Generatorを作成するには Microsoft.CodeAnalysis.CSharpを参照したライブラリを作ればいい、のですが、ここで大事なのはバージョンです。何も考えずに最新を入れると動かないという罠が待ってます。Source Generatorは、インストールされている .NET のバージョンや IDEのコンパイラバージョンと深く紐づいています。.NETのバージョンだけ上げてもダメで、特にVisual Studioの場合は.NETのバージョンと独立して、同梱されているコンパイラのバージョンがあり、それと合わせる必要があります。Unityの場合も同じく、Unityに含まれるC#コンパイラのバージョン(/Editor/Data/DotNetSdkRoslyn/Microsoft.CodeAnalysis.CSharp.dll)を精査する必要があります。使わているバージョンよりも高いバージョンのものを参照すると、動かないという理屈です。

Visual Studioのバージョンとの紐づきは .NET コンパイラ プラットフォーム パッケージ バージョン リファレンスを見れば分かりますが、現状の私のオススメは 4.3.0。これは最小サポートバージョンがVisual Studio 2022 Version 17.3ということで、VS2019は切り捨てでいいでしょう。VS2022使ってるなら、とりあえずそこまでアップデートしてくれ、ということで。古ければ古いほどカバーできる範囲が広がっていい!ようでいて、古ければ古いほど、新しい言語機能の解析ができないなどの問題があるので、お薦めはできません、むしろ何も問題がなければ新しければ新しいほどいいぐらいです。4.3.0がおすすめな最大の理由としては、SyntaxValueProvider.ForAttributeWithMetadataName という、後で説明しますが、Source Generator作成の際に必須とも言える便利メソッドが追加されていることです。C# 11, C# 12の新言語機能部分の解析が必要になる場合は、必然的にアップデートしなければなりませんが。

そしてもう一つ4.3.0がおすすめな理由としてUnityへの対応があります。Unityの場合は公式にC#コンパイラのバージョンが何であるかのリストはないので、自分で調べていく必要がありますが、とりあえずRoslyn analyzers and source generatorsという公式ドキュメントによると「must use Microsoft.CodeAnalysis 3.8」、というわけで3.8じゃないと動かないぞ、と脅しをかけてきてます。が、実際はちょくちょくアップデートされていて、バージョンを調べていくとUnity 2022.3.0にはRoslyn 4.1.0、そしてUnity 2022.3.12f1以降には4.3.0が搭載されているようです。実際、ちゃんと4.3.0で動きます。2024-01-19更新時点でのLTSはUnity 2022.3で、パッチバージョンあげてもらえば4.3.0になるので、これは4.3.0解禁ということでいいでしょう(?)

Microsoft.CodeAnalysis.CSharpのバージョンは大きく分けて 3.* と 4.* があり、3.* はv1の ISourceGenerator、4.* はv2である IIncrementalGenerator が使えます。

Incremental Generatorsは、性能面で大きく改善されている他、作りやすさも大きく上がっているため、現状は Incremental Generators で作ることを最優先で考えたほうがいいでしょう。登場の黎明期では、IDEのバージョン問題があったために、3.* と 4.* の両方のSource Generatorを作って一緒にNuGetパッケージングする、という(かなりややこしい)手法が取られたこともありましたが、もう .NET 7も登場した2022年、も終わろうとしている現在ですので、 3.* は切り捨ててしまってもいいと考えています。

ただしUnityは除く。と思っていましたがUnity 2022.2, Unity 2023.1 からは4.1.0のコンパイラが搭載されていますし、2022.3.12f1以降は4.3.0なので、もうUnity含めても3.* の切り捨てはアリだと考えています。

最小プロジェクトとデバッグ実行

Source Generator開発は、デバッグ環境をきっちり構築できていないとかなり大変です。なので環境構築をしっかりやってから挑みましょう。ここではWindowsのVisual Studio 2022を使った場合の説明のみしますが、他の環境でも、同等のことができるようにしておかないとめちゃくちゃ大変です。

まず「.NET Compiler Platform SDK」を入れましょう。標準では入ってないので。入れておかなくても開発はできるのですが、デバッグ起動ができなくなるため、ほぼ必須と思ってください。

image

次に、「netstandard2.0」のクラスライブラリプロジェクトを作成します。え、2022年にもなってnetstandard2.0なの?なんで?standard2.1やnet7じゃダメなの?という感じですが、そもそもVisual Studioが .NET Frameworkで動いているというしょっぱい事情があり、Source Generatorプロジェクトはnetstandard2.0で作る必要があるという制限があります。使えるクラスライブラリが少なくて辛い感もありますが我慢です。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>

		<!-- LangVersionは明示的に書いておこう -->
		<LangVersion>11</LangVersion>
		<!-- Analyzer(Source Generator)ですという設定 -->
		<IsRoslynComponent>true</IsRoslynComponent>
		<AnalyzerLanguage>cs</AnalyzerLanguage>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
	</ItemGroup>

</Project>
using Microsoft.CodeAnalysis;

namespace SourceGeneratorSample;

[Generator(LanguageNames.CSharp)]
public partial class SampleGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Providerシリーズ
        // context.AdditionalTextsProvider
        // context.AnalyzerConfigOptionsProvider
        // context.CompilationProvider
        // context.MetadataReferencesProvider
        // context.ParseOptionsProvider
        // context.SyntaxProvider

        // Registerシリーズ
        // context.RegisterImplementationSourceOutput
        // context.RegisterPostInitializationOutput
        // context.RegisterSourceOutput
    }
}

これで無のSource Generatorができたので(contextの解説は準備が一通り終わったらします)、次に、このGeneratorを参照するConsoleAppを適当に作成します。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net7.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
	</PropertyGroup>

	<ItemGroup>
		<ProjectReference Include="..\SourceGeneratorSample\SourceGeneratorSample.csproj">
			<OutputItemType>Analyzer</OutputItemType>
			<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
		</ProjectReference>
	</ItemGroup>

</Project>

Source Generatorのプロジェクト参照では、OutputItemTypeとReferenceOutputAssemblyの設定を追加で手書きしてください。

次にまたSource Generator側のプロジェクトに戻って、プロジェクトのプロパティから「デバッグ起動プロファイルUIを開く」を選んでください。

image

既にあるプロファイルは削除した上で、左上の「新しいプロファイルの作成」から「Roslyn Component」を選択。ここでRoslyn Componentが出てこない場合は、「.NET Compiler Platform SDK」を入れているかどうかの確認と、csprojに<IsRoslynComponent>true</IsRoslynComponent>を追加しているかどうかの確認をしてください。

image

そしてTarget Projectに、先ほど作成したSource Generatorを参照しているコンソールアプリプロジェクトを選びます。プロジェクトが選べない場合は、対象プロジェクトがSource GeneratorをAnalyzerとしてのプロジェクト参照をしているかどうかを確認してください。

image

これで準備が完了で、Source Generatorをデバッグ実行(F5)すると、対象コンソールアプリプロジェクトを引っ掛けた状態で起動するようになります。

image

あとは、ひたすら、Generatorのコードを書いていくだけです、めでたし。

ForAttributeWithMetadataName

細かい説明に行く前に、基本的な流れの説明を。Source Generatorは、通常、なにか適当な属性がついているpartial classやpartial methodを探して、それに対して追加のpartial class/methodを生成する、という流れになります。原理的には属性がついていなくてもいいですが、勝手に何かを生成されるとわけわかんなくて困るので、ユーザーに明示的に生成を指示させるような流れにすべき、ということで、起点は属性付与だけと考えていいでしょう。

そんなわけでSource Generatorでまずやることは、属性が付与されてるclass/methodを探し出すことなのですが、Roslyn 4.3.0からは SyntaxValueProvider.ForAttributeWithMetadataName というメソッドで一発で探し出すことができるようになりました。

というわけで、小さなサンプル用ジェネレーターとして、classのToStringをrecordのように自動実装するジェネレーターを作ってみます。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace SourceGeneratorSample;

[Generator(LanguageNames.CSharp)]
public partial class SampleGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // PostInitializationOutputでSource Generatorでしか使わない属性を出力
        context.RegisterPostInitializationOutput(static context =>
        {
            // C# 11のRaw String Literal便利
            context.AddSource("SampleGeneratorAttribute.cs", """
namespace SourceGeneratorSample;

using System;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
internal sealed class GenerateToStringAttribute : Attribute
{
}
""");
        });

        var source = context.SyntaxProvider.ForAttributeWithMetadataName(
            "SourceGeneratorSample.GenerateToStringAttribute", // 引っ掛ける属性のフルネーム
            static (node, token) => true, // predicate, 属性で既に絞れてるので特別何かやりたいことがなければ基本true
            static (context, token) => context); // GeneratorAttributeSyntaxContextにはNode, SemanticModel(Compilation), Symbolが入ってて便利

        // 出力コード部分はちょっとごちゃつくので別メソッドに隔離
        context.RegisterSourceOutput(source, Emit);
    }

Initializeメソッドの行数の短さ!というわけで、Source Generator作り自体はかなり簡単になりました。ここまでがSourceGeneratorとして属性を引っ掛けて何かするための準備部分の全てであり、過去の諸々に比べると明らかに改善されています。

ただし、そうして抽出したところを加工して何かする部分は特に変わりないので、気合で頑張っていきましょう。↑のコードの続きは以下のものになります。

    static void Emit(SourceProductionContext context, GeneratorAttributeSyntaxContext source)
    {
        // classで引っ掛けてるのでTypeSymbol/Syntaxとして使えるように。
        // SemaintiModelが欲しい場合は source.SemanticModel
        // Compilationが欲しい場合は source.SemanticModel.Compilation から
        var typeSymbol = (INamedTypeSymbol)source.TargetSymbol;
        var typeNode = (TypeDeclarationSyntax)source.TargetNode;

        // ToStringがoverride済みならエラー出す
        if (typeSymbol.GetMembers("ToString").Length != 0)
        {
            context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.ExistsOverrideToString, typeNode.Identifier.GetLocation(), typeSymbol.Name));
            return;
        }

        // グローバルネームスペース対応漏れするとたまによく泣くので気をつける
        var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
            ? ""
            : $"namespace {typeSymbol.ContainingNamespace};";

        // 出力ファイル名として使うので雑エスケープ
        var fullType = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
            .Replace("global::", "")
            .Replace("<", "_")
            .Replace(">", "_");

        // Field/Propertyを抽出する
        var publicMembers = typeSymbol.GetMembers() // MethodがほしければOfType<IMethodSymbol>()などで絞る
            .Where(x => x is (IFieldSymbol or IPropertySymbol)
                         and { IsStatic: false, DeclaredAccessibility: Accessibility.Public, IsImplicitlyDeclared: false, CanBeReferencedByName: true })
            .Select(x => $"{x.Name}:{{{x.Name}}}"); // MyProperty:{MyProperty}

        var toString = string.Join(", ", publicMembers);

        // C# 11のRaw String Literalを使ってText Template的な置換(便利)
        // ファイルとして書き出される時対策として <auto-generated/> を入れたり
        // nullable enableしつつ、nullable系のwarningがウザいのでdisableして回ったりなどをテンプレコードとして入れておいたりする
        var code = $$"""
// <auto-generated/>
#nullable enable
#pragma warning disable CS8600
#pragma warning disable CS8601
#pragma warning disable CS8602
#pragma warning disable CS8603
#pragma warning disable CS8604

{{ns}}

partial class {{typeSymbol.Name}}
{
    public override string ToString()
    {
        return $"{{toString}}";
    }
}
""";

        // AddSourceで出力
        context.AddSource($"{fullType}.SampleGenerator.g.cs", code);
    }
}

// DiagnosticDescriptorは大量に作るので一覧性のためにもまとめておいたほうが良い
public static class DiagnosticDescriptors
{
    const string Category = "SampleGenerator";

    public static readonly DiagnosticDescriptor ExistsOverrideToString = new(
        id: "SAMPLE001",
        title: "ToString override",
        messageFormat: "The GenerateToString class '{0}' has ToString override but it is not allowed.",
        category: Category,
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true);
}

作り方のポイントとしては、Source Generator(Analyzer)で使うものにはSyntaxNodeとISymbolの二系統があって、SyntaxNodeは文字列としてのソースコードの構造を指していて、ISymbolはコンパイルされた状態での型の中間状態を指します。情報を取ったりするにはISymbolのほうが圧倒的にやりやすいので、基本的にはSymbolを辿って処理していきます。SyntaxNodeは、エラーの波線表示の位置を示したりする時のみに使うという感じですね。

では、これをビルドして、Visual Studioを、再起動します……!というのも、ConsoleApp1側ではSource Generatorを掴みっぱなしになってしまうので、プロジェクト参照でのSource Generatorの更新ができないからです。今回AttributeをGenerator側で追加しているので、再起動してそれの生成を含めてあげる必要があります。今後もConsoleApp1側での動作確認が必要な際は、定期的に再起動する羽目になります。ただしデバッグ起動では更新されたコードで動くので、大きな変動がなければそのまま作業を進められます。といった、IDEを再起動しなきゃいけないシチュエーションなのかしなくてもいいのか、の切り分けが求められます……。

ConsoleApp1側で以下のようなテスト型を用意して

using SourceGeneratorSample;

var mc = new MyClass() { Hoge = 10, Bar = "tako" };
Console.WriteLine(mc);

[GenerateToString]
public partial class MyClass
{
    public int Hoge { get; set; }
    public string? Bar { get; set; }
}

Source Generator側でデバッグ実行です。いったんの出力の確認でお薦めなのは、AddSourceの直前あたりにブレークポイント貼って見ることですかね。

image

そうして何度かデバッグ実行を繰り返して、理想となるコードが吐けるように調整していって、そして、最終的にそれで大丈夫かどうかはコンパイラ通さないとわからんので、Visual Studioを再起動してConsoleApp1側でコンパイル走らせて、みたいなことになりますね。この段階で問題が出ると、Visual Studio再起動祭りになるのでダルい!

問題なく吐けていれば、ソリューションエクスプローラーで生成コードを確認することができます。

image

以上、基本的な流れでした!C# 11のRaw String Literalsのお陰で別途テンプレートエンジンを用いなくても、テンプレート的な処理をC#のコード中に埋め込めるようになったのが、かなり楽になりました。(ただしif や for が埋め込めるわけではないので、複雑なものを書く場合はそれなりの工夫は必要)。

Source Generatorの良いところはAnalyzerも兼ねているところで、今回はToStringが既に定義されている場合はエラーにするという処理を入れているのですが

image

属性でどうこうする系ってどうしても今までは実行時エラーになりがちだったのですが、エディット時に間違って定義をばんばん教えてあげられるようになったのは親切度が相当上がっています。

IncrementalGeneratorInitializationContext詳解

Incremental Generatorの強みは複数のProviderを繋げてパイプラインを作れるところ、ではあるのですが、基本的なことは SyntaxProvider.ForAttributeWithMetadataName がほとんど全部やってくれるから、特に考えなくてもいいかな……。

ではあるんですが、細かい処理をしたい場合にはいくつか必要になりますので、Provider見ていきましょう。

  • AdditionalTextsProvider

AdditionalTextsProviderは、AdditionalFilesを読み取るのに使います。BannedApiAnalyzersなどでも活用されていますが、例えばコンフィグを渡したいケースなどに有用です。

例えば sampleGenerator.config.json を読み取りたい、といったケースを考えますと、ConsoleApp1側ではこういったcsprojとファイルを用意するとして

<ItemGroup>
	<AdditionalFiles Include="sampleGenerator.config.json" />
</ItemGroup>

AdditionalTextsProviderを使ってこんな風に読み取っていきます。

var configuration = context.AdditionalTextsProvider.Select((text, token) =>
    {
        if (text.Path.EndsWith("sampleGenerator.config.json")) return text.GetText(token);
        return null;
    })
    .Where(x => x != null)
    .Collect(); //雑Collect

// sampleにあったやつ
var types = context.SyntaxProvider.ForAttributeWithMetadataName(
      "SourceGeneratorSample.GenerateToStringAttribute",
      static (node, token) => true,
      static (context, token) => context)
    .Collect(); //雑Collect

var source = configuration.Combine(types);  // くっつける

context.RegisterSourceOutput(source, static (context, source) =>
{
    var configJson = source.Left.FirstOrDefault();
    var types = source.Right;
    foreach (var type in types)
    {
        // よしなに処理
    }
});

なるほどコードが増えた?

まず、Providerが触った直後のやつは IncrementalValuesProvider<T> になります。そしてCollectすると IncrementalValueProvider<ImmutableArray<T>> になります。違いはImmutableArray、ではなくて、 ValueProvider と ValuesProvider のほうです。ValueProviderの状態だと(IObservableみたいに)複数値が流れてくるのですが、ValuesProviderの状態だと、ImmutableArrayとして一塊になったものが一発流れてきます。

で、複数ProviderをCombineで繋いで、RegsiterSourceOutputに流し込むという流れになるわけですが、ValueとValuesが混在してるとCombineの型合わせがめちゃくちゃ大変です……!なんかよくわからんがCombineできない!の原因は型が合わないせいなのですね。というわけで雑にCollectしておくと合わせやすくなるので良いです。

というわけで、こんな感じで次のProvider行きましょう。

  • AnalyzerConfigOptionsProvider

GlobalOptionsと、AdditionalTextやSyntaxTreeに紐付けられたオプションを引っ張るGetOptionsがあります。例えばMemoryPackではcsprojのオプションから取り出すために使いました。

こういう記述をして

<ItemGroup>
    <CompilerVisibleProperty Include="MemoryPackGenerator_SerializationInfoOutputDirectory" />
</ItemGroup>
<PropertyGroup>
    <MemoryPackGenerator_SerializationInfoOutputDirectory>$(MSBuildProjectDirectory)\MemoryPackLogs</MemoryPackGenerator_SerializationInfoOutputDirectory>
</PropertyGroup>

こんな風に取り出すことができる( build_property. が接頭辞に必要)みたいな。

var outputDirProvider = context.AnalyzerConfigOptionsProvider
    .Select((configOptions, token) =>
    {
        if (configOptions.GlobalOptions.TryGetValue("build_property.MemoryPackGenerator_SerializationInfoOutputDirectory", out var path))
        {
            return path;
        }

        return (string?)null;
    });

csproj側があんま書きやすい感じじゃないので、AdditionalFilesでjsonを渡すのとどちらがいいのか、みたいなのは考えどころですね。こちらだとcsproj内のマクロが使える(出力パスとか)のはいいところかもしれません。

  • CompilationProvider

Compilationが拾える最重要Provider、のはずが ForAttributeWithMetadataName がくっつけてくれるので用無し。

  • MetadataReferencesProvider

読み込んでるDLLの情報が拾えます。

image

そんな使わないかも。

  • ParseOptionsProvider

csprojを解析した情報が取れます。例えば言語バージョンやプリプロセッサシンボルから、.NETのバージョンを取り出したりできます。

var parseOptions = context.ParseOptionsProvider.Select((parseOptions, token) =>
{
    var csOptions = (CSharpParseOptions)parseOptions;
    var langVersion = csOptions.LanguageVersion;
    var net7 = csOptions.PreprocessorSymbolNames.Contains("NET7_0_OR_GREATER");
    return (langVersion, net7);
});

つまり、言語バージョンや.NETのバージョン別の出し分けに使える、ということですね。細かくやると面倒くさいのであんまギチギチにやらないほうがいいとは思いますが、どうしてもそういう処理が必要なシチュエーションでは使えます。というか実際MemoryPackではこれで出し分けしています。scoped ref(C# 11)やfile scoped namespace(C# 10)、static abstract method(.NET 7)という切り分けですねー。

  • SyntaxProvider

ForAttributeWithMetadataName を叩くためのやつ。

  • RegisterPostInitializationOutput

ここからはRegisterシリーズですが、PostInitializeationOutputは、Source Generatorのためのマーカーとしてしか使わない属性をinternal classとして解析走らせる前に出力しておきたい、というやつですね。UnitGeneratorでは UnitOfAttribute をそういった形で吐き出しています(なので結果としてUnitGeneratorを使ったプロジェクトはUnitGeneratorへの依存DLLはなし、ということになる)。一方でMemoryPackで使ってる属性 MemoryPackableAttribute は、MemoryPack.Core.dllに含めているので、RegisterPostInitializationOutputは使っていません。どうせReader/Writerとかの他の依存が必要になるので、属性だけ依存なしにしてもしょーがないですからね。

  • RegisterSourceOutput

Providerを繋げて、実際にSource Generateさせるやつ。大事というか必須。

  • RegisterImplementationSourceOutput

ドキュメントが一切ない上に、なんか想定通りの動きをしていないような私の想定が悪いのか、まぁよくわからないけどよくわからないのでよくわからないです。ドキュメントも無なので、とりあえず無視しておきましょう。

ユニットテスト

厳密にやるとキリがないので、そこそこゆるふわ感覚でやるようにしてます。もちろんTDDなんてしません。基本的な考え方としては、ユニットテストプロジェクトがAnalyzerとして開発中のSource Generatorプロジェクトをプロジェクト参照して、ソース生成されるようにしておいて、ユニットテストでは、その生成されたコードが期待通り動いているかのテストをする、みたいな雰囲気で良いんじゃないかと思います。生成ソースコードの中身をチェックして一致するか、みたいなのはちょっと手間が無駄にかかりすぎるので……。

テストプロジェクトはxUnitと、補助ライブラリとしてFluentAssertionを好んで使っています。また、GlobalUsingにテスト系の名前空間を突っ込んでおくと気持ち楽です。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>net7.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<IsPackable>false</IsPackable>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="FluentAssertions" Version="6.7.0" />
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" />
		<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
		<PackageReference Include="xunit" Version="2.4.2" />
		<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
			<PrivateAssets>all</PrivateAssets>
		</PackageReference>
	</ItemGroup>

	<ItemGroup>
		<ProjectReference Include="..\SourceGeneratorSample\SourceGeneratorSample.csproj">
			<OutputItemType>Analyzer</OutputItemType>
            <!-- ReferenceOutputAssemblyをtrueにする! -->
			<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
		</ProjectReference>
	</ItemGroup>

	<ItemGroup>
		<Using Include="Xunit" />
		<Using Include="Xunit.Abstractions" />
		<Using Include="FluentAssertions" />
	</ItemGroup>

</Project>

後述しますが C# 11の内部コンパイルを行うために参照する Microsoft.CodeAnalysis.CSharp は 4.4.0 です。

namespace SourceGeneratorSample.Tests;

public class ToStringTest
{
    [Fact]
    public void Basic()
    {
        var mc = new MyClass() { Hoge = 33, Huga = 99 };
        mc.ToString().Should().Be("Hoge:33, Huga:99");
    }
}

[GenerateToString]
public partial class MyClass
{
    public int Hoge { get; set; }
    public int Huga { get; set; }
}

とりあえずこれをテストすればOK、と。なんか生成結果が更新されてない気がして無限にTestがこけるんだが?という時は、例によってVisual Studio再起動です。

Source Generatorのいいところとして、生成コードへのステップ実行も可能ということで、なんかよーわからん挙動だわーという時はデバッガでどんどん突っ込んでいくといいでしょう。

image

正常に動くケースはこれで概ねいいんですが、Analyzerとしてコンパイルエラーを出すようなケースをテストしたい場合は、もう一捻り必要です。対応としては CSharpGeneratorDriver というのが標準で用意されていて、それにソースコード渡せばいい、という話なのですが、少し手間なのは、元になるCSharpCompilationを作らなければいけない、というところで。この辺もよしなに見てくれる便利ジェネレーターユニットテストヘルパーライブラリみたいなのもありますが、原理原則を知るためにも、ここは手で書いてみましょう。

というわけで、こういうヘルパーを用意してみます。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;

namespace MemoryPack.Tests.Utils;

public static class CSharpGeneratorRunner
{
    static Compilation baseCompilation = default!;

    [ModuleInitializer]
    public static void InitializeCompilation()
    {
        // running .NET Core system assemblies dir path
        var baseAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
        var systemAssemblies = Directory.GetFiles(baseAssemblyPath)
            .Where(x =>
            {
                var fileName = Path.GetFileName(x);
                if (fileName.EndsWith("Native.dll")) return false;
                return fileName.StartsWith("System") || (fileName is "mscorlib.dll" or "netstandard.dll");
            });

        var references = systemAssemblies
            // .Append(typeof(Foo).Assembly.Location) // 依存DLLがある場合はそれも追加しておく
            .Select(x => MetadataReference.CreateFromFile(x))
            .ToArray();

        var compilation = CSharpCompilation.Create("generatortest",
            references: references,
            options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

        baseCompilation = compilation;
    }

    public static Diagnostic[] RunGenerator(string source, string[]? preprocessorSymbols = null, AnalyzerConfigOptionsProvider? options = null)
    {
        // NET 7 + C# 11
        if (preprocessorSymbols == null)
        {
            preprocessorSymbols = new[] { "NET7_0_OR_GREATER" };
        }
        var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp11, preprocessorSymbols: preprocessorSymbols);
        var driver = CSharpGeneratorDriver.Create(new SourceGeneratorSample.SampleGenerator()).WithUpdatedParseOptions(parseOptions);
        if (options != null)
        {
            driver = (Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver)driver.WithUpdatedAnalyzerConfigOptions(options);
        }

        var compilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions));

        driver.RunGeneratorsAndUpdateCompilation(compilation, out var newCompilation, out var diagnostics);

        // combine diagnostics as result.
        var compilationDiagnostics = newCompilation.GetDiagnostics();
        return diagnostics.Concat(compilationDiagnostics).ToArray();
    }
}

CSharpGeneratorDriver.Create して AddSyntaxTrees して RunGeneratorsAndUpdateCompilation して diagnostics を取り出す。というだけなのですが、Compilationを作るところに癖があります、というか Compilation に渡すDLLをかき集めるのが微妙に面倒くさいです。net7の依存関係のDLLを全部持ってくる、とかが一発でできないんですね。素直に typeof().Assembly.Location だけだと全然持ってこれないため、ディレクトリから漁ってくるという処理をいれています。

これを使ってテスト書くと、こんな感じでしょうか。

    [Fact]
    public void ERROR_SAMPLE001()
    {
        // C#11のRaw String Literals本当に便利
        var result = CSharpGeneratorRunner.RunGenerator("""
using SourceGeneratorSample;

[GenerateToString]
public partial class MyClass
{
    public int Hoge { get; set; }
    public int Huga { get; set; }

    public override string ToString()
    {
        return "hogemoge";
    }
}
""");

        result.Length.Should().Be(1);
        result[0].Id.Should().Be("SAMPLE001");
    }

厳密にやるなら、エラーの波線をどこに敷いているかのチェックをすべし、みたいな話もあるのですが、私的にはまぁ面倒くさいのでちゃんと狙ったエラーが出せてるかどうかをDiangnositcsのIdを拾うぐらいでいいかな、みたいな感じでやってます。

NuGetパッケージング

というわけで dotnet pack するわけですが、 追加でコンフィグ仕込む必要があります。

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<LangVersion>11</LangVersion>

		<!-- NuGetPackのための追加をもりもり -->
		<IsRoslynComponent>true</IsRoslynComponent>
		<AnalyzerLanguage>cs</AnalyzerLanguage>
		<IncludeBuildOutput>false</IncludeBuildOutput>
		<DevelopmentDependency>true</DevelopmentDependency>
		<IncludeSymbols>false</IncludeSymbols>
		<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.1" />
	</ItemGroup>

	<!-- 出力先を analyzers/dotnet/cs にする -->
	<ItemGroup>
		<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
	</ItemGroup>
</Project>

外部依存DLLがいたりすると(例えばJSON .NET使いたいとか!)もう少し面倒くさくなるので、外部依存DLLは使わないようにしましょう!というのが第一原則になります。どうしても使いたい場合は頑張ってください。

Unity対応

Incremental Generatorを前提にするなら、特に通常の.NET版とやることは変わりません。Unityのマニュアル通りにビルド済みdllを配置してRoslynAnalyzerとしてLabel設定したmetaを置いておけば、UPMのgit参照とかでも、特に何もせずに自動で認識されます。dllの配置場所はUnityの公式のジェネレーター(例えば com.unity.properties とか)がRuntime配下にいるので、Editorではなく、Runtime側に配置することとしています。

なお、Unity用限定のSource Generatorを作る場合でも、通常の .NET のライブラリとして扱い、普通に .NET ライブラリとしての開発環境やユニットテストプロジェクトを作ったほうが良いでしょう。普通に作るにもかなり環境をしっかり作らないと大変なので、Unity限定だから!みたいな気持ちで挑むとしんどみが爆発します。

また、ライブラリの配布としてNuGetForUnityを使ってNuGet経由で落としてきた場合は自動的にRoslynAnalyzerのLabelを張ってくれます、便利!

まとめ

C#に最初にこの手の機構が登場したのは2014年、 VS2015のRoslynでCode Analyzerを自作する(ついでにUnityコードも解析する) といった記事も書いていたのですが、まぁ正直めっっっちゃくちゃ作りづらかったんですね。

で、現代、この2022年のSource Generator開発はめっっっっちゃくちゃ作りやすくなってます。もちろん、Roslyn自体の知識が必要で、そしてRoslynはドキュメントが無なので、どちらかというとIntelliSenseから勘をどう働かせるかという勝負になっていて、それはそれで大変ではあるのですが、しかし本当に作りやすくなったな、と思います。もちろんそしてIL.Emitよりも遥かに作りやすいし、パフォーマンスも良い。もうEmitの時代は終わりです。もはや黒魔術を誇る時代でもないのです!動的コード生成の民主化!

というわけで、どしどしコード生成していきましょう……!私も今温めてるアイディアが3つぐらいあるので、どんどんリリースしていきたいと思ってます。

C# 11 による世界最速バイナリシリアライザー「MemoryPack」の作り方

と題して、.NET Conf 2022 Recap Event Tokyo というイベントで話してきました。

今回は久々の(数年ぶりの!)オフライン登壇イベントということで、なんだか新鮮な気分で、そして実際、オンライン登壇よりも目の前にオーディエンスがいたほうがいいなぁという思いを新たに。事前レコーディングやオンライン登壇だと、どうしてもライブ感のない、冷めた感じになっちゃうな、と。セッション単体の完成度で言ったら何度も取り直して完璧に仕上げた事前録画のほうがいい、かもしれませんが、でもそういうもんじゃあないかなあ、と。スタジオアルバムとライブアルバムみたいなもんですね。そしてスタジオアルバムに相当するのは念入りに書かれたブログ記事とかだったりするので、事前録画のセッションって、なんか中途半端に感じてしまったりはしますね。スタジオライブみたいな。あれってなんかいまいちじゃないですか、そういうことで。

MemoryPackは先程 v1.9.0 をリリースしました!日刊MemoryPackか?というぐらいに更新ラッシュをしていたのですが、バグというかは機能追加をめちゃくちゃやってました。性能面で究極のシリアライザーを目指した、というのはセッションスライドのほうにも書かせてもらっていますが、機能面でも究極のシリアライザーを目指しています、ということで、めちゃくちゃやれる幅が広がってます。GitHub Star数も既に1200と、めちゃくちゃ凄い勢いで伸びているので(過去最高の勢いです)、出す前は独自フォーマットのシリアライザーだと、どのぐらいまで使ってもらえるものだろうか?と不安に思ったところもあったのですが、割と自信もって押していける感じです。実際、性能も機能も凄い。

Formatterという名前付けについて

特に誰にも聞かれていないのですが説明しておきたいのが MemoryPackFormatter という名前を。Formatterって正直馴染みがないし(BinaryFormatterかよ?)、 IMemoryPackSerializer にしようかな、と当初は考えていたのですが最終的には(MessagePack for C#と同じの)Formatterに落ち着きました。理由は、エントリーポイントである MemoryPackSerializer と紛らわしいんですよね。 MemoryPackFormatterは自作でもしない限りは表に出て来ないし、上級向けのオプションなので、すっきりと名前で区別がついたほうが良いかな、という感じでつけてます。System.Text.Jsonの場合は JsonSerializerJsonConverterという分類で、同じような感じです。

候補になる名前としてはSerializerFormatterConverterEncoderCodecという感じでしょうか。単純で当たり前のチョイスのようでいて、ユーザーがなるべく悩まず直感的に理解できるように、しっかり考えて悩みながらつけてるんですよということで。それで出来上がった名前が、単純で当たり前のように思ってもらえれば正解なわけです。

イベント2

久々のオンフライン登壇!だったのですが、こういうのは始まると続くもので、今月は12/14に bitFlyer.C#/Azure 01というイベントに、Azureじゃないほうの枠として登壇する予定です。「AlterNatsにみる .NET 7世代のハイパフォーマンスSocketプログラミング技法」という内容ですがAlterNatsというよりかは、最新のC#でハイパフォーマンスなSocketプログラミングをどうすればいいか、ということに重点を置いた内容になってますので、C#の最適化に興味ある方は是非是非来てください。まだ席空いてますので……!

MemoryPackにみる .NET 7/C# 11世代のシリアライザー最適化技法

MemoryPackという、C#に特化することで従来のシリアライザーとは比較にならないほどのパフォーマンスを発揮する新しいシリアライザーを新しく開発しました。

高速なバイナリシリアライザーである MessagePack for C# と比較しても、通常のオブジェクトでも数倍、データが最適な場合は50~100倍ほどのパフォーマンスにもなります。System.Text.Jsonとでは全く比較になりません。当初は .NET 7 限定としてリリースしましたが、現在は .NET Standard 2.1(.NET 5, 6)やUnity、そしてTypeScriptにも対応しています。

シリアライザーのパフォーマンスは「データフォーマットの仕様」と「各言語における実装」の両輪で成り立っています。例えば、一般的にはバイナリフォーマットのほうがテキストフォーマット(JSONとか)よりも有利ですが、バイナリシリアライザーより速いJSONシリアライザといったものは有り得ます(Utf8Jsonでそれを実証しました)。では最速のシリアライザーとは何なのか?というと、仕様と実装を突き詰めれば、真の最速のシリアライザーが誕生します。

私は、今もですが、長年MessagePack for C#の開発とメンテナンスをしてきました。MessagePack for C#は .NET の世界で非常に成功したシリアライザーで、4000以上のGitHub Starと、Visual Studio内部や、SignalR, Blazor Serverのバイナリプロトコルなど、Microsoftの標準プロダクトにも採用されています。また、この5年間で1000近くのIssueをさばいてきました。そのため、シリアライザーの実装の詳細からユーザーのリアルなユースケース、要望、問題などを把握しています。Roslynを使用したコードジェネレーターによるAOT対応にも当初から取り組み、特にAOT環境(IL2CPP)であるUnityで実証してきました。更にMessagePack for C#以外にも ZeroFormatter(独自フォーマット)、Utf8Json(JSON) といった、これも多くのGitHub Starを獲得したシリアライザーを作成してきているため、異なるフォーマットの性能特性についても深く理解しています。シリアライザーを活用するシチュエーションにおいても、RPCフレームワークMagicOnionの作成、インメモリデータベースMasterMemory、そして複数のゲームタイトルにおけるクライアント(Unity)/サーバー、両方の実装に関わってきました。

ようするところ私は .NET のシリアライザー実装について最も詳しい人間の一人であり、MemoryPackはその知見がフルに詰め込まれた、なおかつ、 .NET 7 / C# 11という最新のランタイム/言語機能を使い倒したライブラリになっています。そりゃ速くて当然で異論はないですよね?

というだけではアレなので、実際なんで速いのかというのを理屈で説明していきます……!きっと納得してもらえるはず! C#の最適化のTipsとしてもどうぞ。

Incremental Source Generator

MemoryPackでは .NET 5/C# 9.0 から追加された Source Generator、それも .NET 6 で強化された Incremental Source Generatorを全面的に採用しています。使い方的には、対象型をpartialに変更する程度で、MessagePack for C#とあまり変わりません(というか極力同じAPIになるように揃えました)。

using MemoryPack;

[MemoryPackable]
public partial class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
}

// usage
var v = new Person { Age = 40, Name = "John" };

var bin = MemoryPackSerializer.Serialize(v);
var val = MemoryPackSerializer.Deserialize<Person>(bin);

Source Generatorの最大の利点はAOTフレンドリーであることで、従来行っていたIL.Emitによる動的コード生成をせずとも、リフレクションを使用しない、各型に最適化されたシリアライザーコードを自動生成しています。それによりUnityのIL2CPPなどでも安全に動作させることが可能です。

MessagePack for C#では外部ツール(mpc.exe)経由でコード生成することでAOTセーフなシリアライズ処理を実現していましたが、言語機能と統合されたことによって、煩わしい生成プロセス不要で、自然な書き心地のまま高速なシリアライズ処理を可能にしました。

なお、Unity版の場合は言語/コンパイラバージョンの都合上、Incremental Source Generatorではなくて、古いSource Generatorを採用しています。

C#のためのバイナリ仕様

キャッチコピーは「Zero encoding」ということで、エンコードしないから速いんだ!という理論を打ち出しています。奇妙に思えて、実のところ別に特殊な話をしているわけではなくて、例えばRustのメジャーなバイナリシリアライザーであるbincodeなども似通った仕様を持っています。FlatBuffersも、without parsingな実装のために、メモリデータに近い内容を読み書きします。ただしMemoryPackはFlatBuffersなどと違い、特別な型を必要としない汎用的なシリアライザーであり、POCOに対してのシリアライズ/デシリアライズを行うものです。また、スキーマのメンバー追加へのバージョニング耐性やポリモーフィズムサポート(Union)も持ちます。さすがにメモリダンプしてるだけ、では全く実用にならないわけで、一般的なシリアライザーとして使えるための仕様として整えてあります。

varint encoding vs fixed

Int32は4バイトですが、例えばJSONでは数値を文字列として、1バイト~11バイト(例えば 1 であったり -2147483648 であったり)の可変長なエンコーディングが施されます。バイナリフォーマットでも、サイズの節約のために1~5バイトの可変長にエンコードされる仕様を持つものが多くあります。例えばProtocol Buffersの数値型は、値を7ビットに、後続があるかないかのフラグを1ビットに格納する可変長整数エンコーディングになっています(varint)。これにより数値が小さければ小さいほど、バイト数が少なくなります。逆にワーストケースでは本来の4バイトより大きい5バイトに膨れることになります。とはいえ現実的には小さい数値のほうが圧倒的に頻出するはずなので、とても理にかなった方式です。MessagePackCBORも同じように、小さい数値では最小で1バイト、大きい場合は最大5バイトになる可変長エンコーディングで処理されます。

つまり、固定長の場合よりも余計な処理が走ることになります。具体的なコードで比較してみましょう。可変長はprotobufで使われるvarint + ZigZagエンコーディング(負数と正数をまとめる)です。

// 固定長の場合
static void WriteFixedInt32(Span<byte> buffer, int value)
{
    ref byte p = ref MemoryMarshal.GetReference(buffer);
    Unsafe.WriteUnaligned(ref p, value);
}

// 可変長の場合
static void WriteVarInt32(Span<byte> buffer, int value) => WriteVarInt64(buffer, (long)value);

static void WriteVarInt64(Span<byte> buffer, long value)
{
    ref byte p = ref MemoryMarshal.GetReference(buffer);

    ulong n = (ulong)((value << 1) ^ (value >> 63));
    while ((n & ~0x7FUL) != 0)
    {
        Unsafe.WriteUnaligned(ref p, (byte)((n & 0x7f) | 0x80));
        p = ref Unsafe.Add(ref p, 1);
        n >>= 7;
    }
    Unsafe.WriteUnaligned(ref p, (byte)n);
}

固定長は、つまりC#のメモリをそのまま書き出している(Zero encoding)わけで、さすがにどう見ても固定長のほうが速いでしょう。

このことは配列に適用した場合、より顕著になります。

// https://sharplab.io/
Inspect.Heap(new int[]{ 1, 2, 3, 4, 5 });

image

C#のstructの配列は、データが直列に並びます。この時、structが参照型を持っていない場合(unmanaged type)は、データが完全にメモリ上に並んでいることになります。MessagePackとMemoryPackでコードでシリアライズ処理を比較してみましょう。

// 固定長の場合(実際には長さも書き込みます)
void Serialize(int[] value)
{
    // サイズが算出可能なので事前に一発で確保
    var size = (sizeof(int) * value.Length) + 4;
    EnsureCapacity(size);

    // 一気にメモリコピー
    MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}

// 可変長の場合
void Serialize(int[] value)
{
    foreach (var item in value)
    {
        // サイズが不明なので都度バッファサイズのチェック
        EnsureCapacity(); // if (buffer.Length < writeLength) Resize();
        // 1要素毎に可変長エンコード
        WriteVarInt32(item);
    }
}

固定長の場合は、多くのメソッド呼び出しを省いて、メモリコピー一発だけで済ませることが可能です。

C#の配列はintのようなプリミティブ型だけではなく、これは複数のプリミティブを持ったstructでも同様の話で、例えば(float x, float y, float z)を持つVector3の配列の場合は、以下のようなメモリレイアウトになります。

image

float(4バイト)はMessagePackにおいて、固定長で5バイトです。追加の1バイトは、その値が何の型(IntなのかFloatなのかStringなのか...)を示す識別子が先頭に入ります。具体的には[0xca, x, x, x, x]といったように。いわばタグ付与エンコーディングを行っているわけです。MemoryPackのフォーマットは識別子を持たないため、4バイトをそのまま書き込みます。

ベンチマークで50倍の差だった、Vector3[10000]で考えてみましょう。

// 以下の型がフィールドにあるとする
// byte[] buffer
// int offset

void SerializeMemoryPack(Vector3[] value)
{
    // どれだけ複雑だろうとコピー一発で済ませられる
    var size = Unsafe.SizeOf<Vector3>() * value.Length;   
    if ((buffer.Length - offset) < size)
    {
        Array.Resize(ref buffer, buffer.Length * 2);
    }
    MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer.AsSpan(0, offset))
}

void SerializeMessagePack(Vector3[] value)
{
    // 配列の長さ x フィールドの数だけ繰り返す
    foreach (var item in value)
    {
        // X
        {
            // EnsureCapacity
            if ((buffer.Length - offset) < 5)
            {
                // 実際にはResizeではなくてbufferWriter.Advance()です
                Array.Resize(ref buffer, buffer.Length * 2);
            }
            var p = MemoryMarshal.GetArrayDataReference(buffer);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.X);
            offset += 5;
        }
        // Y
        {
            if ((buffer.Length - offset) < 5)
            {
                Array.Resize(ref buffer, buffer.Length * 2);
            }
            var p = MemoryMarshal.GetArrayDataReference(buffer);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Y);
            offset += 5;
        }
        // Z
        {
            if ((buffer.Length - offset) < 5)
            {
                Array.Resize(ref buffer, buffer.Length * 2);
            }
            var p = MemoryMarshal.GetArrayDataReference(buffer);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
            Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Z);
            offset += 5;
        }
    }
}

MessagePackだと30000回のメソッド呼び出しが必要なところが(そしてそのメソッド内では、書き込みメモリが足りているかのチェックと、書き終わった後のオフセットの追加が(愚直に処理する場合)都度必要になる)、一回のメモリコピーだけになります。こうなると、処理時間が文字通り桁違いに変わってきて、冒頭のグラフの50倍~100倍の高速化の理由はここにあります。

もちろん、デシリアライズ処理もコピー一発になります。

// MemoryPackのデシリアライズ、コピーするだけ。
Vector3[] DeserializeMemoryPack(ReadOnlySpan<byte> buffer, int size)
{
    var dest = new Vector3[size];
    MemoryMarshal.Cast<byte, Vector3>(buffer).CopyTo(dest);
    return dest;
}

// ループで都度floatの読み取りが必要
Vector3[] DeserializeMessagePack(ReadOnlySpan<byte> buffer, int size)
{
    var dest = new Vector3[size];
    for (int i = 0; i < size; i++)
    {
        var x = ReadSingle(buffer);
        buffer = buffer.Slice(5);
        var y = ReadSingle(buffer);
        buffer = buffer.Slice(5);
        var z = ReadSingle(buffer);
        buffer = buffer.Slice(5);
        dest[i] = new Vector3(x, y, z);
    }
    return dest;
}

この辺は、MessagePackのフォーマットそのものの限界のため、仕様に従う限りは、圧倒的な速度差はどうやっても覆せません。ただしMessagePackの場合はext format familyという仕様があり、独自仕様としてこれらの配列だけ特別扱いして処理する(MessagePackとしての互換性はなくなりますが)ことも許されています。実際、MessagePack for C#ではUnity向けに UnsafeBlitResolver という、上記のような処理をする特別な拡張オプションを用意していました。

しかし恐らく、ほとんどの人が使っていないでしょう。別に普通にシリアライズできるものを、言語間運用製を壊す、C#だけの独自拡張オプションをわざわざ使おうとは、中々思わない、というのは分かります。そこがまた歯痒かったんですよね、明らかに遅いのに、明らかに速くできるのに、だからせっかく用意したのに、デフォルトではない限り使われない、しかしデフォルトは絶対に仕様に従うべきであり……。

string処理の最適化

MemoryPackではStringに関して、2つの仕様を持っています。UTF8か、UTF16か、です。C#のstringはUTF16のため、UTF16のままシリアライズすると、UTF8へのエンコード/デコードコストを省くことができます。

void EncodeUtf16(string value)
{
    var size = value.Length * 2;
    EnsureCapacity(size);

    // char[] -> byte[] -> Copy
    MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}

string DecodeUtf16(ReadOnlySpan<byte> buffer, int length)
{
    ReadOnlySpan<char> src = MemoryMarshal.Cast<byte, char>(buffer).Slice(0, length);
    return new string(src);
}

ただし、MemoryPackのデフォルトはUTF8です。これは単純にペイロードのサイズの問題で、UTF16だとASCII文字が2倍のサイズになってしまうため、UTF8にしました(なお、日本語の場合はUTF16のほうがむしろ縮まる可能性が高いです)。

UTF8の場合でも、他のシリアライザにはない最適化をしています。

void WriteUtf8MemoryPack(string value)
{
    var source = value.AsSpan();
    var maxByteCount = (source.Length + 1) * 3;
    EnsureCapacity(maxByteCount);
    Utf8.FromUtf16(source, dest, out var _, out var bytesWritten, replaceInvalidSequences: false);
}

void WriteUtf8StandardSerializer(string value)
{
    var maxByteCount = Encoding.UTF8.GetByteCount(value);
    EnsureCapacity(maxByteCount);
    Encoding.UTF8.GetBytes(value, dest);
}

var bytes = Encoding.UTF8.GetBytes(value); は論外です、stringの書き込みで byte[] のアロケーションは許されません。しかし、多くのシリアライザはで使われている Encoding.UTF8.GetByteCount も避けるべきです、UTF8は可変長のエンコーディングであり、 GetByteCount は正確なエンコード後のサイズを算出するために、文字列を完全に走査します。つまり GetByteCount -> GetBytes は文字列を二度も走査することになります。

通常シリアライザーは余裕を持ったバッファの確保が許されています。そこでMemoryPackではUTF8エンコードした場合のワーストケースである文字列長の3倍の確保にすることで、二度の走査を避けています。

デコードの場合は、更に特殊な最適化を施しています。

string ReadUtf8MemoryPack(int utf16Length, int utf8Length)
{
    unsafe
    {
        fixed (byte* p = &buffer)
        {
            return string.Create(utf16Length, ((IntPtr)p, utf8Length), static (dest, state) =>
            {
                var src = MemoryMarshal.CreateSpan(ref Unsafe.AsRef<byte>((byte*)state.Item1), state.Item2);
                Utf8.ToUtf16(src, dest, out var bytesRead, out var charsWritten, replaceInvalidSequences: false);
            });
        }
    }
}

string ReadStandardSerialzier(int utf8Length)
{
    return Encoding.UTF8.GetString(buffer.AsSpan(0, utf8Length));
}

通常、byte[]からstringを取り出すには Encoding.UTF8.GetString(buffer) を使います。MessagePack for C#でもそうです。しかし、改めて、UTF8は可変長のエンコーディングであり、そこからUTF16としての長さは分かりません。そのためUTF8.GetStringだと、stringに変換するためのUTF16としての長さ算出が必要なので、中では文字列を二度走査しています。擬似コードでいうと

var length = CalcUtf16Length(utf8data);
var str = String.Create(length);
Encoding.Utf8.DecodeToString(utf8data, str);

といったことになっています。一般的なシリアライザの文字列フォーマットはUTF8であり、当たり前ですがUTF16へのデコードなどといったことは考慮されていないため、C#の文字列としての効率的なデコードのためにUTF16の長さが欲しくても、データの中にはありません。

しかしMemoryPackの場合はC#を前提においた独自フォーマットのため、文字列はUTF16-LengthとUTF8-Lengthの両方(8バイト)をヘッダに記録しています。そのため、String.Create<TState>(Int32, TState, SpanAction<Char,TState>)Utf8.ToUtf16の組み合わせにより、最も効率的なC# Stringへのデコードを実現しました。

ペイロードサイズについて

MemoryPackは固定長エンコーディングのため可変長エンコーディングに比べてどうしてもサイズが膨らむ場合があります。特にlongを可変長エンコードすると最小1バイトになるので、固定長8バイトに比べると大きな差となり得ます。しかし、MemoryPackはフィールド名を持たない(JSONやMessagePackのMap)ことやTagがないことなどから、JSONよりも小さいのはもちろん、可変長エンコーディングを持つprotobufやMsgPackと比較しても大きな差となることは滅多にないと考えています。

データは別に整数だけじゃないので、真にサイズを小さくしたければ、圧縮(LZ4やZStandardなど)を考えるべきですし、圧縮してしまえばあえて可変長エンコーディングする意味はほぼなくなります。より特化して小さくしたい場合は、列指向圧縮にしたほうがより大きな成果を得られる(Apache Parquetなど)ので、現代的には可変長エンコーディングを採用するほうがデメリットは大きいのではないか?と私は考えています。冒頭でも少し紹介しましたが、実際Rustのシリアライザーbincodeのデフォルトは固定長だったりします。

MemoryPackの実装と統合された効率的な圧縮については、現在BrotliEncode/Decodeのための補助クラスを標準で用意しています。しかし、性能を考えるとLZ4やZStandardを使えたほうが良いため、将来的にはそれらの実装も提供する予定です。

.NET 7 / C#11を活用したハイパフォーマンスシリアライザーのための実装

MemoryPackは .NET Standard 2.1向けの実装と .NET 7向けの実装で、メソッドシグネチャが若干異なります。.NET 7向けには、最新の言語機能を活用した、より性能を追求したアグレッシブな実装になっています。

まずシリアライザのインターフェイスは以下のような static abstract membersが活用されています。

public interface IMemoryPackable<T>
{
    // note: serialize parameter should be `ref readonly` but current lang spec can not.
    // see proposal https://github.com/dotnet/csharplang/issues/6010
    static abstract void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref T? value)
        where TBufferWriter : IBufferWriter<byte>;
    static abstract void Deserialize(ref MemoryPackReader reader, scoped ref T? value);
}

MemoryPackはSource Generatorを採用し、対象型が [MemortyPackable]public partial class Foo であることを要求するため、最終的に対象型は

[MemortyPackable]
partial class Foo : IMemoryPackable
{
    static void IMemoryPackable<Foo>.Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref Foo? value) 
    {
    }
        
    static void IMemoryPackable<Foo>.Deserialize(ref MemoryPackReader reader, scoped ref Foo? value)
    {
    }
}

といったものを生成します。これにより、仮想メソッド経由呼び出しのコストを避けています。

public void WritePackable<T>(scoped in T? value)
    where T : IMemoryPackable<T>
{
    // IMemoryPackableが対象の場合、静的メソッドを直接呼び出しに行く
    T.Serialize(ref this, ref Unsafe.AsRef(value));
}

// 
public void WriteValue<T>(scoped in T? value)
{
    // IMemoryPackFormatter<T> を取得し、仮想メソッド経由で Serialize を呼び出す
    var formatter = MemoryPackFormatterProvider.GetFormatter<T>();
    formatter.Serialize(ref this, ref Unsafe.AsRef(value));
}

また、MemoryPackWriter/MemoryPackReader では ref field を活用しています。

public ref struct MemoryPackWriter<TBufferWriter>
    where TBufferWriter : IBufferWriter<byte>
{
    ref TBufferWriter bufferWriter;
    ref byte bufferReference;
    int bufferLength;

ref byte bufferReference, int bufferLength の組み合わせは、つまりSpan<byte>のインライン化です。また、TBufferWriterref TBufferWriterとして受け取ることにより、ミュータブルなstruct TBufferWriter : IBufferWrite<byte>を安全に受け入れて呼び出すことができるようになりました。

全ての型への最適化

例えばコレクションは IEnumerable<T> としてシリアライズ/デシリアライズすることで実装の共通化が可能ですが、MemoryPackでは全ての型に対して個別の実装をするようにしています。単純なところでは List<T>を処理するのに

public void Serialize(ref MemoryPackWriter writer, IEnumerable<T> value)
{
    foreach(var item in source)
    {
        writer.WriteValue(item);
    }
}

public void Serialize(ref MemoryPackWriter writer, List<T> value)
{
    foreach(var item in source)
    {
        writer.WriteValue(item);
    }
}

この2つでは全然性能が違います。IEnumerable<T>へのforeachは IEnumerator<T> を取得しますが、List<T>へのforeachは struct List<T>.Enumerator という最適化された専用の構造体のEnumeratorを取得するからです。

しかし、もっと最適化する余地があります。

public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
    public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref List<T?>? value)
    {
        if (value == null)
        {
            writer.WriteNullCollectionHeader();
            return;
        }

        writer.WriteSpan(CollectionsMarshal.AsSpan(value));
    }
}

// MemoryPackWriter.WriteSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteSpan<T>(scoped Span<T?> value)
{
    if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
    {
        DangerousWriteUnmanagedSpan(value);
        return;
    }

    var formatter = GetFormatter<T>();
    WriteCollectionHeader(value.Length);
    for (int i = 0; i < value.Length; i++)
    {
        formatter.Serialize(ref this, ref value[i]);
    }
}

// MemoryPackWriter.DangerousWriteUnmanagedSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void DangerousWriteUnmanagedSpan<T>(scoped Span<T> value)
{
    if (value.Length == 0)
    {
        WriteCollectionHeader(0);
        return;
    }

    var srcLength = Unsafe.SizeOf<T>() * value.Length;
    var allocSize = srcLength + 4;

    ref var dest = ref GetSpanReference(allocSize);
    ref var src = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value));

    Unsafe.WriteUnaligned(ref dest, value.Length);
    Unsafe.CopyBlockUnaligned(ref Unsafe.Add(ref dest, 4), ref src, (uint)srcLength);

    Advance(allocSize);
}

まず、そもそも現代では List<T> の列挙は CollectionsMarshal.AsSpan(value) 経由で、Span<T>を取得して、それを列挙するのが最適です。それによってEnumerator経由というコストすら省くことが可能です。更に、Span<T>が取得できているなら、List<int>List<Vector3>の場合にコピーのみで処理することもできます。

Deserializeの場合にも、興味深い最適化があります。まず、MemoryPackのDeserializeは ref T? value を受け取るようになっていて、valueがnullの場合は内部で生成したオブジェクトを(普通のシリアライザと同様)、valueが渡されている場合は上書きするようになっています。これによってDeserialize時の新規オブジェクト生成というアロケーションをゼロにすることが可能です。コレクションの場合も、List<T>の場合はClear()を呼び出すことで再利用します。

その上で、特殊なSpanの呼び出しをすることにより、 List<T>.Add すら避けることに成功しました。

public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
    public override void Deserialize(ref MemoryPackReader reader, scoped ref List<T?>? value)
    {
        if (!reader.TryReadCollectionHeader(out var length))
        {
            value = null;
            return;
        }

        if (value == null)
        {
            value = new List<T?>(length);
        }
        else if (value.Count == length)
        {
            value.Clear();
        }

        var span = CollectionsMarshalEx.CreateSpan(value, length);
        reader.ReadSpanWithoutReadLengthHeader(length, ref span);
    }
}

internal static class CollectionsMarshalEx
{
    /// <summary>
    /// similar as AsSpan but modify size to create fixed-size span.
    /// </summary>
    public static Span<T?> CreateSpan<T>(List<T?> list, int length)
    {
        list.EnsureCapacity(length);

        ref var view = ref Unsafe.As<List<T?>, ListView<T?>>(ref list);
        view._size = length;
        return view._items.AsSpan(0, length);
    }

    // NOTE: These structure depndent on .NET 7, if changed, require to keep same structure.

    internal sealed class ListView<T>
    {
        public T[] _items;
        public int _size;
        public int _version;
    }
}

// MemoryPackReader.ReadSpanWithoutReadLengthHeader
public void ReadSpanWithoutReadLengthHeader<T>(int length, scoped ref Span<T?> value)
{
    if (length == 0)
    {
        value = Array.Empty<T>();
        return;
    }

    if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
    {
        if (value.Length != length)
        {
            value = AllocateUninitializedArray<T>(length);
        }

        var byteCount = length * Unsafe.SizeOf<T>();
        ref var src = ref GetSpanReference(byteCount);
        ref var dest = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value)!);
        Unsafe.CopyBlockUnaligned(ref dest, ref src, (uint)byteCount);

        Advance(byteCount);
    }
    else
    {
        if (value.Length != length)
        {
            value = new T[length];
        }

        var formatter = GetFormatter<T>();
        for (int i = 0; i < length; i++)
        {
            formatter.Deserialize(ref this, ref value[i]);
        }
    }
}

new List<T>(capacity)List<T>.EnsurceCapacity(capacity) によって、List<T>の抱える内部の配列のサイズを事前に拡大しておくことが可能です。これにより、都度拡大/コピーが内部で発生することを避けることができます。

その状態で CollectionsMarshal.CreateSpan を使うと、取得できるSpanは、長さ0のものです。なぜなら内部のsizeは変更されていないため、です。もし CollectionMarshals.AsMemoryがあれば、そこからMemoryMarshal.TryGetArrayのコンボで生配列を取得できて良いのですが、残念ながら Span からは元になっている配列を取得する手段がありません。そこで、Unsafe.Asで強引に型の構造を合わせて、List<T>._sizeを弄ることによって、拡大済みの内部配列を取得することができました。

そうすればunamanged型の場合はコピーだけで済ませてしまう最適化や、List<T>.Add(これは都度、配列のサイズチェックが入る)を避けた、Span<T>[index]経由での値の詰め込みが可能になり、従来のシリアライザのデシリアライズよりも遥かに高いパフォーマンスを実現しました。

List<T>への最適化が代表的ではありますが、他にも紹介しきれないほど、全ての型を精査し、可能な限りの最適化をそれぞれに施してあります。

まとめ

なぜ開発しようかと思ったかというと、MessagePack for C#に不満がでてきたから、です。残念ながら .NET「最速」とはいえないような状況があり、その理由としてバイナリ仕様が足を引っ張っているため、改善するのにも限界があることには随分前から気づいていました。また、実装面でもIL生成とRoslynを使った外部ツールとしてのコードジェネレーター(mpc)の、二種のメンテナンスがかなり厳しくなってきているということもありました。外部ツールとしてのコードジェネレーターはトラブルの種で、何かと環境によって動かないということが多発していて、Source Generatorにフル対応できるのなら、もはや廃止したいぐらいにも思っていました。

そこに .NET 7/C# 11 の ref fieldやstatic abstract methodを見た時、これをシリアライザー開発に応用したらパフォーマンスの底上げが可能になる、ついでにSource Generator化すれば、いっそIL生成も廃止してSource Generatorに一本化できるのではないか?それならもう、それをMessagPack for C#に適用する前に、パフォーマンス向上に問題のあるバイナリ仕様の限界も無視した、C#のためだけに究極の性能を実現するシリアライザーを作って、本当の最速を実証してしまえばいいのでは?と。

性能特化の実験的シリアライザーではなくて、実用性も重視したシリアライザーであるために、MessagePack for C#での経験も元にして、多くの機能も備えるようにしました。

* .NETのモダンI/O API対応(IBufferWriter<byte>, ReadOnlySpan<byte>, ReadOnlySequence<byte>)
* 既存オブジェクトへの上書きデシリアライズ
* ポリモーフィズムなシリアライズ(Union)
* PipeWriter/Readerを活用したストリーミングシリアライズ/デシリアライズ
* (やや限定的ながらも)バージョニング耐性
* TypeScriptコード生成
* Unity(2021.3)サポート

欠点としては、バージョニング耐性が、仕様上やや貧弱です。詳しくはドキュメントを参照してください。パフォーマンスをやや落としてバージョニング耐性を上げるオプションを追加することは検討しています。また、メモリコピーを多用するので、実行環境が little-endian であることを前提にしています。ただし現代のコンピューターはほぼすべて little-endian であるため、問題にはならないはずです。

パフォーマンスのために特化したstructを作ってメモリコピーする、といったことはC#の最適化のための手段として、そこまで珍しいわけではなく、やったことある人もいるのではないかと思います。そこからすると、あるいはこの記事を読んで、MemoryPackは一見ただのメモリコピーの塊じゃん、みたいな感じがあるかもしれませんが、汎用シリアライザーとして成立させるのはかなり大変で、そこをやりきっているのが新しいところです。

当初実現していなかった .NET 5/6(Standard 2.1)対応やUnity対応は完了したので、今後はMasterMemoryのSource Generator/MemoryPack対応や、MagicOnionのシリアライザ変更対応など、利用できる範囲をより広げることを考えています。Cysharpの C#ライブラリ のエコシステムの中心になると位置づけているので、今後もかなり力入れて成長させていこうと思っていますので、まずは、是非是非試してみてください!

async/awaitのキャンセル処理やタイムアウトを効率的に扱うためのパターン&プラクティス

async/awaitの鬼門の一つとして、適切なキャンセル処理が挙げられます。別に基本的にはそんな難しいことではなく、CancellationTokenSourceを作る、CanellationTokenを渡す、OperationCanceledExceptionをハンドリングする。というだけの話です。けれど、Tokenに手動でコールバックをRegisterしたときとか、渡す口が空いてないものに無理やりなんとかするときとか、タイムアウトに使った場合の始末とか、ちょっと気の利いた処理をしたいような場面もあり、そうした時にどうすれば良いのか悩むこともあります。

こういうのはパターンと対応さえ覚えてしまえばいい話でもあるので、今回はAlterNatsの実装時に直面したパターンから、「外部キャンセル・タイムアウト・大元のDispose」が複合された状況での処理の記述方法と、適切な例外処理、そして最後にObjectPoolなども交えた効率的なゼロアロケーションでのCancellationTokenSourceのハンドリング手法を紹介します。

CreateLinkedTokenSourceを使ったパターン

何かのClientを実装してみる、ということにしましょう。キャンセル処理の最も単純なパターンは引数の末尾にCancellationTokenを用意して、内部のメソッドにひたすら伝搬させていくことです。きちんと伝搬させていけば、最奥の処理が適切にCancellationTokenをハンドリングしてキャンセル検知時にOperationCanceledExceptionを投げてくれます。CancellationTokenをデフォルト引数にするか、必ず渡す必要があるよう強制するかは、アプリケーションの性質次第です。アプリケーションに近いコードでは強制させるようにしておくと、渡し忘れを避けれるので良いでしょう。

class Client
{
    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        await SendCoreAsync(cancellationToken);
    }

    async Task SendCoreAsync(CancellationToken cancellationToken)
    {
        // nanika...
    }
}

非同期メソッドのキャンセルはCancellationTokenで処理するのが基本で、別途Cancelメソッドを用意する、といったことはやめておきましょう。実装が余計に複雑化するだけです。CancellationTokenを伝搬させるのが基本であり全てです。

任意のキャンセルの他に、タイムアウト処理を入れたい、というのは特に通信系ではよくあります。async/awaitでのタイムアウトの基本は、タイムアウトもキャンセル処理の一つである、ということです。CancellationTokenSourceにはCancelAfterという一定時間後にCancelを発火させるというメソッドが用意されているので、これを使ってCancellationTokenを渡せば、すなわちタイムアウトになります。

// Disposeすると内部タイマーがストップされるのでリークしない
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMinutes(1));

await client.SendAsync(cts.Token);

UniTaskではCancelAfterSlimというメソッドが用意されているため、そちらを使うことをお薦めします。Cancelはスレッドプールを使いますが、CancelAfterSlimはPlayerLoop上で動くため、Unityフレンドリーな実装になっています。ただし内部タイマーのストップ手法がCancelAfterSlimの戻り値をDisposeする必要があるというように、実装に若干差異があります。

タイムアウト時間は大抵固定のため、ユーザーに都度CancelAfterを叩かせるというのは、だいぶ使いにくい設計です。そこで、CancelAfterの実行はSendAsyncメソッドの内部で行うことにしましょう。そうした内部のタイムアウト用CancellationTokenと、外部からくるCancellationTokenを合成して一つのCancellationTokenに変換するには、CancellationTokenSource.CreateLinkedTokenSourceが使えます。

class Client
{
    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
    }

    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        // 連結された新しいCancellationTokenSourceを作る
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(Timeout);

        await SendCoreAsync(cts.Token);
    }

    // snip...
}

CreateLinkedTokenSourceで生成されたCancellationTourceは連結されたいずれかがCancelされると、生成されたCancellationTokenSource自体もCancelされます。また、それ自体からもCancelが発火できます。

これで完成!なのですが、このままだと例外処理に問題があります。

OperationCanceledExceptionは CancellationTokenというプロパティを持っていて、これを元に呼び出し側はキャンセルの原因を判別することができます。一つ例を出しますが、以下のようにOperationCanceledExceptionをcatchしたうえで、更に判定を入れてコード分岐をかけることがあります。

try
{
    await client.SendAsync(token);
}
catch (OperationCanceledException ex) when (ex.CancellationToken == token)
{
    // Cancelの原因をTokenによって判定できる
}

例外を何も処理せずに全部おまかせでやると、投げられる OperationCanceledException.CancellationToken は CreateLinkedTokenSource で連結したTokenになってしまい、何の意味もない情報ですし、原因の判別に使うこともできません。

また、タイムアウトをOperationCanceledExceptionとして扱ってしまうことも問題です。OperationCanceledExceptionは特殊な例外で、既知の例外であるとしてロギングから抜いたりすることもままあります(例えばウェブサーバーでクライアントの強制切断(リクエスト中にブラウザ閉じたりとか)でキャンセルされることはよくあるけれど、それをいちいちエラーで記録していたらエラー祭りになってしまう)。タイムアウトは明らかな異常であり、そうしたキャンセルとは確実に区別して欲しいし、OperationCanceledExceptionではない例外になって欲しい。

これは .NET のHttpClientでも HttpClient throws TaskCanceledException on timeout #21965 としてIssueがあがり(TaskCanceledExceptionはOperationCanceledExceptionとほぼ同義です)、大激論(121コメントもある!)を巻き起こしました。HttpClientはタイムアウトだろうが手動キャンセルだろうが区別なくTaskCanceledExceptionを投げるのですが、原因は、実装が上の例の通りCreateLinkedTokenSourceで繋げたもので処理していて、そして、特に何のハンドリングもしていなかったからです。

結論としてこれはHttpClientの設計ミスなのですが、一度世の中に出したクラスの例外の型を変更することは .NET の互換性維持のポリシーに反するということで(実際、これを変更してしまうと影響は相当大きくなるでしょう)、お茶を濁した対応(InnerExceptionにTimeoutExceptionを仕込んで、判定はそちら経由で一応できなくもないようにした)となってしまったのですが、今から実装する我々は同じ轍を踏んではいけない。ということで、ちゃんと正しく処理するようにしましょう。

public async Task SendAsync(CancellationToken cancellationToken = default)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    cts.CancelAfter(Timeout);

    try
    {
        await SendCoreAsync(cts.Token);
    }
    catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            // 引数のCancellationTokenが原因なので、それを保持したOperationCanceledExceptionとして投げる
            throw new OperationCanceledException(ex.Message, ex, cancellationToken);
        }
        else
        {
            // タイムアウトが原因なので、TimeoutException(或いは独自の例外)として投げる
            throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
        }
    }
}

やることは別に難しくはなく、OperationCanceledExceptionをcatchしたうえで、外から渡されたcancellationTokenがキャンセルされているならそれが原因、そうでないならタイムアウトが原因であるという判定をして、それに応じた例外を投げ直します。

最後に、Client自体がDisposeできるとして、それに反応するようなコードにしましょう。

class Client : IDisposable
{
    // IDisposableと引っ掛けて、Client自体がDisposeされたら実行中のリクエストも終了させるようにする
    readonly CancellationTokenSource clientLifetimeTokenSource;

    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
        this.clientLifetimeTokenSource = new CancellationTokenSource();
    }

    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(clientLifetimeTokenSource.Token, cancellationToken);
        cts.CancelAfter(Timeout);

        try
        {
            await SendCoreAsync(cts.Token);
        }
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        {
            if (cancellationToken.IsCancellationRequested)
            {
                // 引数のCancellationTokenが原因なので、それを保持したOperationCanceledExceptionとして投げる
                throw new OperationCanceledException(ex.Message, ex, cancellationToken);
            }
            else if (clientLifetimeTokenSource.IsCancellationRequested)
            {
                // クライアント自体がDisposeされたのでOperationCanceledException、或いは独自の例外を投げる
                throw new OperationCanceledException("Client is disposed.", ex, clientLifetimeTokenSource.Token);
            }
            else
            {
                // タイムアウトが原因なので、TimeoutException(或いは独自の例外)として投げる
                throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
            }
        }
    }

    async Task SendCoreAsync(CancellationToken cancellationToken)
    {
        // nanika...
    }

    public void Dispose()
    {
        clientLifetimeTokenSource.Cancel();
        clientLifetimeTokenSource.Dispose();
    }
}

差分はCreateLinkedTokenSourceで連結するトークンを増やすのと、例外処理時の分岐を増やすことだけです。

ゼロアロケーション化する

殆どの場合は上記のパターンで全く問題ないのですが、都度CreateLinkedTokenSourceで新しいCancellationTokenSourceを作るのが気になる、かもしれません。どちらにせよasyncメソッドが非同期で実行される場合には、非同期ステートマシン自体のアロケーションが発生するので実際のところ別に気にするほどのことではない。のですが、IValueTaskSourcePoolingAsyncValueTaskMethodBuilderを使ったアロケーションを避ける非同期実装を行っていた場合には、相当気になる問題になってきます。また、HTTP/1のREST呼び出しのような頻度では大したことないですが、これが例えばサーバーで大量の並列実行をさばく、クライアントではリアルタイム通信で毎フレーム通信する、といった用途だと、この辺も気を配りたくなってくるかもしれません。

なお、ここでは説明の簡略化のために、SendAsyncメソッド自体はasync Taskのままにします。

まずは外部キャンセルのない、タイムアウトだけのケースを見ていきます。タイムアウトは正常系の場合は発火しない、つまり殆どの場合は発火しないため、非発火時にはCancellationTokenSourceを使い回すようにしましょう。

class Client
{
    // SqlConnectionのようなメソッドを多重に呼ぶことを禁止しているクラスの場合はフィールドにCancellationTokenSourceを一つ
    // HttpClientのようにあちこちから多重に呼ばれる場合があるものはObjectPoolで保持する

    readonly ObjectPool<CancellationTokenSource> timeoutTokenSourcePool;

    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
        this.timeoutTokenSourcePool = ObjectPool.Create<CancellationTokenSource>();
    }

    public async Task SendAsync()
    {
        var timeoutTokenSource = timeoutTokenSourcePool.Get();
        timeoutTokenSource.CancelAfter(Timeout);

        try
        {
            await SendCoreAsync(timeoutTokenSource.Token);
        }
        finally
        {
            // Timeout処理が発火していない場合はリセットして再利用できる
            if (timeoutTokenSource.TryReset())
            {
                timeoutTokenSourcePool.Return(timeoutTokenSource);
            }
        }
    }
}

ObjectPoolの実装は色々ありますが、今回は説明の簡略化のためにMicrosoft.Extensions.ObjectPoolを使いました(NuGetからMicrosoft.Extensions.ObjectPoolを参照する必要あり)。タイムアウトが発動した場合は再利用不能なので、プールに戻してはいけません。なお、 CancellationTokenSource.TryResetは .NET 6 からのメソッドになります。それ以前の場合は CancelAfter(Timeout.InfiniteTimeSpan) を呼んでタイマー時間を無限大に引き伸ばす変更を入れる(内部的にはTimerがChangeされる)というハックがあります。

外部キャンセルが入る場合には、LinkedTokenを作らず、CancellationToken.UnsafeRegisterでタイマー用のCancellationTokenSourceをキャンセルするようにします。

public async Task SendAsync(CancellationToken cancellationToken = default)
{
    var timeoutTokenSource = timeoutTokenSourcePool.Get();

    CancellationTokenRegistration externalCancellation = default;
    if (cancellationToken.CanBeCanceled)
    {
        // 引数のCancellationTokenが発動した場合もTimeout用のCancellationTokenを発火させる
        externalCancellation = cancellationToken.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, timeoutTokenSource);
    }

    timeoutTokenSource.CancelAfter(Timeout);

    try
    {
        await SendCoreAsync(timeoutTokenSource.Token);
    }
    finally
    {
        // Registerの解除(TryResetの前に「必ず」先に解除すること)
        // CancellationTokenRegistration.Disposeは解除完了(コールバック実行中の場合は実行終了)までブロックして確実に待ちます
        externalCancellation.Dispose();
        if (timeoutTokenSource.TryReset())
        {
            timeoutTokenSourcePool.Return(timeoutTokenSource);
        }
    }
}

CancellationToken.UnsafeRegisterは .NET 6 からのメソッドでExecutionContextをCaptureしないため、より高効率です。それ以前の場合はRegisterを使うか、呼び出しの前後でExecutionContext.SuppressFlow/RestoreFlowするというハックが使えます(UniTaskのRegisterWithoutCaptureExecutionContextはこの実装を採用しています)。

CancellationTokenにコールバックを仕込む場合、レースコンディションが発生する可能性が出てきます。この場合だとTimeout用のCancellationTokenSourceをプールに戻した後にCancelが発生すると、最悪なことになります。それを防ぐために、CancellationTokenRegistration.DisposeをTryResetの前に必ず呼びましょう。CancellationTokenRegistration.Disposeの優れているところは、コールバックが実行中の場合は実行終了までブロックして確実に待ってくれます。これによりマルチスレッドのタイミング問題ですり抜けてしまうといったことを防いでくれます。

ブロックといいますが、コールバックに登録されたメソッドがすぐに完了する性質のものならば、lockみたいなものなので神経質になる必要はないでしょう。CancellationTokenRegistrationにはDisposeAsyncも用意されていますが、むしろそちらを呼ぶほうがオーバーヘッドであるため、無理にDisposeAsyncのほうを優先する必要はないと考えています。CancellationTokenRegistrationには他にUnregisterメソッドもあり、これはfire-and-forget的に解除処理したい場合に有効です。使い分けですね。

なお、CancellationTokenへのコールバックのRegister(UnsafeRegister)は、初回はコールバック登録用のスロットを生成するといったアロケーションがありますが、Dispose/Registerを繰り返す二回目以降はスロットを再利用してくれます。このへんも新規に(Linked)CancellationTokenSourceを作るより有利な点となりますね。

引き続き、Client自体の寿命に引っ掛けるCancellationTokenを追加した実装を見ていきましょう。といっても、単純にRegisterを足すだけです。

class Client : IDisposable
{
    readonly TimeSpan timeout;
    readonly ObjectPool<CancellationTokenSource> timeoutTokenSourcePool;
    readonly CancellationTokenSource clientLifetimeTokenSource;

    public TimeSpan Timeout { get; }

    public Client(TimeSpan timeout)
    {
        this.Timeout = timeout;
        this.timeoutTokenSourcePool = ObjectPool.Create<CancellationTokenSource>();
        this.clientLifetimeTokenSource = new CancellationTokenSource();
    }

    public async Task SendAsync(CancellationToken cancellationToken = default)
    {
        var timeoutTokenSource = timeoutTokenSourcePool.Get();

        CancellationTokenRegistration externalCancellation = default;
        if (cancellationToken.CanBeCanceled)
        {
            // 引数のCancellationTokenが発動した場合もTimeout用のCancellationTokenを発火させる
            externalCancellation = cancellationToken.UnsafeRegister(static state =>
            {
                ((CancellationTokenSource)state!).Cancel();
            }, timeoutTokenSource);
        }

        // Clientの寿命に合わせたものも同じように追加しておく
        var clientLifetimeCancellation = clientLifetimeTokenSource.Token.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, timeoutTokenSource);

        timeoutTokenSource.CancelAfter(Timeout);

        try
        {
            await SendCoreAsync(timeoutTokenSource.Token);
        }
        finally
        {
            // Registerの解除増量
            externalCancellation.Dispose();
            clientLifetimeCancellation.Dispose();
            if (timeoutTokenSource.TryReset())
            {
                timeoutTokenSourcePool.Return(timeoutTokenSource);
            }
        }
    }

    async Task SendCoreAsync(CancellationToken cancellationToken)
    {
        // snip...
    }

    public void Dispose()
    {
        clientLifetimeTokenSource.Cancel();
        clientLifetimeTokenSource.Dispose();
    }
}

例外処理も当然必要です!が、ここは最初の例のLinkedTokenで作ったときと同じです。

public async Task SendAsync(CancellationToken cancellationToken = default)
{
    var timeoutTokenSource = timeoutTokenSourcePool.Get();

    CancellationTokenRegistration externalCancellation = default;
    if (cancellationToken.CanBeCanceled)
    {
        externalCancellation = cancellationToken.UnsafeRegister(static state =>
        {
            ((CancellationTokenSource)state!).Cancel();
        }, timeoutTokenSource);
    }

    var clientLifetimeCancellation = clientLifetimeTokenSource.Token.UnsafeRegister(static state =>
    {
        ((CancellationTokenSource)state!).Cancel();
    }, timeoutTokenSource);

    timeoutTokenSource.CancelAfter(Timeout);

    try
    {
        await SendCoreAsync(timeoutTokenSource.Token);
    }
    catch (OperationCanceledException ex) when (ex.CancellationToken == timeoutTokenSource.Token)
    {
        // 例外発生時の対応はLinkedTokenで作ったときと特に別に変わらず

        if (cancellationToken.IsCancellationRequested)
        {
            throw new OperationCanceledException(ex.Message, ex, cancellationToken);
        }
        else if (clientLifetimeTokenSource.IsCancellationRequested)
        {
            throw new OperationCanceledException("Client is disposed.", ex, clientLifetimeTokenSource.Token);
        }
        else
        {
            throw new TimeoutException($"The request was canceled due to the configured Timeout of {Timeout.TotalSeconds} seconds elapsing.", ex);
        }
    }
    finally
    {
        externalCancellation.Dispose();
        clientLifetimeCancellation.Dispose();
        if (timeoutTokenSource.TryReset())
        {
            timeoutTokenSourcePool.Return(timeoutTokenSource);
        }
    }
}

ということで、↑のものが最終形となりました。

async/awaitで実装されている場合、Tokenのコールバックも一メソッド内で収まっているために挙動の見通しがだいぶ良くなります。async/awaitを封印してIValueTaskSourceを使った実装をする場合は、複数のコールバックを手で処理する必要があり、また登録、発火する箇所も複数箇所にちらばってしまうため、遥かに複雑性が増します。

AlterNatsではハイパフォーマンスSocketプログラミングとして実装を解説した記事で、IValueTaskSourceをChannel(キュー)に詰め込むとしていますが、キャンセル時にはManualResetValueTaskSourceCoreのSetExceptionを叩き、正常完了時にはSetResultの前でTryResetからのReturnするという、まぁ基本的な流れは一緒です。少し異なるのは、キャンセルで発火するのはawaitに紐付けられた継続処理だけで、実体はキューに残り続けていて、取り出し時にキャンセル状況をチェックして、何もしないようにする。といったことでしょうか。状況が複雑化する分、レースコンディション起因のバグが入り込みやすくなってしまうので、CancellationTokenRegistrationの挙動をしっかり把握して実装する必要があります。

まとめ

簡単かどうかでいうと、言われればなるほどそうですねーって感じですが、都度考えてやれって言われると結構難しいと思います。なので、こういうパターンなんですね、というのを頭に叩き込んでおくというのは重要だと思いますし、まぁとりあえず覚えてください。覚えれば、別にコード的に複雑というわけでもないので、易易と対処できるようになるはずです。

StackExchange.Redisも非同期メソッド、CancellationTokenを受け取ってなかったりしますし、パフォーマンスを追求しつつCancellationToken対応を入れるのは、かなり難しい問題だったりします。しかしこの .NET 6世代ではかなりメソッドも増えていて、やろうと思えばやりきれるだけの手札が揃っています。なので、パターン化して真正面から立ち向かいましょう……!

Microsoft MVP for Developer Technologies(C#)を再々々々々々々々々々々受賞しました

12回目。一年ごとに再審査があって7月に一斉更新されるシステムになっていて、今年も継続しました。

元々、私の活動はオンライン一人自己完結型なので、C#布教活動(?)的にコロナの影響は一切受けていないのですが、勉強会開催などコミュニティ構築型の人だと影響を受けやすいここ数年だったのではないかと思います。ただ、やはりアフターコロナで人との繋がりは極度に減ったし、人の入れ替わり、新しい台頭みたいなのも少なくなってきたなあ、というのが肌間ありますね。改めて、コミュニティを維持してくれている人のありがたさを知りました。というわけで、C#コミュニティ盛り上がっていって欲しい!のですが、私自身のスタンスは変わらず、OSSで世の中に存在感を出していくことだとは思っています。

好不調の並が割と激しくて、ここ数ヶ月何もやってないわーみたいなときもよくあるのですが、年を通すと毎年3, 4個は新規にOSSをリリースしているし、既存ライブラリのメンテナンスやテコ入れ大型リニューアルも数個やっていたりするので、年間通して見ればかなりハイパフォーマンスで、それを10年以上継続してるんだから中々なんじゃないですか?と自画自賛したくなったり。

そんなこんなの活動を続けてきた結果、CEDECという国内最大のゲーム業界のカンファレンスでもCEDEC AWARDS 2022のエンジニアリング部門で、「.NET/Unity開発の可能性を広げるオープンソースソフトウェアの提供」として優秀賞を受賞しました。C#は元々裏方で便利に使っていたというのはありましたが、表でもガンガン使っていけるよ、といったC#の存在感は、高めていけてるんじゃないかと思います。CysharpとしてOSSを通じてC#の可能性を広げるということがしっかり伝わってるというのがとても嬉しいですね!参加者投票で部門別最優秀賞が決まるらしいので、是非投票を……!

また、今年はプリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しましたという記事で書きましたが、開発に関わっていた「プリコネ!グランドマスターズ」のリリースがありました。の事例発表をCEDEC 2022でC#によるクライアント/サーバーの開発言語統一がもたらす高効率な開発体制 ~プリコネ!グランドマスターズ開発事例~としてCygamesさんより発表があります。

  • クライアント/サーバーの開発言語統一によるメリット
  • MagicOnion(gRPC)を利用したリアルタイムサーバー実装手法と具体例
  • Blazorを使用したツールの開発例、開発プロジェクトおよびソリューション統合管理の手法

ということで、かなり面白い感じの内容になるのではないでしょうか、期待……!

C#がエンタープライズ業界(とは)で強いというのは重々承知だしAzureもシェア高くめっちゃ使われてるんだよ、という話は分かりはするのですが、to Cやスタートアップ企業で積極的に使われる言語になって欲しい、というのがずーっとの願いで、私自身もそうした業界に身をおいて、実績で示し続けて来たと思いますし、これからも引き続き道を示せるようにしていきたいです。

もちろん、ハイパフォーマンスなC#によって最前線での実力を見せていく、ということも変わらずに……!

AlterNats - ハイパフォーマンスな.NET PubSubクライアントと、その実装に見る.NET 6時代のSocketプログラミング最適化のTips、或いはMagicOnionを絡めたメタバース構築のアーキテクチャについて

タイトルはここぞとばかりに全盛りにしてみました!今回NATSの.NETクライアント実装としてAlterNatsというライブラリを新しく作成し、公開しました。

公式の既存クライアントの3倍以上、StackExchange.RedisのPubSubと比較して5倍以上高速であり、通常のPubSubメソッドは全てゼロアロケーションです。

image

そもそもNATSとはなんぞやか、というと、クラウドネイティブなPubSubのミドルウェアです。Cloud Native Computing Foundationのincubating projectなので、それなりの知名度と実績はあります。

PubSubというと、特にC#だとRedisのPubSub機能で行うのが、StackExchange.Redisという実績あるライブラリもあるし、AWSやAzure、GCPがマネージドサービスも用意しているしで、お手軽でいいのですが、盲目的にそれを使うのが良いのか少し疑問に思っていました。

RedisはKVS的な使い方がメインであり、PubSubはどちらかというとオマケ機能であるため

  • PubSub専用のモニタリングの欠如
  • PubSub用のクラスタリング対応
  • マネージドサービスでの価格体系のバランスの悪さ(PubSub特化ならメモリはあまりいらない)
  • そもそものパフォーマンス

といった点が具体的な懸念です。そして、NATSはPubSub専用に特化されているため、そのためのシステムが豊富に組まれているし、性能も申し分なさそうに思えました。しいて欠点を言えばマネージドサービスが存在しないのがネックですが、純粋なPubSubとしての利用ならば永続化処理について考える必要がないので、ミドルウェアとしては運用しやすい部類にはいるのではないかと思っています。(NATS自体はNATS JetStreamという機能によってAt-least / exactly onceの保証のあるメッセージングの対応も可能ですが、そこに対応させるにはストレージが必要になる場合もあります)

しかし調べていくうちに懸念となったのが公式クライアントであるnats.netで、あまり使いやすくないのですね。async/awaitにも対応していないし、古くさく、それどころかそもそも.NET的に奇妙に見えるAPIであり、そうなるとパフォーマンスに関しても疑問に思えてくる。

何故そうなっているかの理由はReadMeにも明記されていて、メンテナンス性のためにGoクライアント(ちなみにNATS Server自体はGoで書かれている)と同じようなコードベースになっている、と。そのためC#的ではない部分が多々あるし、GoとC#ではパフォーマンスを出すための書き方が全く異なるので、あまり良い状況ではなさそう。

それならば完全にC#に特化して独自に作ってしまうほうがいいだろうということで、作りました。公式クライアントと比べると全ての機能をサポートしているわけではない(JetStreamにも対応していないしLeaf Nodes運用で必須になるであろうTLSにも対応していません)のですが、PubSubのNATS Coreに特化して、まずは最高速を叩き出せるようにしました。PubSub利用する分には機能面での不足はないはずです。

AlterNatsは公式じゃないAlternativeなNATSクライアントという意味です。まんまですね。割と語感が良いので命名的には結構気に入ってます。

Getting Started

APIは、nats.netがあまりにもC#っぽくなくややこしい、ということを踏まえて、シンプルに、簡単に、C#っぽく書けるように調整しました。

// create connection(default, connect to nats://localhost:4222)
await using var conn = new NatsConnection();

// for subscriber. await register to NATS server(not means await complete)
var subscription = await conn.SubscribeAsync<Person>("foo", x =>
{
    Console.WriteLine($"Received {x}");
});

// for publisher.
await conn.PublishAsync("foo", new Person(30, "bar"));

// unsubscribe
subscription.Dipose();

// ---

public record Person(int Age, string Name);

Subscribeでhandlerを登録し、Publishでメッセージを飛ばす。データは全て自動でシリアライズされます(デフォルトではSystem.Text.Json、MessagePack for C#を用いたハイパフォーマンスなシリアライズも可能な拡張オプションも標準で用意してあります)

別のURLへの接続や、認証のための設定などを行うNatsOptions/ConnectOptionsはイミュータブルです。そのため、with式で構築するやり方を取っています。

// Options can configure `with` operator
var options = NatsOptions.Default with
{
    Url = "nats://127.0.0.1:9999",
    LoggerFactory = new MinimumConsoleLoggerFactory(LogLevel.Information),
    Serializer = new MessagePackNatsSerializer(),
    ConnectOptions = ConnectOptions.Default with
    {
        Echo = true,
        Username = "foo",
        Password = "bar",
    }
};

await using var conn = new NatsConnection(options);

NATSには標準で結果を受け取るプロトコルも用意されています。サーバー間の簡易的なRPCとして使うと便利なところもあるのではないかと思います。これもSubscribeRequestAsync/RequestAsyncという形で簡単に直感的に書けるようにしました(Request側は戻り値の型を指定する必要があるため、型指定が少しだけ冗長になります)

// Server
await conn.SubscribeRequestAsync("foobar", (int x) => $"Hello {x}");

// Client(response: "Hello 100")
var response = await conn.RequestAsync<int, string>("foobar", 100);

例では await usingですぐに破棄してしまっていますが、基本的にはConnectionはシングルトンによる保持を推奨しています。staticな変数に詰めてもいいし、DIでシングルトンとして登録してしまってもいいでしょう。接続は明示的にConnectAsyncすることもできますが、接続されていない場合は自動で接続を開くようにもなっています。

コネクションはスレッドセーフで、物理的にも一つのコネクションには一つの接続として繋がり、全てのコマンドは自動的に多重化されます。これにより裏側で自動的にバッチ化された高効率な通信を実現していますが、負荷状況に応じて複数のコネクションを貼った場合が良いケースもあります。AlterNatsではNatsConnectionPoolという複数コネクションを内包したコネクションも用意しています。また、クライアント側で水平シャーディングを行うためのNatsShardingConnectionもあるため、必要に応じて使い分けることが可能です。

内部のロギングはMicrosoft.Extensions.Loggingで管理されています。AlterNats.Hostingパッケージを使うと、Generic Hostと統合された形で適切なILoggerFactoryの設定と、シングルトンのサービス登録を行ってくれます。

DIでの取り出しは直接NatsConnectionを使わずに、INatsCommandを渡すことで余計な操作(コネクションの切断など)が出来ないようになります。

using AlterNats;

var builder = WebApplication.CreateBuilder(args);

// Register NatsConnectionPool, NatsConnection, INatsCommand to ServiceCollection
builder.Services.AddNats();

var app = builder.Build();

app.MapGet("/subscribe", (INatsCommand command) => command.SubscribeAsync("foo", (int x) => Console.WriteLine($"received {x}")));
app.MapGet("/publish", (INatsCommand command) => command.PublishAsync("foo", 99));

app.Run();

メタバースアーキテクチャ

CysharpではMagicOnionという .NET/Unity で使えるネットワークフレームワークを作っているわけですが、AlterNatsはこれと絡めることで、構成の幅を広げることができると考えています、というかむしろそのために作りました。

クライアントにUnity、サーバーにMagicOnionがいるとして、サーバーが一台構成なら、平和です、繋げるだけですもの。開発の最初とかローカルでは楽なのでこの状態でもいいですね。

image

しかし現実的にはサーバーは複数台になるので、そうなると色々なパターンが出てきます。よくあるのが、ロードバランサーを立ててそれぞれが別々のサーバーに繋がっているものを、更に後ろのPubSubサーバーを通して全サーバーに分配するパターン。

image

これはNode.jsのリアルタイムフレームワークであるSocket.IOのRedisアダプター、それの.NET版であるSignalRのRedisバックプレーン、もちろんMagicOnionにもあるのですが、このパターンはフレームワークでサポートされている場合も多いです。RedisのPubSubでできることはNATSでもできる、ということで、NATSでもできます。

これは各サーバーをステートレスにできるのと、スケールしやすいので、Chatなどの実装にはやりやすい。欠点はステートを持ちにくいので、クライアントにステートがあり、データのやり取りをするタイプしか実装できません。サーバー側にステートを持ったゲームロジックは持たせずらいでしょう(ステートそのものは各サーバーで共有できないため)。また、PubSubを通すことによるオーバーヘッドも気になるところかもしれません。

ロードバランサーを立てる場合、ロードバランサーのスティッキーセッションを活用して一台のサーバーに集約させるというパターンもあります(あるいは独自プロトコルでもリバースプロキシーを全面に立てて、カスタムなロジックで後ろの台を決定することもほぼ同様の話です)。ただし、色々なユーザーを同一サーバーに集約させたいようなケースでは、そのクッキーの発行誰がやるの、みたいなところは変わらずありますね。そこまで決めれるならIPアドレスを返して直繋ぎさせてしまってもいいんじゃないの?というのも真です。

そうした外側に対象のIPアドレスを教えてくれるサービスがいて、先にそれに問い合わせてから、対象のサーバーへ繋ぎに行くパターンは、古典的ですが安定です。

image

この場合は同一サーバーに繋ぎにいくためにサーバー内にインメモリでフルにステートを持たせることが出来ますし、いわゆるゲームループを中で動かして処理するようなこともできます。また、画面のないヘッドレスUnityなどをホストして、クライアントそのものをサーバー上で動かすこともできますね。

しかし、このパターンは素直なようでいて、実際VMだとやりやすいのですが、Kubernetesでやるのは難しかったりします。というのも、Kubernetesの場合は外部にIPが露出していないため、クラスター内の一台の特定サーバーに繋ぎにいくというのが難しい……!

このような場合に最近よく活用されているのがAgonesというGoogleが主導して作っているKubernetesの拡張で、まさにゲーム向きにKubernetesを使えるようにするためのシステムです。

ただし、これはこれで難点があって、Agonesが想定しているゲームサーバーは1プロセス1ゲームセッション(まさにヘッドレスUnityのような)のホスティングであるため、1つのプロセスに多数のゲームセッションをホストさせるような使い方はそのままだと出来ません。コンテナなので、仮想的なプロセスを複数立ち上げればいいでしょ、というのが思想なのはわからなくもないのですが、現実的には軽量なゲームサーバー(それこそMagicOnionで組んだりする場合)なら、1プロセスに多数のゲームセッションを詰め込めれるし、これをコンテナで分けて立ち上げてしまうとコスト面では大きな差が出てしまいます。

さて、Cysharpではステートフルな、特にゲームに向いたC#サーバーを構築するための補助ライブラリとしてLogicLooperというゲームループを公開しています。このライブラリはこないだリリースしたプリコネ!グランドマスターズでも使用していますが、従来MagicOnionと同居して使っていたLogicLooperを、剥がしたアーキテクチャはどうだろうか、という提案があります。(実際のプリコネ!グランドマスターズのアーキテクチャはMagicOnionと同居し、リバースプロキシーを使った方式を採用しているので(↑の画像のものに近い)、この案とは異なります)

image

パーツが増えて複雑になったように見えて、この構成には大きな利点があります。まず、同居しているものがなくなったので複雑になったようで実はシンプルになっています。それぞれがそれぞれの役割にフルに集中できるようになるため、パフォーマンスも良くなり、かつ、性能予測もしやすくなります。特にロジックをフルに回転させるLogicLooperがクライアントや接続数の影響を受けずに独立できているのは大きな利点です。

ゲーム全体のステートはLogicLooper自体が管理するため、クライアントとの接続を直接受けているMagicOnion自体はステートレスな状態です。そのため、インフラ的にもロードバランサーの下にMagicOnionを並べるだけで済みますし、サーバー間の接続に伴う面倒事は全てNATSに押し付けられるため、インフラ管理自体はかなりシンプルな構成が取れます。

また、MagicOnion自体はステートを持てるシステムであり、各ユーザーそれぞれのステートを持つのは容易です(サーバーを越えなければいい)。そこで、LogicLooperから届いたデータのうち、繋がってるユーザーに届ける必要がないデータは、MagicOnionの持つユーザーのステートを使ってカリング処理をして、そもそも転送しなかったり間引いたりして通信量を削減することで、ユーザーの体験が良くなります。

各ユーザーから届くデータを使ったステート更新/データ送信に関しては、LogicLooperがゲームループ状になっているので、ループの間に溜まったデータをもとにしてバッチ処理を行えばいいでしょう。バッチ化というと、通信「回数」の削減のためのコマンドを単純にまとめあげて一斉送信するものと、内容を見て処理内容を縮小するパターンが考えられますが、LogicLooperを使ったアプローチでは後者を効率的に行なえます。前者のコマンドの一斉送信に関しては、AlterNatsが裏側で自動パイプライニング化としてまとめているので(後で詳しく説明します)、そこに関しても効率化されています。

このアーキテクチャで気になるのがPubSub通信のオーバーヘッドですが、それに関しての解決策がAlterNatsで、究極的に高速なクライアントがあれば(さすがにインメモリには到底及ばないとはいえ)、そもそものクライアントとサーバーの間にもネットワークがいるわけで、経路のトータルで見れば実用的な範囲に収められる。という想定で作りました。

ところで、そして究極的な利点は、全てC#で組めるということです。どういうことかというと、MagicOnionもLogicLooperも汎用的なC#フレームワークです。特別なプラグインを差し込んで処理するというわけではなくて、ふつーのC#コードをふつーに書くことで、それぞれの箇所に、アプリケーション固有のコードを仕込んでいくことができる。これが、本当の大きな利点です。専用のC++ミドルウェアを作って挟んで最適化できるぞ!などといったシステムは、素晴らしいことですが、専門性が高く再現性が低い。MagicOnionとLogicLooper、そしてAlterNatsを活用したこの構成なら、C#エンジニアなら誰でも(容易に)できる構成です。Cysharpのメッセージは「C#の可能性を切り開いていく」ですが、誰もが実現できる世界を作っていくというのが目標でもあります。

なお、ワーカーとしてのLogicLooperを作るにWorker Serviceという.NET 6からのプロジェクトタイプが適切です。

ハイパフォーマンスSocketプログラミング

  • Socket API

C#で最も低レベルにネットワーク処理を扱えるクラスはSocketです。そして、非同期でハイパフォーマンスな処理を求めるならSocketAsyncEventArgsをうまく再利用しながらコールバックを仕込む必要があります。

これは非常に厄介で些か難易度も高いのですが、現在はasync/awaitの時代、ちゃんとawaitできる***Asyncメソッド郡が用意されています。しかし、使ってはいけないAPI、使ってはいけないオーバーロードも並んでいるので、その選別が必要です。SocketのAPIは歴史的事情もあり混沌としてしまっているのです……。

使うべきAPIを分かりやすく見分ける手段があります。それは戻り値が ValueTask のものを選ぶことです。

public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken)
public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken)
public ValueTask<int> SendAsync(ReadOnlyMemory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken))

オーバーロードにはTask返しのものもあるので、気をつけてください。

// これらのAPIは使ってはいけない
public Task ConnectAsync(string host, int port)
public Task<int> ReceiveAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)
public Task<int> SendAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)

ValueTask返しのAPIは内部的には AwaitableSocketAsyncEventArgs というものがValueTaskの中身になるようになっていて、これがいい感じに使いまわされる(awaitされると内部に戻るようになっている)ことで、Taskのアロケーションもなく効率的な非同期処理を実現しています。SocketAsyncEventArgsの使いにくさとは雲泥の差なので、これは非常にお薦めできます。

また、同期APIはSpanを受け取れるのですが、非同期APIは(ステートをヒープに置く都合上)Memoryしか受け取れないことには注意してください。これはSocketプログラミングに限らず非同期系APIにおける一般的な話で、全体的に上手く組んでおかないと、Spanが使えないことが障壁になることがあります。必ず、Memoryで取り回せるようにしておきましょう。

  • テキストプロトコルのバイナリコード判定

NATSのプロトコルはテキストプロトコルになっていて、文字列処理で簡単に切り出すことができます。実際これはStreamReaderを使うことで簡単にプロトコルの実装ができます。ReadLineするだけですから。しかし、ネットワークに流れるのは(UTF8)バイナリデータであり、文字列化は無駄なオーバーヘッドとなるため、パフォーマンスを求めるなら、バイナリデータのまま処理する必要があります。

NATSでは先頭の文字列(INFO, MSG, PING, +OK, -ERRなど)によって流れてくるメッセージの種類が判定できます。文字列処理で空白でSplitして if (msg == "INFO") などとすればめちゃくちゃ簡単ですが、先にも言った通り文字列変換は意地でも通しません。INFOは[73, 78, 70, 79]なので、Slice(0, 4).SequenceEqual で判定するのは悪くないでしょう。ReadOnlySpan<byte>のSequenceEqualはめちゃくちゃ最適化されていて、長いものであれば必要であればSIMDとかも使って高速に同値判定します。LINQのSequenceEqualとは別物です!

しかし、もっと欲張って見てみましょう、プロトコルの識別子はサーバーから送られてくるものは全て4文字以内に収まっています。つまり、これはIntに変換しやすい状態です!というわけで、AlterNatsのメッセージ種判定コードはこうなっています。

// msg = ReadOnlySpan<byte>
if(Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference<byte>(msg)) == 1330007625) // INFO
{
}

これ以上速い判定はできないと思うので、理論上最速ということでいいでしょう。3文字の命令も、直後に必ずスペースや改行が来るので、それを含めた以下のような定数を使って判定に回しています。

internal static class ServerOpCodes
{
    public const int Info = 1330007625;  // Encoding.ASCII.GetBytes("INFO") |> MemoryMarshal.Read<int>
    public const int Msg = 541545293;    // Encoding.ASCII.GetBytes("MSG ") |> MemoryMarshal.Read<int>
    public const int Ping = 1196312912;  // Encoding.ASCII.GetBytes("PING") |> MemoryMarshal.Read<int>
    public const int Pong = 1196314448;  // Encoding.ASCII.GetBytes("PONG") |> MemoryMarshal.Read<int>
    public const int Ok = 223039275;     // Encoding.ASCII.GetBytes("+OK\r") |> MemoryMarshal.Read<int>
    public const int Error = 1381123373; // Encoding.ASCII.GetBytes("-ERR") |> MemoryMarshal.Read<int>
}

バイナリプロトコルなら特に何のひねりも必要なく実装できるので、バイナリプロトコルのほうが実装者に優しくて好きです……。

  • 自動パイプライニング

NATSプロトコルの書き込み、読み込みは全てパイプライン(バッチ)化されています。これはRedisのPipeliningの解説が分かりやすいですが、例えばメッセージを3つ送るのに、一つずつ送って、都度応答を待っていると、送受信における多数の往復がボトルネックになります。

メッセージの送信において、AlterNatsは自動でパイプライン化しています。System.Threading.Channelsを用いてメッセージは一度キューに詰め込まれ、書き込み用のループが一斉に取り出してバッチ化します。ネットワーク送信が完了したら、再び送信処理待ち中に溜め込まれたメッセージを一括処理していく、という書き込みループのアプローチを取ることで、最高速の書き込み処理を実現しました。

image

ラウンドトリップタイムの話だけではなく(そもそもNATSの場合はPublish側とSubscribe側が独立しているので応答待ちというのもないのですが)、システムコールの連続した呼び出し回数を削減できるという点でも効果が高いです。

なお、.NET最高速ロガーであるZLoggerでも同じアプローチを取っています。

  • 一つのオブジェクトに機能を盛る

Channelに詰め込む都合上、データを書き込みメッセージオブジェクトに入れてヒープに保持しておく必要があります。また、書き込み完了まで待つ非同期メソッドのためのPromiseも必要です。

await connection.PublishAsync(value);

こうしたAPIを効率よく実装するために、どうしても確保する必要のある一つのメッセージオブジェクト(内部的にはCommandと命名されている)に、あらゆる機能を同居して詰め込みましょう。

class AsyncPublishCommand<T> : ICommand, IValueTaskSource, IThreadPoolWorkItem, IObjectPoolNode<AsyncPublishCommand<T>>

internal interface ICommand
{
    void Write(ProtocolWriter writer);
}

internal interface IObjectPoolNode<T>
{
    ref T? NextNode { get; }
}

このオブジェクト(AsyncPublishCommand<T>)自体は、T dataを保持して、Socketにバイナリデータとして書き込むための役割(ICommand)をまずは持っています。

それに加えてIValueTaskSourceであることにより、このオブジェクト自身がValueTaskになります。

そしてawait時のコールバックとして、書き込みループを阻害しないためにThreadPoolに流す必要があります。そこで従来のThreadPool.QueueUserWorkItem(callback)を使うと、内部的には ThreadPoolWorkItem を生成してキューに詰め込むため、余計なアロケーションがあります。 .NET Core 3.0からIThreadPoolWorkItemを実装することで、内部のThreadPoolWorkItemの生成をなくすことができます。

最後に、同居させることで必要なオブジェクトが1つになりましたが、その1つをプーリングしてゼロアロケーション化します。オブジェクトプールはConcurrentQueue<T>などを使うと簡単に実装できますが、自分自身をStackのNodeにすることで、配列を確保しないで済むようにしています。また、Nodeの出し入れに関しては、今回のキャッシュの実装では正確に取り出せる必要性はないため、lockは使わず、マルチスレッドで競合が発生した場合はキャッシュミス扱いにして新規生成するようにしています。これはオブジェクトプーリングにおける性能バランスとしては、良いチョイスだと考えています。

internal sealed class ObjectPool<T>
    where T : class, IObjectPoolNode<T>
{
    int gate;
    int size;
    T? root;
    readonly int limit;

    public ObjectPool(int limit)
    {
        this.limit = limit;
    }

    public int Size => size;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public bool TryPop([NotNullWhen(true)] out T? result)
    {
        // Instead of lock, use CompareExchange gate.
        // In a worst case, missed cached object(create new one) but it's not a big deal.
        if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)
        {
            var v = root;
            if (!(v is null))
            {
                ref var nextNode = ref v.NextNode;
                root = nextNode;
                nextNode = null;
                size--;
                result = v;
                Volatile.Write(ref gate, 0);
                return true;
            }

            Volatile.Write(ref gate, 0);
        }
        result = default;
        return false;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public bool TryPush(T item)
    {
        if (Interlocked.CompareExchange(ref gate, 1, 0) == 0)
        {
            if (size < limit)
            {
                item.NextNode = root;
                root = item;
                size++;
                Volatile.Write(ref gate, 0);
                return true;
            }
            else
            {
                Volatile.Write(ref gate, 0);
            }
        }
        return false;
    }
}
  • Zero-copy Architecture

Publish/Subscribeするデータは通常、C#の型をJSONやMessagePackなどにシリアライズしたものを流します。この場合、どうしてもbyte[]でやり取りすることが多くなります、例えばStackExchange.RedisのRedisValueの中身は実質byte[]で、送信にせよ受信にせよ、byte[]を生成して保持することになります。

これを避けるために、ArrayPoolから出し入れしてごまかしてゼロアロケーションにする、みたいなのはありがちではありますが、それでもコピーのコストが発生していることには代わりありません。ゼロアロケーションは当然目指すところですが、ゼロコピーに向けても頑張りましょう!

AlterNatsのシリアライザーはWriteにIBufferWriter<byte>, ReadにReadOnlySequence<byte>を要求します。

public interface INatsSerializer
{
    int Serialize<T>(ICountableBufferWriter bufferWriter, T? value);
    T? Deserialize<T>(in ReadOnlySequence<byte> buffer);
}

public interface ICountableBufferWriter : IBufferWriter<byte>
{
    int WrittenCount { get; }
}
// 例えばMessagePack for C#を使う場合の実装
public class MessagePackNatsSerializer : INatsSerializer
{
    public int Serialize<T>(ICountableBufferWriter bufferWriter, T? value)
    {
        var before = bufferWriter.WrittenCount;
        MessagePackSerializer.Serialize(bufferWriter, value);
        return bufferWriter.WrittenCount - before;
    }

    public T? Deserialize<T>(in ReadOnlySequence<byte> buffer)
    {
        return MessagePackSerializer.Deserialize<T>(buffer);
    }
}

System.Text.JsonやMessagePack for C#のSerializeメソッドにはIBufferWriter<byte>を受け取るオーバーロードが用意されています。IBufferWriter<byte>経由でSocketに書き込むために用意しているバッファーにシリアライザが直接アクセスし、書き込みすることで、Socketとシリアライザ間でのbyte[]のコピーをなくします。

image

Read側では、ReadOnlySequence<byte>を要求します。Socketからのデータの受信は断片的な場合も多く、それをバッファのコピーと拡大ではなく、連続した複数のバッファを一塊として扱うことでゼロコピーで処理するために用意されたクラスがReadOnlySequence<T>です。

「ハイパフォーマンスの I/O をより簡単に行えるように設計されたライブラリ」であるSystem.IO.PipelinesPipeReaderで読み取ったものを扱うのが、よくあるパターンとなります。ただし、AlterNatsではPipelinesは使わずに独自の読み取り機構とReadOnlySequence<byte>を使用しました。

System.Text.JsonやMessagePack for C#のSerializeメソッドにはIBufferWriter<byte>を受け取るオーバーロードが用意されているため、それを直接渡すことができます。つまり、現代的なシリアライザはIBufferWriter<byte>ReadOnlySequence<byte>のサポートは必須です。これらをサポートしていないシリアライザはそれだけで失格です。

まとめ

プロトコルが単純で少ないのでちゃちゃっと作れると思いきや、まあ確かに雑にTcpClientとStreamReader/Writerでやれば秒殺だったのですが、プロトコルって量産部分でしかないので、そこがどんだけ量少なかろうと、基盤の作り込みは相応に必要で、普通に割と時間かかってしまった、のですが結構良い感じに作れたと思います。コード的にも例によって色々な工夫が盛り込まれていますので、是非ソースコードも読んでみてください。

クライアント側の実装によってパフォーマンスが大きく違うというのはシリアライザでもよくあり経験したことですが、NATSのパフォーマンスを論じるにあたって、その言語のクライアントは大丈夫ですか?というところがあり、そして、C#は大丈夫ですよ、と言えるものになっていると思います。

NATSの活用に関してはこれからやっていくので実例あるんですか?とか言われると知らんがな、というところですが(ところでMagicOnionはこないだのプリコネ!グランドマスターズだけではなく最近特によくあるので、実例めっちゃあります)、これから色々使っていこうかなと思っているので、まぁ是非AlterNatsと共に試してみてください。

プリコネ!グランドマスターズのサーバー開発をCysharpが開発協力しました

Cygamesから4/1にリリースされたプリコネ!グランドマスターズのサーバーサイドとインフラ開発をCysharpが開発協力しました。リアルタイム通信を含むオートバトラー系のゲームです。

image

image

Cysharpはサーバー側のアーキテクチャ設計と基盤実装、クラウドインフラ構築、一部サーバーロジック実装を担いました。リアルタイム通信部分だけではなくてAPIサーバーからマッチメイキング、インフラまで、構成されるあらゆる要素がC#で作られています!

  • クライアント (Unity)
  • API サーバー(MagicOnion)
  • バトルエンジンサーバー (リアルタイム通信; MagicOnion, LogicLooper)
  • マッチメイキングサーバー (リアルタイム通信; MagicOnion)
  • バッチ(ConsoleAppFramework)
  • デバッグ機能サーバー (Web; Blazor)
  • 管理画面サーバー (Web; Blazor)
  • インフラ (Infrastructure as Code; Pulumi + C#)

サーバー側アプリケーションは.NET 6をKubernetes上で動かしています。Unityクライアント側でもCysharpのOSSは7つクレジットされていますが、表記のないサーバー側専用のものを合わせたら10個以上使用しています。ここまで徹頭徹尾C#でやっているプロジェクトは世界的にも珍しいんじゃないでしょうか。中心的に活躍しているのはMagicOnionですが、サーバーサイドゲームループのためのLogicLooper、負荷テストのためのDFrameなども実戦投入されて、成果を出しました。サーバートラブルも特になく、しっかり安定稼働しました。という事後報告です。そして今日、もとより期間限定公開ということで一週間の配信期間が終了しました。

アーキテクチャ含めの詳しい話は後日どこかでできるといいですね……!今回、私は実装者としては裏方というか監督というかという感じなので、発表する際は別の人にお任せします……!

C#でのサーバー構成をまた一つ実証できて、参考になって欲しいのですが(そしてC#採用事例増えて欲しい!)、こういった構成を、Cysharpだから出来る、のではなくて、誰もが実現できる環境にしていきたいとも思っています。重要なパーツは積極的にOSS化していますし、実績も着実に積み重ねられています。が、しかしまだまだ難しい面も数多くあるということは認識しています。かといってmBaaSの方向でやっていくべき、とは思わないんですね。ロジックはゲームの差別化のための重要な要素であり、サーバーサイドでも書くべきで。だから注力しているのは書きやすくするための環境で、そのために足りないものを提供していっています。

ところでサーバーとクライアントの繋ぎ、あるいはサーバーとサーバーの繋ぎが、MagicOnionだけだと複雑で難しくなってしまうところがあるな、と思っていまして、ちょうど先月-今月はメッセージングライブラリの開発に注力しています。AlterNatsという名前でPreview公開していますが、これを挟むと色々改善されるんじゃないかなあ、と思っているので、少々お待ち下さい。そんな感じに、常により最善のC#アーキテクチャの探究と、OSSを通じた共有をまだまだ続けていきます。

DFrame - C#でテストシナリオを書く分散負荷テストフレームワーク

と、いうものをリリースしました。Web UIとなるDFrame.Controllerと、負荷テストシナリオをC#で書くDFrame.Workerの組み合わせで成り立っていて、DFrame.Workerをウェブ上のクラスターに配置することで(Controllerと接続するただの常駐アプリなので、配置先はオンプレでもVMでもコンテナでもKuberenetesでもなんでもいい)、1から数千のワーカーが連動して、大量のリクエストを発生させます。また、テストシナリオをプレーンなC#で記述できるということは、HTTP/1だけではなく、あらゆる種類の通信をカバーできます。WebSocket、HTTP/2、gRPC、MagicOnion、あるいはPhotonや自作のTCPトランスポート、更にはRedisやデータベースなどが対象になります。

DFrame.Workerは通常の.NETの他に、Unityにも対応しています!つまり、大量のHeadless Unity、あるいはデバイスファームに配置することで、Unityでしか動かないような独自通信フレームワークであっても負荷テストをかけることが可能です。

また、あまり注目されていませんが負荷テストツールにもパフォーマンスの違いは「かなり」あり、性能の良さは重要で、そこのところにもかなりチューニングしました。

image

Web UI(DFrame.Controller)はBlazor Serverで作られていて、分散ワーカーとの通信はMagicOnionで行っています。自動化のためのWeb APIの口もあるため、Blazor Server, ASP.NET Minimum API, MagicOnionのキメラ同居なアーキテクチャでC#でフル活用なのが設計的にも面白いポイントです。

C#で負荷テストシナリオを書く意義

負荷テストフレームワークは世の中に山のようにあります。代表的なものでもab, jMeter, k6, Artillery, Gatling, wrk, bombardier, Locust、k6やArtillery、GatlingなどはSaaSとしても提供していますし、クラウドサービス側も、Azure Load Testing(Managed jMeter)のようなマネージドサービスを出していますし、.NETでもdotnet/crankというものが存在していたりします。

DFrameはこの中でいうとアーキテクチャ含めLocustに近い(Controller-Worker構成やWebUIなど)のですが、その特徴の中で重要な点として挙げられているのが、シナリオをコードで書けること、です。よくわからんUIで設定させたり、複雑怪奇なXMLやYAMLやJSON書かせたりせず、プレーンなコードで書ける。これが大事。LocustはPythonですが、他にk6はJavaScriptで書けるようになっています。

じゃあLocustでいいじゃん、k6でいいじゃん、という話になるのですが、C#で書きたいんですね、シナリオを。これは別にただ単に自分の好きな言語で書きたいからというわけではなくて、サーバーあるいはクライアント言語と負荷試験シナリオ作成言語は同一のものであるべきだからです。例えばUnityのゲームを開発している場合(サーバーサイドの言語は何でもいい)、UnityのゲームはC#で記述されていますが、その場合C#でテストシナリオが書けるのなら

  • 最初からクライアントSDK(エンドポイントと型付きのRequest/Response)に相当するものがある
  • クライアントの実装と完全に等しいのでゲームのドメインロジックが最初からある

となります。それによりテストシナリオの記述の手間を大幅に削減できます。もちろん、Unity依存の部分を引き剥がすなどの追加の作業は必要ですが、完全に書き起こすなどといった無駄は発生しません。もしPythonでもJavaScriptでもLuaでも、とにかく異なる言語である場合は、比較にならないほどに作業量が膨大になってきます。

そして実際のクライアントコードとある程度共通になることで、サーバー/クライアント側の変化への追随が用意になります。それにより一回のリリースのための負荷テストではなく、継続的な負荷テスト環境を作っていけます。

また、プレーンなC#で記述できることで、冒頭にも書きましたがあらゆる通信の種類をカバーできるのは、通信プロトコルが多様化している昨今、大きな利点となります。

DFrameApp.Run

NuGetからDFrameをパッケージ参照したうえで、一行で起動します。テストシナリオ(Workload)の記述の行数もありますが、それでもこれだけで。

using DFrame;

DFrameApp.Run(7312, 7313); // WebUI:7312, WorkerListen:7313

public class SampleWorkload : Workload
{
    public override async Task ExecuteAsync(WorkloadContext context)
    {
        Console.WriteLine($"Hello {context.WorkloadId}");
    }
}

これで http://localhost:7312 をブラウザで開けば、SampleWorkloadがいます。

image

と、いうわけで、WorkloadのExecuteAsyncにコードを書くのが基本です。ExecuteAsync前の準備用としてSetupAsync、後始末としてTeardownAsyncもあります。単純なgRPCのテストを書くとこなります。

public class GrpcTest : Workload
{
    GrpcChannel? channel;
    Greeter.GreeterClient? client;

    public override async Task SetupAsync(WorkloadContext context)
    {
        channel = GrpcChannel.ForAddress("http://localhost:5027");
        client = new Greeter.GreeterClient(channel);
    }

    public override async Task ExecuteAsync(WorkloadContext context)
    {
        await client!.SayHelloAsync(new HelloRequest(), cancellationToken: context.CancellationToken);
    }

    public override async Task TeardownAsync(WorkloadContext context)
    {
        if (channel != null)
        {
            await channel.ShutdownAsync();
            channel.Dispose();
        }
    }
}

Concurrencyの数だけWorkloadが生成されて、Total Request / Workers / Concurrencyの数だけExecuteAsyncが実行されます。コードで書くと言っても別にそう複雑なこともなく、よくわからんDSLで書くわけでもないので、むしろ(C#が書けるなら)とても書きやすいでしょう。中身も見てのとおり単純なので、gRPCでもMagicOnionでも何でも実行できます。

引数を受け取ることも可能なので、任意のURLを渡すようなものも作れます。コンストラクタでは、パラメーター、あるいはDIでインジェクトしたインスタンスを受け取れます。

using DFrame;
using Microsoft.Extensions.DependencyInjection;

// use builder can configure services, logging, configuration, etc.
var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureServices(services =>
{
    services.AddSingleton<HttpClient>();
});
await builder.RunAsync();

public class HttpGetString : Workload
{
    readonly HttpClient httpClient;
    readonly string url;

    // HttpClient is from DI, URL is passed from Web UI
    public HttpGetString(HttpClient httpClient, string url)
    {
        this.httpClient = httpClient;
        this.url = url;
    }

    public override async Task ExecuteAsync(WorkloadContext context)
    {
        await httpClient.GetStringAsync(url, context.CancellationToken);
    }
}

image

WebUI画面にString urlの入力箇所が現れて、好きなURLを叩き込むことができるようになりました。

なお、単純なHTTPのGET/POST/PUT/DELETEをテストしたいという場合は、IncludesDefaultHttpWorkloadを有効にしてもらうと、内蔵のパラメーターを受け取るWorkloadが追加されます。

using DFrame;

var builder = DFrameApp.CreateBuilder(7312, 7313);
builder.ConfigureWorker(x =>
{
    x.IncludesDefaultHttpWorkload = true;
});
builder.Run();

分散テスト

Workerは起動時に指定したControllerのアドレスにHTTP/2(MagicOnion/gRPC)で繋ぎに行って、常駐します。という普通の(?)アプリケーションなので、ウェブサーバーを分散させるのと同様に複数のWorkerを立ち上げてもらえれば、自動的に繋がります。

構成としては、以下の画像のようにControllerとWorkerのプロジェクトを分けるのが正当派(?)ですが

同居させてしまって、起動時のコマンドライン引数でどちらかのモード(あるいは両方)が起動するようにすることも、ローカルでの開発がしやすくなるのでお薦めです。 DFrameApp.CreateBuilder にはそのための補助的な機構が用意されています。

using DFrame;

var builder = DFrameApp.CreateBuilder(5555, 5556); // portWeb, portListenWorker

if (args.Length == 0)
{
    // local, run both(host WebUI on http://localhost:portWeb)
    await builder.RunAsync();
}
else if (args[0] == "controller")
{
    // listen http://*:portWeb as WebUI and http://*:portListenWorker as Worker listen gRPC
    await builder.RunControllerAsync();
}
else if (args[0] == "worker")
{
    // worker connect to (controller) address.
    // You can also configure from appsettings.json via builder.ConfigureWorker((ctx, options) => { options.ControllerAddress = "" });
    await builder.RunWorkerAsync("http://foobar:5556");
}

ローカルでWorkerの.exeを複数実行する、とかでも手元でとりあえずのWorker connectionsが増える様は確認できます。

Workerを増やすと表がにぎやかになって楽しい。実行するWorkerの数はスライダーで調整できるので、各種パラメーターを台数1で調整したあとに、徐々に実行Workerを増やしていく、といった使い方も可能です。また、その辺を自動でやってくれるRepeatモード(TotalRequestとWorkerを完了後に指定数増やして繰り返す)も用意しました。jMeterでいうところのRamp-Upの代わりに使えればいいかな、という想定でもあります。

アーキテクチャ的に最初から分散前提で作られているというのもあり、増やしても性能が劣化しない、リニアに性能が向上していくように作りました。Controllerは単一なのでスケールしないのですが、なるべく多くのWorkerをぶら下げられるように工夫しています。Controller <-> WorkerはMagicOnionで通信しているので、DFrame自身がMagicOnionの負荷テストになっているのです。

パフォーマンス

多数ある負荷テストフレームワークですが、パフォーマンスはそれぞれかなり異なります。詳しくはk6のブログOpen source load testing tool review 2020に非常に詳細に書かれていますが、例えばとにかくwrkがぶっちぎって他の数十倍~数百倍速かったりする、と。パフォーマンスは当然ながらとても重要で、ワーガーの非力さでターゲットに負荷をかけきれなかったりします。それに対応するためクラスターを組んでいくにしても、多くの台数やより高いスペックのマシンが必要になって、色々と辛い。

というわけでパフォーマンスは高ければ高いほうがいいのですが、先のブログに書かれている通り、拡張性の口やレポート取り出しの口などは必要です。その点でWrkは機能を満たさないということで、ブログではなんか結果から取り除かれてますね(その対応がいいのかどうかはなんとも言えませんが、まぁk6自身のアピールのためでもあるのでしょうがないね)。ちなみにフレームワークのパフォーマンスの指標として使われているTechEmpower Web Framework Benchmarksの負荷クライアントはwrkのようです。

さて、で、DFrameはどうかというと、かなり良好です。というのも、DFrameはライブラリとして提供されて、実行時は全てがC#の実行ファイルとしてコンパイル済みの状態になるのですね。スクリプトを動的に読んで実行するから遅くなってしまう、みたいなことがない。比較的高速な言語であるC#をそのまま利用するので、その時点である程度はいける。理論上。理屈上。

と、いう甘い見込みのもと実際作っていくと、さすがにそこまでさっくりとはいかず、相応にチューニングが必要だったのですが、最終的にはかなりの数字が出るようになりました。比較としてabとk6で測ってみると

image

image

image

本来はターゲットとワーカーは別マシンにしないといけないのですが(ワーカーの負荷でCPUが跳ね上がる影響をサーバー側がモロに影響受けてしまうので)、それでもそれなりに数字は変動しますし動きはするしマシンパワーも結構強め(Ryzen 9 5950x)なので、ちょっと手抜きでlocalhost上の無を返すHTTP/1サーバーをターゲットに、32並列(-c 32, -32VUs, Concurrency=32)で実行。

abが、6287 req/sec、k6が125619 req/sec、DFrameが207634 req/secです。abは、厳しい、厳しい……。もっと出るはずと思っているんですが、私の環境(Windows)だと昔からこんな感じなので、性能的には信用できないかなぁ。Windowsだとダメだったりするのかもしないのかもしれませんね。DFrameの場合Concurrencyにまだ余裕があって、増やすとまだまだ伸びたのですが、k6は割と頭打ちでした。

また、画像は出してませんがLocustは残念ながらかなり遅い上にCPUを食いまくるという感じで(Pythonだしね……)、いくらクラスタ化が容易とはいえ、ここまで1ワーカーあたりの性能が低いと、ないかなあ、という感想です。JMeterはそこまで悪くはないですが、パフォーマンスに影響を与える地雷コンフィグを必死にかいくぐってなおそこそこ程度なのはしんどみ。

ちなみになんで圧倒的性能番長であるwrkと比較しないのかというと、Windowsで動かすのが大変だからです。すみません……。

自動化のためのREST API

最初はいいけど、毎回GUIでポチポチやるの面倒で、それはそれで嫌だよね。CIで定期的に回したりもできないし。というわけで、バッチ起動モード、はついていないのですが、代わりにREST APIが自動で有効になっています。例えば /api/connections で現在接続中のワーカーコネクション数が取れます。実行パラメーターなどはPostでJSONを投げる形になっています。

REST APIでJSONをやり取りするだけなので、どの言語から叩くことも可能ですが、C#の場合は DFrame.RestSdk パッケージにて型付けされたクライアントが用意されているので、手間なくはじめられます。

using DFrame.RestSdk;

var client = new DFrameClient("http://localhost:7312/");

// start request
await client.ExecuteRequestAsync(new()
{
    Workload = "SampleWorkload",
    Concurrency = 10,
    TotalRequest = 100000
});

// loadtest is running, wait complete.
await client.WaitUntilCanExecute();

// get summary and results[]
var result = await client.GetLatestResultAsync();

実行状況は全て連動しているので、REST APIから実行した進捗もWeb UI側でリアルタイムに状況確認できます。

Unityでも動く

Unityで動かしやすいかといったら全然そんなことないので、動かせるようにするのはもはや執念という感じではあるのですが、Unity対応しました。冒頭で書いたようにヘッドレスUnityを並べてコントロールする、みたいな用途は考えられます。まぁ、あと普通の負荷テストでも、通信部分のC#を普通の .NET に切り出すのが面倒だという場合に、ヘッドレスUnityでとりあえずビルドすることで何もしなくてもOK(そうか?)という策もあります。

Unityで動かす場合は、依存の解決(MagicOnion、gRPC、MessagePack for C#)が大変です!まぁ、それは置いておいて。それが出来ているなら、以下のようなMonoBehaviourに寿命をくっつけたインスタンスで起動させると良い感じです(MagicOnionというかネイティブgRPCは適切にコネクションをCloseしないとUnity Editorがフリーズするという酷い問題があるのですが、このコードは問題なくちゃんとクリーンアップしてくれるようになっています)。

public class DFrameWorker : MonoBehaviour
{
    DFrameWorkerApp app;

    [RuntimeInitializeOnLoadMethod]
    static void Init()
    {
        new GameObject("DFrame Worker", typeof(SampleOne));
    }

    private void Awake()
    {
        DontDestroyOnLoad(gameObject);
    }

    async void Start()
    {
        // setup your controller address
        app = new DFrameWorkerApp("localhost:7313");
        await app.RunAsync();
    }

    private void OnDestroy()
    {
        app.Dispose();
    }
}

[Preserve]
public class SampleWorkload : Workload
{
    public override Task ExecuteAsync(WorkloadContext context)
    {
        Debug.Log("Exec");
        return Task.CompletedTask;
    }

    public override Task TeardownAsync(WorkloadContext context)
    {
        Debug.Log("Teardown");
        return Task.CompletedTask;
    }
}

// Preserve for Unity IL2CPP

internal class PreserveAttribute : System.Attribute
{
}

Editor上の確認だとこんな具合です。

image

ライブラリかツールか

DFrame.Controller、他の設定を入れなければただのウェブアプリなので、ビルド済みのexeとしての提供も可能です。Locustなど他のツールも入れたら、とりあえず実行できる、のに比べると、必ず自分で組み込んでビルドしなきゃいけない。のは欠点に見える。

なのでビルド済みコンテナをDocker Hubかなんかで提供するという案もあったのですが、Workerはどうしても自分で組み込んでビルドする必要があるので、そこだけ省けても利点あるのかな?と考えて、最終的に却下しました。かわりに DFrameApp.Run の一行だけでController+Workerの同居が起動できるようにして、最初の一歩の面倒臭さをライブラリデザインの工夫で乗り切ることにしました。Controller自体も、Microsoft.NET.Sdk.Webではなく、コンソールアプリケーションのテンプレートのMicrosoft.NET.Sdkから起動できるようにしました。

DFrame.Controllerがライブラリとして提供されていることのメリットは、コンフィグが通常のコードやASP.NETの仕組みに乗っかったほうが圧倒的にシンプルになります。DIで好きなロガーを設定して、URLの指定やSSLなどもappsettings.jsonで行うのは、大量の複雑怪奇なコマンドラインオプションよりもずっと良いでしょう。

ログの永続化処理も、プラグイン的に用意するのではなく、普通にDIでインジェクトしてもらう(IExecutionResultHistoryProviderというものが用意されていて、これを実装したものをDIに登録してもらえば、結果をデータベースに入れたり時系列DBに入れたりして統計的な参照ができるようになります)ほうが、使いやすいはずです。

Blazor Server + MagicOnion

DFrame.ControllerはBlazor ServerとMagicOnion(grpc-dotnet)が同居した構成になっています。これは中々面白い構成で、Web UIとMagicOnion(Server側)が同じメモリを共有しているので、末端のMagicOnion(Client側)の変更をダイレクトにC#だけを通してブラウザにまで届けているんですね。逆もしかりで、APIからのアクセス含めて、全てがリアルタイムに伝搬して画面も同期しているのですが、普通にやるとかなり複雑怪奇になるはずが、かなりシンプルに実装できています。

と、いうわけで、Cysharpではこの組み合わせに可能性を感じていて、別のサービスも同種のアーキテクチャで絶賛制作中なので興味ありましたら以下略。

紆余曲折

最初のバージョンは2年ぐらい前に作っていました。コンセプトは「自己分裂する分散バッチフレームワーク」ということで、自分自身のコピーを動的に作って無限大に分散して実行していくというもので。分散のための基盤としてKubernetesを使って。クラウドネイティブ!かっこいい!そして、一応動くものはできていました。あとは仕上げていくだけ、といったところで、放置していました。完成させなきゃ、と思いつつ、内心薄々あんまいい感じではないな、と思っていたため手が進まず無限放置モードへ。そして時が流れ、社内でもがっつり使うことになり引っ張り出されてきたそれは、やはりあまりいい感じではなく。で、最終的に言われたんですね、そもそも分裂機能いらなくね?と。

それでようやく気づくわけです、コンセプトから完全に間違っているからうまくいくわけがない!

反省として良くなかった理由としては、まず、現代のクラウドコンピューターを過大に評価していた。「自己分裂する」のは、一瞬で無限大にスケールして即起動、そして終わったら即終了、ならば、まぁそれでいいんですが、現実のスケールする時間はそんなに立派じゃない。サーバーレスといいつつ、別に1リクエスト毎にコンテナが起動して処理するわけはなく、常駐してリクエストを待つ。そりゃそうだ、と。自己分裂のコンセプトだと、分裂コストが重たいのは否めない。

もう一つは分裂するためのコードがDFrame内に記述されている。Kuberentesをコントロールするコードがたっぷり入ってしまって。そのせいでコードサイズが膨らんでしまったし、使う方も複雑なコンフィグをDFrame側に埋めなきゃいけなくなってしまった。これは二重にイケてない。作るのも複雑で、使うのも複雑ですからね、いいところがない……。

と、いうわけで、最初のかっこいいコンセプトを否定して、自己分裂しない。単純に繋ぎに行くだけ。としたことで、頭を抱えてうまくいかないと感じていた行き詰まりは解消したのでした。

まとめ

もう少し早くに作って提供したかった、という後悔がめっちゃあるのですが、同時に .NET 6だから出来たという要素もめっちゃあるので(パラメーター渡しの仕組みなどはConsoleAppFramework v4の設計の経験からスムーズに実装できた)、しょーがない。という気もする。Blazor Serverなどの進化も必要だったし。

しかし↑で書いたとおり最初に立てたコンセプトが間違っていて、長いこと軌道修正できず放置してしまっていたというのは個人的には割と手痛い経験です……。まぁ、間違ったコンセプトのまま進行してしまうというのは別によくあるので、それはしょーがないものとして別にいいんですが、自力で気づいてパーッと作り上げられてたらなあ、みたいな、みたいな。。。

ともあれ、完成したものとしてはかなり良い感じで(私の出すものとしては珍しくUIもちゃんとついているし!←UI作業は他の人に助力を請うてます)、ちょっとニッチ感もありますがC#アプリケーション開発の必需品として成り得る出来だと思っていますので、ぜひぜひお試しください。

WebSerializer - オブジェクトからクエリストリングに変換するHttpClientリクエスト用シリアライザ

T valueから URLエンコードされたクエリストリング、またはx-www-form-urlencodedなHttpContentを生成する、つまりはウェブ(HTTP/1)リクエスト用のシリアライザを作りました。

クエリストリングの生成、意外と面倒くさいな!と。(C#用の)専用のSDKが存在しないWeb APIの場合は、自分でURL組み立てたりFormUrlEncodedContentを組み立てたりしますが、数が多いとまぁ面倒くさい。リクエストのパラメーター数が多いと、null抜いたりも面倒くさい。

レスポンス側はReadFromJsonAsyncなどでダイレクトに変換できるようになって特に問題はないのですが、リクエスト側は、かなりの手作業が要求されます。そのへんを全部やってくれるrefitというライブラリもありますが(Androidのretrofitにインスパイアされたもの)、導入するにはちょっと大仰だな、と思うときも多々あります、というか私は今まで一度も使ってません。

HttpClient用にURLを組み立てるのを簡略化してくれるぐらいでいいな、と思って考えていたら、そういえばそもそもそれってT valueから何かに変換する、つまりシリアライザじゃん、ということに気づきました。T -> msgpack byte[]に変換すればMessagePackシリアライザだし、T -> Json stringに変換すればJSONシリアライザだし、これはT -> UrlEncoded stringに変換するということなのだと。シリアライザ脳なので、そう理解すれば話が早い。

using Cysharp.Web;

var req = new Request(sortBy: "id", direction: SortDirection.Desc, currentPage: 3)

// sortBy=id&direction=Desc&currentPage=3
var q = WebSerializer.ToQueryString(req);

await httpClient.GetAsync("/sort?"+ q);

// data...
public record Request(string? sortBy, SortDirection direction, int currentPage);

public enum SortDirection
{
    Default,
    Asc,
    Desc
}

基本的に使うメソッドは WebSerializer.ToQueryStringWebSerializer.ToHttpContent だけです。URLエンコードされてname=valueで&連結された文字列が取り出せます。メソッドとして叩いたりする場合は、そのまま匿名型で渡してあげればちょうど良い。urlも一緒に渡してあげれば全て同時に組み立ててくれます。値がnullのものは文字列化対象から自動で外されます。

const string UrlBase = "https://foo.com/search";

// null, SortDirection.Asc, 0
async Task SearchAsync(string? sortBy, SortDirection direction, int currentPage)
{
    // "https://foo.com/search?direction=Asc&currentPage=0"
    var url = WebSerializer.ToQueryString(UrlBase, new { sortBy, direction, currentPage });
    await httpClient.GetAsync(url);
}

動的に組み立てる場合は、Dictionary<string, object> も渡せます。(FormUrlEncodedContentDictionary<string, string>で、Value側のToString()が必須なのが地味に面倒くさいので、objectで良いというのは何気に楽だったりします)。

var req = new Dictionary<string, object>
{
    { "sortBy", "id" },
    { "direction", SortDirection.Desc },
    { "currentPage", 10 }
};
var q = WebSerializer.ToQueryString(req);

POST用には、ToHttpContentを使います。

async Task PostMessage(string name, string email, string message)
{
    var content = WebSerializer.ToHttpContent(new { name, email, message });
    await httpClient.PostAsync("/postmsg", content);
}

内部的にはFormUrlEncodedContentは使わずに、専用のHttpContentを通しているため、byte[]変換のオーバーヘッドがありません。

シリアライザ設計

ただたんにクエリストリング組み立てるだけっしょ!というと軽く見られてしまうかもしれないのですが、中身はかなりガチめに作ってあって、構成としてはMessagePack for C#と同様です。パフォーマンスに関しても超ギチギチに詰めているわけではないですが、かなり気を配って作られているので、手で組み立てるよりもむしろ高速になるケースも多いはずです。拡張性もかなり高く作れているはずです。

シリアライザのデザインに関してはMessagePack for C#の次期バージョン(v3)をどうしていこうかなあ、と考えているタイミングでもあるので、そのプロトタイプ的な意識もありますね。なので設計としてはむしろ最新型で、かなり洗練されています。.NET 5/6のみにしているので、レガシーも徹底的に切り捨てていますし。最初は .NET 6のみだったのですが、さすがにそれはやりすぎかと思い .NET 5は足しました。(が、2022-01-21のv1.3.0にて、.NET Standard 2.0/2.1対応もしました)

例えばコンフィグ(WebSerializerOptions)はイミュータブルなのですが、これ自体はrecordで作ってあってwith式でカスタムのコンフィグを作れます。

// CultureInfo: 数値型やDateTimeの文字列化変換に渡すCultureInfo、デフォルトはnull
// CollectionSeparator: 配列などを変換する場合のセパレーター、デフォルトはnullでname=value&name=value...
// Provider: 対象の型をどのように変換するか(`IWebSerialzier<T>`)の変更
var newConfig = WebSerializerOptions.Default with
{
    CultureInfo = CultureInfo.InvariantCulture,
    CollectionSeparator = ",",
    Provider = WebSerializerProvider.Create(
        new[] { new BoolZeroOneSerializer() },
        new[] { WebSerializerProvider.Default })
};

// Bool値を0, 1に変換する(こういうの求めてくるWeb APIあるんですよねー!)
public class BoolZeroOneSerializer : IWebSerializer<bool>
{
    public void Serialize(ref WebSerializerWriter writer, bool value, WebSerializerOptions options)
    {
        // true => 0, false => 1
        writer.AppendPrimitive(value ? 0 : 1);
    }
}

IWebSerializer<T>のインターフェイスについて、ref T valueにしようか検討したのですが、最終的にやめました。

public interface IWebSerializer<T> : IWebSerializer
{
    void Serialize(ref WebSerializerWriter writer, T value, WebSerializerOptions options);
}

ref T valueにすると、プロパティをそのまま渡せなくて、かなり面倒くさくなってね。理屈的にはlarge structに対するコピーコスト削減、ではあるけれど、まぁこのままだと99%効力ないかなあ、という感じがあり。入り口だけinにして一回分コピーを消すぐらいを落とし所にしました、とりあえず今回は。

public static string ToQueryString<T>(in T value, WebSerializerOptions? options = default)

それとSource Generator対応についても考えましたが、まぁ一旦今回は見送って、後でやるかもという感じでしょうか。アイディアは色々ありますが、まずは作ってみないとうまくハマるか見えないところがあるし、MessagePack for C#のような大きなものでドカンとやるよりは、最初は小さなものでテストしていくのが良いものを作る正攻法でもありますね。

Deserializeがない問題

ASP.NET CoreのAddControllerなら、Model Bindingでデシリアライズできるので、不要でしょう。.NET 6時点でのMinimal APIだとなんと自動モデルバインディングがなくて手動でQueryStringから組み立てるという手間が必要になってて、まぁそこでは必要かなあ?と思ったんですが、いや、それしたいならMinimal APIではなくてAddControllerしろや、と思ったので、機能入れるのやめました。実際、そのうちバインディング自体はいれるそうです(さすがに不便なので)。

まとめ

手で組み立てている人は結構多いと思うので、使えるシチュエーションはかなりあると思ってます。ただまあ、こんぐらいなら手でやるよ!と思う人は多いと思うので、その点ではニッチかなあ、というところですね。Web APIの仕様によってはリクエストパラメーターが微妙にデカくてイライラすることがあったり、まぁあとは数を作るときにはやっぱダルいので、ハマるシチュエーションも少なくはないかな、と。

とりあえずは試してみてもらえればと思います。

2021年を振り返る

例年、30日に投稿しているはずなのですが、今年は、どうしても今年中に作りきりたいという思いでConsoleAppFramework v4のリリースをしてしまったので31日で。今年の後半から道具をガラーッと変えて、それがいい感じに作用していったので、なんか満足した気でいます。

Heyをとにかく薦めたい

今年良かったもの第一位はHeyです。Ruby on Rails作者のDHHがやってる会社(Basecamp)のメールサービスなのですが、これが抜群によく出来てる!今までメール一ヶ月放置は当たり前、未読1万件、みたいな状態だったのですが、After Heyでは未読0。すごい。メーラー変えただけでこんな変わるとは。よく出来たツールは人を変えるね。

どうしてもたまりがちな、スパムではないけど自分にとってはスパムに等しいもの(なんだかんだで送られてくる広告メールとかね)を、実質スパム扱いして、一生このメールは見ないという設定をワンポチでできるのが小気味よい。ワンポチどころか、最初のメール受信時に強制的に決めさせることで(決めないとメールが受信ボックスに入らないので、決めるしかない)、最初に使うときの罪悪感というか、とはいえ見るかもしれないしー、役に立つときもあるかもしれないしー、みたいななんとなくある抵抗感みたいなのを、その手で実行させ続けることにより薄れさせていく手腕は見事というほかない。

メールを3分類、読むものとフィード的に見るもの(メルマガとかGitHubのWatchとか)と、領収書系で分けたというのもセンスを感じる。領収書は、例えばKindleで購入するたびに買いましたメールは、捨てるのもアレだけど別に自明すぎて見たくはない、ものが溜まっていくとメールボックスがウザいことになる、を専用の置き場を用意しました、で解決しているのはなるほどなー、と。フィード的なのは全部連結されているので、スクロールさせてバッと流し見で終わらせられるのも良い。

細かいフィルターはできないけど、そもそも細かいフィルターなんて作るの面倒だしメンテ不能になるだけだから作るんじゃねえ、俺達の考えた最高のRailに乗ってりゃあいいんだよ、という押し付けがましさ全開の思想性溢れるのが、いいですね。そういうの、嫌いじゃないです。DHHの語るプログラミング的な思想も好きですしね、私は。Rubyは使いませんが、DHHの思想には納得できるものがめちゃくちゃ多くて割と好きなので。

メールアドレスという、なんだかんだで変えられない、変えにくいものなのに、 hey.com を使え!というのは中々ハードル高いのですが(基本的に汎用メーラーとしては使えず、専用メールアドレスが必要。GMailのクライアントにもPOP3クライアントにもならない)、今回私は10年以上使ってきたプロバイダのメールアドレスが不慮の事故により完全消滅したので、思い切って乗り換えることができました。結果、良かった。怪我の功名ということで。

iPad miniがとにかく良い

タブレットは、というかiPadはなんだかんだで今まで色々なサイズのものを買ってきました。普通のもAirもPro 13インチも。そしてほとんど全く使わなかったのですが……!なんか面倒くさくてねー、重いしー、と。で、iPhoneがPro Maxで大きいから、そこまでサイズ変わらないしねえと思ってminiだけは手を出さなかったのですが、世間でiPad mini 6があまりにも評判がいいので、じゃあまぁ試してみるかと買ったら、なるほど納得!これは超いい!最高……!

やっぱ重さとサイズ感ですかね。これなら手軽に持ち運べる(今の時期コートとかだったらポケットにすら入る)し、片手で持てるというのが読みやすさにもめっちゃ寄与してる。デカいと手も疲れるし、ちゃっと手にとってソファで読もうとかいう気になれなかったわけですが、このサイズ感は絶妙、でした。大きさ的にも全然iPhone Pro Maxよりは明らかに大きくて、雑誌も十分読めるレベルで、漫画は快適。

iPad miniのサイズのままで高級路線(有機EL積んでもらうとか)して欲しいですね。

で、とても気に入ったので、いい感じのスタンドないかなあと思って選んだのがMOFT X。ちょうどiPad mini 6用のサイズのものが出たのでこれがまた快適。どこでもいつでもさっくりスタンドになるのがこんなに良いとは。厚すぎない/重すぎないので、背中に常時貼り付けている状態でも苦にならないし。なんだったらカメラの出っ張り(うざい!)が相殺されて、平らなところにおいてもガタつかなくなったのが最高。

5K2Kは捗る

家の作業環境に割と不満があって、特にモニタ環境が良くなかった(32インチ4K + WQXGAの組合わせ、別に悪くはないんですけどね)ので、5K2K(5120x2160)モニタに変更。

image

現状だとこの解像度はDell U4021QW一択。40インチは大きいかなーと思ったんですが、横に長いから高さ的には今まで使ってた32インチとそう変わらず、ですかね。デカいっちゃあデカですが、こんなもんかな、という感じでもある。この机も横1200の机でそんあ大きい机じゃないんですが、ジャストサイズぐらいで収まりますし。

人によっては100%サイズで使うのは小さい、と思ってしまいそうなところですが、私的には人体改造済み(ICLというレンズを眼球に埋める手術をしたので)なので問題なし、ということで100%サイズで使っています。

このサイズ、いい感じに視野に全部収まるので、デュアルやトリプルよりも快適さあります。今まで、最大で5画面ぐらいまでモニタ増やしてきたのですが、結局メイン以外のものは首をふるのもダルくてそんなに使える感じではないし、音楽プレイヤーのプレイリストを並べるとかだったら、置いてるiPadで聞いて表示しとけばいいじゃん、ということで、全然問題なし。

ツール補助がないとウィンドウがとっちらかってしまうので、Microsoft PowerToysのFancyZonesを使って割り振ってます。これのグリッド吸着がまた使いやすくて(Shift押しながら移動すると、事前定義したグリッドに張り付く)いいですね。

image

中央を広めに取りつつ(主にVisual Studioか、ブラウザがっつし見たりするときはブラウザを置く)、右にブラウザ、左は半分に割って下にGitKraken、上にExplorerみたいなパターンが多めでしょうか。Visual Studio + Unityとか、作業域的に大きく取りたいものを並べる場合は2分割のグリッドパターンも用意して、切り替えるようにしています。

3分割みたいなのは、5K2Kぐらいの解像度がないと出来ないので、この解像度のモニタ増えてくれーって感じですね。選択肢がDELLしかないのは寂しい。液晶自体の画質もそんな良いわけでもないので、もう少し良いのが欲しい。しっかりしたHDR対応のが欲しい。120Hz出るのが欲しい。とか、思うところはそれなりにあります。とはいえ、それでも大満足です。もうこれ以外の解像度のモニタには戻りたくないなあ。

と、いうわけで、モニタ変更のためにゴミ溜めだった机をキレイにした記念で、来年はずっとすっきりした机をキープするぞ、という強い気持ちがあります!単純に机がキレイになってたほうが作業やりやすいですしねー、やっぱゴミ溜めはダメですよ。

キーボードをREALFORCE R3にしたのですが(前はR2でした)、今回からデフォ無線なんですね。キーボードなんて置きっぱなしだから別に有線でいいだろ、と思ってたんですが、これはこれでアリというか、めっちゃいいじゃん?と。サッとキーボードどかしたりがやりやすくなったのがいいですね、デスクで他の作業がやりやすくて。Bluetoothだから、そのままiPadやiPhoneに繋げてもいいし。いやあ、時代は無線。ケーブルがなくなってすっきりするし。

と、いうわけで、無線環境が気に入ったので、デカい有線ヘッドフォン+ヘッドフォンアンプを使っていたのですが(MDR-Z1R + TA-ZH1ES、合計40万もした)、撤去して、写真には写ってませんがAirPods Max買いました。これも満足。いやー、正直あんま有線ヘッドフォン使ってなかったんですよね、面倒くさくて。電源入れるだけ、ではありんですがやっぱ面倒くさくて。ケーブル太くて邪魔くさいし(無駄にケーブル換装して太いケーブルにしてしまったのも良くなかった)。PC専用になっちゃってiPhoneの音も聞けないし。

AirPods MaxだとiPhone/iPad/PCの切り替えが自由なのが想像の100億倍良いなあ、と。音質面でも悪くないし、Apple Musicの空間オーディオとの相性は抜群でこれはめちゃくちゃいいし、さすがのヘッドフォン型なのでAirPods Proよりも音がいい。デジタルクラウンによる音量調整は最高に便利。ゲーム用にDolby Access入れてDolby Atmos for Headphonesを有効化してますが、これもなかなか良い。

スピーカーも置いてたんですが、撤去しました。簡単なものはモニタ内蔵のしょぼいスピーカーで済ませる。ちゃんと聴きたい場合はAirPods Max。それでいいや、と。割り切ったら、全然それでいいじゃん、という気になりました。そもそもあまりデスクトップのスピーカーを稼働させてないしなあ、というのもありますが。

時代は無線

スピーカー撤去の代わりに、じゃないのですが、今年はホームシアターシステムとしてHT-A9を導入したのですが、これも満足度高い。何がいいって、設置が無線で自由度高い。なんか今年は無線化の年ですね、時代は無線。

昔は9.1chにしてたり5.1chだったりでスピーカー並べてたのですが、諸事情あってここ数年はサウンドバーを使ってたんですが、とにかくその音質には不満だったんですね。かといってリアスピーカー並べる気力も起きずというか、そのスペースも確保できない状態なのでどうしたものか、と思っていたところに出たのが、フル無線4.1chシステムのHT-A9。360 Spatial Sound MappingでDolby Atomos時代なサラウンドにするという謳い文句もいいし、設置レイアウトが自由というのがいい。オフィシャルサイトのこの画像を見て購入を決めました。

image

HT-A9の画像、どれもフロントスピーカーの置き方が「わざとらしく」でたらめなんですよね、高さを絶対揃えない。これは、メーカーが別に高さ揃えなくていいんですよ、と推奨してるということなんですが、実際うちでの設置環境もフロントの高さは揃ってないです。揃えられないので。リアも位置も高さもグチャグチャで、適当における棚に置いてるだけって感じです。フロントもリアも無線スピーカーなので、そういう適当配置がめっちゃやりやすい。

それでもちゃんとサウランドするし、360 Spatialな音は心地良い。ただたんに音楽鳴らすだけにも使ってますね、起動も早いのでSpotify Connectでよく流してます。

アンプのサイズが小さい(Apple TVが一回り大きくなった程度)というのも設置が楽になった要因で、いやほんとよく出来てますね。確かに、全部無線なのでスピーカーを駆動するアンプは各スピーカー内蔵状態だから、本体をAVアンプあるあるなクソデカサイズにしなくてもいいんですね。そういうところにも無線の良さが出てますね。

来年に向けてのC#

こうしてダラダラと文章書いたり、そもそもこの12月はやたら記事量産しているなあ、というのは、ブログ書く環境が変わったからです!10年前のWordPressから自家製サイトジェネレーターへの変更によって快適度上がったからですね。いやあ、書きやすいと書く気になります。環境大事。

そんなわけでCysharpはC#はの環境を良くすることに今年一年もちゃんと務められたんじゃないでしょーか。Cysharpからの新規リリース/大型更新は

MessagePipeが大きめかな?

こうした公開していく姿勢、足を少しでも止めてはダメだという思いがあるので、作ろうと思ったらできるだけ勢い持って作りきるようにしています(もちろん途中で止まってしまったものも幾つかありはしますが)。毎年継続的に、Cysharp全体としても既に20個以上公開していて、ヒット作もそれなりの量を出し続けていられる状態は中々のことだと思います。

と、いうわけで、OSSを通じてCysharpをアピールしていくという方向では、良い点をあげてもいいかな、と思うのですが、反面、他の仕事に集中しきれていないのではないかというのが散見していたのは個人的にはマイナスです。今年は「世の中の開発生産性を革命的に改善するプロダクト」の作成に着手し(構想は前からあったのですがようやく始動)、徐々に人も集めだしてCysharpが割とまともな(?)会社っぽく動き出した頃合いでもあるんですが、私がボトルネックになりがち、な状況になりがちなのが、まぁいくないですねえ、と。これはグラニの頃もそうだったので、なんかもうそういうもの感もあるんですが、今回は私が主導してやってるので尻拭いしてくれる人もいないので純粋に良くない!

というわけで、来年の中旬までにその「世の中の開発生産性を革命的に改善するプロダクト」をリリースするために全力でやっていくぞ、というのが目標です。実際出来上がってきて、かなりいいものになりそうな手応えはあるので、早く世の中に出して評価されたいものです。

まぁ、そんなわけで大きな何かがあったわけではないですが、確実に前進した年だと思うので、来年は爆発させる年にしましょう。

ConsoleAppFramework v4 - Minimal API for CommandLine tool

皆さん .NET 6で追加されたMinimal API使ってみました?最初は別にいらんやろ、とか思ってたんですが、いや、これ正直めっちゃ凄い、いい。まぁDelegateベースで書くかどうかは別として(書かないかなー)、謎Startupを葬り去ってBuilder/Runが素直に繋がった形が美しい。Top level statementとの相性も良いので、もうこっちのAPI以外で作る気しないなあ。

さて、ところでConsoleAppFrameworkです。今までクラスが必要だったんですよね、たった一個のメソッドを実装するにも。それがTop level statementとの相性が悪い。Top level statementだけで完結できるとき、クラスって作りたくないんですよね。と、いうわけで、そろそろ大改修が必要かなーと思っていたところにMinimal APIですよ。特にその場でラムダ式でばしばしAPI作っていくスタイルは、むしろコマンドラインツールのほうがマッチするじゃんどう考えても?

と、いうわけで大改修して、Minimal APIベースになったv4、作りました。何が凄いって、一行でコマンドライン引数をパースしてハンドラー定義できちゃうんですね。

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

これは嘘偽りなくNuGetからダウンロードしたら、そのままでこう書けます。C# 10.0のglobal using(をNuGetのライブラリ側に埋め込むというEvilな手法を使ってます)と、ラムダ式の推論の向上によって実現しました。内側では、Minimal APIの実現のために Microsoft.Extensions.* 側にもかなり改修が入っていたので、それをそっくりそのまま利用できました。そういう意味で、 .NET 6になった今だからようやく作れた形になりますね。もちろんv1~v3までの蓄積のお陰というところもあります。集大成……!

さて、Runはちょっとウケ狙いなところもあるんですが、それ以外のAPIもBuilderベースになったので、だいぶ様変わりしています。ただし特徴としてGeneric Hostの上に乗っているというのは変わらないので、DbContext埋めたりappconfig.jsonから取ったりというのは、変わらずスムーズにできます。

// You can use full feature of Generic Host(same as ASP.NET Core).

var builder = ConsoleApp.CreateBuilder(args);
builder.ConfigureServices((ctx,services) =>
{
    // Register EntityFramework database context
    services.AddDbContext<MyDbContext>();

    // Register appconfig.json to IOption<MyConfig>
    services.Configure<MyConfig>(ctx.Configuration);

    // Using Cysharp/ZLogger for logging to file
    services.AddLogging(logging =>
    {
        logging.AddZLoggerFile("log.txt");
    });
});

var app = builder.Build();

// setup many command, async, short-name/description option, subcommand, DI
app.AddCommand("calc-sum", (int x, int y) => Console.WriteLine(x + y));
app.AddCommand("sleep", async ([Option("t", "seconds of sleep time.")] int time) =>
{
    await Task.Delay(TimeSpan.FromSeconds(time));
});
app.AddSubCommand("verb", "childverb", () => Console.WriteLine("called via 'verb childverb'"));

// You can insert all public methods as sub command => db select / db insert
// or AddCommand<T>() all public methods as command => select / insert
app.AddSubCommands<DatabaseApp>();

app.Run();

単独のコマンドラインツール用に使ってもいいのですが、ASP.NETのウェブアプリが他にあって、それのバッチを作りたいみたいなときに、こうしたコンフィグの共通化はめっちゃ便利に使えるはずです。ConfigureServicesのコードはまんま一緒にできて、そのままDIできますからね。

また、引き続き AddCommands<T>AddAllCommandType によって、メソッド定義するだけで大量のコマンドを一括追加も可能になっています。

v3 -> v4の破壊的変更

破壊的変更、は沢山あるのですが、基本的に今までの使い方をしている場合は互換オプションで動くようにしたので、アップデートしたから壊れるということはない、はずです。v4からはConsoleApp.Create/CreateBuilder 経由で作るのが基本なのですが、v3は Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<T>() 経由なので、ちょうど互換性オプションを突っ込むのに都合が良かったんですね。なお、RunConsoleAppFrameworkAsyncはエディタから見えないようにしてます。今後は非推奨で、本当に互換のためだけに残してます。

まず変わったところは、デフォルトで長いオプション名が--、短いオプション名が-になりました。v3では-が幾つついていてもいいというゆるふわマッチングだったのですが、(dotonet toolsと同じように)厳格化しています。

また、デフォルトのコマンド/オプション名の変換ルールが単純なlower化から、hoge-hugaというlowerなkebab-caseになりました。これもdotnet tools合わせですね。

また、AddCommands<T>した場合の挙動(v3ではRunConsoleAppFrameworkAsync<T>した場合)が、全てのpublicメソッドをコマンドとして追加するようになりました。デフォルト(ルート)コマンドにしたい場合は[RootCommand]属性を付与してくださいということで。これはAddSubCommand<T>した時と挙動を合わせたかったからです、違うと一貫性がなくて戸惑うので。

と、いうわけで、互換性モードで動かした場合はConsoleAppOptionsは以下のような変更で動くようになっています。よきかなよきかな。(それとargsのコマンド名でHoge.Hugaが来てたらHoge Hugaに分解するのも、この互換性モードだけの挙動です)

options.StrictOption = false;
options.NoAttributeCommandAsImplicitlyDefault = true;
options.NameConverter = x => x.ToLower();
options.ReplaceToUseSimpleConsoleLogger = false;

そうだ、それとCtrl+Cした場合に、正しくCancellationTokenをハンドリングしていない場合でも、タイムアウトをハンドリングしてabortするようになりました。これは、なんか強制終了できなくてウゼーってなりがちというか、私自身よく引っかかってヤバかったので。むしろこれは今までがバグに近くて、正しくHostOptions.ShutdownTimeoutを処理していないせいでした。

ちなみにこのタイムアウト時間はデフォルトは5秒で、ConfigureHostOptions(地味にこれは.NET 6(というかMicrosoft.Extensionsのv6)からの新API)で変更できます。

var app = ConsoleApp.CreateBuilder(args)
    .ConfigureHostOptions(options =>
    {
        // change timeout.
        options.ShutdownTimeout = TimeSpan.FromMinutes(30);
    })
    .Build();

まとめ

無計画にアドホックに作っていったせいで、どうにもクソコードすぎて、改修にめっちゃ手間取ったというか内部的にはほぼ作り直した……。弄るのだるくて嫌だなあと内心実際今まで思ってたんですが、やはりとても嫌なコードであった。v1の時の最初の発想が Class.Method にパラメータ分解してバッチを大量に作りたい(そもそもライブラリ名もMicroBatchFrameworkだったし)というものだけだったのが、徐々に汎用コマンドラインツールに進化していって、都度、適当に追加していった結果ではある。

今回がっつし仕切り直したので、しばらくはメンテが楽になれるかなあ、という感じで、よきかなよきかな。

まぁしかしC# 10.0は地味にヤバいですよ!使えば使うほど味が出てくるというか、最近ようやく手に馴染んで、よくわかってきた感じです。なんというか、とにかく、めっちゃいい。それとC# 10.0 + ConsoleAppFrameworkは全言語見渡しても最強のコマンドラインツール作成ライブラリじゃないです?いや、API自体のできの良さはほとんど ASP .NET CoreのMinimal APIのコピーにすぎないんですが、まぁしかしそれでもやっぱ、これはかなり良い感じじゃないかという手応えがあります。

NativeMemoryArray - .NET 6 APIをフル活用した2GB超えの巨大データを扱うライブラリ

.NET 6 Advent Calendar 2021の12日の代理投稿となります。プレゼント付きですと!?BALMUDA The Brew STARBUCKS RESERVE LIMITED EDITIONが欲しいです!

さて、先程NativeMemoryArrayという新しいライブラリを作成し、公開しました。.NET Standard 2.0でも動作しますが、全体的に .NET 6 の新API群(NativeMemory, Scatter/Gather I/O)を活かすための作りになっていますので、今回のAdvent Calendarにもピッタリ。実用性も、ある……!あります……!もちろんUnity版も用意してあります(NativeArrayと何が違うって?まぁ違うと言えば違います)。

C#には配列、特にbyte[]を扱う上で大きな制約が一つあります。それは、一次元配列の上限値が0x7FFFFFC7(2,147,483,591)ということ。int.MaxValueよりちょっと小さめに設定されていて、ようするにざっくり2GBちょいが限界値になっています。

この限界値は、正確には .NET 6 でひっそり破壊的変更が行われましたので、.NET 6とそれ以外で少し異なります。詳しくは後で述べます。

この2GBという値は、int Lengthの都合上しょうがない(intの限界値に引っ張られている)のですが、昨今は4K/8Kビデオや、ディープラーニングの大容量データセットや、3Dスキャンの巨大点群データなどで、大きな値を扱うことも決して少ないわけではないため、2GB制約は正直厳しいです。そして、この制約はSpan<T>Memory<T>であっても変わりません(Lengthがintのため)。

ちなみにLongLengthは多次元配列における全次元の総数を返すためのAPIのため、一次元配列においては特に意味をなしません。.NET Frameworkの設定であるgcAllowVeryLargeObjectsも、構造体などを入れた場合の大きなサイズを許容するものであり(例えば4バイト構造体の配列ならば、2GB*4のサイズになる)、要素数の限界は超えられないため、byte[]としては2GBが限界であることに変わりはありません。

こうした限界に突き当たった場合は、ストリーミング処理に切り替えるか、またはポインタを使って扱うかになりますが、どちらもあまり処理しやすいとは言えませんし、必ずしもインメモリで行っていた操作が代替できるわけではありません(ポインタなら頑張れば最終的にはなんとでもなりますが)。

そこで、2GB制約を超えつつも、新しいAPI群(Span<T>, IBufferWriter<T>, ReadOnlySequence<T>, RandomAccess.Write/Read, System.IO.Pipelinesなど)と親和性の高いネイティブメモリを裏側に持つ配列(みたいな何か)を作りました。

これによって、例えば巨大データの読み込み/書き込みも、 .NET 6の新Scatter/Gather APIのRandomAccessを用いると、簡単に処理できます。

// for example, load large file.
using var handle = File.OpenHandle("4GBfile.bin", FileMode.Open, FileAccess.Read, options: FileOptions.Asynchronous);
var size = RandomAccess.GetLength(handle);

// via .NET 6 Scatter/Gather API
using var array = new NativeMemoryArray<byte>(size);
await RandomAccess.ReadAsync(handle, array.AsMemoryList(), 0);

// iterate Span<byte> as chunk
foreach (var chunk in array)
{
    Console.WriteLine(chunk.Length);
}

Scatter/Gather APIに馴染みがなくても、IBufferWriter<T>IEnumerable<Memory<T>> を経由してStreamで処理する手法も選べます。

public static async Task ReadFromAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    var writer = buffer.CreateBufferWriter();

    int read;
    while ((read = await stream.ReadAsync(writer.GetMemory(), cancellationToken).ConfigureAwait(false)) != 0)
    {
        writer.Advance(read);
    }
}

public static async Task WriteToAsync(NativeMemoryArray<byte> buffer, Stream stream, CancellationToken cancellationToken = default)
{
    foreach (var item in buffer.AsMemorySequence())
    {
        await stream.WriteAsync(item, cancellationToken);
    }
}

あるいはSpan<T>のSliceを取り出して処理してもいいし、ref T this[long index]によるインデクサアクセスやポインタの取り出しもできます。 .NET 6時代に完全にマッチしたAPIを揃えることで、標準の配列と同等、もしくはそれ以上の使い心地に仕上げることによって、C#の限界をまた一つ超える提供できたと思っています。

とはいえもちろん、 .NET Standard 2.0/2.1 にも対応しているので、非 .NET 6なAPIでも大丈夫です、というかScatter/Gather API以外は別に今までもありますし普通に使えますので。

普通の配列的にも使えます。GC避けには、こうした普通のAPIを使っていくのでも便利でしょう、

// call ctor with length, when Dispose free memory.
using var buffer = new NativeMemoryArray<byte>(10);

buffer[0] = 100;
buffer[1] = 100;

// T allows all unmanaged(struct that not includes reference type) type.
using var mesh = new NativeMemoryArray<Vector3>(100);

// AsSpan() can create Span view so you can use all Span APIs(CopyTo/From, Write/Read etc.).
var otherMeshArray = new Vector3[100];
otherMeshArray.CopyTo(mesh.AsSpan());

NativeMemoryArray<T>

NativeMemoryArray<T>はwhere T : unmanagedです。つまり、参照型を含まない構造体にしか使えません。まぁ巨大配列なんて使う場合には参照型含めたものなんて含めてんじゃねーよなので、いいでしょうきっと。巨大配列で使えることを念頭においてはいますが、別に普通のサイズの配列として使っても構いません。ネイティブメモリに確保するので、ヒープを汚さないため、適切な管理が行える箇所では便利に使えるはずです。

Span<T>との違いですが、NativeMemoryArray<T>そのものはクラスなので、フィールドに置けます。Span<T>と違って、ある程度の長寿命の確保が可能ということです。Memory<T>のSliceが作れるため、Async系のメソッドに投げ込むこともできます。また、もちろん、Span<T>の長さの限界はint.MaxValueまで(ざっくり2GB)なので、それ以上の大きさも確保できます。

UnityにおけるNativeArray<T>との違いですが、NativeArray<T>はUnity Engine側との効率的なやりとりのための入れ物なので、あくまでC#側で使うためのNativeMemoryArray<T>とは全然役割が異なります。まぁ、必要に思えない状況ならば、おそらく必要ではありません。

主な長所は、以下になります。

  • ネイティブメモリから確保するためヒープを汚さない
  • 2GBの制限がなく、メモリの許す限り無限大の長さを確保できる
  • IBufferWriter<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonWriter, System.IO.Pipelinesなどから直接読み込み可能
  • ReadOnlySequence<T> 経由で、MessagePackSerializer, System.Text.Json.Utf8JsonReaderなどへ直接データを渡すことが可能
  • IReadOnlyList<Memory<T>>, IReadOnlyList<ReadOnlyMemory<T>> 経由で RandomAccess(Scatter/Gather API)に巨大データを直接渡すことが可能

あまりピンと来ない、かもしれませんが、使ってみてもらえれば分かる、かも。

NativeMemoryArray<T>の全APIは以下のようになっています。

  • NativeMemoryArray(long length, bool skipZeroClear = false, bool addMemoryPressure = false)
  • long Length
  • ref T this[long index]
  • ref T GetPinnableReference()
  • Span<T> AsSpan()
  • Span<T> AsSpan(long start)
  • Span<T> AsSpan(long start, int length)
  • Memory<T> AsMemory()
  • Memory<T> AsMemory(long start)
  • Memory<T> AsMemory(long start, int length)
  • bool TryGetFullSpan(out Span<T> span)
  • IBufferWriter<T> CreateBufferWriter()
  • SpanSequence AsSpanSequence(int chunkSize = int.MaxValue)
  • MemorySequence AsMemorySequence(int chunkSize = int.MaxValue)
  • IReadOnlyList<Memory<T>> AsMemoryList(int chunkSize = int.MaxValue)
  • IReadOnlyList<ReadOnlyMemory<T>> AsReadOnlyMemoryList(int chunkSize = int.MaxValue)
  • ReadOnlySequence<T> AsReadOnlySequence(int chunkSize = int.MaxValue)
  • SpanSequence GetEnumerator()
  • void Dispose()

AsSpan(), AsMemory()はスライスのためのAPIです。取得したSpanやMemoryは書き込みも可能なため、 .NET 5以降に急増したSpan系のAPIに渡せます。SpanやMemoryには最大値(int.MaxValue)の限界があるため、lengthの指定がない場合は、例外が発生する可能性もあります。そこでTryGetFullSpan()を使うと、単一Spanでフル取得が可能かどうか判定できます。また、AsSpanSequence(), AsMemorySequence()でチャンク毎のforeachで全要素を列挙することが可能です。直接foreachした場合は、AsSpanSequence()と同様の結果となります。

long written = 0;
foreach (var chunk in array)
{
    // do anything
    written += chunk.Length;
}

ポインタの取得は、配列とほぼ同様に、そのまま渡せば0から(これはGetPinnableReference()の実装によって実現できます)、インデクサ付きで渡せばそこから取れます。

fixed (byte* p = buffer)
{
}

fixed (byte* p = &buffer[42])
{
}

CreateBufferWriter() によって IBufferWriter<T>を取得できます。これはMessagePackSerializer.Serializeなどに直接渡すこともできるほかに、先の例でも出しましたがStreamからの読み込みのように、先頭からチャンク毎に取得して書き込んでいくようなケースで便利に使えるAPIとなっています。

AsReadOnlySequence() で取得できるReadOnlySequence<T>は、MessagePackSerializer.Deserializeなどに直接渡すこともできるほかに .NET 5から登場した SequenceReaderに通すことで、長大なデータのストリーミング処理をいい具合に行える余地があります。

AsMemoryList(), AsReadOnlySequence()は .NET 6から登場したRandomAccessRead/Writeに渡すのに都合の良いデータ構造です。プリミティブな処理なので使いにくいと思いきや、意外とすっきりと処理できるので、File経由の処理だったらStreamよりもいっそもうこちらのほうがいいかもしれません。

NativeMemory

.NET 6からNativeMemoryというクラスが新たに追加されました。その名の通り、ネイティブメモリを扱いやすくするものです。今までもMarshal.AllocHGlobalといったメソッド経由でネイティブメモリを確保することは可能であったので、何が違うのか、というと、何も違いません。実際NativeMemoryArrayの .NET 6以前版はMarshalを使ってますし。そして .NET 6 では Marshal.AllocHGlobal は NativeMemory.Alloc を呼ぶので、完全に同一です。

ただしもちろん .NET 6 実装時にいい感じに整理された、ということではあるので、NativeMemory、いいですよ。NativeMemory.Allocがmalloc、NativeMemory.AllocZeroedがcalloc、NativeMemory.Freeがfreeと対応。わかりやすいですし。

ちなみにゼロ初期化する NativeMemory.AllocZeroed に相当するものはMarshalにはないので、その点でも良くなったところです。NativeMemoryArray<T>では、コンストラクタのskipZeroClear(public NativeMemoryArray(long length, bool skipZeroClear = false))によってゼロ初期化する/しないを選べます。デフォルトは(危ないので)初期化しています。非.NET 6版では、メモリ確保後にSpan<T>.Clear()経由で初期化処理を入れています。

真のArray.MaxValue

.NET 6以前では、配列の要素数はバイト配列(1バイト構造体の配列)と、それ以外の配列で異なる値がリミットに設定されていました。例えばSystem.Arrayのドキュメントを引いてくると

配列のサイズは、合計で40億の要素に制限され、任意の次元の0X7FEFFFFF の最大インデックス (バイト配列の場合は0X7FFFFFC7、1バイト構造体の配列の場合) に制限されます。

つまり、0X7FFFFFC7の場合と、0X7FEFFFFFの場合がある、と。

と、いうはずだったのですが、.NET 6からArray.MaxLengthというプロパティが新規に追加されて、これは単一の定数を返します。その値は、0X7FFFFFC7です。よって、いつのまにかひっそりと配列の限界値は(ちょびっと大きい方に)大統一されました。

この変更は意外とカジュアルに行われ、まず最大値を取得する、ただし単一じゃないため型によって結果の変わる Array.GetMaxLength<T>() を入れよう、という実装があがってきました。そうしたら、そのPR上での議論で、そもそも当初は最適化を期待したけど別にそんなことなかったし、統一しちゃってよくね?という話になり、そのまま限界値は統一されました。そして新規APIも無事、Array.MaxLengthという定数返しプロパティになりました。

まぁ、シンプルになって良いですけどね。大きい方で統一されたので実害も特にないでしょうし。前述のSystem.Arrayのドキュメントは更新されてないということで、正しくは、.NET 6からは0x7FFFFFC7が限界で、その値はArray.MaxLengthで取れる。ということになります。

Span<T>の限界値はint.MaxValueなので、限界に詰め込んだSpan<T>をそのままToArray()すると死ぬ、という微妙な問題が発生することがあるんですが、まぁそこはしょうがないね。

まとめ

NativeArrayという名前にしたかったのですがUnityと被ってしまうので避けました。しょーがない。

着手当時はマネージド配列のチャンクベースで作っていたのですが(LargeArray.cs)、Sliceが作りづらいし、ネイティブメモリでやったほうが出来ること多くて何もかもが圧倒的にいいじゃん、ということに作業進めている最中に気づいて、破棄しました。参照型の配列が作れるという点で利点はありますが、まぁ参照型で巨大配列なんて作らねーだろ、思うと、わざわざ実装増やして提供するメリットもないかな、とは。

配列はもう昔からあるのでint Lengthなのはしょうがないのですが、Span<T>, Memory<T>のLengthはlongであって欲しかったかなー、とは少し思っています。2016年の段階でのSpanのAPIどうするかドキュメントによると、候補は幾つかあったけど、結果的に配列踏襲のint Lengthになったそうで。2GBでも別に十分だろ、みたいなことも書いてありますが、いや、そうかなー?年にそこそこの回数でたまによく引っかかるんだけどねー?

そして2016年の議論時点ではなかった、C# 9.0でnuint, nuintが追加されたので、nuint Span<T>/Memory<T>.Lengthはありなんじゃないかな、と。

ただNativeMemoryArrayの開発当初はnuint Lengthで作っていたのですが、AsSpan(nuint start, nuint length)みたいなAPIは、カジュアルにintやlongを突っ込めなくて死ぬほど使いづらかったので、最終的にlongで統一することにしました。ので、nuint Lengthは、なしかな。つまり一周回って現状維持。そんなものかー、そんなもんですねー。

.NET 6とAngleSharpによるC#でのスクレイピング技法

C# Advent Calendar 2021の参加記事となっています。去年は2個エントリーしたあげく、1個すっぽかした(!)という有様だったので、今年は反省してちゃんと書きます。

スクレイピングに関しては10年前にC#でスクレイピング:HTMLパース(Linq to Html)のためのSGMLReader利用法という記事でSGMLReaderを使ったやり方を紹介していたのですが、10年前ですよ、10年前!さすがにもう古臭くて、現在ではもっとずっと効率的に簡単にできるようになってます。

今回メインで使うのはAngleSharpというライブラリです。AngleSharp自体は2015年ぐらいからもう既に定番ライブラリとして、日本でも紹介記事が幾つかあります。が、いまいち踏み込んで書かれているものがない気がするので、今回はもう少しがっつりと紹介していきたいと思っています。それと直近Visual StudioのWatchウィンドウの使い方を知らん、みたいな話を聞いたりしたので、デバッグ方法の手順みたいなものを厚めに紹介したいなあ、という気持ちがあります!

AngleSharpの良いところは、まずはHTMLをパースしてCSSセレクターで抽出できるところです。以前はLINQ(to DOM)があればCSSセレクターじゃなくてもいいっす、WhereとSelectManyとDescendantsでやってきますよ、とか言ってましたが、そんなにきちんと構造化されてるわけじゃないHTMLを相手にするのにあたっては、CSSセレクターのほうが100億倍楽!CSSセレクターの文法なんて大したことないので、普通に覚えて使えってやつですね。SQLと正規表現とCSSセレクターは三大言語関係なく覚えておく教養、と。

もう一つは、それ自体でネットワークリクエストが可能なこと。FormへのSubmitなどもサポートして、Cookieも保持し続けるとかが出来るので、ログインして会員ページを弄る、といったようなクローラーが簡単に書けるんですね。この辺非常に良く出来ていて、もう自前クローラーなんて投げ捨てるしかないです。また、JintというPure C#なJavaScriptインタプリタと統合したプラグインも用意されているので、JavaScriptがDOMをガリガリっと弄ってくる今風のサイトにも、すんなり対応できます。

AngleSharpの紹介記事では、よくHttpClientなどで別途HTMLを取ってきたから、それをAngleSharpのHtmlParserに読み込ませる、というやり方が書かれていることが多いのですが、取得も含めて全てAngleSharp上で行ったほうが基本的には良いでしょう。

ここまで来るとPure C#の軽量なヘッドレスブラウザとしても動作する、ということになるので、カジュアルなE2Eテストの実装基盤にもなり得ます。普通のユニットテストと並べて dotnet test だけでその辺もある程度まかなえたら、とても素敵なことですよね?がっつりとしたE2Eテストを書きたい場合はPlaywrightなどを使わなければ、ということになってしまいますが、まずは軽い感じから始めたい、という時にうってつけです。C#で書けるし。いいことです。

BrowingContextとQuerySelectorの基本

まずはシンプルなHTMLのダウンロードと解析を。基本は BrowsingContext を作って、それをひたすら操作していくことになります。

// この辺で色々設定する
var config = Configuration.Default
    .WithDefaultLoader(); // LoaderはデフォではいないのでOpenAsyncする場合につける

// Headless Browser的なものを作る
using var context = BrowsingContext.New(config);

// とりあえずこのサイトの、右のArchivesのリンクを全部取ってみる
var doc = await context.OpenAsync("https://neue.cc");

OpenAsyncで取得できた IDocument をよしなにCSSセレクターで解析していくわけですが、ここで絞り込みクエリー作成に使うのがVisual StudioのWatchウィンドウ。(Chromeのデベロッパーツールなどで機械的に取得したい要素のCSSセレクターを取得できたりしますが、手セレクターのほうがブレなくルールは作りやすいかな、と)。

デバッガーを起動して、とりあえずウォッチウィンドウを開いておもむろに、Nameのところでコードを書きます。

image

ウォッチウィンドウは見たい変数を並べておく、お気に入り的な機能、と思いきや本質的にはそうじゃなくて、式を自由に書いて、結果を保持する、ついでに式自体も保持できるという、実質REPLなのです。代入もラムダ式もLINQも自由に書けるし、入力補完も普通に出てくる。Immediate Windowよりも結果が遥かに見やすいので、Immediate Windowは正直不要です。

デバッガー上で動いているので実データを自由に扱えるというところがいいですね。というわけで、ToHtml()でHTMLを見て、QuerySelectorAllをゆっくり評価しながら書いていきましょう。まずはサイドバーにあるので .side_body を出してみると、あれ、二個あるの?と。

image

中開けてInnerHtml見ると、なるほどProfile部分とArchive部分、と。とりあえず後ろのほうで固定のはずなのでlast-childね、というところで一旦評価して大丈夫なのを確認した後に、あとはa、と。でここまでで期待通りの結果が取れていれば、コピペる。よし。

// 基本、QuerySelectorかQuerySelectorAllでDOMを絞り込む
var anchors = doc.QuerySelectorAll(".side_body:last-child a")
    .Cast<IHtmlAnchorElement>() // AngleSharp.Html.Dom
    .Select(x => x.Href)
    .ToArray();

単一の要素に絞り込んだ場合は、 IHtml*** にキャストしてあげると扱いやすくなります(attributeのhrefのtextを取得、みたいにしなくていい)。頻出パターンなので、QuerySelectorAll<T>でCastもセットになってすっきり。

doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a")

せっかくなので、年に何本記事を書いていたかの集計を出してみたいと思います!URLから正規表現で年と月を取り出すので、とりあえずここでもウォッチウィンドウです。

image

anchrosの[0]を確認して、これをデータソースとしてRegex.Matchを書いて、どのGroupに収まったのかを見ます。この程度だったら特にミスらないでしょー、と思いきや普通に割とミスったりするのが正規表現なので、こういうので確認しながらやっていけるのはいいですね。

後は普通の(?)LINQコード。グルーピングした後に、ひたすら全ページをOpenAsyncしていきます。記事の本数を数えるのはh1の数をチェックするだけなので、特に複雑なCSSセレクターは必要なし。本来はページングの考慮は必要ですが、一月単位だとページングが出てくるほどの記事量がないので、そこも考慮なしで。

var yearGrouped = anchors
    .Select(x =>
    {
        var match = Regex.Match(x, @"(\d+)/(\d+)");
        return new
        {
            Url = x,
            Year = int.Parse(match.Groups[1].Value),
            Month = int.Parse(match.Groups[2].Value)
        };
    })
    .GroupBy(x => x.Year);

foreach (var year in yearGrouped.OrderBy(x => x.Key))
{
    var postCount = 0;
    foreach (var month in year)
    {
        var html = await context.OpenAsync(month.Url);
        postCount += html.QuerySelectorAll("h1").Count(); // h1 == 記事ヘッダー
    }
    Console.WriteLine($"{year.Key}年記事更新数: {postCount}");
}

結果は

2009年記事更新数: 92
2010年記事更新数: 61
2011年記事更新数: 66
2012年記事更新数: 30
2013年記事更新数: 33
2014年記事更新数: 22
2015年記事更新数: 19
2016年記事更新数: 24
2017年記事更新数: 13
2018年記事更新数: 11
2019年記事更新数: 14
2020年記事更新数: 11
2021年記事更新数: 5

ということで右肩下がりでした、メデタシメデタシ。今年は特に書いてないなあ、せめて2ヶ月に1本は書きたいところ……。

なお、C#による自家製静的サイトジェネレーターに移行した話 で紹介しているのですが、このサイトは完全にGitHub上に.mdがフラットに並んで.mdが管理されているので、こういうの出すなら別にスクレイピングは不要です。

UserAgentを変更する

スクレイピングといったらログインしてごにょごにょする。というわけで、そうしたログイン処理をさくっとやってくれるのがAngleSharpの良いところです。ので紹介していきたいのですが、まずはやましいことをするので(?)、UserAgentを偽装しましょう。

AngleSharpが現在何を送っているのかを確認するために、とりあえずダミーのサーバーを立てます。その際には .NET 6 のASP .NET から搭載されたMinimal APIが非常に便利です!そしてそれをLINQPadで動かすと、テスト用サーバーを立てるのにめっちゃ便利です!やってみましょう。

image

たった三行でサーバーが立ちます。便利。

await context.OpenAsync("http://localhost:5000/headers");

でアクセスして、 AngleSharp/1.0.0.0 で送られていることが確認できました。

なお、LINQPadでASP.NETのライブラリを使うには、Referene ASP.NET Core assembliesのチェックを入れておく必要があります。

image

他、よく使うNuGetライブラリや名前空間なども設定したうえで、Set as default for new queriesしておくと非常に捗ります。

さて、で、このUser-Agentのカスタマイズの方法ですが、AngleSharpはServicesに機能が詰まっているようなDI、というかService Locatorパターンの設計になっているので、ロードされてるServicesを(Watch Windowで)一通り見ます。

image

型に限らず全Serviceを取得するメソッドが用意されていない場合でも、<object>で取ってやると全部出てくるような実装は割と多い(ほんと)ので、とりあえずやってみるのはオススメです。今回も無事それで取れました。

で、型名を眺めてそれっぽそうなのを見ると DefaultHttpRequester というのがかなりそれっぽく、その中身を見るとHeadersという輩がいるので、これを書き換えればいいんじゃないだろうかと当たりがつきます。

ここはやましい気持ちがあるので(?)Chromeに偽装しておきましょう。

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36";

再びOpenAsyncしてLINQPadの表示を見て、変更されてること確認できました。

image

ちなみに、DefaultじゃないHttpRequesterをConfigurationに登録しておく、ということも出来ますが、よほどカスタムでやりたいことがなければ、デフォルトのものをちょっと弄るぐらいの方向性でやっていったほうが楽です。

FormにSubmitする

クローラーと言ったらFormにSubmit、つまりログイン!そしてクッキーをいただく!認証!

さて、が、まぁ認証付きの何かを例にするのはアレなので、googleの検索フォームを例にさせていただきたいと思います。先にまずはコード全体像と結果を。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom; // 拡張メソッドとかで有効化されたりするのでusing大事
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies(); // login form的なものの場合これでクッキーを持ち歩く

using var context = BrowsingContext.New(config);

// お行儀悪いので(?)前述のこれやっておく
var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://google.com/");
var form = doc.Forms[0];
var result = await form.SubmitAsync(new { q = "AngleSharp" }); // name = valueは匿名型が使える

// とりあえず結果を表示しておく
var titles = result.QuerySelectorAll<IHtmlHeadingElement>("h3").Select(x => x.TextContent);
var i = 1;
foreach (var item in titles)
{
    Console.WriteLine($"{i++:00}: {item}");
}

image

WithDefaultLoader と、そして認証クッキー持ち歩きのために WithDefaultCookies をコンフィギュレーションに足しておくことが事前準備として必須です。User-Agentの書き換えはご自由に、ただやましいこと、ではなくてUA判定をもとにして処理する、みたいなサイトも少なからずあるので、余計ないこと考えなくて済む対策としてはUAをChromeに偽装しておくのはアリです。

FormへのSubmit自体は3行というか2行です。ページをOpenしてFormに対してSubmitするだけ。超簡単。 .FormsIHtmlElementFormsがすっと取れるので、あとは単純にSubmitするだけです。渡す値は { name = value }の匿名型で投げ込めばOK。

度々出てくるウォッチウィンドウの宣伝ですが、この何の値を投げればいいのか、を調べるのにHTMLとニラメッコではなく、ウォッチウィンドウで調査していきます。

image

まず("input")を拾うのですが、9個ある。多いね、で、まぁこれはほとんどtype = "hidden"なので無視して良い(AngleSharpがSubmitAsync時にちゃんと自動でつけて送信してくれる)。値を入れる必要があるのはhiddden以外のものなので、それをウォッチで普通にLINQで書けば、3件に絞れました。で、中身見ると必要っぽいのはqだけなので、 new { q = "hogemoge" } を投下、と。

認証が必要なサイトでは、これでBrowingContextに認証クッキーがセットされた状態になるので、以降のこのContextでのOpenや画像、動画リクエストは認証付きになります。

画像や動画を拾う

スクレイピングといったら画像集めマンです(?)。AngleSharpでのそうしたリソース取得のやり方には幾つかあるのですが、私が最も良いかな、と思っているのはIDocumentLoader経由でのフェッチです。

// BrowsingContextから引っ張る。Contextが認証クッキー取得済みなら認証が必要なものもダウンロードできる。
var loader = context.GetService<IDocumentLoader>();

// とりあえず適当にこのブログの画像を引っ張る
var response = await loader.FetchAsync(new DocumentRequest(new Url("https://user-images.githubusercontent.com/46207/142736833-55f36246-cb7f-4b62-addf-0e18b3fa6d07.png"))).Task;

using var ms = new MemoryStream();
await response.Content.CopyToAsync(ms);

var bytes = ms.ToArray(); // あとは適当にFile.WriteAllBytesでもなんでもどうぞ

内部用なので少し引数やAPIが冗長なところもありますが、それは後述しますが別になんとでもなるところなので、どちらかというと生のStreamが取れたりといった柔軟性のところがプラスだと思っています。普通にHttpClientで自前で取るのと比べると、認証周りやってくれた状態で始められるのが楽ですね。

並列ダウンロードもいけます、例えば、このブログの全画像を引っ張るコードを、↑に書いた全ページ取得コードを発展させてやってみましょう。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://neue.cc/");
var loader = context.GetService<IDocumentLoader>();

foreach (var arvhives in doc.QuerySelectorAll<IHtmlAnchorElement>(".side_body:last-child a"))
{
    var page = await context.OpenAsync(arvhives.Href);

    // content(ページ本体)下のimgを全部。
    // 今回はページ単位で5並列ダウンロードすることにする(粒度の考え方は色々ある)
    var imgs = page.QuerySelectorAll<IHtmlImageElement>("#content img");
    await Parallel.ForEachAsync(imgs, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (img, ct) =>
     {
         var url = new Url(img.Source);
         var response = await loader.FetchAsync(new DocumentRequest(url)).Task;

         // とりあえず雑にFile書き出し。
         Console.WriteLine($"Downloading {url.Path}");
         using (var fs = new FileStream(@$"C:\temp\neuecc\{url.Path.Replace('/', '_')}", FileMode.Create))
         {
             await response.Content.CopyToAsync(fs, ct);
         }
     });
}

.NET 6から Parallel.ForEachAsync が追加されたので、asyncコードを並列数(MaxDegreeOfParallelism)で制御した並列実行が容易に書けるようになりました。async/await以降、Parallel系の出番は圧倒的に減ったのは確かなのですが、Task.WhenAllだけだと並列に走りすぎてしまって逆に非効率となってしまって、そこを制御するコードを自前で用意する必要が出てきていたりと面倒なものも残っていました。それが、このParallel.ForEachAsyncで解消されたと思います。

Kurukuru Progress

数GBの動画をダウンロードする時などは、プログレスがないとちゃんと動いているのか確認できなくて不便です。しかし、ただ単にConsole.WriteLineするだけだとログが凄い勢いで流れていってしまって見辛くて困りものです。そこを解決するC#ライブラリがKurukuruで、見ればどんなものかすぐわかるので、まずは実行結果を見てもらいましょう(素の回線だと一瞬でダウンロード終わってしまったので回線の低速シミュレーションしてます)

guruguru

一行だけを随時書き換えていってくれるので、見た目も非常に分かりやすくて良い感じです。これはとても良い。Kurukuru、今すぐ使いましょう。ちなみに今回の記事で一番時間がかかったのは、Kurukuruの並列リクエスト対応だったりして(対応していなかったのでコード書いてPR上げて、今日リリースしてもらいましたできたてほやほやコード)。

AngleSharp側のコードですが、この例はFile Examples のMP4を並列で全部取るというものです。

using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Io;
using Kurukuru;
using System.Text;

// Kurukuruを使う上で大事なおまじない
// え、デフォルトのEncodingがUTF8じゃないシェルがあるんです!?←Windows
Console.OutputEncoding = Encoding.UTF8;

var config = Configuration.Default
    .WithDefaultLoader()
    .WithDefaultCookies();

using var context = BrowsingContext.New(config);

var requester = context.GetService<DefaultHttpRequester>();
requester.Headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.106 Safari/537.36";

var doc = await context.OpenAsync("https://file-examples.com/index.php/sample-video-files/sample-mp4-files/");
var loader = context.GetService<IDocumentLoader>();

// ここから本体
var mp4s = doc.QuerySelectorAll<IHtmlAnchorElement>("a").Where(x => x.Href.EndsWith(".mp4"));
Console.WriteLine("Download sample-mp4-files");
await Parallel.ForEachAsync(mp4s, new ParallelOptions { MaxDegreeOfParallelism = 5 }, async (mp4, ct) =>
{
    var bin = await loader.FetchBytesAsync(mp4.Href);
    // あとはFile.WriteAllBytesするとか好きにして
});

ポイントは var bin = await loader.FetchBytesAsync(mp4.Href); で、これは拡張メソッドです。loaderにProgress付きでbyte[]返すメソッドを生やしたことで、随分シンプルに書けるようになりました。StreamのままFileStreamに書いたほうがメモリ節約的にはいいんですが、中途半端なところでコケたりした場合のケアが面倒くさいので、ガチガチなパフォーマンスが重視される場合ではないならbyte[]のまま受けちゃってもいいでしょう。1つ4GBの動画を5並列なんですが?という場合でも、たかがメモリ20GB程度なので普通にメモリ積んで処理すればいいっしょ。

FetchBytesAsyncの中身は以下のようなコードになります。

public static class DocumentLoaderExtensions
{
    public static async Task<byte[]> FetchBytesAsync(this IDocumentLoader loader, string address, CancellationToken cancellationToken = default)
    {
        var url = new AngleSharp.Url(address);
        var response = await loader.FetchAsync(new DocumentRequest(url)).Task;
        if (response.StatusCode != System.Net.HttpStatusCode.OK)
        {
            return Array.Empty<byte>(); // return empty instead of throws error(ここをどういう挙動させるかは好みで……。)
        }

        // Content-Lengthが取れない場合は死でいいということにする
        var contentLength = int.Parse(response.Headers["Content-Length"]);

        using var progress = new ProgressSpinner(url.Path.Split('/').Last(), contentLength);
        try
        {
            return await ReadAllDataAsync(response.Content, contentLength, progress, cancellationToken);
        }
        catch
        {
            progress.Cancel();
            throw;
        }
    }

    static async Task<byte[]> ReadAllDataAsync(Stream stream, int contentLength, IProgress<int> progress, CancellationToken cancellationToken)
    {
        var buffer = new byte[contentLength];
        var readBuffer = buffer.AsMemory();
        var len = 0;
        while ((len = await stream.ReadAsync(readBuffer, cancellationToken)) > 0)
        {
            progress.Report(len);
            readBuffer = readBuffer.Slice(len);
        }
        return buffer;
    }
}

public class ProgressSpinner : IProgress<int>, IDisposable
{
    readonly Spinner spinner;
    readonly string fileName;
    readonly int? totalBytes;
    int received = 0;

    public ProgressSpinner(string fileName, int? totalBytes)
    {
        this.totalBytes = totalBytes;
        this.fileName = fileName;
        this.spinner = new Spinner($"Downloading {fileName}");
        this.spinner.Start();
    }

    public void Report(int value)
    {
        received += value;
        if (totalBytes != null)
        {
            var percent = (received / (double)totalBytes) * 100;
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)} / {ToHumanReadableBytes(totalBytes.Value)} ( {Math.Floor(percent)}% )";
        }
        else
        {
            spinner.Text = $"Downloading {fileName} {ToHumanReadableBytes(received)}";
        }
    }

    public void Cancel()
    {
        spinner.Fail($"Canceled {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    public void Dispose()
    {
        spinner.Succeed($"Downloaded {fileName}: {ToHumanReadableBytes(received)}");
        spinner.Dispose();
    }

    static string ToHumanReadableBytes(int bytes)
    {
        var b = (double)bytes;
        if (b < 1024) return $"{b:0.00} B";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} KB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} MB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} GB";
        b /= 1024;

        if (b < 1024) return $"{b:0.00} TB";
        b /= 1024;

        return $"{0:0.00} PB";
    }
}

KurukuruのSpinnerを内包した IProgress<T> を作ってあげて、その中でよしなにやってあげるということにしました。まぁちょっと長いですが、一回用意すれば後はコピペするだけなので全然いいでしょう。みなさんもこのProgressSpinner、使ってやってください。

コマンド引数やロギング処理やオプション取得

クローラーとしてガッツシやりたいなら、モードの切り替えとかロギングとか入れたいです、というか入れます。そこで私が定形として使っているのはConsoleAppFrameworkZLogger。Cysharpの提供です。ワシが作った。それと今回のようなケースだとKokubanも便利なので入れます。やはりCysharpの提供です。

<ItemGroup>
    <PackageReference Include="AngleSharp" Version="1.0.0-alpha-844" />
    <PackageReference Include="Kurukuru" Version="1.4.0" />
    <PackageReference Include="ConsoleAppFramework" Version="3.3.2" />
    <PackageReference Include="ZLogger" Version="1.6.1" />
    <PackageReference Include="Kokuban" Version="0.2.0" />
</ItemGroup>

この場合Program.csは以下のような感じになります。割と短いですよ!

using ConsoleAppFramework;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Text;
using ZLogger;

Console.OutputEncoding = Encoding.UTF8;

await Host.CreateDefaultBuilder()
    .ConfigureLogging(x =>
    {
        x.ClearProviders();
        x.AddZLoggerConsole();
        x.AddZLoggerFile($"logs/{args[0]}-{DateTime.Now.ToString("yyyMMddHHmmss")}.log");
    })
    .ConfigureServices((hostContext, services) =>
    {
        services.Configure<NanikaOptions>(hostContext.Configuration.GetSection("Nanika"));
    })
    .RunConsoleAppFrameworkAsync(args);

public class NanikaOptions
{
    public string UserId { get; set; } = default!;
    public string Password { get; set; } = default!;
    public string SaveDirectory { get; set; } = default!;
}

コンソールログだけだとウィンドウ閉じちゃったときにチッとかなったりするので(?)、ファイルログあると安心します。ZLoggerは秘伝のxmlコンフィグなどを用意する必要なく、これだけで有効化されるのが楽でいいところです。それでいてパフォーマンスも抜群に良いので。

ConsoleAppFrameworkはGenericHostと統合されているので、コンフィグの読み込みもOptionsで行います。appsettings.jsonを用意して

{
  "Nanika": {
    "UserId": "hugahuga",
    "Password": "takotako",
    "SaveDirectory": "C:\\temp\\dir",
  }
}

.csprojのほうに

<ItemGroup>
    <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
</ItemGroup>

と書いてあげれば、自動で読み込まれるようになるという仕様です。そして本体のコードは

public class NanikaDownloader : ConsoleAppBase
{
    readonly ILogger<NanikaDownloader> logger;
    readonly NanikaOptions options;

    // コンストラクタインジェクションでOptionsを受け取る
    public NanikaDownloader(ILogger<NanikaDownloader> logger, IOptions<NanikaOptions> options)
    {
        this.logger = logger;
        this.options = options.Value;
    }

    public async Task DownloadAre()
    {
        // Context.CancellationTokenを渡すのを忘れないように!(Ctrl+Cのキャンセル対応に必須)
        await loader.FecthAsyncBytes("...", Context.CancellationToken)
    }

    public async Task DownloadSore(int initialPage)
    {
        // Kokubanを使うとConsoleに出す文字列の色分けが簡単にできる!( `Chalk.Color +` だけで色が付く)
        logger.LogInformation(Chalk.Green + $"Download sore {initialPage} start");
    }
}

のように書きます。これの場合は、引数で NanikaDownloader.DownloadAre, NanikaDownloader.DownloadSore -initialPage * の実行切り替えができるようになるわけですね……!

また、文字色が一色だけだとコンソール上のログはかなり見づらいわけですが、Kokubanを使うことで色の出し分けが可能になります。これは、地味にめちゃくちゃ便利なのでおすすめ。別にバッチ系に限らず、コンソールログの色を調整するのってめっちゃ大事だと、最近実感しているところです。

ASP .NET Core(とかMagicOnionとか)で、ZLoggerでエラーを赤くしたい!とか、フレームワークが吐いてくる重要でない情報はグレーにして目立たなくしたい!とかの場合は、ZLoggerのPrefix/SuffixFormatterを使うのをオススメしてます(Kokubanのようにさっくり書けはしないのですが、まぁConfigurationのところで一回やるだけなので)

logging.AddZLoggerConsole(options =>
{
#if DEBUG
    // \u001b[31m => Red(ANSI Escape Code)
    // \u001b[0m => Reset
    // \u001b[38;5;***m => 256 Colors(08 is Gray)
    options.PrefixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error)
        {
            ZString.Utf8Format(writer, "\u001b[31m[{0}]", info.LogLevel);
        }
        else
        {
            if (!info.CategoryName.StartsWith("MyApp")) // your application namespace.
            {
                ZString.Utf8Format(writer, "\u001b[38;5;08m[{0}]", info.LogLevel);
            }
            else
            {
                ZString.Utf8Format(writer, "[{0}]", info.LogLevel);
            }
        }
    };
    options.SuffixFormatter = (writer, info) =>
    {
        if (info.LogLevel == LogLevel.Error || !info.CategoryName.StartsWith("MyApp"))
        {
            ZString.Utf8Format(writer, "\u001b[0m", "");
        }
    };
#endif

}, configureEnableAnsiEscapeCode: true); // configureEnableAnsiEscapeCode

こういうの、地味に開発効率に響くので超大事です。やっていきましょう。

まとめ

AngleSharpにかこつけてウォッチウィンドウをとにかく紹介したかったのです!ウォッチウィンドウ最強!値の変化があると赤くなってくれたりするのも便利ですね、使いこなしていきましょう。別にUnityとかでもクソ便利ですからね?

あ、で、AngleSharpはめっちゃいいと思います。他の言語のスクレピングライブラリ(Beautiful Soupとか)と比べても、全然張り合えるんじゃないかな。冒頭に書きましたがE2Eテストへの応用なども考えられるので、使いこなし覚えるのとてもいいんじゃないかと思います。ドキュメントが色々書いてあるようで実は別にほとんど大したこと書いてなくて役に立たないというのは若干問題アリなんですが、まぁ触って覚えるでもなんとかなるので、大丈夫大丈夫。

Prev | | Next

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive