より良いトランザクションスクリプトを目指す

ファウラーのエンタープライズアプリケーションアーキテクチャパターン(PofEAA)において、 ビジネスロジックアーキテクチャにはドメインモデルトランザクションスクリプトかという二択があります。

仮にプレイヤーの名前変更(ゲームでは可能なことも普通にあるので)をするとします。

var user = _repository.Find(userId);
user.ChangeName(name); //バリデーションは中で行われる=ビジネスロジックがオブジェクトにある
_repository.Save(user);
//ロジックがモデルオブジェクトの外にある
if(!IsValidName(name))
{
    throw new ArgumentException("name");
}

var user = _repository.Find(userId);
user.Name = name;
_repository.Save(user);

-トランザクションスクリプト

if(!IsValidName(name))
{
    throw new ArgumentException("name");
}

_dao.UpdateName(userId, name);

daoはData Access Object です。PofEAAでは「テーブルデーゲートウェイ」という名前で紹介されている、SQL文と実行を閉じ込めただけの簡素なデータアクセスクラスです。
SIにいた人なら「ダオ」のほうが通りが良いのではと思います。テーブルデーゲートウェイなんて現場で聞いたことが無い(私がそうというだけかもしれませんが)。

それはさておき、アプリで使われるSQLを完全に制御したい(手書きSQLを使いたい)なら基本的にデータアクセスはO/RマッパーやActiveRecordではなくDAOを選択することになります。
そしてデータアクセスでO/RマッパーやActiveRecordを使わないということはドメインモデルを選択できないということでもあります。
ドメインモデルに実装されるビジネスロジックは、最終的に集約のルートを頂点としたプレーンなオブジェクトグラフを更新するだけで、データの保存に関しては関知しません。O/Rマッパーが、オブジェクトグラフをどう保存するかを知っていてよしなに保存するのです。
DAOが、単に変更後のオブジェクトグラフを渡されてこれを正しく保存せよと言われると大変厳しい。

単に一つのエンティティを保存するだけなら、力業で全プロパティをUpdateする汎用UPDATE文をDAOに用意すれば効率はさておき保存は可能です。しかしオブジェクトがコレクションを持っていたりするともうだいぶ厳しい。

class Blog
{
    ...
    //これへのAddをDAOで検知するの厳しい
    public ICollection<Comment> Comments { get; }
}

これをスマートに検知できるのはもうシンプルなSQLのラッパーの範疇を超えています。
力技ならCommentDaoに全行InsertOrUpdateみたいなことをできるかもしれないけど、これをやるともうSQLを完全に制御して性能を稼ぐDAOのメリットがありません。O/Rマッパーよりはるかにひどい性能劣化を引き起こしてしまいます。
つまり、SQLを細かく制御したいならばドメインモデルを選択することはできず、トランザクションスクリプト一択だと思います。

なおSQLを細かく制御するということは、「データがどのように保存されるべきか」をビジネス層が知っているということです。少なくとも完全な隠蔽はできません。だからビジネスロジックであるにも関わらず「トランザクションスクリプト」という名前が付けられているのでしょう。

より良いトランザクションスクリプトを目指す

トランザクションスクリプトアンチパターンではありません。PofEAAでファウラーが「ビジネスロジックが複雑になってきたらオブジェクト指向的な手法のほうが良い」と連呼しているし DDDも有名なのでトランザクションスクリプトはなんとなく肩身が狭いですが、RDBが性能のボトルネックになりうる環境でSQLを細かく制御したいというのは自然な欲求であると自分は思います。 DAOは実装がシンプルなのでオーバーヘッドも少なく、バッチでの使用もまったく問題ありません。O/Rマッパーをバッチで使えと言われたらやったことはないですが個人的にはやや不安だったりします。チェンジトラッカーさん耐えれられるのかな…

というわけで、より良いトランザクションスクリプトのためにモヤモヤと考えていること。

リポジトリパターン

トランザクションスクリプト+DAOではリポジトリパターンは不要と考えます。集約の単位で不変条件を守りながらデータの出し入れをするDDDのデータアクセスレイヤーがリポジトリです。DAOことテーブルデーゲートウェイはテーブル単位なのでリポジトリより細かい単位なので、リポジトリでDAOをラップすれば集約の単位での保存が可能になりますが、おそらくラップしているリポジトリ側で組み合わせ爆発的にメソッドを生やさないといけなくなってくるので、DAOをバラバラに触ったほうが効率が良いでしょう。また、単一エンティティの集約の場合は、リポジトリは単にDAOに引数をリレーするだけの存在になってしまいます。これを維持するのはつらかろう…ということで不要として良いのではと思います。

