Kein einziger Monolith. Modularer Ansatz in Unity

Bild


In diesem Artikel wird ein modularer Ansatz für das Design und die weitere Implementierung eines Spiels auf der Unity-Engine betrachtet. Die wichtigsten Vor- und Nachteile sowie Probleme, mit denen Sie konfrontiert waren, werden beschrieben.



Der Begriff "modularer Ansatz" bezeichnet eine Softwareorganisation, die intern unabhängige, steckbare Endbaugruppen verwendet, die parallel entwickelt, im laufenden Betrieb geändert und je nach Konfiguration ein unterschiedliches Softwareverhalten erzielt werden können.



Modulstruktur



Es ist wichtig, zunächst festzustellen, was das Modul ist, welche Struktur es hat, welche Teile des Systems für was verantwortlich sind und wie sie verwendet werden sollen.



Das Modul ist eine relativ unabhängige Baugruppe, die nicht vom Projekt abhängt. Es kann in völlig unterschiedlichen Projekten mit der richtigen Konfiguration und dem Vorhandensein eines gemeinsamen Kerns im Projekt verwendet werden. Obligatorische Bedingungen für die Implementierung des Moduls sind das Vorhandensein einer Ablaufverfolgung. Teile:



Montage der Infrastruktur


Diese Baugruppe enthält Modelle und Verträge, die von anderen Baugruppen verwendet werden können. Es ist wichtig zu verstehen, dass dieser Teil des Moduls keine Links zur Implementierung bestimmter Funktionen enthalten darf. Im Idealfall kann das Framework nur auf den Kern des Projekts verweisen.

Die Baugruppenstruktur sieht wie folgt aus. Weg:



Bild


  • Entitäten - Entitäten, die innerhalb des Moduls verwendet werden.
  • Messaging - Anforderungs- / Signalmodelle. Sie können später darüber lesen.
  • Verträge sind ein Ort zum Speichern von Schnittstellen.


Es ist wichtig zu beachten, dass empfohlen wird, die Verwendung von Verknüpfungen zwischen Infrastrukturbaugruppen zu minimieren.



Erstellen Sie mit Funktionen


Spezifische Implementierung der Funktion. Es kann jedes der Architekturmuster in sich selbst verwenden, jedoch mit der Änderung, dass das System modular sein muss.

Die interne Architektur kann folgendermaßen aussehen:



Bild


  • Entitäten - Entitäten, die innerhalb des Moduls verwendet werden.
  • Installateure - Klassen zur Registrierung von Verträgen für DI.
  • Services ist die Business-Schicht.
  • Manager - Die Aufgabe des Managers besteht darin, die erforderlichen Daten aus den Diensten abzurufen, eine ViewEntity zu erstellen und den ViewManager zurückzugeben.
  • ViewManagers - Empfängt eine ViewEntity vom Manager, erstellt die erforderlichen Ansichten und leitet die erforderlichen Daten weiter.
  • Ansicht - Zeigt die Daten an, die vom ViewManager übergeben wurden.


Implementierung eines modularen Ansatzes



Um diesen Ansatz zu implementieren, können mindestens zwei Mechanismen erforderlich sein. Wir brauchen einen Ansatz zur Aufteilung des Codes in Assemblys und ein DI-Framework. In diesem Beispiel werden die Mechanismen Assembly Definitions Files und Zenject verwendet.



Die Verwendung der oben genannten spezifischen Mechanismen ist optional. Die Hauptsache ist zu verstehen, wofür sie verwendet wurden. Sie können Zenject durch ein beliebiges DI-Framework mit einem IoC-Container oder etwas anderem und Assembly Definitions Files ersetzen - mit jedem anderen System, mit dem Sie Code zu Assemblys kombinieren oder einfach unabhängig machen können (Sie können beispielsweise verschiedene Repositorys für verschiedene Module verwenden, die als Pekages oder Submodule verbunden werden können Gita oder etwas anderes).



