Efficiently serving Blobs from Azure Storage in ASP.NET Core using custom ActionResult
You already may be using Azure Blob Storage to store some files in your application. Do you return those files to the user efficiently? There are a couple of things that are commonly done.
Taking this a step further, you could enable a CDN on the storage account, optionally prefixing the files. You would set up a CDN profile with an endpoint pointing to your blob storage account. The official documentation explains it a more simplistic way with images for the steps. You would then modify the URL of the blob before writing it into your response. For our blob above, you would end up with something like https://contoso.azureedge.net/container1/dir1/file1.pdf and if you add a prefix to the CDN endpoint origin, you can omit some path segments. For example, you can set the path to /container1/dir1 so that the resulting URL would be https://contoso.azureedge.net/file1.pdf. Using a CDN provides better a user experience and can help save costs on Blob Storage but that is not the focus of this post. Thereβs tons of information out there on this.
How about authentication?
So you have built your app but you want to decide if someone gets access to certain files. For example, in a muti-tenant application where the storage account is shared. Azure Blob Storage supports authentication using keys and Azure AD. Keys are meant for your application. For users in your organization (the tenant that owns the Azure Subscription in which the storage account exists), Azure AD may be sufficient to control permissions or using a more enterprise solution like SharePoint would be better. Unfortunately, this is not always the case, at least in my line of work.
The very first and fairly reliable option is to use ASP.NET Core, where you can control user access to your liking and in the process, hide away the intrinsics of where your files are stored from your end user. In most cases, you would either download the file to memory or local disk before serving it in your response.
Look familiar? Yes! Why? It is the easiest way to do so, and if it works, why change it? Unfortunately, this tends to be inefficient in at least two scenarios I can think of:
You want to support HTTP HEAD verb not just HTTP GET without having to download the file from Azure Blob Storage.
You need clients to perform ranging when downloading the contents.
In both of these situations, you need to decide whether to download the contents of the file. The second situation particularly is useful for large files. Storage/Memory (volatile and non-volatile) is expensive, you do not always have enough resources to use for these downloads.
Solution? Give me the code
The solution to this is make use of the Azure.Storage.Blobs library with all the features it offers, including steaming download. If the file is large, a HTTP HEAD request will help the HTTP client (e.g. the browser) to make appropriate preparations before downloading such as checking for space, planning parallel downloads (e.g. download manager).
First, we create a custom implementation of ActionResult because the inbuilt FileStreamResult and FileContentResult expect you to have the complete stream or byte array with you. Basically, they only support local files or small files whose contents you can keep in memory. Here we create BlobStorageResult :
To use it, we would simplify our controller to:
Simple, right? Not so quick. We still need to implement IActionResultExecutor<BlobStorageResult> before it can be resolved in ExecuteResultAsync(β¦). Luckily that is not so hard. Given that BlobStorageResult inherits from FileResult, we can make use of the inbuilt FileResultExecutorBase. A blob is a file after all (well, most of the times).
When I wrote this, I looked at the inbuilt implementation of FileStreamResultExecutor. At first, I thought I did not even need to make use of FileResult because it would be different till I realized using that would allow me to use the FileResultExecutorBase which does a lot of the boiler plate such as:
Validating ranges provided in the request header and deciding if we should write the body.
Setting the appropriate response headers (Content-Length, Content-Type, If-Range, Range, Content-Range, etc) and response code (200, 206 or 416).
What does the proposed approach achieve?
Allow the client to request a range and when they do so, we only download the range from Azure Blob Storage using DownloadStreamingAsync(β¦)
If we have details required for the HTTP HEAD request (e.g. from the database), we can avoid our call to Azure Blob Storage.
When we do not have the details required for the HTTP HEAD request we can fetch the blob properties only which is faster and smaller.
Finally, setting up the services is easy-peasy:
You can then call services.AddBlobStorage(...) in your Startup.cs file.
Even though this may cover most scenarios, it does not support Page blobs (or may be it does by mistake? Let me know), or passing of ContainerName and BlobName instead of BlobUrl but that you can modify in your application.