Safetica Blogs

Azure Functions: una service factory que cumple SOLID

Escrito por Sample HubSpot User | 13-mar-2023 19:00:00

El problema

Durante la implementación de una funcionalidad en mi trabajo me encontré con un problema:

  • Tenía varias implementaciones de una misma interfaz de servicio y necesitaba inyectar una de ellas según el tipo de dato a procesar.
  • Estas implementaciones tenían a su vez otras dependencias, así que necesitaba una factory que me construyera todo el árbol de dependencias usando la inyección de dependencias de .NET.
  • La factory también debía ser fácilmente extensible, sin tener que modificar el código existente.

Con estos criterios en mente, busqué inspiración en internet. Por desgracia, no encontré nada útil. Así que decidí resolver el problema por mi cuenta y compartir la idea contigo.

A modo de demostración, he creado un proyecto de ejemplo disponible en GitHub. En este artículo te guiaré por el código y explicaré la idea para que puedas aplicarla en tu propio proyecto.

La función

El ejemplo es la función AreaCalculator, activada vía HTTP, que puede calcular el área de figuras geométricas comunes. Se invoca mediante el método POST con la ruta calculateArea/{shape}. El parámetro de ruta {shape} representa la forma para la cual se calculará el área. Los datos de la forma se especifican como JSON en el cuerpo de la petición.

Como puedes ver, aquí estamos inyectando IAreaCalculatorFactory. La factory proporciona la implementación adecuada del calculador en función del tipo de forma extraído del parámetro de ruta. El calculador entonces calcula el área de la forma específica, y el resultado se devuelve como JSON en la respuesta OK.

public class AreaCalculator
{
    private readonly IAreaCalculatorFactory _calculatorFactory;
    private readonly ILogger<AreaCalculator> _logger;

    public AreaCalculator(
        IAreaCalculatorFactory calculatorFactory,
        ILogger<AreaCalculator> log)
    {
        _calculatorFactory = calculatorFactory;
        _logger = log;
    }

    [FunctionName(nameof(AreaCalculator))]
    [OpenApiOperation(operationId: "Run", tags: new[] { "name" })]
    [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
    [OpenApiParameter(
        name: "shape",
        In = ParameterLocation.Path,
        Required = true,
        Type = typeof(string),
        Description = "The shape (circle, square, rectangle, or triangle)"
    )]
    [OpenApiResponseWithBody(
        statusCode: HttpStatusCode.OK,
        contentType: "application/json",
        bodyType: typeof(AreaResult),
        Description = "The area of the specified shape, rounded to 2 decimal places"
    )]
    public async Task<IActionResult> RunAsync(
        [HttpTrigger(AuthorizationLevel.Function, "post", Route = "calculateArea/{shape}")]
        HttpRequest req,
        string shape,
        CancellationToken ct)
    {
        _logger.LogInformation("Executing area calculator for shape: {shape}", shape);

        if (!Enum.TryParse(shape, true, out Shape shapeType))
            return new BadRequestObjectResult($"{shape} shape not supported");

        var calculator = _calculatorFactory.GetAreaCalculatorForShape(shapeType);
        var result = await calculator.CalculateAreaAsync(req.Body, ct);

        return new OkObjectResult(new AreaResult(result));
    }
}

La service factory

La factory del calculador de áreas es directa. Mantiene un diccionario de servicios calculadores registrados para cada tipo de forma disponible e implementa el método GetAreaCalculatorForShape. Este método recupera los servicios calculadores registrados directamente del IServiceProvider según el tipo de forma proporcionado.

public class AreaCalculatorFactory : IAreaCalculatorFactory
{
    private readonly Dictionary<Shape, Type> _registeredCalculators;
    private readonly IServiceProvider _serviceProvider;

    public AreaCalculatorFactory(
        IServiceProvider serviceProvider,
        Dictionary<Shape, Type> registeredCalculators)
    {
        _registeredCalculators = registeredCalculators;
        _serviceProvider = serviceProvider;
    }

    public IAreaCalculatorService GetAreaCalculatorForShape(Shape shape)
    {
        if (!_registeredCalculators.TryGetValue(shape, out var calculatorType) || calculatorType == null)
            throw new InvalidOperationException($"No calculator registered for shape {shape}");

        return (IAreaCalculatorService)_serviceProvider.GetRequiredService(calculatorType);
    }
}

El builder de la service factory

Quizás te preguntes cómo inicializar y registrar todo esto fácilmente para poder inyectarlo donde haga falta. El service provider que se inyecta en la factory es inmutable y debe contener ya todos los servicios calculadores y su árbol de dependencias.

Entonces, ¿dónde y cómo se registran los calculadores? La respuesta es AreaCalculatorFactoryBuilder. Recibe la colección de servicios en su constructor, lo que significa que puedes registrar servicios calculadores específicos para tipos de forma concretos y guardar el mapeo Shape → IAreaCalculatorService al mismo tiempo mediante el método RegisterCalculatorForType.

public class AreaCalculatorFactoryBuilder
{
    private readonly Dictionary<Shape, Type> _registeredCalculators;
    private readonly IServiceCollection _services;

