Managing images on filesystem using Repository pattern in .NET Core
Repository pattern in action for managing files
It is not uncommon that for records in database you need to store image files or other documents related to the specific record on the file system. Storing documents related to the database records on the file system is not a silver bullet. As any approach there are pros and cons in this one as well.
Storing the data in database makes sense if the data will not be accessed to frequent. In case of the images that are for example related to the product database and you are displaying these images publicly, reading large data from your database can increase the load on your database server and impact the overall performances of your application.
Storing images to file system or any other storage in this case makes more sense.
Why should I use file store through Repository pattern implementation?
You may decide to store files to local file system or maybe some distributed store on your cloud provider. This may change over time as the product and system evolves. For example, project budget initially may be quite limited and you will not be storing huge amount of files. In this case you just go with the local file system.
As the requirements, traffic and number of records grow, you will have to switch from file system to some distributed storage. Imagine your code referencing System.IO in pretty much any interaction with file system. Refactoring such code can turn into coding horror.
By changing all System.IO references with lets say Google.Cloud.Storage nuget package references you are pretty much stuck to Google Cloud provider. You are basically dragging your self into Cloud provider Lock-in state. Not to mention the time, effort and in the end cost of this refactoring process. Imagine if you have to after couple of years do another refactoring for let's say moving to Azure. Close to mission impossible.
Masking your file storage logic behind the repository interface gives you freedom to swicth easily from one storage to another just by writing new repository implementation for targeting persistance framework. Switching to a new storage is only a matter of injecting new repository interface implementation in using .NET Core native dependency injection mechanism or AutoFac.
Filesystem implementation
As a sample for our implementation we'll take a product catalog application. To keep things simple, we'll only have the Product entity for storing data to our database and since we do not want to store images to database we'll have ProductImage entity which we'll persist to our local file system.
Let's start first with out Product entity
namespace Catalog.Core.Entities { public class Product:BaseEntity { [Key, Column(Order = 0)] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public Guid Id { get; set; } [ConcurrencyCheck] [Timestamp] public byte[] Version { get; set; } [Required] public String Name { get; set; } [Required] [Range(0, double.MaxValue)] public double Price { get; set; } public DateTime LastUpdated { get; set; } [MaxLength(500)] public String Description { get; set; } } }
You can see that we are not referencing any image entity although they are in relation one to many. It is not needed because these two entities can exist separately, so coupling them together may only complicate things.
Now lets create ProductImage entity
namespace Catalog.Core.Entities { public class ProductImage { public String Id { get; private set; } public Guid ProductId { get; private set; } public String FileName { get; private set; } public byte[] Content { get; set; } public ProductImage(Guid productId, String fileName) { this.ProductId = productId; this.FileName = fileName; this.Id = new StringBuilder().Append( MD5.Create().ComputeHash( Encoding.ASCII.GetBytes($"{productId.ToString()}:{fileName.ToLower()}")) .Select(b => b.ToString("X2")).ToArray()) .ToString(); } } }
ProductImage entity may look a bit more complex because of the ID generated on the fly. Since our files do not have unique identity property, they have instead ProductId and FileName as a composite key, so to simplify things when working with ProductImage entity instances, I just combined then and hashed to create unique ID for the entity instance.
OK, we have our core layer with entities. We can proceed to core services which in our case will be repositories for Product and ProductImage entities. Let's start with the products repository.
We first need to define the interface fir the repository
namespace Catalog.CoreServices.Repositories { public interface IProductRepository<T> : IDisposable { Task<T> GetByIdAsync(Guid id); Task<T> GetByIdAsync(String id); Task<IEnumerable<T>> GetAsync(int pageNumber, int pageSize); Task<Guid> InsertAsync(T item); Task InsertRangeAsync(IEnumerable<T> items); void Remove(Guid id); void RemoveRange(IEnumerable<Guid> ids); } }
And now simple implementation with Entity Framework Core
namespace Catalog.CoreServices.Repositories { public abstract class BaseRepository<T> : IRepository<T> where T : BaseEntity { DbContext dbContext; bool disposing; public DbContext DbContext { get { return this.dbContext; } } public BaseRepository(CatalogDbContext dbContext) { this.dbContext = dbContext; } public virtual void Dispose() { if (!this.disposing) { this.dbContext.Dispose(); this.disposing = true; } } public virtual async Task<T> GetByIdAsync(Guid id) { return await this.dbContext.Set<T>().FindAsync(id); } public virtual async Task<T> GetByIdAsync(string id) { return await GetByIdAsync(Guid.Parse(id)); } public virtual async Task<IEnumerable<T>> GetAsync(int pageNumber, int pageSize) { await Task.CompletedTask; return this.dbContext.Set<T>().AsNoTracking().Skip(pageNumber*pageSize).Take(pageSize); } public virtual async Task<Guid> InsertAsync(T item) { await this.dbContext.Set<T>().AddAsync(item); return item.Id; } public virtual IQueryable<T> Find(System.Linq.Expressions.Expression<Func<T, bool>> expression) { return this.dbContext.Set<T>().AsNoTracking().Where(expression); } public void Remove(Guid id) { var item = this.dbContext.Set<T>().FirstOrDefault(e => e.Id == id); if (item != null) { this.dbContext.Set<T>().Remove(item); } } public void RemoveRange(IEnumerable<Guid> ids) { var items = this.dbContext.Set<T>().Where(e => ids.Contains(e.Id)); if (items != null && items.Any()) { this.dbContext.Set<T>().RemoveRange(items); } } public async Task InsertRangeAsync(IEnumerable<T> items) { await this.dbContext.Set<T>().AddRangeAsync(items); } } }
Detailed Product entity repository and DbContext implementation is left out as it is not the focus of this article. Product repository interface, implementation and Product entity are only listed as a reference for the ProductImage repository we are going to implement.
We briefly see what are main methods of the repository for storing our Product entities to the database. We have a big help of Entity Framework and the DbContext when manipulation the database, but for our image implementation, we are less relational data oriented, so we'll have to write our own implementation using only the persistence APIs to actually store the data.
For the simplicity reasons, we are going to use file system, as we are not going to need any third party library and pretty much any .NET developer is familiar with System.IO namespace. Same as with the Product repository, let's start by declaring the interface for our repository.
namespace Catalog.CoreServices.Repositories { public interface IImageRepository { Task AddImage(Guid productId,String name, byte[] image); Task AddImage(ProductImage productImage); Task DeleteImage(Guid productId, String name); Task<byte[]> GetImage(Guid productId, String name); String GetImagePath(Guid productId, String name); Task SaveChanges(); } }
Now, as you can see in the Product repository, nothing is actually persisted until we invoke SaveChanges of the DbContext class. All the changes that are done by the Product repository are handled by Entity Framework and once SaveChanges is invoked changes will be actually persisted. This allow us to have a transaction on top of our Product entities changes.
I am not going to go into transaction implementation for file system, but I will still persist changes only on SaveChanges method of the ImageRepository. Since we do not have the DbContext here to track the changes, we'll do SaveChanges on the repository implementation itself.
namespace Catalog.CoreServices.Repositories { public class ImageRepository : IImageRepository { private int usingResource = 0; readonly IConfiguration configuration; readonly String folderPath; readonly IDictionary<String, ProductImage> addImages; readonly ICollection<ProductImage> removeImages; public ImageRepository(IConfiguration configuration) { this.addImages = new Dictionary<String, ProductImage>(); this.removeImages = new List<ProductImage>(); this.configuration = configuration; this.folderPath = configuration.GetValue<String>("Images:Folder"); if (String.IsNullOrWhiteSpace(this.folderPath)) { //Throw exception } else if (!Directory.Exists(folderPath)) { Directory.CreateDirectory(folderPath); } } public async Task AddImage(Guid productId, String name, byte[] image) { var productImage = new ProductImage(productId, name) { Content = image }; await AddImage(productImage); } public async Task DeleteImage(Guid productId, String name) { var productImage = new ProductImage(productId, name); this.addImages.Remove(productImage.Id); //Cancel image add if (!this.removeImages.Any(r=>r.Id.Equals(productImage.Id))) { this.removeImages.Add(productImage); } await Task.CompletedTask; } public async Task<byte[]> GetImage(Guid productId, String name) { return File.ReadAllBytes(GetImagePath(productId,name)); } public async Task SaveChanges() { foreach(var addFile in addImages) { File.WriteAllBytes( this.GetImagePath(addFile.Value.ProductId,addFile.Value.FileName), addFile.Value.Content); } foreach (var removeFile in removeImages) { File.Delete(this.GetImagePath(removeFile.ProductId, removeFile.FileName)); } await Task.CompletedTask; } public async Task AddImage(ProductImage productImage) { this.addImages.Remove(productImage.Id); //Remove old image if (this.removeImages.Any(r => r.Id.Equals(productImage.Id, StringComparison.InvariantCultureIgnoreCase))) { this.removeImages.Remove(this.removeImages.First(r => r.Id.Equals(productImage.Id, StringComparison.InvariantCultureIgnoreCase))); //Cancel deletion } this.addImages.Add(productImage.Id, productImage); //Add new image await Task.CompletedTask; } public String GetImagePath(Guid productId, String name) { return Path.Combine(folderPath, productId.ToString(), name); } } }
Note that ProductImageRepository operations are not thread save! Since the lifetime of the repository should be Scoped, repository instance lives only during the request in ASP.NET Core application. If you however plan to use it as a singleton, please consider using Locking or any other thread synchronization technique.
This is just a conceptual model for images repository. For smaller projects it may work, but if you consider involving repository invocation in multi-threaded environment or introduce transaction mechanism I suggest you spend some time on the implementation.
However, this should be able to give you a nice jumstart in creating your own repository for handling the static files.
References
Disclaimer
Purpose of the code contained in snippets or available for download in this article is solely for learning and demo purposes. Author will not be held responsible for any failure or damages caused due to any other usage.
Comments for this article