1

I have a microservice architecture with ASP.Net Core applications and RabbitMq as the event bus between the microservices.
I also want to support multi tenancy.
So I have following dependency injection service defined in the Startup.cs to open a connection to the Database on every request based on the user's tenant id.

services.AddScoped<IDocumentSession>(ds =>
            {
                var store = ds.GetRequiredService<IDocumentStore>();
                var httpContextAccessor = ds.GetRequiredService<IHttpContextAccessor>();
                var tenant = httpContextAccessor?.HttpContext?.User?.Claims.FirstOrDefault(c => c.Type == "tid")?.Value;
                return tenant != null ? store.OpenSession(tenant) : store.OpenSession();
            });

The problem is when the service processes an event bus message (like UserUpdatedEvent).
In that case when it tries to open the Db connection, it obviously does not have the user information from the http context.

How do I send/access the tenant id of the respective user when injecting the scoped service and processing an event with RabbitMq?

Or rephrasing my question: Is there any way to access the RabbitMQ message (and for example its headers) when the dependency injection code is executed?

Palmi
  • 2,381
  • 5
  • 28
  • 65

2 Answers2

7

Since there is no HttpContext, because a RabbitMq request is not a Http request, as pointed out in @istepaniuk's answer, I created my own context and called it AmqpContext:

public interface IAmqpContext
    {
        void ClearHeaders();
        void AddHeaders(IDictionary<string, object> headers);
        string GetHeaderByKey(string headerKey);
    }

    public class AmqpContext : IAmqpContext
    {
        private readonly Dictionary<string, object> _headers;

        public AmqpContext()
        {
            _headers = new Dictionary<string, object>();
        }

        public void ClearHeaders()
        {
            _headers.Clear();
        }

        public void AddHeaders(IDictionary<string, object> headers)
        {
            foreach (var header in headers)
                _headers.Add(header.Key, header.Value);
        }

        public string GetHeaderByKey(string headerKey) 
        {
            if (_headers.TryGetValue(headerKey, out object headerValue))
            {
                return Encoding.Default.GetString((byte[])headerValue);
            }
            return null;
        }
    }

And when sending the RabbitMq message I send the tenant id via the headers like this:

                    var properties = channel.CreateBasicProperties();
                    if (tenantId != null)
                    {
                        var headers = new Dictionary<string, object>
                        {
                            { "tid", tenantId }
                        };
                        properties.Headers = headers;
                    }

                    channel.BasicPublish(exchange: BROKER_NAME,
                                     routingKey: eventName,
                                     mandatory: true,
                                     basicProperties: properties,
                                     body: body);

Then when on the receiving service I register the AmqpContext as a scoped service in the Startup.cs:

services.AddScoped<IAmqpContext, AmqpContext>();

When receiving the RabbitMq message, within the consumer channel, a scope and the Amqp context is created:

consumer.Received += async (model, ea) =>
            {
                var eventName = ea.RoutingKey;
                var message = Encoding.UTF8.GetString(ea.Body);
                var properties = ea.BasicProperties;

                using (var scope = _serviceProvider.CreateScope())
                        {
                            var amqpContext = scope.ServiceProvider.GetService<IAmqpContext>();
                            if (amqpContext != null)
                            {
                                amqpContext.ClearHeaders();
                                if (properties.Headers != null && amqpContext != null)
                                {
                                    amqpContext.AddHeaders(properties.Headers);
                                }
                            }
                            var handler = scope.ServiceProvider.GetService(subscription.HandlerType);
                            if (handler == null) continue;
                            var eventType = _subsManager.GetEventTypeByName(eventName);
                            var integrationEvent = JsonConvert.DeserializeObject(message, eventType);
                            var concreteType = typeof(IIntegrationEventHandler<>).MakeGenericType(eventType);
                            await (Task)concreteType.GetMethod("Handle").Invoke(handler, new object[] { integrationEvent });
                        }

                channel.BasicAck(ea.DeliveryTag, multiple: false);
            };

Then when the scoped Db connection service is created (see my question) I can access the tenant id from the message headers:

    services.AddScoped<IDocumentSession>(ds =>
    {
        var store = ds.GetRequiredService<IDocumentStore>();
        string tenant = null;
        var httpContextAccessor = ds.GetRequiredService<IHttpContextAccessor>();
        if (httpContextAccessor.HttpContext != null)
        {
            tenant = httpContextAccessor.HttpContext.User?.Claims.FirstOrDefault(c => c.Type == "tid")?.Value;
        }
        else
        {
            var amqpContext = ds.GetRequiredService<IAmqpContext>();
            tenant = amqpContext.GetHeaderByKey("tid");
        }
        return tenant != null ? store.OpenSession(tenant) : store.OpenSession();
    });
Palmi
  • 2,381
  • 5
  • 28
  • 65
0

You can't

Or maybe, but not if your design depends on the HTTP context. As the .NET documentation on service lifetime states:

Scoped lifetime services are created once per client request (connection).

So from the point of view of your (HTTP) service, the request is an entry point that used container magic to, by means of the global HTTP context, set up your database per request, before any of your business logic. This does not seem to be the best design choice, especially if you plan to use this same logic outside of an HTTP request.

In contrast, your message consumer service is long-running; In this lifetime cycle, if your connection setup requires information from each message (tenant id) you can't solely rely on dependency injection.

The "right" way would be not to rely on global state in the HTTP context to set up the database connection. Set up a database context that works for all your tenants instead.

Community
  • 1
  • 1
istepaniuk
  • 4,016
  • 2
  • 32
  • 60
  • My design does not depend on the HTTP context. Even accessing the rabbitmq message during the dependency injection would help. – Palmi May 27 '19 at 15:35
  • @Palmi precicely, injection does not happen per-message (as it happens per-request). You cannot use a scoped service for this. Maybe this other question sheds some more light: https://stackoverflow.com/questions/23535569/managing-ravendb-idocumentsession-lifecycles-with-structuremap-for-nservicebus-a – istepaniuk May 27 '19 at 20:16
  • Yes. I always thought a message is basically a request. Somehow ASP Net Core treats a message like a request because every time a message is processed the scoped services are created... – Palmi May 27 '19 at 20:55
  • @Palmi, I would like to see how is that hooked up, I imagine you have to manually `CreateScope()` for that to be the case. Like on the answer here (which might also be of use for you) https://stackoverflow.com/questions/49728884/using-dbcontext-in-rabbitmq-consumer-singleton-service – istepaniuk May 28 '19 at 09:51
  • I don't call CreateScope(). Maybe it is because the IDocumentSession service is part of another service which is scoped as transient. – Palmi May 28 '19 at 20:30
  • 1
    Wait. Yes. You are right. There is a CreateScope() when processing the RabbitMQ messages. – Palmi May 28 '19 at 20:44