Friday 27 September 2013

CFML: Mostly pointless custom tag exercise

G'day:
Yesterday I wittered on about IRC, and how I landed there as part of an exercise to help Kyle with a code challenge he was having. Kyle wanted to know if it was possible to write a custom tag which emulated <cfmail> (easy), except including the fact one doesn't need to specify <cfoutput> tags in the body of a mail message with <cfmail>. That got me thinking.

First up, here's a <cfmail> example of what I'm talking about:

<cfset msg = "G'day world">
<cfmail to="dac.cfml@example.com" from="dac.cfml@example.com" subject="#msg#">
#msg#
</cfmail>

And this yields:

type:  text/plain; charset=UTF-8
server:  127.0.0.1:25
from:  dac.cfml@example.com
to:  dac.cfml@example.com
subject:  G'day world
X-Mailer:  ColdFusion 10 Application Server
body:   G'day world 

Note how #msg# was resolved in the body of the <cfmail> tag without there being any <cfoutput> tags around it.

If I was to knock out a quick mail.cfm custom tag and try that:

// mail.cfm
if (thistag.executionMode == "END"){
    writeOutput(thistag.generatedContent);
    thistag.generatedContent = "";
}

I'd get this:

#msg#

So that's the challenge.

Initially I thought I'd be able to find something clever in the PageContext object (as returned by getPageContext()) to "switch on" <cfoutput> mode, and we'd be away laughing. I couldn't find anything, and also it occurred to me... how is this stuff actually compiled? My suspicion was that there's a fundamental issue here because CFML files are compiled as discrete units, so anything that the <cf_mail> code had (in mail.cfm) would never be able to alter the "outputness" of the calling code because the outputness (or lack thereof) would be dealt with at compile time: the code would be compiled differently if there was <cfoutput> or not. Here's a quick experiment:

<!--- withoutCfOutput.cfm --->
<cfset msg = "G'day world">
#msg#

<!--- withCfOutput.cfm --->
<cfset msg = "G'day world">
<cfoutput>#msg#</cfoutput>

The decompiled code for withoutCfOutput.cfm is thus:

protected final Object runPage() {
    Object value;
    JspWriter out = this.pageContext.getOut();
    Tag parent = this.parent;
    bindImportPath("com.adobe.coldfusion.*");
    this.MSG.set("G'day world");
    out.write("\r\n#msg#");
    return null;
}

And this monster is for withCfOutput.cfm:

protected final Object runPage() {
    Throwable t8;
    Throwable t7;
    Object t6;
    int mode0;
    Object value;
    JspWriter out = this.pageContext.getOut();
    Tag parent = this.parent;
    bindImportPath("com.adobe.coldfusion.*");
    this.MSG.set("G'day world");
    _whitespace(out, "\r\n");
    OutputTag output0 = (OutputTag)_initTag(class$coldfusion$tagext$io$OutputTag, 0, parent);
    _setCurrentLineNo(2);
    output0.hasEndTag(true);
    try {
        if ((mode0 = output0.doStartTag()) != 0)
            do
                out.write(Cast._String(_autoscalarize(this.MSG)));
            while (output0.doAfterBody() != 0);
        if (output0.doEndTag() == 5) {
            t6 = null;
            jsr 35;
        }
        jsr 29;
    }
    catch (Throwable localThrowable1) {
        output0.doCatch(localThrowable1);
        jsr 14;
    }
    catch (Throwable localThrowable2) {
        jsr 6;
    }
    Object t9 = returnAddress;
    output0.doFinally();
    ret;
    return null;
}

The point being, the decision as to how code is output or otherwise is done at compile time, whereas custom tags are called at runtime, so a custom tag cannot influence the outputness (I will stop using that word after this article is done, I promise) of its calling code.

So that's a challenge.

I pondered for a few min, and came up with this:

// implicitOutput.cfm
if (thistag.executionMode == "START"){
    param string attributes.returnVariable="implicitOutput";
}else{
    structAppend(variables, caller);    // emulate the context of the calling code as best we can

    thisDir = getDirectoryFromPath(getCurrentTemplatePath());
    fileName = createUuid();
    filePath = thisDir & fileName;
    try {
        fileWrite(filePath, "<cfoutput>" & thisTag.generatedContent & "</cfoutput>");
        savecontent variable="content"{
            include fileName;
        }
        caller[attributes.returnVariable] = content;
    }
    catch (any e){
        rethrow;
    }
    finally {
        if (fileExists(filePath)){
            try {
                fileDelete(filePath);
            } catch(any ignore){
                // oh well: I tried
            }
        }
    }
    thistag.generatedcontent = "";
}

The chief conceit here is that I:
  • grab the stuff between the custom tags,
  • wrap it in <cfoutput> tags,
  • write it to a temporary file,
  • include the file (which forces a compile on it, remember),
  • within a savecontent block,
  • and pass the captured content back to the calling code.
And that works. Now, before you start  -  because some of you will - yes, I know there's the file I/O and compilation overhead in there. And I also know that this is an awful lot of work just to avoid having <cfoutput> tags in the calling code. This is a proof of concept and a coding exercise, nothing that I'd actually ever do.

The calling code is this, btw:

Content before<br>
<cfset msg = "Content within">
<cf_implicitOutput>
    #msg#<br>
</cf_implicitOutput>
Content after<br>

<cfdump var="#variables#">

And the output:

Content before
Content after
struct
MSGContent within
implicitOutputContent within<br>

The interesting bit of this was the thought process I formalised when I went through trying to work out how to coerce CF into "output mode" via the PageContext, and the realisation that it wasn't a starter due to how stuff is compiled.

I had the briefest of looks at performance here, and knocked out two variations of the code above which just used <cfoutput> in the calling code instead as controls.

This first example simply takes out the file ops, but otherwise is the same (so a lot of redundant code in it):

// explicitOutput.cfm
if (thistag.executionMode == "START"){
    param string attributes.returnVariable="explicitOutput";
}else{
    structAppend(variables, caller);    // emulate the context of the calling code as best we can

    thisDir = getDirectoryFromPath(getCurrentTemplatePath());
    fileName = createUuid();
    filePath = thisDir & fileName;
    try {
        savecontent variable="content" {
            writeOutput(thisTag.generatedContent);
        }
        caller[attributes.returnVariable] = content;
    }
    catch (any e){
        rethrow;
    }
    finally {
        if (fileExists(filePath)){
            try {
                fileDelete(filePath);
            } catch(any ignore){
                // oh well: I tried
            }
        }
    }
    thisTag.generatedContent = "";
}

And this one is a more sensible approach to the task at hand:

// minimalExplicitOutput.cfm
if (thistag.executionMode == "START"){
    param string attributes.returnVariable="minimalExplicitOutput";
}else{
    caller[attributes.returnVariable] = thisTag.generatedContent;
    thistag.generatedContent = "";
}

And I called all three variations thus:

// timeThem.cfm
start = getTickCount();
include "callImplicitOutput.cfm";
elapsed = getTickCount() - start;
writeOutput("Execution time for implicitOutput test: #elapsed#<br>");
writeOutput("<hr>");

start = getTickCount();
include "callExplicitOutput.cfm";
elapsed = getTickCount() - start;
writeOutput("Execution time for explicitOutput test: #elapsed#<br>");
writeOutput("<hr>");

start = getTickCount();
include "callMinimalExplicitOutput.cfm";
elapsed = getTickCount() - start;
writeOutput("Execution time for minimalExplicitOutput test: #elapsed#<br>");
writeOutput("<hr>");

The includes there are variations of the calling code I listed above - just without the <cfdump> in them -  for each example (all this code is in github, btw).

Test results were along the lines of:

Content before
Content after
Execution time for implicitOutput test: 11


Content before
Content after
Execution time for explicitOutput test: 0


Content before
Content after
Execution time for minimalExplicitOutput test: 1






So the file-ops and compilation did add a reasonable overhead to the piece, really. If it was essential to do this sort of thing, I'd probably not be that worried about it though.


Anyway, that was it. Not hugely exciting in the end. C'est la vie.

--
Adam