In this blog post mini-series we have been exploring the gradual evolution of an IoT Edge application, starting from a tightly-coupled monolithic approach based on the default C# module template, to a more loosely coupled and composable application. In this post we will see how we can use this design to setup a true F5 development experience.

In the process of evolving our IoT Edge the application, we explored many best practices on various trivial IoT Edge topics, like controlling the application lifetime, logging, configuration, telemetry pumps and more. Now we will use an IoT Edge application example to see how all these things come together with setting up a flexible development environment. Although no prior knowledge is required to follow through, to get a better understanding of the design decisions, a review of the the previous posts is highly recommended.

Improving the CLI-based current development experience

In the past years we've witnessed the re-emergence of CLI-oriented development, where people use various CLIs to compose, compile, containerize and publish their IoT Edge application. As an effect of this, today we can find a CLI for every single thing. And although these CLIs were meant to replace opinionated and platform-specific IDEs, like Visual Studio, these CLIs ended up getting integrated with development tools like VS Code, hidden behind their UI.

Specifically for the Azure IoT Edge development experience, the tooling story has been imperfect. It is good for getting an introductory understanding of the technology, but a better approach is required for building an application beyond the Hello World level complexity. In the following example, we will take a code-first approach: we will include most of the development tooling inside the application source code, so that people can get started with just cloning a repo.

Simulated Temperature Sensor v2.0

Let's say we want to build the runtime's simulated temperature sensor example from scratch, without any prior setup, starting from a clean Linux, macOS or Windows installation; I prefer using Visual Studio on Windows.

  1. Install the latest .NET Core SDK.
  2. Install the latest VS Code.
  3. Create a folder:
    mkdir SimulatedTemperatureSensor
  4. Create a new console app in this folder:
    cd SimulatedTemperatureSensor
    dotnet new console
  5. Install the following packages:
    dotnet add package Microsoft.Azure.Devices.Client
    dotnet add package Microsoft.Extensions.Hosting
    dotnet add package Serilog.Extensions.Hosting
    dotnet add package Serilog.Settings.Configuration
    dotnet add package Serilog.Sinks.Console
    dotnet add package Serilog.Enrichers.Thread
  6. Open the project in VS Code:
    code .
  7. Replace the Program.cs with the following code:
namespace SimulatedTemperatureSensor
{
    using System.Threading.Tasks;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Serilog;

    class Program
    {
        static async Task Main(string[] args)
        {
            using (var host = Host.CreateDefaultBuilder(args)
                .ConfigureServices((hostContext, services) =>
                {
                    if(hostContext.HostingEnvironment.EnvironmentName == "Development")
                        services.AddSingleton<IModuleClient, MockModuleClientWrapper>();
                    else
                        services.AddSingleton<IModuleClient, ModuleClientWrapper>();

                    services.AddHostedService<TemperatureSensorModule>();
                })
                .UseSerilog((hostingContext, log) =>
                {
                    log.ReadFrom.Configuration(hostingContext.Configuration);
                })
                .UseConsoleLifetime()
                .Build())
            {
                await host.RunAsync();
            }
        }
    }
}
Note that we switch from ModuleClientWrapper to MockModuleClientWrapper when in development. This is controlled by the environment variable with name DOTNET_ENVIRONMENT.  
I'm using the  launchSettings.json  to set any variables at launch.

We've seen this code in the previous post, and in the one before that, we saw how to define an abstraction interface for the SDK's ModuleClient so that we can use it in a dependency injection setup. Next, we will add this IModuleClient interface and the two ModuleClientWrapper and MockModuleClientWrapper classes:

namespace SimulatedTemperatureSensor
{
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Azure.Devices.Shared;
    using Microsoft.Azure.Devices.Client;
    
    public interface IModuleClient
    {
        Task OpenAsync(CancellationToken cancellationToken);
        Task CloseAsync(CancellationToken cancellationToken);
        Task SendEventAsync(string outputName,
            Message message);
        Task SetInputMessageHandlerAsync(string inputName,
            MessageHandler messageHandler,
            object userContext);
        Task SetMethodHandlerAsync(string methodName,
            MethodCallback methodHandler,
            object userContext);
        Task<Twin> GetTwinAsync(CancellationToken cancellationToken);
        Task<Twin> GetTwinAsync();
    }
}

and the class ModuleClientWrapper:

namespace SimulatedTemperatureSensor
{
    using Microsoft.Azure.Devices.Client;
    using Microsoft.Azure.Devices.Client.Transport.Mqtt;
    using Microsoft.Azure.Devices.Shared;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.Logging;
    using System.Threading;
    using System.Threading.Tasks;

    public class ModuleClientWrapper : IModuleClient
    {
        readonly ModuleClient moduleClient;
        readonly ILogger logger;

