Managing Azure WebJobs with Base Classes

Azure AppService is my go-to when I want to create cloud-based applications. I have no desire to set up or administer a VM. Sometimes, though, you just need a service that quietly does its job in the background. That’s why I love WebJobs.

However, I found myself in the situation where I needed a WebJob that ran on a schedule, but could also be triggered manually. Oh, and depending on the environment I don’t want the schedule to run. Oh, and I have four environments. Oh, and I have five WebJobs.

Configuring 20 WebJob instances by hand isn’t the end of the world, but what happens when I want to add WebJobs? The approach doesn’t scale. I needed a way to manage some of this complexity through configuration, so I wrote a WebJobBase class that I can inherit from, which gives me almost everything I want for free.

I’ll begin by showing the entire instance. From there, I will describe each code block and its purpose.

WebJobBase.cs

using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.SendGrid;
using Microsoft.ServiceBus;
using Microsoft.ServiceBus.Messaging;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DPL
{
    public abstract class WebJobBase
    {
        protected static JobHost GetJobHost(string webJobName)
        {
            JobHost host = new JobHost(GetConfiguration(webJobName));

            return host;
        }

        protected static JobHostConfiguration GetConfiguration(string webJobName)
        {
            JobHostConfiguration config = new JobHostConfiguration();
 
            // Timers
            bool isTimersEnabled = bool.Parse(ConfigurationManager.AppSettings["WebJobsTimersEnabled"]);

            if (isTimersEnabled)
            {
                config.UseTimers();
            }
 
            // ServiceBus
            CreateStartSubscription(webJobName);
            config.UseServiceBus();

            // Set Configs
            config.NameResolver = new ServiceBusTopicResolver(webJobName);
            config.UseServiceBus();

            config.UseCore();

            return config;
        }

        private static void CreateStartSubscription(string webJobName)
        {
            NamespaceManager nsMgr = NamespaceManager.CreateFromConnectionString(ConfigurationManager.ConnectionStrings["AzureWebJobsServiceBus"].ConnectionString);
            string environment = ConfigurationManager.AppSettings["WebJobsEnvName"];
            string topic = ConfigurationManager.AppSettings["WebJobsTopicName"];
            string subscription = $"{webJobName}{environment}StartMessages";

            if (nsMgr.SubscriptionExists(topic, subscription))
            {
                nsMgr.DeleteSubscription(topic, subscription);
            }

            SqlFilter startMessagesFilter = new SqlFilter($"Environment = '{environment}' AND JobName='{webJobName}'");
 
            nsMgr.CreateSubscription(topic, subscription, startMessagesFilter);
        }
    }

    public class ServiceBusTopicResolver : INameResolver
    {
        private string _webJobName;

        public ServiceBusTopicResolver(string webJobName)
        {
            _webJobName = webJobName;
        }

        public string Resolve(string key)
        {
            string result = "";

            if (key == "SubscriptionName")
            {
                string environment = ConfigurationManager.AppSettings["WebJobsEnvName"];
                string subscription = $"{_webJobName}{environment}StartMessages";

                result = subscription;
            }
            else
            {
                result = ConfigurationManager.AppSettings[key];
            }

            return result;
        }
    }
}

Constructor

This is typical, minimal setup for a WebJob. The key thing to note here is passing in webJobName. This is important because it will determine names for our Service Bus topics, subscriptions, and it will be used by our NameResolver.

protected static JobHost GetJobHost(string webJobName)
{
    JobHost host = new JobHost(GetConfiguration(webJobName));
    return host;
}

GetConfiguration

My app.config/web.config/Azure WebApp Application Settings contains a flag that determines if we ever even parse our TimerTriggers. This enables us to configure timers on a per-environment basis.

protected static JobHostConfiguration GetConfiguration(string webJobName)
{
    JobHostConfiguration config = new JobHostConfiguration();
    bool isTimersEnabled = bool.Parse(ConfigurationManager.AppSettings["WebJobsTimersEnabled"]);

    if (isTimersEnabled)
    {
        config.UseTimers();
    }

For my purposes, I want to be able to trigger a function via a Service Bus topic in all environments. This could easily be wrapped in a config flag similar to timers. We’ll dive into CreateStartSubscription in the next section.

    // ServiceBus
    CreateStartSubscription(webJobName);
    config.UseServiceBus();

Here, we setup our NameResolver. We’ll look at this in a bit.

    // Set Configs
    config.NameResolver = new ServiceBusTopicResolver(webJobName);

We want to use the core WebJobs configuration options.

    config.UseCore();

    return config;
}

CreateStartSubscription

