Separando um projeto de utilitário "maço de coisas" em componentes individuais com dependências "opcionais"

26

Ao longo dos anos usando o C # / .NET para vários projetos internos, tivemos uma biblioteca crescer organicamente em uma enorme quantidade de coisas. Chama-se "Util", e tenho certeza que muitos de vocês já viram uma dessas bestas em suas carreiras.

Muitas partes desta biblioteca são muito independentes e podem ser divididas em projetos separados (que gostaríamos de código aberto). Mas há um grande problema que precisa ser resolvido antes que eles possam ser lançados como bibliotecas separadas. Basicamente, existem muitos casos do que eu poderia chamar de "dependências opcionais" entre essas bibliotecas.

Para explicar melhor, considere alguns dos módulos que são bons candidatos para se tornarem bibliotecas independentes. CommandLineParseré para analisar linhas de comando. XmlClassifyé para serializar classes para XML. PostBuildCheckexecuta verificações no assembly compilado e relata um erro de compilação se elas falharem. ConsoleColoredStringé uma biblioteca para literais de strings coloridos. Lingoé para traduzir interfaces de usuário.

Cada uma dessas bibliotecas pode ser usada completamente independente, mas se forem usadas juntas, haverá recursos extras úteis. Por exemplo, ambos CommandLineParsere XmlClassifyexpõem a funcionalidade de verificação pós-compilação, o que requer PostBuildCheck. Da mesma forma, CommandLineParserpermite que a documentação da opção seja fornecida usando os literais de strings coloridos, exigindo ConsoleColoredString, e suporta a documentação traduzível via Lingo.

Portanto, a principal distinção é que esses são recursos opcionais . Pode-se usar um analisador de linha de comando com strings simples e sem cores, sem traduzir a documentação ou executar verificações pós-compilação. Ou pode-se tornar a documentação traduzível, mas ainda sem cor. Ou colorido e traduzível. Etc.

Examinando esta biblioteca "Util", vejo que quase todas as bibliotecas potencialmente separáveis ​​possuem recursos opcionais que as vinculam a outras bibliotecas. Se eu realmente exigisse essas bibliotecas como dependências, esse monte de coisas não é realmente desordenado: você ainda precisaria basicamente de todas as bibliotecas se quiser usar apenas uma.

Existem abordagens estabelecidas para gerenciar essas dependências opcionais no .NET?

Roman Starkov
fonte
2
Mesmo que as bibliotecas sejam dependentes uma da outra, ainda pode haver algum benefício em separá-las em bibliotecas coerentes, mas separadas, cada uma contendo uma ampla categoria de funcionalidade.
Robert Harvey

Respostas:

20

Refatorar lentamente.

Espere que isso leve algum tempo para ser concluído e pode ocorrer em várias iterações antes que você possa remover completamente o assembly Utils .

Abordagem global:

  1. Primeiro, dedique algum tempo e pense em como você deseja que esses conjuntos de utilitários sejam exibidos quando terminar. Não se preocupe muito com o código existente, pense no objetivo final. Por exemplo, você pode querer ter:

    • MyCompany.Utilities.Core (contendo algoritmos, registro em log etc.)
    • MyCompany.Utilities.UI (código de desenho etc.)
    • MyCompany.Utilities.UI.WinForms (código relacionado ao System.Windows.Forms, controles personalizados etc.)
    • MyCompany.Utilities.UI.WPF (código relacionado ao WPF, classes base do MVVM).
    • MyCompany.Utilities.Serialization (código de serialização).
  2. Crie projetos vazios para cada um desses projetos e crie referências de projeto apropriadas (referências da interface do usuário Core, UI.WinForms referencia a interface do usuário), etc.

  3. Mova qualquer fruta pendente (classes ou métodos que não sofrem com os problemas de dependência) do assembly Utils para os novos assemblies de destino.

  4. Obtenha uma cópia do NDepend e da refatoração de Martin Fowler para começar a analisar sua montagem de Utils e começar a trabalhar nas mais difíceis. Duas técnicas que serão úteis:

Manipulação de interfaces opcionais