もしDAOに相当するものを流行りに流されてリポジトリと呼んでいるなら改名したほうが良いでしょう。パターンは同じものを指してこそ。誤用は避けていきたい。

「ふん!リポジトリっていうのかい?贅沢な名前だねぇ…!今からお前の名はダオだ!いいかい、ダオだよ!」

テスタビリティ

I/Oのレイヤーを差し替えてテストしやすくする場合、トランザクションスクリプトが直接DAOを触るので、DAOにinterfaceを持たせて差し替え可能にすることになりますので、DAOのファクトリをDIで挿せばテスタビリティは問題なし。

余談ですが、手書きSQLを使うパターンには行データゲートウェイもあります。これはデータのレコードが自らのCUDを行うオブジェクトです。

public class HogeRecordGateway
{
    //このあたりはDBのカラム
    public long Id { get; set; }
    public string Name {get; set; }
    ...

    //ここからはCRUDのCUD
    public void Insert() { ... }
    public void Update() { ... }
    public void Delete() { ... }
}

テスタビリティを考えると結局はInsert/Update/DeleteはDIされたDAO(テーブルデーゲートウェイ)に任せる必要があるため、行データゲートウェイを生成するファクトリまで必要になってしまうのでイマイチだと思います。
PlainなデータクラスとDAOを使うほうがマシではないかと。

ドメインサービス

DDDにおける「エンティティでもバリューオブジェクトでもない、関数のようにしかモデリングできないもの」がサービスになりますが、これはトランザクションスクリプトでも問題なく使用できます。というかエンティティ側にロジックを持たない以上は「ドメインサービスの組み合わせ+流れるデータ+DAOでの保存=トランザクションスクリプト」という形になってくるはずで、比重が増えます。

CQRS

問題なく使用できますが、コマンド側にもドメインモデルが登場しなくなるのでクエリがドメインモデルをバイパスできるメリットは感じにくくなるかもしれません。キャッシュ参照をクエリ側に閉じ込めることで見通しを良くするメリットは依然として享受できるはずです。(キャッシュ消しはコマンド側からも行う必要があるので、完全に手が切れるわけではありません)

まとめ

スマホゲームのサーバサイドアーキテクチャについてモヤモヤと考えていること

とりとめもなく書き散らかします。

テスタビリティ

真面目に単体テストを書く人がチームに入ったのでテスタビリティについて真面目に考えています。特にインゲームロジックについてはテストを組めるようにすることの価値が非常に大きいので、手間暇かけてテスト可能にしています。

身も蓋もないことをいうと中核のロジック以外はとどのつまりはIOみたいなものなので(ビューやタイマーとかも含む)、IOのレイヤーを差し替え可能にすることがテスタビリティを担保します。動的言語ではレイヤーなどは差し替え自由自在ですが、C#ではこのための最も有力な手段はDIになると思います。

DI考

副作用、寿命、実行時パラメータ、ポリモーフィズム

実製品レベルでDIを組んでみると寿命の問題が難しさの中心にくることがわかります。

ServiceA → ServiceB → ServiceC のような依存がある場合、一番寿命の短いものに全体がひきずられる、ということが良くあります。

public class ServiceA
{
    private ServiceB _serviceB;
    pubilc ServiceA(ServiceB serviceB) => _serviceB = serviceB;
}

public class ServiceB
{
    private ServiceC _serviceC;
    pubilc ServiceA(ServiceC serviceC) => _serviceC = serviceC;
}

services.AddSingleton<ServiceA>();
services.AddSingleton<ServiceB>();
services.AddTransient<ServiceC>(); //NG: 寿命が短いものが寿命が長いものに使われている!

この手の寿命問題を簡素にするためには、DIコンテナ管理化に置かれるオブジェクトの多くを状態をもたない関数にしてしまうのが最も良いでしょう。状態をもたなければ、TransientだろうがSingletonだろうがそのオブジェクト自体は同じ動作をするので、自分が依存サービスに合わせて自分の寿命をいかようにも落とせます。ステートレスなWebサービス内なら仮に全部TransientにしたとしてもDIに生成されるオブジェクトが1リクエストで何万も生成されるわけないのでallocのコストは無視して良いレベルに収まります。
※リアルタイムなゲームサーバで1サーバマルチインスタンスで毎秒何十フレームも回しているケースではallocのコストも考えたほうがいいとは思います

