Tired of Firebug telling you to add expire headers to your image files? Well, here’s a quick tutorial that’ll shut the ol’ Firebug up. It’ll also help to speed your fancy lady-site up, which will surely put a smile on your mouth-adorned users’ faces.
Let’s start by creating an interface, IFileBase:
using System;
namespace Fat.Mvc.Helpers.IO
{
public interface IFileBase
{
/// <summary>
/// Opens file, reads all of the bytes from the file into an array, closes file.
/// </summary>
/// <param name="path">The path of a file.</param>
byte[] ReadAllBytes(string path);
}
}
Okay. Easy enough. Now, let’s create a class that implements this interface. This class will serve as a simple wrapper class for accessing the ReadAllBytes() method in the static class, System.IO.File:
using System.IO;
namespace Fat.Mvc.Helpers.IO
{
public class FileBase : IFileBase
{
public byte[] ReadAllBytes(string path)
{
return File.ReadAllBytes(path);
}
}
}
Now, for the undeniably fun part. Add a Controller to your project. Call it “ZipController.” (Sidenote: in a later tutorial, I’ll show you a bagful of other methods that you might like to place in this ZipController class, including ActionResult methods for aggregating, minifying, gzipping, and caching javascript and css files.) ZipController will be responsible for caching our images on both the server and the client side. So here’s our ZipController, beautiful, timid, childlike, partially nude:
using System;
using System.Web;
using System.Web.Mvc;
using System.Web.Caching;
using System.IO;
using System.IO.Compression;
using System.Text;
using Fat.Mvc.Helpers.IO;
namespace Fat.Mvc.Web.Controllers
{
public class ZipController : Controller
{
IFileBase file;
const int cacheForMins = 60;
public ZipController() : this(null) { }
public ZipController(IFileBase fileBase)
{
this.file = fileBase ?? new FileBase();
}
byte[] GetBytesFromCache(string path)
{
object fromCache = HttpRuntime.Cache.Get(path);
if (fromCache == null) return null;
try
{
return fromCache as byte[];
}
catch
{
//the object in the cache wasn't a byte array
//that's sad and perplexing...
return null;
}
}
public virtual void Image(string path)
{
//verify that this path is valid...
//and leads to a public image file,
//not to a private file, or a strip club
var server = HttpContext.Server;
string decodedPath = server.UrlDecode(path);
string mappedPath = server.MapPath(decodedPath);
byte[] imageBytes = GetBytesFromCache(mappedPath);
if (imageBytes == null)
{
imageBytes = file.ReadAllBytes(mappedPath);
//cache image src
HttpRuntime.Cache.Insert(
path,
imageBytes,
null, //or: new CacheDependency(mappedPath),
Cache.NoAbsoluteExpiration,
TimeSpan.FromMinutes(cacheForMins));
}
Response.Cache.SetCacheability(HttpCacheability.Public);
Response.Cache.SetExpires(Cache.NoAbsoluteExpiration);
Response.AddFileDependency(mappedPath);
Response.Cache.SetLastModifiedFromFileDependencies();
Response.AppendHeader("Content-Length", imageBytes.Length.ToString());
Response.ContentType = "image/jpeg";
Response.OutputStream.Write(imageBytes, 0, imageBytes.Length);
Response.Flush();
}
}
}
Now that we have our ZipController all finished up, let’s test it out. To do so, I added an image to my project just like a normal non-ZipController person would:
<img src="../../Public/Images/bird.png" />
And then, beneath that image tag, I created another image tag with a source url that fires off the Image method in our fancy new ZipController:
<!-- Here's the normal image tag -->
<img src="../../Public/Images/bird.png" alt="bird" />
<!-- Here's the Zippy image tag -->
<img src="/Zip/Image?Path=/Public/Images/bird.png" alt="bird" />
Now, let’s test our speedy image rendering skills. Run YSlow in Firebug and see just how fast our cached image renders. Here are my results, which are listed under the components tab:
If anybody enjoys a little half-assed TDD with their speedy images, below are some unit tests to validate your friendly neighborhood ZipController. If you want to run these tests, you’ll have to add a reference to Moq in your test project:
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.IO;
using System.Web;
using System.Web.Mvc;
using System.Web.Caching;
using Moq;
using Fat.Mvc.Helpers.IO;
using Fat.Mvc.Web.Controllers;
namespace fat.mvc.tests
{
[TestClass]
public class ZipControllerTest
{
Mock controllerContext;
Mock httpContext;
Mock response;
Mock responseCache;
Mock httpServer;
Mock file;
ZipController zip;
string path;
[TestInitialize]
public void Init()
{
//IO/File
path = Guid.NewGuid().ToString() + ".png";
file = new Mock();
//HttpContext.Response
responseCache = new Mock();
response = new Mock();
response.Setup(m => m.Cache).Returns(responseCache.Object);
response.Setup(m => m.OutputStream).Returns(new MemoryStream());
//HttpContext
httpServer = new Mock();
httpServer.Setup(m => m.UrlDecode(path)).Returns(path);
httpServer.Setup(m => m.MapPath(path)).Returns(path);
httpContext = new Mock();
httpContext.Setup(m => m.Response).Returns(response.Object);
httpContext.Setup(m => m.Server).Returns(httpServer.Object);
httpContext.Setup(m => m.Cache).Returns(HttpRuntime.Cache);
//ControllerContext
controllerContext = new Mock();
controllerContext.Setup(m => m.HttpContext).Returns(httpContext.Object);
//Zip Controller
zip = new ZipController(file.Object);
zip.ControllerContext = controllerContext.Object;
}
[TestMethod]
public void Controllers_Zip_Image_Path_UrlDecoded()
{
zip.Image(path);
httpServer.Verify(m => m.UrlDecode(path), Times.Once());
}
[TestMethod]
public void Controllers_Zip_Image_Path_Virtual_Mapped()
{
zip.Image(path);
httpServer.Verify(m => m.MapPath(path), Times.Once());
}
[TestMethod]
public void Controllers_Zip_Image_DecodedPath_ReadAllBytes()
{
zip.Image(path);
file.Verify(m => m.ReadAllBytes(path), Times.Once());
}
[TestMethod]
public void Controllers_Zip_Image_Cached()
{
var cachedByteArray = new byte[10];
file.Setup(m => m.ReadAllBytes(path)).Returns(cachedByteArray);
zip.Image(path);
var retrievedByteArray = HttpRuntime.Cache.Get(path);
Assert.IsNotNull(retrievedByteArray);
Assert.AreEqual(cachedByteArray, retrievedByteArray);
}
[TestMethod]
public void Controllers_Zip_Image_Cached_Retrieved()
{
var cachedByteArray = new byte[100];
HttpRuntime.Cache.Insert(path, cachedByteArray);
httpServer.Setup(m => m.UrlDecode(path)).Returns(path);
httpServer.Setup(m => m.MapPath(path)).Returns(path);
zip.Image(path);
Assert.AreEqual(100, response.Object.OutputStream.Length);
}
[TestMethod]
public void Controllers_Zip_Image_Written_To_Response()
{
var cachedByteArray = new byte[100];
HttpRuntime.Cache.Insert(path, cachedByteArray);
zip.Image(path);
response.Verify(m => m.AppendHeader("Content-Length", "100"));
response.Verify(m => m.Flush(), Times.Once());
responseCache.Verify(m => m.SetCacheability(HttpCacheability.Public));
responseCache.Verify(m => m.SetExpires(Cache.NoAbsoluteExpiration));
}
}
}
Extra Credit
This is completely unnecessary, and possibly even counterproductive to the propagation of human life, but I thought it’d be somewhat interesting to reveal some other, more trivial image-altering methods that you might be interested in adding to your ZipController class. Here’s one, for starters. Add the following method in your ZipController if you(‘re stupid enough to) dare:
public virtual void CropImage(string path, int? x, int? y, int? width, int? height)
{
var server = HttpContext.Server;
string decodedPath = server.UrlDecode(path);
string mappedPath = server.MapPath(path);
//initial image
Bitmap src = System.Drawing.Image.FromFile(mappedPath) as Bitmap;
//get dimensions
int posX = x ?? 0;
int posY = y ?? 0;
int newWidth = width ?? src.Width - posX;
int newHeight = height ?? src.Height - posY;
Rectangle rect = new Rectangle(posX, posY, newWidth, newHeight);
Bitmap cropped = src.Clone(rect, src.PixelFormat);
//convert image to byte array
MemoryStream ms = new MemoryStream();
cropped.Save(ms, System.Drawing.Imaging.ImageFormat.Bmp);
byte[] imageBytes = ms.ToArray();
//write image to response
Response.AddFileDependency(mappedPath);
Response.Cache.SetLastModifiedFromFileDependencies();
Response.AppendHeader("Content-Length", imageBytes.Length.ToString());
Response.ContentType = "image/jpeg";
Response.OutputStream.Write(imageBytes, 0, imageBytes.Length);
Response.Flush();
}
Now, you can call this method much like you call the Image method, and it’ll crop the image to the size you specify in the url:
<!-- Here's a normal image-->
<img src="/Public/Images/bird.png" alt="bird" />
<!-- And here's an image that will be cropped to 20px by 20px -->
<img src="/Zip/CropImage?Path=/Public/Images/bird.png&x=5&y=5&width=20&height=20" alt="bird">
Need I say more? Or might I get shot?
Read More
You can leave a response, or trackback from your own site.
11 Responses to “Caching Images in Asp.Net MVC”
Leave a Reply



