Tuesday, 26 March 2013

Tuning Grails resources plugin to support multiple modules for same file

A long time since my previous blog post. Deepest apologizes, I have had so little time for my "hobbies"...

But now! I faced so frustrating issue in my work so that I must forward it to you. We have been developing our system quite a time already. Real problems started when it was time for the first test deployment to the actual server. We are using Grails as our controller layer which includes a wonderful resource plugin that enables us to use newest techiques in our assets easily (such as Coffeescript or LESS). The production mode will be caching, zipping and minimizing all resource modules in order to reduce clients' overhead. These are not used in development mode. So, during the deployment I discovered that the resource plugin has a "bug" which prevents the definition of same file to multiple modules.

After a Google moment, I found that the plugin's author wasn't planning to support such a feature (GPRESOURCES-166). We had two options: either divide our code into multiple mini-mini modules (which reduces the usefulness of modules a lot) OR try to go around the bug/feature.

So, after a long moment of discovering plugin's source codes, I managed to find a detour. It is not an elegant way but working well enough. The resouces plugin uses a chain of resource mappers to transform original files into processed files. The bundling and bundle renamining (for cache prevention) are dealt by using file paths as identifiers for files. So so... The best bet is to rename the file with a unique prefix before the resource is "registered" into module. When registering is done, the resources plugin tries to fetch the resource file based on its defined filename. Thus, we need to translate the modified filename back to the original one so that resources plugin can find the actual file. When the file has been fetched, resources plugin starts to process the file with its processing chain. We must add our own custom resource mapper to the beginning of that chain to translate processed resource's URL back to the original so that later processors can deal with right files.

The solution is to override two resource plugin's methods by using meta-programming. First we modify the filename during the resource definition. Then we rename it back to the original for file handle fetching. After that, we use our own resource mapper to change resource's URLs back to the normal at the beginning of the processing chain. And hóle! Now the plugin supports multiple module definitions for same file also with cached-resources plugin.

Here is the actual code:
package com.tunkkaus
import org.grails.plugin.resource.ResourceMeta
import org.grails.plugin.resource.ResourceProcessor
import org.grails.plugin.resource.mapper.MapperPhase;
import org.grails.plugin.resource.module.ModuleBuilder
import org.springframework.util.AntPathMatcher
/**
* We are using prefix '_' because we want that this resource mapper
* is FIRST mapper of the processing chain. Sorting is done by name
* so '_' prefix gives us advantage compared to other mappers.
*
*/
class _MultiModuleResourceMapper {
private static final String MULTIPLE_RESOURCES_PREFIX = "/__multiModule__"
private static long declaredResourcesNum = 0
static defaultIncludes = [ '**/*.coffee', '**/*.js', '**/*.handlebars' ]
static String getResourcePrefix() { "${MULTIPLE_RESOURCES_PREFIX}.${declaredResourcesNum++}/" }
static boolean isMultipleResourceFile(String uri) { uri?.startsWith(MULTIPLE_RESOURCES_PREFIX) }
static String getOriginalURI(String uri) { uri?.replaceFirst(MULTIPLE_RESOURCES_PREFIX + '\\.\\d+/', '/') }
private static boolean canBeRenamed(String filepath) {
if(filepath.startsWith(MULTIPLE_RESOURCES_PREFIX)) return false
// behaviour can be modified by chancing defaultIncludes
def matcher = new AntPathMatcher()
return defaultIncludes.find { matcher.match(it, filepath) } != null
}
/**
* Call this from resources.groovy! This modifies resources plugin methods
* so that they support multiple resource definitions inside multiple modules.
*/
static void enable() {
def origResource = ModuleBuilder.metaClass.getMetaMethod("resource", [Object] as Object[])
def origGetURL = ResourceProcessor.metaClass.getMetaMethod("getOriginalResourceURLForURI", [Object] as Object[])
assert origResource != null && origGetURL != null
// override modulebuilder's resource function to apply a unique prefix
// because file will have an unique prefix, then it is interpreted as
// "new" file for resources plugin
ModuleBuilder.metaClass.resource = { attrs ->
if (attrs instanceof String) {
if (canBeRenamed(attrs)) {
attrs = resourcePrefix + attrs
}
} else if (attrs instanceof Map && attrs.url instanceof String) {
if (canBeRenamed(attrs.url)) {
attrs.url = resourcePrefix + attrs.url
}
}
origResource.invoke(delegate, attrs)
}
// this method is called before resource processing chain begins
// this gets the file handle to the original resource. Since we have
// added a prefix, the actual file is never found unless we tweak this
// method a little bit to transform the modified filename back to the
// original one (just for this method)
ResourceProcessor.metaClass.getOriginalResourceURLForURI { uri ->
if (isMultipleResourceFile(uri)) {
uri = getOriginalURI(uri)
}
return origGetURL.invoke(delegate, uri)
}
}
// using first possible mapper phase
def phase = MapperPhase.GENERATION
def map(resource, config) {
def url = resource?.actualUrl
// now we can process the resource whatever we want. Let's just change
// resource's URLs back to the original file's, so that later processors
// can deal with right file.
if (isMultipleResourceFile(url)) {
url = getOriginalURI(url)
resource.actualUrl = resource.originalUrl = resource.sourceUrl = url
}
}
}
You must place that into your grails-app/resourceMappers folder. After that you must enable support by calling MultipleStaticResources.enable() from your resources.groovy bean definition closure.
import com.tunkkaus._MultiModuleResourceMapper
beans = {
_MultiModuleResourceMapper.enable()
}
Of course this solution may conflict with our own resource mappers, but for me it works well enough: resource mappers for CoffeeScript, Handlebars, LESS, zipped and bundled/cached resoures (and also few own) are working fine with this hack.

Happy hacking!

No comments:

Post a Comment