状態を持たないオブジェクトを主体にすると、ポリモーフィズムの適用範囲もDI管理下のオブジェクト群に寄っていくことになります。

つまり

//サービスのクライアントコード
var obj = _factory.Create(type, param);
obj.DoPolymorphic(); //objの型に応じた処理

から

//サービスのクライアントコード
_service.DoPolymorphic(type, param);

という、パラメータをコンストラクタで詰めて後で命令だけ出すような可能な限り駆逐されていく方向に向かいます。これはもう必然というか、DIをやってくと前者のようなオブジェクトは使いにくく感じるはずです。DIが、合成のルート(ASP.NET MVCのControllerみたいな)に対して一発Resolveすることで連鎖的に生まれるもろもろは生成の時点では柔軟にパラメータを渡せないことがほとんどなので、パラメータ渡しのためにFactoryを作る羽目になるのが嫌だなぁと、自然になっていくと思います。

生成の重たいサービス

public class HogeController
{
    private readonly IServiceA _serviceA;
    private readonly IServiceB _serviceB;

    //コンストラクタは省略

    public IAsyncResult DoA()
    {
        _serviceA.DoNantoka();
        ...
    }
    
    public IAsyncResult DoB()
    {
        //DoBが呼ばれたときだけしか使われないサービス
        //実は生成が重たいので、コンストラクタインジェクションは勿体ない
        _serviceB.DoNantoka();
        ...
    }
}

コメントにかいてあるとおり、コンストラクタで注入はするものの使うかどうかは状況次第の重たいサービスみたいなのがあるとしたら勿体ない。ので、IServiceBを取得するためのブツだけ注入して、状況に応じて使うということが考えられます。

public class HogeController
{
    ...
    private readonly IServiceBFactory _serviceBFactory;

    //コンストラクタは省略

    ...

    public IAsyncResult DoB()
    {
        var serviceB = _serviceBFactory.Create();
        serviceB.DoNantoka();
        ...
    }
}

DIの本を読むと、これはアンチパターンらしいです。なんでや!

理由は「Leaky Abstraction」とのことで、解決策として以下のようなコードを上げています。

public class LazyServiceB : IServiceB
{
    private readonly Lazy<ISerivceB> _lazy;
    public LazyServiceB(IServiceBFactory factory)
    {
        _lazy = new Lazy(() => factory.Create());
    }

    public void DoNantoka()
    {
        _lazy.Value.DoNantoka();
    }
}

こいつを挿せばDoB以外ではコスト気にしなくていいでしょうと。なるほどそれ自体はごもっとも。

ただし性能が大事なアプリでコストを隠蔽するのは個人的にはあまり好きではありません。重さの理由がIO待ちだったらfactoryはCreateではなくCreateAsyncを公開するべきだろうし、そういうものを含めた全体で見た場合には必要な情報を露出しているだけで「Leaky Abstraction」とは思えないのですね。ただし後者のLazyな例がハマるケースもあるだろうとは思っています。

実行時パラメータを渡してオブジェクトを生成しなければならない場合はFactory一択ですし、ケースバイケースで考えて使う。

キャッシュとCQRS

Redisのようなキャッシュ層をいれる場合はCQRS(コマンドクエリ責務分離)も採用でいい気がしている昨今です。クエリ専用のDBやテーブルをつくらなくても、コード内でServiceを分けるだけでもだいぶスッキリするかと思います。つまりこう。

public class DeckController
{
   private readonly IDeckQueryService _queryService;
   private readonly IDeckCommandService _commandService;
}

そもそも更新のときには、更新の前の読み取りでキャッシュをつかってはいけないわけですし、根本的に通るコードや関連クラスを分けてしまったほうがスッキリする。クエリはDB or キャッシュのデータアクセスと、クライアントに返すDTOへの変換が主なのでビジネスロジックが関与する余地が少ない。フィルタ要件によっては面倒も多いけど、単純作業が多いだけで複雑ではない。デルレイヤーを通す必要もないはずです。

コマンド側は複雑なことが多いです。強化であれば、餌になったキャラが消えた結果、セットしていたデッキからは外す必要があり、強化された結果進化しちゃったりすることがあり、ユーザーのパラメータアップによってミッションが達成されることがある。こういうドメインロジックの複雑さはコマンド側に寄ります。決してクエリではない。