[...] post builds on my previous post, Caching Images in Asp.Net MVC. We’ll be using/modifying the ZipController class, FileBase Class, and IFileBase interface, [...]
[...] to VoteCaching Your Images in Asp.Net MVC (5/6/2010)Thursday, May 06, 2010 from EvanTired of Firebug telling you to add expire headers to your image [...]
Nice post. I might suggest that you look at the image file extension and map that to an appropriate Content-Type instead of hard coding to “image/jpeg”. In the case of your sample code there is an actual “image/png” Content-Type that might be seen as more correct … or whatever
Eric! Yes. You are absolutely correct. Though I’m too stupid to know if it really matters, as I’ve seen some super-super-lazy developers set the content type to “image/xyz”.
Check this out, and I’d love to hear your thoughts: http://www.w3.org/Protocols/rfc1341/4_Content-Type.html
p.s. Really enjoy your blog. I’ll be reading regularly, and possibly linking to some of your content, if you don’t mind.
[...] is a little trickier. You can try something like this. Ultimately, instead of pointing directly to the image in your url call, you point instead to a [...]
Might want to add some checking in which files you allow being served.
Try:
/Zip/Image?Path=/Web.config
And it’ll return files your server normally wouldn’t allow.
Hey David,
Thanks!
Verifying that the path leads to an image (wherever that may be) should do the trick.
Or, of course, there’s always FileContentResult…
C,e
Thanks, your blog post indirectly helped me with test setup.
I’ve done as explained here, but when using the path /Zip/Image?path=
I get a 404 error stating cannot find resource /Zip/Image. How do I map this?
Thanks.
Why was my comment not added?
I simply would like to ask how to set this up. Is there a route I need to state?
Fred,
This fella is from way back when. I imagine it’s probably how you have routing setup in your project? Maybe you can throw your question onto stackoverflow? Might be easier for me to help you there.
Cheers,
Evan