¿Cómo diseño efectivamente mi aplicación donde la mayoría de las clases dependen de ILogger?
Estoy inyectando un Logger
componente en todas mis clases. La mayoría de mis clases tienen la Logger
propiedad definida, excepto cuando hay una cadena de herencia (en ese caso solo la clase base tiene esta propiedad y todas las clases derivadas la usan). Cuando se crean instancias de estos a través del contenedor Windsor, se ILogger
les inyectará mi implementación . Actualmente estoy usando Property Inyección, porque inyectar todo en el constructor no se siente bien.
¿Puedo sacarlos del contenedor ya que son de naturaleza transitoria? ¿Tengo que registrarlos en el contenedor e inyectarlos en el constructor de la clase necesitada? Además, solo para una clase, no quiero crear TypedFactory
e inyectar la fábrica en la clase que la necesita.
Otro pensamiento que se me ocurrió fue new
que los necesitaban. Entonces, si los nuevo, tendré que crear una instancia manualmente Logger
en esas clases. ¿Cómo puedo seguir usando el contenedor para TODAS mis clases?
Ejemplos de registros de Windsor:
//Install QueueMonitor as Singleton
Container.Register(Component.For<QueueMonitor>().LifestyleSingleton());
//Install DataProcessor as Trnsient
Container.Register(Component.For<DataProcessor>().LifestyleTransient());
Container.Register(Component.For<Data>().LifestyleScoped());
Clases de ejemplo:
public class QueueMonitor
{
private dataProcessor;
public ILogger Logger { get; set; }
public void OnDataReceived(Data data)
{
// pull the dataProcessor from factory
dataProcessor.ProcessData(data);
}
}
public class DataProcessor
{
public ILogger Logger { get; set; }
public Record[] ProcessData(Data data)
{
// Data can have multiple Records
// Loop through the data and create new set of Records
// Is this the correct way to create new records?
// How do I use container here and avoid "new"
Record record = new Record(/*using the data */);
...
// return a list of Records
}
}
public class Record
{
public ILogger Logger { get; set; }
private _recordNumber;
private _recordOwner;
public string GetDescription()
{
Logger.LogDebug("log something");
// return the custom description
}
}
Preguntas:
¿ Cómo creo un nuevo
Record
objeto sin usar "nuevo"?QueueMonitor
esSingleton
, mientras queData
es "alcance". ¿ Cómo puedo inyectarData
en elOnDataReceived()
método?
A partir de los ejemplos que proporciona, es difícil ser muy específico, pero en general, cuando inyecta ILogger
instancias en la mayoría de los servicios, debe preguntarse dos cosas:
- ¿Registro demasiado?
- ¿Violo los principios SÓLIDOS?
1. ¿Me registro demasiado?
Estás registrando demasiado cuando tienes mucho código como este:
try
{
// some operations here.
}
catch (Exception ex)
{
this.logger.Log(ex);
throw;
}
Escribir código como este surge de la preocupación de perder información de error. Sin embargo, duplicar este tipo de bloques try-catch por todas partes no ayuda. Peor aún, a menudo veo que los desarrolladores inician sesión y continúan eliminando la última throw
declaración:
try
{
// some operations here.
}
catch (Exception ex)
{
this.logger.Log(ex); // <!-- No more throw. Execution will continue.
}
En la mayoría de los casos, esto es una mala idea (y huele como el antiguo ON ERROR RESUME NEXT
comportamiento de VB), porque en la mayoría de las situaciones simplemente no tienes suficiente información para determinar si es seguro continuar. A menudo hay un error en el código o un problema en un recurso externo como una base de datos que provocó que la operación fallara. Continuar significa que el usuario a menudo tiene la idea de que la operación se realizó correctamente, cuando no fue así. Pregúntese: ¿qué es peor, mostrar a los usuarios un mensaje de error genérico que dice que algo salió mal y pedirles que vuelvan a intentarlo, o ignorar silenciosamente el error y dejar que los usuarios piensen que su solicitud se procesó correctamente?
Piense en cómo se sentirán los usuarios si descubren dos semanas después que su pedido nunca se envió. Probablemente perderías un cliente. O peor aún, el registro de MRSA de un paciente falla silenciosamente, lo que provoca que el paciente no sea puesto en cuarentena por parte de enfermería y provoca la contaminación de otros pacientes, lo que provoca altos costos o incluso la muerte.
La mayoría de estos tipos de líneas try-catch-log deben eliminarse y simplemente debe dejar que la excepción aumente en la pila de llamadas.
¿No deberías iniciar sesión? ¡Absolutamente deberías hacerlo! Pero si puedes, define un bloque try-catch en la parte superior de la aplicación. Con ASP.NET, puede implementar el Application_Error
evento, registrarlo HttpModule
o definir una página de error personalizada que realice el registro. Con Win Forms la solución es diferente, pero el concepto sigue siendo el mismo: definir un único top que abarque todo.
A veces, sin embargo, aún desea detectar y registrar un determinado tipo de excepción. Un sistema en el que trabajé en el pasado permitía que la capa empresarial lanzara archivos ValidationExceptions
, que serían capturados por la capa de presentación. Esas excepciones contenían información de validación para mostrarla al usuario. Dado que esas excepciones quedarían atrapadas y procesadas en la capa de presentación, no aparecerían en la parte superior de la aplicación y no terminarían en el código general de la aplicación. Aún así, quería registrar esta información, solo para saber con qué frecuencia el usuario ingresó información no válida y para saber si las validaciones se activaron por el motivo correcto. Así que no se trataba de un registro de errores; simplemente registrando. Escribí el siguiente código para hacer esto:
try
{
// some operations here.
}
catch (ValidationException ex)
{
this.logger.Log(ex);
throw;
}
¿Luce familiar? Sí, se ve exactamente igual que el fragmento de código anterior, con la diferencia de que solo detecté ValidationException
excepciones. Sin embargo, hubo otra diferencia que no se puede ver con solo mirar este fragmento. ¡Solo había un lugar en la aplicación que contenía ese código! Era un decorador, lo que me lleva a la siguiente pregunta que deberías hacerte:
2. ¿Violo los principios SOLID?
Cosas como el registro, la auditoría y la seguridad se denominan preocupaciones (o aspectos) transversales . Se denominan transversales porque pueden abarcar muchas partes de su aplicación y, a menudo, deben aplicarse a muchas clases del sistema. Sin embargo, cuando descubre que está escribiendo código para su uso en muchas clases del sistema, lo más probable es que esté violando los principios SOLID. Tomemos, por ejemplo, el siguiente ejemplo:
public void MoveCustomer(int customerId, Address newAddress)
{
var watch = Stopwatch.StartNew();
// Real operation
this.logger.Log("MoveCustomer executed in " +
watch.ElapsedMiliseconds + " ms.");
}
Aquí mides el tiempo que lleva ejecutar la MoveCustomer
operación y registras esa información. Es muy probable que otras operaciones del sistema necesiten esta misma preocupación transversal. Comienzas a agregar código como este para tus casos de uso ShipOrder
, y otros, y esto genera una gran duplicación de código y, finalmente, una pesadilla de mantenimiento (yo he pasado por eso) CancelOrder
.CancelShipping
El problema con este código se remonta a una violación de los principios SOLID . Los principios SOLID son un conjunto de principios de diseño orientado a objetos que le ayudan a definir software flexible y mantenible (orientado a objetos). El MoveCustomer
ejemplo violó al menos dos de esas reglas:
- El principio de responsabilidad única (SRP): las clases deben tener una responsabilidad única. Sin embargo, la clase que contiene el
MoveCustomer
método no sólo contiene la lógica empresarial central, sino que también mide el tiempo que lleva realizar la operación. En otras palabras, tiene múltiples responsabilidades . - The Open-Closed principle (OCP)—it prescribes an application design that prevents you from having to make sweeping changes throughout the code base; or, in the vocabulary of the OCP, a class should be open for extension, but closed for modification. In case you need to add exception handling (a third responsibility) to the
MoveCustomer
use case, you (again) have to alter theMoveCustomer
method. But not only do you have to alter theMoveCustomer
method, but many other methods as well, as they will typically require that same exception handling, making this a sweeping change.
The solution to this problem is to extract the logging into its own class and allow that class to wrap the original class:
// The real thing
public class MoveCustomerService : IMoveCustomerService
{
public virtual void MoveCustomer(int customerId, Address newAddress)
{
// Real operation
}
}
// The decorator
public class MeasuringMoveCustomerDecorator : IMoveCustomerService
{
private readonly IMoveCustomerService decorated;
private readonly ILogger logger;
public MeasuringMoveCustomerDecorator(
IMoveCustomerService decorated, ILogger logger)
{
this.decorated = decorated;
this.logger = logger;
}
public void MoveCustomer(int customerId, Address newAddress)
{
var watch = Stopwatch.StartNew();
this.decorated.MoveCustomer(customerId, newAddress);
this.logger.Log("MoveCustomer executed in " +
watch.ElapsedMiliseconds + " ms.");
}
}
By wrapping the decorator around the real instance, you can now add this measuring behavior to the class, without any other part of the system to change:
IMoveCustomerService service =
new MeasuringMoveCustomerDecorator(
new MoveCustomerService(),
new DatabaseLogger());
The previous example did, however, just solve part of the problem (only the SRP part). When writing the code as shown above, you will have to define separate decorators for all operations in the system, and you'll end up with decorators like MeasuringShipOrderDecorator
, MeasuringCancelOrderDecorator
, and MeasuringCancelShippingDecorator
. This lead again to a lot of duplicate code (a violation of the OCP principle), and still needing to write code for every operation in the system. What's missing here is a common abstraction over use cases in the system.
What's missing is an ICommandHandler<TCommand>
interface.
Let's define this interface:
public interface ICommandHandler<TCommand>
{
void Execute(TCommand command);
}
And let's store the method arguments of the MoveCustomer
method into its own (Parameter Object) class called MoveCustomerCommand
:
public class MoveCustomerCommand
{
public int CustomerId { get; set; }
public Address NewAddress { get; set; }
}
TIP: This
MoveCustomerCommand
object becomes a message. That's why some postfix this type with 'Message', calling itMoveCustomerMessage
. Others tend to call itMoveCustomerRequest
, while others completely remove the post fix and simply call this parameter objectMoveCustomer
. When I wrote this answer initially, I used to use the 'Command' postfix, but nowadays, I tend to go with simplyMoveCustomer
. But whatever you choose, the power here lies in the separation between data (the command/message) and the behavior (the handler), as we'll see next.
And let's put the behavior of the MoveCustomer
method in a new class that implements ICommandHandler<MoveCustomerCommand>
:
public class MoveCustomerCommandHandler : ICommandHandler<MoveCustomerCommand>
{
public void Execute(MoveCustomerCommand command)
{
int customerId = command.CustomerId;
Address newAddress = command.NewAddress;
// Real operation
}
}
This might look weird at first, but because you now have a general abstraction for use cases, you can rewrite your decorator to the following:
public class MeasuringCommandHandlerDecorator<TCommand>
: ICommandHandler<TCommand>
{
private ILogger logger;
private ICommandHandler<TCommand> decorated;
public MeasuringCommandHandlerDecorator(
ILogger logger,
ICommandHandler<TCommand> decorated)
{
this.decorated = decorated;
this.logger = logger;
}
public void Execute(TCommand command)
{
var watch = Stopwatch.StartNew();
this.decorated.Execute(command);
this.logger.Log(typeof(TCommand).Name + " executed in " +
watch.ElapsedMiliseconds + " ms.");
}
}
This new MeasuringCommandHandlerDecorator<T>
looks much like the MeasuringMoveCustomerDecorator
, but this class can be reused for all command handlers in the system:
ICommandHandler<MoveCustomerCommand> handler1 =
new MeasuringCommandHandlerDecorator<MoveCustomerCommand>(
new MoveCustomerCommandHandler(),
new DatabaseLogger());
ICommandHandler<ShipOrderCommand> handler2 =
new MeasuringCommandHandlerDecorator<ShipOrderCommand>(
new ShipOrderCommandHandler(),
new DatabaseLogger());
This way it will be much, much easier to add cross-cutting concerns to your system. It's quite easy to create a convenient method in your Composition Root that can wrap any created command handler with the applicable command handlers in the system. For instance:
private static ICommandHandler<T> Decorate<T>(ICommandHandler<T> decoratee)
{
return
new MeasuringCommandHandlerDecorator<T>(
new DatabaseLogger(),
new ValidationCommandHandlerDecorator<T>(
new ValidationProvider(),
new AuthorizationCommandHandlerDecorator<T>(
new AuthorizationChecker(
new AspNetUserProvider()),
new TransactionCommandHandlerDecorator<T>(
decoratee))));
}
This method can be used as follows:
ICommandHandler<MoveCustomerCommand> handler1 =
Decorate(new MoveCustomerCommandHandler());
ICommandHandler<ShipOrderCommand> handler2 =
Decorate(new ShipOrderCommandHandler());
If your application starts to grow, however, it can get useful to bootstrap this with a DI Container, because a DI Container can support Auto-Registration. This prevents you from having to make changes to your Composition Root for every new command/handler pair you add to the system.
Most modern, mature DI Containers for .NET have fairly decent support for decorators, and especially Autofac (example) and Simple Injector (example) make it easy to register open-generic decorators.
Unity and Castle, on the other hand, have Dynamic Interception facilities (as Autofac does to btw). Dynamic Interception has a lot in common with decoration, but it uses dynamic-proxy generation under the covers. This can be more flexible than working with generic decorators, but you pay the price when it comes to maintainability, because you often loose type safety and interceptors always force you to take a dependency on the interception library, while decorators are type-safe and can be written without taking a dependency on an external library.
I've been using these types of designs for over a decade now and can't think of designing my applications without it. I've written extensively about these designs, and more recently, I coauthored a book called Dependency Injection Principles, Practices, and Patterns, which goes into much more detail on this SOLID programming style and the design described above (see chapter 10).