Skip to content

Latest commit

 

History

History
827 lines (631 loc) · 24.9 KB

README.md

File metadata and controls

827 lines (631 loc) · 24.9 KB

VitalRouter

GitHub license Unity 2022.2+

VitalRouter, is a zero-allocation message passing tool for Unity (Game Engine). And the very thin layer that encourages MVP (or whatever)-like design. Whether you're an individual developer or part of a larger team, VitalRouter can help you build complex game applications.

Bring fast, declarative routing to your application.

[Routes]
[Filter(typeof(Logging))]
[Filter(typeof(ExceptionHandling))]
[Filter(typeof(GameStateUpdating))]
public partial class ExamplePresenter
{
    // Declare event handler
    public void On(FooCommand cmd)
    {
        // Do something ...
    }

    // Declare event handler (async)
    public async UniTask On(BarCommand cmd)
    {
        // Do something for await ...
    }
    
    // Declare event handler with extra filter
    [Filter(typeof(ExtraFilter))]
    public async UniTask On(BuzCommand cmd, CancellationToken cancellation)
    {
        // Do something after all filters runs on.
    }
}
Feature Description
Declarative routing The event delivery destination and inetrceptor stack are self-explanatory in the type definition.
Async/non-Async handlers Integrate with async/await (with UniTask), and providing optimized fast pass for non-async way
With DI and without DI Auto-wiring the publisher/subscriber reference by DI (Dependency Injection). But can be used without DI for any project
Thread-safe N:N pub/sub Built on top of a thread-safe, in-memory, asynchronized pub/sub system, which is critical in game design.

Due to the async task's exclusivity control, events are characterized by being consumed in sequence. So it can be used as robust FIFO queue.
FIFO (First in first out), Fan-out In Game, it is very useful to have events processed in series, VitalRouter provide FIFO constraints. it is possible to fan-out to multiple FIFOs in concurernt.

Table of Contents

Installation

Prerequirements:

  • Unity 2022.2+
    • This limitation is due to the use of the Incremental Source Generator.
  • Install UniTask
    • Currently, VitalRouter uses UniTask instead of UnityEngine.Awaitable. UniTask is a fully featured and fast awaitable implementation.
    • In a future, if UnityEngine.Awaitable is enhanced in a future version of Unity, it may be migrated.
  • (optional) Install VContainer
    • For bringing in DI style, VitalRouter supports Integration with VContainer, a fast and lightweight DI container for Unity.

Then, add git URL from Package Manager:

https://github.com/hadashiA/Vitalrouter.git?path=/VitalRouter.Unity/Assets/VitalRouter#0.2.0

Getting Started

First, define the data types of the event/message you want to dispatch. In VitalRouter this is called "command". Any data type that implements ICommand will be available as a command, no matter what the struct/class/record is.

public readonly struct FooCommand : ICommand
{
    public int X { get; init; }
    public string Y { get; init; }
}

public readonly struct BarCommand : ICommand
{
    public Guid Id { get; init; }
    public Vector3 Destination { get; init; }
}

Command is a data type (without any functionally). You can call it an event, a message, whatever you like. Forget about the traditional OOP "Command pattern" :) This library is intended for data-oriented design.

The name "command" is to emphasize that it is a operation that is "published" to your game system entirely. The word is borrowed from CQRS, EventStorming etc.

One of the main advantages of event being a data type is that it is serializable.

[Serializable]       // < When you want to serialize to a scene or prefab in Unity.
[MessagepackObject]  // < When you want to go through file or network I/O by MessagePack-Csharp.
[YamlObject]         // < When you want to go through configuration files etc by VYaml.
public readonly struct CharacterSpawnCommand : ICommand
{
    public long Id { get; init; }
    public CharacterType Type { get; init; } 
    public Vector3 Position { get; init; }      	
}

In game development, the reason why the pub/sub model is used is because that any event will affect multiple sparse objects. See Concept, Technical Explanation section to more information.

Tip

Here we use the init-only property for simplicity. In your Unity project, you may need to add a definition of type System.Runtime.CompilerServices.IsExternalInitas a marker. However, this is not a requirement. You are welcome to define the datatype any way you like. Modern C# has additional syntax that makes it easy to define such data-oriented types. Using record or record struct is also a good option. In fact, even in Unity, you can use the new syntax by specifying langVersion:11 compiler option or by doing dll separation. It would be worth considering.