コマンド側もキャッシュから読み取ってはいけないがキャッシュを飛ばす必要はあるので、キャッシュと完全にノータッチとはいきませんがそれでも分けたほうが幸せなんじゃないかというのが僕の肌感覚です。ちなみに実戦投入はしていません。

サーバサイドで何をキャッシュするか

ガラケーのブラウザ時代と違ってスマホならばアプリを一度起動すれば自分のデータは自端末にキャッシュされるわけだから、自分のデータの表示のためのRedisキャッシュはあまり意味をなさないのではないかと思います。ギルドやフレンドなどで他人のデータを見る時がRedisキャッシュの一番の用途となる。そう考えると、たとえば他人のデッキ一覧を見る要件は無いが他人が最後に使用した(いわゆる現在の)デッキを見ることはできる要件があるならば、デッキ詳細のオブジェクトグラフをシリアライズしてユーザー単位で格納、とかのほうがキャッシュ効率は良さそうです。ただし、アプリ起動時の一発目にどかっと自キャラ情報をとるところでDBを守らなくて良いのかという気持ちもなくはないので、テーブルをそのまま突っ込むような汎用キャッシュとすぐ手が切れるかと言われればまだ迷いはあります。モヤモヤ

Migrationやデザインタイムの挙動を別アセンブリに閉じ込める

「Migration用のコードがアプリ実行時に含まれるのが嫌だ。ツール実行時だけ存在すればいい」というニーズもごく普通にあろうかと思います。 EF Coreはそのあたりのオプションがちゃんと用意されています。大丈夫だ問題ない。が、結構わかりにくい挙動をしますのでメモを残します。

ソリューション構成は以下とします。

WebApplication1
  WebApplication1 (ASP.NET Coreプロジェクト)
  AppCore (MyDbContextのあるアセンブリ)
  AppCore.Migrations (MyDbContextのMigrationのコードの生成先)

MyDbContextからパラメータのないコンストラクタを抹殺する

さきにASP.NET固有の問題を片付けます。 ASP.NETからEF Coreを使用する場合、AddDbContextPool<MyDbContext>でPoolを使って性能を稼ぎたいはずですが、Poolを使う場合はDbContextのパラメータのないデフォルトコンストラクタは使用できません。
DBファーストでscaffoldを使ってMyDbContextを作成する場合はデフォルトコンストラクタが勝手に作成されるのでこれを手で消す必要があります。(DbContextOptions<MyDbContext>を受け取るコンストラクタのみとします)

ところが、このデフォルトコンストラクタの削除によってMigrationにエラーがでるようになります。

Migrationを実行するプロジェクト(dotnet ef migrations addを実行するプロジェクト)ではMigrationさんがMyDbContextをnewしようとします。
このとき、ASP.NETのようにpublic static IHostBuilder CreateHostBuilder(string[] args)が存在するプロジェクトではDI経由でMyDbContextを生成してもらえるので、DbContextOptions引数をもつコンストラクタを読んでもらえるのですが、
Migration実行プロジェクトにCreateHostBuilderがない場合は、MigrationさんはデフォルトコンストラクタでMyDbContextをnewしようとします。Poolのために消しちゃうのでエラーとなります。

この問題を解決するため、Migrationを実行するプロジェクトにCreateHostBuilderがない場合は、IDesignTimeDbContextFactory<TDbContext>を実装したクラスを作る必要があります。

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

public class DesignTimeTestDbContextFactory : IDesignTimeDbContextFactory<MyDbContext>
{
    public MyDbContext CreateDbContext(string[] args)
    {
        var optionsBuilder = new DbContextOptionsBuilder<MyDbContext>();
        optionsBuilder.UseSqlServer("Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=testdb;");
        return new MyDbContext(optionsBuilder.Options);
    }
}

これでMyDbContext側には必要なコンストラクタのみを残すことができて整います。 また、上記のinterfaceはMicrosoft.EntityFrameworkCore.Designパッケージにありますが、このパッケージはMigrationを実行するプロジェクトには必ず必要なものです。入れておきましょう。
今回の例ソリューション構成だと、AppCore.Migrationsプロジェクトにパッケージを入れます。

MigrationsAssemblyを指定する

Migrationの既定の動作は、「MigrationファイルはMyDbContextが存在するアセンブリに出力する」というものです。
このルールからそれる場合、オプションでその旨を伝える必要があります。
これは、Migrationを実行するプロジェクトがどこであれ、MyDbContextと異なるアセンブリにMigrationファイルを出力するならば必要です。

