ZK Hash Router With Spring DI

| October 27, 2014 | Reply

2014-12-09 Update: The router has been updated. This post has been edited to reflect the changes. As a result, the security part can be found here

The ZK Framework offers great support for SPAs (Single Page Applications), however it lacks one key feature – a routing solution. Standard javascript routers usually inspect the value of the hash (window.location.hash), listen for changes, and dispatch views accordingly. Examples of such routers are Backbone.Router (from Backbone.js), crossroads.js, etc. This is what has to be replicated in Java if you want to have working BACK, FORWARD and RELOAD browser buttons.

Starting a fresh new project using ZK and Spring, I thought it was a great time to make improvements over previous work. And thus, the ZK Router was born. It has the following key features:

  • ability to map routes to views
  • support for parameterized routes
  • plugins

Let’s start by defining the different parts that make the routing system work:

  • ZkRouterFactory – registration of route mappings and security constraints. Creates ZkRouters.
  • ZkRouter – one instance per ZK Desktop. Created by a ZkRouterFactory, can dispatch requests and change the current route.
  • ZkRoute – describes a mapped route, provides methods for testing against a URL and resolving path variables.
  • ZkRouterPlugin – an interface which provides hooks into the routing mechanism.
  • ZkRoutingErrorHandler – an interface that provides hooks when an error ocurs.
  • PathVariableType – an interface used for defining different path variable types (e.g. numeric path variables).

Examples of route paths are:

  • /
  • /foo
  • /foo/bar
  • /foo/{bar}
  • /foo/{bar:int}/baz

Segments surrounded by curly braces define path variables. A path variable contains a name and optionally a type.

Now, let’s review the router implementation by explaining each component using a bottom-up approach.

ZkRoute

A ZkRoute represents the mapping of a route to a view (e.g. a .zul). On creation, a ZkRoute parses the given route URL and coverts it to a regular expression which then gets compiled into a Pattern. This is later used for testing whether a user-navigated route matches this one. Path variable names are also extracted into a Map. This Map is referenced when resolving the path variable values from a user-navigated route.

public class ZkRoute {

	private String url;
	private String view;
	private Pattern urlPattern;
	private Map<Integer, VariableAndType> pathVariables = new HashMap<>();
	private List<PathVariableType<?>> pathVariableTypes = new ArrayList<>();

	public ZkRoute(String url, String view) {
		this.url = url;
		this.view = view;
		initPathvariableTypes();
		resolveUrlPattern(url);
	}

	private void initPathvariableTypes() {
		pathVariableTypes.add(new BytePathVariableType());
		pathVariableTypes.add(new IntegerPathVariableType());
		pathVariableTypes.add(new LongPathVariableType());
		pathVariableTypes.add(new ShortPathVariableType());
		pathVariableTypes.add(new StringPathVariableType());
	}

	private void resolveUrlPattern(String url) {
		String urlRegexp = url.replaceAll("\\{[a-zA-Z0-9:]+?\\}", "([^/\\]+)");
		urlPattern = Pattern.compile(urlRegexp);

		String varPatternRegexp = url.replaceAll("\\{[a-zA-Z0-9:]+?\\}", "\\\\{([a-zA-Z0-9:]+)\\\\}");
		Pattern varPattern = Pattern.compile(varPatternRegexp);
		Matcher varMatcher = varPattern.matcher(url);
		if (varMatcher.matches() && varMatcher.groupCount() >= 1) {
			for (int i = 1; i <= varMatcher.groupCount(); i++) {
				String varGroup = varMatcher.group(i);
				registerVariable(varGroup, i);
			}
		}
	}

	private void registerVariable(String varGroup, int groupNumber) {
		String[] parts = varGroup.split(":");
		if(parts.length > 2) {
			throw new IllegalArgumentException("Cannot parse variable " + varGroup + ". Expected input is 'varName[:varType]'!");
		}
		PathVariableType<?> resolvedType = new StringPathVariableType();
		if(parts.length == 2) {
			String type = parts[1].trim().toLowerCase();
			resolvedType = resolvePathVariableType(type);
		}
		String varName = parts[0].trim();
		pathVariables.put(groupNumber, new VariableAndType(varName, resolvedType));
	}

	private PathVariableType<?> resolvePathVariableType(String type) {
		for(PathVariableType<?> pathVariableType : pathVariableTypes) {
			if(pathVariableType.getName().equals(type)) {
				return pathVariableType;
			}
		}
		return new StringPathVariableType();
	}

	public String getUrl() {
		return url;
	}

	public String getView() {
		return view;
	}

	public boolean matches(String url) {
		if (urlPattern.matcher(url).matches()) {
			return true;
		}
		return false;
	}

	public Map<String, Object> resolvePathVariables(String url) {
		Map<String, Object> pathVariables = new HashMap<>();
		Matcher matcher = urlPattern.matcher(url);
		if (matcher.matches() && matcher.groupCount() >= 1) {
			for (int i = 1; i <= matcher.groupCount(); i++) {
				String varGroup = matcher.group(i);
				VariableAndType pathVariable = this.pathVariables.get(i);
				Object value = pathVariable.getType().fromString(varGroup);
				pathVariables.put(pathVariable.getVarName(), value);
			}
		}
		return pathVariables;
	}

	private class VariableAndType {
		private String varName;
		private PathVariableType<?> type;

		public VariableAndType(String varName, PathVariableType<?> type) {
			this.varName = varName;
			this.type = type;
		}

		public String getVarName() {
			return varName;
		}

		public PathVariableType<?> getType() {
			return type;
		}

	}

}

As you can see, the algorithm that converts the registered route to a regular expression is pretty simple. It finds all defined path variables, which are known to be surrounded by curly braces and replaces them with the pattern “([^/]+)” – one or more symbols without a slash. After that, the route is converted in a similar way to another regular expression, which is used to extract the path variable names and types from the Matcher groups.
And, mentioning path variable types, let’s dive into that mechanism a little. A PathVariableType must define its name and how to convert to said type from a String. Currently, apart from the default string type, the only supported types are numeric: byte, short, int, long. If a type is not provided for a path variable, or the type cannot be otherwise resolved, the variable is assumed to be a String.
To give you a hint at how a type is implemented, here’s a snippet from IntegerPathVariableType:

public class IntegerPathVariableType implements PathVariableType<Integer> {

	@Override
	public Integer fromString(String value) {
		return Integer.valueOf(value);
	}

	@Override
	public String getName() {
		return "int";
	}

}

ZkRouter

The two most important methods of the ZkRouter are dispatch() and goTo().
dispatch() accepts a String argument which is the current URL hash value. It tries to find the registered route mapping by the given URL. It tests all routes in alphabetical order, starting from those without path variables, until it finds a match. When a match is found, the path variables are exported into a Map. The current content (if any) is detached and destroyed and the new content is created inside the contentHolder, passing the path variables Map as Execution Args. If anything fails during the routing process, a ZkRoutingErrorHandler will be notified accordingly. All registered plugins are called at the appropriate times during the dispatch process.

public class ZkRouter {
	private Map<String, ZkRoute> routesWithoutParams = new TreeMap<>();
	private Map<String, ZkRoute> routesWithParams = new TreeMap<>();
	private Component contentHolder = null;
	private Component content = null;
	private ZkRoutingErrorHandler routingErrorHandler = new NoOpRoutingErrorHandler();

	public void dispatch(String url) {
		url = RouterUtil.removeFirstAndLastSlash(url);
		try {
			tryDispatch(url);
		} catch (RouterException e) {
			if(routingErrorHandler != null) {
				routingErrorHandler.handleRoutingError(e);
			}
		}
	}
	
	private void tryDispatch(String url) throws RouterException {
		pluginDelegator.beforeRouting(this, content, url);
		
		ZkRoute route = findRoute(url);
		if (route == null) {
			throw new RouteMissingException();
		}		
		Map<String, Object> pathVariables = new HashMap<>();
		try {
			pathVariables = route.resolvePathVariables(url);
		} catch (RuntimeException e) {
			throw new InvalidRouteException(e);
		}
		
		pluginDelegator.beforeContentChanged(this, content);
		if (content != null) {
			content.detach();
			content = null;
		}
		content = Executions.createComponents(route.getView(), contentHolder, pathVariables);
		pluginDelegator.afterContentChanged(this, content);
	}

