Fermer

septembre 16, 2025

.NET Aspire 5: Orchestration et découverte de services

.NET Aspire 5: Orchestration et découverte de services


Dans la partie 5, nous introduisons une toute nouvelle API des commandes, apprenons à parler à l’inventaire et à voir comment l’orchestrateur Aspire gère tout le câblage désordonné pour nous.

Ceci est la partie 5 de notre plongée profonde en six parties dans .NET Aspire.

Bienvenue, amis. Nous cuisinons vraiment avec du gaz maintenant.

Jusqu’à présent, nous avons:

  • Dit «Bonjour, monde» à .net Aspire et a appris pourquoi un modèle d’application bat un dossier plein de fichiers docker ad hoc
  • Poké autour du tableau de bord du développeur pour regarder notre application s’allumer en temps réel
  • J’ai vu comment le service par défaut a ajouté des capacités d’opinion comme l’OpenTelemetry, les sondes de santé et la résilience sur chaque service avec une ligne de code
  • SQL Server branché et redis dans le mixage

Nous nous sommes beaucoup amusés jusqu’à présent, mais jusqu’à présent, nous avons organisé une fête de un: une API d’inventaire qui répertorie les guitares à vendre. Aujourd’hui, nous présenterons une toute nouvelle API des commandes, nous apprendrons à parler à l’inventaire et voir comment l’orchestrateur Aspire gère tout le câblage désordonné pour nous.

Note: Cette série se concentre sur .NET Aspire et non sur les API, je suppose donc que vous avez une connaissance pratique générale de la façon d’écrire des API C #. J’expliquerai un nouveau code le cas échéant.

Pourquoi l’orchestration est importante

Pourquoi l’orchestration est-elle importante? Docker Compose et Kubernetes aident absolument à planifier des conteneurs, mais ils vivent en dehors de votre base de code. Votre pipeline CI doit jongler avec un tas de fichiers YAML, garder les ports en synchronisation et prier que personne ne commette un point de terminaison codé dur par erreur.

Avec Aspire, l’orchestration revient dans C #. Vous déclarez ce qui existe et comment les pièces sont liées.

  • Cycle de vie: Les bases de données sont disponibles en ligne avant les API qui en dépendent.
  • Configuration: Les chaînes de connexion, les secrets et les URL circulent dans les variables environnementales.
  • Résilience et télémétrie: Opt en une fois avec AddServiceDefaults() Et chaque appel sortant est enveloppé de tentatives de tentatives, de séances de temps et de travées d’opentelémétrie.

En bref, avec Aspire Orchestration est le centre central qui connaît le graphique de dépendance, regarde les sondes de santé et protège les secrets. Plus en fonction d’une date README.md et lancer neuf terminaux.

Revisiter le service par défaut

Avant de commencer à câbler de nouveaux services, revisitons les défaillances du service. Dans notre post précédent, nous avons vu comment un seul appel à builder.AddServiceDefaults() Ajoute une instrumentation OpenteLemetry, des sondes de santé, une découverte de services et un pipeline de résilience. Ces valeurs par défaut s’appliquent à chaque projet de base ASP.NET qui s’oppose en appelant builder.AddServiceDefaults().

Cela permet aux valeurs par défaut de service de faire le gros du travail pour l’orchestration. Lorsque l’apphost injecte des variables d’environnement comme ConnectionStrings__guitardbnos services les ramassent automatiquement via la liaison de configuration. La télémétrie s’écoule vers le tableau de bord sans aucun code supplémentaire. Et quand nous appelons un autre service via HttpClientle gestionnaire de résilience standard ajoute des tentatives, des délais d’attente et des disjoncteurs.

Nous ne remanerons pas tous les détails de la mise en œuvre ici – voir Partie 3 pour des détails approfondis. Cependant, gardez à l’esprit que tout ce que nous construisons dans ce post repose sur ces conventions.

Rencontrez l’API ORDERS

Notre magasin de guitare se compose actuellement d’un frontend Blazor et d’un seul inventaire (/guitars) API qui gère les opérations de base de la création de lecture-readate (CRUD). Nous présenterons maintenant une API ORDERS.

L’API ORDERS sera:

  1. Acceptez une commande de notre application Blazor
  2. Pour chaque élément de ligne, demandez à l’API d’inventaire si le produit existe et a des stocks
  3. Si la validation passe, calculer la taxe, persister l’ordre et répondre 201 Created avec un résumé de la commande

Note: Pour plus de clarté et de concision, le code suivant est sous un seul point de terminaison. Pour une application de production «réelle», une grande partie de cette logique vivra dans d’autres parties de votre application.

Construire le point final

Créons l’API ORDERS. Jetons un coup d’œil à notre POST point de terminaison. Je vais vous guider à travers ensuite.

app.MapPost("/orders", async (CreateOrderRequest req,
                              OrdersDbContext db,
                              InventoryClient inventory) =>
{
    if (!req.Lines.Any())
        return Results.BadRequest("At least one line is required.");

    var validatedLines = new List<OrderLine>();
    foreach (var line in req.Lines)
    {
        var product = await inventory.GetAsync(line.ProductId);
        if (product is null) return Results.BadRequest($"Product {line.ProductId} not found");
        if (product.Stock < line.Quantity) return Results.BadRequest($"Insufficient stock for {product.Sku}");

        validatedLines.Add(new OrderLine
        {
            ProductId  = line.ProductId,
            Quantity   = line.Quantity,
            UnitPrice  = product.Price
        });
    }

    var subtotal = validatedLines.Sum(l => l.LineTotal);
    var tax = Math.Round(subtotal * 0.05m, 2);

    var order = new Order
    {
        CustomerName = req.CustomerName,
        Subtotal     = subtotal,
        Tax          = tax,
        Lines        = validatedLines
    };

    db.Orders.Add(order);
    await db.SaveChangesAsync();

    return Results.Created($"/orders/{order.Id}",
        new { order.Id, order.OrderNumber, order.Subtotal, order.Tax, order.Total });
});

Que vient-il de se passer?

  1. Nous utilisons une clause de garde pour rejeter une commande vide.
  2. Nous utilisons la validation de service croisé pour vérifier l’inventaire, nous ne supposons donc pas qu’un produit est en stock.
  3. Nous calculons la taxe.
  4. Nous utilisons le noyau de Framework Entity pour écrire l’ordre dans un OrdersDb base de données.
  5. Et nous répondons avec un 201 Created avec la nouvelle ressource.

Comment les commandes parlent-elles à l’inventaire?

Dans nos nouvelles API Program.csnous enregistrons un client HTTP:

builder.Services.AddHttpClient<InventoryClient>(client =>
    client.BaseAddress = new Uri("https+http://inventory"));

L’uri a l’air un peu funky: remarquez le régime (https+http) et l’hôte (inventory).

  • https+http dit au résolveur d’Aspire d’essayer d’abord HTTPS, puis de retomber à HTTP.
  • Comme vous l’avez probablement remarqué, inventory n’est pas un nom DNS. C’est le nom du service canonique que nous définirons dans le AppHost.

Si vous vous en souvenez, nous l’avons défini plus tôt:

var inventoryApi = builder.AddProject<Projects.Api>("inventory")
    .WithReference(inventoryDb)
    .WithReference(cache)
    .WaitFor(inventoryDb);

Avec cela en place, Aspire injecte l’URL réelle via la configuration. En conséquence, nous ne nécessitons pas de numéros de port, une configuration locale ou spécifique à l’environnement. Au moment de l’exécution, Aspire injecte deux variables d’environnement:

Services__inventory__https = https://localhost:6001
Services__inventory__http  = http://localhost:5001

Le résolveur échange l’uri d’espace réservé pour le véritable point final. Juste comme ça, Service Discovery gère tout cela.

Améliorer le frontend Blazor

Notre nouveau service n’est pas utile si nos clients ne peuvent pas le voir. Voyons maintenant les trois composants qui présentent l’API ORDERS.

Chaque composant utilise HttpClient Services afin qu’ils héritent de la télémétrie, de la résilience et de la découverte de services hors de la boîte.

builder.Services.AddHttpClient<OrdersHttpClient>(client =>
    client.BaseAddress = new Uri("https+http://orders"))
        .AddServiceDiscovery()           
        .AddStandardResilienceHandler();

Page de liste de commandes

Pour une liste de commandes, nous utilisons une grille paginée qui vous permet de cliquer sur une ligne pour plus de détails. Il prend également en charge les suppressions en ligne sans rafraîchissement de page.

@page "/orders"
@inject OrdersHttpClient Http
@inject NavigationManager Nav
@inject IJSRuntime JS