public class DesignTimeTestDbContextFactory : IDesignTimeDbContextFactory<MyDbContext>
{
    public MyDbContext CreateDbContext(string[] args)
    {
        var optionsBuilder = new DbContextOptionsBuilder<MyDbContext>();
        optionsBuilder.UseSqlServer(
            "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=testdb;",
            b => b.MigrationsAssembly("AppCore.Migrations"));
        return new MyDbContext(optionsBuilder.Options);
    }
}

Migraionファイルが作成されるプロジェクトに、MyDbContextをもつプロジェクトへの参照を持たせる

Migrationで作成されるC#ソースコードはMyDbContextへの参照があるのでコンパイルを通すために単に必要です。 例のソリューション構成だと、AppCore.DesignがAppCoreを参照します。

Migrationファイルが生成されるプロジェクトのアセンブリの出力先を、Migrationを実行するプロジェクトの出力先と揃える

これはわかりにくいですが、仮に今回の例のソリューション構成でAppCoreでMigraionを実行する場合は、 AppCore/bin/$(Configuration)/AppCore.Migrations.dll
が存在するようにAppCore.Migrations側のOutputPathを設定しなければならない、ということです。
この挙動をさせるために、AppCore→AppCore.Migrationsのプロジェクト参照をもたせてしまうと循環参照になるのでNGです。

AppCore.MigrationsプロジェクトでMigrationを実行する場合はこの条件を勝手に満たすので、Migrationファイルが作成されるプロジェクトをMigration実行プロジェクトにしてしまうのが楽です。

もしもMigrations実行プロジェクトとMigrationsファイルが生成されるプロジェクトを分ける場合は、dotnet ef migrationsコマンドでも--projectオプションを渡す必要があります。

例のソリューション構成でAppCoreやWebApplication1でMigrationを実行する場合は以下のようになります。

dotnet ef migrations add MigrationName --project ../AppCore.Migrations

シャドウプロパティ

シャドウプロパティは、エンティティclass側には存在しないがDBのテーブル側には存在する列を作る、使うための機能です。

public partial class Blog
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Text { get; set; }
}

こういうclassに対して、Fluent APIでシャドウプロパティを追加で定義できます。

builder.Property(typeof(DateTimeOffset), "Created")
    .HasColumnType("datetimeoffset");
    
builder.Property(typeof(DateTimeOffset), "Modified")
    .HasColumnType("datetimeoffset");

こうすると、EF CoreはCreatedやModifiedをテーブルに列が存在するものとして扱います。 Migrationすればちゃんとテーブルに列が作られますし、select * from BlogでちゃんとCreated列もModified列も読み込まれますし、ChangeTrackerのAPI経由で値の取得や更新も可能です。 普通にアプリを書いている分にはエンティティclass経由で操作することがほぼ全てだと思われますので、通常のアプリコードからは隠蔽されます。

シャドウプロパティは、他のテーブルへのリレーションのキーとして使われたりするようです。

public partial class Blog
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Text { get; set; }
    public OtherEntity OtherEntity { get; set; } //シャドウプロパティでOtherEntityId列が隠れている
}

システム列として論理削除列、作成日時、更新日時などアプリから隠蔽したい日付を持たせたい場合も有効です。というかMigrationを考えるとシャドウプロパティにするほかありません。

シャドウプロパティに初期値を与える

シャドウプロパティに初期値を与えたい場合、定数であればFluentAPIのHasDefaultValueを使うことができます。

builder.Property(typeof(DateTimeOffset), "Created")
    .HasColumnType("datetimeoffset")
    .HasDefaultValue(DateTimeOffset.MinValue);

あるいはSQLのDEFAULT制約を用いることもできます。

builder.Property(typeof(DateTimeOffset), "Created")
    .HasColumnType("datetimeoffset")
    .HasDefaultValueSql("getdate()");

コードで動的に値を与えたい場合は、ValueGeneratorを使います。

using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.ValueGeneration;

public class CurrentDateTimeOffsetValueGenerator : ValueGenerator<DateTimeOffset>
{
    public override bool GeneratesTemporaryValues => false;

    public override DateTimeOffset Next([NotNull] EntityEntry entry)
    {
        return DateTimeOffset.Now;
    }
}
builder.Property(typeof(DateTimeOffset), "Created")
    .HasColumnType("datetimeoffset")
    .HasValueGenerator<CurrentDateTimeOffsetValueGenerator>();

更新時にシャドウプロパティも更新する

