Database
 sql >> Base de Dados >  >> RDS >> Database

Indicadores-chave do Design do Problema


O conceito de design bom ou ruim é relativo. Ao mesmo tempo, existem alguns padrões de programação, que na maioria dos casos garantem eficácia, manutenibilidade e testabilidade. Por exemplo, em linguagens orientadas a objetos, este é o uso de encapsulamento, herança e polimorfismo. Existe um conjunto de padrões de design que, em vários casos, têm um efeito positivo ou negativo no design do aplicativo, dependendo da situação. Por outro lado, existem opostos, seguindo o que às vezes leva ao design do problema.





Esse design geralmente tem os seguintes indicadores (um ou vários de cada vez):
  • Rigidez (é difícil modificar o código, pois uma simples mudança afeta muitos lugares);
  • Imobilidade (é complicado dividir o código em módulos que podem ser usados ​​em outros programas);
  • Viscosidade (é bastante difícil desenvolver ou testar o código);
  • Complexidade desnecessária (há uma funcionalidade não utilizada no código);
  • Repetição desnecessária (Copiar/Colar);
  • Má legibilidade (é difícil entender para que o código foi projetado e mantê-lo);
  • Fragilidade (é fácil quebrar a funcionalidade mesmo com pequenas alterações).

Você precisa ser capaz de entender e distinguir esses recursos para evitar um projeto problemático ou prever possíveis consequências de seu uso. Esses indicadores são descritos no livro «Agile Principles, Patterns, And Practices in C#» de Robert Martin. No entanto, há uma breve descrição e nenhum exemplo de código neste artigo, bem como em outros artigos de revisão.

Vamos eliminar essa desvantagem ao se concentrar em cada recurso.

Rigidez


Como já foi mencionado, um código rígido é difícil de ser modificado, mesmo nas menores coisas. Isso pode não ser um problema se o código não for alterado com frequência ou de forma alguma. Assim, o código acaba por ser muito bom. No entanto, se for necessário modificar o código e for difícil fazer isso, torna-se um problema, mesmo que funcione.

Um dos casos populares de rigidez é especificar explicitamente os tipos de classe em vez de usar abstrações (interfaces, classes base, etc.). Abaixo, você pode encontrar um exemplo do código:
class A
{
  B _b;
  public A()
  {
    _b = new B();
  }

  public void Foo()
  {
    // Do some custom logic.
    _b.DoSomething();
    // Do some custom logic.
  }
}

class B
{
   public void DoSomething()
  {
    // Do something
   }
}

Aqui a classe A depende muito da classe B. Portanto, se no futuro você precisar usar outra classe em vez da classe B, isso exigirá a alteração da classe A e fará com que ela seja testada novamente. Além disso, se a classe B afetar outras classes, a situação ficará muito complicada.

A solução alternativa é uma abstração que consiste em introduzir a interface IComponent por meio do construtor da classe A. Nesse caso, ela não dependerá mais da classe específica B e dependerá apenas da interface IComponent. Сlass В por sua vez deve implementar a interface IComponent.
interface IComponent
{
  void DoSomething();
}

class A
{
  IComponent _component;
  public A(IComponent component)
  {
    _component = component;
  }

  void Foo()
  {
     // Do some custom logic.    
     _component.DoSomething();
     // Do some custom logic.
   }
}

class B : IComponent
{
  void DoSomething()
  {
    // Do something
  }
}

Vamos dar um exemplo específico. Suponha que haja um conjunto de classes que registram as informações – ProductManager e Consumer. Sua tarefa é armazenar um produto no banco de dados e encomendá-lo de forma correspondente. Ambas as classes registram eventos relevantes. Imagine que no início havia um log em um arquivo. Para isso, foi utilizada a classe FileLogger. Além disso, as aulas foram localizadas em diferentes módulos (montagens).
// Module 1 (Client)
static void Main()
{
  var product = new Product("milk");
  var productManager = new ProductManager();
  productManager.AddProduct(product);
  var consumer = new Consumer();
  consumer.PurchaseProduct(product.Name);
}

// Module 2 (Business logic)
public class ProductManager
{
  private readonly FileLogger _logger = new FileLogger();
  public void AddProduct(Product product)
  {
    // Add the product to the database.
    _logger.Log("The product is added.");
  }
}