Uma montagem faz referência a outra montagem ou não. A única outra maneira de usar a funcionalidade em um assembly não vinculado é através de uma interface carregada através da reflexão de uma classe comum. A desvantagem disso é que seu assembly principal precisará conter interfaces para todos os recursos compartilhados, mas a desvantagem é que você pode implantar seus utilitários conforme necessário sem o "excesso" de arquivos DLL, dependendo de cada cenário de implantação. Aqui está como eu lidaria com esse caso, usando a sequência colorida como exemplo:

  1. Primeiro, defina as interfaces comuns no seu assembly principal:

    insira a descrição da imagem aqui

    Por exemplo, a IStringColorerinterface seria semelhante a:

     namespace MyCompany.Utilities.Core.OptionalInterfaces
     {
         public interface IStringColorer
         {
             string Decorate(string s);
         }
     }
    
  2. Em seguida, implemente a interface na montagem com o recurso Por exemplo, a StringColorerclasse seria semelhante a:

    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. Crie uma PluginFinderclasse (ou talvez InterfaceFinder seja um nome melhor nesse caso) que possa encontrar interfaces de arquivos DLL na pasta atual. Aqui está um exemplo simplista. De acordo com o conselho de @ EdWoodcock (e eu concordo), quando seus projetos crescerem, sugiro usar uma das estruturas de injeção de dependência disponíveis ( Common Serivce Locator with Unity e Spring.NET ) - para uma implementação mais robusta com mais "encontre-me" esse recurso ", também conhecido como Padrão do Localizador de Serviço . Você pode modificá-lo para atender às suas necessidades.

    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. Por fim, use essas interfaces em seus outros assemblies chamando o método FindInterface. Aqui está um exemplo 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;
        }
    

    }

O MAIS IMPORTANTE: Teste, teste, teste entre cada alteração.

Kevin McCormick
fonte
Eu adicionei o exemplo! :-) #
22412 Kevin McCormick
1
Essa classe PluginFinder parece suspeitamente um manipulador de DI auto-mágico do tipo roll-your-own (usando um padrão ServiceLocator), mas isso é um bom conselho. Talvez seja melhor apontar o OP para algo como o Unity, pois isso não teria problemas com várias implementações de uma interface específica nas bibliotecas (StringColourer vs StringColourerWithHtmlWrapper, ou qualquer outra coisa).
Ed James
@ EdWoodcock Bom ponto Ed, e eu não posso acreditar que não pensei no padrão Service Locator enquanto escrevia isso. O PluginFinder é definitivamente uma implementação imatura e uma estrutura de DI certamente funcionaria aqui.
21412 Kevin Kormormick
Eu concedi a você a recompensa pelo esforço, mas não vamos seguir esse caminho. Compartilhar um conjunto principal de interfaces significa que conseguimos apenas afastar as implementações, mas ainda há uma biblioteca que contém um monte de interfaces pouco relacionadas (relacionadas por dependências opcionais, como antes). A configuração é muito mais complicada agora, com pouco benefício para bibliotecas tão pequenas quanto essa. A complexidade extra pode valer a pena para projetos enormes, mas não para esses.
Roman Starkov
@romkyns Então, qual o seu caminho? Deixando como está? :)
Max
5

Você pode fazer uso de interfaces declaradas em uma biblioteca adicional.

Tente resolver um contrato (classe via interface) usando uma injeção de dependência (MEF, Unity etc.). Se não encontrado, configure-o para retornar uma instância nula.
Em seguida, verifique se a instância é nula; nesse caso, você não executa as funcionalidades extras.

Isso é especialmente fácil de fazer com o MEF, já que é o livro usado para isso.

Isso permitiria que você compile as bibliotecas, com o custo de dividi-las em n + 1 dlls.

HTH.

Louis Kottmann
fonte
Isso parece quase certo - se não fosse por uma DLL extra, que é basicamente como um monte de esqueletos do material original. As implementações estão todas divididas, mas ainda resta um "monte de esqueletos". Suponho que isso tenha algumas vantagens, mas não estou convencido de que as vantagens superem todos os custos desse conjunto particular de bibliotecas ...
Roman Starkov
Além disso, a inclusão de uma estrutura inteira é totalmente um retrocesso; essa biblioteca como está tem o tamanho de uma dessas estruturas, negando totalmente o benefício. Se alguma coisa, eu usaria um pouco de reflexão para ver se uma implementação está disponível, pois só pode haver entre zero e um, e a configuração externa não é necessária.
Roman Starkov
2

Pensei em postar a opção mais viável que temos até agora, para ver quais são os pensamentos.

