Versioning Static Assets with UrlRewriteFilter
A few weeks ago, a co-worker sent me interesting email after talking with the Zoompf CEO at JSConf.
One interesting tip mentioned was how we querystring the version on our scripts and css. Apparently this doesn't always cache the way we expected it would (some proxies will never cache an asset if it has a querystring). The recommendation is to rev the filename itself.
This article explains how we implemented a "cache busting" system in our application with Maven and the UrlRewriteFilter. We originally used querystring in our implementation, but switched to filenames after reading Souders' recommendation. That part was figured out by my esteemed colleague Noah Paci.
Our Requirements
- Make the URL include a version number for each static asset URL (JS, CSS and SWF) that serves to expire a client's cache of the asset.
- Insert the version number into the application so the version number can be included in the URL.
- Use a random version number when in development mode (based on running without a packaged war) so that developers will not need to clear their browser cache when making changes to static resources. The random version number should match the production version number formats which is currently: x.y-SNAPSHOT-revisionNumber
- When running in production, the version number/cachebust is computed once (when a Filter is initialized). In development, a new cachebust is computed on each request.
In our app, we're using Maven, Spring and JSP, but the latter two don't really matter for the purposes of this discussion.
Implementation Steps
1. First we added the buildnumber-maven-plugin to our project's pom.xml so the build number is calculated from SVN.
<plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>buildnumber-maven-plugin</artifactId> <version>1.0-beta-4</version> <executions> <execution> <phase>validate</phase> <goals> <goal>create</goal> </goals> </execution> </executions> <configuration> <doCheck>false</doCheck> <doUpdate>false</doUpdate> <providerImplementations> <svn>javasvn</svn> </providerImplementations> </configuration> </plugin>
2. Next we used the maven-war-plugin to add these values to our WAR's MANIFEST.MF file.
<plugin> <artifactId>maven-war-plugin</artifactId> <version>2.0.2</version> <configuration> <archive> <manifest> <addDefaultImplementationEntries>true</addDefaultImplementationEntries> </manifest> <manifestEntries> <Implementation-Version>${project.version}</Implementation-Version> <Implementation-Build>${buildNumber}</Implementation-Build> <Implementation-Timestamp>${timestamp}</Implementation-Timestamp> </manifestEntries> </archive> </configuration> </plugin>
3. Then we configured a Filter to read the values from this file on startup. If this file doesn't exist, a default version number of "1.0-SNAPSHOT-{random}" is used. Otherwise, the version is calculated as ${project.version}-${buildNumber}.
private String buildNumber = null; ... @Override public void initFilterBean() throws ServletException { try { InputStream is = servletContext.getResourceAsStream("/META-INF/MANIFEST.MF"); if (is == null) { log.warn("META-INF/MANIFEST.MF not found."); } else { Manifest mf = new Manifest(); mf.read(is); Attributes atts = mf.getMainAttributes(); buildNumber = atts.getValue("Implementation-Version") + "-" + atts.getValue("Implementation-Build"); log.info("Application version set to: " + buildNumber); } } catch (IOException e) { log.error("I/O Exception reading manifest: " + e.getMessage()); } } ... // If there was a build number defined in the war, then use it for // the cache buster. Otherwise, assume we are in development mode // and use a random cache buster so developers don't have to clear // their browswer cache. requestVars.put("cachebust", buildNumber != null ? buildNumber : "1.0-SNAPSHOT-" + new Random().nextInt(100000));
4. We then used the "cachebust" variable and appended it to static asset URLs as indicated below.
<c:set var="version" scope="request" value="${requestScope.requestConfig.cachebust}"/> <c:set var="base" scope="request" value="${pageContext.request.contextPath}"/> <link rel="stylesheet" type="text/css" href="${base}/v/${version}/assets/css/style.css" media="all"/> <script type="text/javascript" src="${base}/v/${version}/compressed/jq.js"></script>
The injection of /v/[CACHEBUSTINGSTRING]/(assets|compressed) eventually has to map back to the actual asset (that does not include the two first elements of the URI). The application must remove these two elements to map back to the actual asset. To do this, we use the UrlRewriteFilter. The UrlRewriteFilter is used (instead of Apache's mod_rewrite) so when developers run locally (using mvn jetty:run) they don't have to configure Apache.
5. In our application, "/compressed/" is mapped to wro4j's WroFilter. In order to get UrlRewriteFilter and WroFilter to work with this setup, the WroFilter has to accept FORWARD and REQUEST dispatchers.
<filter-mapping> <filter-name>rewriteFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter-mapping> <filter-name>WebResourceOptimizer</filter-name> <url-pattern>/compressed/*</url-pattern> <dispatcher>FORWARD</dispatcher> <dispatcher>REQUEST</dispatcher> </filter-mapping>
Once this was configured, we added the following rules to our urlrewrite.xml to allow rewriting of any assets or compressed resource request back to its "correct" URL.
<rule match-type="regex"> <from>^/v/[0-9A-Za-z_.\-]+/assets/(.*)$</from> <to>/assets/$1</to> </rule> <rule match-type="regex"> <from>^/v/[0-9A-Za-z_.\-]+/compressed/(.*)$</from> <to>/compressed/$1</to> </rule> <rule> <from>/compressed/**</from> <to>/compressed/$1</to> </rule>
Of course, you can also do this in Apache. This is what it might look like in your vhost.d file:
RewriteEngine on RewriteLogLevel 0! RewriteLog /srv/log/apache22/app_rewrite_log RewriteRule ^/v/[.A-Za-z0-9_-]+/assets/(.*) /assets/$1 [PT] RewriteRule ^/v/[.A-Za-z0-9_-]+/compressed/(.*) /compressed/$1 [PT]
Whether it's a good idea to implement this in Apache or using the UrlRewriteFilter is up for debate. If we're able to do this with the UrlRewriteFilter, the benefit of doing this at all in Apache is questionable, especially since it creates a duplicate of code.