Skip to content

programmersdigest/Metamorphosis

Repository files navigation

Build status

Metamorphosis

Contributors

  • Awesome-Source
  • programmersdigest

Overview

Metamorphosis is a configuration based application framework. The building blocks of the configuration are components. Components define signals (senders) and triggers (receivers). Compatible (by signature) signals and triggers can be connected via configuration to build a complex tree of interacting components - a complete application.

The concept of triggers and signals

The concept of triggers and signals is based on the typical usecase of methods: Say component A needs to push some data to component B. In a "normal" program component A would retrieve an instance of component B and execute a method on this instance of B.

The downside of this approach is that it cannot be configured at runtime but has to be hard-coded. To get around this limitation, interfaces can abstract away the actual implementation of component B. However, component A would need to use a specific (hard-coded) interface which all possible component _B_s would have to implement. Again we run into limitations with possible configurations.

To get around this limitation, in Metamorphosis component A defines a signal, component B defines a trigger. If signal and trigger are compatible, a connection can be created via configuration. Whenever component A needs to push data to component B, it executes its signal which executes the trigger on component B.

Of course, a trigger may return data to the singaling component, just as a method call would.

Signals can be mandatory or optional. If a signal is declared as an abstract method, it is considered mandatory. The initialization will throw an exception if the configuration does not define a valid connection to a mandatory signal. If a signal is declared as an implemented method, it is considered optional. If the configuration does not contain a connection, the existing implementation is executed. If a connection is configured, it overwrites the default implementation.

More akin to events, a signal may also be used to trigger multiple components at the same time. It is then, however, not possible to return any data to the signal.

A component implementing a signal is called a sender. A component implementing a trigger is called a receiver.

A technical view on triggers and signals

A signal is a method on a component. A trigger is a method with a compatible method signature on another component.

During initialization of a sender, a proxy is created which implements all abstract signal methods and optionally overrides all virtual signal methods. If a connection is configured, a reference to the receiver is stored in a hidden field in the sender. The generated implementation of the signal method calls the trigger method on the receiver reference. Overhead is therefore minimized to an additional method call.

If multiple triggers are connected to a single signal, each trigger on each receiver is executed one after the other. Note that the order of execution is undefined!

Since senders require references to receivers (as is the case with a similarly setup method call), connections define a dependency graph. This does mean that connections must not create circular references. The dependency graph must always be a tree. The order of components in the configuration file does not matter. The initialization sequence initializes all components with respect to their dependencies.

Implementing components

A sender with a mandatory signal looks like follows:

[Component]
public abstract class MathSender
{
    [Signal]
    protected abstract int Add(int a, int b);
}
  • A component must be decorated with the ComponentAttribute.
  • A component must be a public abstract class.
  • A signal must be decorated with the SignalAttribute.
  • A mandatory signal must be a protected abstract method.

The same component using an optional signal looks like follows:

[Component]
public abstract class MathSender
{
    [Signal]
    protected virtual int Add(int a, int b)
    {
        return a + b;
    }
}
  • An optional signal must be a protected virtual method.

A matching receiver looks like follows:

[Component]
public abstract class MathReceiver
{
    [Trigger]
    public int Add(int a, int b)
    {
        return a + b;
    }
}
  • A trigger must be decorated with the TriggerAttribute.
  • A trigger must be a public method.
  • A trigger must not be abstract.

It is possible to use generic methods (including constraints) for signals and triggers:

[Component]
public abstract class CloneSender
{
    [Signal]
    protected abstract T Clone<T>(T original) where T : IClonable;
}

[Component]
public abstract class CloneReceiver
{
    [Trigger]
    public T Clone<T>(T original) where T : IClonable
    {
        return original.Clone();
    }
}
  • The signature must be identical, this includes generic parameters and constraints.
  • Generic type parameters on components are not supported.

Components may implement IDisposable to perform cleanup before application shutdown. During shutdown, all components which implement IDisposable get disposed with respect to their dependency tree. This means that dependent components will be disposed before their dependencies.

Configuration

The configuration is a simple JSON file. It defines which component instances need to be created and how they are connected.

A typical configuration might look as follows:

{
  "Components": [
    {
      "Name": "ConsoleLogger",
      "Type": "Metamorphosis.Logging.ConsoleLogger"
    },
    {
      "Name": "TestComponent",
      "Type": "Metamorphosis.Playground.TestComponent"
    }
  ],
  "Connections": [
    {
      "Signal": "TestComponent.Log",
      "Trigger": "ConsoleLogger.Log"
    }
  ],
  "Assemblies": [
    "Metamorphosis.Logging"
  ]
}
  • Each component instance must have a unique name.
  • Each component instance has must have a type, which is defined by the FullName of the type object (including namespace, excluding assembly name).
  • Each connection requires a signal and a trigger.
  • Signals are defined by joining the name of the sender and the signal using a "." (dot): "sender.signal".
  • Likewise, triggers are defined by joining the name of the receiver and the trigger: "receiver.trigger".
  • All assemblies containing the configured components need to be provided using their file name without extension.

Running the app

All "magic" is encapsulated in the class App.

class Program
{
    static void Main(string[] args)
    {
        var app = new App();
        app.Start("Model.json");
    }
}

Special components

Metamorphosis.Lifecycle

The lifecycle component provides signals regarding application startup and shutdown.

void Startup()

The startup signal is raised after the initialization is complete and before the main thread goes into sleep mode to wait for application shutdown. Components may now do their own initializations and/or start actions which need to run for the whole length of the application lifetime (timers, listeners, ...).

A components startup method must be non-blocking. Any long running actions require creation of a separate thread.

void Shutdown()

The shutdown signal is raised just before the components get disposed. A component may now stop long running actions and perform cleanup. Do note that the order in which components receive the shutdown trigger is undefined.

About

Prototype of a configuration based application framework.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages