Inject any custom class or service into web content templates

Problem situation

Web content templates are easy to write in Liferay, but as they become more complex they tend to contain a lot of scripting. Moreover, complex Velocity or Freemarker scripts are hard to maintain and even harder to debug. Also, unit testing your scripts is impossible.

Macros

A first possible optimization is to use macros. Generic script blocks can be captured into a macro so the same logic can be used multiple times in your script. You can even reuse macro definitions across multiple templates by defining a generic macro template and including (parsing) that template into other templates that want to use those macros.

However, macros are not that flexible either. References to macro calls are basically replaced at parse time, so they can not really be used as functions. Maintaining the scripts remains difficult and you still don't get to unit test your macro scripts. For complex functionality, writing Java code is clearly the preferred approach.

Velocity tools

Ray Augé of Liferay proposed a way to write custom Velocity utilities in his blog post Custom Velocity Tools. The approach was to create an empty hook project with a proper configuration and to create interface/implementation pairs for each tool you wanted to expose. Those tools could then be referenced with the $utilLocator variable in Velocity or Freemarker templates:

my-template.vm
#set ($sayHelloTool = $utilLocator.findUtil( "velocity-hook" , "be.aca.literay.tool.SayHelloTool" ))
 
$sayHelloTool.sayHello( "Peter" )

However, there are some limitations with this approach. At deploy time, all Velocity tools are put into a custom context, in which only these tools are accessible. There is no way to use normal beans, loaded by an application context, as Velocity tools. Ideally, when using Spring, you want to scan the classpath of your hook for beans and inject those beans into the Velocity context. This is not possible with Velocity tools.

Example

Suppose we have the following service, defined as a Spring component (@Service could be @Named as well, if you prefer using CDI):

SayHelloService.java
@Service
public class SayHelloService {
 
     public String sayHello(String name) {
         return String.format( "Hello, %s!" , name);
     }
    
}

This service is added to an application context by classpath scanning. This is configured in the applicationContext.xml file of our hook:

applicationContext.xml
<? xml version = "1.0" encoding = "UTF-8" ?>
< beans ...>
 
     < context:component-scan base-package = "be.aca" />
    
</ beans >

The goal is to expose and use SayHelloService in our web content templates.

The solution

When deploying a hook with Velocity tools to Liferay, these tools will be stored behind a custom bean locator of Liferay. Check outcom.liferay.portal.bean.BeanLocatorImpl for its implementation. We will create a custom implementation of this bean locator that will search for beans in Spring's application context instead of in the context that Liferay creates for Velocity tools.

Create a custom bean locator

So first we need to implement the com.liferay.portal.kernel.bean.BeanLocator interface. We will only implement the methods that are important for us and let the other methods throw an UnsupportedOperationException.

VelocityBeanLocator.java
@Component
public class VelocityBeanLocator implements BeanLocator {
 
     private static final String SUFFIX = ".velocity" ;
    
     public Object locate(String name) {
         String realName = stripVelocitySuffix(name);
         return SpringBeanLocator.getBean(realName);
     }
 
     private String stripVelocitySuffix(String name) {
         String realName = name;
         if (realName.endsWith(SUFFIX)) {
             realName = realName.substring( 0 , realName.length() - SUFFIX.length());
         }
         return realName;
     }
 
     public ClassLoader getClassLoader() {
         throw new UnsupportedOperationException();
     }
 
     public String[] getNames() {
         throw new  String[0];
     }
 
     public Class<?> getType(String name) throws BeanLocatorException {
         throw new UnsupportedOperationException();
     }
 
     public <T> Map<String, T> locate(Class<T> clazz) throws BeanLocatorException {
         throw new UnsupportedOperationException();
     }
}

The important method here is the locate(String) method. We redirect this method to SpringBeanLocator, which is a static class that holds the ApplicationContext object of Spring. This context will be initialized at deploy time because it implements the ApplicationContextAware interface of Spring:

SpringBeanLocator.java
@Component
public class SpringBeanLocator implements ApplicationContextAware {
 
     private static ApplicationContext ctx;
 
     @SuppressWarnings ( "unchecked" )
     public static <T> T getBean(String className) {
         try {
             return (T) ctx.getBean(Class.forName(className));
         } catch (BeansException e) {
             throw new RuntimeException(e);
         } catch (ClassNotFoundException e) {
             throw new RuntimeException(e);
         }
     }
 
     public void setApplicationContext(ApplicationContext applicationContext) {
         ctx = applicationContext;
     }
    
}

Replace Liferay's bean locator with your own

We have to tell Liferay that it has to use your bean locator instead of the default Bean Locator implementation. We will replace the original bean locator in a servlet context listener, which we will configure in the web.xml of our project.

VelocityBeanLocatorContextListener.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class VelocityBeanLocatorContextListener implements ServletContextListener {
 
     private static final String CONTEXT_CONFIG = "contextConfigLocation" ;
 
     private XmlWebApplicationContext ctx;
 
     public VelocityBeanLocatorContextListener() {
         ctx = new XmlWebApplicationContext();
     }
 
     public void contextInitialized(ServletContextEvent sce) {
         initializeContext(sce);
         String servletContext = sce.getServletContext().getContextPath().substring( 1 );
         PortletBeanLocatorUtil.setBeanLocator(servletContext, new VelocityBeanLocator());
     }
 
     private void initializeContext(ServletContextEvent sce) {
         ctx.setServletContext(sce.getServletContext());
         ctx.setConfigLocation(sce.getServletContext().getInitParameter(CONTEXT_CONFIG));
         ctx.refresh();
     }
 
     public void contextDestroyed(ServletContextEvent sce) {
         ctx.close();
     }
 
}

Line 14 is important here. Here, the bean locator is set to our custom bean locator using the PortletBeanLocatorUtil. The bean locator is only used within the context of this hook, so you don't risk breaking anything in other portlets or hooks. The rest of the code just initializes the application context of Spring.

This context listener has to be configured inside the web.xml so it is called when your hook gets deployed. This is what your web.xml will look like:

web.xml
<? xml version = "1.0" encoding = "UTF-8" ?>
< web-app ...>
    
     < listener >
         < listener-class >be.aca.literay.spring.VelocityBeanLocatorContextListener</ listener-class >
     </ listener >
    
     < context-param >
         < param-name >contextConfigLocation</ param-name >
         < param-value >classpath*:applicationContext.xml</ param-value >
     </ context-param >
</ web-app >

Call your service the normal way in Velocity or Freemarker

When your hook is deployed (don't forget to add an empty liferay-hook.xml file!), you'll be able to call your Spring services by using the utilLocator variable inside Velocity templates.

custom_service.vm
#set ($helloTool = $utilLocator.findUtil("velocity-hook", "be.aca.liferay.SayHelloService"))
$helloTool.sayHello("Peter")

Or, if you prefer using Freemarker:

custom_service.ftl
<#assign sayHelloTool=utilLocator.findUtil("velocity-hook", "be.aca.liferay.SayHelloService")>
${sayHelloTool.sayHello("Peter")}

In the background, the util locator will now pass through your bean locator and your Spring service will be retrieved from the application context, defined in your hook. And we'll have this awesome result:

Mission successful!

Conclusion

The ability to use advanced logic inside web content templates allows you to better separate business logic from presentation logic. Your templates will be much cleaner and only contain presentation logic. Your Java classes will do the heavy work and can be managed, optimized and unit tested properly. Guess this is a win-win situation!

Clone the full example of the code on Github: https://github.com/limburgie/velocity-bean-locator. Your feedback is very welcome!

Blogues
For those interested, a small update was applied to the code making it fully compatible with Liferay 6.2. In Liferay 6.2, the getNames() method of the bean locator is also called, so it should not throw an UnsupportedOperationException. Instead, it can return an empty String array. Both the code in this article was updated, as well as the code in the Github repo.
Thanks to https://github.com/tmetten for noticing this.
Thanks oodles for this. Took me a while to wrap my head around that "velocity-hook" was just the name of your hook project, if your hook project is called something else, you'll need to update that. (Liferay 6.2 GA2)
Hi Alessandro,

Unfortunately the approach only works for Web Content templates.
There however already exists a solution for injecting custom beans/services/tools in Liferay themes. See [1] for more information. You could use this approach to instantiate and then inject the Velocity tool into your theme's template variables.

Best regards and good luck

[1] https://www.liferay.com/community/wiki/-/wiki/Main/Custom+Velocity+Variables
Hi Peter we do a workaround that works for themes without EXT plugin!

With a fresh Liferay 6.2 CE GA2 installation the context (ctx variable in SpringBeanLocator) is null both in theme and web content template emoticon

Instead of use the ctx.getBean(clazz) we use classic reflection clazz.newIstance().

In this case the util locator works fine both in themes and web content templates!

We hope this is useful for the community


Cheers
Thanks for sharing your experiences Allessandro.

Class reflection is indeed possible but in that case your service beans will no longer be singletons. If you can live with that, your solution is fine :-)
No singleton no party emoticon

We need implement singleton too!
This seems a great way to bridge liferay web contents to our (remote EJemoticon service layer. However I'm having some troubles when returning POJOs from the service layer.

Lets say that I have a Products interface annotated with @Remote. The actual implementation is running on a separate TomEE instance.
The interfaces has two methods:

String getProductName(Long productId);
Product getProduct(Long productId);

Both the interface and the Product POJO are in a service-common maven module which is packaged as a jar archive, and it is a dependency in the hook project.

The getProductName works just nicely, and if used in the web content, I can see something like "Hello, peter! I found a product named TestProduct for you!".
However if I use the method that returns the whole product POJO, it only works during the first time I load the web content. When I refresh the page I get ClassNotFoundException for the product POJO. So clearly(?) the JAR dependency is not in the right place.. or.. something? Any ideas what might be going on here?

Running LR 6.2 GA2
Hi Jouni,

Thanks for your feedback. This evening I'll perform a similar test on my code and let you know my findings.
Seems to work if I put the dependency jar to Tomcat's lib, and set it as provided in the pom. Naturally this is not ideal as it would require manually updating the jar everytime it changes.
@Jouni

I just tried this by defining a separate JAR module containing the service returning a POJO object that is defined within that JAR module as well. Finally, the JAR module was depended on by the velocity hook WAR project.

See my branch on https://github.com/limburgie/velocity-bean-locator/tree/external-library for the full layout.

This way, I could do the following:
#set ($otherHelloTool = $utilLocator.findUtil("velocity-hook", "be.aca.liferay.OtherSayHelloService"))
$otherHelloTool.getPojo().sayHi()

This worked for me every time, even after reloading the page multiple times. My template is not cached, maybe that makes a difference? I tested this in Liferay 6.1 EE GA3 but I don't think the version matters here.
Hi Peter,
I tried to implement the code but using FreeMarker as template. I get an error telling me the utilLocator.FindUtil(...) is undefined. Would I change something in the code to make it running with Freemarker?
Thank you
#set ($sayHelloTool = $utilLocator.findUtil("velocity-hook", "be.aca.literay.tool.SayHelloTool"))

change by

#set ($sayHelloTool = $serviceLocator.findService("velocity-hook", "be.aca.literay.tool.SayHelloTool"))
Hi Peter,

I created a hook project and put your code in the hook project. When I tried to deploy it to my liferay 6.2, I got below error message in the log file. Do you know what happen? My local JDK is 1.7 and I do see XmlWebApplicationContext in string-web.jar under
\liferay-portal-6.2-ce-ga6\tomcat-7.0.62\webapps\ROOT\WEB-INF\lib\spring-web.jar



02:31:09,481 ERROR [localhost-startStop-8][HotDeployImpl:233] com.liferay.portal.kernel.deploy.hot.HotDeployException: Error registering servlet context listeners for content-hookcontent-hook
com.liferay.portal.kernel.deploy.hot.HotDeployException: Error registering servlet context listeners for content-hookcontent-hook
at com.liferay.portal.kernel.deploy.hot.BaseHotDeployListener.throwHotDeployException(BaseHotDeployListener.java:46)
at com.liferay.portal.deploy.hot.ServletContextListenerHotDeployListener.invokeDeploy(ServletContextListenerHotDeployListener.java:40)
at com.liferay.portal.deploy.hot.HotDeployImpl.doFireDeployEvent(HotDeployImpl.java:230)
at com.liferay.portal.deploy.hot.HotDeployImpl.fireDeployEvent(HotDeployImpl.java:96)
at com.liferay.portal.kernel.deploy.hot.HotDeployUtil.fireDeployEvent(HotDeployUtil.java:28)
at com.liferay.portal.kernel.servlet.PluginContextListener.fireDeployEvent(PluginContextListener.java:164)
at com.liferay.portal.kernel.servlet.PluginContextListener.doPortalInit(PluginContextListener.java:154)
at com.liferay.portal.kernel.util.BasePortalLifecycle.portalInit(BasePortalLifecycle.java:44)
at com.liferay.portal.kernel.util.PortalLifecycleUtil.register(PortalLifecycleUtil.java:74)
at com.liferay.portal.kernel.util.PortalLifecycleUtil.register(PortalLifecycleUtil.java:58)
at com.liferay.portal.kernel.util.BasePortalLifecycle.registerPortalLifecycle(BasePortalLifecycle.java:54)
at com.liferay.portal.kernel.servlet.PluginContextListener.contextInitialized(PluginContextListener.java:116)
at com.liferay.portal.kernel.servlet.SecurePluginContextListener.contextInitialized(SecurePluginContextListener.java:151)
at org.apache.catalina.core.StandardContext.listenerStart(StandardContext.java:5016)
at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:5528)
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:150)
at org.apache.catalina.core.ContainerBase.addChildInternal(ContainerBase.java:901)
at org.apache.catalina.core.ContainerBase.addChild(ContainerBase.java:877)
at org.apache.catalina.core.StandardHost.addChild(StandardHost.java:652)
at org.apache.catalina.startup.HostConfig.deployDirectory(HostConfig.java:1263)
at org.apache.catalina.startup.HostConfig$DeployDirectory.run(HostConfig.java:1948)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:471)
at java.util.concurrent.FutureTask.run(FutureTask.java:262)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
at java.lang.Thread.run(Thread.java:745)
Caused by: java.lang.reflect.InvocationTargetException
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:606)
at com.liferay.portal.deploy.hot.ServletContextListenerHotDeployListener.doInvokeDeploy(ServletContextListenerHotDeployListener.java:77)
at com.liferay.portal.deploy.hot.ServletContextListenerHotDeployListener.invokeDeploy(ServletContextListenerHotDeployListener.java:37)
... 24 more
Caused by: java.lang.NoClassDefFoundError: org/springframework/web/context/support/XmlWebApplicationContext
at be.aca.literay.spring.VelocityBeanLocatorContextListener.<init>(VelocityBeanLocatorContextListener.java:37)
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:57)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:526)
at java.lang.Class.newInstance(Class.java:379)
at com.liferay.portal.kernel.util.InstanceFactory.newInstance(InstanceFactory.java:63)
at com.liferay.portal.kernel.util.InstanceFactory.newInstance(InstanceFactory.java:27)
at com.liferay.portal.kernel.servlet.SecurePluginContextListener.instantiatingListener(SecurePluginContextListener.java:304)
at com.liferay.portal.kernel.servlet.SecurePluginContextListener.instantiatingListeners(SecurePluginContextListener.java:163)
... 30 more
Caused by: java.lang.ClassNotFoundException: org.springframework.web.context.support.XmlWebApplicationContext
at org.apache.catalina.loader.WebappClassLoader.loadClass(WebappClassLoader.java:1720)
at org.apache.catalina.loader.WebappClassLoader.loadClass(WebappClassLoader.java:1571)
... 40 more
OK. After adding below attribute in liferay-plugin-package.properties, the missing class is resolved.

portal-dependency-jars=spring-aop.jar,\
spring-asm.jar,\
spring-aspects.jar,\
spring-beans.jar,\
spring-context-support.jar,\
spring-context.jar,\
spring-core.jar,\
spring-expression.jar,\
spring-jdbc.jar,\
spring-jms.jar,\
spring-orm.jar,\
spring-oxm.jar,\
spring-transaction.jar,\
spring-web-portlet.jar,\
spring-web-servlet.jar,\
spring-web-struts.jar,\
spring-web.jar