Top-Level Statements (C# v9.0)
Date: 04/26/2021
The Users Challenge
They want to write a few utility apps or microservices and they like C# in general but it is kind of boilerplate-y. They would rather brush
off/ignore my NetCore/C# suggestions and end up writing their app in something simpler like golang
or python
when they want a quick
ConsoleApp or Daemon etc.
Top-Level Statements
While I whole heartedly believe people should enjoy the language they work with, I do think the basic "boilerplate" code found around C# patterns
like Program.cs
, Startup.cs
, and AspNetCore hosting in general, make a lot of sense. I find it quite tolerable and the complaints are kind of
hyperbolic compared to other systems. They clearly have not met Java
huehuehuehue. A large majority of the time what that person is really saying
is I don't know why I need this. Now that is something I could totally get behind. Why do I need a namespace
? Why do I need have a Program.cs
?
Why is there a static main
? Et cetera.
Even though I don't necessarily agree that C# is very boilerplate-y, there just happens to be a shiny, new, but more importantly - slimming -
feature in C#9.0 targeted at those that want to spin up tiny apps or utilities
from a single .cs
file without a lot of ceremony. I want to try and put that idea to the test.
Introducing Top-Level
Microsoft: Top-level statements enable you to avoid the extra ceremony required by placing your program's entry point in a static method in a class.
dotnet new
Console application templates usually generate something like this.
using System;
namespace Application
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
In Top-level (final?) form:
using System;
Console.WriteLine("Hello World!");
That is... it? Okay... so I made a real application in a single file, but boy is that just too boring and disappointing for a guide. It doesn't even do anything! I may
not fully like the idea, but we should at least demonstrate features with pizazz and fuego
!
Edit
A silly redditor pointed out you could have
simplified it just a single line. Which is just a reminder you can use the fully qualified names everywhere and remove the need for using
statements at the top of your file... but I actually dislike that. I always use using
statements. Regardless, here is what that looks like.
System.Console.WriteLine("Hello World!");
You can skip out on me taking this to the next level if you want, there are Top-level
statement links below that follow the basic usage guidelines from Microsoft.
A Top-Level RabbitMQ Consumer
I am going to see if I can combine top-level statements with my Tesseract/RabbitMQ library to create a RabbitMQ Consumer
as
a NanoService. Oh I like that, NanoService
. I picked RabbitMQ because I have plenty of experience with RabbitMQ, microservices, and self-contained realiable apps that
also happen to scale from micro to monolithic. I can also easily test how well it performs. So lets see what that looks like at the nano-scale.
Lets start by building an app with the categorical ILogger<T>
and LoggerFactory
because real apps use logging. That is today's additional challenge, building a real
world application contained in a single file that is also still somehow maintainable.
using Microsoft.Extensions.Logging;
using System;
using System.Text;
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
var logger = loggerFactory.CreateLogger("Program");
That means also adding some NuGet references.
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="5.0.0" />
Going to create the helper service provided for RabbitMQ.
using HouseofCat.RabbitMQ;
using HouseofCat.RabbitMQ.Services;
using HouseofCat.Serialization;
using Microsoft.Extensions.Logging;
using System;
using System.Text;
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
var logger = loggerFactory.CreateLogger("Program");
var serializationProvider = new Utf8JsonProvider();
var rabbitService = new RabbitService(
"HouseofCatConfig.json",
serializationProvider,
encryptionProvider: null,
compressionProvider: null,
loggerFactory);
Which means more NuGets. How about we just list out all the NuGets together.
<ItemGroup>
<PackageReference Include="HouseofCat.RabbitMQ" Version="1.0.6" />
<PackageReference Include="HouseofCat.RabbitMQ.Services" Version="1.0.7" />
<PackageReference Include="HouseofCat.Serialization.Json.Utf8Json" Version="1.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="5.0.0" />
</ItemGroup>
Let me copy in a basic HoC config with our consumer settings in it. This file needs to be copied to the bin folder, so do not forget to Copy Always
. I named mine
"HouseofCatConfig.json"
for the purposes of this experiment.
{
"FactoryOptions": {
"Uri": "amqp://guest:guest@localhost:5672/",
"MaxChannelsPerConnection": 2000,
"HeartbeatInterval": 6,
"AutoRecovery": true,
"TopologyRecovery": true,
"NetRecoveryTimeout": 10,
"ContinuationTimeout": 10,
"EnableDispatchConsumersAsync": true,
"SslOptions": {
"EnableSsl": false,
"CertServerName": "",
"LocalCertPath": "",
"LocalCertPassword": "",
"ProtocolVersions": 3072
}
},
"PoolOptions": {
"ServiceName": "TopLevel-HoC-Consumer",
"MaxConnections": 5,
"MaxChannels": 25,
"SleepOnErrorInterval": 1000
},
"PublisherOptions": {
"LetterQueueBufferSize": 100,
"PriorityLetterQueueBufferSize": 100,
"BehaviorWhenFull": 0,
"AutoPublisherSleepInterval": 0,
"CreatePublishReceipts": true,
"Compress": false,
"Encrypt": false
},
"GlobalConsumerOptions": {
"AggressiveOptions": {
"ErrorSuffix": "Error",
"BatchSize": 128,
"BehaviorWhenFull": 0,
"SleepOnIdleInterval": 0,
"UseTransientChannels": true,
"AutoAck": false,
"NoLocal": false,
"Exclusive": false,
"GlobalConsumerPipelineOptions": {
"WaitForCompletion": false,
"MaxDegreesOfParallelism": 64,
"EnsureOrdered": false
}
},
"SingleThreadedOptions": {
"ErrorSuffix": "Error",
"BatchSize": 1,
"BehaviorWhenFull": 0,
"SleepOnIdleInterval": 0,
"UseTransientChannels": true,
"AutoAck": false,
"NoLocal": false,
"Exclusive": false,
"GlobalConsumerPipelineOptions": {
"WaitForCompletion": true,
"MaxDegreesOfParallelism": 1,
"EnsureOrdered": true
}
}
},
"ConsumerOptions": {
"HoC-Consumer": {
"Enabled": true,
"GlobalSettings": "AggressiveOptions",
"ConsumerName": "HoC-Consumer",
"QueueName": "HoC-ConsumerQueue"
}
}
}
The config has the Consumer
named as HoC-Consumer
so let me get that prebuilt Consumer
object out of the RabbitService and then start the consuming.
using HouseofCat.RabbitMQ;
using HouseofCat.RabbitMQ.Services;
using HouseofCat.Serialization;
using Microsoft.Extensions.Logging;
using System;
using System.Text;
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
var logger = loggerFactory.CreateLogger("Program");
var serializationProvider = new Utf8JsonProvider();
var rabbitService = new RabbitService(
"HouseofCatConfig.json",
serializationProvider,
encryptionProvider: null,
compressionProvider: null,
loggerFactory);
var consumer = rabbitService.GetConsumer("HoC-Consumer");
await consumer.StartConsumerAsync();
Messages at this point should be sitting in the ConsumerBuffer
. I am going to use IAsyncEnumerable
to stream those out of the local buffer for
further processing. ForEach ReceivedData
we will read the inner body and then Ack/Nack the message as a processing step (do work step).
Rather than an ugly/bulky foreach
let us create a local function
called ProcessMessage
to keep things nice and clean. We are not using
auto-ack so we have to ack our messages for them be marked as finished (or nack/unfinished) with server-side.
await foreach (var receivedData in consumer.StreamOutUntilClosedAsync()) // this will exit only when the internal buffer closes/exception
{
ProcessMessage(receivedData);
}
void ProcessMessage(IReceivedData receivedData)
{
try
{
var body = Encoding.UTF8.GetString(receivedData.Data);
logger.LogInformation($"{DateTime.Now:yyyy/MM/dd hh:mm:ss.ffffff} - [Message Received]: {body}");
if (receivedData.Ackable)
{ receivedData.AckMessage(); }
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred processing messages from the consumer buffer.");
if (receivedData.Ackable)
{ receivedData.NackMessage(requeue: true); }
}
}
Adding a ShutdownAsync
in there brings the whole thing together.
Lets take a look at everything.
using HouseofCat.RabbitMQ;
using HouseofCat.RabbitMQ.Services;
using HouseofCat.Serialization;
using Microsoft.Extensions.Logging;
using System;
using System.Text;
var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
var logger = loggerFactory.CreateLogger("Program");
var serializationProvider = new Utf8JsonProvider();
var rabbitService = new RabbitService(
"HouseofCatConfig.json",
serializationProvider,
encryptionProvider: null,
compressionProvider: null,
loggerFactory);
var consumer = rabbitService.GetConsumer("HoC-Consumer");
await consumer.StartConsumerAsync();
await foreach (var receivedData in consumer.StreamOutUntilClosedAsync())
{
ProcessMessage(receivedData);
}
await rabbitService.ShutdownAsync(immediately: false);
void ProcessMessage(IReceivedData receivedData)
{
try
{
var body = Encoding.UTF8.GetString(receivedData.Data);
logger.LogInformation($"{DateTime.Now:yyyy/MM/dd hh:mm:ss.ffffff} - [Message Received]: {body}");
if (receivedData.Ackable)
{ receivedData.AckMessage(); }
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred streaming out messages from the consumer.");
if (receivedData.Ackable)
{ receivedData.NackMessage(requeue: true); }
}
}
What Have I Done?
That was 45 lines of code. Looking at this makes me feel dirty. I am impressed and disgusted with myself at the same time. Now this is kind of cheating, durable RabbitMQ connectivity is handled by my library. I have quite a few Quality of Life things as well that help keep this code short, but that is the entire point of that library and I had never considered that an application would be this small and manageable.
So now what?
In keeping with the slim theme, lets publish tiny files. I am going to publish this app as a self-contained win-x64 runtime with...
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>Link</TrimMode>
...and also disabling ReadyToRun compilation to see how small I can get this single coded file (Program.cs
) application. ReadyToRun
being disabled should in theory slow down startup time but can bloat executables for that performance.
And just like Baby Yoda, this thing can be freakishly cute small at 7,519 KB. Just imagine speeding up Docker container deployments with this tiny fella.
So how does it perform?
It isn't the fastest test I have ever ran, but the code above is essentially a non-blocking sequential for
loop. It managed to peak at around 12,000 msg/s. We could
definitely make this quite a bit faster by adding concurrent processing so it is still pretty good for a single consumer not using concurrency.
Links
- Microsoft - Top Level Statements
- Microsoft - Top-level statements (C# Programming Guide)