基本的に、DB側でTriggerやそれに類する仕組みを使用すべきです。 コードで行う場合、EF Coreにはそれを行う正規の手順はありません。ValueGeneratorは、エンティティの新規作成時のみしか動作しません。

ただしどうしても行いたいなら、DbContext.SaveChanges()をオーバーライドして中でChangeTrackerをごにょることで実現することは可能です。おすすめはしませんが。※非同期の場合はSaveChangesAsyncも

public override int SaveChanges()
{
    var now = DateTimeOffset.Now;
    foreach(var x in ChangeTracker.Entries()
        .Where(x => x.State == EntityState.Modified || x.State == EntityState.Added))
    {
        //全てのテーブルにModifiedが存在する前提
        x.Property("Modified").CurrentValue = now;
    }

    return base.SaveChanges();
}

DB側でTriggerなどを用いて新規作成時や更新時に値をいれる列では、アプリ側で値を入れるのは無駄です。 EF Coreでは、そのようなときに、これはDB側で更新される列だから余計なことをしないように、と伝える方法があります。

builder.Property(typeof(DateTimeOffset), "Created")
    .HasColumnType("datetimeoffset")
    .ValueGeneratedOnAdd();

builder.Property(typeof(DateTimeOffset), "Modified")
    .HasColumnType("datetimeoffset")
    .ValueGeneratedOnAddOrUpdate();

ValueGeneratedOnAddOrUpdate()というメソッド名をみると、先述したValueGeneratorがUpdate時も動いてくれそうな印象がありますが、これはまったくValueGeneratorとは無関係です。ValueGeneratorはエンティティ新規作成時しか起動されません。

EntityFramework CoreでSELECTせずにUPDATEを行う

O/Rマッパーでデータの更新を行う場合は、

  1. オブジェクトを取得して
  2. プロパティを変更して
  3. SaveChanges()を行う

というのが普通です。

using(var context = new BlogDbContext())
{
    var blog = context.Blog.Find(1);
    blog.Title = "まだ〇〇で消耗しているの?";
    await context.SaveChangesAsync();
}

取得も更新もしているので、もちろんSELECT文とUPDATE文が飛びます。

SQLだとPKと変更したい値がわかってるならSELECT無しでUPDATEできるじゃん!無駄!」という気持ちになりますよね。なりませんか。O/Rマッパーは値の変更を追跡しているので、ChangeTrackerに変更を伝える事さえできれば、SELECTは要りません。EntityFrameworkでは、DbContext.Entryメソッドで取得した諸々からChangeTrackerとやり取りをできます。

using(var context = new BlogDbContext())
{
    var blog = new Blog
    {
        Id = 1,
        Title = "まだ〇〇で消耗しているの?",
    };
    context.Entry(blog).Property(nameof(Blog.Title)).IsModified = true;
    await context.SaveChangesAsync();
}

このレベルのチューニングをすべきアプリでそもそもなんでO/Rマッパーなんぞ使うんだ…というのはさておきですけどね。

UIElementsの基本構造

UIElementsがいまいち使いにくい。もっと拡張性を上げられないものか…とおもってUIElementsのソースコードをいろいろ読み漁っていたら理解が深まってしまったのでメモとして残します。 読んでも「ふーん」と思うだけできっと役に立ちません。

VisualTreeAssetとは何なのか

var vitualTreeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/Nantoka.uxml");
var rootElement = vitualTreeAsset .CloneTree();

UIElementsを使う場合はいつも上記のようなコードを書くと思います。拡張子がuxmlなのでuxmlファイルの生テキストそのものを読んでいるような気持ちになりますが、これは厳密には違います。

XMLが記述されたテキストファイルであるところのuxmlファイルは専用のScriptedImporterによって前処理され、ScriptableObjectに変換されます。このScriptableObjectがVisualTreeAssetです。つまりVisualTreeAssetはXMLのパースが完了した結果生まれたバイナリです。

VisualTreeAssetに含まれる要素はいくつかの種類に分類されます。

  • VisualElementAsset
  • inlineStyleSheet
  • UsingEntry
  • TemplateAsset

などです。順にみていきましょう。

VisualElementAsset

VisualElementAssetは以下のような代物です

[Serializable]
class VisualElementAsset
{
    int Id;
    string 要素名;
    string フルネーム(解決済み名前空間付き要素名);
    int 親のId;
    (string attributeName, string attributeValue)[] 要素がもつ全属性;
    string 要素内のテキスト;
}

VisualTreeAssetにはこれがListで入っています。 この時点では、まだButtonやImageなどの具体的なVIsualElementに解決はされていません。解決のための文字列情報をもっている状態です。

