Séparation d'un projet d'utilitaire «liasse de trucs» en composants individuels avec des dépendances «facultatives»

26

Au cours des années d'utilisation de C # /. NET pour un tas de projets internes, nous avons vu une bibliothèque se développer organiquement en une énorme liasse de choses. Cela s'appelle "Util", et je suis sûr que beaucoup d'entre vous ont vu une de ces bêtes dans votre carrière.

De nombreuses parties de cette bibliothèque sont très autonomes et pourraient être divisées en projets distincts (que nous aimerions ouvrir). Mais il y a un problème majeur qui doit être résolu avant de pouvoir les publier sous forme de bibliothèques distinctes. Fondamentalement, il y a beaucoup, beaucoup de cas de ce que je pourrais appeler des "dépendances facultatives" entre ces bibliothèques.

Pour mieux expliquer cela, considérez certains des modules qui sont de bons candidats pour devenir des bibliothèques autonomes. CommandLineParsersert à analyser les lignes de commande. XmlClassifysert à sérialiser des classes en XML. PostBuildCheckeffectue des vérifications sur l'assembly compilé et signale une erreur de compilation en cas d'échec. ConsoleColoredStringest une bibliothèque de littéraux de chaîne colorés. Lingosert à traduire les interfaces utilisateur.

Chacune de ces bibliothèques peut être utilisée de manière totalement autonome, mais si elles sont utilisées ensemble, il existe des fonctionnalités supplémentaires utiles. Par exemple, les deux CommandLineParseret XmlClassifyexposent la fonctionnalité de vérification post-génération, ce qui nécessite PostBuildCheck. De même, la CommandLineParserdocumentation de l'option permet d'être fournie à l'aide des littéraux de chaîne de couleur, exigeant ConsoleColoredString, et il prend en charge la documentation traduisible via Lingo.

La principale distinction est donc qu'il s'agit de fonctionnalités facultatives . On peut utiliser un analyseur de ligne de commande avec des chaînes simples et non colorées, sans traduire la documentation ni effectuer de vérifications après la construction. Ou on pourrait rendre la documentation traduisible mais toujours incolore. Ou à la fois coloré et traduisible. Etc.

En parcourant cette bibliothèque "Util", je constate que presque toutes les bibliothèques potentiellement séparables ont des fonctionnalités optionnelles qui les lient à d'autres bibliothèques. Si je devais réellement avoir besoin de ces bibliothèques en tant que dépendances, cette liasse de choses n'est pas vraiment démêlée du tout: vous auriez toujours besoin de toutes les bibliothèques si vous voulez en utiliser une seule.

Existe-t-il des approches établies pour gérer ces dépendances facultatives dans .NET?

Roman Starkov
la source
2
Même si les bibliothèques dépendent les unes des autres, il pourrait être avantageux de les séparer en bibliothèques cohérentes mais distinctes, chacune contenant une large catégorie de fonctionnalités.
Robert Harvey

Réponses:

20

Refactorise lentement.

Attendez-vous à ce que cela prenne un certain temps et peut se produire sur plusieurs itérations avant de pouvoir supprimer complètement votre ensemble Utils .

Approche générale:

  1. Prenez d'abord un peu de temps et réfléchissez à la façon dont vous souhaitez que ces assemblages utilitaires se présentent lorsque vous avez terminé. Ne vous inquiétez pas trop de votre code existant, pensez à l'objectif final. Par exemple, vous souhaiterez peut-être:

    • MyCompany.Utilities.Core (contenant des algorithmes, la journalisation, etc.)
    • MyCompany.Utilities.UI (code de dessin, etc.)
    • MyCompany.Utilities.UI.WinForms (code lié à System.Windows.Forms, contrôles personnalisés, etc.)
    • MyCompany.Utilities.UI.WPF (code lié à WPF, classes de base MVVM).
    • MyCompany.Utilities.Serialization (code de sérialisation).
  2. Créez des projets vides pour chacun de ces projets, et créez des références de projet appropriées (UI référence Core, UI.WinForms référence UI), etc.

  3. Déplacez l'un des fruits bas (classes ou méthodes qui ne souffrent pas des problèmes de dépendance) de votre assembly Utils vers les nouveaux assemblys cibles.

  4. Obtenez une copie de NDepend et du refactoring de Martin Fowler pour commencer à analyser votre assemblage Utils pour commencer à travailler sur les plus difficiles. Deux techniques qui vous seront utiles:

Gestion des interfaces facultatives

Soit un assembly fait référence à un autre assembly, soit il ne le fait pas. La seule autre façon d'utiliser des fonctionnalités dans un assembly non lié consiste à utiliser une interface chargée via la réflexion d'une classe commune. L'inconvénient est que votre assemblage principal devra contenir des interfaces pour toutes les fonctionnalités partagées, mais l'inconvénient est que vous pouvez déployer vos utilitaires selon vos besoins sans la "liasse" de fichiers DLL en fonction de chaque scénario de déploiement. Voici comment je gérerais ce cas, en utilisant la chaîne colorée comme exemple:

  1. Tout d'abord, définissez les interfaces communes dans votre assemblage principal:

    entrez la description de l'image ici

    Par exemple, l' IStringColorerinterface ressemblerait à:

     namespace MyCompany.Utilities.Core.OptionalInterfaces
     {
         public interface IStringColorer
         {
             string Decorate(string s);
         }
     }
    
  2. Ensuite, implémentez l'interface dans l'assemblage avec la fonction. Par exemple, la StringColorerclasse ressemblerait à:

    using MyCompany.Utilities.Core.OptionalInterfaces;
    namespace MyCompany.Utilities.Console
    {
        class StringColorer : IStringColorer
        {
            #region IStringColorer Members
    
            public string Decorate(string s)
            {
                return "*" + s + "*";   //TODO: implement coloring
            }
    
            #endregion
        }
    }
    
  3. Créez une PluginFinderclasse (ou peut-être InterfaceFinder est un meilleur nom dans ce cas) qui peut trouver des interfaces à partir de fichiers DLL dans le dossier en cours. Voici un exemple simpliste. Selon les conseils de @ EdWoodcock (et je suis d'accord), lorsque vos projets se développeront, je suggérerais d'utiliser l'un des cadres d'injection de dépendance disponibles ( Common Serivce Locator avec Unity et Spring.NET me viennent à l'esprit) pour une implémentation plus robuste avec des fonctionnalités avancées. qui disposent de capacités ", autrement connu comme le modèle de localisateur de service . Vous pouvez le modifier selon vos besoins.

    using System;
    using System.Linq;
    using System.IO;
    using System.Reflection;
    
    namespace UtilitiesCore
    {
        public static class PluginFinder
        {
            private static bool _loadedAssemblies;
    
            public static T FindInterface<T>() where T : class
            {
                if (!_loadedAssemblies)
                    LoadAssemblies();
    
                //TODO: improve the performance vastly by caching RuntimeTypeHandles
    
                foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
                {
                    foreach (Type type in assembly.GetTypes())
                    {
                        if (type.IsClass && typeof(T).IsAssignableFrom(type))
                            return Activator.CreateInstance(type) as T;
                    }
                }
    
                return null;
            }
    
            private static void LoadAssemblies()
            {
                foreach (FileInfo file in new DirectoryInfo(Directory.GetCurrentDirectory()).GetFiles())
                {
                    if (file.Extension != ".DLL")
                        continue;
    
                    if (!AppDomain.CurrentDomain.GetAssemblies().Any(a => a.Location == file.FullName))
                    {
                        try
                        {
                            //TODO: perhaps filter by certain known names
                            Assembly.LoadFrom(file.FullName);
                        }
                        catch { }
                    }
                }
            }
        }
    }
    
  4. Enfin, utilisez ces interfaces dans vos autres assemblys en appelant la méthode FindInterface. Voici un exemple de CommandLineParser:

    static class CommandLineParser
    {
        public static string ParseCommandLine(string commandLine)
        {
            string parsedCommandLine = ParseInternal(commandLine);
    
            IStringColorer colorer = PluginFinder.FindInterface<IStringColorer>();
    
            if(colorer != null)
                parsedCommandLine = colorer.Decorate(parsedCommandLine);
    
            return parsedCommandLine;
        }
    
        private static string ParseInternal(string commandLine)
        {
            //TODO: implement parsing as desired
            return commandLine;
        }
    

    }

LE PLUS IMPORTANT: testez, testez, testez entre chaque changement.

Kevin McCormick
la source
J'ai ajouté l'exemple! :-)
Kevin McCormick
1
Cette classe PluginFinder ressemble étrangement à un gestionnaire de DI automagical roll-your-own (utilisant un modèle ServiceLocator), mais c'est un autre bon conseil. Peut-être que vous feriez mieux de simplement pointer l'OP vers quelque chose comme Unity, car cela n'aurait pas de problèmes avec plusieurs implémentations d'une interface particulière dans les bibliothèques (StringColourer vs StringColourerWithHtmlWrapper, ou autre).
Ed James
@EdWoodcock Bon point Ed, et je n'arrive pas à croire que je n'ai pas pensé au modèle Service Locator en écrivant ceci. Le PluginFinder est définitivement une implémentation immature et un framework DI fonctionnerait certainement ici.
Kevin McCormick
Je vous ai accordé la prime pour l'effort, mais nous n'allons pas suivre cette voie. Le partage d'un assemblage d'interfaces de base signifie que nous avons seulement réussi à éloigner les implémentations, mais il y a toujours une bibliothèque qui contient une liasse d'interfaces peu liées (liées via des dépendances facultatives, comme précédemment). La configuration est maintenant beaucoup plus compliquée, avec peu d'avantages pour des bibliothèques aussi petites que celle-ci. La complexité supplémentaire pourrait en valoir la peine pour des projets gigantesques, mais pas ceux-ci.
Roman Starkov
@romkyns Alors quelle route empruntez-vous? Le laisser tel quel? :)
Max
5

