DEV Community

Thang Chung
Thang Chung

Posted on

Orchestrating Distributed Apps (Spin/Rust and .NET/C#) with .NET Aspire/Dapr

Introduction

If you have followed me long enough, then you know that I strongly believe that WebAssembly (WASI) will become the popular choice for running and development workloads in the future (and it is happening now). It is more efficient and usable if it can work with other apps built with current container technology (on Docker or Podman), see https://dev.to/thangchung/series/24617.

In the journey of discovering all possibilities in this mechanism, we look to enhance the DevEx when developing the Spin app on the local machine. To experiment with some innovation ideas, I have a look at .NET Aspire (just released a new version with .NET 9 a couple of months ago).

We built this sample because .NET Aspire is very cool in enhancing DevEx for developers, and my question is whether we can combine .NET Aspire to develop .NET apps with Spin Apps altogether. And one more thing, let's say we build a custom action on .NET Aspire to allow re-build the Spin app on the dashboard, then it should be very cool, right?

That being said, the Fermyon team built something very cool at https://github.com/fermyon/Aspire.Spin, but we want something simpler, just for running and building Spin apps in the .NET Aspire dashboard. This post will explore the ability to do that and add a new custom task on .NET Aspire dashboard to rebuild the Spin app without the need to stop and start solution again (.NET Aspire community has worked very actively to bring up the ability to work on various programming languages as well as web frameworks at https://github.com/CommunityToolkit/Aspire/tree/main/examples, but again we would like to re-build the task on .NET Aspire to do just very simple spin build command, then reload the app state).

Let's get started...

High level of the sample distributed apps

High level of the sample distributed apps

  1. .NET app calls to Spin app using service invocation (Dapr)
  2. .NET app publishes an event (PingEvent) to Spin App (pub-sub via Dapr)
  3. Spin app subscribes to the event (PingEvent) from step 2, processing, and then publishes an event (PongEvent) back to the .NET app.

Preprequisites

  • .NET 9
dotnet --list-sdks
9.0.100 [C:\Program Files\dotnet\sdk]
Enter fullscreen mode Exit fullscreen mode
  • Rust & Spin CLI
cargo --version
cargo 1.83.0 (5ffbef321 2024-10-29)

spin --version
spin 3.1.1 (aa919ce 2024-12-20)
Enter fullscreen mode Exit fullscreen mode
  • Dapr CLI
dapr version
CLI version: 1.14.1
Runtime version: 1.14.4
Enter fullscreen mode Exit fullscreen mode

The solution setup

The solution setup

Source code for the whole solution: https://github.com/thangchung/dapr-labs/tree/main/aspire

.NET app - Minimal API (.NET 9)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors();

builder.AddServiceDefaults();

builder.Services.AddOpenApi();

builder.Services.AddDaprClient();
builder.Services.AddSingleton(new JsonSerializerOptions()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    PropertyNameCaseInsensitive = true,
});

var app = builder.Build();

app.MapDefaultEndpoints();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseCors(x => x.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());

app.UseRouting();

app.UseCloudEvents();

app.MapSubscribeHandler();

app.MapGet("/", () => Results.Redirect("/scalar/v1"))
    .ExcludeFromDescription();

app.MapGet("/item-types", async (DaprClient client, IConfiguration config) =>
{
    var res = await client.InvokeMethodAsync<List<ItemTypeDto>>(
        HttpMethod.Get, 
        config.GetValue<string>("TestSpinApp"), 
        "v1-get-item-types");

    var curActivity = Activity.Current;
    curActivity?.AddBaggage("method-name", "item-types");

    return res;
})
.WithName("GetItemTypes");

app.MapPost("/ping", async (DaprClient client) =>
{
    await client.PublishEventAsync("pubsub", "pinged", new { Id = Guid.NewGuid() });
    return Results.Ok();
});

app.MapPost("/pong", [Topic("pubsub", "ponged")] async (Pong pong) =>
{
    Console.WriteLine($"Pong received: {pong.Id}");
    return Results.Ok();
}).ExcludeFromDescription();

app.Run();

internal record ItemTypeDto(string Name, int ItemType, float Price, string Image);
internal record Pong(Guid Id);

[ExcludeFromCodeCoverage]
public partial class Program;
Enter fullscreen mode Exit fullscreen mode

Spin app (Rust)

