Pas un seul monolithe. Approche modulaire dans Unity

image


Cet article examinera une approche modulaire de la conception et de la mise en œuvre ultérieure d'un jeu sur le moteur Unity. Les principaux avantages, inconvénients et problèmes auxquels vous avez dû faire face sont décrits.



Le terme «approche modulaire» désigne une organisation logicielle qui utilise en interne des assemblages finaux indépendants, enfichables, qui peuvent être développés en parallèle, modifiés à la volée et obtenir un comportement logiciel différent en fonction de la configuration.



Structure du module



Il est important de déterminer d'abord ce qu'est le module, quelle structure il a, quelles parties du système sont responsables de quoi et comment elles doivent être utilisées.



Le module est un assemblage relativement indépendant qui ne dépend pas du projet. Il peut être utilisé dans des projets complètement différents avec une configuration appropriée et la présence d'un noyau commun dans le projet. Les conditions obligatoires pour la mise en œuvre du module sont la présence d'une trace. les pièces:



Assemblage d'infrastructure


Cet assembly contient des modèles et des contrats qui peuvent être utilisés par d'autres assemblys. Il est important de comprendre que cette partie du module ne doit pas avoir de liens vers l'implémentation de fonctionnalités spécifiques. Idéalement, le cadre ne peut référencer que le cœur du projet.

La structure de l'assemblage ressemble à ce qui suit. façon:



image


  • Entités - entités utilisées à l'intérieur du module.
  • Messagerie - modèles de demande / signal. Vous pourrez en savoir plus plus tard.
  • Contracts est un lieu de stockage des interfaces.


Il est important de se rappeler qu'il est recommandé de minimiser l'utilisation de références entre les assemblys d'infrastructure.



Construire avec des fonctionnalités


Implémentation spécifique de la fonctionnalité. Il peut utiliser en lui-même n'importe lequel des modèles architecturaux, mais avec l'amendement que le système doit être modulaire.

L'architecture interne peut ressembler à ceci:



image


  • Entités - entités utilisées à l'intérieur du module.
  • Installateurs - classes d'enregistrement de contrats pour DI.
  • Les services constituent la couche métier.
  • Gestionnaires - la tâche du gestionnaire est d'extraire les données nécessaires des services, de créer un ViewEntity et de renvoyer le ViewManager.
  • ViewManagers - Reçoit une ViewEntity du Manager, crée les vues requises, transmet les données requises.
  • View - Affiche les données qui ont été transmises à partir de ViewManager.


Mettre en œuvre une approche modulaire



Pour mettre en œuvre cette approche, au moins deux mécanismes peuvent être nécessaires. Nous avons besoin d'une approche de division du code en assemblys et en un framework DI. Cet exemple utilise les fichiers de définitions d'assembly et les mécanismes Zenject.



L'utilisation des mécanismes spécifiques ci-dessus est facultative. L'essentiel est de comprendre à quoi ils servent. Vous pouvez remplacer Zenject par n'importe quel framework DI avec un conteneur IoC ou autre chose, et des fichiers de définitions d'assemblage - avec tout autre système qui vous permet de combiner du code en assemblages ou simplement de le rendre indépendant (par exemple, vous pouvez utiliser différents référentiels pour différents modules qui peuvent être connectés en tant que pekages, sous-modules gita ou autre).



Une caractéristique de l'approche modulaire est qu'il n'y a pas de références explicites de l'assemblage d'une fonction à une autre, à l'exception des références aux assemblys d'infrastructure dans lesquels les modèles peuvent être stockés. L'interaction entre les modules est implémentée à l'aide d'un wrapper sur les signaux du framework Zenject. Le wrapper vous permet d'envoyer des signaux et des requêtes à différents modules. Il est à noter qu'un signal signifie toute notification par le module actuel d'autres modules, et une requête signifie une requête pour un autre module qui peut renvoyer des données.



Signaux


Signal - un mécanisme pour informer le système de certains changements. Et le moyen le plus simple de les démonter est la pratique.



Disons que nous avons 2 modules. Foo et Foo2. Le module Foo2 devrait répondre à certains changements dans le module Foo. Pour s'affranchir de la dépendance des modules, 2 signaux sont implémentés. Un signal à l'intérieur du module Foo, qui informera le système du changement d'état, et le deuxième signal - à l'intérieur du module Foo2. Le module Foo2 réagira à ce signal. Le routage du signal OnFooSignal dans OnFoo2Signal se fera dans le module de routage.