Vous pouvez utiliser des interfaces déclarées dans une bibliothèque supplémentaire.

Essayez de résoudre un contrat (classe via interface) en utilisant une injection de dépendance (MEF, Unity, etc.). S'il n'est pas trouvé, définissez-le pour renvoyer une instance nulle.
Vérifiez ensuite si l'instance est nulle, auquel cas vous ne faites pas les fonctionnalités supplémentaires.

C'est particulièrement facile à faire avec MEF, car c'est l'utilisation des manuels pour cela.

Cela vous permettrait de compiler les bibliothèques, au prix de les diviser en n + 1 dll.

HTH.

Louis Kottmann
la source
Cela semble presque exact - si seulement ce n'était pas pour cette DLL supplémentaire, qui est essentiellement comme un tas de squelettes de la liasse de choses d'origine. Les implémentations sont toutes divisées, mais il reste encore une "liasse de squelettes". Je suppose que cela a quelques avantages, mais je ne suis pas convaincu que les avantages l'emportent sur tous les coûts pour cet ensemble particulier de bibliothèques ...
Roman Starkov
De plus, le fait d'inclure un cadre entier représente un véritable recul; cette bibliothèque telle quelle est de la taille d'un de ces frameworks, annulant totalement l'avantage. Si quoi que ce soit, j'utiliserais juste un peu de réflexion pour voir si une implémentation est disponible, car il ne peut y avoir qu'entre zéro et un, et une configuration externe n'est pas requise.
Roman Starkov
2

Je pensais que je publierais l'option la plus viable que nous ayons trouvée jusqu'à présent, pour voir quelles sont les pensées.

Fondamentalement, nous séparerions chaque composant dans une bibliothèque avec zéro référence; tout le code qui nécessite une référence sera placé dans un #if/#endifbloc avec le nom approprié. Par exemple, le code CommandLineParserqui gère ConsoleColoredStrings serait placé dans #if HAS_CONSOLE_COLORED_STRING.

Toute solution qui souhaite inclure uniquement le CommandLineParserpeut facilement le faire, car il n'y a plus de dépendances. Cependant, si la solution inclut également le ConsoleColoredStringprojet, le programmeur a désormais la possibilité de:

  • ajouter une référence CommandLineParseràConsoleColoredString
  • ajoutez la HAS_CONSOLE_COLORED_STRINGdéfinition au CommandLineParserfichier de projet.