const PUB_SUB_NAME: &str = "pubsub";

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Pinged {
    pub id: Uuid,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Ponged {
    pub id: Uuid,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct ItemType {
    name: String,
    item_type: i8,
    price: f32,
    image: String,
}

impl TryFrom<&[u8]> for Pinged {
    type Error = anyhow::Error;

    fn try_from(value: &[u8]) -> std::result::Result<Self, Self::Error> {
        serde_json::from_slice::<Pinged>(value)
            .with_context(|| "Could not deserialize value into Pinged model")
    }
}

fn init_logger() -> Result<()> {
    const LOG_LEVEL_CONFIG_VARIABLE: &str = "loglevel";

    let level: LevelFilter = variables::get(LOG_LEVEL_CONFIG_VARIABLE)?
        .parse()
        .map_err(|e| anyhow!("parsing log level: {e}"))?;

    SimpleLogger::new()
        .with_level(level)
        .init()?;

    Ok(())
}

/// A simple Spin HTTP component.
#[http_component]
fn handle_test_spin(req: Request) -> anyhow::Result<impl IntoResponse> {
    info!("method={}, uri={}", req.method(), req.uri());
    init_logger()?;
    let mut router = Router::default();
    router.get("/", get_home_handler);
    router.get("/v1-get-item-types", get_item_types_handler);
    router.post_async("/pinged", post_ping_handler);
    router.get("/dapr/subscribe", get_dapr_subscribe_handler);
    Ok(router.handle(req))
}

fn get_home_handler(_: Request, _: Params) -> Result<impl IntoResponse> {
    Ok(Response::builder()
        .status(200)
        .header("content-type", "text/plain")
        .body("Hello, Fermyon")
        .build())
}

fn get_item_types_handler(_: Request, _: Params) -> Result<impl IntoResponse> {
    let items = json!(get_item_types());
    let result = bytes::Bytes::from(items.to_string());
    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(Some(result))
        .build())
}

fn get_item_types() -> Vec<ItemType> {
    vec![
        ItemType {
            name: "CAPPUCCINO".to_string(),
            item_type: 0,
            price: 4.5,
            image: "img/CAPPUCCINO.png".to_string(),
        },
        ItemType {
            name: "COFFEE_BLACK".to_string(),
            item_type: 1,
            price: 3.0,
            image: "img/COFFEE_BLACK.png".to_string(),
        }
    ]
}

fn get_dapr_subscribe_handler(_: Request, _params: Params) -> Result<impl IntoResponse> {
    let model = json!([
        {
            "pubsubname": PUB_SUB_NAME,
            "topic": "pinged",
            "routes": {
              "rules": [
                {
                  "match": "event.type == 'pinged'",
                  "path": "/pinged"
                },
              ],
              "default": "/pinged"
            }
        }
    ]);

    let result = bytes::Bytes::from(model.to_string());

    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(Some(result))
        .build())
}

async fn post_ping_handler(req: Request, _params: Params) -> Result<impl IntoResponse> {
    let dapr_url = variables::get("dapr_url")?;
    info!("# dapr_url: {}", dapr_url);

    let Ok(model) = Pinged::try_from(req.body()) else {
        return Ok(Response::builder()
            .status(400)
            .body(Some("Something wrong."))
            .build());
    };

    info!("post_ping_handler: {:?}", json!(model).to_string());

    pub_ponged(
        dapr_url.as_str(),
        PUB_SUB_NAME,
        "ponged",
        Ponged { id: model.id },
    ).await;

    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(Some(""))
        .build())
}

async fn pub_ponged(dapr_url: &str, pubsub_name: &str, topic: &str, e: Ponged) {
    let url = format!("{}/v1.0/publish/{}/{}", dapr_url, pubsub_name, topic);
    info!("pub_ponged: {:?}", url.to_string());
    info!("pub_ponged: {:?}", json!(e).to_string());

    let body = bytes::Bytes::from(json!(e).to_string());
    let result = spin_sdk::http::send::<_, Response>(
        RequestBuilder::new(Method::Post, url)
            .header("content-type", "application/json")
            .body(Some(body))
            .build(),
    );

    let result_unwrapped = result.await.unwrap();
    info!("pub_ponged result: {:?}", result_unwrapped.body());
}
Enter fullscreen mode Exit fullscreen mode

Orchestrating apps with .NET Aspire + Dapr host

var builder = DistributedApplication.CreateBuilder(args);

var rabbitUser = builder.AddParameter("RabbitUser");
var rabbitPass = builder.AddParameter("RabbitPassword", true);
var rmq = builder.AddRabbitMQ("rabbitMQ", rabbitUser, rabbitPass)
                   .WithManagementPlugin()
                   .WithEndpoint("tcp", e => e.Port = 5672)
                   .WithEndpoint("management", e => e.Port = 15672);

var stateStore = builder.AddDaprStateStore("statestore");
var pubSub = builder.AddDaprPubSub(
    "pubsub",
    new DaprComponentOptions
    {
        LocalPath = Path.Combine("..", "dapr" , "components", "pubsub-rabbitmq.yaml")
    }).WaitFor(rmq);

var testSpinApp = builder.AddSpinApp("test-spin", workingDirectory: Path.Combine("..", "test-spin"), 
    args: ["--env", $"dapr_url=http://localhost:3500"])
    .WithHttpEndpoint(name: "http", targetPort: 3000, port: 8080)
    .WithDaprSidecar()
    .WithReference(stateStore)
    .WithReference(pubSub);

