diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Controllers/ProductsController.cs b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Controllers/ProductsController.cs new file mode 100644 index 0000000..eb57fe1 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Controllers/ProductsController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.DatabaseIsolationService.Entities; +using MultiTenancySample.DatabaseIsolationService.EntityFrameworks; + +namespace MultiTenancySample.DatabaseIsolationService.Controllers +{ + [Route("api/{tenant}/[controller]")] + [ApiController] + public class ProductsController(IDbContextFactory dbContextFactory) : ControllerBase + { + private readonly DatabaseIsolationServiceDbContext _dbContext = dbContextFactory.CreateDbContext(); + + [HttpGet] + public IActionResult GetProducts(string? keyword) + { + IQueryable products = _dbContext.Set(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + products = products.Where(p => p.Name.Contains(keyword)); + } + + return Ok(products); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Entities/Product.cs b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Entities/Product.cs new file mode 100644 index 0000000..e21a15b --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Entities/Product.cs @@ -0,0 +1,9 @@ +namespace MultiTenancySample.DatabaseIsolationService.Entities +{ + public class Product + { + public Guid Id { get; set; } + + public required string Name { get; set; } + } +} \ No newline at end of file diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/EntityFrameworks/DataSeeding.cs b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/EntityFrameworks/DataSeeding.cs new file mode 100644 index 0000000..d4309f8 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/EntityFrameworks/DataSeeding.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.DatabaseIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + +namespace MultiTenancySample.DatabaseIsolationService.EntityFrameworks +{ + public class DataSeeding(IDbContextFactory dbContext, ICurrentTenant currentTenant) + { + public async Task SeedDataAsync() + { + currentTenant.SetTenant("Tenant1"); + + DatabaseIsolationServiceDbContext dbContext1 = await dbContext.CreateDbContextAsync(); + + if (await dbContext1.Database.EnsureCreatedAsync()) + { + await dbContext1.AddRangeAsync(new List { + new() { Id = Guid.NewGuid(), Name = "Product1"}, + new() { Id = Guid.NewGuid(), Name = "Product3"}, + new() { Id = Guid.NewGuid(), Name = "Product5"} + }); + + await dbContext1.SaveChangesAsync(); + } + + currentTenant.SetTenant("Tenant2"); + + DatabaseIsolationServiceDbContext dbContext2 = await dbContext.CreateDbContextAsync(); + + if (await dbContext2.Database.EnsureCreatedAsync()) + { + await dbContext2.AddRangeAsync(new List + { + new() { Id = Guid.NewGuid(), Name = "Product2"}, + new() { Id = Guid.NewGuid(), Name = "Product4"}, + new() { Id = Guid.NewGuid(), Name = "Product6"} + }); + + await dbContext2.SaveChangesAsync(); + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/EntityFrameworks/DatabaseIsolationServiceDbContext.cs b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/EntityFrameworks/DatabaseIsolationServiceDbContext.cs new file mode 100644 index 0000000..cf32e91 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/EntityFrameworks/DatabaseIsolationServiceDbContext.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.DatabaseIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + + +namespace MultiTenancySample.DatabaseIsolationService.EntityFrameworks +{ + public class DatabaseIsolationServiceDbContext(DbContextOptions options, ICurrentTenant currentTenant, IConfiguration configuration) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(p => p.Name).HasMaxLength(32); + + base.OnModelCreating(modelBuilder); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (currentTenant.TenantId != null) + { + string? connectionString = configuration.GetConnectionString(currentTenant.TenantId); + + optionsBuilder.UseNpgsql(connectionString); + } + + base.OnConfiguring(optionsBuilder); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/MultiTenancySample.DatabaseIsolationService.csproj b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/MultiTenancySample.DatabaseIsolationService.csproj new file mode 100644 index 0000000..0354e42 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/MultiTenancySample.DatabaseIsolationService.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Program.cs b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Program.cs new file mode 100644 index 0000000..43c67a6 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Program.cs @@ -0,0 +1,42 @@ +using MultiTenancySample.DatabaseIsolationService.EntityFrameworks; +using MultiTenancySample.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextFactory(lifetime: ServiceLifetime.Scoped); + +builder.Services.AddScoped(); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddMultiTenancy(); + +var app = builder.Build(); + +app.UseMultiTenancy(); + +var serviceProvider = app.Services.CreateScope().ServiceProvider; +var dataSeeding = serviceProvider.GetRequiredService(); +await dataSeeding.SeedDataAsync(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Properties/launchSettings.json b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Properties/launchSettings.json new file mode 100644 index 0000000..62e6cf6 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:62504", + "sslPort": 44361 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5220", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7102;http://localhost:5220", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/appsettings.Development.json b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/appsettings.json b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/appsettings.json new file mode 100644 index 0000000..2870b1b --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.DatabaseIsolationService/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Tenant1": "Host=localhost;Port=5432;Database=DatabaseIsolationService1;Username=postgres;Password=postgres", + "Tenant2": "Host=localhost;Port=5432;Database=DatabaseIsolationService2;Username=postgres;Password=postgres" + } +} \ No newline at end of file diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Controllers/ProductsController.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Controllers/ProductsController.cs new file mode 100644 index 0000000..44a9d8a --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Controllers/ProductsController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.FieldIsolationService.Entities; +using MultiTenancySample.FieldIsolationService.EntityFrameworks; + +namespace MultiTenancySample.FieldIsolationService.Controllers +{ + [Route("api/{tenant}/[controller]")] + [ApiController] + public class ProductsController(IDbContextFactory dbContextFactory) : ControllerBase + { + private readonly FieldIsolationServiceDbContext _dbContext = dbContextFactory.CreateDbContext(); + + [HttpGet] + public IActionResult GetProducts(string? keyword) + { + IQueryable products = _dbContext.Set(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + products = products.Where(p => p.Name.Contains(keyword)); + } + + return Ok(products); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Entities/IMultiTenant.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Entities/IMultiTenant.cs new file mode 100644 index 0000000..d08b099 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Entities/IMultiTenant.cs @@ -0,0 +1,7 @@ +namespace MultiTenancySample.FieldIsolationService.Entities +{ + public interface IMultiTenant + { + string? TenantId { get; set; } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Entities/Product.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Entities/Product.cs new file mode 100644 index 0000000..9ce4077 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Entities/Product.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MultiTenancySample.FieldIsolationService.Entities +{ + public class Product : IMultiTenant + { + public Guid Id { get; set; } + + public required string Name { get; set; } + + [JsonIgnore] + public string? TenantId { get; set; } + } +} \ No newline at end of file diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/DataSeeding.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/DataSeeding.cs new file mode 100644 index 0000000..284097d --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/DataSeeding.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.FieldIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + +namespace MultiTenancySample.FieldIsolationService.EntityFrameworks +{ + public class DataSeeding(IDbContextFactory dbContext, ICurrentTenant currentTenant) + { + public async Task SeedDataAsync() + { + currentTenant.SetTenant("Tenant1"); + + FieldIsolationServiceDbContext dbContext1 = await dbContext.CreateDbContextAsync(); + + if (await dbContext1.Database.EnsureCreatedAsync()) + { + await dbContext1.AddRangeAsync(new List { + new() { Id = Guid.NewGuid(), Name = "Product1"}, + new() { Id = Guid.NewGuid(), Name = "Product3"}, + new() { Id = Guid.NewGuid(), Name = "Product5"} + }); + + await dbContext1.SaveChangesAsync(); + } + + currentTenant.SetTenant("Tenant2"); + + FieldIsolationServiceDbContext dbContext2 = await dbContext.CreateDbContextAsync(); + + if (await dbContext2.Set().IgnoreQueryFilters().CountAsync() < 6) + { + await dbContext2.AddRangeAsync(new List + { + new() { Id = Guid.NewGuid(), Name = "Product2"}, + new() { Id = Guid.NewGuid(), Name = "Product4"}, + new() { Id = Guid.NewGuid(), Name = "Product6"} + }); + + await dbContext2.SaveChangesAsync(); + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/EntityTypeBuilderQueryFilterExtensions.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/EntityTypeBuilderQueryFilterExtensions.cs new file mode 100644 index 0000000..c96dbf0 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/EntityTypeBuilderQueryFilterExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Query; +using System.Linq.Expressions; + +namespace MultiTenancySample.FieldIsolationService.EntityFrameworks +{ + public static class EntityTypeBuilderQueryFilterExtensions + { + /// + /// Support multiple HasQueryFilter calls on same entity type + /// https://github.com/dotnet/efcore/issues/10275 + /// + public static void AddQueryFilter(this EntityTypeBuilder entityTypeBuilder, Expression> expression) + { + ParameterExpression parameterType = Expression.Parameter(entityTypeBuilder.Metadata.ClrType); + Expression expressionFilter = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), parameterType, expression.Body); + + LambdaExpression? currentQueryFilter = entityTypeBuilder.Metadata.GetQueryFilter(); + if (currentQueryFilter is not null) + { + Expression currentExpressionFilter = ReplacingExpressionVisitor.Replace(currentQueryFilter.Parameters.Single(), parameterType, currentQueryFilter.Body); + expressionFilter = Expression.AndAlso(currentExpressionFilter, expressionFilter); + } + + LambdaExpression lambdaExpression = Expression.Lambda(expressionFilter, parameterType); + entityTypeBuilder.HasQueryFilter(lambdaExpression); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/FieldIsolationServiceDbContext.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/FieldIsolationServiceDbContext.cs new file mode 100644 index 0000000..46b209a --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/FieldIsolationServiceDbContext.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using MultiTenancySample.FieldIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + +namespace MultiTenancySample.FieldIsolationService.EntityFrameworks +{ + public class FieldIsolationServiceDbContext(DbContextOptions options, ICurrentTenant currentTenant) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(p => p.Name).HasMaxLength(32); + + foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) + { + if (entityType.ClrType.IsAssignableTo(typeof(IMultiTenant))) + { + modelBuilder.Entity(entityType.ClrType).AddQueryFilter(e => e.TenantId == currentTenant.TenantId); + } + } + + base.OnModelCreating(modelBuilder); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.AddInterceptors(new TenantSaveChangesInterceptor(currentTenant)); + + base.OnConfiguring(optionsBuilder); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/TenantSaveChangesInterceptor.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/TenantSaveChangesInterceptor.cs new file mode 100644 index 0000000..20435fb --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/EntityFrameworks/TenantSaveChangesInterceptor.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using MultiTenancySample.FieldIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + +namespace MultiTenancySample.FieldIsolationService.EntityFrameworks +{ + public class TenantSaveChangesInterceptor(ICurrentTenant currentTenant) : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + if (eventData.Context is not null) + { + MultiTenancyTracking(eventData.Context); + } + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + { + MultiTenancyTracking(eventData.Context); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void MultiTenancyTracking(DbContext dbContext) + { + IEnumerable> multiTenancyEntries = dbContext.ChangeTracker.Entries().Where(entry => entry.State == EntityState.Added || entry.State == EntityState.Modified); + + multiTenancyEntries?.ToList().ForEach(entityEntry => + { + entityEntry.Entity.TenantId ??= currentTenant.TenantId; + }); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/MultiTenancySample.FieldIsolationService.csproj b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/MultiTenancySample.FieldIsolationService.csproj new file mode 100644 index 0000000..0354e42 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/MultiTenancySample.FieldIsolationService.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Program.cs b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Program.cs new file mode 100644 index 0000000..520be5e --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Program.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.FieldIsolationService.EntityFrameworks; +using MultiTenancySample.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextFactory(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("Default")); +}, lifetime: ServiceLifetime.Scoped); + +builder.Services.AddScoped(); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddMultiTenancy(); + +var app = builder.Build(); + +app.UseMultiTenancy(); + +var serviceProvider = app.Services.CreateScope().ServiceProvider; +var dataSeeding = serviceProvider.GetRequiredService(); +await dataSeeding.SeedDataAsync(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Properties/launchSettings.json b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Properties/launchSettings.json new file mode 100644 index 0000000..664fccb --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:40935", + "sslPort": 44346 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5072", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7237;http://localhost:5072", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/appsettings.Development.json b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/appsettings.json b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/appsettings.json new file mode 100644 index 0000000..c53ae15 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.FieldIsolationService/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=FieldIsolationService;Username=postgres;Password=postgres" + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Controllers/ProductsController.cs b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Controllers/ProductsController.cs new file mode 100644 index 0000000..2690a1b --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Controllers/ProductsController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.SchemaIsolationService.Entities; +using MultiTenancySample.SchemaIsolationService.EntityFrameworks; + +namespace MultiTenancySample.SchemaIsolationService.Controllers +{ + [Route("api/{tenant}/[controller]")] + [ApiController] + public class ProductsController(IDbContextFactory dbContextFactory) : ControllerBase + { + private readonly SchemaIsolationDbContext _dbContext = dbContextFactory.CreateDbContext(); + + [HttpGet] + public IActionResult GetProducts(string? keyword) + { + IQueryable products = _dbContext.Set(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + products = products.Where(p => p.Name.Contains(keyword)); + } + + return Ok(products); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Entities/Product.cs b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Entities/Product.cs new file mode 100644 index 0000000..27c2859 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Entities/Product.cs @@ -0,0 +1,9 @@ +namespace MultiTenancySample.SchemaIsolationService.Entities +{ + public class Product + { + public Guid Id { get; set; } + + public required string Name { get; set; } + } +} \ No newline at end of file diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/EntityFrameworks/DataSeeding.cs b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/EntityFrameworks/DataSeeding.cs new file mode 100644 index 0000000..be8a4ec --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/EntityFrameworks/DataSeeding.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.SchemaIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + +namespace MultiTenancySample.SchemaIsolationService.EntityFrameworks +{ + public class DataSeeding(IDbContextFactory dbContext, ICurrentTenant currentTenant) + { + public async Task SeedDataAsync() + { + currentTenant.SetTenant("Tenant1"); + + SchemaIsolationDbContext dbContext1 = await dbContext.CreateDbContextAsync(); + + if (await dbContext1.Database.EnsureCreatedAsync()) + { + await dbContext1.AddRangeAsync(new List { + new() { Id = Guid.NewGuid(), Name = "Product1"}, + new() { Id = Guid.NewGuid(), Name = "Product3"}, + new() { Id = Guid.NewGuid(), Name = "Product5"} + }); + + await dbContext1.SaveChangesAsync(); + } + + currentTenant.SetTenant("Tenant2"); + + SchemaIsolationDbContext dbContext2 = dbContext.CreateDbContext(); + + await dbContext2.Database.MigrateAsync(); + + if (await dbContext2.Database.EnsureCreatedAsync()) + { + await dbContext2.AddRangeAsync(new List + { + new() { Id = Guid.NewGuid(), Name = "Product2"}, + new() { Id = Guid.NewGuid(), Name = "Product4"}, + new() { Id = Guid.NewGuid(), Name = "Product6"} + }); + + await dbContext2.SaveChangesAsync(); + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/EntityFrameworks/SchemaIsolationDbContext.cs b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/EntityFrameworks/SchemaIsolationDbContext.cs new file mode 100644 index 0000000..5a803a0 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/EntityFrameworks/SchemaIsolationDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.SchemaIsolationService.Entities; +using MultiTenancySample.ServiceDefaults; + +namespace MultiTenancySample.SchemaIsolationService.EntityFrameworks +{ + public class SchemaIsolationDbContext(DbContextOptions options, ICurrentTenant currentTenant) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(p => p.Name).HasMaxLength(32); + + // This scenario is not directly supported by EF Core and is not a recommended solution. + // https://learn.microsoft.com/en-us/ef/core/miscellaneous/multitenancy + + modelBuilder.HasDefaultSchema(currentTenant.TenantId); + + base.OnModelCreating(modelBuilder); + } + } +} \ No newline at end of file diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/MultiTenancySample.SchemaIsolationService.csproj b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/MultiTenancySample.SchemaIsolationService.csproj new file mode 100644 index 0000000..f50de7b --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/MultiTenancySample.SchemaIsolationService.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + PreserveNewest + true + PreserveNewest + + + PreserveNewest + true + PreserveNewest + + + + diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Program.cs b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Program.cs new file mode 100644 index 0000000..5b1d09d --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Program.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using MultiTenancySample.SchemaIsolationService.EntityFrameworks; +using MultiTenancySample.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContextFactory(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("Default")); +}, lifetime: ServiceLifetime.Scoped); + +builder.Services.AddScoped(); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddMultiTenancy(); + +var app = builder.Build(); + +app.UseMultiTenancy(); + +var serviceProvider = app.Services.CreateScope().ServiceProvider; +var dataSeeding = serviceProvider.GetRequiredService(); +await dataSeeding.SeedDataAsync(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Properties/launchSettings.json b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Properties/launchSettings.json new file mode 100644 index 0000000..85ce2d9 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:40979", + "sslPort": 44360 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5258", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7264;http://localhost:5258", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/appsettings.Development.json b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/appsettings.json b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/appsettings.json new file mode 100644 index 0000000..f034949 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.SchemaIsolationService/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=SchemaIsolationService;Username=postgres;Password=postgres" + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/CurrentTenant.cs b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/CurrentTenant.cs new file mode 100644 index 0000000..108e6db --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/CurrentTenant.cs @@ -0,0 +1,28 @@ +namespace MultiTenancySample.ServiceDefaults +{ + public class CurrentTenant : ICurrentTenant + { + public string? TenantId { get; private set; } + + public IDisposable SetTenant(string? tenantId) + { + string? parentTenantId = TenantId; + + TenantId = tenantId; + + return new DisposeAction(() => + { + TenantId = parentTenantId; + }); + } + + public class DisposeAction(Action action) : IDisposable + { + void IDisposable.Dispose() + { + action(); + GC.SuppressFinalize(this); + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/ICurrentTenant.cs b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/ICurrentTenant.cs new file mode 100644 index 0000000..4cda38a --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/ICurrentTenant.cs @@ -0,0 +1,9 @@ +namespace MultiTenancySample.ServiceDefaults +{ + public interface ICurrentTenant + { + string? TenantId { get; } + + IDisposable SetTenant(string? tenantId); + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/ITenantIdProvider.cs b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/ITenantIdProvider.cs new file mode 100644 index 0000000..058b53e --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/ITenantIdProvider.cs @@ -0,0 +1,7 @@ +namespace MultiTenancySample.ServiceDefaults +{ + public interface ITenantIdProvider + { + Task GetTenantIdAsync(); + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/MultiTenancyExtensions.cs b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/MultiTenancyExtensions.cs new file mode 100644 index 0000000..2a6f0d1 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/MultiTenancyExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace MultiTenancySample.ServiceDefaults +{ + public static class MultiTenancyExtensions + { + public static IServiceCollection AddMultiTenancy(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services.AddScoped(); + } + + public static IApplicationBuilder UseMultiTenancy(this IApplicationBuilder app) + { + app.UseMiddleware(); + + return app; + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/MultiTenancySample.ServiceDefaults.csproj b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/MultiTenancySample.ServiceDefaults.csproj new file mode 100644 index 0000000..b566464 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/MultiTenancySample.ServiceDefaults.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/Properties/launchSettings.json b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/Properties/launchSettings.json new file mode 100644 index 0000000..945faae --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "MultiTenancySample.ServiceDefaults": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61544;http://localhost:61545" + } + } +} \ No newline at end of file diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/TenantIdProvider.cs b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/TenantIdProvider.cs new file mode 100644 index 0000000..c4990d1 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/TenantIdProvider.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; + +namespace MultiTenancySample.ServiceDefaults +{ + public class TenantIdProvider(IHttpContextAccessor httpContextAccessor) : ITenantIdProvider + { + public async Task GetTenantIdAsync() + { + HttpContext httpContext = httpContextAccessor.HttpContext ?? new DefaultHttpContext(); + + const string tenantKey = "tenant"; + + if (httpContext.Request.Headers.TryGetValue(tenantKey, out var headerValues)) + { + return headerValues.First(); + } + + if (httpContext.Request.Query.TryGetValue(tenantKey, out var queryValues)) + { + return queryValues.First(); + } + + if (httpContext.Request.Cookies.TryGetValue(tenantKey, out var cookieValue)) + { + return cookieValue; + } + + if (httpContext.Request.RouteValues.TryGetValue(tenantKey, out var routeValue)) + { + return routeValue?.ToString(); + } + + return await Task.FromResult(null); + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/TenantMiddleware.cs b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/TenantMiddleware.cs new file mode 100644 index 0000000..1df756a --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.ServiceDefaults/TenantMiddleware.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; + +namespace MultiTenancySample.ServiceDefaults +{ + public class TenantMiddleware(ICurrentTenant currentTenant, ITenantIdProvider tenantProvider) : IMiddleware + { + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + string? tenantId = await tenantProvider.GetTenantIdAsync(); + + using (currentTenant.SetTenant(tenantId)) + { + await next(context); + } + } + } +} diff --git a/samples/MultiTenancySample/MultiTenancySample.sln b/samples/MultiTenancySample/MultiTenancySample.sln new file mode 100644 index 0000000..750e740 --- /dev/null +++ b/samples/MultiTenancySample/MultiTenancySample.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiTenancySample.SchemaIsolationService", "MultiTenancySample.SchemaIsolationService\MultiTenancySample.SchemaIsolationService.csproj", "{57818F02-DC4F-44CD-89A6-75D00AB5640C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiTenancySample.DatabaseIsolationService", "MultiTenancySample.DatabaseIsolationService\MultiTenancySample.DatabaseIsolationService.csproj", "{340A9AE9-C3FA-4555-86AF-395780D2B89B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiTenancySample.FieldIsolationService", "MultiTenancySample.FieldIsolationService\MultiTenancySample.FieldIsolationService.csproj", "{0B3EB138-AE17-4EB0-B282-88777F6D5FCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiTenancySample.ServiceDefaults", "MultiTenancySample.ServiceDefaults\MultiTenancySample.ServiceDefaults.csproj", "{CF4169D1-AB89-42FD-B209-FCBA9B6F0816}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {57818F02-DC4F-44CD-89A6-75D00AB5640C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57818F02-DC4F-44CD-89A6-75D00AB5640C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57818F02-DC4F-44CD-89A6-75D00AB5640C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57818F02-DC4F-44CD-89A6-75D00AB5640C}.Release|Any CPU.Build.0 = Release|Any CPU + {340A9AE9-C3FA-4555-86AF-395780D2B89B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {340A9AE9-C3FA-4555-86AF-395780D2B89B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {340A9AE9-C3FA-4555-86AF-395780D2B89B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {340A9AE9-C3FA-4555-86AF-395780D2B89B}.Release|Any CPU.Build.0 = Release|Any CPU + {0B3EB138-AE17-4EB0-B282-88777F6D5FCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B3EB138-AE17-4EB0-B282-88777F6D5FCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B3EB138-AE17-4EB0-B282-88777F6D5FCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B3EB138-AE17-4EB0-B282-88777F6D5FCA}.Release|Any CPU.Build.0 = Release|Any CPU + {CF4169D1-AB89-42FD-B209-FCBA9B6F0816}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF4169D1-AB89-42FD-B209-FCBA9B6F0816}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF4169D1-AB89-42FD-B209-FCBA9B6F0816}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF4169D1-AB89-42FD-B209-FCBA9B6F0816}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {201289BC-2E55-46AF-AD01-B3D4288D9904} + EndGlobalSection +EndGlobal diff --git a/samples/MultiTenancySample/README.md b/samples/MultiTenancySample/README.md new file mode 100644 index 0000000..0210302 --- /dev/null +++ b/samples/MultiTenancySample/README.md @@ -0,0 +1,20 @@ +# Áã¿ò¼Ü¶à×⻧Éè¼Æ·½°¸ + +¶à×⻧ӦÓóÌÐòÊÇÒ»ÖÖÈí¼þ¼Ü¹¹Éè¼Æ£¬ÔÊÐíµ¥¸öʵÀýµÄÈí¼þ·þÎñ¶à¸ö¿Í»§£¬Ã¿¸ö¿Í»§±»³ÆΪһ¸ö×⻧£¬×⻧֮¼äµÄÊý¾Ý×Ô¶¯¸ôÀëµÄ£¬×⻧֮¼äµÄÊý¾Ý²»»áÏ໥ӰÏ죬×⻧ºÍÓû§ÊÇÒ»¶Ô¶àµÄ¹Øϵ¡£ + +## Áã¶È¿ò¼ÜÖÐʵÏÖ¶à×⻧ + +ÓÉÓÚÿ¸öϵͳµÄÐèÇó²»Í¬£¬Áã¶È¿ò¼ÜûÓÐÌṩ¶à×⻧µÄͨÓýâ¾ö·½°¸£¬µ«ÎÒÃÇÌṩÁËÈýÖÖ¶à×⻧Éè¼ÆµÄ˼·£¬²¢ÌṩÁËʾÀý´úÂ룬ÒÔ±ãÓÚ¿ª·¢Õ߸ù¾Ý×Ô¼ºµÄÐèÇóÀ´ÊµÏÖ¶à×⻧Éè¼Æ¡£ + +## »ùÓÚµ¥±í×ֶθôÀë×⻧Êý¾Ý + +µ¥±í¶à×⻧Éè¼ÆÊÇÖ¸ÔÚÒ»¸ö±íÖд洢¶à¸ö×⻧µÄÊý¾Ý£¬Í¨¹ýÔÚ±íÖÐÔö¼ÓÒ»¸ö TenantId ×Ö¶ÎÀ´Çø·Ö²»Í¬×⻧µÄÊý¾Ý£¬Ê¹ÓÃÈ«¾Ö²éѯ¹ýÂËÆ÷À´¹ýÂË×⻧Êý¾Ý£¬Ê¹ÓÃÀ¹½ØÆ÷À´±£´æ×⻧Êý¾Ý¡£ + + +## »ùÓÚ¶àÊý¾Ý¿â¸ôÀë×⻧Êý¾Ý + +¶àÊý¾Ý¿â¶à×⻧Éè¼ÆÊÇָΪÿ¸ö×⻧´´½¨Ò»¸ö¶ÀÁ¢µÄÊý¾Ý¿â£¬Í¨¹ýÊý¾Ý¿âÁ¬½Ó×Ö·û´®À´Çø·Ö²»Í¬×⻧µÄÊý¾Ý£¬Ê¹ÓÃÀ¹½ØÆ÷À´ÉèÖÃÊý¾Ý¿âÁ¬½Ó×Ö·û´®¡£ + +## »ùÓÚµ¥Êý¾Ý¿â¼Ü¹¹¸ôÀë×⻧Êý¾Ý + +»ùÓÚ Schema µÄ¶à×⻧Éè¼ÆÊÇָΪÿ¸ö×⻧´´½¨Ò»¸ö¶ÀÁ¢µÄ Schema Ãû³Æ £¬Í¨¹ý Schema À´Çø·Ö²»Í¬×⻧µÄÊý¾Ý£¬Ê¹ÓÃÀ¹½ØÆ÷À´ÉèÖà Schema Ãû³Æ¡£ \ No newline at end of file diff --git a/samples/MultiTenancySample/global.json b/samples/MultiTenancySample/global.json new file mode 100644 index 0000000..40975d5 --- /dev/null +++ b/samples/MultiTenancySample/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.204", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/samples/README.md b/samples/README.md deleted file mode 100644 index 413967b..0000000 --- a/samples/README.md +++ /dev/null @@ -1 +0,0 @@ -# ʾÀý´úÂë \ No newline at end of file