Gagnez du temps grâce aux applications multi-tenantes

écrit par yannick.nguedong publié le mercredi 5 décembre 2018
Écrire des applications multi-tenantes peut s’avérer très complexe. Il m’est arrivé par le passé d’en écrire avec plus ou moins de réussites. Dans cet article je vous propose une nouvelle approche (qui est le fruit de mes recherches et qui peut être subjective) avec ASP.NET Core en utilisant les notions de middleware, d’injection de dépendance.

Qu’est-ce qu’une application multi-tenante ?

En informatique, multi-tenant, ou multi-entité désigne un principe d'architecture logicielle permettant à un logiciel de servir plusieurs organisations clientes (tenant en anglais, ou locataire en français) à partir d'une seule installation. Elle s'oppose à une architecture multi-instance où chaque organisation cliente a sa propre instance d'installation logicielle (et/ou matérielle). Avec une architecture multi-tenante, un logiciel est conçu pour partitionner virtuellement ses données et sa configuration, et chaque organisation cliente travaille avec une instance virtuelle adaptée à ses besoins.

Wikipédia - https://fr.wikipedia.org/wiki/Multi-tenant

Pourquoi et comment construire une application Multi-tenante.

1 - Pourquoi devrait-on utiliser une application multi-tenante ?

Il nous arrive de développer et héberger des applications pour différents clients et force est de constater très souvent l'utilisation de la même base de code. Dans ce contexte une architecture multi-tenante serait adaptée parce qu’elle aurait plusieurs avantages :
  • Mutualisation des ressources
  • Réduction du coût de développement lors de l’acquisition d’un nouveau client
  • Facilité de déploiement, on déploie une fois pour tous les clients
  • Facilité de customisation
  • Très bonne qualité de service (sécurité, robuste et performant)
Attention l’utilisation du multi-tenant peut amener une complexité supplémentaire en termes de développement. Dans la suite de l’article le mot « Client » désignera aussi un « Tenant ».

2 - Comment mettre en place une application multi-tenante

J’ai développé une librairie disponible sur le gestionnaire de paquets NuGet à cette adresse et aussi sur Github à ce lien. Elle nous servira de base pour la suite de l'article. Cette libraire offre les fonctionnalités suivantes :
  • Ajout/suppression/mise à jour d’un Tenant à la volée sans redémarrer l’application
  • Middleware par Tenant
  • Container IOC par tenant
  • Création de services spécifique pour chaque Tenant
  • Séparation des logs pour chaque Tenant
Voyons comment la configurer tout en expliquant sa mécanique interne.

2.1 - Installation et utilisation sur un projet ASP.NET Core

Créons un nouveau projet ASP.NET Core 2.1 (version lors de la rédaction de l'article) et référençons le package NuGet Puzzle.Core.Multitenancy (cité précédemment). Dans votre fichier .csproj vous devriez avoir ceci :
<ItemGroup>
…
<PackageReference Include=" Puzzle.Core.Multitenancy" Version="1.0.0" />
…
</ItemGroup>
Ajoutons un fichier de configuration « MultitenancyOptions.json  » et déposons le à la racine du projet ou bien dans un dossier Configs (c’est une convention choisie que nous pouvons changer). Ce fichier contient la configuration pour chaque tenant. Un exemple de structure sera comme suit (un exemple de fichier de configuration se trouve ici):
{
  "MultitenancyOptions": {
    ...
    "Tenants": [
      {
        "Name": "Tenant 1",
        "Hostnames": [
          "localhost:47887",
          "localhost:44301"
        ],
        "Theme": "{DS}",
        "ConnectionString": "{TenantFolder}"
      },
      {
        "Name": "Tenant 2",
        "Hostnames": [
          ...
        ],
        "Theme": "",
        "ConnectionString": ""
      },
       ...
    ]
  }
}
La dernière étape consiste à remplacer le « UseStartup<Startup>  » par la ligne de code suivante .UseUnobtrusiveMulitenancyStartupWithDefaultConvention<Startup>()  dans le fichier «Progam.cs  » comme ceci (voir exemple complet ici):
public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
            return Microsoft.AspNetCore.WebHost
                  .CreateDefaultBuilder()
                  ...
                  <del>.UseStartup<Startup>()</del>
                  .UseUnobtrusiveMulitenancyStartupWithDefaultConvention<Startup>()
                  ;
}
Et voilà ! Votre application est devenue multi-tenante (ou presque). L'objet tenant correspondant peut ainsi être injecté ( Controller ,Vue etc ...). Nous verrons dans un prochain article comment customiser cette configuration.

2.2 - Configurer un service spécifique pour client donné

Il peut arriver que nous ayons besoin d’un service spécifique pour chaque client, par exemple avoir les fonctionnalités MVC pour le « client 1 » et pas pour le « client 2 ». Il suffit pour cela d’ajouter une méthode « ConfigurePerTenantServices(IServiceCollection services, AppTenant tenant) » dans le fichier Startup.cs et ensuite ajouter sa configuration spécifique. Voir exemple ci-dessous :
 public void ConfigurePerTenantServices(IServiceCollection services, AppTenant tenant)
 {
            if (tenant.Id.ToUpperInvariant().StartsWith("Tenant-1".ToUpperInvariant()))
            {
                services.AddMvc();
            }
            else if (tenant.Id.ToUpperInvariant() == "Tenant-2".ToUpperInvariant())
            {
            }
 }
Plus tard nous verrons comment injecter notre propre objet tenant en remplaçant l’objet « AppTenant  » par défaut. N.B: Il faut noter que cette méthode n'existe pas dans la version ASP.NET Core de Microsoft, c'est la libraire qui ajoute cette méthode par convention. Lançons l'application et naviguons aux urls correspondantes, nous aurons ceci comme résultat :  

2.3 - L’identification d’un Tenant

Le premier aspect du multi-tenant est l’identification. Ici l’identification est basée sur le HttpContext. Pour éviter de réinventer la roue sur cette partie j’ai utilisé le projet SaasKit disponible ici. Ce projet injecte un middleware  TenantResolutionMiddleware<TTenant> qui lui même dépend d'un resolver de Tenant. Un resolver de tenant est une interface qui définit comment le tenant va être identifié. Cette interface est définie comme suit :
public interface ITenantResolver<TTenant>
{     
   Task<TenantContext<TTenant>> ResolveAsync(HttpContext context);
}
Ensuite le middleware injecte le Tenant identifié dans le HttpContext pour qu'il puisse être exécuté par les autres middleware (Middleware,Controller etc ...) dans le pipeline. C'est pourquoi ce middleware doit être parmi les premiers à être exécuter dans le pipeline.
internal class TenantResolutionMiddleware<TTenant>
{
  ...
   public async Task Invoke(
          HttpContext httpContext,
          ILog<TenantResolutionMiddleware<TTenant>> logger, 
          ITenantResolver<TTenant> tenantResolver)
    {
           ...
                logger?.Debug($"Resolving TenantContext using {tenantResolver.GetType().Name}.");
                TenantContext<TTenant> tenantContext = await tenantResolver.ResolveAsync(httpContext).ConfigureAwait(false);

                if (tenantContext != null)
                {
                    logger?.Debug("TenantContext Resolved. Adding to HttpContext.");
                    httpContext?.SetTenantContext(tenantContext);
                }
                else
                {
                    logger?.Debug("TenantContext Not Resolved.");
                }

            await next.Invoke(httpContext).ConfigureAwait(false);
      }
        
 }
Il suffit juste de définir un objet « Tenant » et implémenter l'interface ITenantResolver<TTenant>. Voir exemple ci-dessous.
public class AppTenant
{
   public string Name { get; set; }
   public string Id => GenerateSlug(Name).ToLowerInvariant();
   public string[] Hostnames { get; set; }
   public string Theme { get; set; }
   public string ConnectionString { get; set; }
}
public abstract class MemoryCacheTenantResolver<TTenant> : ITenantResolver<TTenant>
{
      private Dictionary<string, string> mappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
      {
        { "localhost", "Tenant 1"},
        { "dev.local", "Tenant 2"}
      };

      async Task<TenantContext<TTenant>> ITenantResolver<TTenant>.ResolveAsync(HttpContext context)
      {
        string tenantName = null;
        TenantContext<AppTenant> tenantContext = null;

        if (mappings.TryGetValue(uri.Host, out tenantName))
        {
            tenantContext = new TenantContext<AppTenant>(new AppTenant { Name = tenantName, Hostnames = new[] { uri.Host } });
            tenantContext.Properties.Add("Created", DateTime.UtcNow);
        }

        return Task.FromResult(tenantContext);
       }

}
Ensuite nous mettons tout cela ensemble (lien complet ici) :

2.4 - Détails d’implémentation

Rappels sur le fonctionnement de démarrage d’une application ASP.NET Core

La méthode générique « UseStartup<TStartup>  » appelée dans IWebHostBuilder  appelle en dessous la méthode d’extension UseStartup(this IWebHostBuilder hostBuilder, Type startupType), le lien complet de la source se trouve ici.
 public static IWebHostBuilder UseStartup(this IWebHostBuilder hostBuilder, Type startupType)
 {
            var startupAssemblyName = startupType.GetTypeInfo().Assembly.GetName().Name;

            return hostBuilder
                .UseSetting(WebHostDefaults.ApplicationKey, startupAssemblyName)
                .ConfigureServices(services =>
                {
                    if (typeof(IStartup).GetTypeInfo().IsAssignableFrom(startupType.GetTypeInfo()))
                    {
                        services.AddSingleton(typeof(IStartup), startupType);
                    }
                    else
                    {
                        services.AddSingleton(typeof(IStartup), sp =>
                        {
                            var hostingEnvironment = sp.GetRequiredService<IHostingEnvironment>();
                            return new ConventionBasedStartup(StartupLoader.LoadMethods(sp, startupType, hostingEnvironment.EnvironmentName));
                        });
                    }
                });
}
Nous pouvons remarquer qu’elle vérifie d’abord si la classe Startup  implémente l’interface IStartup  (voir définition ici). Si oui, elle enregistre la classe telle quelle en singleton, sinon elle utilise une convention consistant à rechercher par réflexion les méthodes ConfigureServices  et Configure et les injecter en Delegate dans la classe ConventionBasedStartup. Pour approfondir le sujet vous pouvez regarder cette série d’articles ici.

Injection d'un Container IOC pour chaque Tenant

Le IWebHostBuilder construit le IWebHost en plusieurs étapes que nous allons résumer en deux grandes parties.
  • Ajout d’un Filtre dès la construction pour gérer la requête. Ici c’est AutoRequestServicesStartupFilter
  • AutoRequestServicesStartupFilter quant à lui ajoute un middleware RequestServicesContainerMiddleware avec dans son constructeur un IServiceScopeFactory, pour permettre aux services de requête d'être ajoutés au pipeline.
Pour aller loin regardez ici et . Comme vous pouvez le constater le système est conçu pour un seul tenant et cela se fait très tôt dans la construction des services associés, ce qui rend difficile l’implémentation du multi-tenant. Pour pallier à cette problématique nous allons injecter notre propre Filtre MultitenantRequestStartupFilter<TStartup, TTenant>  et court-circuiter AutoRequestServicesStartupFilter .
ServiceDescriptor descriptor = new ServiceDescriptor(
                            typeof(IStartupFilter),
                            sp => new MultitenantRequestStartupFilter<TStartup, TTenant>(),
                            ServiceLifetime.Transient);

services.Insert(0, descriptor);

internal sealed class MultitenantRequestStartupFilter<TStartup, TTenant> : IStartupFilter
         where TStartup : class
         where TTenant : class
{
        public MultitenantRequestStartupFilter()
        {
        }

        public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
        {
            return builder =>
            {
                ...
                builder.UseMultitenancy<TTenant>();

                // builder.UseMiddleware<TenantUnresolvedRedirectMiddleware<AppTenant>>("", false);
                builder.UseMiddleware<MultitenancyRequestServicesContainerMiddleware<TTenant>>();

                // builder.UsePerTenant<TStartup, TTenant>((ctx, innerBuilder) => { });

                next(builder);
            };
        }
}
Ce filtre à son tour va injecter MultitenancyRequestServicesContainerMiddleware<TTenant> qui va nous permettre de cour-circuiter le container IOC RequestServicesContainerMiddleware,
internal class MultitenancyRequestServicesContainerMiddleware<TTenant>
{
        ...
        public async Task Invoke(HttpContext httpContext)
        {
            Debug.Assert(httpContext != null, nameof(httpContext));

            TenantContext<TTenant> tenantContext = httpContext.GetTenantContext<TTenant>();
            if (tenantContext != null)
            {
                IServiceProvider existingRequestServices = httpContext.RequestServices;

                using (RequestServicesFeature feature = new RequestServicesFeature(httpContext,serviceFactoryForMultitenancy.Build(tenantContext).GetRequiredService<IServiceScopeFactory>()))
                {
                     ...
                        // Replace the request IServiceProvider created by IServiceScopeFactory
                        httpContext.RequestServices = feature.RequestServices;
                        await next.Invoke(httpContext).ConfigureAwait(false);
                    ...
                }
            }
        }
 }
La raison du fonctionnement de cette méthode est simple. La classe RequestServicesContainerMiddleware définie le IServiceProvider pour le HttpContext. Cependant elle effectue une vérification en amont pour savoir s'il n'a pas déjà été défini. Dans notre cas, la définition existant déjà elle n'est donc pas écrasé.
...
// All done if request services is set
if (existingFeature?.RequestServices != null)
{
    await _next.Invoke(httpContext);
    return;
}
...
Il nous reste à voir comment est construit le RequestServices pour chaque Tenant. Pour cela il nous faut un container qui puisse créer un container enfant (child container en anglais) et qu’on puisse le faire au Runtime. L’implémentation du container ASP.NET Core ne propose pas ces fonctionnalités, ce qui entraîne une complexité supplémentaire (des containers tels que Autofac ou StructureMap proposent ces fonctionnalités, mais je ne voulais pas une dépendance externe lors de la création de la librairie). Pour résoudre ce problème nous allons injecter dans MultitenancyRequestServicesContainerMiddleware l’interface IServiceFactoryForMultitenancy<TTenant>.
internal interface IServiceFactoryForMultitenancy<TTenant>
{
        IServiceProvider Build(TenantContext<TTenant> tenantContext);

        void RemoveAll();
}
Son implémentation est simple. En fonction du contexte Http et de l’identification d’un tenant, vu précédemment, nous allons construire un service spécifique pour chaque tenant.
public IServiceProvider Build(TenantContext<TTenant> tenantContext)
{
            string key = tenantContext.Id;

            IServiceProvider value = Cache.GetOrAdd(key, (k) =>
            {
                IServiceCollection serviceCollection = Services.Clone();

                // Add plugin tenant services to servicecollection.
                BuildAddTenantServiceCollection(serviceCollection, tenantContext.Tenant);

                // Add specific tenant services to servicecollection.
                ConfigurePerTenantServicesDelegate(serviceCollection, tenantContext.Tenant);
                return GetProviderFromFactory(serviceCollection, tenantContext);
            });

            return value;
}
Dans la méthode Build nous utilisons un Lazy et ConcurrentDictionary pour s’assurer du caractère thread-safe du traitement, d’une exécution unique et d’un lazy loading (pour plus de précision voir ici). Ensuite nous clonons le ServiceCollection et le sauvegardons pour chaque tenant.

Conclusion

Nous sommes arrivés au bout de ce premier article sur une longue série concernant le multi-tenant. La mise en place d’un tel système peut s’avérer complexe, mais avec les bons outils et ASP.NET Core (et la notion de middleware), il est possible de faciliter cette mise en oeuvre. Nous pouvons retenir que la construction d'une application multi-tenante se fait en trois grandes étapes :
  • Identification du tenant
  • Résolution du tenant
  • Création des services pour chaque tenant.
J’ai choisi le terme « Unobtrusive » dans le non de la librairie, car je voulais un outil (non intrusif) très facile à configurer et sans modification ou presque du code existant.

Ce qu'on peut faire pour améliorer la librairie

  • Faire une configuration personnalisée pour injecter un Tenant customisé
  • Ajout d’un Middleware pour prendre en compte la non résolution d’un Tenant
  • Dans la configuration des services par client nous avons vu la possibilité d'avoir plusieurs if pour chaque client (qui est presque un anti-pattern), ce qui serait fastidieux par exemple si nous avons 100 clients à configurer, la solution serait donc de développer un système de plugin configurable pour chaque client.
 public void ConfigurePerTenantServices(IServiceCollection services, AppTenant tenant)
 {
            if (tenant.Id.ToUpperInvariant().StartsWith("Tenant-1".ToUpperInvariant()))
            {
                services.AddMvc();
            }
            else if (tenant.Id.ToUpperInvariant() == "Tenant-2".ToUpperInvariant())
            {
            }
 }
  • Créer un système de Data Isolation pour chaque tenant.
  • La sécurité étant un point important, mise en place d’un système d’authentication et d’authorization avec Identity Server4.
  • Création d’une IHM pour ajouter/modifier/supprimer un Tenant avec VueJs.

Astuces

Lorsqu’on développe une application multi-tenante, il est préférable que son serveur web de développement puisse répondre à plusieurs urls différentes. Avec asp.net core la tâche se fait plus facilement. Nous pouvons le faire de plusieurs façons différentes :
  • Directement dans le code du Program.cs en utilsant le méthode UseUrls
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
 WebHost.CreateDefaultBuilder(args)
        .UseUrls("http://localhost:4000;http://localhost:4001;http://localhost:4002")
        .UseIISIntegration()
        .UseUnobtrusiveMulitenancyStartupWithDefaultConvention<Startup>(actionConfiguration: (action) => {
                   })
  • En ligne de commande
var config = new ConfigurationBuilder()
    .AddCommandLine(args)
    .Build();

 dotnet run --server.urls "http://localhost:5100;http://localhost:6101"
  • En variables d’environnements
[Environment]::SetEnvironmentVariable("XXX_SERVER.URLS", "http://localhost:5100")
  • Et la dernière avec un fichier externe « hosting.json » (c’est elle que je préfère).
Il suffit pour cela dans le fichier de démarrage de préciser où on doit trouver ce fichier. Vous trouverez un exemple dans le fichier source :
{
  "webroot": "wwwroot",
  "urls": "http://localhost:47887;http://localhost:60000;http://localhost:60001;http://localhost:51261;http://localhost:50000"
}
  Une fois fait , en cliquant sur "run ou dotnet run" dans visual studio ,vous auriez ceci : Il suffira alors d'accéder aux urls qui correspondent au différents tenants.  

Liens Utiles utilisés pour mes recherches et rédiger cet article

https://www.stevejgordon.co.uk/aspnetcore-anatomy-deep-dive-index https://andrewlock.net/exploring-middleware-as-mvc-filters-in-asp-net-core-1-1/ https://andrewlock.net/configuring-urls-with-kestrel-iis-and-iis-express-with-asp-net-core/ https://github.com/saaskit/saaskit https://stackoverflow.com/questions/38940241/autofac-multitenant-in-an-aspnet-core-application-does-not-seem-to-resolve-tenant http://benfoster.io/blog/how-to-configure-kestrel-urls-in-aspnet-core-rc2