The Partial Template Reduction feature was discussed in article FreshMarker Partial Template Reduction. The corresponding implementation is presented in this article.
With Partial Template Reduction, the template is filled with a fraction of the necessary data and thus simplified as far as possible. Primarily, interpolations are replaced by constant fragments and conditional directives are reduced to the actual alternative.
In FreshMarker, an internal representation of the template is created in the form of a fragment tree. The output is generated when running through the tree, in which each fragment generates its part of the output in its process
method.
The idea behind Partial Template Reduction is to implement a suitable reduce
method for each fragment, returning a new fragment if a reduction is possible. If a reduction is not possible, the method returns the current fragment itself. The starting point of the implementation is therefore the Fragment
interface that all fragments implement.
public interface Fragment { void process(ProcessContext context); default Fragment reduce(ReduceContext context) { return this; } default int getSize() { return 1; } }
The reduce
method is a default method and implements the return of the fragment if no reduction is possible. Instead of the ProcessContext
, the reduce
method receives a ReduceContext
as a parameter. Why this is the case and how the contexts differ is shown below.
The getSize
method has also been added for the number of fragments at this position in the fragment tree. For all fragments without a special implementation, the return value is 1. The getSize
method has been added to easily check the reduce effect. If a tree contains fewer fragments than the original as a result of the reduce method, then the reduction was successful.
The reduce
method of the root of the fragment tree is called within the method of the same name in the Template
. Like the process
method in the Template
, the reduce
method is also passed a model in the form of a Map
. However, this Map
only contains part of the complete model when it is called. Of course, it could also contain the entire model, but that doesn’t really make sense.
public Template reduce(Map<String, Object> dataModel, ReductionStatus status) { status.total().set(rootFragment.getSize()); ProcessContext context = configuration.createContext(dataModel, new StringWriter()); context.setEnvironment(getWrapperEnvironment(context)); try { BlockFragment reducedFragment = rootFragment.reduce(new ReduceContext(context, status)); status.deleted().set(rootFragment.getSize() - reducedFragment.getSize()); return new Template(configuration, templateLoader, path, reducedFragment); } catch (RuntimeException e) { throw new ReduceException("cannot reduce: " + e.getMessage(), e); } }
The second parameter status
contains a few counters to check the reduction more precisely. The attributes total
and deleted
are filled with the number of fragments in the original template and the difference to the number of fragments in the newly created tree.
The ReduceContext
inherits from ProcessContext
and is initialized with a ProcessContext
and the ReductionStatus
instances.
public class ReduceContext extends ProcessContext { private final ReductionStatus status; public ReduceContext(ProcessContext context, ReductionStatus status) { super(context.getEnvironment(), context.builtIns, context.outputs); this.status = status; } public ReductionStatus getStatus() { return status; } }
This means that a fully-fledged ProcessContext
is available for further processing and fragments can modify the changed
attribute in the ReductionStatus
. Only the change in the number of fragments says nothing about the size of the reduction, as the replacement of InterpolationFragment
instances with ConstantFragment
instances cannot be measured in this way.
The Partial Template Reduction feature can already be tested with these changes. Of course, no reduction takes place because only the fragment root of the old Template
is inserted into a new Template
. In order for something to change, the implementation of Fragment
must be overridden. To see the first effects, we will adapt in this article the InterpolationFragment
and the BlockFragment
. The InterpolationFragment
produces an output from an interpolation and the model. If data is missing, processing is aborted with a ProcessException
.
public class InterpolationFragment implements Fragment { // ... @Override public Fragment reduce(ReduceContext context) { try { TemplateString templateObject = expression.evaluate(context, TemplateString.class); context.getStatus().changed().incrementAndGet(); return new ConstantFragment(templateObject.getValue()); } catch (UnsupportedBuiltInException e) { throw new UnsupportedBuiltInException(e.getMessage(), ftl, e); } catch (WrongTypeException e) { throw new WrongTypeException(e.getMessage(), ftl, e); } catch (ProcessException e) { return this; } } }
The new reduce
method works in a similar way. If a new output could be generated, then the reduce
method produces a ConstantFragment
with this output. In the negative case, the InterpolationFragment
itself is returned. In addition, the reduce
method increments the number of changed
fragments in the ReduceContext
so that we can see what happened during the reduction.
As the root fragment in the Template
is a BlockFragment
, the base implementation must also be overridden here. A BlockFragment
is a container that contains a List
of Fragment
instances. The new BlockFragment
must therefore contain a List
of Fragment
instances that were created using the reduce
method.
public class BlockFragment implements Fragment { // ... @Override public int getSize() { return fragments.stream().mapToInt(Fragment::getSize).sum() + 1; } @Override public BlockFragment reduce(ReduceContext context) { return new BlockFragment(fragments.stream().map(f -> f.reduce(context)).toList()); } }
In the reduce
method, the reduce
method is called on all list elements and the result is collected in a new list. In addition, the getSize
method has been overridden, as the number must also include the sizes of the list elements.
Simple templates can already be reduced with these changes.
Map<String, Object> model = Map.of("company", "schegge.de"); Template original = configuration.getTemplate("test", "${company}: ${name}"); Template reduced = origial.reduce(model, reductionStatus); assertNotNull(reduced); assertEquals("schegge.de: Jens Kaiser", reduced.process(Map.of("name", "Jens Kaiser"))); assertEquals(5, reductionStatus.total().get()); assertEquals(0, reductionStatus.deleted().get()); assertEquals(1, reductionStatus.changed().get());
In this test, the original
Template
is created from ${company}: ${name}
and then reduced with the model Map.of("company", "schegge.de")
. The new reduced
template has just as many Fragment
instances as the original Template
, but the fragment ${company}
has been reduced to schegge.de
.
The next article deals with the implementations for the List-, Switch- and If-Directives.
2 thoughts on “Partial Template Reduction (1)”