真の姿をみたければ公式のソースを。Propertyまわりがちょっとウッとなります。 https://github.com/Unity-Technologies/UnityCsReference/blob/master/Modules/UIElements/UXML/VisualElementAsset.cs

inlineStyleSheet

UXMLのビジュアル要素には以下のようにstyle属性を書けますが、これを抽出してかき集めたものです。

<engine:Button name="btn" text="Click!" style="height: 40px" />

uxmlファイルと同様にussファイルもScriptedImporterを経由してStyleSheetというScriptableObjectになります。inlineStyleSheetもソースがussファイルじゃなくなっただけでできるものは同じでStyleSheetを作ってVisualTreeAssetのSubAssetとして保持されます。

UsingEntry

UsingEntryは外部のテンプレートへの参照です。

<engine:Template path="Assets/Portrait.uxml" name="Portrait" />

上記のように書けばUXMLでは外部テンプレート(UXML)を参照して名前をつけることができますが、この要素それ自体は何もVisualElementを作りませんのでVisualElementAssetとして保持せずUsingEntryという構造体のListで持っています。

[Serializable]
struct UsingEntry
{
    public string alias;
    public string path;
    public VisualTreeAsset asset;
}

実際に外部Assetへの参照になっているのでAssetBundleの依存解決なんかも上手くいきそうな気がします。というかこうしないと文字列だけではUnityの参照解決の仕組みに乗っかれないので不安というか。

TemplateAsset

TemplateAssetは、UsingEntryで保持した外部テンプレートを使うための仕組みです。

<engine:Template path="Assets/Portrait.uxml" name="Portrait" />

<engine:Instance template="Portrait" />
<engine:Instance template="Portrait" />

上記のようにInstance要素をかけばTemplateを実体化できるわけですが、これは単純なVisualElementとは異なる挙動をする必要がるのでVisualElementAssetだけでは保持されません。TemplateContainerというVisualElementとTemplateAssetの合わせ技で処理されます。

その他のVisualTreeAsset内の要素

Slotsなどあるんですが、まだ私がちゃんと理解していないのでこの記事では触れません。
気になる方は公式のソースを読むなりマニュアルからあたりをつけるなりで平に御容赦を。

IUxmlFactory

IUxmlFactoryはVisualElementAssetをLabelやButtonなどの具体的なVisualElementに変換するための仕組みです。

public interface IUxmlFactory
{
    string uxmlName { get; }
    string uxmlNamespace { get; }
    string uxmlQualifiedName { get; }
    bool canHaveAnyAttribute { get; }
    IEnumerable<UxmlAttributeDescription> uxmlAttributesDescription { get; }
    IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription { get; }
    string substituteForTypeName { get; }
    string substituteForTypeNamespace { get; }
    string substituteForTypeQualifiedName { get; }
    bool AcceptsAttributeBag(IUxmlAttributes bag, CreationContext cc);
    VisualElement Create(IUxmlAttributes bag, CreationContext cc);
}

メンバーは結構ありますが、もっとも大事なのは uxmlQualifiedName とCreateです。
VisualElementAsset内のフルネームとuxmlQualifiedNameが一致したIUxmlFactoryのCreateがVisualElementの生成に使われます。逆にいうとuxmlQualifiedNameが一致するIUxmlFactoryが存在しそのCreateが何らかのVisualElementを返すならば、UXMLにはどんなXML要素を書いても良いのです。これがUXMLの拡張モデルの中核になります。

個人的に胸熱なのが、どうも公式のソースを読む限りUnity2020.1から、IUxmlFactory.Createがnullを返すことが許されるようになる気配があります。これは、UXMLの中に非VisualElement要素を自由に追加できるようになることを意味します。2019.2現在ではUXMLに非VisualElementを混ぜるにはScriptedImporterで特別扱いするしかなく、それためまったくカスタム不可能な領域です。ここに手が入るのは大変好ましいと思います。ただし未来のことなので取り下げられても文句は言えないので鵜呑みにしないでもらえると助かります。

UxmlTraits

IUxmlFactoryは要素とのマッピングを行いますが、UxmlTraitsは属性とのマッピングを行います。VisalElementAsset内の属性のキーと値のペア群を元にしてIUxmlFactory内で生成されるVisualElementのプロパティをセットします。UxmlTraitsは基本的にはIUxmlFactory実装の内部メンバーとして仕事します。

XMLスキーマ生成