	private ZkRoute findRoute(String url) {
		for (String testUrl : routesWithoutParams.keySet()) {
			ZkRoute route = routesWithoutParams.get(testUrl);
			if (route.matches(url)) {
				return route;
			}
		}

		for (String testUrl : routesWithParams.keySet()) {
			ZkRoute route = routesWithParams.get(testUrl);
			if (route.matches(url)) {
				return route;
			}
		}

		return null;
	}
}

The goTo() method is used to change the current hash (or Bookmark in ZK terminology):

public class ZkRouter {
	public void goTo(String url) {
		url = url.replaceAll("#", "");
		Clients.evalJavaScript("window.location.hash = '#" + url + "';");
	}
}

Note that Desktop.setBookmark() isn’t used for changing the hash. That’s because setBookmark URL-encodes the passed string and thus it is impossible to have paths separated by slashes.

ZkRouterFactory

Since registering routes results in compiling Patterns, it is generally not a very good idea to do this every time a user starts using your application. Moreover, routes should be registered once at application startup because they do not change. This is where ZkRouterFactory comes into play. ZkRouterFactory is designed to be configured once during application startup and create ZkRouter instances later with the already parsed route mappings.

Registration of route mappings is handled the following way:

public class ZkRouterFactory {
	private Map<String, ZkRoute> routesWithoutParams = new TreeMap<>();
	private Map<String, ZkRoute> routesWithParams = new TreeMap<>();

	public ZkRoute addRoute(String url, String view) {
		url = url.toLowerCase();
		url = removeFirstAndLastSlash(url);
		ZkRoute route = new ZkRoute(url, view);
		if (url.contains("{")) {
			routesWithParams.put(url, route);
		} else {
			routesWithoutParams.put(url, route);
		}
		return route;
	}

	private String removeFirstAndLastSlash(String url) {
		url = url.replaceAll("^/", "");
		url = url.replaceAll("/$", "");
		return url;
	}
}

Route mappings are stored in two ordered maps – one for routes without path variables and one for routes with path variables. This means that the router will try to match routes alphabetically, starting from the mappings without variables.

Finally, an option to change the default routing error handler is added:

public class ZkRouterFactory {
	private ZkRoutingErrorHandler defaultRoutingErrorHandler = new NoOpRoutingErrorHandler();

	public void setDefaultRoutingErrorHandler(ZkRoutingErrorHandler errorHandler) {
		this.defaultRoutingErrorHandler = errorHandler;
	}
}

This is how ZkRoutingErrorHandler looks like:

public interface ZkRoutingErrorHandler {

	void handleRoutingError(RouterException e);
}

handleRoutingError() is called whenever any routing error occurs. The type of the error can be deduced by the the type of the exception. For example, if a route cannot be found (similar to 404), a RouteMissingException is thrown and will be passed to the error handler. If a route is found but contains invalid parameters, an InvalidRouteException is thrown.
If you don’t register an error handler, a default NoOpRoutingErrorHandler is registered for you, which, as the name suggests, does absolutely nothing.

The only thing left is the actual factory method that creates ZkRouter instances:

public class ZkRouterFactory {
	public ZkRouter createRouter(Component contentHolder) {
		ZkRouter router = new ZkRouter(routesWithoutParams, routesWithParams, plugins);
		router.setContentHolder(contentHolder);
		router.setRoutingErrorHandler(defaultRoutingErrorHandler);
		Executions.getCurrent().getDesktop().setAttribute("router", router);
		return router;
	}
}

The contenHolder is the parent ZK Component of all your views. Think of it as the content-holding <div> in a typical html- and javascript-based SPA. The contentHolder can easily come from the ViewModel which handles the layout of your application.
The created ZkRouter instance is also stored in the current Desktop for easy retrieval. It only seems natural to add a convenience method to get the currently bound router:

public class ZkRouter {
	public static ZkRouter getCurrent() {
		Execution execution = Executions.getCurrent();
		if(execution != null && execution.getDesktop() != null) {
			Object router = execution.getDesktop().getAttribute("router");
			if(router != null && router instanceof ZkRouter) {
				return (ZkRouter) router;
			}
		}
		return null;
	}
}