Next, define the class that will handle the commands.

using VitalRouter;

// Classes with the Routes attribute are the destinations of commands.
[Routes]
public partial class FooPresentor
{
    // This is called when a FooCommand is published.
    public void On(FooCommand cmd)
    {
        // Do something ...
    }

    // This is called when a BarCommand is published.
    public async UniTask On(BarCommand cmd)
    {
        // Do something for await ...
        // Here, by awaiting for async task, the next command will not be called until this task is completed.
    }
}

Types with the [Routes] attribute are analyzed at compile time and a method to subscribe to Command is automatically generated.

Methods that satisfy the following conditions are eligible.

  • public accesibility.
  • The argument must be an ICommand, or ICommand and CancellationToken.
  • The return value must be void, or UniTask.

For example, all of the following are eligible. Method names can be arbitrary.

public void On(FooCommand cmd) { /* .. */ }
public async UniTask HandleAsync(FooCommand cmd) { /* .. */ }
public async UniTask Recieve(FooCommand cmd, CancellationToken cancellation) { /* .. */ }

Note

There are no restrictions by Interface but it will generate source code that will be resolved at compile time, so you will be able to follow the code well enough.

Now, when and how does the routing defined here call? There are several ways to make it enable this.

MonoBehaviour based

In a naive Unity project, the easy way is to make MonoBehaviour into Routes.

[Routes] // < If routing as a MonoBehaviour
public class FooPresenter : MonoBehaviour
{
    void Start()
    {
        MapTo(Router.Default); // < Start command handling here 
    }
}
  • MapTo is an automatically generated instance method.
  • When the GameObject is Destroyed, the mapping is automatically removed.

If you publish the command as follows, FooPresenter will be invoked.

await Router.Default.PublishAsync(new FooCommand
{
    X = 111,
    Y = 222,
}

If you want to split routing, you may create a Router instance. As follows.

var anotherRouter = new Router();
MapTo(anotherRouter);
anotherRouter.PublishAsync(..)

DI based

If DI is used, plain C# classes can be used as routing targets.

using VContainer;
using VitalRouter;
using VitalRouter.VContainer;

// VContaner's configuration
public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.RegisterVitalRouter(routing =>
        {
            routing.Map<FooPresenter>(); // < Register routing plain class

            // Or, use MonoBehaviour instance with DI
            routing.MapComponent(instance);
            // Or, use MonoBehaviour in the scene
            routing.MapComponentInHierarchy<MyRoutesComponent>();
            // Or, use MonoBehaviour from prefab
            routing.MapComponentInNewPrefab(prefab);
        });			
    }
}

The instances mapped here are released with to dispose of the DI container.

In this case, publisher is also injectable.

class SomeMyComponent : MonoBehaviour
{
    [SerializeField]
    Button someUIBotton;

    ICommandPublisher publisher;

    [Inject]
    public void Construct(ICommandPublisher publisher)
    {
        this.publisher = publisher;
    }

    void Start()
    {
        someUIButton.onClick += ev =>
        {
            publisher.PublishAsync(new FooCommand { X = 1, Y = 2 }).Forget();
        }
    }
}

Just register your Component with the DI container. References are auto-wiring.

public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
+        builder.RegisterComponentInHierarchy<SomeMyComponent>(Lifetime.Singleton);
	
        builder.RegisterVitalRouter(routing =>
        {
            routing.Map<FooPresenter>();
        });			
    }
}

Note

This is a simple demonstration. If your codebase is huge, just have the View component notify its own events on the outside, rather than Publish directly. And maybe only the class responsible for the control flow should Publish.

Manual setup

You can also set up your own entrypoint without using MonoBehaviour or a DI container.

var presenter = new FooPresenter();
presenter.MapTo(Router.Default);

In this case, unmapping is required manually to discard the FooPresenter.

presenter.UnmapRoutes();

Or, handle subscription.

var subscription = presenter.MapTo(Router.Default);
// ...
subscription.Dispose();

Publish

ICommandPublisher has an awaitable publish method.

ICommandPublisher publisher = Router.Default;