Unityのアセットメニューで「Update UIElements Schema」を選択すると、IUxmlFactoryとUxmlTraitsの情報をもとにXMLスキーマが自動生成されます。XMLスキーマはUXMLを書く時のコード補完に使用されますので、ユーザーが追加したカスタムUML要素に対してもコード補完が効くようになります。

なお<engine:Template />や<engine:Instance />にコード補完が効かないのもこの仕組みが理由です。この二つはScriptedImporterで特別扱いされることで機能しているので対応したIUxmlFactoryが存在しないのです。これは2020.1でCreateがnullを返すIUxmlFactoryが作成されることで改善されそうな気配です。

まとめ

UXMLはScriptedImporterによってVisualTreeAssetに変換され、VisualTreeAsset内の各VisualElementAsset要素は対応するIUxmlFactoryによってButtonやLabelなどのVIsualElementに変換されます。 カスタムのIUxmlFactoryを書くことでUXMLを拡張できます。

f:id:enrike3:20191013173439p:plain

SRP Batcher良き良き

SRPを導入するならSRP Batcher使いたい!

SRP Batcherの解説といえば公式Blog。充実した内容です。

blogs.unity3d.com

Unity 2019.2からOpen GL ES 3.1もサポートし、幅広く使えるようになりました。

SRP Batcherは、Materialが切り替わってもShaderVariantさえ変わらなければ高効率な描画を維持できるという点で従来描画より優れています。

Dynamic BatchはMaterialに違いがあればバッチが効かず、また効いたとしてもMesh結合がCPUに優しくないため頂点数の多い3Dプロジェクトではデフォルトでオフになりました。

GPU Instancingは高効率ですが、メッシュが同一の描画のみを束ねます。メッシュが異なるとbreak。

SRP Batcherはメッシュ結合を行わないためDrawCallは減りませんがMaterialが切り替わってもOKということで使えるシーンが広がります。

https://blogs.unity3d.com/wp-content/uploads/2019/02/image3-5.png 公式blogより画像引用

ShaderはSRP Batcher対応のものを使わなければいけませんが、対応は拍子抜けするぐらい簡単です。

比較のために最初にSRP Batcher非対応のシェーダを書いて、次にSRP Batcherに対応させてみます。

というわけで以下はTextureに色を乗算する簡単なTransparentシェーダ 。この時点ではSRP Batcherとはcompatibleではありません。

Shader "SRPB/Hoge"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color("Color", Color) = (1,1,1,1)
    }
    SubShader
    {
        Tags
        {
            "Queue" = "Transparent"
            "IgnoreProjector" = "True"
            "RenderType" = "Transparent"
            "PreviewType" = "Plane"
            "CanUseSpriteAtlas" = "True"
        }

        Cull Off
        ZWrite Off
        ZTest Always
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
    
            fixed4 _Color;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv) * _Color;
                return col;
            }
            ENDCG
        }
    }
}

f:id:enrike3:20191011001112p:plain

_Colorが白と赤のMaterialを用意して、QuadとSphereで描画してみます。

f:id:enrike3:20191011003617p:plain Sphereはちょっとグロかった、反省している…
Materialが分かれているので、同一のShaderVariantでもSetPassCallが分かれてBatches(DrawCall)と同数です。

f:id:enrike3:20191011001512p:plain
FrameDebugger。Materialが異なるので当たり前ですがQuadとSphereの描画は別

シェーダをSRP Batcherとcompatibleに修正する

fixed4 _Color;

CBUFFER_START(UnityPerMaterial)
    fixed4 _Color;
CBUFFER_END

に書き換えるだけです。

f:id:enrike3:20191011002126p:plain
無事compatibleになりました

f:id:enrike3:20191011002402p:plain
DrawCallであるところのBatchesは据え置きですが、SetPassCallは一つ減ります。

f:id:enrike3:20191011002713p:plain
FrameDebugger。SRP BatcherによってSetPassCall1に対してDrawCallが2になっていることが表示されます。 Dynamic Batchだと1Meshの描画になるのでDrawCall自体が減るのですが、SRP BatcherはMesh結合をしないのでDrawCallは減りません。

このあたりはRenderDocでみると、もっとはっきりとわかります。

f:id:enrike3:20191011003221p:plain
QuadとSphereのDrawCallはちゃんと別々ですね。

まとめ

SRP Batcher良き。 シェーダの対応も簡単なので今後シェーダを書くときは最初からSRP Batcher対応シェーダを書くように癖をつけていこうと思ってます。