aspire2.jpg

Neste artigo, explorar a utilização do recurso de Banco de Dados em um projeto .Net Aspire com a integração com o PostgreSQL

Neste artigo vamos realizar o estudo de uma integração com o banco de dados PostgreSQL em um projeto .Net Aspire.

Após o estudo, vamos modelo que foi criado neste artigo ou pode ser baixado neste repositório. O Objetivo final será termos um CRUD completo com o PostgreSql em nosso projeto modelo.

Usando as integrações do .NET Aspire para o PostgreSQL

As integrações do .NET Aspire PostgreSQL exigem alterações no projeto app host e em quaisquer microsserviços que usam os bancos de dados.

  • Configurando o app host

Comece instalando a integração apropriada no projeto app host do aplicativo:

dotnet add package Aspire.Hosting.PostgreSQL --prerelease

Em seguida, para registrar um banco de dados e criar um contêiner para ele, adicione este código ao arquivo Program.cs no projeto app host aplicativo:

var postgres = builder.AddPostgres("aspirepostgres");
                      
var postgresdb = postgres.AddDatabase("productdb");

Algumas das integrações de banco de dados .NET Aspire também permitem criar um contêiner para ferramentas de gerenciamento de banco de dados. Para adicionar o PgAdmin à sua solução para gerenciar o banco de dados PostgreSQL, use o código abaixo. Este é um código alternativo ao apresentado acima.

var postgresdb = builder.AddPostgres("pg")
                        .AddDatabase("postgresdb")
                        .WithPgAdmin();

A vantagem de deixar o .NET Aspire criar o container é que você não precisa fazer nenhuma configuração para conectar o PgAdmin ao banco de dados PostgreSQL, é tudo automático.

Você também deve passar uma referência ao serviço de banco de dados para qualquer projeto que o consuma:

var productCatalogAPI = builder.AddProject<Projects.NorthernTraders_CatalogAPI>("product-api")
                               .WithReference(postgresdb);
  • Configurando os projetos que irão consumir o banco de dados:

Para instalar a integração .NET Aspire PostgreSQL, use um comando como este em seus projetos .NET Aspire:

dotnet add package Aspire.Npgsql --prerelease

Ou para usar a integração do .NET Aspire PostgreSQL Entity Framework Core, use este comando:

dotnet add package Aspire.Npgsql.EntityFrameworkCore.PostgreSQL --prerelease
  • Usando o Database PostgreSQL

Em qualquer projeto onde você deseja usar o banco de dados, você adiciona uma fonte de dados para representar a conexão com o PostgreSQL. No arquivo Program.cs, este código registra o banco de dados:

builder.AddNpgsqlDataSource("postgresdb");

Ou para usar a integração do Entity Framework Core, registre o contexto do banco de dados:

builder.AddNpgsqlDbContext<YourDbContext>("postgresdb");

Depois que o banco de dados estiver registrado no projeto de consumo, você poderá interagir com a fonte de dados sempre que precisar usando injeção de dependência, segue um código de exemplo:

public class YourService(NpgsqlDataSource dataSource)
{
    public async Task<IEnumerable<Catalog>> GetCatalog()
	{
        const string query = "SELECT * FROM catalog";
        using var dbConnection = dataSource.OpenConnection();
        var results = await dbConnection.QueryAsync<Catalog>(command);
        return queryResult.ToArray();
	}
}

Ou você pode recuperar o contexto do banco de dados YourDbContext para interagir com o banco de dados:

public class YourService(YourDbContext context)
{
    public async Task<IEnumerable<Catalog>> GetCatalog()
	{
        var items = await context.ObjectItems;
        if (item is null)
        {
            return Results.NotFound();
        }
		else
		{
		    return items;
		}
	}
}
  • Configurando a integração com o PostgreSQL

A stack do .NET Aspire tenta reduzir a quantidade de configurações necessárias. Usando injeção de dependência e descoberta de serviço, você pode acessar o banco de dados sem precisar configurar as cadeias de conexão em seus projetos.

Usar o projeto app host da solução para criar o contêiner do banco de dados e passá-lo como referência aos projetos permite que os projetos receptores acessem o local do banco de dados, as strings de conexão e as portas. Não há necessidade de gerenciar variáveis ​​de ambiente ou arquivos appsettings.json.

