Padrões comuns para delegados
Os delegados fornecem um mecanismo que permite projetos de software que envolvem acoplamento mínimo entre componentes.
Um excelente exemplo para este tipo de design é o LINQ. O LINQ Query Expression Pattern depende de delegados para todos os seus recursos. Considere este exemplo simples:
var smallNumbers = numbers.Where(n => n < 10);
Isso filtra a sequência de números apenas para aqueles menores que o valor 10.
O Where
método usa um delegado que determina quais elementos de uma sequência passam pelo filtro. Ao criar uma consulta LINQ, você fornece a implementação do delegado para essa finalidade específica.
O protótipo do método Where é:
public static IEnumerable<TSource> Where<TSource> (this IEnumerable<TSource> source, Func<TSource, bool> predicate);
Este exemplo é repetido com todos os métodos que fazem parte do LINQ. Todos eles dependem de delegados para o código que gerencia a consulta específica. Este padrão de design de API é poderoso para aprender e entender.
Este exemplo simples ilustra como os delegados exigem muito pouco acoplamento entre componentes. Você não precisa criar uma classe que deriva de uma classe base específica. Você não precisa implementar uma interface específica. O único requisito é fornecer a implementação de um método que é fundamental para a tarefa em questão.
Crie seus próprios componentes com delegados
Vamos aproveitar esse exemplo criando um componente usando um design que depende de delegados.
Vamos definir um componente que pode ser usado para mensagens de log em um sistema grande. Os componentes da biblioteca podem ser usados em muitos ambientes diferentes, em várias plataformas diferentes. Há muitos recursos comuns no componente que gerencia os logs. Ele precisará aceitar mensagens de qualquer componente do sistema. Essas mensagens terão prioridades diferentes, que o componente central pode gerenciar. As mensagens devem ter carimbos de data/hora em sua forma final arquivada. Para cenários mais avançados, você pode filtrar mensagens pelo componente de origem.
Há um aspeto do recurso que mudará com frequência: onde as mensagens são escritas. Em alguns ambientes, eles podem ser gravados no console de erro. Em outros, um arquivo. Outras possibilidades incluem armazenamento de banco de dados, logs de eventos do sistema operacional ou outro armazenamento de documentos.
Há também combinações de saída que podem ser usadas em diferentes cenários. Talvez você queira gravar mensagens no console e em um arquivo.
Um design baseado em delegados proporcionará uma grande flexibilidade e facilitará o suporte a mecanismos de armazenamento que podem ser adicionados no futuro.
Sob esse design, o componente de log primário pode ser uma classe não virtual, até mesmo selada. Você pode conectar qualquer conjunto de delegados para gravar as mensagens em diferentes mídias de armazenamento. O suporte interno para delegados multicast facilita o suporte a cenários em que as mensagens devem ser gravadas em vários locais (um arquivo e um console).
Uma primeira implementação
Vamos começar pequeno: a implementação inicial aceitará novas mensagens e as escreverá usando qualquer delegado anexado. Você pode começar com um delegado que grava mensagens no console.
public static class Logger
{
public static Action<string>? WriteMessage;
public static void LogMessage(string msg)
{
if (WriteMessage is not null)
WriteMessage(msg);
}
}
A classe estática acima é a coisa mais simples que pode funcionar. Precisamos escrever a única implementação para o método que grava mensagens no console:
public static class LoggingMethods
{
public static void LogToConsole(string message)
{
Console.Error.WriteLine(message);
}
}
Finalmente, você precisa conectar o delegado anexando-o ao delegado WriteMessage declarado no logger:
Logger.WriteMessage += LoggingMethods.LogToConsole;
Práticas
Nossa amostra até agora é bastante simples, mas ainda demonstra algumas das diretrizes importantes para projetos envolvendo delegados.
O uso dos tipos de delegados definidos na estrutura principal facilita o trabalho dos usuários com os delegados. Você não precisa definir novos tipos, e os desenvolvedores que usam sua biblioteca não precisam aprender novos tipos de delegados especializados.
As interfaces usadas são tão mínimas e flexíveis quanto possível: Para criar um novo registrador de saída, você deve criar um método. Esse método pode ser um método estático ou um método de instância. Pode ter qualquer acesso.
Formatar saída
Vamos tornar esta primeira versão um pouco mais robusta e, em seguida, começar a criar outros mecanismos de registro.
Em seguida, vamos adicionar alguns argumentos ao método para LogMessage()
que sua classe de log crie mensagens mais estruturadas:
public enum Severity
{
Verbose,
Trace,
Information,
Warning,
Error,
Critical
}
public static class Logger
{
public static Action<string>? WriteMessage;
public static void LogMessage(Severity s, string component, string msg)
{
var outputMsg = $"{DateTime.Now}\t{s}\t{component}\t{msg}";
if (WriteMessage is not null)
WriteMessage(outputMsg);
}
}
Em seguida, vamos usar esse Severity
argumento para filtrar as mensagens que são enviadas para a saída do log.
public static class Logger
{
public static Action<string>? WriteMessage;
public static Severity LogLevel { get; set; } = Severity.Warning;
public static void LogMessage(Severity s, string component, string msg)
{
if (s < LogLevel)
return;
var outputMsg = $"{DateTime.Now}\t{s}\t{component}\t{msg}";
if (WriteMessage is not null)
WriteMessage(outputMsg);
}
}
Práticas
Você adicionou novos recursos à infraestrutura de registro. Como o componente do registrador é acoplado muito vagamente a qualquer mecanismo de saída, esses novos recursos podem ser adicionados sem impacto em qualquer código que implementa o delegado do registrador.
À medida que você continua construindo isso, você verá mais exemplos de como esse acoplamento solto permite maior flexibilidade na atualização de partes do site sem alterações em outros locais. Na verdade, em um aplicativo maior, as classes de saída do logger podem estar em um assembly diferente e nem precisam ser reconstruídas.
Construir um segundo motor de saída
O componente Log está vindo bem. Vamos adicionar mais um mecanismo de saída que registra mensagens em um arquivo. Este será um motor de saída um pouco mais envolvido. Será uma classe que encapsula as operações de arquivo e garante que o arquivo seja sempre fechado após cada gravação. Isso garante que todos os dados sejam liberados no disco depois que cada mensagem for gerada.
Aqui está o registrador baseado em arquivo:
public class FileLogger
{
private readonly string logPath;
public FileLogger(string path)
{
logPath = path;
Logger.WriteMessage += LogMessage;
}
public void DetachLog() => Logger.WriteMessage -= LogMessage;
// make sure this can't throw.
private void LogMessage(string msg)
{
try
{
using (var log = File.AppendText(logPath))
{
log.WriteLine(msg);
log.Flush();
}
}
catch (Exception)
{
// Hmm. We caught an exception while
// logging. We can't really log the
// problem (since it's the log that's failing).
// So, while normally, catching an exception
// and doing nothing isn't wise, it's really the
// only reasonable option here.
}
}
}
Depois de criar essa classe, você pode instanciá-la e anexar seu método LogMessage ao componente Logger:
var file = new FileLogger("log.txt");
Estes dois aspetos não se excluem mutuamente. Você pode anexar ambos os métodos de log e gerar mensagens para o console e um arquivo:
var fileOutput = new FileLogger("log.txt");
Logger.WriteMessage += LoggingMethods.LogToConsole; // LoggingMethods is the static class we utilized earlier
Mais tarde, mesmo no mesmo aplicativo, você pode remover um dos delegados sem quaisquer outros problemas para o sistema:
Logger.WriteMessage -= LoggingMethods.LogToConsole;
Práticas
Agora, você adicionou um segundo manipulador de saída para o subsistema de registro. Este precisa de um pouco mais de infraestrutura para suportar corretamente o sistema de arquivos. O delegado é um método de instância. É também um método privado. Não há necessidade de maior acessibilidade porque a infraestrutura delegada pode conectar os delegados.
Em segundo lugar, o design baseado em delegados permite vários métodos de saída sem qualquer código extra. Você não precisa criar nenhuma infraestrutura adicional para dar suporte a vários métodos de saída. Eles simplesmente se tornam outro método na lista de invocação.
Preste especial atenção ao código no método de saída de log de arquivos. Ele é codificado para garantir que não lance exceções. Embora isso nem sempre seja estritamente necessário, muitas vezes é uma boa prática. Se qualquer um dos métodos delegados lançar uma exceção, os delegados restantes que estão na invocação não serão invocados.
Como última nota, o registrador de arquivos deve gerenciar seus recursos abrindo e fechando o arquivo em cada mensagem de log. Você pode optar por manter o arquivo aberto e implementar IDisposable
para fechá-lo quando estiver concluído.
Qualquer um dos métodos tem suas vantagens e desvantagens. Ambos criam um pouco mais de acoplamento entre as classes.
Nenhum dos códigos na Logger
classe precisaria ser atualizado para oferecer suporte a qualquer cenário.
Manipular delegados nulos
Finalmente, vamos atualizar o método LogMessage para que ele seja robusto para os casos em que nenhum mecanismo de saída é selecionado. A implementação atual lançará um NullReferenceException
quando o WriteMessage
delegado não tiver uma lista de invocação anexada.
Você pode preferir um design que continue silenciosamente quando nenhum método tiver sido anexado. Isso é fácil usando o operador condicional nulo, combinado com o Delegate.Invoke()
método:
public static void LogMessage(string msg)
{
WriteMessage?.Invoke(msg);
}
O operador condicional nulo (?.
) curto-circuita quando o operando esquerdo (WriteMessage
neste caso) é nulo, o que significa que nenhuma tentativa é feita para registrar uma mensagem.
Você não encontrará o método listado Invoke()
na documentação para System.Delegate
ou System.MulticastDelegate
. O compilador gera um método de tipo seguro Invoke
para qualquer tipo de delegado declarado. Neste exemplo, isso significa Invoke
que usa um único string
argumento e tem um tipo de retorno vazio.
Resumo das práticas
Você viu o início de um componente de log que pode ser expandido com outros gravadores e outros recursos. Usando delegados no design, esses diferentes componentes são acoplados de forma flexível. Isso proporciona várias vantagens. É fácil criar novos mecanismos de saída e anexá-los ao sistema. Esses outros mecanismos só precisam de um método: o método que grava a mensagem de log. É um design que é resiliente quando novos recursos são adicionados. O contrato necessário para qualquer escritor é implementar um método. Esse método pode ser estático ou de instância. Pode ser público, privado ou qualquer outro acesso legal.
A classe Logger pode fazer qualquer número de melhorias ou alterações sem introduzir alterações de rutura. Como qualquer classe, você não pode modificar a API pública sem o risco de interromper as alterações. Mas, como o acoplamento entre o logger e quaisquer mecanismos de saída é apenas através do delegado, nenhum outro tipo (como interfaces ou classes base) está envolvido. O acoplamento é o menor possível.