var webapp = builder.AddProject<Projects.WebApp>("webapp")
    .WithDaprSidecar(o => o.WithOptions(new DaprSidecarOptions { DaprHttpPort = 3500 }))
    .WithReference(stateStore)
    .WithReference(pubSub)
    .WaitFor(testSpinApp);

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

Up and running apps

dotnet run --project SpinAppHost/SpinAppHost.csproj
Enter fullscreen mode Exit fullscreen mode

or

if you are using Visual Studio, then you can press F5 (make sure SpinAppHost is a default running project), and you are on set.

.NET Aspire Dashboard

.NET Aspire Dashboard

Look at it. We have all Daprized apps ๐Ÿ˜๐Ÿ˜๐Ÿ˜

Observability

Structure logs

Structure logs

Traces

  • Overall tracing

Overall tracing

  • PingRequest and PongRequest (pubsub tracing)
curl curl -d '{}' http://localhost:5000/ping/
Enter fullscreen mode Exit fullscreen mode

PingRequest and PongRequest

  • Service invocation
curl http://localhost:5000/item-types/
Enter fullscreen mode Exit fullscreen mode

Service invocation

Develop a .NET Aspire's custom resource command to re-build SpinApp (Rust)

Our purpose is to re-build the Spin app in the .NET Aspire dashboard and to do that we have a look at https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/custom-resource-commands. The idea is to add a custom resource command to Re-build Spin App.

The code is below

internal class SpinAppResource(string name, string workingDirectory) 
    : ExecutableResource(name, "spin", workingDirectory)
{
}

internal static class SpinAppExtensions
{
    public static IResourceBuilder<SpinAppResource> AddSpinApp(
        this IDistributedApplicationBuilder builder, string name, string command = "up", string workingDirectory = "", string[]? args = null)
    {
        var resource = new SpinAppResource(name, workingDirectory);

        return builder.AddResource(resource)
            .BuildAppCommand(workingDirectory)
            .WithArgs(context =>
            {
                context.Args.Add(command);

                if (args is not null)
                {
                    foreach (var arg in args)
                    {
                        context.Args.Add(arg);
                    }
                }
            })
            .WithOtlpExporter()
            .ExcludeFromManifest();
    }

    public static IResourceBuilder<SpinAppResource> BuildAppCommand(
        this IResourceBuilder<SpinAppResource> builder, string workingDirectory)
    {
        builder.WithCommand(
            name: "build",
            displayName: "Build Spin App",
            executeCommand: context => OnRunReBuildSpinAppCommandAsync(builder, context, workingDirectory),
            iconName: "BuildingFactory",
            iconVariant: IconVariant.Filled);

        return builder;
    }

    private static async Task<ExecuteCommandResult> OnRunReBuildSpinAppCommandAsync(
        IResourceBuilder<SpinAppResource> builder,
        ExecuteCommandContext context,
        string workingDirectory)
    {
        var logger = context.ServiceProvider.GetRequiredService<ResourceLoggerService>().GetLogger(builder.Resource);
        var notificationService = context.ServiceProvider.GetRequiredService<ResourceNotificationService>();

        await Task.Run(async () =>
        {
            var cmd = Cli.Wrap("spin").WithArguments(["build"]).WithWorkingDirectory(workingDirectory);
            var cmdEvents = cmd.ListenAsync();

            await foreach (var cmdEvent in cmdEvents)
            {
                switch (cmdEvent)
                {
                    case StartedCommandEvent:
                        await notificationService.PublishUpdateAsync(builder.Resource, state => state with { State = "Running" });
                        break;
                    case ExitedCommandEvent:
                        await notificationService.PublishUpdateAsync(builder.Resource, state => state with { State = "Finished" });
                        break;
                    case StandardOutputCommandEvent stdOut:
                        logger.LogInformation("{ResourceName} stdout: {StdOut}", builder.Resource.Name, stdOut.Text);
                        break;
                    case StandardErrorCommandEvent stdErr:
                        logger.LogInformation("{ResourceName} stderr: {StdErr}", builder.Resource.Name, stdErr.Text);
                        break;
                }
            }
        });

        return CommandResults.Success();
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding it into .NET Aspire AppHost:

var testSpinApp = builder.AddSpinApp("test-spin", workingDirectory: Path.Combine("..", "test-spin"), 
    args: ["--env", $"dapr_url=http://localhost:3500"])
Enter fullscreen mode Exit fullscreen mode

Then, run the solution again. Look at the picture below:

Custom resource command

After clicking on that command, the spin build will trigger, and we can re-build the Spin app.

Custom resource command - re-build

It's very cool. We can hack it ๐Ÿ˜Ž

Conclusion

During the post, we show you how can we make the Spin app and .NET app work together with each other via Dapr's service invocation and pub-sub mechanisms. That's very cool, right?

Next post, we will explore the ability to deploy this sample app on AKS with SpinKube. Stay stunned, we can do even more. Thanks for reading it โค๏ธ

Source code: https://github.com/thangchung/dapr-labs/tree/main/aspire

Top comments (0)