First, if you’re not familiar with Service Bus, Topics, or Subscriptions, I highly recommend you familiarize yourself before continuing: https://azure.microsoft.com/en-us/documentation/articles/service-bus-dotnet-how-to-use-topics-subscriptions/

Here, we create our manager to interact with Service Bus.

private static void CreateStartSubscription(string webJobName)
{
    NamespaceManager nsMgr =
    NamespaceManager.CreateFromConnectionString(ConfigurationManager
                                                .ConnectionStrings["AzureWebJobsServiceBus"]
                                                .ConnectionString);

Next, we pull some Configuration Settings, and build our subscription name. This is the first place our WebJob name plays a role. We’re building a Topic subscription specific to this WebJob on this environment.

    string environment = ConfigurationManager.AppSettings["WebJobsEnvName"];
    string topic = ConfigurationManager.AppSettings["WebJobsTopicName"];
    string subscription = $"{webJobName}{environment}StartMessages";

    if (nsMgr.SubscriptionExists(topic, subscription))
    {
        nsMgr.DeleteSubscription(topic, subscription);
    }

    SqlFilter startMessagesFilter = new SqlFilter($"Environment = '{environment}'
                                                    AND JobName = '{webJobName}'");

    nsMgr.CreateSubscription(topic, subscription, startMessagesFilter);
}

ServiceBusTopicResolver

Our ServiceBusTopicResolver is going to implement INameResolver. This is going to allow us to have configurable attributes in our WebJob functions. Like this:

public static void FunctionWithTimerTrigger([TimerTrigger(typeof(Schedule), RunOnStartup = false)] TimerInfo timerInfo)
{
    ActualFunctionLogic();

public static void FuctionWithServiceBusTrigger([ServiceBusTrigger("%WebJobsTopicName%",
                                                        "%SubscriptionName%")] BrokeredMessage message)
{

Calling message.Complete() is important. Otherwise, the message will sit in the topic and trigger the function multiple times.

    message.Complete();
    ActualFunctionLogic();
}

private static void ActualFunctionLogic()
{
    ... Do the work here.
}

Specifically, notice the %WebJobsTopicName% and %SubscriptionName%. We can’t put ConfigurationManager.AppSettings[“ThisEnvironmentTopic”] in an attribute tag, so the name resolver keys off of the “%” to do the replacement for us.

public class ServiceBusTopicResolver : INameResolver
    {
        private string _webJobName;

        public ServiceBusTopicResolver(string webJobName)
        {
            _webJobName = webJobName;
        }

        public string Resolve(string key)
        {
            string result = "";

            if (key == "SubscriptionName")
        {
                string environment = ConfigurationManager.AppSettings["WebJobsEnvName"];
                string subscription = $"{_webJobName}{environment}StartMessages";

                result = subscription;
        }

This is the only potentially strange block. If we’re not specifically looking for a subscription name, we just get a general app setting. This could probably be refactored.

            else
            {
                result = ConfigurationManager.AppSettings[key];
            }

            return result;
        }
    }
}

So, how do I run it?

All we need to do to trigger one of Service Bus functions, is put a message on the Topic. You can write a CLI/WPF/WinForms/Web app to do this. Personally, I’m a huge fan of LinqPad for little scripts like this. This is the one I wrote:

// DON'T TOUCH **************************************************************************
string connectionString = ConfigurationManager.ConnectionStrings["AzureWebJobsServiceBus"].ConnectionString;
const string TOPIC = "WebJobsTopic";
TopicClient client = TopicClient.CreateFromConnectionString(connectionString, TOPIC);

const string WebJobName = "WebJobNameGoesHere";

const string DEBUG = "debug";
const string TEST = "test";
const string STAGE = "stage";
const string PROD = "prod";
// ************************************************************************************

BrokeredMessage message = new BrokeredMessage();
message.Properties["Environment"] = PROD;
message.Properties["JobName"] = WebJobName;
client.Send(message);

You can specify multiple WebJobs and environments, configure the BrokeredMessage, and hit F5.

So what does the actual WebJob look like?

public class Program : WebJobBase
{
    // Please set the following connection strings in app.config for this WebJob to run:
    // AzureWebJobsDashboard and AzureWebJobsStorage
    static void Main()
    {
        JobHost host = GetJobHost("WebJobName");

        host.RunAndBlock();
    }
}

Yep. That’s it. Everything else is in the actual functions to be triggered.

Conclusion

I’ve found this approach has solved about 90% of my challenges when managing WebJobs. My most common use cases are automatically included with new jobs, and I now have a common point to extend and improve ALL of my existing WebJobs.

This class is out in GitHub if you want to try it for yourself (https://github.com/DontPanicLabsBlog/WebJobBaseClass). If you do, let me know how it goes and how you’re using it.

 


Related posts