<PageTitle>Orders</PageTitle>
<div class="container py-4">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h1 class="display-6 d-flex gap-2 mb-0">
            <span>🧾</span> Orders
        </h1>
        <button class="btn btn-primary" @onclick="CreateNewOrder">
            ➕ New Order
        </button>
    </div>

    @if (_orders is null)
    {
        <p>Loading…</p>
    }
    else
    {
        <table class="table table-striped">
            <thead class="table-light small text-uppercase">
                <tr>
                    <th>#</th>
                    <th>Date</th>
                    <th>Customer Name</th>
                    <th>Total</th>
                    <th>Delete?</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var o in _orders)
                {
                    <tr style="cursor:pointer"
                        @onclick="@(() => Nav.NavigateTo($"/orders/{o.Id}"))">
                        <td>@o.OrderNumber</td>
                        <td>@o.CreatedUtc.ToString("yyyy-MM-dd")</td>
                        <td>@o.CustomerName</td>
                        <td>@o.Total.ToString("C")</td>
                        <td>
                            <button class="btn btn-sm btn-link text-danger"
                                    title="Delete"
                                    @onclick:stopPropagation
                                    @onclick="() => DeleteOrder(o.Id)">
                                🗑
                            </button>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    }
</div>

@code {
    private List<OrderSummaryDto>? _orders;

    protected override async Task OnInitializedAsync()
        => _orders = (await Http.GetOrdersAsync()).ToList();

    async Task DeleteOrder(Guid id)
    {
        bool ok = await JS.InvokeAsync<bool>("confirm", "Delete this order?");
        if (!ok) return;

        var resp = await Http.DeleteOrderAsync(id);
        if (resp.IsSuccessStatusCode)
        {
            var row = _orders.FirstOrDefault(x => x.Id == id);
            if (row is not null)
            {
                _orders.Remove(row);    
                StateHasChanged();      
            }
        }
        else
        {
            await JS.InvokeVoidAsync("alert", $"Delete failed – {resp.StatusCode}");
        }
    }

    private void CreateNewOrder() => Nav.NavigateTo("/create-order");
}

Voici la page de la liste des commandes terminées.

liste d'ordre

Page de détails sur commande

Avec l’ensemble de page de liste de commandes, nous pouvons créer une page de détails de commande. Cette page affiche la facture complète avec la tarification des éléments lignes tirée directement du serveur.

@page "/orders/{Id:guid}"
@inject OrdersHttpClient Http

<h1 class="mb-3">Order @Id</h1>

@if (_order is null)
{
    <p>Loading…</p>
}
else
{
    <p><b>Customer:</b> @_order.CustomerName</p>
    <p><b>Date:</b> @_order.CreatedUtc.ToString("u")</p>

    <table class="table">
        <thead>
            <tr>
                <th>Product</th>
                <th class="text-end">Qty</th>
                <th class="text-end">Line Total</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var l in _order!.Lines)
            {
                <tr>
                    <td>@l.ProductName</td>
                    <td class="text-end">@l.Quantity</td>
                    <td class="text-end">@l.LineTotal.ToString("C")</td>
                </tr>
            }
        </tbody>
        <tfoot>
            <tr><td colspan="2" class="text-end">Subtotal</td><td class="text-end">@_order.Subtotal.ToString("C")</td></tr>
            <tr><td colspan="2" class="text-end">Tax</td><td class="text-end">@_order.Tax.ToString("C")</td></tr>
            <tr class="fw-bold"><td colspan="2" class="text-end">Total</td><td class="text-end">@_order.Total.ToString("C")</td></tr>
        </tfoot>
    </table>
}

@code {
    [Parameter] public Guid Id { get; set; }
    private OrderDetailDto? _order;

    protected override async Task OnParametersSetAsync()
        => _order = await Http.GetOrderAsync(Id);
}

Nous pouvons alors facilement afficher une ventilation d’une commande:

OrderDetails

Créer la page de commande

Nous avons également construit un formulaire convivial qui interroge l’API d’inventaire pour le catalogue de guitare, permet à l’utilisateur de commander plusieurs éléments de ligne, puis de calculer le côté client.

@page "/create-order"
@using Entities
@inject InventoryHttpClient Inventory
@inject OrdersHttpClient    Orders
@inject NavigationManager   Nav
@inject IJSRuntime          JS

