Já imaginou criar aplicativos na nuvem que atendem a vários clientes ao mesmo tempo, cada um com seus dados protegidos? Com o Multitenancy com Azure Functions, isso é totalmente possível! Essa arquitetura permite que uma única instância do seu aplicativo sirva a diversos clientes, chamados de tenants, de forma segura e isolada. E o melhor de tudo: você pode usar as APIs da Neon para automatizar a criação, atualização e exclusão de bancos de dados para cada um desses tenants.
O que é Multitenancy?
Multitenancy é uma arquitetura de software que permite que uma única instância de um aplicativo atenda a múltiplos clientes, conhecidos como tenants. Cada tenant tem seus dados isolados e invisíveis para os outros, garantindo a segurança. Existem várias formas de separar os dados de cada tenant em aplicações multi-tenant:
- Banco de dados por Tenant: Cada tenant tem seu próprio banco de dados, oferecendo o maior isolamento de dados, mas pode aumentar a complexidade e os custos de gerenciamento.
- Esquema por Tenant: Um único banco de dados com esquemas separados para cada tenant, equilibrando isolamento e compartilhamento de recursos.
- Tabela por Tenant: Um único banco de dados e esquema, com tabelas específicas para cada tenant, sendo eficiente, mas pode complicar o gerenciamento de dados.
- Coluna Discriminadora: Um único banco de dados, esquema e tabelas, com uma coluna indicando o tenant. É o modelo mais simples, mas o menos isolado.
A Neon simplifica a abordagem de “Banco de dados por Tenant“. Com as APIs da Neon, você pode automatizar a criação, atualização e exclusão de bancos de dados para cada tenant. Para começar, você precisa de uma conta ativa no Azure e uma conta Neon Postgres.
Pré-requisitos para Multitenancy com Azure Functions
Antes de começar, você precisa ter algumas coisas prontas. Primeiro, certifique-se de ter uma assinatura ativa do Azure para criar e gerenciar seus recursos. Em seguida, inscreva-se e configure sua instância Neon Postgres. O bacana é que você pode começar de graça!
Para o banco de dados Neon, você tem duas opções: configurar um banco de dados na Neon Cloud ou configurar o “Neon Serverless Postgres” como um contêiner nativo do Azure. Ambas as opções oferecem uma assinatura gratuita com a possibilidade de upgrade conforme você precisar.
Em um artigo anterior, o autor explicou como construir uma API de Produtos e implantá-la no Azure com .NET Aspire e Neon. Essa aplicação permite que você gerencie produtos e os usuários criem um carrinho de compras com vários itens. Agora, a ideia é evoluir essa aplicação e torná-la multi-tenant.
Em um sistema multi-tenant, cada tenant será uma loja com seus próprios produtos, armazenados em bancos de dados separados. Os usuários poderão encomendar produtos em cada uma dessas lojas. Para construir essa aplicação, você precisará seguir alguns passos:
- Adicionar modelos de dados e APIs Tenant ao serviço de API de Produtos.
- Criar uma Azure Function para gerenciar bancos de dados tenant com a API Neon.
- Configurar um projeto .NET Aspire.
- Adicionar comunicação entre a API de Produtos e a Azure Function de gerenciamento de tenant.
- Migrar um banco de dados tenant e gerenciar connection strings no Azure KeyVault com suporte a Caching.
- Implantar e testar a solução no Azure.
O código fonte completo da solução está disponível para download ao final deste artigo.
Adicionando Tenant à API de Produtos
O primeiro passo é criar uma entidade Tenant na API de Produtos. Uma versão simplificada para facilitar o entendimento seria:
public class Tenant
{
public Guid Id { get; set; }
public string Name { get; set; }
}
Em seguida, adicione uma chave estrangeira para Tenant nas entidades Product e ProductCart:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Description { get; set; } = string.Empty;
public Guid? TenantId { get; set; }
}
public class ProductCart
{
public Guid Id { get; set; }
public int Quantity { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public List<ProductCartItem> CartItems { get; set; } = [];
public Guid? TenantId { get; set; }
}
Agora, defina as APIs para gerenciar os tenants na aplicação:
public record CreateTenantRequest(string Name);
public record UpdateTenantRequest(string CurrentName, string NewName);
app.MapPost("/tenants", (CreateTenantRequest request) => { });
app.MapPatch("/tenants/{currentName}", (string currentName, UpdateTenantRequest request) => { });
app.MapDelete("/tenants/{name}", (string name) => { });
app.MapGet("/tenants", () => { });
Com o primeiro passo concluído, o próximo passo é implementar esses endpoints.
Criando a Azure Function de Gerenciamento de Tenant
Para criar uma Azure Function no Visual Studio, selecione o template de projeto “Azure Function” e defina o tipo da Function para “HTTP” trigger. Para JetBrains Rider, instale o Azure Toolkit for Rider e crie um projeto “Azure Function“.
Na Tenant Management Function, certifique-se de que os seguintes pacotes Nuget estão instalados:
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
</ItemGroup>
Antes de mergulhar no código, é importante entender a estrutura da Neon. Primeiro, você precisa criar um projeto:
Cada projeto tem um ou mais branches (como no GIT):
E cada branch contém um banco de dados. As requisições de API a serem implementadas são:
- Criar um banco de dados.
- Atualizar um banco de dados.
- Deletar um banco de dados.
- Obter todos os bancos de dados para referência.
- Obter a connection string do banco de dados.
Para simplificar as chamadas de API para a Neon, será utilizada a biblioteca Refit. Refit é um wrapper em torno do HttpClientFactory que transforma chamadas REST em chamadas de método simples, tornando o código mais limpo e fácil de trabalhar.
A interface INeonApi é usada para a comunicação com a Neon:
public interface INeonApi
{
[Get("/api/v2/projects/{projectId}/branches/{branchId}/databases")]
Task<NeonDatabaseListResponseJson> GetDatabasesAsync(
[AliasAs("projectId")] string projectId,
[AliasAs("branchId")] string branchId);
[Post("/api/v2/projects/{projectId}/branches/{branchId}/databases")]
Task<NeonDatabaseCreateResponse> CreateDatabaseAsync(
[AliasAs("projectId")] string projectId,
[AliasAs("branchId")] string branchId,
[Body] CreateNeonDatabaseRequest request);
[Patch("/api/v2/projects/{projectId}/branches/{branchId}/databases/{databaseName}")]
Task<NeonDatabaseCreateResponse> UpdateDatabaseAsync(
[AliasAs("projectId")] string projectId,
[AliasAs("branchId")] string branchId,
[AliasAs("databaseName")] string databaseName,
[Body] UpdateNeonDatabaseRequest request);
[Delete("/api/v2/projects/{projectId}/branches/{branchId}/databases/{databaseName}")]
Task<NeonDatabaseCreateResponse> DeleteDatabaseAsync(
[AliasAs("projectId")] string projectId,
[AliasAs("branchId")] string branchId,
[AliasAs("databaseName")] string databaseName);
[Get("/api/v2/projects/{projectId}/connection_uri")]
Task<ConnectionStringResponse> GetConnectionStringAsync(
[AliasAs("projectId")] string projectId,
[AliasAs("branch_id")] string? branchId,
[AliasAs("database_name")] string databaseName,
[AliasAs("role_name")] string roleName);
}
Cada chamada de API precisa de um projeto e um identificador de branch. O identificador do projeto pode ser encontrado na aba de configurações:
E o identificador do branch pode ser encontrado na aba overview:
Para enviar requisições de API para a Neon, você precisa criar uma API Key na seção “Account settings” > “API keys“:
As Azure Functions são usadas para lidar com as requisições de banco de dados tenant. Cada Azure Function tem HTTP triggers e é projetada para lidar com uma tarefa.
A função de criação de banco de dados:
internal record CreateTenantDatabaseRequest(string DatabaseName);
internal record CreateTenantDatabaseResponse(TenantDatabaseDetails Database,
string ConnectionString);
public class CreateTenantDatabaseFunction(
ILogger<CreateTenantDatabaseFunction> logger,
NeonConfigurationProvider configurationProvider,
INeonApi neonApi)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
[Function(nameof(CreateNeonDatabase))]
public async Task<HttpResponseData> CreateNeonDatabase(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "database/create")]
HttpRequestData requestData)
{
try
{
var requestBody = await new StreamReader(requestData.Body).ReadToEndAsync();
var createRequest = JsonSerializer.Deserialize<CreateTenantDatabaseRequest>(
requestBody, SerializerOptions);
if (createRequest is null)
{
logger.LogError("Failed to deserialize request");
var badRequestResponse = requestData.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = "Invalid request body" });
return badRequestResponse;
}
var neonResponse = await CreateNeonDatabaseAsync(createRequest);
var connectionStringResponse = await GetConnectionStringAsync(
createRequest.DatabaseName);
logger.LogInformation("Database {DatabaseName} created: {@NeonResponse}",
createRequest.DatabaseName, neonResponse);
var response = requestData.CreateResponse(HttpStatusCode.OK);
var tenantDatabaseDetails = neonResponse.MapToResponseDetails();
var response = new CreateTenantDatabaseResponse(tenantDatabaseDetails,
connectionStringResponse.Uri);
await response.WriteAsJsonAsync(response);
return response;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create Neon database");
var errorResponse = requestData.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { error = "Failed to create database" });
return errorResponse;
}
}
}
Essa função segue os seguintes passos:
- Deserializa o corpo da requisição HTTP que aciona a função.
- Cria um banco de dados Neon no projeto e branch especificados.
- Obtém a connection string para o banco de dados recém-criado.
- Retorna a resposta.
Para criar um banco de dados e obter sua connection string, a interface Refit é utilizada:
private async Task<NeonDatabaseCreateResponse> CreateNeonDatabaseAsync(
CreateTenantDatabaseRequest createRequest)
{
var neonConfiguration = configurationProvider.Get();
var neonRequest = new CreateNeonDatabaseRequest(new CreateDatabaseInfo
{
Name = createRequest.DatabaseName,
OwnerName = neonConfiguration.OwnerName
});
return await neonApi.CreateDatabaseAsync(
neonConfiguration.ProjectId,
neonConfiguration.BranchId,
neonRequest);
}
private async Task<ConnectionStringResponse> GetConnectionStringAsync(
string databaseName)
{
var neonConfiguration = configurationProvider.Get();
return await neonApi.GetConnectionStringAsync(
neonConfiguration.ProjectId,
neonConfiguration.BranchId,
databaseName,
neonConfiguration.OwnerName);
}
A configuração da Azure Function no arquivo Program.cs:
var builder = FunctionsApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Logging.AddConsole();
builder.ConfigureFunctionsWebApplication();
builder.Services.AddTransient<AuthDelegatingHandler>();
builder.Services.AddTransient<NeonConfigurationProvider>();
var neonUrl = builder.Configuration.GetConnectionString("NeonUrl")!;
builder.Services.AddRefitClient<INeonApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(neonUrl))
.AddHttpMessageHandler<AuthDelegatingHandler>()
.AddStandardResilienceHandler();
builder.Build().Run();
Em cada requisição de API para a Neon, é preciso fornecer um header de Authorization com “Bearer API_KEY“. Para isso, um DelegatingHandler é adicionado ao Refit HttpClientFactory:
public class AuthDelegatingHandler(IConfiguration configuration) : DelegatingHandler
{
private readonly string _apiKey = configuration.GetConnectionString("NeonApiKey")
?? throw new InvalidOperationException("NeonApiKey configuration is missing");
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
return await base.SendAsync(request, cancellationToken);
}
}
Essa função tem as seguintes connection strings (vindas do Aspire):
- NeonApiKey
- NeonUrl
E parâmetros de configuração no arquivo local.settings.json (vindas do Aspire):
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"NEON_PROJECT_ID": "[COMING_FROM_ASPIRE]",
"NEON_BRANCH_ID": "[COMING_FROM_ASPIRE]",
"NEON_DATABASE_OWNER": "[COMING_FROM_ASPIRE]"
}
}
Outras funções são implementadas da mesma forma.
Configurando o projeto .NET Aspire
Para adicionar o .NET Aspire ao projeto, você pode consultar este artigo.
O projeto Aspire Host:
using Projects;
var builder = DistributedApplication.CreateBuilder(args);
var neonApiKey = builder.AddConnectionString("NeonApiKey");
var neonUrl = builder.AddConnectionString("NeonUrl");
var configuration = builder.Configuration;
var neonProjectId = configuration["NeonProjectId"];
var neonBranchId = configuration["NeonBranchId"];
var neonOwnerName = configuration["NeonDatabaseOwner"];
var keyVault = builder.ExecutionContext.IsPublishMode
? builder.AddAzureKeyVault("Secrets")
: builder.AddConnectionString("Secrets");
var function = builder.AddAzureFunctionsProject<Multitenancy_Function>("multitenancy-api")
.WithReference(neonApiKey)
.WithReference(neonUrl)
.WithEnvironment("NEON_PROJECT_ID", neonProjectId)
.WithEnvironment("NEON_BRANCH_ID", neonBranchId)
.WithEnvironment("NEON_DATABASE_OWNER", neonOwnerName)
.WithExternalHttpEndpoints();
var databaseConnectionString = builder.AddConnectionString("Postgres");
builder.AddProject<ProductService_Host>("product-service")
.WithExternalHttpEndpoints()
.WithReference(function)
.WithReference(keyVault)
.WithReference(databaseConnectionString)
.WaitFor(function);
builder.Build().Run();
Os pacotes Nuget utilizados:
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.1.0" />
<PackageReference Include="Aspire.Hosting.Azure.Functions" Version="9.1.0-preview.1.25121.10" />
<PackageReference Include="Aspire.Hosting.Azure.KeyVault" Version="9.1.0" />
</ItemGroup>
Aqui, é especificado:
- Azure Function que envia requisições para a Neon.
- Aplicação de API de Produtos que gerencia produtos, carrinho de produtos e envia requisições para a Azure Function.
- Azure Key Vault para armazenar connection strings.
Integrando a API de Produtos com a Azure Function de Gerenciamento de Tenant
Adicione o pacote Nuget Azure KeyVault ao projeto da API de Produtos:
<PackageReference Include="Aspire.Azure.Security.KeyVault" Version="9.1.0" />
Registre as dependências no DI:
services.AddMemoryCache();
services.AddHttpContextAccessor();
builder.Configuration.AddAzureKeyVaultSecrets("Secrets");
builder.AddAzureKeyVaultClient("Secrets");
Será usado o Refit para enviar requisições da API de Produtos para a Azure Function:
public interface ITenantApi
{
[Post("/api/database/create")]
Task<CreateTenantDatabaseResponse> CreateDatabaseAsync([Body] CreateTenantDatabaseRequest request);
[Patch("/api/database/update/{database}")]
Task<DatabaseDetails> UpdateDatabaseAsync(string database, [Body] UpdateTenantDatabaseRequest request);
[Delete("/api/database/delete/{database}")]
Task<DatabaseDetails> DeleteDatabaseAsync(string database);
[Get("/api/database")]
Task<ListDatabasesResponse> ListDatabasesAsync();
}
O endpoint de criação de tenant:
app.MapPost("/tenants", async (
CreateTenantRequest request,
ITenantApi tenantApi,
ApplicationDbContext applicationDbContext,
IDatabaseMigrator databaseMigrator,
ILogger<TenantEndpoints> logger) =>
{
await using var transaction = await applicationDbContext.Database.BeginTransactionAsync();
try
{
// Implementation code ...
await transaction.CommitAsync();
return Results.Ok(new
{
TenantId = tenant.Id,
DatabaseName = databaseName
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create tenant {TenantName}", request.Name);
await transaction.RollbackAsync();
return Results.Problem("Failed to create tenant");
}
}
- Certifique-se de envolver tudo em uma transação, para que as mudanças possam ser revertidas caso a Azure Function retorne uma falha.
- Salve o tenant no banco de dados “mestre”, aquele que conterá todos os dados dos tenants:
var tenant = new Tenant
{
Id = Guid.NewGuid(),
Name = request.Name
};
applicationDbContext.Tenants.Add(tenant);
await applicationDbContext.SaveChangesAsync();
- Envie a requisição para criar um banco de dados tenant:
var databaseName = $"products-{request.Name}";
var createDatabaseRequest = new CreateTenantDatabaseRequest(databaseName);
var response = await tenantApi.CreateDatabaseAsync(createDatabaseRequest);
- É preciso criar tabelas no banco de dados recém-criado. Para isso, podem ser usadas as migrações do EF Core e aplicá-las programaticamente ao banco de dados.
try
{
await databaseMigrator.MigrateDatabaseAsync(tenant.Id.ToString(), connectionString);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to apply migrations for tenant {TenantName}", request.Name);
await transaction.RollbackAsync();
try
{
await tenantApi.DeleteDatabaseAsync(databaseName);
}
catch (Exception cleanupEx)
{
logger.LogError(cleanupEx, "Failed to cleanup tenant database after migration failure");
}
return Results.Problem("Failed to setup tenant database");
}
Se tudo funcionar bem, confirme as mudanças. Caso contrário, exclua o banco de dados tenant enviando uma requisição de API correspondente e reverta a transação.
Migrando um Banco de Dados Tenant e Gerenciando Connection Strings
As migrações são aplicadas a um banco de dados tenant. O código para DatabaseMigrator:
await databaseMigrator.MigrateDatabaseAsync(tenant.Id.ToString(), connectionString);
public class DatabaseMigrator(IServiceScopeFactory scopeFactory) : IDatabaseMigrator
{
public async Task MigrateDatabaseAsync(
string tenantId,
string connectionString,
TimeSpan? cachingExpiration = null)
{
using var scope = scopeFactory.CreateScope();
var tenantConnectionFactory = scope.ServiceProvider
.GetRequiredService<ITenantConnectionFactory>();
tenantConnectionFactory.SetConnectionString(tenantId, connectionString, cachingExpiration);
using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync();
await DatabaseSeedService.SeedAsync(dbContext);
}
}
O banco de dados está sendo seeding apenas para fins de teste.
A ITenantConnectionFactory fornece a connection string correta para o DbContext à medida que é criado:
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
{
var tenantConnectionFactory = serviceProvider.GetRequiredService<ITenantConnectionFactory>();
var connectionString = tenantConnectionFactory
.GetConnectionString(tenantConnectionFactory.GetCurrentTenant();
options.EnableSensitiveDataLogging()
.UseNpgsql(connectionString),
npgsqlOptions =>
{
npgsqlOptions.MigrationsHistoryTable(
DatabaseConsts.MigrationHistoryTable,
DatabaseConsts.Schema);
});
options.UseSnakeCaseNamingConvention();
});
A fábrica de conexões recupera a connection string do IMemoryCache e, se não for encontrada, chama a classe SecretClient do Azure para obter a connection string do Azure KeyVault. Após criar o banco de dados tenant, é chamada a função SetConnectionString para salvar a connection string no IMemoryCache e no Azure KeyVault.
public void SetConnectionString(
string tenantId,
string connectionString,
TimeSpan? cachingExpiration = null)
{
_memoryCache.Set(
$"tenant-connection-string-{tenantId}",
connectionString,
cachingExpiration ?? TimeSpan.FromHours(1));
_tenantId = tenantId;
_secretClient.SetSecret(tenantId, connectionString);
}
public class TenantConnectionFactory : ITenantConnectionFactory
{
public string? GetConnectionString(string? customTenantId = null)
{
var tenantId = customTenantId ?? GetCurrentTenant();
if (tenantId is null)
{
return _configuration.GetConnectionString(DatabaseConsts.DefaultConnectionString);
}
return _memoryCache.GetOrCreate(
$"tenant-connection-string-{tenantId}",
entry =>
{
entry.SlidingExpiration = TimeSpan.FromHours(1);
var secret = _secretClient.GetSecret(tenantId);
return secret.Value.Value;
});
}
}
Para produção, ajuste o tempo de expiração do IMemoryCache com base nos seus requisitos. O Azure cobra pelo envio de requisições para o Azure KeyVault, então consulte a documentação oficial para obter informações sobre preços.
Implantando e Testando a Solução no Azure
Neste artigo, você encontra um guia passo a passo sobre como implantar sua aplicação no Azure.
Execute os seguintes comandos e aguarde a criação dos recursos no Azure:
azd auth login
azd init
azd up
Após a implantação, verifique o Azure Portal para ver seus contêineres em execução:
Abra o Aspire Dashboard navegando a partir do product-service:
Use o Postman ou sua ferramenta preferida para enviar uma requisição para criar um banco de dados tenant:
{
"name": "Shopify"
}
Você receberá uma resposta como essa:
{
"tenantId": "78b0c444-49aa-4308-b35f-9ff530b31920",
"databaseName": "products