From 8d709c2e740cca25486d25e040a88f4dc928e6f5 Mon Sep 17 00:00:00 2001 From: Ray Coulstock Date: Mon, 29 Jun 2026 19:15:13 +0100 Subject: [PATCH 1/2] Add dotnet Kafka webserver --- README.md | 18 +- docker-compose.yml | 14 ++ webserver-dotnet/.dockerignore | 2 + webserver-dotnet/Dockerfile | 12 ++ webserver-dotnet/Program.cs | 208 +++++++++++++++++++++++ webserver-dotnet/webserver-dotnet.csproj | 10 ++ 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 webserver-dotnet/.dockerignore create mode 100644 webserver-dotnet/Dockerfile create mode 100644 webserver-dotnet/Program.cs create mode 100644 webserver-dotnet/webserver-dotnet.csproj diff --git a/README.md b/README.md index 2a91c7c..166b43c 100644 --- a/README.md +++ b/README.md @@ -43,4 +43,20 @@ root@tools:/ exit #turn of the docker containers to save on resources $ docker-compose down -v zookeeper kafka tools -``` \ No newline at end of file +``` + +# Optional .NET Kafka Webserver + +This repo also includes a small ASP.NET Core webserver for C# developers who want to experiment with Kafka from a browser instead of using the prebuilt Node webservers. + +```bash +# Start Kafka, create the course topics, and run the .NET webserver +$ docker-compose up -d zookeeper kafka create-topics webserver-dotnet +``` + +Open in your browser. + +* Use **Send** (`/produce`) to write a keyed string message into the `dotnet-messages` Kafka topic. +* Use **Consume** (`/consume`) to view messages consumed from the same topic. The page refreshes automatically every two seconds. + +The implementation lives in `webserver-dotnet/Program.cs` and is intentionally small so it can be modified during class. diff --git a/docker-compose.yml b/docker-compose.yml index f2cec8b..65f247b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -221,6 +221,19 @@ services: done; \ npm run start'" + webserver-dotnet: + build: ./webserver-dotnet + container_name: webserver-dotnet + hostname: webserver-dotnet + ports: + - 3005:8080 + environment: + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + KAFKA_TOPIC: dotnet-messages + depends_on: + - kafka + - create-topics + postgres: image: postgres:11.2-alpine restart: always @@ -246,6 +259,7 @@ services: kafka-topics --bootstrap-server kafka:9092 --create --topic driver-augmented-avro --partitions 3 --replication-factor 1 ; kafka-topics --bootstrap-server kafka:9092 --create --topic _confluent-monitoring --partitions 12 --replication-factor 1 --config retention.ms=259200000 --config message.timestamp.type=LogAppendTime ; kafka-topics --bootstrap-server kafka:9092 --create --topic driver-positions-string-avro --partitions 3 --replication-factor 1 ; + kafka-topics --bootstrap-server kafka:9092 --create --topic dotnet-messages --partitions 3 --replication-factor 1 ; true' tools: diff --git a/webserver-dotnet/.dockerignore b/webserver-dotnet/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/webserver-dotnet/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/webserver-dotnet/Dockerfile b/webserver-dotnet/Dockerfile new file mode 100644 index 0000000..4edcaf7 --- /dev/null +++ b/webserver-dotnet/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY webserver-dotnet.csproj ./ +RUN dotnet restore +COPY . ./ +RUN dotnet publish -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 +COPY --from=build /app ./ +ENTRYPOINT ["dotnet", "webserver-dotnet.dll"] diff --git a/webserver-dotnet/Program.cs b/webserver-dotnet/Program.cs new file mode 100644 index 0000000..7173746 --- /dev/null +++ b/webserver-dotnet/Program.cs @@ -0,0 +1,208 @@ +using System.Collections.Concurrent; +using Confluent.Kafka; + +var builder = WebApplication.CreateBuilder(args); + +var bootstrapServers = Environment.GetEnvironmentVariable("KAFKA_BOOTSTRAP_SERVERS") ?? "kafka:9092"; +var topic = Environment.GetEnvironmentVariable("KAFKA_TOPIC") ?? "dotnet-messages"; +var groupId = Environment.GetEnvironmentVariable("KAFKA_GROUP_ID") ?? $"dotnet-webserver-{Environment.MachineName}"; + +builder.Services.AddSingleton(new KafkaSettings(bootstrapServers, topic, groupId)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton>(_ => +{ + var config = new ProducerConfig + { + BootstrapServers = bootstrapServers, + ClientId = "dotnet-webserver", + }; + + return new ProducerBuilder(config).Build(); +}); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapGet("/", () => Results.Redirect("/produce")); + +app.MapGet("/produce", (KafkaSettings settings) => Results.Content(Html.Page("Send Messages", $""" +

Send a string message to Kafka topic {settings.Topic}.

+
+ + + + + +