Configuration and initialization

For easier configuration of the router using Spring, an extension was created:

public class ZkRouterFactoryBean extends ZkRouterFactory {

	public void setRoutes(List routes) {
		for(String routeDefinition : routes) {
			String[] parts = routeDefinition.split("=>");
			if(parts.length != 2) {
				throw new IllegalArgumentException("Cannot parse route " + routeDefinition + ". Routes must be defined as 'url => view'!");
			}
			String url = parts[0].toLowerCase().trim();
			String view = parts[1].trim();
			addRoute(url, view);
		}
	}
}

This allows you to create XML configuration like the one shown:

<bean id="routerFactory" class="com.pastelstudios.zk.router.spring.ZkRouterFactoryBean">
	<property name="routes">
		<list>
			<value>/ => root.zul</value>
			<value>/foo => foo.zul</value>
			<value>/foo/{bar:long} => foobar.zul</value>
		</list>
	</property>
</bean>

Next, we need to wire the routerFactory to the layout viewmodel, create a router instance and start listening for hashchange (onBookmarkChange) events.
Here’s a sample viewmodel using zkspring:

@VariableResolver(DelegatingVariableResolver.class)
public class LayoutVM {
	
	@WireVariable
	private ZkRouterFactory routerFactory;
	
	private ZkRouter router;

	@AfterCompose
	public void afterCompose(@ContextParam(ContextType.VIEW) Component view) {
		Component contentHolder = view.getFellow("contentHolder");
		router = routerFactory.createRouter(contentHolder);
	}

	@Command
	public void onHashChange(@BindingParam("url") String url) {
		Clients.clearBusy(router.getContentHolder());
		router.dispatch(url);
	}
}

and a sample zul:

<window id="layoutWindow" border="none" apply="org.zkoss.bind.BindComposer" viewModel="@id('vm') @init('com.pastelstudios.zk.exmaple.LayoutVM')"
	height="100%" onBookmarkChange="@command('onHashChange', url = event.bookmark)">
	
	<div id="contentHolder" style="overflow: auto;" vflex="true">
		
	</div>
</window>

And this is basically it! However, for the sake of convenience let’s go one step further and register a very simple VariableResolver for the router. The VariableResolver looks like this:

public class RouterVariableResolver implements VariableResolver {

	@Override
	public Object resolveVariable(String variableName) throws XelException {
		if("router".equals(variableName)) {
			return ZkRouter.getCurrent();
		}
		return null;
	}

}

Then, in zk.xml, add the following lines:

<listener>
	<listener-class>com.pastelstudios.zk.router.RouterVariableResolver</listener-class>
</listener>

Thus, whenever the variable “router” is referenced inside a zul, it will automatically be resolved to the ZkRouter bound to the current Desktop. This is very useful in the following case:

	<menubar id="menubar" width="100%">
		<menuitem label="Root" onClick='router.goTo("/")' />
		<menuitem label="Foo" onClick='router.goTo("/foo")' />
		<menuitem label="Bar" onClick='router.goTo("/foo/1")' />
	</menubar>

But wait, what about instantiating a default route? I know that the prurpose of ZK is to AVOID writing javascript, but sometimes it is necessary. So, just add the following javascript to your view:

	<script type="text/javascript" defer="true">
		var hash = window.location.hash;
		if(hash === null || hash === "") {
			zAu.send(new zk.Event(zk.Widget.$("$layoutWindow"), "onEmptyRoute", {'' : {'data' : {'nodeId': ''}}}, {toServer:true}));
		}
	</script>

It will fire a custom “onEmptyRoute” event on the root component when the hash is empty. By listening to this event you can switch to the default route (for example onEmptyRoute='router.goTo("/")').

That’s it! We now have a fully functional router and the BACK, FORWARD and RELOAD buttons in the browser work properly. It is by no means a flawless implementation, but I beleive it is good enough.

All source code is available on github: Zk-Router
You can also try out a simplified demo in ZK Fiddle: Zk-Router Demo

Tags: , , ,

Category: Development

About the Author ()

Leave a Reply