Mittwoch, 10. August 2011

Creating a Custom Wicket Tag Resolver

When building pages with Wicket the wicket:message tag is very handy for adding localized text to said page. What I don't like about it is the fact that you are limited to
using properties. The problem about that is when you need to use large portion of text a property file gets very bloated, very ugly to read and incredibly ugly when trying to add formatting. From a maintenance point of view resoruce should be
structured very clearly and especially when using xml properties an auto formatter is very handy, which can also cause ill formatted results.

On workaround is to split your text in several properties and then just add them one by one to your markup/components but this is very cumbersome. So I figured it would be cool if you were able to use
includes in the same way as properties with the message tag where you could externalize a layouted portion of text with format information. But to my knowledge no such tag exists, so I took this as an opportunity
to find out how to build my very own tag resolver :-)

Unfortunately I did not find a lot of information about this topic which meant I had find out the hard way, by looking at existing Wicket classes and the obvious choice was WicketMessageResolver, so what
you will see next is heavily inspired by this class even though I did change quite a few portions.

These are the basics that need to be done:

  • your class has to implement IComponentResolver
  • in a static initializer block make your tag well known by calling WicketTagIdentifier.registerWellKnownTagName(tagname)
  • in your application class register your resolver using getPageSettings().addComponentResolver(new YourResolver())
  • in the resolve method instantiate a subclass of MarkupContainer and add it to the resolvers container
  • make sure the subclass' isTransparentResolver() method always returns true, thus he can access it's parent's children as if they were it's own
  • and as a last you override the markup container's onComponentTag() method where you can put all the logic you need to handle your tag, create the rendered result and put it into the response object

And here my complete IncludeResolver, I hope this will be useful to someone someday. And as usual I would be greatful for comments, additions and corrections on this :-)

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.commons.lang.StringUtils;
import org.apache.wicket.Application;
import org.apache.wicket.Component;
import org.apache.wicket.MarkupContainer;
import org.apache.wicket.Response;
import org.apache.wicket.WicketRuntimeException;
import org.apache.wicket.markup.ComponentTag;
import org.apache.wicket.markup.MarkupElement;
import org.apache.wicket.markup.MarkupException;
import org.apache.wicket.markup.MarkupStream;
import org.apache.wicket.markup.WicketTag;
import org.apache.wicket.markup.html.include.Include;
import org.apache.wicket.markup.parser.filter.WicketTagIdentifier;
import org.apache.wicket.markup.resolver.IComponentResolver;
import org.apache.wicket.model.Model;
import org.apache.wicket.response.StringResponse;
import org.apache.wicket.util.lang.PropertyResolver;
import org.apache.wicket.util.string.Strings;
import org.apache.wicket.util.string.interpolator.MapVariableInterpolator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class is a tag resolver to handle tags of the following format
 * <wicket:include [key="xxx"|filename="xxx"]/>.
 * 

* This class tries to provide the same functionality for included html files as * WicketMessageResolver does for property file entries but also * tries to avoid to fail early. *

* You have to specify either a key or a filename. A key will be looked up via * the resource mechanism and the result will be used as filename. *

* In addition you can nest tags within this tag. Those child tags will then be * used to generate the replacements for variables defined in the file content. * If this fails for a variable the parent's model object will queried for a * property with the variable's value as name. Should this also fail the last * fall back is the same query on the parent object. *

* If the resource settings are set to throw exceptions on missing properties * this class will raise an exception if either a variable cannot be looked up * or a child component is not replaced. *

* This tag allows you to easily include files in your pages which can also * depend on your localized properties. *

* Usage: *

* *

*     <wicket:include key="myFile">
 *        This text will be replaced with text from the specified file.
 *        <span wicket:id="replaceme">[to be replaced]</span>.
 *     </wicket:include>
 * 
* * Then your property file needs an entry like this: * *
* myFile = path / to / file
 * 
* * This file contains html fragments with variables: * *
* This is a file with ${replaceme}.
 * 
* * And your java component add: * *
* add(new Label("replaceme", new Model<String>("some replaced text")));
 * 