await publisher.PublishAsync(command);
await publisher.PublishAsync(command, cancellationToken);

If you await PublishAsync, you will await until all Subscribers ([Routes] class etc.) have finished all processing.

await publisher.PublishAsync(command1);  // Wait until all subscribers have finished processing command1
await publisher.PublishAsync(command2);  // Wait until all subscribers have finished processing command2
// ...

Note that by default, when Publish is executed in parallel, Subscribers is also executed in parallel.

publisher.PublishAsync(command1).Forget(); // Start processing command1 immediately
publisher.PublishAsync(command2).Forget(); // Start processing command2 immediately
publisher.PublishAsync(command3).Forget(); // Start processing command3 immediately
// ...

If you want to treat the commands like a queue to be sequenced, see FIFO section for more information.

The following is same for the above.

publisher.Enqueue(command1);
publisher.Enqueue(command2);
publisher.Enqueue(command3);
// ...

Enqueue is an alias to PublishAsync(command).Forget().

Of course, if you do await, you can try/catch all subscriber/routes exceptions.

try
{
    await publisher.PublishAsync(cmd);
}
catch (Exception ex)
{
    // ...
}

Interceptors

Interceptors can intercede additional processing before or after the any published command has been passed and consumed to subscribers.

Create a interceptor

Arbitrary interceptors can be created by implementing ICommandInterceptor.

Example 1: Some kind of processing is interspersed before and after the command is consumed.

class Logging : ICommandInterceptor
{
    public async UniTask InvokeAsync<T>(  
        T command,  
        CancellationToken cancellation,  
        Func<T, CancellationToken, UniTask> next)  
        where T : ICommand  
    {  
        UnityEngine.Debug.Log($"Start {typeof(T)}");	
        // Execute subsequent routes.	
        await next(command, cancellation);		
        UnityEngine.Debug.Log($"End   {typeof(T)}");
    }		
}

Example 2: try/catch all subscribers exceptions.

class ExceptionHandling : ICommandInterceptor
{
    public async UniTask InvokeAsync<T>(  
        T command,  
        CancellationToken cancellation,  
        Func<T, CancellationToken, UniTask> next)  
        where T : ICommand  
    {  
        try
        {
            await next(command, cancellation);
        }
        catch (Exception ex)
        {
            // Error tracking you like			
            UnityEngine.Debug.Log($"oops! {ex.Message}");			
        }
    }		
}

Example 3: Filtering command.

class MyFilter : ICommandInterceptor
{
    public async UniTask InvokeAsync<T>(  
        T command,  
        CancellationToken cancellation,  
        Func<T, CancellationToken, UniTask> next)  
        where T : ICommand  
    {  
        if (command is FooCommand { X: > 100 } cmd) 
        {
            // Deny. Skip the rest of the subscribers.
            return;
        }
        // Allow.
        await next(command, cancellation);
    }		
}

Configure interceptors

There are three levels to enable interceptor

  1. Apply globally to the Router.
  2. Apply it to all methods in the [Routes] class.
  3. Apply only to specific methods in the [Routes] class.
// Apply to the router.
Router.Default
    .Filter(new Logging())
    .Filter(new ErrorHandling);
// 1. Apply to the router with VContaienr.
builder.RegisterVitalRouter(routing => 
{
    routing.Filters.Add<Logging>();
    routing.Filters.Add<ErrorHandling>();
});
// 2. Apply to the type
[Routes]
[Filter(typeof(Logging))]
public partial class FooPresenter
{
    // 3. Apply to the method
    [Filter(typeof(ExtraInterceptor))]
    public void On(CommandA cmd)
    {
        // ...
    }
}

All of these are executed in the order in which they are registered, from top to bottom.

If you take the way of 2 or 3, the Interceptor instance is resolved as follows.

  • If you are using DI, the DI container will resolve this automatically.
  • if you are not using DI, you will need to pass the instance in the MapTo call.
    • MapTo(Router.Default, new Logging(), new ErrorHandling());
    • // auto-generated
      public Subscription MapTo(ICommandSubscribable subscribable, Logging interceptor1, ErrorHandling interceptor2) { /* ... */ }

FIFO

If you want to treat the commands like a queue to be sequenced, do the following:

// Set FIFO constraint to the globally.
Router.Default.FirstInFirstOut();