Mas, se você quiser ou precisar de mais controle sobre como o banco de dados está configurado, há mais opções.

Usando uma string de conexão:

No projeto que requer o banco de dados, você usa uma string de conexão para conectar-se ao banco de dados. Essa abordagem é útil quando você precisa se conectar a um banco de dados que não está registrado no app host da solução.

builder.AddNpgsqlDataSource("NpgsqlConnectionString");

Em seguida, no arquivo de configuração, você pode adicionar a string de conexão:

{
  "ConnectionStrings": {
    "NpgsqlConnectionString": "Host=myserver;Database=postgresdb;User id=myuser;Password=mypassword"
  }
}

Usando a configuração do provider:

O .NET Aspire possui um recurso de integrações que permite suportar Microsoft.Extensions.Configuration. A integração do PostgreSQL suporta esse recurso e, por padrão, procura configurações usando a chave Aspire:Npgsql. Em projetos que usam appsettings.json, um exemplo de configuração pode ser assim:

{
  "Aspire": {
    "Npgsql": {
      "ConnectionString": "Host=myserver;Database=postgresdb;User id=myuser;Password=mypassword",
      "HealthChecks": true,
      "Tracing": true,
      "Metrics": true
    }
  }
}

A configuração anterior define a string de conexão, habilitando health checks, tracing e metrics para a integração do PostgreSQL. Seu código não precisa mais especificar a string de conexão, basta usar builder.AddNpgsqlDataSource();.

Usando inline delegates

The last option is to pass a configureSettings inline delegate to the AddNpgsqlDataSource method. This delegate allows you to configure the settings for the database integration directly with code:

A última opção é passar a configureSettings através de um delegate para o método AddNpgsqlDataSource. Este delegate permite que você defina as configurações para a integração do banco de dados diretamente com o código:

builder.AddNpgsqlDataSource(
    "postgresdb", static settings => settings.HealthChecks = false);

Utilizando o modelo para realizarmos a integração com o PostgreSql

Como descrito no início do artigo, você pode criar seu modelo no artigo mencionado ou baixar o fonte no repositório informado.

Com a solução aberta, vamos executar os seguintes passos para executar a integração:

  • Projeto AppHost (DatabaseIntegrationBase.AppHost)

Iniciando, faça a instalação do pacote necessário para a instalação

dotnet add package Aspire.Hosting.PostgreSQL --prerelease

No arquivo Program.cs, adicione a inclusão do PostgreSql ao projeto:

// Add PostgreSql integration
var postgres = builder.AddPostgres("pg")
               .WithPgAdmin()
               .WithBindMount("./Service.API/Seed", "/docker-entrypoint-initdb.d")
               .AddDatabase("postgres");

Explorando o código acima:

.AddPostgres: Irá adicionar a integração com o PostgrSql, adicionando um servidor chamado pg.

.WithPgAdmin: Irá adicionar um contêiner com o PgAdmin, para administração do banco, execução de queries e demais operações.

.WithBindMount: Irá adicionar um script de inicialização para nosso banco, criando uma tabela para nosso CRUD. Esta parte ficou como script pois o objetivo é a integração e não mergulharmos no mundo SQL.

.AddDatabase: Adiciona o database com o nome postgres. Fiz testes com outros nomes e estudei a execução de um script criando bancos, schemas e databases diferentes. Isso é possível? Sim, porém, esta implementação foi realizada através do recurso de service discovery, onde nem a connection string precisamos configurar. A intenção de apresentar desta forma é justamente para mostrar como nós, desenvolvedores(as) podemos ganhar tempo sabendo utilizar os recursos do .Net Aspire.

Após a inclusão do PostgreSql no arquivo Program.cs, vamos alterar o registro do serviço da cama de API, ou seja o projeto DatabaseIntegrationBase.ApiService. Faça a substituição da linha abaixo:

var apiService = builder.AddProject<Projects.DatabaseIntegrationBase_ApiService>("apiservice");

Por esta, adicionando a referência do PostgreSql:

var apiService = builder.AddProject<Projects.PostgreExample_ApiService>("apiservice")
                        .WithReference(postgres);

