dazinator / Dotnettency

Mutlitenancy for dotnet applications
MIT License
111 stars 23 forks source link

Default IConfiguration based tenant implementation #68

Open dazinator opened 4 years ago

dazinator commented 4 years ago

This will come in two parts.

The first part will be to provide a default way out of the box for identifying tenants that is based on configurable pattern matching on the incoming request Authority (essentially hostname or ip address:port portion). This is because this is the most common way tenants are mapped in my experience.

To achieve this, we will first need to model a class structure to use with the Microsoft.Extensions.Options system and that can therefore be bound to IConfiguration.

I am thinking of something like the following:


public class TenantMappingOptions<TKey>
{
    List<TenantMapping<TKey>> TenantMappings { get; }  
}

public class TenantMapping<TKey>
{
    TKey Key { get; }
    List<string> Patterns{ get; }  
}

Note TKey - i've left that generic because this key will be then be used to lookup the current TenantShell for an inbound request from a cache (if not present the tenant's TenantShell is initialised), so the datatype chosen can have an impact on performance in terms of lookup from the cache. Some might be happy with a GUID, some might want an integer, so a small int for those with not many tenants etc etc. The important thing is that this will be a unique value that identifies the tenant.

The patterns are glob patterns (I use my own dotnet.glob library for evaluating these), that will be evaluated against the inbound request Authority: https://docs.microsoft.com/en-us/dotnet/api/system.uri.authority?view=netcore-3.1 when any pattern matches, then the tenant is identified, and the corresponding tenant Key is returned as an identifier for that tenant.

This class structure will map to the following json config:

{
   TenantMappings : [     
        { 
            Key: 1,
            Patterns: ["**.foo.com"]
        }
    ]
}

If you used the same TKey value for multiple entries in the mapping, then they would in effect map to the same tenant so you might as well condense the mapping into one, for example:

{
   TenantMappings : [     
        { 
            Key: 1,
            Patterns: ["**.foo.com"]
        },
       { 
            Key: 1,
            Patterns: ["**.bar.com"]
        }
    ]
}

might as well be:

{
   TenantMappings : [     
        { 
            Key: 1,
            Patterns: ["**.foo.com","**.bar.com"]
        }
    ]
}

In order to start using this you'd do something like this in startup:


var tenantConfig = new ConfgurationBuilder();
tenantConfig.AddJsonFile("/tenant-mappings.json");
var config = config.Build();