<PageTitle>Create Order</PageTitle>

@if (_guitars is null)
{
    <p class="m-4">Loading catalog…</p>
    return;
}

<div class="container py-4" style="max-width:720px">
    <h1 class="display-6 mb-4">➕ Create Order</h1>
    <div class="mb-3">
        <label class="form-label fw-semibold">Customer name</label>
        <InputText @bind-Value="_customerName" class="form-control" />
    </div>
    <EditForm Model="_draft" OnValidSubmit="AddLine">
        <div class="row g-2 align-items-end">
            <div class="col-7">
                <label class="form-label">Product</label>
                <InputSelect TValue="Guid?" @bind-Value="_draft.ProductId" class="form-select">
                    <option value="">select guitar ‒</option>
                    @foreach (var g in _guitars)
                    {
                        <option value="@g.Id">
                            @($"{g.Brand} {g.Model} — {g.Price:C}")
                        </option>
                    }
                </InputSelect>
            </div>
            <div class="col-2">
                <label class="form-label">Qty</label>
                <InputNumber @bind-Value="_draft.Quantity" class="form-control" min="1" max="10" />
            </div>
            <div class="col-3 text-end">
                <label class="form-label invisible">btn</label>
                <button class="btn btn-outline-primary w-100" disabled="@(!_draft.IsValid)">
                    Add
                </button>
            </div>
        </div>
    </EditForm>

    @if (_lines.Any())
    {
        <table class="table table-sm table-hover my-4">
            <thead class="table-light small text-uppercase">
                <tr>
                    <th>Product</th>
                    <th class="text-end">Qty</th>
                    <th class="text-end">Line&nbsp;Total</th>
                    <th></th>
                </tr>
            </thead>
            <tbody>
                @foreach (var l in _lines)
                {
                    var g = _guitarsById[l.ProductId];
                    <tr>
                        <td>@g.Brand @g.Model</td>
                        <td class="text-end">@l.Quantity</td>
                        <td class="text-end">@((g.Price * l.Quantity).ToString("C"))</td>
                        <td class="text-end">
                            <button class="btn btn-sm btn-link text-danger"
                                    @onclick="() => RemoveLine(l)"></button>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
        <div class="text-end mb-3">
            <div>Subtotal: <strong>@_subtotal.ToString("C")</strong></div>
            <div>Tax (5%): <strong>@_tax.ToString("C")</strong></div>
            <div class="fs-5">Total: <strong>@_total.ToString("C")</strong></div>
        </div>
        <button class="btn btn-success" @onclick="SubmitOrder">Submit Order</button>
    }
</div>

@code {
    private IReadOnlyList<GuitarDto>? _guitars;
    private Dictionary<Guid, GuitarDto> _guitarsById = new();
    private readonly List<CreateOrderLine> _lines = [];
    private readonly LineDraft _draft = new();
    private string _customerName = "Web Customer";

    private decimal _subtotal, _tax, _total;

    protected override async Task OnInitializedAsync()
    {
        _guitars = await Inventory.GetGuitarsAsync();
        _guitarsById = _guitars.ToDictionary(g => g.Id);
    }

    void AddLine()
    {
        if (!_draft.IsValid) return;

        _lines.Add(new CreateOrderLine(_draft.ProductId!.Value, _draft.Quantity));
        _draft.Reset();
        RecalcTotals();
    }

    void RemoveLine(CreateOrderLine l)
    {
        _lines.Remove(l);
        RecalcTotals();
    }

    void RecalcTotals()
    {
        _subtotal = _lines.Sum(l => _guitarsById[l.ProductId].Price * l.Quantity);
        _tax      = Math.Round(_subtotal * 0.05m, 2);
        _total    = _subtotal + _tax;
    }

    async Task SubmitOrder()
    {
        var req  = new CreateOrderRequest(_customerName, _lines);
        var resp = await Orders.SubmitOrderAsync(req);

        if (resp.IsSuccessStatusCode)
            Nav.NavigateTo("/orders");
        else
            await JS.InvokeVoidAsync("alert", $"Order failed – {resp.StatusCode}");
    }

    private class LineDraft
    {
        public Guid? ProductId { get; set; }
        public int   Quantity  { get; set; } = 1;

        public bool IsValid => ProductId.HasValue && Quantity > 0;

        public void Reset()
        {
            ProductId = null;
            Quantity  = 1;
        }
    }
}

Voici notre nouvelle page de commande de création.

création

Avec ces trois composants de rasoir en place, l’interface utilisateur consomme désormais l’API complète des ordres – et, transititivement, l’API d’inventaire – le tout sans codant dur une seule URL.

Enregistrer sur l’AppHost

Avec notre nouveau service en place, orchestrons-les. Regardons la finale Program.cs Pour le projet AppHost:

var builder = DistributedApplication.CreateBuilder(args);

var password = builder.AddParameter("password", secret: true);
var server = builder.AddSqlServer("server", password, 1433)
        .WithDataVolume("guitar-data")
        .WithLifetime(ContainerLifetime.Persistent);

var inventoryDb = server.AddDatabase("guitardb");
var orderDb = server.AddDatabase("ordersdb");

var cache = builder.AddRedis("cache")
            .WithRedisInsight()
            .WithLifetime(ContainerLifetime.Persistent);

var inventoryApi = builder.AddProject<Projects.Api>("inventory")
    .WithReference(inventoryDb)
    .WithReference(cache)
    .WaitFor(inventoryDb);

var ordersApi = builder.AddProject<Projects.OrdersApi>("orders")
        .WithReference(orderDb)
        .WithReference(inventoryApi)
        .WaitFor(orderDb);  

builder.AddProject<Projects.Frontend>("frontend")
    .WithReference(inventoryApi)
    .WithReference(ordersApi)
    .WaitFor(inventoryApi)
    .WaitFor(ordersApi)
    .WithExternalHttpEndpoints();

builder.Build().Run();

Lorsque vous exécutez le projet à travers AppHostl’orchestrateur tourne SQL Server, l’API d’inventaire et l’API Orders, le tout dans le bon ordre.

Chaque projet reçoit des variables d’environnement pointant ses dépendances. Parce que nous avons appelé AddServiceDefaults Dans nos deux API, ils lisent automatiquement ces variables.

Par exemple, le service d’inventaire se lit ConnectionStrings__guitardb Pour configurer EF Core, et le service des commandes se lit Services__inventory__https Pour configurer son client HTTP.

Avec plusieurs services, les points de terminaison de santé intégrés sont encore plus importants. Notre AppHost utilise ces points de terminaison pour décider quand un service est prêt. Si le service d’inventaire échoue à sa sonde de santé, le service des commandes attend qu’il soit sain. Si un appel en aval échoue à plusieurs reprises, le disjoncteur configuré par AddServiceDefaults empêche la submergence de la dépendance.

Observer le flux final à fin

Lançons notre solution complète et observons ce qui se passe. Du terminal, exécutez:

dotnet run --project GuitarShop.AppHost

Aspire construit chaque projet, crée un conteneur SQL Server, lance les services d’inventaire et de commande et ouvre le tableau de bord du développeur. Sur Ressources Page Vous verrez ce que nous avons construit jusqu’à présent.

graphique de ressources

Cliquer sur orders révèle ses variables d’environnement – Services__inventory Entrée pointant vers les points de terminaison réels.

inventaire

Passons une commande du frontend. Ouvrir le Traces Onglet et vous verrez une portée pour le poste entrant /orders Demande, un enfant à l’échelle pour le sortant /guitars/{id} Appel à l’inventaire. Cela confirme que notre instrumentation fonctionne – toute la chaîne est capturée et visualisée.

chaîne entière

Envelopper (et quelle est la prochaine)

Dans cet article, les choses sont devenues réelles: nous avons ajouté notre premier scénario d’orchestration réel à la boutique de guitare de Dave. Nous avons construit un nouveau service de commandes aux côtés de notre service d’inventaire existant, utilisé le service d’Aspire par défaut pour ajouter de la télémétrie et de la résilience, et avons tout orchestré via l’AppHost. L’AppHost déclare désormais des bases de données distinctes pour les stocks et les commandes et injecte des chaînes de connexion dans les services.

Dans la dernière partie de cette série, nous allons prendre notre projet de nos ordinateurs portables et au cloud. Nous verrons comment Aspire Azure Container Apps Integration Maps Maps Nos SQL et Redis Resources aux offres Azure et comment la même définition AppHost peut être utilisée pour déployer toute notre boutique de guitare avec un seul az containerapp up commande.

Restez à l’écoute et à bientôt!




Source link