Pushing Magnolia: Creating a Command
When Magnolia CMS is set up normally, it generates content on a page-by-page basis as it’s requested. The HTML is never written out to the filesystem, but is generated on the fly by the app and then cached in memory. This “pull” approach has a number of advantages: content is only rendered when it’s actually needed, one can be sure that the content is up to date, and content activations are quick.
Recently, however, one of our new customers needed Magnolia’s web pages written out to disk so that their existing web infrastructure could do some unusual post-processing on the HTML. We had to figure out a way to “push” Magnolia’s content out to the filesystem.
To enable this functionality and make it available throughout Magnolia, I decided the best thing to do would be to wrap it up into a Command. The great thing about Commands is that they can be used in workflows, called by the user interface, triggered by the Observation modules, and generally reused in a ton of different contexts.
(Note: we don’t recommend this approach for serving content. This article is more useful as a way to see how commands are constructed and can interact with the rest of the system than as something one would generally want to emulate for a production system.)
To create a new command, the first thing one must do is to create a new Java class that extends one of the existing command classes. The class from which all commands are derived is MgnlCommand, though in this case BaseActivationCommand gives us some additional functionality that is useful, so we’ll use it instead:
public class PublishToFileSystemCommand extends BaseActivationCommand { @Override public boolean execute(Context ctx) throws Exception { } }
Next up, we need to add some parameters to the command. This is done by adding properties to the object, along with standard getters and setters. Magnolia’s introspection is clever enough to pick these up and generate the appropriate hooks to allow them to be set in all of the contexts where a command can be used. (We’ll also set up a logger while we’re at it.)
private static Logger log = LoggerFactory.getLogger(PublishToFileSystemCommand.class); private String fileSystemPath = "/tmp/"; private String pathToWget = "/opt/local/bin/wget"; private String baseUrl = "http://localhost:8080/magnoliaPublic"; public void setFileSystemPath(String fileSystemPath) { this.fileSystemPath = fileSystemPath; } public String getFileSystemPath() { return fileSystemPath; } public void setPathToWget(String pathToWget) { this.pathToWget = pathToWget; } public String getPathToWget() { return pathToWget; } public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } public String getBaseUrl() { return baseUrl; }
As you can see, the various parameters we’re interested in letting the user set are:
- fileSystemList: Where in the file system to write the files that wget retrieves.
- pathToWget: Where to find the wget command-line utility.
- baseUrl: The URL of the Magnolia instance from which to download the rendered HTML pages. If you want a page without the editing widgets, you’ll want to use a public instance here.
Now, since there are already programs out there that do a great job at fetching web content and writing it to the filesystem, there’s no need for us to reinvent that wheel. Instead, we’ll just use wget, which is widely available across unix-y operating systems, and which can be called with tons of different command-line options to make it behave the way we want. Here we add a template for calling the wget command, and a function to fire up command-line commands from Java:
private static String commandTemplate = "PATH_TO_WGET -q --auth-no-challenge --http-user=SUPERUSER_NAME --http-password=SUPERUSER_PASSWORD -P FILE_SYSTEM_PATH -k -r URL"; private boolean runCommandLine(String command) { log.info( "About to execute: " + command ); try { Runtime rt = Runtime.getRuntime(); Process pr = rt.exec( command ); BufferedReader input = new BufferedReader(new InputStreamReader( pr.getInputStream()) ); String line = null; // since we're using quiet mode, we shouldn't be getting output, // but just in case... while ((line = input.readLine()) != null) { log.info(line); } int exitVal = pr.waitFor(); log.info("Exited with code " + exitVal ); return true; } catch (Exception e) { log.error(e.toString()); e.printStackTrace(); return false; } }
(Thanks to linglom.com for the sample code I drew upon for this.)
When Java calls other programs using the command-line, the read-write buffers can fill up very quickly. Thus, we use wget’s quiet mode to keep it from overwhelming Java with its voluminous output. Additionally, wget’s exit codes are often odd, at least with the version of it I was using, so we merely log the codes, but otherwise ignore them.
Because Magnolia supports content in multiple languages, we also need a way to get a list of additional languages that should be retrieved. This method iterates through the languages listed in the site definition. (Site definitions, of course, rely on Magnolia’s templating kits to work.) As the primary language is at the default URL, we don’t include that language in the list of additional ones we’ll want to retrieve:
private List getAdditionalLocales( Context ctx ) throws RepositoryException { SiteManager siteManager = SiteManager.Factory.getInstance(); Site site = siteManager.getAssignedSite( getNode( ctx ) ); I18nContentSupport i18nSupport = site.getI18n(); Collection locales = i18nSupport.getLocales(); List returnLocales = new LinkedList(); Iterator i = locales.iterator(); while ( i.hasNext() ) { Locale locale = i.next(); if ( !locale.toString().equals( i18nSupport.getFallbackLocale().toString() ) ) { returnLocales.add( locale.toString() ); } } return returnLocales; }
Now the infrastructure is all set up. Let’s go back and fill in the execute() method, which is the code that actually gets called when a command is used:
@Override public boolean execute(Context ctx) throws Exception { Content nodeToActivate = getNode( ctx ); Content homePage = nodeToActivate.getAncestor( 1 ); // These credentials are not needed when downloading from a public // instance, but it doesn't hurt to provide them, and we'll need them // if we're ever grabbing content from an Author instance. User superuser = Security.getSystemUser(); String superuserName = superuser.getName(); String superuserPassword = superuser.getPassword(); log.info( "path to fetch: " + baseUrl + homePage.getHandle() ); log.info( "fileSystemPath: " + this.getFileSystemPath() ); log.info( "additionalLocales: " + this.getAdditionalLocales( ctx ) ); String command = commandTemplate.replaceAll("FILE_SYSTEM_PATH", fileSystemPath). replaceAll("URL", baseUrl + homePage.getHandle() ). replaceAll("PATH_TO_WGET", pathToWget). replaceAll("SUPERUSER_NAME", superuserName ). replaceAll("SUPERUSER_PASSWORD", superuserPassword); if ( !runCommandLine(command) ) return false; Iterator i = this.getAdditionalLocales(ctx).iterator(); while ( i.hasNext() ) { String localeName = i.next(); command = commandTemplate.replaceAll("FILE_SYSTEM_PATH", fileSystemPath). replaceAll("URL", baseUrl + homePage.getHandle() + "/" + localeName + "/" ). replaceAll("PATH_TO_WGET", pathToWget). replaceAll("SUPERUSER_NAME", superuserName ). replaceAll("SUPERUSER_PASSWORD", superuserPassword); if ( !runCommandLine( command ) ) return false; } return true; }
This code could obviously be refactored a bit to make it cleaner and less repetitious, but it should give a good idea of what’s involved in actually making the command execute. The return value indicates whether the command was successful or not, and can affect the calling context in various ways.
With this final step, we have a fully functional, parameterized command! Here’s the finished, complete source:
package com.sample; import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.Collection; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Locale; import javax.jcr.RepositoryException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import info.magnolia.cms.core.Content; import info.magnolia.cms.core.HierarchyManager; import info.magnolia.cms.core.NodeData; import info.magnolia.cms.i18n.I18nContentSupport; import info.magnolia.cms.security.Security; import info.magnolia.cms.security.User; import info.magnolia.context.Context; import info.magnolia.context.MgnlContext; import info.magnolia.module.admininterface.commands.BaseActivationCommand; import info.magnolia.module.templatingkit.sites.Site; import info.magnolia.module.templatingkit.sites.SiteManager; public class PublishToFileSystemCommand extends BaseActivationCommand { private String fileSystemPath = "/tmp/"; private String pathToWget = "/opt/local/bin/wget"; private String baseUrl = "http://localhost:8080/magnoliaPublic"; private static Logger log = LoggerFactory.getLogger(PublishToFileSystemCommand.class); private static String commandTemplate = "PATH_TO_WGET -q --auth-no-challenge --http-user=SUPERUSER_NAME --http-password=SUPERUSER_PASSWORD -P FILE_SYSTEM_PATH -k -r URL"; @Override public boolean execute(Context ctx) throws Exception { Content nodeToActivate = getNode(ctx); Content homePage = nodeToActivate.getAncestor( 1 ); // These credentials are not needed when downloading from a public // instance, but it doesn't hurt to provide them, and we'll need them // if we're ever grabbing content from an Author instance. User superuser = Security.getSystemUser(); String superuserName = superuser.getName(); String superuserPassword = superuser.getPassword(); log.info( "path to fetch: " + baseUrl + homePage.getHandle() ); log.info( "fileSystemPath: " + this.getFileSystemPath() ); log.info( "additionalLocales: " + this.getAdditionalLocales( ctx ) ); String command = commandTemplate.replaceAll("FILE_SYSTEM_PATH", fileSystemPath). replaceAll("URL", baseUrl + homePage.getHandle() ). replaceAll("PATH_TO_WGET", pathToWget). replaceAll("SUPERUSER_NAME", superuserName ). replaceAll("SUPERUSER_PASSWORD", superuserPassword); if ( !runCommand(command) ) return false; Iterator<String> i = this.getAdditionalLocales(ctx).iterator(); while ( i.hasNext() ) { String localeName = i.next(); command = commandTemplate.replaceAll("FILE_SYSTEM_PATH", fileSystemPath). replaceAll("URL", baseUrl + homePage.getHandle() + "/" + localeName + "/" ). replaceAll("PATH_TO_WGET", pathToWget). replaceAll("SUPERUSER_NAME", superuserName ). replaceAll("SUPERUSER_PASSWORD", superuserPassword); if ( !runCommand( command ) ) return false; } return true; } private boolean runCommand(String command) { log.info( "About to execute: " + command ); try { Runtime rt = Runtime.getRuntime(); Process pr = rt.exec( command ); BufferedReader input = new BufferedReader(new InputStreamReader( pr.getInputStream()) ); String line = null; // since we're using quiet mode, we shouldn't be getting output, // but just in case... while ((line = input.readLine()) != null) { log.info(line); } int exitVal = pr.waitFor(); log.info("Exited with error code " + exitVal ); return true; } catch (Exception e) { log.error(e.toString()); e.printStackTrace(); return false; } } private List<String> getAdditionalLocales( Context ctx ) throws RepositoryException { SiteManager siteManager = SiteManager.Factory.getInstance(); Site site = siteManager.getAssignedSite( getNode( ctx ) ); I18nContentSupport i18nSupport = site.getI18n(); Collection<Locale> locales = i18nSupport.getLocales(); List<String> returnLocales = new LinkedList<String>(); Iterator<Locale> i = locales.iterator(); while ( i.hasNext() ) { Locale locale = i.next(); if ( !locale.toString().equals( i18nSupport.getFallbackLocale().toString() ) ) { returnLocales.add( locale.toString() ); } } return returnLocales; } public void setFileSystemPath(String fileSystemPath) { this.fileSystemPath = fileSystemPath; } public String getFileSystemPath() { return fileSystemPath; } public void setPathToWget(String pathToWget) { this.pathToWget = pathToWget; } public String getPathToWget() { return pathToWget; } public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } public String getBaseUrl() { return baseUrl; } }
Hi Sean,
good article, but I am curious to know how do you trigger that command in your scenario.. once a time? Every not-cached request?
Thanks for this info!
Hi Matteo,
In this case, we set it up to write out the whole site whenever a site editor uses the Activate command through AdminCentral. This ensures that the content is up-to-date on whatever pages might be affected, but does put a heavy load on the system while it’s rebuilding the pages.
Like I said, probably not a good practice for a production system. 🙂 Using the scheduler module to trigger the command once an hour or once every 30 minutes or something might be a better approach under some circumstances.
Sean