Ein Merkmal des modularen Ansatzes besteht darin, dass es keine expliziten Verweise von der Baugruppe eines Merkmals auf ein anderes gibt, mit Ausnahme von Verweisen auf Infrastrukturbaugruppen, in denen Modelle gespeichert werden können. Die Interaktion zwischen den Modulen wird mithilfe eines Wrappers über Signale aus dem Zenject-Framework implementiert. Mit dem Wrapper können Sie Signale und Anforderungen an verschiedene Module senden. Es ist zu beachten, dass ein Signal jede Benachrichtigung anderer Module durch das aktuelle Modul bedeutet und eine Anforderung eine Anforderung für ein anderes Modul bedeutet, das Daten zurückgeben kann.



Signale


Signal - Ein Mechanismus, um das System über einige Änderungen zu informieren. Und der einfachste Weg, sie zu zerlegen, ist in der Praxis.



Nehmen wir an, wir haben 2 Module. Foo und Foo2. Das Foo2-Modul sollte auf Änderungen im Foo-Modul reagieren. Um die Abhängigkeit der Module zu beseitigen, werden 2 Signale implementiert. Ein Signal im Foo-Modul, das das System über die Statusänderung informiert, und das zweite Signal im Foo2-Modul. Das Foo2-Modul reagiert auf dieses Signal. Das Routing des OnFooSignal-Signals in OnFoo2Signal erfolgt im Routing-Modul.

Schematisch sieht es so aus:



Bild




Anfragen


Abfragen ermöglichen die Lösung von Kommunikationsproblemen beim Empfangen / Senden von Daten durch ein Modul von einem anderen (anderen).



Betrachten wir ein ähnliches Beispiel, das oben für Signale angegeben wurde.

Nehmen wir an, wir haben 2 Module. Foo und Foo2. Das Foo-Modul benötigt einige Daten vom Foo2-Modul. In diesem Fall sollte das Foo-Modul nichts über das Foo2-Modul wissen. Tatsächlich könnte dieses Problem mit zusätzlichen Signalen gelöst werden, aber die Lösung mit Abfragen sieht einfacher und schöner aus.



Es wird schematisch so aussehen:



Bild


Kommunikation zwischen Modulen



Um die Verknüpfungen zwischen Modulen mit Funktionen (einschließlich Verknüpfungen Infrastruktur-Infrastruktur) zu minimieren, wurde beschlossen, einen Wrapper über die vom Zenject-Framework bereitgestellten Signale zu schreiben und ein Modul zu erstellen, dessen Aufgabe darin besteht, verschiedene Signale und Kartendaten zu routen.



PS Tatsächlich enthält dieses Modul Links zu allen Infrastruktur-Assemblys, die nicht gut sind. Dieses Problem kann jedoch über das IoC gelöst werden.



Beispiel für die Modulinteraktion



Angenommen, es gibt zwei Module. LoginModule und RewardModule. Das RewardModule sollte dem Benutzer nach dem FB-Login eine Belohnung geben.



namespace RewardModule.src.Infrastructure.Messaging.Signals
{
    public class OnLoginSignal : SignalBase
    {
        public bool IsFirstLogin { get; set; }
    }
}


namespace RewardModule.src.Infrastructure.Messaging.RequestResponse.Produce
{
    public class GainRewardRequest : EventBusRequest<ProduceResponse>
    {
        public bool IsFirstLogin { get; set; }
    }
}


namespace MessagingModule.src.Feature.Proxy
{
    public class LoginModuleProxy
    {
        [Inject]
        private IEventBus eventBus;
        
