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
- .NET app calls to Spin app using
service invocation
(Dapr) - .NET app publishes an event (PingEvent) to Spin App (
pub-sub
via Dapr) - 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]
- Rust & Spin CLI
cargo --version
cargo 1.83.0 (5ffbef321 2024-10-29)
spin --version
spin 3.1.1 (aa919ce 2024-12-20)
- Dapr CLI
dapr version
CLI version: 1.14.1
Runtime version: 1.14.4
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;
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());
}
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();
Up and running apps
dotnet run --project SpinAppHost/SpinAppHost.csproj
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
Look at it. We have all Daprized apps ๐๐๐
Observability
Structure logs
Traces
- Overall tracing
- PingRequest and PongRequest (pubsub tracing)
curl curl -d '{}' http://localhost:5000/ping/
- Service invocation
curl http://localhost:5000/item-types/
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();
}
}
Adding it into .NET Aspire AppHost:
var testSpinApp = builder.AddSpinApp("test-spin", workingDirectory: Path.Combine("..", "test-spin"),
args: ["--env", $"dapr_url=http://localhost:3500"])
Then, run the solution again. Look at the picture below:
After clicking on that command, the spin build
will trigger, and we can re-build the Spin app.
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)