public class Consumer
{
  private readonly FileLogger _logger = new FileLogger();
  public void PurchaseProduct(string product)
  {
     // Purchase the product.
    _logger.Log("The product is purchased.");
  }
}

public class Product
{
  public string Name { get; private set; }
  public Product(string name)
  {
    Name = name;
  }
}

// Module 3 (Logger implementation)
public class FileLogger
{
  const string FileName = "log.txt";
  public void Log(string message)
  {
    // Write the message to the file.
  }
}

Se no início bastasse usar apenas o arquivo e, em seguida, for necessário fazer login em outros repositórios, como um banco de dados ou um serviço de coleta e armazenamento de dados baseado em nuvem, precisaremos alterar todas as classes na lógica de negócios módulo (Módulo 2) que usam FileLogger. Afinal, isso pode ser difícil. Para resolver este problema, podemos introduzir uma interface abstrata para trabalhar com o logger, conforme mostrado abaixo.
// Module 1 (Client)
static void Main()
{
  var logger = new FileLogger();
  var product = new Product("milk");
  var productManager = new ProductManager(logger);
  productManager.AddProduct(product);
  var consumer = new Consumer(logger);
  consumer.PurchaseProduct(product.Name);
}

// Module 2 (Business logic)
class ProductManager
{
  private readonly ILogger _logger;
  public ProductManager(ILogger logger)
  {
    _logger = logger;
  }
  
  public void AddProduct(Product product)
  {
    // Add the product to the database.
    _logger.Log("The product is added.");
  }
}

public class Consumer
{
  private readonly ILogger _logger;
  public Consumer(ILogger logger)
  {
    _logger = logger;
  }

  public void PurchaseProduct(string product)
  {
     // Purchase the product.
    _logger.Log("The product is purchased.");
  }
}

public class Product
{
  public string Name { get; private set; }
  public Product(string name)
  {
    Name = name;
  }
}

// Module 3 (interfaces)
public interface ILogger
{
  void Log(string message);
}

// Module 4 (Logger implementation)
public class FileLogger : ILogger
{
  const string FileName = "log.txt";
  public virtual void Log(string message)
  {
    // Write the message to the file.
  }
}

Nesse caso, ao alterar um tipo de logger, basta modificar o código do cliente (Main), que inicializa o logger e o adiciona ao construtor de ProductManager e Consumer. Assim, fechamos as classes de lógica de negócios a partir da modificação do tipo de logger conforme necessário.

Além de links diretos para as classes utilizadas, podemos monitorar a rigidez em outras variantes que podem gerar dificuldades na hora de modificar o código. Pode haver um conjunto infinito deles. No entanto, tentaremos fornecer outro exemplo. Suponha que haja um código que exiba a área de um padrão geométrico no console.
static void Main()
{
  var rectangle = new Rectangle() { W = 3, H = 5 };
  var circle = new Circle() { R = 7 };
  var shapes = new Shape[] { rectangle, circle  };
  ShapeHelper.ReportShapesSize(shapes);
}

class ShapeHelper
{
  private static double GetShapeArea(Shape shape)
  {
    if (shape is Rectangle)
    {
      return ((Rectangle)shape).W * ((Rectangle)shape).H;
    }
    if (shape is Circle)
    {
      return 2 * Math.PI * ((Circle)shape).R * ((Circle)shape).R;
    }
    throw new InvalidOperationException("Not supported shape");
  }

  public static void ReportShapesSize(Shape[] shapes)
  {
    foreach(Shape shape in shapes)
    {
       if (shape is Rectangle)
       {
         double area = GetShapeArea(shape); 
         Console.WriteLine($"Rectangle's area is {area}");
       }
       if (shape is Circle)
       {
         double area = GetShapeArea(shape); 
         Console.WriteLine($"Circle's area is {area}");
       }
    }
  }
}

public class Shape
{  }

public class Rectangle : Shape
{
  public double W { get; set; }
  public double H { get; set; }
}

public class Circle : Shape
{
  public double R { get; set; }
}

