From 8d32ac8e788e604393bb967dbdbb3041e66b7879 Mon Sep 17 00:00:00 2001 From: James South Date: Tue, 17 Feb 2015 00:02:23 +0000 Subject: [PATCH] More tooling in Azure cache. TODO: Config Former-commit-id: d4bb114a2a45ee9e1cb5286d700668d9ad243cbd Former-commit-id: 12c00c04646fefc73f241ee93a7fb358ad32d2e7 --- .../AzureBlobCache.cs | 189 ++++++++++++++---- .../ImageProcessor.Web.AzureBlobCache.csproj | 4 + src/ImageProcessor.Web/Caching/DiskCache2.cs | 58 +++--- src/ImageProcessor.Web/Caching/IImageCache.cs | 6 + .../Caching/ImageCacheBase.cs | 64 +++--- src/ImageProcessor.sln | 17 ++ 6 files changed, 238 insertions(+), 100 deletions(-) diff --git a/src/ImageProcessor.Web.AzureBlobCache/AzureBlobCache.cs b/src/ImageProcessor.Web.AzureBlobCache/AzureBlobCache.cs index 4706fd857..d6beca980 100644 --- a/src/ImageProcessor.Web.AzureBlobCache/AzureBlobCache.cs +++ b/src/ImageProcessor.Web.AzureBlobCache/AzureBlobCache.cs @@ -1,11 +1,17 @@ namespace ImageProcessor.Web.AzureBlobCache { using System; + using System.Collections.Generic; using System.Configuration; + using System.Globalization; + using System.IO; + using System.Linq; using System.Threading.Tasks; using System.Web; using ImageProcessor.Web.Caching; + using ImageProcessor.Web.Extensions; + using ImageProcessor.Web.Helpers; using Microsoft.WindowsAzure; using Microsoft.WindowsAzure.Storage; @@ -13,39 +19,72 @@ public class AzureBlobCache : ImageCacheBase { - private CloudStorageAccount cloudStorageAccount; + /// + /// The max age. + /// + private readonly int maxAge; + + private CloudStorageAccount cloudCachedStorageAccount; + + private CloudStorageAccount cloudSourceStorageAccount; + + private CloudBlobClient cloudCachedBlobClient; - private CloudBlobClient cloudBlobClient; + private CloudBlobClient cloudSourceBlobClient; - private CloudBlobContainer cloudBlobContainer; + private CloudBlobContainer cloudCachedBlobContainer; + + private CloudBlobContainer cloudSourceBlobContainer; + + private string cachedContainerRoot; + + /// + /// The physical cached path. + /// + private string physicalCachedPath; public AzureBlobCache(string requestPath, string fullPath, string querystring) : base(requestPath, fullPath, querystring) { - // TODO: These should all be in the configuration. + // TODO: Get from configuration. + this.Settings = new Dictionary(); - // Retrieve storage account from connection string. - this.cloudStorageAccount = CloudStorageAccount.Parse( - CloudConfigurationManager.GetSetting("StorageConnectionString")); + this.maxAge = Convert.ToInt32(this.Settings["MaxAge"]); - // Create the blob client. - this.cloudBlobClient = this.cloudStorageAccount.CreateCloudBlobClient(); + // Retrieve storage accounts from connection string. + this.cloudCachedStorageAccount = CloudStorageAccount.Parse(this.Settings["CachedStorageAccount"]); + this.cloudSourceStorageAccount = CloudStorageAccount.Parse(this.Settings["SourceStorageAccount"]); - // Retrieve reference to a previously created container. - this.cloudBlobContainer = this.cloudBlobClient.GetContainerReference("mycontainer"); + // Create the blob clients. + this.cloudCachedBlobClient = this.cloudCachedStorageAccount.CreateCloudBlobClient(); + this.cloudSourceBlobClient = this.cloudSourceStorageAccount.CreateCloudBlobClient(); + + // Retrieve references to a previously created containers. + this.cloudCachedBlobContainer = this.cloudCachedBlobClient.GetContainerReference(this.Settings["CachedBlobContainer"]); + this.cloudSourceBlobContainer = this.cloudSourceBlobClient.GetContainerReference(this.Settings["SourceBlobContainer"]); + + this.cachedContainerRoot = this.Settings["CachedContainerRoot"]; } public override int MaxAge { - get { throw new System.NotImplementedException(); } + get + { + return this.maxAge; + } } public override async Task IsNewOrUpdatedAsync() { string cachedFileName = await this.CreateCachedFileName(); - // TODO: Generate cache path. - CloudBlockBlob blockBlob = new CloudBlockBlob(new Uri("")); + // Collision rate of about 1 in 10000 for the folder structure. + // That gives us massive scope to store millions of files. + string pathFromKey = string.Join("\\", cachedFileName.ToCharArray().Take(6)); + this.CachedPath = Path.Combine(this.cachedContainerRoot, pathFromKey, cachedFileName).Replace(@"\", "/"); + + ICloudBlob blockBlob = await this.cloudCachedBlobContainer + .GetBlobReferenceFromServerAsync(this.RequestPath); bool isUpdated = false; if (!await blockBlob.ExistsAsync()) @@ -53,46 +92,126 @@ // Nothing in the cache so we should return true. isUpdated = true; } - else if (blockBlob.Properties.LastModified.HasValue) + else { - // Check to see if the cached image is set to expire. - if (this.IsExpired(blockBlob.Properties.LastModified.Value.UtcDateTime)) + // Pull the latest info. + await blockBlob.FetchAttributesAsync(); + if (blockBlob.Properties.LastModified.HasValue) { - isUpdated = true; + // Check to see if the cached image is set to expire. + if (this.IsExpired(blockBlob.Properties.LastModified.Value.UtcDateTime)) + { + isUpdated = true; + } } } return isUpdated; } - public override async Task AddImageToCacheAsync(System.IO.Stream stream) + public override async Task AddImageToCacheAsync(Stream stream) { - throw new System.NotImplementedException(); + CloudBlockBlob blockBlob = this.cloudCachedBlobContainer.GetBlockBlobReference(this.CachedPath); + await blockBlob.UploadFromStreamAsync(stream); } public override async Task TrimCacheAsync() { - throw new System.NotImplementedException(); + Uri uri = new Uri(this.CachedPath); + string path = uri.GetLeftPart(UriPartial.Path); + string directory = path.Substring(0, path.LastIndexOf('/')); + string parent = directory.Substring(0, path.LastIndexOf('/')); + + BlobContinuationToken continuationToken = null; + CloudBlobDirectory directoryBlob = this.cloudCachedBlobContainer.GetDirectoryReference(parent); + List results = new List(); + + // Loop through the all the files in a non blocking fashion. + do + { + BlobResultSegment response = await directoryBlob.ListBlobsSegmentedAsync(continuationToken); + continuationToken = response.ContinuationToken; + results.AddRange(response.Results); + } + while (continuationToken != null); + + // Now leap through and delete. + foreach (CloudBlockBlob blob in results + .Where((blobItem, type) => blobItem is CloudBlockBlob) + .Cast() + .OrderBy(b => b.Properties.LastModified != null ? b.Properties.LastModified.Value.UtcDateTime : new DateTime())) + { + if (blob.Properties.LastModified.HasValue && !this.IsExpired(blob.Properties.LastModified.Value.UtcDateTime)) + { + await blob.DeleteAsync(); + } + } } - public override void RewritePath(HttpContext context) + public override async Task CreateCachedFileName() { - throw new System.NotImplementedException(); + string streamHash = string.Empty; + + try + { + if (new Uri(this.RequestPath).IsFile) + { + ICloudBlob blockBlob = await this.cloudSourceBlobContainer + .GetBlobReferenceFromServerAsync(this.RequestPath); + + if (await blockBlob.ExistsAsync()) + { + // Pull the latest info. + await blockBlob.FetchAttributesAsync(); + + if (blockBlob.Properties.LastModified.HasValue) + { + string creation = blockBlob.Properties.LastModified.Value.UtcDateTime.ToString(CultureInfo.InvariantCulture); + string length = blockBlob.Properties.Length.ToString(CultureInfo.InvariantCulture); + streamHash = string.Format("{0}{1}", creation, length); + } + } + else + { + // Get the hash for the filestream. That way we can ensure that if the image is + // updated but has the same name we will know. + FileInfo imageFileInfo = new FileInfo(this.RequestPath); + if (imageFileInfo.Exists) + { + // Pull the latest info. + imageFileInfo.Refresh(); + + // Checking the stream itself is far too processor intensive so we make a best guess. + string creation = imageFileInfo.CreationTimeUtc.ToString(CultureInfo.InvariantCulture); + string length = imageFileInfo.Length.ToString(CultureInfo.InvariantCulture); + streamHash = string.Format("{0}{1}", creation, length); + } + } + } + } + catch + { + streamHash = string.Empty; + } + + // Use an sha1 hash of the full path including the querystring to create the image name. + // That name can also be used as a key for the cached image and we should be able to use + // The characters of that hash as sub-folders. + string parsedExtension = ImageHelpers.GetExtension(this.FullPath, this.Querystring); + string encryptedName = (streamHash + this.FullPath).ToSHA1Fingerprint(); + + string cachedFileName = string.Format( + "{0}.{1}", + encryptedName, + !string.IsNullOrWhiteSpace(parsedExtension) ? parsedExtension.Replace(".", string.Empty) : "jpg"); + + return cachedFileName; } - /// - /// Gets a value indicating whether the given images creation date is out with - /// the prescribed limit. - /// - /// - /// The creation date. - /// - /// - /// The true if the date is out with the limit, otherwise; false. - /// - private bool IsExpired(DateTime creationDate) + public override void RewritePath(HttpContext context) { - return creationDate.AddDays(this.MaxAge) < DateTime.UtcNow.AddDays(-this.MaxAge); + // The cached file is valid so just rewrite the path. + context.RewritePath(this.CachedPath, false); } } } diff --git a/src/ImageProcessor.Web.AzureBlobCache/ImageProcessor.Web.AzureBlobCache.csproj b/src/ImageProcessor.Web.AzureBlobCache/ImageProcessor.Web.AzureBlobCache.csproj index 4d30e737d..f2b15b3ab 100644 --- a/src/ImageProcessor.Web.AzureBlobCache/ImageProcessor.Web.AzureBlobCache.csproj +++ b/src/ImageProcessor.Web.AzureBlobCache/ImageProcessor.Web.AzureBlobCache.csproj @@ -47,11 +47,15 @@ ..\packages\Microsoft.WindowsAzure.ConfigurationManager.1.8.0.0\lib\net35-full\Microsoft.WindowsAzure.Configuration.dll + + ..\packages\WindowsAzure.Storage.4.3.0\lib\net40\Microsoft.WindowsAzure.Storage.dll + ..\packages\Newtonsoft.Json.5.0.8\lib\net45\Newtonsoft.Json.dll True + False diff --git a/src/ImageProcessor.Web/Caching/DiskCache2.cs b/src/ImageProcessor.Web/Caching/DiskCache2.cs index 2ea9e34a6..f4e04e7c3 100644 --- a/src/ImageProcessor.Web/Caching/DiskCache2.cs +++ b/src/ImageProcessor.Web/Caching/DiskCache2.cs @@ -26,29 +26,33 @@ private const int MaxFilesCount = 100; /// - /// The virtual cache path. + /// The max age. /// - private static readonly string VirtualCachePath = ImageProcessorConfiguration.Instance.VirtualCachePath; + private readonly int maxAge; /// - /// The absolute path to virtual cache path on the server. - /// TODO: Change this so configuration is determined per IImageCache instance. + /// The virtual cache path. /// - private static readonly string AbsoluteCachePath = HostingEnvironment.MapPath(VirtualCachePath); + private readonly string virtualCachePath; /// - /// The physical cached path. + /// The absolute path to virtual cache path on the server. /// - private string physicalCachedPath; + private readonly string absoluteCachePath; /// - /// The virtual cached path. + /// The virtual cached path to the cached file. /// - private string virtualCachedPath; + private string virtualCachedFilePath; public DiskCache2(string requestPath, string fullPath, string querystring) : base(requestPath, fullPath, querystring) { + // TODO: Get from configuration. + this.Settings = new Dictionary(); + this.maxAge = Convert.ToInt32(this.Settings["MaxAge"]); + this.virtualCachePath = this.Settings["VirtualCachePath"]; + this.absoluteCachePath = HostingEnvironment.MapPath(this.virtualCachePath); } /// @@ -59,7 +63,7 @@ { get { - return ImageProcessorConfiguration.Instance.MaxCacheDays; + return this.maxAge; } } @@ -68,15 +72,14 @@ string cachedFileName = await this.CreateCachedFileName(); // Collision rate of about 1 in 10000 for the folder structure. - // That gives us massive scope for files. + // That gives us massive scope to store millions of files. string pathFromKey = string.Join("\\", cachedFileName.ToCharArray().Take(6)); string virtualPathFromKey = pathFromKey.Replace(@"\", "/"); - this.physicalCachedPath = Path.Combine(AbsoluteCachePath, pathFromKey, cachedFileName); - this.virtualCachedPath = Path.Combine(VirtualCachePath, virtualPathFromKey, cachedFileName).Replace(@"\", "/"); - this.CachedPath = this.physicalCachedPath; + this.CachedPath = Path.Combine(this.absoluteCachePath, pathFromKey, cachedFileName); + this.virtualCachedFilePath = Path.Combine(this.virtualCachePath, virtualPathFromKey, cachedFileName).Replace(@"\", "/"); bool isUpdated = false; - CachedImage cachedImage = CacheIndexer.GetValue(this.physicalCachedPath); + CachedImage cachedImage = CacheIndexer.GetValue(this.CachedPath); if (cachedImage == null) { @@ -88,7 +91,7 @@ // Check to see if the cached image is set to expire. if (this.IsExpired(cachedImage.CreationTimeUtc)) { - CacheIndexer.Remove(this.physicalCachedPath); + CacheIndexer.Remove(this.CachedPath); isUpdated = true; } } @@ -99,13 +102,13 @@ public override async Task AddImageToCacheAsync(Stream stream) { // ReSharper disable once AssignNullToNotNullAttribute - DirectoryInfo directoryInfo = new DirectoryInfo(Path.GetDirectoryName(this.physicalCachedPath)); + DirectoryInfo directoryInfo = new DirectoryInfo(Path.GetDirectoryName(this.CachedPath)); if (!directoryInfo.Exists) { directoryInfo.Create(); } - using (FileStream fileStream = File.Create(this.physicalCachedPath)) + using (FileStream fileStream = File.Create(this.CachedPath)) { await stream.CopyToAsync(fileStream); } @@ -113,7 +116,7 @@ public override async Task TrimCacheAsync() { - string directory = Path.GetDirectoryName(this.physicalCachedPath); + string directory = Path.GetDirectoryName(this.CachedPath); if (directory != null) { @@ -159,22 +162,7 @@ public override void RewritePath(HttpContext context) { // The cached file is valid so just rewrite the path. - context.RewritePath(this.virtualCachedPath, false); - } - - /// - /// Gets a value indicating whether the given images creation date is out with - /// the prescribed limit. - /// - /// - /// The creation date. - /// - /// - /// The true if the date is out with the limit, otherwise; false. - /// - private bool IsExpired(DateTime creationDate) - { - return creationDate.AddDays(this.MaxAge) < DateTime.UtcNow.AddDays(-this.MaxAge); + context.RewritePath(this.virtualCachedFilePath, false); } } } diff --git a/src/ImageProcessor.Web/Caching/IImageCache.cs b/src/ImageProcessor.Web/Caching/IImageCache.cs index 6153c1f8a..d6404ff51 100644 --- a/src/ImageProcessor.Web/Caching/IImageCache.cs +++ b/src/ImageProcessor.Web/Caching/IImageCache.cs @@ -1,12 +1,18 @@  namespace ImageProcessor.Web.Caching { + using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using System.Web; public interface IImageCache { + /// + /// Gets or sets any additional settings required by the cache. + /// + Dictionary Settings { get; } + string CachedPath { get; } int MaxAge { get; } diff --git a/src/ImageProcessor.Web/Caching/ImageCacheBase.cs b/src/ImageProcessor.Web/Caching/ImageCacheBase.cs index c33a03e60..cfb7ea5ce 100644 --- a/src/ImageProcessor.Web/Caching/ImageCacheBase.cs +++ b/src/ImageProcessor.Web/Caching/ImageCacheBase.cs @@ -1,6 +1,7 @@ namespace ImageProcessor.Web.Caching { using System; + using System.Collections.Generic; using System.Globalization; using System.IO; using System.Reflection; @@ -12,25 +13,25 @@ public abstract class ImageCacheBase : IImageCache { - /// - /// The assembly version. - /// - private static readonly string AssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); - /// /// The request path for the image. /// - private readonly string requestPath; + protected readonly string RequestPath; /// /// The full path for the image. /// - private readonly string fullPath; + protected readonly string FullPath; /// /// The querystring containing processing instructions. /// - private readonly string querystring; + protected readonly string Querystring; + + /// + /// The assembly version. + /// + private static readonly string AssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); /// /// Initializes a new instance of the class. @@ -46,11 +47,16 @@ /// protected ImageCacheBase(string requestPath, string fullPath, string querystring) { - this.requestPath = requestPath; - this.fullPath = fullPath; - this.querystring = querystring; + this.RequestPath = requestPath; + this.FullPath = fullPath; + this.Querystring = querystring; } + /// + /// Gets any additional settings required by the cache. + /// + public Dictionary Settings { get; set; } + public string CachedPath { get; protected set; } public abstract int MaxAge { get; } @@ -61,17 +67,17 @@ public abstract Task TrimCacheAsync(); - public Task CreateCachedFileName() + public virtual Task CreateCachedFileName() { string streamHash = string.Empty; try { - if (new Uri(this.requestPath).IsFile) + if (new Uri(this.RequestPath).IsFile) { // Get the hash for the filestream. That way we can ensure that if the image is // updated but has the same name we will know. - FileInfo imageFileInfo = new FileInfo(this.requestPath); + FileInfo imageFileInfo = new FileInfo(this.RequestPath); if (imageFileInfo.Exists) { // Pull the latest info. @@ -92,34 +98,32 @@ // Use an sha1 hash of the full path including the querystring to create the image name. // That name can also be used as a key for the cached image and we should be able to use // The characters of that hash as sub-folders. - string parsedExtension = ImageHelpers.GetExtension(this.fullPath, this.querystring); - string encryptedName = (streamHash + this.fullPath).ToSHA1Fingerprint(); + string parsedExtension = ImageHelpers.GetExtension(this.FullPath, this.Querystring); + string encryptedName = (streamHash + this.FullPath).ToSHA1Fingerprint(); string cachedFileName = string.Format( "{0}.{1}", encryptedName, !string.IsNullOrWhiteSpace(parsedExtension) ? parsedExtension.Replace(".", string.Empty) : "jpg"); - this.CachedPath = cachedFileName; return Task.FromResult(cachedFileName); } public abstract void RewritePath(HttpContext context); - public virtual void SetHeaders(HttpContext context, string responseType) + /// + /// Gets a value indicating whether the given images creation date is out with + /// the prescribed limit. + /// + /// + /// The creation date. + /// + /// + /// The true if the date is out with the limit, otherwise; false. + /// + protected virtual bool IsExpired(DateTime creationDate) { - HttpResponse response = context.Response; - - response.ContentType = responseType; - - if (response.Headers["Image-Served-By"] == null) - { - response.AddHeader("Image-Served-By", "ImageProcessor.Web/" + AssemblyVersion); - } - - HttpCachePolicy cache = response.Cache; - cache.SetCacheability(HttpCacheability.Public); - cache.VaryByHeaders["Accept-Encoding"] = true; + return creationDate.AddDays(this.MaxAge) < DateTime.UtcNow.AddDays(-this.MaxAge); } } } diff --git a/src/ImageProcessor.sln b/src/ImageProcessor.sln index 8caaeabc7..a5b0609f3 100644 --- a/src/ImageProcessor.sln +++ b/src/ImageProcessor.sln @@ -36,6 +36,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageProcessor.Playground", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageProcessor.Web.PostProcessor", "ImageProcessor.Web.PostProcessor\ImageProcessor.Web.PostProcessor.csproj", "{55D08737-7D7E-4995-8892-BD9F944329E6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageProcessor.Web.AzureBlobCache", "ImageProcessor.Web.AzureBlobCache\ImageProcessor.Web.AzureBlobCache.csproj", "{3C805E4C-D679-43F8-8C43-8909CDB4D4D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution All|Any CPU = All|Any CPU @@ -198,6 +200,21 @@ Global {55D08737-7D7E-4995-8892-BD9F944329E6}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {55D08737-7D7E-4995-8892-BD9F944329E6}.Release|Mixed Platforms.Build.0 = Release|Any CPU {55D08737-7D7E-4995-8892-BD9F944329E6}.Release|x86.ActiveCfg = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.All|Any CPU.ActiveCfg = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.All|Any CPU.Build.0 = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.All|Mixed Platforms.ActiveCfg = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.All|Mixed Platforms.Build.0 = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.All|x86.ActiveCfg = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Release|Any CPU.Build.0 = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3C805E4C-D679-43F8-8C43-8909CDB4D4D7}.Release|x86.ActiveCfg = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE