We've been saying for a long time that you could write plugins in a variety of languages, but we never really had any examples to prove that.
Of course some languages will be far simpler to achieve this that others. It'll be far simpler to do this using languages that have native java bindings. Groovy is writen natively in java and even extends the JDK with awesome new features, so that's the one I'm choosing.
Another concern is speed of delievery while not comprimising on performance, stability, ability to debug, etc.
Groovy has awesome tooling support under Eclipse, and even takes part in debugging with little problem at all. All I had to do was install the Groovy Eclipse plugin and add the Groovy nature to my plugin project (yes, I'm using the LIDE. But since this isn't a blog about LIDE, I'm not gonna waste time showing that. It's safe to say that it wasn't a huge effort to add the Groovy support to the .project file and the IDE came in handy when specifying the props configurations which were few).
So, I'm going to demonstrate how to write a hook in Groovy. I want to demonstate 2 things in particular:
- That you can indeed write Liferay plugins using another language, in this case Groovy.
- That you can use the power of a wonderful language like Groovy to achieve things that would take an order of magnitude more code if you were to try with java.
I also have three goals:
- I'm going to implement a single listener of as many model events as I choose, listenting to as many model types as I choose, without having to write more that a single implementation.
- I'm going to persist audit events to the DB using as little persistence code as physically possible.
- I'm going to do it really fast (I'm pretending my boss wanted this done last week...).
So, where do I start?
The first thing I need to do is support compiling my groovy code. To do that I'm going to override the default ant target which normally compiles the plugin code, but only for this one plugin (Cause I don't want to harm other projects until I have this nailed down to a science.)
- First thing to do is download the latest Groovy jar and add that to your plugin's
<project>/docroot/WEB-INF/libfolder (I used groovy-all-1.7.3.jar, which was the latest version at the time of writting).
- Next, I'll open up
<project>/build.xmland paste the following ant target definition:
<target name="compile"> <antcall target="merge" /> <mkdir dir="docroot/WEB-INF/classes" /> <mkdir dir="docroot/WEB-INF/lib" /> <copy todir="docroot/WEB-INF/lib"> <fileset dir="${app.server.lib.portal.dir}" includes="${plugin.jars}" /> </copy> <copy todir="docroot/WEB-INF/tld"> <fileset dir="${app.server.portal.dir}/WEB-INF/tld" includes="${plugin.tlds}" /> </copy> <if> <available file="docroot/WEB-INF/src" /> <then> <if> <available file="tmp" /> <then> <path id="plugin-lib.classpath"> <fileset dir="docroot/WEB-INF/lib" includes="*.jar" /> <fileset dir="tmp/WEB-INF/lib" includes="*.jar" /> <pathelement location="docroot/WEB-INF/classes" /> <pathelement location="tmp/WEB-INF/classes" /> </path> </then> <else> <path id="plugin-lib.classpath"> <fileset dir="docroot/WEB-INF/lib" includes="*.jar" /> <pathelement location="docroot/WEB-INF/classes" /> </path> </else> </if> <copy todir="docroot/WEB-INF/lib"> <fileset dir="${app.server.lib.portal.dir}" includes="${required.portal.jars}" /> </copy> <if> <available file="docroot/WEB-INF/lib/portal-impl.jar" /> <then> <fail> . Detected inclusion of portal-impl.jar in WEB-INF/lib. portal-impl.jar is designed with a large number of singleton classes which are instantiated on the basis that they will exist alone in the application server. While compile time issues may be resolved, portlets cannot be made to work by simply adding portal-impl.jar, because doing so violates the above assumption, and the resulting problems will be extremely difficult to debug. Please find a solution that does not require portal-impl.jar. </fail> </then> </if> <taskdef name="groovyc"
classname="org.codehaus.groovy.ant.Groovyc"
classpathref="plugin.classpath"/>
<groovyc
classpathref="plugin.classpath"
destdir="docroot/WEB-INF/classes"
srcdir="docroot/WEB-INF/src"
>
<javac
compiler="${javac.compiler}"
debug="${javac.debug}"
deprecation="${javac.deprecation}"
fork="${javac.fork}"
memoryMaximumSize="${javac.memoryMaximumSize}"
nowarn="${javac.nowarn}"
/>
</groovyc> </then> </if> <antcall target="merge" /> </target>
Note the part in red that replaces the default compiler call with one togroovyc(Don't worry, this will also compile any java code seamlessly if there is some in the project).
- Now I make sure that my hook has a properties file defined cause I'm going to write an application Startup event, and a model listener:
<hook>
<portal-properties>portal.properties</portal-properties>
</hook> - My two implementations are going to be called
com.liferay.sample.groovy.GStartupActionandcom.liferay.sample.groovy.GModelListenerso I'll set those up in the props file (for now I'm just picking a whole bunch of interesting models to listen to, I can tune this again later):
application.startup.events=com.liferay.sample.groovy.GStartupAction
value.object.listener.com.liferay.portal.model.User=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portal.model.Layout=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.blogs.model.BlogsEntry=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.blogs.model.BlogsStatsUser=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.journal.model.JournalArticle=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.journal.model.JournalStructure=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.journal.model.JournalTemplate=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.messageboards.model.MBCategory=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.messageboards.model.MBMessage=com.liferay.sample.groovy.GModelListener
value.object.listener.com.liferay.portlet.messageboards.model.MBThread=com.liferay.sample.groovy.GModelListener - Ok, so now we have all the house keeping stuff done, and I can get to work on writting the code. For the sake of speed, I'm using the simplest type of DB interaction that comes in the form of Groovy's
groovy.sql.Sqlclass which easily lets me do JDBC operations using very lean amount of code (my DB operations are on MySQL specifically, again for the sake of speed, I'm not concerned with syntax that will work with other DBs).
The first thing I need to do is create the DB table, if it doesn't exist. I'm going to do that in my GStartupAction:
You gotta admit that's pretty short. That's it for that class.package com.liferay.sample.groovy
import com.liferay.portal.kernel.events.SimpleAction
import com.liferay.portal.kernel.util.InfrastructureUtil
import groovy.sql.Sql
class GStartupAction extends SimpleAction {
public void run(String[] arg0) {
try {
_sql.rows('select count(*) from AuditLog')
}
catch (e) {
_sql.execute '''
create table AuditLog (
auditId bigint not null primary key,
groupId bigint,
className varchar(75),
classPK bigint,
classUuid varchar(75),
auditDate datetime,
description varchar(200),
model longtext
)
'''
}
}
private static final Sql _sql = new Sql(InfrastructureUtil.dataSource)
}
- On to GModelListener. Note that we want it to work with any of the models we throw at it. And we also want to limit the code, so we're going to extend
com.liferay.portal.model.BaseModelListener<T>. The only events we care about are: onBeforeCreate, onBeforeRemove, onBeforeUpdate.
We start with the above.package com.liferay.sample.groovy
import com.liferay.portal.model.BaseModelListener
class GModelListener extends BaseModelListener {
void onBeforeCreate(model) {
}
void onBeforeRemove(model) {
}
void onBeforeUpdate(model) {
}
}
- I need a couple other objects setup, logging, and the Groovy Sql object that I need to do the DB operations:
package com.liferay.sample.groovy
import com.liferay.portal.kernel.log.LogFactoryUtil
import com.liferay.portal.kernel.util.InfrastructureUtil;
import com.liferay.portal.model.BaseModelListener
class GModelListener extends BaseModelListener {
void onBeforeCreate(model) {
}
void onBeforeRemove(model) {
}
void onBeforeUpdate(model) {
}
private static final _log = LogFactoryUtil.getLog(GModelListener.class)
private static final _sql = new Sql(InfrastructureUtil.dataSource)
private static final _auditLog = _sql.dataSet("AuditLog")
}
The_auditLogvariable is a Groovygroovy.sql.DataSetobject that lets me do really clean operations on a given table.
- Finally, I want to write a
closurethat will do the work of updating the AuditLog table, but first I want a very simple API for it:
package com.liferay.sample.groovy
import com.liferay.portal.kernel.log.LogFactoryUtil
import com.liferay.portal.kernel.util.InfrastructureUtil;
import com.liferay.portal.model.BaseModelListener
class GModelListener extends BaseModelListener {
void onBeforeCreate(model) {
audit(model, "onBeforeCreate")
}
void onBeforeRemove(model) {
audit(model, "onBeforeRemove")
}
void onBeforeUpdate(model) {
audit(model, "onBeforeUpdate")
}
def audit = { model, message ->
// do my work here
}
private static final _log = LogFactoryUtil.getLog(GModelListener.class)
private static final _sql = new Sql(InfrastructureUtil.dataSource)
private static final _auditLog = _sql.dataSet("AuditLog")
}
That's pretty straight forward! It'll do the trick.
- Now, I want to track who did the particular operation, so I need to current user's Id, so it can be recoreded. The simplest way to do this is to get the current PermissionChecker and if it exists, then record the userId:
def audit = { model, message ->
def userId = PermissionThreadLocal.permissionChecker?.userId
} - We also want the Group, anbd for the sake of completeness, if the model has a uuid field we want that too:
def audit = { model, message ->
def userId = PermissionThreadLocal.permissionChecker?.userId
def groupId = 0
def uuid = ''
}
But, how do we handle with the models without those fields? Isn't there a bunch of reflection involed in handling things like that? Well, in java, Yes! In Groovy, No! This is so easy in Groovy it's almost trivial:
def audit = { model, message ->
def userId = PermissionThreadLocal.permissionChecker?.userId
def groupId = 0
def uuid = ''
if (model.metaClass.respondsTo(model, 'getGroupId')) {
groupId = model.groupId
}
if (model.metaClass.respondsTo(model, 'getUuid')) {
uuid = model.uuid
}
}
That's to say, if the model object has a given method, we'll just call it. If not, well we just ignore it and keep the default value. - Next we'll populate a map of data we want to store in the AuditLog table:
def audit = { model, message ->
def userId = PermissionThreadLocal.permissionChecker?.userId
def groupId = 0
def uuid = ''
if (model.metaClass.respondsTo(model, 'getGroupId')) {
groupId = model.groupId
}
if (model.metaClass.respondsTo(model, 'getUuid')) {
uuid = model.uuid
}
def map = [
auditId: CounterLocalServiceUtil.increment(),
groupId: groupId,
className: model.class.name,
classPK: model.primaryKey,
classUuid: uuid,
auditDate: new Date(),
description: message + " by " + String.valueOf(userId),
model: model.toString()
]
}
Simple enough!
- Now, let's store and log the result:
def audit = { model, message ->
def userId = PermissionThreadLocal.permissionChecker?.userId
def groupId = 0
def uuid = ''
if (model.metaClass.respondsTo(model, 'getGroupId')) {
groupId = model.groupId
}
if (model.metaClass.respondsTo(model, 'getUuid')) {
uuid = model.uuid
}
def map = [
auditId: CounterLocalServiceUtil.increment(),
groupId: groupId,
className: model.class.name,
classPK: model.primaryKey,
classUuid: uuid,
auditDate: new Date(),
description: message + " by " + String.valueOf(userId),
model: model.toString()
]
_auditLog.add(
auditId: map.auditId, groupId: map.groupId,
className: map.className, classPK: map.classPK,
classUuid: map.classUuid, auditDate: map.auditDate,
description: map.description, model: map.model)
if (_log.infoEnabled) {
_log.info map
}
}
Phew!!! We're done. We got it all done before lunch time.
Arguably, we could have saved another 13 lines od code if we didn't need the map that we used for both persisting and logging the event. We could just as easily passed the raw values to the _auditLog instance, but wait.. we don't want the code to be TOO short, otherwise our "lines of code contributed" factor will make it look like we never do ANY work at all!!!
- The whole class looks like this:
package com.liferay.sample.groovy
import com.liferay.counter.service.CounterLocalServiceUtil
import com.liferay.portal.kernel.log.LogFactoryUtil
import com.liferay.portal.kernel.util.InfrastructureUtil;
import com.liferay.portal.model.BaseModelListener
import com.liferay.portal.security.permission.PermissionThreadLocal;
import groovy.sql.Sql
class GModelListener extends BaseModelListener {
void onBeforeCreate(model) {
audit(model, "onBeforeCreate")
}
void onBeforeRemove(model) {
audit(model, "onBeforeRemove")
}
void onBeforeUpdate(model) {
audit(model, "onBeforeUpdate")
}
def audit = { model, message ->
def userId = PermissionThreadLocal.permissionChecker?.userId
def groupId = 0
def uuid = ''
if (model.metaClass.respondsTo(model, 'getGroupId')) {
groupId = model.groupId
}
if (model.metaClass.respondsTo(model, 'getUuid')) {
uuid = model.uuid
}
def map = [
auditId: CounterLocalServiceUtil.increment(),
groupId: groupId,
className: model.class.name,
classPK: model.primaryKey,
classUuid: uuid,
auditDate: new Date(),
description: message + " by " + String.valueOf(userId),
model: model.toString()
]
_auditLog.add(
auditId: map.auditId, groupId: map.groupId,
className: map.className, classPK: map.classPK,
classUuid: map.classUuid, auditDate: map.auditDate,
description: map.description, model: map.model)
if (_log.infoEnabled) {
_log.info map
}
}
private static final _log = LogFactoryUtil.getLog(GModelListener.class)
private static final _sql = new Sql(InfrastructureUtil.dataSource)
private static final _auditLog = _sql.dataSet("AuditLog")
}
Now you can go back and add the missing models and you're good to go.
Oh, and when you're done, take the rest of the day off... you deserve it!

