Scaling Web Push Notifications with Azure Functions
One of my talks is talk about native real-time technologies like WebSockets, Server-Sent Events, and Web Push in ASP.NET Core. In that talk, I briefly go through the subject of scaling applications based on those technologies. For Web Push, I mention that it can be scaled with help of microservices or functions, because it doesn't require an active HTTP request/response. After the talk, I'm frequently asked for some samples of how to do that. This post is me finally putting one together.
Sending Web Push Notifications from Azure Functions
I've written a series of posts about Web Push Notifications. In general, it's good to understand one level deeper than what you're working at (so I encourage you to go through that series), but for this post, it's enough to know that you can grab a client from NuGet. This client is all that is needed to send a notification from Azure Function, so let's quickly create one.
The function will use Azure Cosmos DB as a data source. Whenever an insert or update happens in NotificationsCollection, the function will be triggered.
public static class SendNotificationFunction
{
[FunctionName("SendNotification")]
public static void Run(
[CosmosDBTrigger("PushNotifications", "NotificationsCollection",
LeaseCollectionName = "NotificationsLeaseCollection", CreateLeaseCollectionIfNotExists = true,
ConnectionStringSetting = "CosmosDBConnection")]
IReadOnlyList<PushMessage> notifications)
{
}
}
You may notice that it's using an extension for Azure Cosmos DB Trigger described in my previous post, which allows for taking a collection of POCOs as an argument.
The second piece of data kept in Cosmos DB is subscriptions. The function will need to query all subscriptions to send notifications. This is best done by using DocumentClient
.
public static class SendNotificationFunction
{
private static readonly Uri _subscriptionsCollectionUri =
UriFactory.CreateDocumentCollectionUri("PushNotifications", "SubscriptionsCollection");
[FunctionName("SendNotification")]
public static void Run(
...,
[CosmosDB("PushNotifications", "SubscriptionsCollection", ConnectionStringSetting = "CosmosDBConnection")]
DocumentClient client)
{
if (notifications != null)
{
IDocumentQuery<PushSubscription> subscriptionQuery =
client.CreateDocumentQuery<PushSubscription>(_subscriptionsCollectionUri, new FeedOptions
{
EnableCrossPartitionQuery = true,
MaxItemCount = -1
}).AsDocumentQuery();
}
}
}
Now the function is almost ready to send notifications. Last missing part is PushServiceClient
. An instance of PushServiceClient
is internally holding an instance of HttpClient
. This means that improper instantiation antipattern must be taken into consideration. The simplest approach is to create a static instance.
public static class SendNotificationFunction
{
...
private static readonly PushServiceClient _pushClient = new PushServiceClient
{
DefaultAuthentication = new VapidAuthentication(
"<Application Server Public Key>",
"<Application Server Private Key>")
{
Subject = "<Subject>"
}
};
[FunctionName("SendNotification")]
public static void Run(...)
{
...
}
}
Sending notifications is now nothing more than calling RequestPushMessageDeliveryAsync
for every combination of subscription and notification.
public static class SendNotificationFunction
{
...
[FunctionName("SendNotification")]
public static async Task Run(...)
{
if (notifications != null)
{
...
while (subscriptionQuery.HasMoreResults)
{
foreach (PushSubscription subscription in await subscriptionQuery.ExecuteNextAsync())
{
foreach (PushMessage notification in notifications)
{
// Fire-and-forget
_pushClient.RequestPushMessageDeliveryAsync(subscription, notification);
}
}
}
}
}
}
And that's it. A very simple Azure Function taking care of broadcast notifications.
Improvements, Improvements ...
The code above can be better. I'm not thinking about the Cosmos DB part (although there are ways to better utilize partitioning or implement fan-out approach with help of durable functions). The usage of PushServiceClient
is far from perfect. Static instance makes configuration awkward and may cause issues due to static HttpClient
not respecting DNS changes. In the past, I've written about using HttpClientFactory
and making HttpClient
injectable in Azure Functions. The exact same approach can be used here. I will skip the boilerplate code, it's described in that post. I'll focus only on things specific to PushServiceClient
.
First thing is an attribute. It will allow taking PushServiceClient
instance as a function parameter. It will also help solve the configuration problem, by providing properties for names of settings with needed values.
[Binding]
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class PushServiceAttribute : Attribute
{
[AppSetting]
public string PublicKeySetting { get; set; }
[AppSetting]
public string PrivateKeySetting { get; set; }
[AppSetting]
public string SubjectSetting { get; set; }
}
The second thing is a converter which will use HttpClientFactory
and instance of PushServiceAttribute
to create a properly initialized PushServiceClient
. Luckily PushServiceClient
has a constructor which takes HttpClient
instance as a parameter, so it's quite simple.
internal class PushServiceClientConverter : IConverter<PushServiceAttribute, PushServiceClient>
{
private readonly IHttpClientFactory _httpClientFactory;
public PushServiceClientConverter(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
}
public PushServiceClient Convert(PushServiceAttribute attribute)
{
return new PushServiceClient(_httpClientFactory.CreateClient())
{
DefaultAuthentication = new VapidAuthentication(attribute.PublicKeySetting, attribute.PrivateKeySetting)
{
Subject = attribute.SubjectSetting
}
};
}
}
The last thing is IExtensionConfigProvider
which will add a binding rule for PushServiceAttribute
and tell the Azure Functions runtime to use PushServiceClientConverter
for providing PushServiceClient
instance.
[Extension("PushService")]
internal class PushServiceExtensionConfigProvider : IExtensionConfigProvider
{
private readonly IHttpClientFactory _httpClientFactory;
public PushServiceExtensionConfigProvider(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public void Initialize(ExtensionConfigContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
//PushServiceClient Bindings
var bindingAttributeBindingRule = context.AddBindingRule<PushServiceAttribute>();
bindingAttributeBindingRule.AddValidator(ValidateVapidAuthentication);
bindingAttributeBindingRule.BindToInput<PushServiceClient>(typeof(PushServiceClientConverter), _httpClientFactory);
}
...
}
I've skipped the code which reads and validates settings. If you're interested you can find it on GitHub.
With the above extension in place, the static instance of PushServiceClient
is no longer needed.
public static class SendNotificationFunction
{
...
[FunctionName("SendNotification")]
public static async Task Run(
...,
[PushService(PublicKeySetting = "ApplicationServerPublicKey",
PrivateKeySetting = "ApplicationServerPrivateKey", SubjectSetting = "ApplicationServerSubject")]
PushServiceClient pushServiceClient)
{
if (notifications != null)
{
...
while (subscriptionQuery.HasMoreResults)
{
foreach (PushSubscription subscription in await subscriptionQuery.ExecuteNextAsync())
{
foreach (PushMessage notification in notifications)
{
// Fire-and-forget
pushServiceClient.RequestPushMessageDeliveryAsync(subscription, notification);
}
}
}
}
}
}
You Don't Have to Reimplement This by Yourself
The PushServiceClient
binding extension is available on NuGet. I have also pushed the demo project to GitHub. Hopefully it will help you to get the best out of Web Push Notifications.