        public override async void Subscribe()
        {            
            eventBus.Subscribe<OnLoginSignal>((loginSignal) =>
            {
                var request = new GainRewardRequest()
                {
                    IsFirstLogin = loginSignal.IsFirstLogin;
                }

                var result = await eventBus.FireRequestAsync<GainRewardRequest, GainRewardResponse>(request);
                var analyticsEvent = new OnAnalyticsShouldBeTracked()
                {
                   AnalyticsPayload = new Dictionary<string, string>
                    {
                      {
                        "IsFirstLogin", "false"
                      },
                    },
                  };
                eventBus.Fire<OnAnalyticsShouldBeTrackedSignal>(analyticsEvent);
            });


Im obigen Beispiel gibt es keine direkten Verbindungen zwischen Modulen. Sie sind jedoch über das MessagingModule verbunden. Es ist sehr wichtig, sich daran zu erinnern, dass das Routing nur Signal- / Anforderungs-Routing und Mapping enthalten sollte.



Substitution von Implementierungen



Mit einem modularen Ansatz und dem Feature-Toggle-Muster können Sie erstaunliche Ergebnisse hinsichtlich der Auswirkungen auf Ihre Anwendung erzielen. Mit einer bestimmten Konfiguration auf dem Server können Sie das Aktivieren / Deaktivieren verschiedener Module zu Beginn der Anwendung manipulieren und während des Spiels ändern.



Dies wird erreicht, indem die Modulverfügbarkeitsflags während des Bindens von Modulen in Zenject (tatsächlich in einen Container) überprüft werden. Auf dieser Grundlage wird das Modul entweder in einen Container gebunden oder nicht. Um eine Verhaltensänderung während einer Spielsitzung zu erreichen (sagen wir, Sie müssen die Mechanik während einer Spielsitzung ändern. Es gibt ein Solitaire-Modul und ein Klondike-Modul. Und für 50 Prozent der Benutzer sollte das Kopftuchmodul funktionieren), wurde ein Mechanismus entwickelt, der beim Wechsel von einer Szene zur anderen funktioniert Bereinigte einen bestimmten Modulcontainer und band neue Abhängigkeiten.



Er arbeitete an der Spur. Prinzip: Wenn eine Funktion aktiviert und dann während der Sitzung deaktiviert wurde, muss der Container geleert werden. Wenn die Funktion aktiviert war, müssen Sie alle Änderungen am Container vornehmen. Es ist wichtig, dies auf einer "leeren" Bühne zu tun, um die Integrität von Daten und Verbindungen nicht zu verletzen. Es war möglich, dieses Verhalten zu implementieren, aber als Produktionsmerkmal wird nicht empfohlen, solche Funktionen zu verwenden, da dies ein höheres Risiko birgt, etwas zu beschädigen.



Unten ist der Pseudocode der Basisklasse, deren Nachkommen erforderlich sind, um etwas im Container zu registrieren.



    public abstract class GlobalInstallerBase<TGlobalInstaller, TModuleInstaller> : MonoInstaller<TGlobalInstaller>
        where TGlobalInstaller : MonoInstaller<TGlobalInstaller>
        where TModuleInstaller : Installer
    {
        protected abstract string SubContainerName { get; }
        
        protected abstract bool IsFeatureEnabled { get; }
        
        public override void InstallBindings()
        {
            if (!IsFeatureEnabled)
            {
                return;
            }
            
            var subcontainer = Container.CreateSubContainer();
            subcontainer.Install<TModuleInstaller>();
            
            Container.Bind<DiContainer>()
                .WithId(SubContainerName)
                .FromInstance(subcontainer)
                .AsCached();
        }
        
        protected virtual void SubContainerCleaner(DiContainer subContainer)
        {
            subContainer.UnbindAll();
        }

        protected virtual DiContainer SubContainerInstanceGetter(InjectContext containerContext)
        {
            return containerContext.Container.ResolveId<DiContainer>(SubContainerName);
        }
    }


Ein Beispiel für ein primitives Modul



Schauen wir uns ein einfaches Beispiel an, wie ein Modul implementiert werden kann.



Angenommen, Sie müssen ein Modul implementieren, das die Bewegung der Kamera einschränkt, damit der Benutzer sie nicht über den "Rand" des Bildschirms hinausführen kann.



Das Modul enthält eine Infrastrukturbaugruppe mit einem Signal, das das System darüber informiert, dass die Kamera versucht hat, vom Bildschirm zu verschwinden.



Feature - Feature-Implementierung. Dies ist die Logik, um zu überprüfen, ob sich die Kamera außerhalb der Reichweite befindet, um andere Module darüber zu informieren usw.



Bild


  • BorderConfig ist eine Entität, die die Grenzen des Bildschirms beschreibt.
  • BorderViewEntity ist eine Entität, die an ViewManager und View übergeben werden soll.
  • BoundingBoxManager - Ruft BorderConfig vom Server ab und erstellt BorderViewEntity.
  • BoundingBoxViewManager — MonoBehaviour'a. , .
  • BoundingBoxView — , «» .




  • . , , .
  • .
  • EventHell, , .
  • — , . , , — .
  • .
  • .
  • - , . , MVC, — ECS.
  • , .
  • , .



All Articles