    public AreaCalculatorFactoryBuilder(IServiceCollection services)
    {
        _registeredCalculators = new Dictionary<Shape, Type>();
        _services = services;
    }

    public AreaCalculatorFactoryBuilder RegisterCalculatorForType<TCalculator>(Shape shape)
        where TCalculator : IAreaCalculatorService
    {
        _services.AddScoped(typeof(TCalculator));
        _registeredCalculators.Add(shape, typeof(TCalculator));

        return this;
    }

    public IAreaCalculatorFactory Build(IServiceProvider serviceProvider)
    {
        return new AreaCalculatorFactory(serviceProvider, _registeredCalculators);
    }
}

Registro de servicios

La factory y sus servicios se registran como se muestra a continuación. AreaCalculatorFactoryBuilder se inicializa de antemano, y el método Build se invoca dentro del delegate del service provider cuando se necesita la implementación de IAreaCalculatorFactory.

También se aprecia el registro del parser JSON personalizado utilizado por los servicios calculadores para deserializar el stream del cuerpo de la petición y convertirlo en los datos necesarios para calcular el área. Esto demuestra que los servicios proporcionados por la factory pueden tener otras dependencias inyectadas mediante la inyección de dependencias de .NET.

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddServices(this IServiceCollection services)
    {
        // Parsers
        services.AddScoped<ICustomParser, ShapeParser>();

        // Area calculators
        var areaCalculatorFactoryBuilder = new AreaCalculatorFactoryBuilder(services)
            .RegisterCalculatorForType<TriangleAreaCalculator>(Shape.Triangle)
            .RegisterCalculatorForType<SquareAreaCalculator>(Shape.Square)
            .RegisterCalculatorForType<RectangleAreaCalculator>(Shape.Rectangle)
            .RegisterCalculatorForType<CircleAreaCalculator>(Shape.Circle);

        services.AddScoped(x => areaCalculatorFactoryBuilder.Build(x));

        return services;
    }
}

Explicación del código restante

Por último, una breve explicación de las implementaciones de CircleAreaCalculator y ShapeParser. Como puedes ver, CircleAreaCalculator es una implementación de la clase abstracta AreaCalculatorBase. AreaCalculatorBase se encarga del parseo y proporciona los datos a las implementaciones específicas para que estas puedan realizar los cálculos requeridos. El parser utiliza el serializador JSON integrado de .NET con opciones personalizadas para deserializar el stream al tipo requerido.

public class CircleAreaCalculator : AreaCalculatorBase<Circle>
{
    public CircleAreaCalculator(ICustomParser shapeParser) : base(shapeParser) { }

    protected override double CalculateArea(Circle circle)
        => Math.Pow(circle.R, 2) * Math.PI;
}

public abstract class AreaCalculatorBase<TShape> : IAreaCalculatorService
{
    private readonly ICustomParser _shapeParser;

    public AreaCalculatorBase(ICustomParser shapeParser)
    {
        _shapeParser = shapeParser;
    }

    private async Task<TShape> GetShapeAsync(Stream stream, CancellationToken ct)
        => await _shapeParser.ParseJsonBodyAsync<TShape>(stream, ct);

    public async Task<double> CalculateAreaAsync(Stream stream, CancellationToken ct)
        => CalculateArea(await GetShapeAsync(stream, ct));

    protected abstract double CalculateArea(TShape shape);
}

public class ShapeParser : ICustomParser
{
    private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };

    public async Task<T> ParseJsonBodyAsync<T>(Stream stream, CancellationToken ct = default)
    {
        T? val = await JsonSerializer.DeserializeAsync<T>(stream, SerializerOptions, ct);

        if (val == null)
        {
            throw new JsonException("Null payload");
        }

        return val;
    }
}

Resumen

La solución resultante es:

  • Fácil de usar: utiliza la inyección de dependencias integrada de .NET.
  • Abierta a la extensión, cerrada a la modificación: se puede añadir soporte para nuevas formas sin cambiar el código existente.
  • Sencilla de implementar: puede hacerse en tres pasos:
  1. Implementar una service factory usando IServiceProvider para aprovechar la inyección de dependencias de .NET.
  2. Implementar un builder de la service factory para simplificar el registro de servicios y la creación de la factory.
  3. Proporcionar un delegate de la service factory que construya la factory necesaria cuando se requiera.

El proyecto de ejemplo se puede ejecutar en local. Basta con arrancar la function app y abrir http://localhost:{function-app-port}/api/swagger/ui en el navegador. Allí puedes usar Swagger UI para hacer llamadas a la API.