+    
+ + """), "text/html")); + +app.MapGet("/consume", (KafkaSettings settings) => Results.Content(Html.Page("Consume Messages", $""" +

Recent messages consumed from Kafka topic {settings.Topic}.

+ +
+ + """), "text/html")); + +app.MapPost("/api/messages", async (ProduceRequest request, IProducer producer, KafkaSettings settings) => +{ + if (string.IsNullOrWhiteSpace(request.Value)) + { + return Results.BadRequest(new { error = "Message value is required." }); + } + + var result = await producer.ProduceAsync(settings.Topic, new Message + { + Key = string.IsNullOrWhiteSpace(request.Key) ? null : request.Key, + Value = request.Value, + }); + + return Results.Ok(new + { + status = "sent", + result.Topic, + result.Partition, + result.Offset, + result.Message.Key, + result.Message.Value, + }); +}); + +app.MapGet("/api/messages", (MessageStore store) => Results.Ok(store.GetAll())); + +app.Lifetime.ApplicationStopping.Register(() => +{ + var producer = app.Services.GetRequiredService>(); + producer.Flush(TimeSpan.FromSeconds(10)); + producer.Dispose(); +}); + +app.Run(); + +record KafkaSettings(string BootstrapServers, string Topic, string GroupId); +record ProduceRequest(string? Key, string Value); +record ConsumedMessage(string? Key, string Value, string Topic, int Partition, long Offset, DateTime Timestamp); + +class MessageStore +{ + private const int MaxMessages = 50; + private readonly ConcurrentQueue messages = new(); + + public void Add(ConsumedMessage message) + { + messages.Enqueue(message); + while (messages.Count > MaxMessages && messages.TryDequeue(out _)) + { + } + } + + public ConsumedMessage[] GetAll() => messages.Reverse().ToArray(); +} + +class KafkaConsumerService(KafkaSettings settings, MessageStore store, ILogger logger) : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) => Task.Run(() => Consume(stoppingToken), stoppingToken); + + private void Consume(CancellationToken stoppingToken) + { + var config = new ConsumerConfig + { + BootstrapServers = settings.BootstrapServers, + GroupId = settings.GroupId, + AutoOffsetReset = AutoOffsetReset.Earliest, + EnableAutoCommit = true, + }; + + using var consumer = new ConsumerBuilder(config).Build(); + consumer.Subscribe(settings.Topic); + logger.LogInformation("Consuming topic {Topic} from {BootstrapServers}", settings.Topic, settings.BootstrapServers); + + try + { + while (!stoppingToken.IsCancellationRequested) + { + var result = consumer.Consume(stoppingToken); + store.Add(new ConsumedMessage( + result.Message.Key, + result.Message.Value, + result.Topic, + result.Partition.Value, + result.Offset.Value, + result.Message.Timestamp.UtcDateTime)); + } + } + catch (OperationCanceledException) + { + logger.LogInformation("Kafka consumer is stopping."); + } + finally + { + consumer.Close(); + } + } +} + +static class Html +{ + public static string Page(string title, string body) => $$""" + + + + + + {{title}} + + + + +
+

{{title}}

+ {{body}} +
+ + + """; +} diff --git a/webserver-dotnet/webserver-dotnet.csproj b/webserver-dotnet/webserver-dotnet.csproj new file mode 100644 index 0000000..11c0c45 --- /dev/null +++ b/webserver-dotnet/webserver-dotnet.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + + + + + From 8d205c4df3d71f08073f3f234128fa6566433f6f Mon Sep 17 00:00:00 2001 From: Ray Coulstock Date: Mon, 29 Jun 2026 19:35:17 +0100 Subject: [PATCH 2/2] Fix dotnet webserver publish --- webserver-dotnet/Dockerfile | 2 +- webserver-dotnet/Program.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webserver-dotnet/Dockerfile b/webserver-dotnet/Dockerfile index 4edcaf7..cea4efb 100644 --- a/webserver-dotnet/Dockerfile +++ b/webserver-dotnet/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /src COPY webserver-dotnet.csproj ./ RUN dotnet restore COPY . ./ -RUN dotnet publish -c Release -o /app --no-restore +RUN dotnet publish webserver-dotnet.csproj -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:8.0 WORKDIR /app diff --git a/webserver-dotnet/Program.cs b/webserver-dotnet/Program.cs index 7173746..20a9523 100644 --- a/webserver-dotnet/Program.cs +++ b/webserver-dotnet/Program.cs @@ -79,7 +79,7 @@ async function loadMessages() {{ """), "text/html")); -app.MapPost("/api/messages", async (ProduceRequest request, IProducer producer, KafkaSettings settings) => +app.MapPost("/api/messages", async Task (ProduceRequest request, IProducer producer, KafkaSettings settings) => { if (string.IsNullOrWhiteSpace(request.Value)) {