Como você pode ver, ao adicionar um novo padrão, teremos que alterar os métodos da classe ShapeHelper. Uma das opções é passar o algoritmo de renderização nas classes de padrões geométricos (Retângulo e Círculo), conforme mostrado abaixo. Dessa forma, isolaremos a lógica relevante nas classes correspondentes, reduzindo assim a responsabilidade da classe ShapeHelper antes de exibir informações no console.
static void Main()
{
  var rectangle = new Rectangle() { W = 3, H = 5 };
  var circle = new Circle() { R = 7 };
  var shapes = new Shape[]() { rectangle, circle  };
  ShapeHelper.ReportShapesSize(shapes);
}

class ShapeHelper
{
  public static void ReportShapesSize(Shape[] shapes)
  {
    foreach(Shape shape in shapes)
    {
       shape.Report();
    }
  }
}

public abstract class Shape
{
  public abstract void Report();
}

public class Rectangle : Shape
{
  public double W { get; set; }
  public double H { get; set; }
  public override void Report()
  {
     double area = W * H;
     Console.WriteLine($"Rectangle's area is {area}");
  }
}

public class Circle : Shape
{
  public double R { get; set; }
  public override void Report()
  {
     double area = 2 * Math.PI * R * R;
     Console.WriteLine($"Circle's area is {area}");
  }
}

Como resultado, fechamos a classe ShapeHelper para alterações que adicionam novos tipos de padrões usando herança e polimorfismo.

Imobilidade


Podemos monitorar a imobilidade ao dividir o código em módulos reutilizáveis. Como resultado, o projeto pode parar de se desenvolver e ser competitivo.

Como exemplo, consideraremos um programa de desktop, cujo código inteiro é implementado no arquivo executável do aplicativo (.exe) e foi projetado para que a lógica de negócios não seja construída em módulos ou classes separadas. Mais tarde, o desenvolvedor enfrentou os seguintes requisitos de negócios:
  • Para alterar a interface do usuário transformando-a em um aplicativo da Web;
  • Para publicar a funcionalidade do programa como um conjunto de serviços da Web disponíveis para clientes de terceiros para serem usados ​​em seus próprios aplicativos.

Nesse caso, esses requisitos são difíceis de serem atendidos, pois todo o código está localizado no módulo executável.

A figura abaixo mostra um exemplo de design imóvel em contraste com aquele que não possui este indicador. Eles são separados por uma linha pontilhada. Como pode ver, a atribuição do código em módulos reutilizáveis ​​(Logic), bem como a publicação da funcionalidade ao nível dos Web Services, permitem utilizá-lo em várias aplicações cliente (App), o que é uma vantagem indiscutível.



A imobilidade também pode ser chamada de design monolítico. É difícil dividi-lo em unidades menores e úteis do código. Como podemos fugir desta questão? Na fase de projeto, é melhor pensar na probabilidade de usar esse ou aquele recurso em outros sistemas. O código que se espera que seja reutilizado é melhor colocado em módulos e classes separados.

Viscosidade


Existem dois tipos:
  • viscosidade de desenvolvimento
  • viscosidade do ambiente

Podemos ver a viscosidade do desenvolvimento ao tentar seguir o projeto de aplicação selecionado. Isso pode acontecer quando um programador precisa atender a muitos requisitos enquanto há uma maneira mais fácil de desenvolvimento. Além disso, a viscosidade de desenvolvimento pode ser vista quando o processo de montagem, implantação e teste não é eficaz.

Como exemplo simples, podemos considerar o trabalho com constantes que devem ser colocadas (By Design) em um módulo separado (Módulo 1) para ser usado por outros componentes (Módulo 2 e Módulo 3).
// Module 1 (Constants)
static class Constants
{
  public const decimal MaxSalary = 100M;
  public const int MaxNumberOfProducts = 100;
}
 
// Finance Module
#using Module1
static class FinanceHelper
{
  public static bool ApproveSalary(decimal salary)
  {
    return salary <= Constants.MaxSalary;
  }
} 
 
// Marketing Module
#using Module1
class ProductManager
{
  public void MakeOrder()
  {
    int productsNumber = 0;
    while(productsNumber++ <= Constants.MaxNumberOfProducts)
    {
      // Purchase some product
    }
  }
}

Se por algum motivo o processo de montagem demorar muito, será difícil para os desenvolvedores esperar até que ele termine. Além disso, deve-se notar que o módulo constante contém entidades mistas que pertencem a diferentes partes da lógica de negócios (módulos financeiros e de marketing). Assim, o módulo constante pode ser alterado com bastante frequência por motivos independentes um do outro, o que pode levar a problemas adicionais, como sincronização das alterações.

