Nesse post, utilizaremos o Entity Framework Core em conjunto com o Azure Cosmos DB para criar um pequena aplicação em ASP.NET Core MVC . No meu setup, vou utilizar o Visual Studio For Mac para criar o aplicativo web e o Visual Studio Code para trabalhar com a infraestrutura como código.
Antes de começar, recomendo a leitura de dois posts que já escrevi sobre Azure Cosmos e Azure bicep:
- Introdução ao Azure Cosmos DB + ASP.NET Core. Parte 1;
- Deploy de arquivos bicep com o Azure Pipelines.
Vamos inicialmente criar um projeto web MVC em .NET 7:
Com o projeto criado, podemos adicionar o pacote NuGet Microsoft.EntityFrameworkCore.Cosmos:
Vamos voltar no diretório raiz do projeto, criar um novo diretório chamado infra e um arquivo chamado main.bicep com o seguinte conteúdo:
@description('Cosmos DB account name')
param accountName string = 'cosmos-${uniqueString(resourceGroup().id)}'
@description('Location for the Cosmos DB account.')
param location string = resourceGroup().location
@description('The name for the SQL API database')
param databaseName string
@description('The name for the SQL API container')
param containerName string
resource account 'Microsoft.DocumentDB/databaseAccounts@2022-05-15' = {
name: toLower(accountName)
location: location
properties: {
enableFreeTier: true
databaseAccountOfferType: 'Standard'
consistencyPolicy: {
defaultConsistencyLevel: 'Session'
}
locations: [
{
locationName: location
}
]
}
}
resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2022-05-15' = {
parent: account
name: databaseName
properties: {
resource: {
id: databaseName
}
options: {
throughput: 1000
}
}
}
resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2022-05-15' = {
parent: database
name: containerName
properties: {
resource: {
id: containerName
partitionKey: {
paths: [
'/Id'
]
kind: 'Hash'
}
indexingPolicy: {
indexingMode: 'consistent'
includedPaths: [
{
path: '/*'
}
]
excludedPaths: [
{
path: '/_etag/?'
}
]
}
}
}
}
Esse arquivo bicep basicamente irá criar um Azure Cosmos DB na camada gratuita. Como estou utilizando a extensão bicep, iremos fazer o deploy desse recurso de nuvem por meio do VS Code.
Antes do deploy, devemos criar um arquivo de parâmetros:
Vamos utilizar os seguintes valores para o nosso Azure Cosmos DB
Com o arquivo de parâmetros criado, podemos fazer o deploy.
Como não temos nenhum grupo de recursos criado, iremos criar um durante o deploy do arquivo bicep.
Vamos chamá-lo de:
rg-app-eastus
Após a finalização, teremos a seguinte estrutura de arquivos e diretórios em nossa solução:
Voltando ao VS for Mac, iremos criar a entidade que será salva no Azure cosmos DB. Vamos chamá-la de Folder:
using System;
namespace App.Web.Models
{
public class Folder
{
public Guid Id { get; set; }
public Guid? ParentId { get; set; }
public string Name { get; set; }
public Folder(Guid? parentId, string name)
{
Id = Guid.NewGuid();
ParentId = parentId;
Name = name;
}
}
}
Durante o fluxo de exibição de dados na View (recomendo a leitura sobre o padrão de projeto MVC), iremos reordenar essa estrutura para uma árvore de dados.
Vamos criar a classe principal para o Entity Framework Core, o CosmosContext que herda de DbContext.
using App.Web.Models;
using Microsoft.EntityFrameworkCore;
namespace App.Web.Data
{
public class CosmosContext : DbContext
{
public CosmosContext(DbContextOptions<CosmosContext> options)
: base(options)
{
}
public DbSet<Folder> Folders { get; set; }
protected override void
OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Folder>(x =>
{
x.ToContainer("Folders");
x.HasKey(x => x.Id);
x.HasPartitionKey(x => x.Id);
});
}
}
}
Observe que definimos como a entidade Folder é modelada no banco de dados Azure Cosmos DB. Definimos sua Key, Partition key e o container. Eu já escrevi alguns posts sobre esse recursos de nuvem, vale a leitura!
Iremos criar um repositório (interface + classe), que será responsável por buscar entidades de Folders do banco de dados, além de criar novos registros.
using App.Web.Models;
namespace App.Web.Data.Repositories
{
public interface IFolderRepository
{
Task<IEnumerable<Folder>> GetllAllAsync();
Task AddAsync(Folder folder);
}
}
using App.Web.Models;
using Microsoft.EntityFrameworkCore;
namespace App.Web.Data.Repositories
{
public class FolderRepository : IFolderRepository
{
private readonly CosmosContext _cosmosContext;
public FolderRepository(CosmosContext cosmosContext)
{
_cosmosContext = cosmosContext;
}
public async Task AddAsync(Folder folder)
{
await _cosmosContext.AddAsync(folder);
await _cosmosContext.SaveChangesAsync();
}
public async Task<IEnumerable<Folder>> GetllAllAsync()
{
return await _cosmosContext.Folders.ToListAsync();
}
}
}
Esses itens ficarão em um diretório chamado Data.
Para trabalharmos com estruturas de árvore, devemos criar a classe TreeNode<T> e TreeNodeHelper
namespace App.Web.Utils
{
public class TreeNode<T>
{
public T Id { get; set; } = default!;
public T? ParentId { get; set; }
public string Name { get; set; } = default!;
public List<TreeNode<T>> Children { get; set; } = new List<TreeNode<T>>();
}
}
namespace App.Web.Utils
{
public static class TreeNodeHelper
{
public static IEnumerable<TreeNode<T>> FetchChildren<T>(this TreeNode<T> root, List<TreeNode<T>> nodes)
{
return nodes.Where(n =>
n.ParentId is not null &&
n.ParentId.Equals(root.Id));
}
public static void RemoveChildren<T>(this TreeNode<T> root, List<TreeNode<T>> nodes)
{
foreach (var node in root.Children)
{
nodes.Remove(node);
}
}
public static TreeNode<T> BuildTree<T>(this TreeNode<T> root, List<TreeNode<T>> nodes)
{
if (nodes.Count == 0) { return root; }
var children = root.FetchChildren(nodes).ToList();
root.Children.AddRange(children);
root.RemoveChildren(nodes);
for (int i = 0; i < children.Count; i++)
{
children[i] = children[i].BuildTree(nodes);
if (nodes.Count == 0) { break; }
}
return root;
}
}
}
Vale comentar que essa implementação de árvore de dados é uma das inúmeras implementações possíveis!
Para servir como modelo para a nossa View, devemos criar a HomeIndexViewModel
using System.ComponentModel.DataAnnotations;
namespace App.Web.ViewModels
{
public class HomeIndexViewModel
{
public Guid? ParentFolderId { get; set; }
[Required]
[Display(Name = "Folder Name")]
public string? FolderName { get; set; } = default!;
}
}
Temos a nossa controladora:
using Microsoft.AspNetCore.Mvc;
using App.Web.Utils;
using App.Web.Data.Repositories;
using App.Web.ViewModels;
using App.Web.Models;
namespace App.Web.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
private readonly IFolderRepository _folderRepository;
public HomeController(ILogger<HomeController> logger, IFolderRepository folderRepository)
{
_logger = logger;
_folderRepository = folderRepository;
}
public async Task<IActionResult> IndexAsync()
{
ViewBag.Data = await GenerateFoldersAsync();
var viewModel = new HomeIndexViewModel();
return View(viewModel);
}
[HttpPost]
public async Task<IActionResult> IndexAsync(HomeIndexViewModel viewModel)
{
if (ModelState.IsValid)
{
var folder = new Folder(viewModel.ParentFolderId, viewModel.FolderName!);
await _folderRepository.AddAsync(folder);
return RedirectToAction(nameof(Index));
}
ViewBag.Data = await GenerateFoldersAsync();
return View(viewModel);
}
private async Task<List<TreeNode<Guid>>> GenerateFoldersAsync()
{
var folders = await _folderRepository.GetllAllAsync();
var data = new List<TreeNode<Guid>>();
foreach (var rootFolder in folders.Where(x => x.ParentId is null))
{
var rootData = new TreeNode<Guid>
{
Id = rootFolder.Id,
ParentId = default,
Name = rootFolder.Name,
};
data.Add(rootData.BuildTree<Guid>(
folders.Select(x =>
new TreeNode<Guid>
{
Id = x.Id,
ParentId = x.ParentId ?? default,
Name = x.Name
})
.ToList()));
}
return data;
}
}
Ela basicamente possui duas actions:
- Index (GET): Retorna dados de Folders em uma árvore de dados;
- Index (POST): Recebe dados via HomeIndexViewModel, faz as validações do Model, adiciona um novo registro de Folder ao banco de dados e redireciona para a action Index (GET).
Dito isso, podemos criar a partial view _Folder.cshtml e a View principal Index.cshtml
@using App.Web.Utils;
@model (List<TreeNode<Guid>> data, int index)
@{
var data = Model.data;
var index = Model.index;
var paddingLeft = $"{(20 * index)}px";
}
@foreach (var item in data
.OrderByDescending(x => x.Children.Any())
.ThenBy(x => x.Name)
.Select(x => new { Node = x, ComponentId = Guid.NewGuid() }))
{
<div class="row">
<div class="col" style="padding-left:@paddingLeft">
<input class="justify-content-start" form-check-input folder" type="checkbox">
<span>@(item.Node.Name)</span>
<input class="form-check-input folderId" value="@item.Node.Id" type="hidden">
</div>
</div>
@if (item.Node.Children.Any())
{
<partial name="_folder" model="(item.Node.Children, index + 1)" />
}
}
@using App.Web.ViewModels;
@using App.Web.Utils
@model HomeIndexViewModel
@{
ViewData["Title"] = "Home Page";
var data = ViewBag.Data as List<TreeNode<Guid>>;
}
<div class="container">
<form asp-action="Index">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="ParentFolderId"/>
<div class="row mb-3">
<div class="col-4">
<label asp-for="FolderName" class="form-label"></label>
<input asp-for="FolderName" class="form-control">
<span asp-validation-for="FolderName" class="text-danger"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<partial name="_Folder" model="(data, 0)" />
</div>
</div>
<div class="row">
<div class="col-2">
<button class="btn btn-primary" type="submit">
Add folder
</button>
</div>
</div>
</form>
</div>
No arquivo site.js, devemos adicionar o seguinte código jQuery:
var cleanSelection = function ()
{
$(this).prop('checked', false);
}
var onClick = function () {
var checked = $(this).is(":checked");
// Clean all checkboxes
$("input:checkbox.folder").each(cleanSelection)
$(this).prop('checked', checked);
if (checked) {
var parent = $(this).parent();
var children = parent.children();
var parentId = children.filter(".folderId")[0].value;
$("#ParentFolderId").val(parentId);
}
else {
$("#ParentFolderId").val(null);
}
};
$("input[type=checkbox]").on("click", onClick);
Temos a seguinte estrutura de arquivos:
Para finalizar o desenvolvimento, devemos adicionar o seguinte trecho de código no Program.cs. Isso é necessário para configurar a injeção de dependência do repositório IFolderRepository e o Azure Cosmos DB como banco de dados para o Entity Framework Core.
builder.Services.AddScoped<IFolderRepository, FolderRepository>();
builder.Services.AddDbContext<CosmosContext>(opt =>
opt.UseCosmos(
connectionString: "YOUR-CONNECTION-STRING",
databaseName: "MyDb"));
O valor da connection string pode ser obtido por meio do portal do Azure, como mostrado na imagem abaixo:
Vamos executar o projeto e adicionar algumas instâncias de Folder:
a
Tudo funcionou corretamente!
Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!
Até a próxima, abraços!