* * The output will be: * *
* This is a file with some replaced text.
 * 
* * @author Chris * */ public class IncludeResolver implements IComponentResolver { /** * */ private static final long serialVersionUID = -9164415653709941809L; private static final String tagname = "include"; private static final Logger log = LoggerFactory.getLogger(IncludeResolver.class); // register the tagname static { WicketTagIdentifier.registerWellKnownTagName(tagname); } /** * Handles resolving the wicket:include tag. If a filename is specified this * file will be looked up. If a key is specified it's value is looked up * using the resource mechanism. The looked up value will then be used as * filename. * */ public boolean resolve(MarkupContainer container, MarkupStream markupStream, ComponentTag tag) { if ((tag instanceof WicketTag) && tagname.equalsIgnoreCase(tag.getName())) { WicketTag wtag = (WicketTag) tag; String fileName = StringUtils.trimToNull(wtag.getAttribute("file")); String fileKey = StringUtils.trimToNull(wtag.getAttribute("key")); if (null != fileKey) { if (null != fileName) { throw new MarkupException( "Wrong format of : you must not use file and key attribtue at once"); } fileName = StringUtils.trimToNull(container.getString(fileKey)); if (null == fileName) { throw new MarkupException("The key inside could not be resolved"); } } else if (null == fileName) { throw new MarkupException( "Wrong format of : specify the file or key attribute"); } final String id = "_" + tagname + "_" + container.getPage().getAutoIndex(); IncludeContainer ic = new IncludeContainer(id, fileName, fileKey, markupStream); ic.setRenderBodyOnly(container.getApplication().getMarkupSettings().getStripWicketTags()); container.autoAdd(ic, markupStream); return true; } return false; } /** * Helper class to break open the original Include class by using * inheritance. * * @author Chris * */ private static final class ExposingInclude extends Include { /** * */ private static final long serialVersionUID = -212861726226482365L; public ExposingInclude(String id, String filename) { super(id, filename); } public final String expose() { return this.importAsString(); } } /** * Container class to render the included file. * * @author Chris * */ private static final class IncludeContainer extends MarkupContainer { /** * */ private static final long serialVersionUID = 3991477945186422264L; private final String key; private final String fileName; public IncludeContainer(String id, String fileName, String key, MarkupStream markupStream) { super(id, new Model(fileName)); this.key = key; this.fileName = fileName; } /** * Wrapper to log this instance's settings. * * @return String containing the instance's settings. */ private final String logParams() { return " tag with " + (null != this.key ? "key = '" + this.key + "' and resulting " : "") + "fileName = '" + this.fileName + "'"; } /** * Renders the component tag. The base content is read from the file * defined by the filename. Afterwards all containing variables are * replaced by the following methods in this order: *
    *
  • trying to get the value from a child component that has the
    * variable's value as id
  • *
  • trying to get the value as property from the parent's model
    * object
  • *
  • trying to get the value as property from the parent object
  • *
* * If all attempts to lookup up variable fail or not all child tags are * replaced either an exception is thrown or a warning is logged * depending on the resource settings. * */ @Override protected void onComponentTagBody(final MarkupStream markupStream, final ComponentTag openTag) { // get all child tags' rendered contents Map children = this.getRenderedChildren(markupStream, openTag); // read the included file content String fileContent = new ExposingInclude(this.getId() + "_internalInclude", this.getDefaultModelObjectAsString()).expose(); // remember all replaced child tags final Set replacedChildren = new HashSet(); // substitute all variables and set the result as response this.getResponse().write(new MapVariableInterpolator(fileContent, children) { @Override protected String getValue(String variableName) { // try to get value from rendered child tags String value = super.getValue(variableName); if (null != value) { // if we used a child tag create a marker replacedChildren.add(variableName); } else { if (log.isDebugEnabled()) { log.debug(IncludeContainer.this.logParams() + " - Could not find replacement value for variable = '" + variableName + "' in child tags"); } // see if the variable can be replaced using the // parent's model object - use try/catch to avoid fail // early try { value = Strings.toString(PropertyResolver.getValue(variableName, IncludeContainer.this.getParent().getDefaultModelObject())); } catch (WicketRuntimeException e) { if (log.isDebugEnabled()) { log.debug(IncludeContainer.this.logParams() + " - Could not find replacement value for variable = '" + variableName + "' in parent model"); } } } if (null == value) { // see if the variable can be replaced using the parent // object - use try/catch to avoid fail early try { value = Strings.toString(PropertyResolver.getValue(variableName, IncludeContainer.this.getParent())); } catch (WicketRuntimeException e) { if (log.isDebugEnabled()) { log.debug(IncludeContainer.this.logParams() + " - Could not find replacement value for variable = '" + variableName + "' in parent object"); } } } if (null == value) { // Handle failed lookup String logMsg = IncludeContainer.this.logParams() + " - Bailing with exception since variable = '" + variableName + "' could not be replaced"; if (IncludeContainer.this.isThrowingExceptions()) { if (log.isDebugEnabled()) { log.debug(logMsg); } markupStream.throwMarkupException(IncludeContainer.this.logParams() + " - Could not replace variable = '" + variableName + "'"); } else { log.warn(logMsg); } } return value; } }.toString()); // Make sure all of the children were rendered Iterator iter = children.keySet().iterator(); while (iter.hasNext()) { String id = iter.next(); if (replacedChildren.contains(id) == false) { String msg = "The for file " + this.fileName + "has a child element with wicket:id=\"" + id + "\". You must add the variable ${" + id + "} to the file content for the wicket:include."; if (this.isThrowingExceptions() == true) { markupStream.throwMarkupException(msg); } else { log.warn(msg); } } } } /** * Wraps call to resource settings to determine if a exception should be * thrown in case not all variables are resolved or not all children are * rendered. * * @return * Application.get().getResourceSettings().getThrowExceptionOnMissingResource() */ private final boolean isThrowingExceptions() { return Application.get().getResourceSettings().getThrowExceptionOnMissingResource(); } /** * Walks through all children of the current tag and stores their * rendering results in a map. * * @param markupStream * The markupStream associated with the tag. * @param openTag * The current include tag to be rendered. * @return Map containing all children's rendered responses associated * with each child's id. */ private Map getRenderedChildren(final MarkupStream markupStream, final ComponentTag openTag) { Map children = new HashMap(); if (!openTag.isOpenClose()) { while (markupStream.hasMore() && !markupStream.get().closes(openTag)) { MarkupElement element = markupStream.get(); // If it a tag like or if ((element instanceof ComponentTag) && !markupStream.atCloseTag()) { String id = ((ComponentTag) element).getId(); Component comp = this.getParent().get(id); if (comp != null) { children.put(id, this.getRenderedResponseString(markupStream, comp)); } else { markupStream.next(); } } else { markupStream.next(); } } } return children; } /** * Obtains the rendered response of the given component rendered * according to the markupStream. Rendering uses a temporary response * and the original response is restored before returning the result. * * @param markupStream * The markupStream used to render the component. * @param comp * The component to be rendered. * @return The rendered result. */ private String getRenderedResponseString(final MarkupStream markupStream, Component comp) { Response webResponse = comp.getResponse(); StringResponse response = new StringResponse(); try { this.getRequestCycle().setResponse(response); comp.render(markupStream); } finally { this.getRequestCycle().setResponse(webResponse); } return response.getBuffer().toString(); } /** * * @see org.apache.wicket.MarkupContainer#isTransparentResolver() */ @Override public boolean isTransparentResolver() { return true; } } }

3 Kommentare:

  1. Nice post, thanks.

    IMHO it would be easier just to implement a custom IStringResourceLoader though.

    AntwortenLöschen
  2. Thanks for the info, that under getPageSettings() you can add ComponentResolvers.

    But for your problem:
    Instead of writing an own ComponentResolver you can just write an own IStringResourceLoader, and install it with

    getResourceSettings().getStringResourceLoaders().add(0, new CustomStringLoader());

    It will be called by the (default) ComponentResolver.

    AntwortenLöschen
  3. Ups - I should have read the other comment beforehand.

    AntwortenLöschen