Cela rendrait les fonctionnalités pertinentes disponibles.

Il y a plusieurs problèmes avec ceci:

  • Il s'agit d'une solution source uniquement; chaque consommateur de la bibliothèque doit l'inclure comme code source; ils ne peuvent pas simplement inclure un binaire (mais ce n'est pas une exigence absolue pour nous).
  • Le fichier de projet de bibliothèque de la bibliothèque obtient quelques modifications spécifiques à la solution , et il n'est pas exactement évident de savoir comment cette modification est validée dans SCM.

Plutôt pas joli, mais c'est le plus proche que nous ayons trouvé.

Une autre idée que nous avons envisagée était d'utiliser des configurations de projet plutôt que d'exiger que l'utilisateur modifie le fichier de projet de bibliothèque. Mais cela est absolument irréalisable dans VS2010 car il ajoute toutes les configurations de projet à la solution de manière indésirable .

Roman Starkov
la source
1

Je vais recommander le livre Brownfield Application Development in .Net . Deux chapitres directement pertinents sont 8 et 9. Le chapitre 8 parle de relayer votre application, tandis que le chapitre 9 parle d'apprivoiser les dépendances, l'inversion du contrôle et l'impact que cela a sur les tests.

Tangurena
la source
1

Divulgation complète, je suis un gars Java. Je comprends donc que vous ne cherchez probablement pas les technologies que je mentionnerai ici. Mais les problèmes sont les mêmes, alors peut-être que cela vous indiquera la bonne direction.

En Java, il existe un certain nombre de systèmes de construction qui prennent en charge l'idée d'un référentiel d'artefacts centralisé qui héberge des "artefacts" construits - à ma connaissance, cela est quelque peu analogue au GAC dans .NET (veuillez excuser mon ignorance s'il s'agit d'une anaologie tendue) mais plus que cela, car il est utilisé pour produire des versions répétables indépendantes à tout moment.

Quoi qu'il en soit, une autre fonctionnalité prise en charge (dans Maven, par exemple) est l'idée d'une dépendance OPTIONNELLE, puis en fonction de versions ou de plages spécifiques et potentiellement en excluant les dépendances transitives. Cela me semble être ce que vous recherchez, mais je peux me tromper. Jetez un œil à cette page d'introduction sur la gestion des dépendances de Maven avec un ami qui connaît Java et voyez si les problèmes vous semblent familiers. Cela vous permettra de construire votre application et de la construire avec ou sans avoir ces dépendances disponibles.

Il existe également des constructions si vous avez besoin d'une architecture véritablement dynamique et enfichable; OSGI est une technologie qui essaie de résoudre ce type de résolution de dépendance à l'exécution. C'est le moteur derrière le système de plugins d'Eclipse . Vous verrez qu'il peut prendre en charge des dépendances facultatives et une plage de versions minimale / maximale. Ce niveau de modularité d'exécution vous impose un bon nombre de contraintes et de développement. La plupart des gens peuvent s'en sortir avec le degré de modularité fourni par Maven.

Une autre idée possible que vous pourriez étudier et qui pourrait être plus simple à implémenter serait d'utiliser un style d'architecture Pipes and Filters. C'est en grande partie ce qui a fait d'UNIX un écosystème aussi ancien et prospère qui a survécu et évolué pendant un demi-siècle. Jetez un œil à cet article sur les tuyaux et filtres dans .NET pour quelques idées sur la façon d'implémenter ce type de modèle dans votre framework.

cwash
la source
0

Peut-être que le livre "Large-scale C ++ software design" de John Lakos est utile (bien sûr C # et C ++ ou pas le même, mais vous pouvez distiller des techniques utiles du livre).

Fondamentalement, reformatez et déplacez la fonctionnalité qui utilise deux bibliothèques ou plus dans un composant distinct qui dépend de ces bibliothèques. Si nécessaire, utilisez des techniques telles que les types opaques, etc.

Kasper van den Berg
la source