Para finalizar a configuração de nosso projeto AppHost, vamos adicionar o script sql responsável por criar a tabela para que possamos integrar com o banco de dados.

Na raiz do projeto AppHost, crie uma pasta chamada Service.API. Dentro desta pasta crie uma outra pasta chamada Seed. Este é o caminho que o .Net Aspire irá mapear um arquvo de nossa solução para um volume com script de execução dentro do conteiner. salve o arquivo dentro da pasta Seed com o nome initdatabase.sql e insira o conteúdo abaixo dentro do arquivo:

-- Conceder todas as permissões no schema 'movie' ao usuário postgres
GRANT ALL PRIVILEGES ON SCHEMA public TO postgres;

-- Criação da tabela 'movies' no schema 'movie' se não existir
CREATE TABLE IF NOT EXISTS public.movies (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    gender VARCHAR(50),
    is_active BOOLEAN NOT NULL
);

-- Conceder todas as permissões na tabela 'movies' ao usuário postgres
GRANT ALL PRIVILEGES ON TABLE public.movies TO postgres;

Pronto, em nosso projeto AppHost estamos com tudo configurado para a implementação do PostgreSql, agora vamos incluir o mesmo nos projetos que irão consumir, no nosso caso o projeto ApiService

  • Projeto ApiService (DatabaseIntegrationBase.ApiService)

Vamos adicionar o pacote do PostgreSql no nosso projeto da API. Para tal, acesse a pasta do projeto ApiService DatabaseIntegrationBase.ApiService, onde se encontra o arquivo .csproj do mesmo e execute o comando para adicionar o pacote. Para facilitar os testes com a integração com o banco de dados, vamos adicionar o Swagger ao projeto da API também, desta forma poderemos ver e valdiar todas as nossas operações. Execute o segundo comando para adicionar o pacote do Swagger.

dotnet add package Aspire.Npgsql --prerelease
dotnet add package Swashbuckle.AspNetCore

Com os dois pacotes adicionados, vamos incluir a instrução responsável pelo service discovery para realizar toda a implementação realizada no projeto AppHost com a referência ao projeto ApiService. Podemos adicionar a instrução logo abaixo da linha var builder = WebApplication.CreateBuilder(args);. Já iremos aproveitar e adicionar também a refeẽncia do Swagger, como poderemos ver nas linhas de código:

// Add postgres integration
builder.AddNpgsqlDataSource("postgresdb");

// Add swagger to API
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Version = "v1",
        Title = "Movie API",
        Description = "API for managing a list of movies and their active status.",
        TermsOfService = new Uri("https://example.com/terms")
    });
});

Agora vamos adicionar o middleware do Swagger para interagirmos com nossos endpoints. Logo acima da linha app.Run(), adicione o código abaixo:

app.UseSwagger();
app.UseSwaggerUI();

Adicionando os endpoints para testes

Se tratando de uma minimal api e um projeto de teste, no arquivo Program.cs, vamos fazer uma alterações em nosso arquivo Program.cs.

Exclusão de linhas:

Procure em seu código as linhas abaixo e exclua as mesmas, a intenção é deixar a solução menos poluída para estudo e não atrapalhar nossa nova implementação:


// Primeiro comando pra exlcuir
string[] summaries = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"];

// Segundo comando para exlcuir
app.MapGet("/weatherforecast", () =>
{
    var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast
        (
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]
        ))
        .ToArray();
    return forecast;
})
.WithName("GetWeatherForecast");

// Terceiro comando para excluir
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Logo após a linha var app = builder.Build();, adicione o código abaixo:

app.MapGet("/movieitems", async (NpgsqlDataSource dataSource) =>
{
    using var dbConnection = dataSource.OpenConnection();

    await using var command = dbConnection.CreateCommand();
    command.CommandText = "SELECT * FROM public.movies";

    await using var reader = await command.ExecuteReaderAsync();

    var movies = new List<Movie>();

    while (await reader.ReadAsync())
    {
        movies.Add(new Movie
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1),
            Gender = reader.IsDBNull(2) ? null : reader.GetString(2),
            IsActive = reader.GetBoolean(3)
        });

    }
    return Results.Ok(movies);

}).WithName("GetAllMovies");
   