Tudo isso retarda o processo de desenvolvimento e pode estressar os programadores. As variantes do design menos viscoso seriam criar módulos de constantes separados – por um para o módulo de lógica de negócios correspondente – ou passar constantes para o lugar certo sem levar um módulo separado para eles.

Um exemplo da viscosidade do ambiente pode ser o desenvolvimento e teste do aplicativo na máquina virtual do cliente remoto. Às vezes, esse fluxo de trabalho se torna insuportável devido a uma conexão lenta com a Internet, de modo que o desenvolvedor pode ignorar sistematicamente o teste de integração do código escrito, o que pode levar a erros no lado do cliente ao usar esse recurso.

Complexidade desnecessária


Nesse caso, o design tem uma funcionalidade realmente não utilizada. Este fato pode complicar o suporte e manutenção do programa, além de aumentar o tempo de desenvolvimento e teste. Por exemplo, considere o programa que requer a leitura de alguns dados do banco de dados. Para isso, foi criado o componente DataManager, que é utilizado em outro componente.
class DataManager
{
  object[] GetData()
  {
    // Retrieve and return data
  }
}

Se o desenvolvedor adicionar um novo método ao DataManager para gravar dados no banco de dados (WriteData), que provavelmente não será usado no futuro, também será uma complexidade desnecessária.

Outro exemplo é uma interface para todos os fins. Por exemplo, vamos considerar uma interface com o método único Process que aceita um objeto do tipo string.
interface IProcessor
{
  void Process(string message);
}

Se a tarefa fosse processar um determinado tipo de mensagem com uma estrutura bem definida, seria mais fácil criar uma interface estritamente tipada, em vez de fazer os desenvolvedores desserializarem essa string em um tipo de mensagem específico a cada vez.

O uso excessivo de padrões de design nos casos em que isso não é necessário também pode levar ao design de viscosidade.

Por que perder seu tempo escrevendo um código potencialmente não utilizado? Às vezes, o controle de qualidade deve testar esse código, porque ele está realmente publicado e aberto para uso por clientes de terceiros. Isso também adia o tempo de lançamento. Incluir um recurso para o futuro só vale se seu possível benefício exceder os custos para seu desenvolvimento e teste.

Repetição desnecessária


Talvez, a maioria dos desenvolvedores já tenha se deparado ou se depare com esse recurso, que consiste em copiar várias vezes a mesma lógica ou o código. A principal ameaça é a vulnerabilidade desse código ao modificá-lo – ao consertar algo em um lugar, você pode esquecer de fazer isso em outro. Além disso, leva mais tempo para fazer alterações em comparação com a situação em que o código não contém esse recurso.

A repetição desnecessária pode ser devido à negligência dos desenvolvedores, bem como devido à rigidez/fragilidade do design quando é muito mais difícil e arriscado não repetir o código ao invés de fazer isso. No entanto, em qualquer caso, a repetibilidade não é uma boa ideia, sendo necessário melhorar constantemente o código, passando partes reutilizáveis ​​para métodos e classes comuns.

Má legibilidade


Você pode monitorar esse recurso quando for difícil ler um código e entender para que ele foi criado. As razões para a baixa legibilidade podem ser a não conformidade com os requisitos para a execução do código (sintaxe, variáveis, classes), uma lógica de implementação complicada, etc.

Abaixo você encontra o exemplo do código difícil de ler, que implementa o método com a variável booleana.
void Process_true_false(string trueorfalsevalue)
{
  if (trueorfalsevalue.ToString().Length == 4)
  {
    // That means trueorfalsevalue is probably "true". Do something here.
  }
  else if (trueorfalsevalue.ToString().Length == 5)
  {
    // That means trueorfalsevalue is probably "false". Do something here.
  }
  else
  {
    throw new Exception("not true of false. that's not nice. return.")
  }
}

Aqui, podemos esboçar várias questões. Em primeiro lugar, nomes de métodos e variáveis ​​não estão de acordo com as convenções geralmente aceitas. Em segundo lugar, a implementação do método não é a melhor.

Talvez valha a pena pegar um valor booleano, em vez de uma string. No entanto, é melhor convertê-lo em um valor booleano no início do método, em vez de usar o método para determinar o comprimento da string.