Basicamente, separaríamos cada componente em uma biblioteca com zero referências; todo o código que requer uma referência será colocado em um #if/#endifbloco com o nome apropriado. Por exemplo, o código em CommandLineParserque manipula ConsoleColoredStrings seria inserido #if HAS_CONSOLE_COLORED_STRING.

Qualquer solução que deseje incluir apenas o CommandLineParserpode fazê-lo facilmente, pois não há mais dependências. No entanto, se a solução também incluir o ConsoleColoredStringprojeto, o programador agora tem a opção de:

  • adicione uma referência CommandLineParserparaConsoleColoredString
  • adicione a HAS_CONSOLE_COLORED_STRINGdefinição ao CommandLineParserarquivo do projeto.

Isso tornaria disponível a funcionalidade relevante.

Existem vários problemas com isso:

  • Esta é uma solução somente de origem; todo consumidor da biblioteca deve incluí-la como um código-fonte; eles não podem apenas incluir um binário (mas isso não é um requisito absoluto para nós).
  • A biblioteca de arquivo de projeto da biblioteca recebe um par de soluções edições especficos, e não é exatamente óbvia como esta mudança está empenhada em SCM.

Bastante pouco bonito, mas ainda assim, este é o mais próximo que chegamos.

Uma idéia adicional que consideramos foi usar configurações do projeto, em vez de exigir que o usuário editasse o arquivo de projeto da biblioteca. Mas isso é absolutamente impraticável no VS2010 porque adiciona todas as configurações do projeto à solução indesejadamente .

Roman Starkov
fonte
1

Vou recomendar o livro Brownfield Application Development em .Net . Dois capítulos diretamente relevantes são 8 e 9. O capítulo 8 fala sobre a retransmissão de seu aplicativo, enquanto o capítulo 9 fala sobre domar dependências, inversão de controle e o impacto que isso tem nos testes.

Tangurena
fonte
1

Divulgação completa, eu sou um cara de Java. Entendo que você provavelmente não está procurando as tecnologias que mencionarei aqui. Mas os problemas são os mesmos, então talvez isso o direcione na direção certa.

Em Java, existem vários sistemas de construção que suportam a idéia de um repositório de artefatos centralizado que abriga "artefatos" construídos - pelo que sei, isso é algo análogo ao GAC no .NET (por favor, execute minha ignorância se for uma anaologia forçada) mas mais do que isso, porque é usado para produzir construções repetíveis independentes a qualquer momento.

De qualquer forma, outro recurso suportado (no Maven, por exemplo) é a idéia de uma dependência OPCIONAL, dependendo de versões ou intervalos específicos e, potencialmente, excluindo dependências transitivas. Parece-me o que você está procurando, mas posso estar errado. Dê uma olhada nesta página de introdução sobre gerenciamento de dependências do Maven com um amigo que conhece Java e veja se os problemas parecem familiares. Isso permitirá que você construa seu aplicativo e construa-o com ou sem essas dependências disponíveis.

Também existem construções se você precisar de uma arquitetura verdadeiramente dinâmica e conectável; uma tecnologia que tenta abordar essa forma de resolução de dependência de tempo de execução é a OSGI. Este é o mecanismo por trás do sistema de plugins do Eclipse . Você verá que ele suporta dependências opcionais e um intervalo de versão mínimo / máximo. Esse nível de modularidade de tempo de execução impõe um bom número de restrições a você e como você se desenvolve. A maioria das pessoas consegue sobreviver com o grau de modularidade que o Maven fornece.

Uma outra idéia possível que você pode considerar que pode ser de ordem de magnitude mais simples de implementar é usar um estilo de arquitetura Pipes and Filters. Isso foi em grande parte o que fez do UNIX um ecossistema tão bem-sucedido e de longa data que sobreviveu e evoluiu por meio século. Dê uma olhada neste artigo sobre Pipes e filtros no .NET para obter algumas idéias sobre como implementar esse tipo de padrão em sua estrutura.

cwash
fonte
0

Talvez o livro "Design de software C ++ em larga escala" de John Lakos seja útil (é claro que C # e C ++ ou não é o mesmo, mas você pode destilar técnicas úteis do livro).

Basicamente, re-fatore e mova a funcionalidade que usa duas ou mais bibliotecas para um componente separado que depende dessas bibliotecas. Se necessário, use técnicas como tipos opacos, etc.

Kasper van den Berg
fonte