Writing Liferay Plugins with Groovy

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:

  1. That you can indeed write Liferay plugins using another language, in this case Groovy.
  2. 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:

  1. 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.
  2. I'm going to persist audit events to the DB using as little persistence code as physically possible.
  3. 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/lib folder (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.xml and 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 to groovyc (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.GStartupAction and com.liferay.sample.groovy.GModelListener so 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.Sql class 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:
    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)

    }
     You gotta admit that's pretty short. That's it for that class.
     
  •  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.
    package com.liferay.sample.groovy

    import com.liferay.portal.model.BaseModelListener

    class GModelListener extends BaseModelListener {

        void onBeforeCreate(model) {
        }

        void onBeforeRemove(model) {
        }

        void onBeforeUpdate(model) {
        }

    }
    We start with the above.
     
  • 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 _auditLog variable is a Groovy groovy.sql.DataSet object that lets me do really clean operations on a given table.
     
  • Finally, I want to write a closure that 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!
Blogs
Sweet post Ray! I can't wait to try this out for myself.
Just awesome, 30 mins and it runs on my 5.2, too. Thx 4 that example ray! Also good example to show off groovy powers.

cheers armin
ugg,boots shoes,ugg boots,buy ugg boots,argyle knit,knit argyle,mini boots,buy leather boots,boots on sale,boots sale,2010 ugg boots,ugg boots 2010,boots store,classic crochet,classic crochet boots,womens boots on sale,boots for sale,boots in sale,short boots,classic boots,ugg australia,classic tall,discount ugg boots,ugg 2010,ugg company,ugg discount boots,ugg bailey button,cheap womens boots,discount womens boots,womens boots,cheap leather boots,discount leather boots,leather boots,ugg australia boots ,uggstore,kids snow boots,snow boots for kids,uggs 2010,uggs,ugg boots discount,cheap ugg boots,classic knit,ugg 5879,classic argyle knit,classic argyle knit boots,ugg classic argyle knit,classic mini boot,classic sheepskin,classic mini,cheap winter boots,discount winter boots,classic mini for sale,buy classic mini,classic sheepskins,comfortable boots,short leather boots,best winter boots,knee high boots,boat shoes,blue boots,boots online,womens boot,furry boots for women,cheap tall boots,girls boots,thigh boots,thigh high boots,fur boots cheap,waterproof boots,thigh high boots for women,cheap furry boots,full fur boots,ugg classic crochet 5833,ugg 5833 boots,crochet boot,fur boots on sale,cheap crochet boots,crochet boots,ugg 5833,boots uk,green ankle boots,where to buy boots,classic crochet tall,cheap fashion boots,cheap flat boots,flat boots,classic boot,over the knee boots,short boot,boots on line,black leather boots,ugg 5825 boots,short sand,high leather boots,pink in boots,knee boots,ugg classic short 5825,ugg 5825,short classics,classic short 5825,boots usa,australia classic short boots,winter boots,winter boots for women on sale,winter boots on sale,boot shop,knitted boots for women,classic cardy 5819,australia classic cardy boots,australia classic cardy,classic cardy black,australia cardy classic boot,ugg 5819,ugg 5819 boots,ugg classic cardy 5819,grey cardy boots,cream boots,white boots,black cardy boots,classic tall sand,red back boots,red boots,ugg 5815 boots ,pink boots for women,ugg classic tall 5815,classic tall 5815,leggings and boots,ugg 5815,waterproof leather boots,waterproof sheepskin boots,classic tall metallic,cheap fur boots,waterproof winter boots,golden boot,gold boots,gold ankle boots,bailey button boot,pink fur boots,snow boot,bailey button 5803,bailey button chocolate,ugg bailey button 5803,ugg 5803,black knit boots,shop for boots,woman boots on sale,womens shoes boots,boots shop,classic sheepskin boots,boots to buy,buy boots,fur boots for women,green boots,boots with fur,female boots,real fur boots,cheap snow boots,ugg 5359,furry boot,cheap grey boots,cheap brown boots,cozy bootswoman boots,long black boots,black shoe boots,big boots,boot for women,cheap over the knee boots,boot on sale,lace up boots,boot for sale,ankle boots,cheap thigh high boots,cheap knee high boots,boots that are in style,style boots,tall sheepskin boot,boots and all,tall boots,tall boots on sale,snow wear,fall boots,boots shoe stores,patent boots,patent leather boots,love from australia,boot uk,hot boots,shoes australia,kid classic,new boots,boots with the fur,grey boots for women,grey boots on sale,boots on sales,boots sales,boots and more,womens boots and shoes,long boots,button boot,pink boots,leather boots on sale,long leather boots,cheap ankle boots,grey boot,grey boots,boot chocolate,black boots,fashion boots,fur boots,