services.AddMultiTenancy<MyTenant>((multitenancyOptions) =>
            {
                multitenancyOptions
                .AddAspNetCore()
                .IdentifyTenantsUsingRequestAuthorityMapping<TenantMappingOptions>(config)

This will register a custom IdentifierFactory that will have an IOptionsMonitor<TenantMappingOptions> injected, that it will use to evaluate the patterns on an incoming request and to return the matching tenants TKey value as the TenantIdentifier. This basically has to be a Uri but it can be any value, so in this case the Uri will contain the TKey as a string in some portion of it easily retreived. If a matching TKey cannot be found it will set a special string value / constant instead of the key value, so that no match can be determined. Because this uses IOptionsMontitor.CurrentValue - it will always be using the latest configuration when evaluating an incoming request, this means the configuration can change whilst the application is running and it should take effect immediately without a restart.

The next piece of the puzzle is to provide a new base class for a TenantShellFactory that can load a TenantShell based on the TKey supplied in the TenantIdentifier when using this new mechanism.

 public abstract class MappedTenantShellFactory<TTenant, TKey> : ITenantShellFactory<TTenant, TKey>
 {
        public Task<TenantShell<TTenant>> Get(TenantIdentifier identifier)
        {          
             // This method only gets invoked once when the tenant shell needs to be created as it's not in the cache, so although performance is important, this is not a critical path.
// this is only example code at this point:
             var keyString = identifier.Uri.Host; // this will be a string representation of TKey.
             TTenant tenant = null;
             if(NoTenantMatched(keyString)) // probably use a null or special string value to indicate no tenant key matched.
             {
                tenant  = GetDefaultTenant(identifier);                
             }
            else
            { 
               TKey tenantKey = (T)Convert.ChangeType(keyString, typeof(TKey)); 
               tenant  = GetTenant(tenantKey);             
            }   
            return GetTenantShell(identifier, tenant);               
        }

      protected abstract TTenant GetTenant(TKey key);

      protected virtual TTenant GetDefaultTenant(TenantIdentifier key)
      {
          return default(TTenant);
      }

       protected virtual TenantShell<TTenant> GetTenantShell(TenantIdentifier identifier, TTenant tenant)
       {
               var tenantShell = new TenantShell<TTenant>(tenant); // could be null tenant, by default we initialise one shell to serve default tenants. That shell could be configured with default middleware like a welcome page etc.
               return Task.FromResult(tenantShell);           
       }
}

You'd implement this class something like so in your application:

public class MyEfCoreTenantShellFactory : MappedTenantShellFactory<MyTenant, int>
{
   protected override MyTenant GetTenant(int key)
   {
         using(dbContext = new GetDbContext())
         {
             var tenant = dbContext.FirstOrDefault(a=> a.Id == key).Select(a=>new MyTenant() {Name = a.Name}); // etc etc
             return tenant;
        }
   }
}

You could also override GetDefaultTenant if you still want to create a default instance of TTenant class to represent a non-mapped tenant. The default behaviour will be to return a instance NULL which means if injecting Task<TTenant> into controllers etc, if the current tenant has not been mapped successfully, the Task.Result will be a NULL instance. By overriding GetDefaultTenant you could return a new TTenant {Name = "Unknown"} etc or whatever you need.

So usage in your application then becomes something like:


var tenantConfig = new ConfgurationBuilder();
tenantConfig.AddJsonFile("/tenant-mappings.json");
var config = config.Build();

services.AddMultiTenancy<MyTenant>((multitenancyOptions) =>
            {
                multitenancyOptions
                .AddAspNetCore()
                .MapTenantsByRequestAuthority<int, TenantMappingOptions<int>, MyEfCoreTenantShellFactory>(config)

Note: MapTenantsByRequestAuthority is sugar that does the equivalent of:

  multitenancyOptions
                ///... 
                .IdentifyTenantsUsingRequestAuthorityMapping<TenantMappingOptions>(config)
                .InitialiseTenant<MyEfCoreTenantShellFactory>()

Most likely the default non generic overload will be GUID based keys:

   multitenancyOptions
                .AddAspNetCore()
                .MapTenantsByRequestAuthority<TenantMappingOptions<Guid>, MyTenantShellFactory>(config)

What does this enable

Once dotnettency can be hooked up to the IConfiguration / IOptions system like this, you could easily plugin something like apache zookepper for managing tenants accross a cluster.

For example, if a new tenant is added to the zookeeper record, this will be distributed and become available to all nodes.

That would be a case of using something like this: https://github.com/fglaeser/Extensions.Configuration.Zookeeper for this.

dazinator commented 4 years ago

This feature will need to evolve a bit, as I found I need to map different startups.

For example, I'd like something like the following:

mappings.json:

TenantMappings : [     
        { 
            Key: -1,
            Patterns: ["**"]
            Condition: { "Name": IsSystemSetup", "Value": false }
            Startup: "SystemSetup"
        },
       { 
            Key: 1,
            Patterns: ["**.localhost.com", "localhost.com"]
        }
    ]

and

.SetMappings("/mappings.json")
.AddCondition("IsSystemSetup", (sp)=> false)
.RegisterStartup<SystemSetupStartup>("SystemSetup")
 RegisterStartup<TenantStartup>(); // default startup

So, when the "IsSystemSetup" condition returns false, this enables the following mapping:

{ 
            Key: -1,
            Patterns: ["**"]
            Condition: { "Name": IsSystemSetup", "Value": false }
            Startup: "SystemSetup"
        },

Which matches all domains, and specifies "Startup: "SystemSetup" as the startup type. If this tenant has not yet been initialised, this will result in this startup type being activated: .RegisterStartup<SystemSetupStartup>("SystemSetup")

And you'd write that something like:

public class SystemSetupStartup<Tenant>
{    
   private readonly ILogger<SystemSetupStartup> _logger;

    public SystemSetupStartup(ILogger<SystemSetupStartup> logger)
    {
       _logger= logger; // can use DI in this class as normal as its resolved at request scope.
    }

    public Tenant LoadTenant()
    {
        return new Tenant() {  Name: "this is your tenant class, use whatever properties you like to have handy for the tenant at runtime." }
    }
}

Then TenantStartup will only be used when System Setup process has been completed. Because of this, it can consume dependencies that are only safe to use once setup has been completed, such as things that require a configured database etc.

public class TenantStartup<Tenant, int>
{    
   private readonly MyDbContext  _dbContext;

    public SystemSetupStartup(MyDbContext dbContext)
    {
       _dbContext= dbContext; // can use this safely because we know system setup is complete
    }

    public Tenant LoadTenant(int key)
    {   
        var tenant = dbContext.Tenants.Find(key);
        return tenant;
    }
}

I'm thinking of referring to these classes as forms of TenantStartup classes, because I am thinking these could be extended in future, potentially so that all per tenant configuration is implemented within them.

public interface ITenantStartup<TKey, TTenant>
   where TTenant: class
{

     TTenant LoadTenant(TKey key);
     void ConfigureTenant(ITenantStartupBuilder<TTenant> builder);

}
public interface ITenantStartupBuilder<TTenant>
   where TTenant: class
{

}
public static class TenantStartupBuilderContainerExtensions
{
          // Adds something that can configure this tenants IServiceCollection when the tenants container is built.
          public static AddConfigureServices(IConfigureServices configureServices)
          { 
                // todo: 
          }
}

public static class TenantStartupBuilderMiddlewareExtensions
{
          // Adds something that can configure this tenants middleware when the middleware pipeline is built.
          public static AddConfigure(IConfigure configureMiddleware)
          { 
                // todo: 
          }
}

public static class TenantStartupBuilderConfigurationExtensions
{
          // Adds something that can configure this tenants IConfiguration when the tenants configuration is built.
          public static AddConfigureConfiguration(IConfigureConfiguration configureConfiguration)
          { 
                // todo: 
          }
}

But for now, that part will be beyond scope.