        public ModuleClientWrapper(IConfiguration configuration,
            ILogger<ModuleClientWrapper> logger)
        {
            this.logger = logger;

            var transportType = 
                configuration.GetValue("ClientTransportType", TransportType.Amqp_Tcp_Only);

            ITransportSettings[] settings = { new MqttTransportSettings(transportType) };

            moduleClient = ModuleClient.CreateFromEnvironmentAsync(settings).Result;
        }

        public async Task SendEventAsync(string outputName, Message message)
        {
            await moduleClient.SendEventAsync(outputName, message);
        }

        public async Task SetInputMessageHandlerAsync(string inputName,
            MessageHandler messageHandler,
            object userContext)
        {
            await moduleClient.SetInputMessageHandlerAsync(inputName,
                messageHandler,
                userContext);
        }

        public async Task SetMethodHandlerAsync(string methodName,
            MethodCallback methodHandler,
            object userContext)
        {
            await moduleClient.SetMethodHandlerAsync(methodName,
                methodHandler,
                userContext);
        }
        public async Task OpenAsync(CancellationToken cancellationToken)
        {
            await moduleClient.OpenAsync(cancellationToken);
        }
        public async Task CloseAsync(CancellationToken cancellationToken)
        {
            await moduleClient.CloseAsync(cancellationToken);
        }
        public async Task<Twin> GetTwinAsync(CancellationToken cancellationToken)
        {
            return await moduleClient.GetTwinAsync(cancellationToken);
        }
        public async Task<Twin> GetTwinAsync()
        {
            return await moduleClient.GetTwinAsync();
        }
    }
}

And finally here's a mock implementation of the above class. The purpose of this class is to simulate the IoT Edge Runtime behavior for development and testing purposes.

namespace SimulatedTemperatureSensor
{
    using System;
    using System.Collections.Generic;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Azure.Devices.Client;
    using Microsoft.Azure.Devices.Shared;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;

    internal class MockModuleClientWrapper : IModuleClient
    {
        readonly ILogger logger;
        readonly IHostApplicationLifetime application;

        readonly TaskTimer taskTimer;
        readonly Dictionary<string, List<Message>> messageQueues;
        readonly Dictionary<string, ValueTuple<MessageHandler, object>> inputMessageHandlers;
        readonly Dictionary<string, MethodCallback> methodMessageHandlers;

        public MockModuleClientWrapper(IHostApplicationLifetime application,
            ILogger<MockModuleClientWrapper> logger)
        {
            this.logger = logger;
            this.application = application;

            messageQueues = new Dictionary<string, List<Message>>();
            inputMessageHandlers = new Dictionary<string, (MessageHandler, object)>();
            methodMessageHandlers = new Dictionary<string, MethodCallback>();

            taskTimer = new TaskTimer(OnTimer, TimeSpan.FromSeconds(1), logger);
        }

        private void OnTimer()
        {
            lock (messageQueues)
                foreach (var queue in messageQueues)
                {
                    if (inputMessageHandlers.ContainsKey(queue.Key))
                        foreach (var message in queue.Value)
                            inputMessageHandlers[queue.Key].Item1(message, inputMessageHandlers[queue.Key].Item2);
                    messageQueues[queue.Key].Clear();
                }
            // TODO: Process method messsages too
        }

        public Task SendEventAsync(string outputName, Message message)
        {
            lock (messageQueues)
            {
                if (!messageQueues.ContainsKey(outputName))
                    messageQueues[outputName] = new List<Message>();
                messageQueues[outputName].Add(message);
            }
            logger.LogInformation($"Message Sent to {outputName}");
            return Task.CompletedTask;
        }

        public Task SetInputMessageHandlerAsync(string inputName, MessageHandler messageHandler, object userContext)
        {
            inputMessageHandlers[inputName] = (messageHandler, userContext);
            logger.LogInformation($"Message Handler Set for {inputName}");
            return Task.CompletedTask;
        }

        public Task SetMethodHandlerAsync(string methodName, MethodCallback methodHandler, object userContext)
        {
            methodMessageHandlers[methodName] = methodHandler;
            logger.LogInformation($"Method Handler Set for {methodName}");
            return Task.CompletedTask;
        }

        public Task OpenAsync(CancellationToken token)
        {
            logger.LogInformation("Opened ModuleClient");
            taskTimer.Start(application.ApplicationStopping);
            return Task.CompletedTask;
        }

        public Task CloseAsync(CancellationToken token)
        {
            logger.LogInformation("Closed ModuleClient");
            return Task.CompletedTask;
        }

        public Task<Twin> GetTwinAsync(CancellationToken cancellationToken)
        {
            logger.LogInformation("GetTwinAsync");
            return Task.FromResult<Twin>(null);
        }

        public Task<Twin> GetTwinAsync()
        {
            logger.LogInformation("GetTwinAsync");
            return Task.FromResult<Twin>(null);
        }
    }
}

We have almost everything in place. The last missing piece is to implement the TemperatureSensorModule class, the class that will contain the business logic of our IoT Edge application. This module will periodically emit messages, simulating a temperature sensor.