app.MapGet("/movieitems/active", async (NpgsqlDataSource dataSource) =>
{
    using var dbConnection = dataSource.OpenConnection();

    await using var command = dbConnection.CreateCommand();
    command.CommandText = "SELECT * FROM public.movies WHERE is_active = TRUE";

    await using var reader = await command.ExecuteReaderAsync();

    var movies = new List<Movie>();

    while (await reader.ReadAsync())
    {
        movies.Add(new Movie
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1),
            Gender = reader.IsDBNull(2) ? null : reader.GetString(2),
            IsActive = reader.GetBoolean(3)
        });

    }
    return Results.Ok(movies);

}).WithName("GetActiveMovies");
    

app.MapGet("/movieitems/{id}", async (int id, NpgsqlDataSource dataSource) =>
{
    using var dbConnection = dataSource.OpenConnection();

    await using var command = dbConnection.CreateCommand();
    command.CommandText = "SELECT * FROM public.movies WHERE id = @id";
    command.Parameters.AddWithValue("id", id);

    await using var reader = await command.ExecuteReaderAsync();

    if (await reader.ReadAsync())
    {
        var movie = new Movie
        {
            Id = reader.GetInt32(0),
            Name = reader.GetString(1),
            Gender = reader.IsDBNull(2) ? null : reader.GetString(2),
            IsActive = reader.GetBoolean(3)
        };
        return Results.Ok(movie);
    }
    return Results.NotFound();
    

}).WithName("GetMovieById");
    

app.MapPost("/movieitems", async (Movie movie, NpgsqlDataSource dataSource) =>
{
    using var dbConnection = dataSource.OpenConnection();

    await using var cmd = dataSource.CreateCommand("INSERT INTO public.movies (name, gender, is_active) VALUES (@name, @gender, @is_active) RETURNING id");
    cmd.Parameters.AddWithValue("name", movie.Name);
    cmd.Parameters.AddWithValue("gender", (object)movie.Gender ?? DBNull.Value);
    cmd.Parameters.AddWithValue("is_active", movie.IsActive);

    movie.Id = (int)await cmd.ExecuteScalarAsync();

    return Results.Ok(movie);

}).WithName("CreateMovie");

app.MapPut("/movieitems/{id}", async (int id, Movie inputMovie, NpgsqlDataSource dataSource) =>
{
    using var dbConnection = dataSource.OpenConnection();

    await using var cmd = dataSource.CreateCommand("UPDATE public.movies SET name = @name, gender = @gender, is_active = @is_active WHERE id = @id");
    cmd.Parameters.AddWithValue("name", inputMovie.Name);
    cmd.Parameters.AddWithValue("gender", (object)inputMovie.Gender ?? DBNull.Value);
    cmd.Parameters.AddWithValue("is_active", inputMovie.IsActive);
    cmd.Parameters.AddWithValue("id", inputMovie.Id);

    var result = await cmd.ExecuteNonQueryAsync();

    return result > 0 ?
    Results.Ok(inputMovie) : 
    Results.NotFound();

});

app.MapDelete("/movieitems/{id}", async (int id, NpgsqlDataSource dataSource) =>
{
    using var dbConnection = dataSource.OpenConnection();

    await using var cmd = dataSource.CreateCommand("DELETE FROM public.movies WHERE id = @id");
    cmd.Parameters.AddWithValue("id", id);

    var result = await cmd.ExecuteNonQueryAsync();

    return result > 0 ?
        Results.Ok() : 
        Results.NotFound();

}).WithName("DeleteMovie");

Pronto, ocorrendo tudo bem, estaremos com o postgres na solução referenciado pelo projeto da API, ou seja, com a informação da utilizaçao da mesma e o Swagger também. Vamos dar build em nossa solução e executar as mesmas, para observar os recursos adicionados no dashboard. Após validarmos a implementação, vamos adicionar nossos endpoints de teste.

Basta executar a aplicação, como já realizado anteriormente, mas agora, teremos um resultado semelhante ao abaixo: Podemos esperar (por um tempo) que um serviço (no nosso caso será o pg-pgadmin) possa demorar um pouco mais para subir. O campo State dele ficaria como este ab-8.png, mas logo ele estará pronto para uso.