// Create FIFO router.
var fifoRouter = new Router().FirstInFirstOut();

// for DI
builder.RegisterVitalRouter(routing => 
{
    routing.FirstInFirstOut();
});

In this case, the next command will not be delivered until all [Routes] classes and Interceptors have finished processing the Command.

In other words, per Router, command acts as a FIFO queue for the async task.

publisher.PublishAsync(command1).Forget(); // Start processing command1 immediately
publisher.PublishAsync(command2).Forget(); // Queue command2 behaind command1
publisher.PublishAsync(command3).Forget(); // Queue command3 behaind command2
// ...
publisher.Enqueue(command1); // Start processing command1 immediately
publisher.Enqueue(command2); // Queue command2 behaind command1
publisher.Enqueue(command3); // Queue command3 behaind command2
// ...

Fan-out

When FIFO mode, if you want to group the awaiting subscribers, you can use FanOutInterceptor

Router.Default.FirstInFirstOutOrdering();

var fanOut = new FanOutInterceptor();
var groupA = new Router();
var groupB = new Router();

fanOut.Add(groupA);
fanOut.Add(groupB);

Router.Default.Filter(fanOut);

// Map routes per group

presenter1.MapTo(groupA);
presenter2.MapTo(groupA);

presente3.MapTo(groupB);
presente4.MapTo(groupB);

For DI,

public class SampleLifetimeScope : LifetimeScope
{
    public override void Configure(IContainerBuilder builder)
    {                
        builder.RegisterVitalRouter(routing =>
        {
            routing
                .FirstInFirstOut()            
                .FanOut(groupA =>
                {
                    groupA.Map<Presenter1>();
                    groupA.Map<Presenter2>();
                })    
                .FanOut(groupB =>
                {
                    groupB.Map<Presenter3>();
                    groupB.Map<Presenter4>();
                })                
        });
    }
}

Now we have the structure shown in the following:

flowchart LR

Default(Router.Default)
GroupA(groupA)
GroupB(groupB)
P1(presenter1)
P2(presenter2)
P3(presenter3)
P4(presenter4)

Default -->|fire and forget| GroupA
Default -->|fire and forget| GroupB

subgraph awaitable 1
  GroupA --> P1
  GroupA --> P2
end

subgraph awaitable 2
  GroupB --> P3
  GroupB --> P4
end
Loading

DI scope

VContainer can create child scopes at any time during execution.

RegisterVitalRouter inherits the Router defined in the parent. For example,

public class ParentLifetimeScope : LifetimeScope  
{  
    protected override void Configure(IContainerBuilder builder)  
    {    
        builder.RegisterVitalRouter(routing =>  
        {  
            routing.Map<PresenterA>();  
        });
        
        builder.Register<ParentPublisher>(Lifetime.Singleton);
    }  
}
public class ChildLifetimeScope : LifetimeScope  
{  
    protected override void Configure(IContainerBuilder builder)  
    {    
        builder.RegisterVitalRouter(routing =>  
        {  
            routing.Map<PresenterB>();  
        });  
        
        builder.Register<MyChildPublisher>(Lifetime.Singleton);
    }  
}
  • When an instance in the parent scope publishes used ICommandPublisher, PresenterA and PresenterB receive it.
  • When an instance in the child scope publishes ICommandPublisher, also PresenterA and PresenterB receives.

If you want to create a dedicated Router for a child scope, do the following.

builder.RegisterVitalRouter(routing =>  
{
+    routing.Isolated = true;
    routing.Map<PresenterB>();  
});  

Command Pooling

If Command is struct, VitalRouter avoids boxing, so no heap allocation occurs. This is the reson of using sturct is recommended.

In some cases, however, you may want to use class. Typically, when Command is treated as a collection element, boxing is unavoidable.

So we support the ability to pooling commands when classes are used.

public class MyBoxedCommmand : IPoolableCommand
{
    public ResourceA ResourceA { ge; set; }

    void IPoolableCommand.OnReturnToPool()
    {
        ResourceA = null!;
    }
}

Rent from pool

// To publish, use CommandPool for instantiation.
var cmd = CommandPool<MyBoxedCommand>.Shared.Rent(() => new MyBoxedCommand());

