Once upon a time I worked out simple file client generalization so my applications can keep files on local machine or somewhere on the cloud without changes in application code. This blog post shows how to generalize file access in web applications and provides implementations for local and cloud file clients.
This is how you generalize file access in your ASP.NET Core applications Click To Tweet
IFileClient generalization
For those not familiar with my IFileClient generalization here is the diagram illustrating what I did.
Here is my IFileClient definition.
public interface IFileClient
{
Task DeleteFile(string storeName, string filePath);
Task<bool> FileExists(string storeName, string filePath);
Task<Stream> GetFile(string storeName, string filePath);
Task<string> GetFileUrl(string storeName, string filePath);
Task SaveFile(string storeName, string filePath, Stream fileStream);
}
The important concept I’m using here is store name. Store contains files that logically belong together. Originally I took this concept because in Azure blob storage the first level of file system is blob container.
Local file client
Here is my implementation for local file client.
public class LocalFileClient : IFileClient
{
private string _fileRoot;
public LocalFileClient(string fileRoot)
{
_fileRoot = fileRoot;
}
public async Task DeleteFile(string storeName, string filePath)
{
var path = Path.Combine(_fileRoot, storeName, filePath);
if(File.Exists(path))
{
File.Delete(path);
}
}
public Task<bool> FileExists(string storeName, string filePath)
{
var path = Path.Combine(_fileRoot, storeName, filePath);
return Task.FromResult(File.Exists(path));
}
public async Task<Stream> GetFile(string storeName, string filePath)
{
var path = Path.Combine(_fileRoot, storeName, filePath);
Stream stream = null;
if(File.Exists(path))
{
stream = File.OpenRead(path);
}
return await Task.FromResult(stream);
}
public async Task<string> GetFileUrl(string storeName, string filePath)
{
return await Task.FromResult((string)null);
}
public async Task SaveFile(string storeName, string filePath, Stream fileStream)
{
var path = Path.Combine(_fileRoot, storeName, filePath);
if(File.Exists(path))
{
File.Delete(path);
}
using (var file = new FileStream(path, FileMode.CreateNew))
{
await fileStream.CopyToAsync(file);
}
}
}
My local file client doesn’t support URL-s because local file system in web server is not open to public space. Therefore it’s the matter of web application to form download URL-s for browsers.
Azure blob storage client
Let’s start with Azure blob storage. Every blob storage account has blob containers on first level. Blobs – in our case files we want to save – are held in containers. Azure blob storage containers may contain folders almost like local file system but there’s a little trick. Folders on Azure blob storage are not real. They exist only with blob in them. If there’s no blobs with given folder path then there’s no such “folder” anymore.
With this in mind I wrote Azure blob storage file client.
public class AzureBlobFileClient : IFileClient
{
private CloudBlobClient _blobClient;
public AzureBlobFileClient(string connectionString)
{
var account = CloudStorageAccount.Parse(connectionString);
_blobClient = account.CreateCloudBlobClient();
}
public async Task DeleteFile(string storeName, string filePath)
{
var container = _blobClient.GetContainerReference(storeName);
var blob = container.GetBlockBlobReference(filePath.ToLower());
await blob.DeleteIfExistsAsync();
}
public async Task<bool> FileExists(string storeName, string filePath)
{
var container = _blobClient.GetContainerReference(storeName);
var blob = container.GetBlockBlobReference(filePath.ToLower());
return await blob.ExistsAsync();
}
public async Task<Stream> GetFile(string storeName, string filePath)
{
var container = _blobClient.GetContainerReference(storeName);
var blob = container.GetBlockBlobReference(filePath.ToLower());
var mem = new MemoryStream();
await blob.DownloadToStreamAsync(mem);
mem.Seek(0, SeekOrigin.Begin);
return mem;
}
public async Task<string> GetFileUrl(string storeName, string filePath)
{
var container = _blobClient.GetContainerReference(storeName);
var blob = container.GetBlockBlobReference(filePath.ToLower());
string url = null;
if(await blob.ExistsAsync())
{
url = blob.Uri.AbsoluteUri;
}
return url;
}
public async Task SaveFile(string storeName, string filePath, Stream fileStream)
{
var container = _blobClient.GetContainerReference(storeName);
var blob = container.GetBlockBlobReference(filePath.ToLower());
await blob.UploadFromStreamAsync(fileStream);
}
}
Blob storage file client supports file URL-s. This is because you may have public blob and instead of dragging files through your web application you may want browsers to download files directly from blob storage.
Azure file share client
Another popular storage option on Azure is File Share that belongs to same family with blob storage. Azure file share is like network share we are using over SMB protocol.
Here’s my Azure file share client.
public class AzureFileShareClient : IFileClient
{
private CloudFileClient _fileClient;
public AzureFileShareClient(string connectionString)
{
var account = CloudStorageAccount.Parse(connectionString);
_fileClient = account.CreateCloudFileClient();
}
public async Task DeleteFile(string storeName, string filePath)
{
var share = _fileClient.GetShareReference(storeName);
var folder = share.GetRootDirectoryReference();
var pathParts = filePath.Split('/');
var fileName = pathParts[pathParts.Length - 1];
for (var i = 0; i < pathParts.Length - 2; i++)
{
folder = folder.GetDirectoryReference(pathParts[i]);
if(! await folder.ExistsAsync())
{
return;
}
}
var fileRef = folder.GetFileReference(fileName);
await fileRef.DeleteIfExistsAsync();
}
public async Task<bool> FileExists(string storeName, string filePath)
{
var share = _fileClient.GetShareReference(storeName);
var folder = share.GetRootDirectoryReference();
var pathParts = filePath.Split('/');
var fileName = pathParts[pathParts.Length - 1];
for (var i = 0; i < pathParts.Length - 2; i++)
{
folder = folder.GetDirectoryReference(pathParts[i]);
if (!await folder.ExistsAsync())
{
return await Task.FromResult(false);
}
}
var fileRef = folder.GetFileReference(fileName);
return await fileRef.ExistsAsync();
}
public async Task<Stream> GetFile(string storeName, string filePath)
{
var share = _fileClient.GetShareReference(storeName);
var folder = share.GetRootDirectoryReference();
var pathParts = filePath.Split('/');
var fileName = pathParts[pathParts.Length - 1];
for (var i = 0; i < pathParts.Length - 2; i++)
{
folder = folder.GetDirectoryReference(pathParts[i]);
if (!await folder.ExistsAsync())
{
return null;
}
}
var fileRef = folder.GetFileReference(fileName);
if(!await fileRef.ExistsAsync())
{
return null;
}
return await fileRef.OpenReadAsync();
}
public async Task<string> GetFileUrl(string storeName, string filePath)
{
return await Task.FromResult((string)null);
}
public async Task SaveFile(string storeName, string filePath, Stream fileStream)
{
var share = _fileClient.GetShareReference(storeName);
var folder = share.GetRootDirectoryReference();
var pathParts = filePath.Split('/');
var fileName = pathParts[pathParts.Length - 1];
for (var i = 0; i < pathParts.Length - 2; i++)
{
folder = folder.GetDirectoryReference(pathParts[i]);
await folder.CreateIfNotExistsAsync();
}
var fileRef = folder.GetFileReference(fileName);
await fileRef.UploadFromStreamAsync(fileStream);
}
}
Notice that GetFileUrl() method returns null. This is because files from file share are served through web application. I don’t expect files on file share to be available for public access.
Using IFileClient in ASP.NET Core applications
Using file clients in web applications is simple. First we need to register file client in ASP.NET Core dependency injection. It is done on ConfigureServices() method of Startup class. Here’s how to do it using instance factory action.
services.AddScoped<IFileClient, AzureFileShareClient>(client => {
var cloudConnStr = Configuration["StorageConnectionString"];
return new AzureFileShareClient(cloudConnStr);
});
There are some options to configure dependencies based on environment:
- Environment-based configuring methods in Startup class
- Environment-based startup classes
- Using #if(DEBUG) checks (know what you do!)
As dependency injection knows what type of instance to return when IFileClient is asked, we can inject IFileClient now to controllers and services. Here’s the illustrative example.
[Authorize]
public class FilesController : Controller
{
private readonly IFileClient _fileClient;
public FilesController(IFileClient fileClient)
{
_fileClient = fileClient;
}
// Index() and other actions
public async Task<IActionResult> UploadSignedContract(IFormFile file)
{
var fileName = Sanitize("/" + UserId + "/" + file.FileName);
using (var fileStream = file.OpenReadStream())
{
await _fileClient.SaveFile("Contracts", fileName, fileStream);
}
return RedirectToAction("Index");
}
}
Extending IFileClient
It’s possible to extend my work and add additional features if you need. Some ideas:
- method to get directory contents (challenging with large blob storage containers)
- returning metadata with files
- setting properties like content type and cache control for public blobs
Based on your needs you may have something to add to this list. I have kept my current implementations simple because they mostly work in context where files and their locations are known well and there’s no need to list files, by example.
Wrapping up
When moving web applications to cloud we have to get rid of local files as web server and file server have different load patterns. Usually cloud providers doesn’t expect content files to be held on web servers. On local machine we want file stores to be local. Besides faster file access we don’t need internet connection to build applications. IFileClient solves these problems effectively.
View Comments (4)
There are some async/await issues in your code that I try to fix in this gist
https://gist.github.com/SirRufo/6c31d6d688542bf0a139452087235163
You might want to have a look at this https://github.com/aloneguid/storage
Interested how your `Sanitize` method looks like... Microsoft has some specific rules about the path segments.
Nice explanation