Schématiquement, cela ressemblera à ceci:



image




Demandes


Les requêtes permettent de résoudre des problèmes de communication de données reçues / émises par un module depuis un autre (autres).



Prenons un exemple similaire qui a été donné ci-dessus pour les signaux.

Disons que nous avons 2 modules. Foo et Foo2. Le module Foo a besoin de certaines données du module Foo2. Dans le même temps, le module Foo ne doit rien savoir du module Foo2. En fait, ce problème pourrait être résolu à l'aide de signaux supplémentaires, mais la solution avec les requêtes semble plus simple et plus belle.



Cela ressemblera schématiquement à ceci:



image


Communication entre modules



Afin de minimiser les liens entre les modules avec des fonctionnalités (y compris les liens Infrastructure-Infrastructure), il a été décidé d'écrire un wrapper sur les signaux fournis par le framework Zenject et de créer un module dont la tâche serait d'acheminer différents signaux et de cartographier les données.



PS En fait, ce module a des liens vers tous les assemblages d'infrastructure qui ne sont pas bons. Mais ce problème peut être résolu via l'IoC.



Exemple d'interaction de module



Disons qu'il y a deux modules. LoginModule et RewardModule. Le RewardModule devrait donner une récompense à l'utilisateur après la connexion FB.



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);
            });


Dans l'exemple ci-dessus, il n'y a pas de liens directs entre les modules. Mais ils sont liés via le MessagingModule. Il est très important de se rappeler qu'il ne devrait y avoir rien d'autre dans le routage que le routage et le mappage de signaux / demandes.



Substitution d'implémentations



En utilisant une approche modulaire et le modèle de bascule des fonctionnalités, vous pouvez obtenir des résultats étonnants en termes d'impact sur votre application. Ayant une certaine configuration sur le serveur, vous pouvez manipuler l'activation / la désactivation de différents modules au démarrage de l'application, en les modifiant pendant le jeu.



Ceci est réalisé en vérifiant les indicateurs de disponibilité du module lors de la liaison des modules dans Zenject (en fait, dans un conteneur), et sur cette base, le module est lié ou non à un conteneur. Afin d'obtenir un changement de comportement pendant une session de jeu (disons que vous devez changer les mécanismes pendant une session de jeu. Il existe un module Solitaire et un module Klondike. Et pour 50% des utilisateurs, le module foulard devrait fonctionner) un mécanisme a été développé qui, lors du passage d'une scène à une autre nettoyé un conteneur de module spécifique et lier de nouvelles dépendances.



Il a travaillé sur la piste. principe: si la fonctionnalité était activée, puis pendant la session désactivée, il serait nécessaire de vider le conteneur. Si la fonctionnalité a été activée, vous devez apporter toutes les modifications au conteneur. Il est important de le faire sur une scène "vide" afin de ne pas violer l'intégrité des données et des connexions. Il était possible d'implémenter ce comportement, mais en tant que fonctionnalité de production, il n'est pas recommandé d'utiliser une telle fonctionnalité, car elle comporte un plus grand risque de casser quelque chose.



Vous trouverez ci-dessous le pseudo-code de la classe de base, dont les descendants doivent enregistrer quelque chose dans le conteneur.



    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);
        }
    }


Un exemple de module primitif



Regardons un exemple simple de la façon dont un module peut être implémenté.



Disons que vous devez implémenter un module qui restreindra le mouvement de la caméra afin que l'utilisateur ne puisse pas l'emmener au-delà de la «bordure» de l'écran.



Le module contiendra un ensemble d'infrastructure avec un signal qui informera le système que la caméra a essayé de sortir de l'écran.



Fonctionnalité - implémentation de fonctionnalités. Ce sera la logique pour vérifier si la caméra est hors de portée, notifier les autres modules à ce sujet, etc.



image


  • BorderConfig est une entité qui décrit les limites de l'écran.
  • BorderViewEntity est une entité à transmettre au ViewManager et à la vue.
  • BoundingBoxManager - obtient BorderConfig du serveur, crée BorderViewEntity.
  • BoundingBoxViewManager — MonoBehaviour'a. , .
  • BoundingBoxView — , «» .




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



All Articles