Following the same approach we did last time, this class will implement the IHostedService. We will include an IHostApplicationLifetime dependency to gain control on the application lifetime and trigger a restart in case something goes terribly wrong. Finally, we'll use an updated version of the TaskTimer we saw in a previous post to drive the telemetry loop:

Here's the TaskTimer class:

namespace SimulatedTemperatureSensor
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.Extensions.Logging;

    public class TaskTimer
    {
        readonly Action onTimer;
        readonly TimeSpan timerPeriod;
        readonly Action onError;
        readonly ILogger logger;

        public TaskTimer(Action onTimer,
            TimeSpan timerPeriod,
            ILogger logger,
            Action onError = null)
        {
            this.timerPeriod = timerPeriod;
            this.onTimer = onTimer;
            this.logger = logger;
            this.onError = onError;
        }

        public void Start(CancellationToken token)
        {
            Task elapsedTask = null;
            elapsedTask = new Task((x) =>
            {
                OnTimer(elapsedTask, token);
            }, token);

            HandleError(elapsedTask, token);

            elapsedTask.Start();
        }

        private void OnTimer(Task task, object objParam)
        {
            var start = DateTime.Now;
            var token = (CancellationToken)objParam;

            if (token.IsCancellationRequested)
            {
                logger.LogInformation("A cancellation has been requested.");
                return;
            }

            onTimer();

            var delay = timerPeriod - (DateTime.Now - start);
            if (delay.Ticks > 0)
            {
                task = Task.Delay(delay);
            }
            HandleError(task.ContinueWith(OnTimer, token), token);
        }

        private void HandleError(Task task, CancellationToken token)
        {
            task.ContinueWith((e) =>
            {
                logger.LogError(
                    $"Exception when running timer callback: {e.Exception}");

                onError?.Invoke();
                if (!token.IsCancellationRequested)
                    task.ContinueWith(OnTimer, token);

            }, TaskContinuationOptions.OnlyOnFaulted);
        }
    }
}

Here's the code of the TemperatureSensorModule:


namespace SimulatedTemperatureSensor
{
    using Microsoft.Azure.Devices.Client;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    using System;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;

    public class TemperatureSensorModule : IHostedService
    {
        const string SAMPLING_PERIOD_CONFIG_NAME = "TelemetryPeriodSeconds";
        const int SAMPLING_PERIOD_DEFAULT = 1;
        
        readonly IModuleClient moduleClient;
        readonly ILogger logger;
        readonly IHostApplicationLifetime application;
        
        readonly Random random = new Random();
        readonly TaskTimer telemetryPump;

        public TemperatureSensorModule(IModuleClient moduleClient,
            IConfiguration config,
            IHostApplicationLifetime application, 
            ILogger<TemperatureSensorModule> logger)
        {
            this.moduleClient = moduleClient;
            this.logger = logger;
            this.application = application;

            var period = TimeSpan.FromSeconds(
                config.GetValue(SAMPLING_PERIOD_CONFIG_NAME, SAMPLING_PERIOD_DEFAULT));

            telemetryPump = new TaskTimer(OnTimer, period, logger, application.StopApplication);
            
            application.ApplicationStopping.Register(() =>
            {
                logger.LogWarning("Stop-draining application for 3 seconds...");
                Task.Delay(TimeSpan.FromSeconds(3)).Wait();
            });
        }

        private async void OnTimer()
        {
            await moduleClient.SendEventAsync("telemetry",
                new Message(Encoding.UTF8.GetBytes($"Current temperature: {random.Next(0, 100)}")));
        }

        public async Task StartAsync(CancellationToken cancellationToken)
        {
            await moduleClient.OpenAsync(cancellationToken);
            telemetryPump.Start(application.ApplicationStopping);

            logger.LogInformation("Started.");
        }

        public async Task StopAsync(CancellationToken cancellationToken)
        {
            await moduleClient.CloseAsync(cancellationToken);
            logger.LogInformation("Stopped.");
        }
    }
}

This is the project appSettings.json content that contains just our Serilog config:

{
  "Serilog": {
    "Using": [ "Serilog.Sinks.Console" ],
    "MinimumLevel": {
      "Default": "Debug"
    },
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] [{SourceContext}] [{ThreadId}] - {Message}{NewLine}{Exception}"
        }
      }
    ],
    "Enrich": [ "FromLogContext", "WithThreadId" ]
  }
}

This the project file structure so far:

Our first version is ready. Set the DOTNET_ENVIRONMENT environment variable to Development and hit F5 to run the application. Hit CTRL+C to gracefully stop it.

Recap

Following this approach, we are allowing ourselves to develop most the Edge application as a console app, using the most easy F5 development experience, without any prior setup.

Next, we will see how we can gradually transform this standalone development environment to becoming our Azure IoT Edge development environment, without sacrificing any flexibility once so ever!