Em terceiro lugar, o texto da exceção não corresponde ao estilo oficial. Ao ler tais textos, pode haver a sensação de que o código é criado por um amador (ainda assim, pode haver um ponto em questão). O método pode ser reescrito da seguinte forma se receber um valor booleano:
public void Process(bool value)
{
  if (value)
  {
    // Do something.
  }
  else
  {
    // Do something.
  }
}

Aqui está outro exemplo de refatoração se você ainda precisar pegar uma string:
public void Process(string value)
{
  bool bValue = false;
  if (!bool.TryParse(value, out bValue))
  {
    throw new ArgumentException($"The {value} is not boolean");
  }  
  if (bValue)
  {
    // Do something.
  }
  else
  {
    // Do something.
  }
}

Recomenda-se realizar a refatoração com o código de difícil leitura, por exemplo, quando sua manutenção e clonagem levam a vários bugs.

Fragilidade


A fragilidade de um programa significa que ele pode ser facilmente travado ao ser modificado. Existem dois tipos de falhas:erros de compilação e erros de tempo de execução. Os primeiros podem ser um verso da rigidez. Estes últimos são os mais perigosos, pois ocorrem no lado do cliente. Então, eles são um indicador da fragilidade.

Sem dúvida, o indicador é relativo. Alguém corrige o código com muito cuidado e a possibilidade de seu travamento é bastante baixa, enquanto outros fazem isso com pressa e descuido. Ainda assim, um código diferente com os mesmos usuários pode causar uma quantidade diferente de erros. Provavelmente, podemos dizer que quanto mais difícil for entender o código e confiar no tempo de execução do programa, e não na etapa de compilação, mais frágil será o código.

Além disso, a funcionalidade que não será modificada geralmente falha. Pode sofrer com o alto acoplamento da lógica de diferentes componentes.

Considere o exemplo específico. Aqui a lógica de autorização do usuário com um determinado papel (definido como parâmetro rolado) para acessar um determinado recurso (definido como resourceUri) está localizado no método estático.
static void Main()
{
  if (Helper.Authorize(1, "/pictures"))
  {
    Console.WriteLine("Authorized");
  }
}

class Helper
{
  public static bool Authorize(int roleId, string resourceUri)
  {
    if (roleId == 1 || roleId == 10)
    {
      if (resourceUri == "/pictures")
      {
        return true;
      }
    }

    if (roleId == 1 || roleId == 2 && resourceUri == "/admin")
    {
      return true;
    }

    return false;
  }
}

Como você pode ver, a lógica é complicada. É óbvio que adicionar novas funções e recursos facilmente o quebrará. Como resultado, uma determinada função pode obter ou perder acesso a um recurso. A criação da classe Resource que armazena internamente o identificador de recurso e a lista de funções com suporte, conforme mostrado abaixo, reduziria a fragilidade.
static void Main()
{
  var picturesResource = new Resource() { Uri = "/pictures" };
  picturesResource.AddRole(1);
  if (picturesResource.IsAvailable(1))
  {
    Console.WriteLine("Authorized");
  }
}

class Resource
{
  private List<int> _roles = new List<int>();
  public string Uri { get; set; }
  public void AddRole(int roleId)
  {
    _roles.Add(roleId);
  }
  public void RemoveRole(int roleId)
  {
    _roles.Remove(roleId);
  }
  public bool IsAvailable(int roleId)
  {
    return _roles.Contains(roleId);
  }
}

Nesse caso, para adicionar novos recursos e funções, não é necessário modificar o código lógico de autorização, ou seja, na verdade não há nada para quebrar.

O que pode ajudar a detectar erros de tempo de execução? A resposta é o teste manual, automático e unitário. Quanto melhor o processo de teste for organizado, mais provável é que o código frágil ocorra no lado do cliente.

Muitas vezes, a fragilidade é o verso de outros identificadores de design ruim, como rigidez, baixa legibilidade e repetição desnecessária.

Conclusão


Tentamos delinear e descrever os principais identificadores de design ruim. Alguns deles são interdependentes. Você precisa entender que a questão do design nem sempre leva inevitavelmente a dificuldades. Apenas aponta que eles podem ocorrer. Quanto menos esses identificadores forem monitorados, menor será essa probabilidade.