ab-7.png

Com a visualizaçao do dashboard, podemoas observar os seguintes recursos:

pg: Tipo container para levantar o servidor postgres de nome pg. Iremos confirmar logo abaixo a criação do mesmo e execução do script para iniciar o banco de dados.

pg-admin: Tipo container para disponibilizar a ferramenta pg-admin, assim sendo possível realizar o gerenciamento do banco de dados.

postgres: Tipo PostgresDatabaseResource, ou seja, nosso banco de dados de nome postgres.

apiservice: Já mencionado acima. Projeto da API de nossa solução.

webfrontend: Já mencionado acima. Projeto frontend de nossa solução, mas não iremos abordar o front end neste artigo.

Repare que para cada recurso (menos o resource do database), temos um endpoint, vamos clicar no endpoint responsável pelo pg-admin e verificar sua abertura, podemos esperar um resultado como o abaixo:

ab-9.png

Repare na imagem nos seguintes aspectos:

pg: Servidor criado, conforme instrução em nosso projeto AppHost.

postgres: Database padrão criada.

movies: Tabela criada de acordo com o script que inserimos na pasta Seed, conforme orientado acima.

Agora vamos validar o Swagger que adicionamos, para verificar e testar todos nossos endpoints e a integração com o postgresql.

No dashboard do .Net Aspire, teremos o recurso apiservice. Vamos clicar no link da coluna endpoints adicionando o caminho /swagger. Devemos esperar um resutado igual ao abaixo:

ab-10.png

Testando a aplicação

Com a aplicação rodando e o swagger aberto, vamos executar essa rotina para validar todos os endpoints, seus retornos e os dados armazenados no postgres.

  • Execute o método post com o json abaixo POST/movieitems:
{
  "name": "Homem de Ferro",
  "gender": "Ação",
  "isActive": true
}
  • Adicione um segundo filme para a lista com o json abaixo POST/movieitems:
{
  "name": "Homem de Ferro 2",
  "gender": "Ação",
  "isActive": true
}
  • Execute a consulta para obter todos os filmes GET/movieitems. Teremos o seguinte resultado:
[
  {
    "id": 1,
    "name": "Homem de Ferro",
    "gender": "Ação",
    "isActive": true
  },
  {
    "id": 2,
    "name": "Homem de Ferro 2",
    "gender": "Ação",
    "isActive": true
  }
]
  • Execute a consulta para obter um filme por id GET/movieitems/{id} utilizando o id 1 como parâmetro de busca. Teremos o seguinte resultado:
{
  "id": 1,
  "name": "Homem de Ferro",
  "gender": "Ação",
  "isActive": true
}
  • Volte para a guia anteriormente aberta do pg-pgadmin, que nos dá acesso ao gerenciamento do banco de dados. Abra uma janela de query nova e utilize a query abaixo para buscar os filmes do banco:
SELECT * FROM public.movies

Observação: Para abrir a janela de query, clique no database postgres e no botão Query Tool, como demonstrado na imagem abaixo. Deveremos esperar um resultado como o da imagem abaixo:

Botão Query Tool:

ab-12.png

Resultado esperado:

ab-11.png

Estes testes já nos mostra que a integração com o banco de dados foi realizada com sucesso. Fica o convite de realizar atualização e deleção para verificar, enquanto realiza queries no banco de dados.

Conclusão

Fica o convite para continuar testando e alterar a forma de conexão com o postgres, pois esta que implementamos foi através do service discovery. Como mencionado no artigo logo acima, temos várias outras formas de realizarmos esta conexão. Este código final executando com sucesso ficará no meu repositório, na branch feature/postgres-integration.

Este foi um artigo grande, denso e fica o convite de voltar e repetir a implementação para praticar. Lembre-se de interagir com o post dando sua opinião, tirando dúvidas ou dando um feedback, que é muito importante para a continuação do projeto.

Se você gostou do conteúdo, deixe um comentário ou uma reação para apoiar o projeto! Compartilhe com alguém e ajude a divulgar!

Até a próxima!

[ ]´s Degas.