// Lambda expressions are used to instantiate objects that are not in the pool. Any number of arguments can be passed from outside.
var cmd = CommandPool<MyBoxedCommand>.Shared.Rent(arg1 => new MyBoxedCommand(arg1), extraArg);
var cmd = CommandPool<MyBoxedCommand>.Shared.Rent((arg1, arg2) => new MyBoxedCommand(arg1, arg2), extraArg1, extraArg2);
// ...

// Configure value
cmd.ResourceA = resourceA;

// Use it
publisher.PublishAsync(cmd);

Return to pool

// It is convenient to use the `CommandPooling` Interceptor to return to pool automatically.
Router.Default.Filter(CommandPooling.Instance);


// Or, return to pool manually.
CommandPool<MyBoxedCommand>.Shard.Return(cmd);

Sequence Command

If your command implements IEnumerable<ICommand>, it represents a sequence of time series.

var sequenceCommand = new SequenceCommand
{
    new CommandA(), 
    new CommandB(), 
    new CommandC(),
    // ...
}

Low-level API

ICommandSubscribale router = Router.default;

// Subscribe handler via lambda expression
router.Subscribe<FooCommand>(cmd => { /* ... */ });

// Subscribe async handler via lambda expression
router.Subscribe<FooCommand>(async cmd => { /* ... */ });

// Subscribe handler
router.Subscribe(Subscriber);

class Subscriber : ICommandSubscriber
{
    pubilc void Receive<T>(T cmd) where T : ICommand { /* ... */ }
}

// Subscribe async handler
router.Subscribe(AsyncSubscriber);

class AsyncSubscriber : IAsyncCommandSubscriber
{
    pubilc UniTask Receive<T>(T cmd, CancellationToken cancellation) where T : ICommand { /* ... */ }
}

// Add interceptor via lambda expresion
router.Filter<FooCommand>(async (cmd, cancellationToken, next) => { /* ... */ });

Concept, Technical Explanation

Unidirectional control flow

Unity is a very fun game engine that is easy to work with, but handling communication between multiple GameObjects is a difficult design challenge.

In the game world, there are so many objects working in concert: UI, game system, effects, sounds, and various actors on the screen.

It is common for an event fired by one object to affect many objects in the game world. If we try to implement this in a naive OOP way, we will end up with complex... very complex N:N relationships.

More to the point, individual objects in the game are created and destroyed at a dizzying rate during execution, so this N:N would tend to be even more complex!

There is one problem. There is no distinction between "the one giving the orders" and "the one being given the orders." In the simplicity of Unity programming, it is easy to mix up the object giving the orders and the object being given the orders. This is one of the reasons why game design is so difficult.

When the relationship is N:N, bidirectional binding is almost powerless. This is because it is very fat for an object to resolve references to all related objects. Moreover, they all repeat their creation.

Most modern GUI application frameworks recommend an overall unidirectional control flow rather than bidirectional binding.

sequence

Games are more difficult to generalize than GUIs. However, it is still important to organize the "control flow".

Distinguish between publishable and encapsulated states

A major concern in game development is creating a Visualize Component that is unique to that game.

The Component we create has very detailed state transitions. It will move every frame. Maybe. It will have complex parent-child relationships. Maybe.

But we should separate this very detailed state management from the state that is brought to the entire game system and to which many objects react.

It is the latter fact that should be "publich".

Each View Component should hide its own state inside.

So how should granular components expose their own events to the outside world? Be aware of ownership. An object with ownership of a fine-grained object communicates further outside of it.

The "Controller" in MVC is essentially not controlled by anyone. It is the entry point of the system. VitalRouter does not require someone to own the Controller, only to declare it in an attribute. So it encourages this kind of design.

Data-oriented

An important advantage of giving a type for each type of event is that it is serializable.

For example,

  • If you store your commands in order, you can implement game replays later by simply re-publishing them in chronological order.
  • Commands across the network, your scenario data, editor data, whatever is datamined, are the same input data source.

A further data-oriented design advantage is the separation of "data" from "functionality.

The life of data is dynamic. Data is created/destroyed when certain conditions are met in a game. On the other hand, "functionality" has a static lifetime. This is a powerful tool for large code bases.

LISENCE

MIT

AUTHOR

@hadashiA