Writing A Custom ViewEngine For Jakarta MVC
Nov 2, 2021, 2:31 PM
One of the very-long-term side projects I have going on is a rewrite of OpenNTF's site. While the one we have has served us well, we have a lot of ideas about how we want to present projects differently and similar changes to make, so this is as good a time as any to go whole-hog.
The specific hog in question involves an opportunity to use modern Jakarta EE technologies by way of my Domino Open Liberty Runtime project, as I do with my blog here. And that means I can, also like this blog, use the delightful Jakarta MVC Spec.
However, when moving to JEE 9.1, I ran into some trouble with the current Open Liberty beta and its handling of JSP as the view template engine. At some point, I plan to investigate to see if the bug is on my side or in Liberty's (it is beta, in this case), but in the mean time it gave my brain an opportunity to wander: in theory, I could use ERB (a Ruby-based templating engine) for this purpose. I started to look around, and it turns out the basics of such a thing aren't difficult at all, and I figure it's worth documenting this revelation.
MVC ViewEngines
The way the MVC spec works, you have a JAX-RS endpoint that returns a string or is annotated with a view-template name, and that's how the framework determines what page to use to render the request. For example:
1 2 3 4 5 6 7 8 9 | @Path("home") @GET @Produces(MediaType.TEXT_HTML) public String get() { models.put("recentReleases", projectReleases.getRecentReleases(30)); //$NON-NLS-1$ models.put("blogEntries", blogEntries.getEntries(5)); //$NON-NLS-1$ return "home.jsp"; //$NON-NLS-1$ } |
Here, the controller method does a little work to load required model data for the page and then hands it off to the view engine, identified here by returning "home.jsp"
, which is in turn loaded from WEB-INF/views/home.jsp
in the app.
In the framework, it looks through instances of ViewEngine
to find one that can handle the named page. The default spec implementation ships with a few of these, and JspViewEngine
is the one that handles view names ending with .jsp
or .jspx
. The contract for ViewEngine
is pretty simple:
1 2 3 4 | public interface ViewEngine { boolean supports(String view); void processView(ViewEngineContext context) throws ViewEngineException; } |
So basically, one method to check whether the engine can handle a given view name and another one to actually handle it if it returned true
earlier.
Writing A Custom Engine
With this in mind, I set about writing a basic ErbViewEngine
to see how practical it'd be. I added JRuby to my dependencies and then made my basic class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | @ApplicationScoped @Priority(ViewEngine.PRIORITY_APPLICATION) public class ErbViewEngine extends ViewEngineBase { @Inject private ServletContext servletContext; @Override public boolean supports(String view) { return String.valueOf(view).endsWith(".erb"); //$NON-NLS-1$ } @Override public void processView(ViewEngineContext context) throws ViewEngineException { // ... } } |
At the top, you see how a custom ViewEngine
is registered: it's done by way of making your class a CDI bean in the application scope, and then it's good form to mark it with a @Priority
of the application level stored in the interface. Extending ViewEngineBase
gets you a handful of utility classes, so you don't have to, for example, hard-code WEB-INF/views
into your lookup code. The bit with ServletContext
is there because it becomes useful in implementation below - it's not part of the contractual requirement.
And that's basically the gist of hooking up your custom engine. The devil is in the implementation details, for sure, but that processView
is an empty canvas for your work, and you're not responsible for the other fiddly details that may be involved.
First-Pass ERB Implementation
Though the above covers the main concept of this post, I figure it won't hurt to discuss the provisional implementation I have a bit more. There are a couple ways to use JRuby in a Java app, but the way I'm most familiar with is using JSR 223, which is a generic way to access scripting languages in Java. With it, you can populate contextual objects and settings and then execute a script in the target language. The Krazo MVC implementation actually comes with a generic Jsr223ViewEngine
that lets you use any such language by extension.
In my case, the task at hand is to read in the ERB template, load up the Ruby interpreter, and then pass it a small script that uses the in-Ruby ERB
class to render the final page. This basic implementation looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | @Override public void processView(ViewEngineContext context) throws ViewEngineException { Charset charset = resolveCharsetAndSetContentType(context); String res = resolveView(context); String template; try { // From Apache Commons IO template = IOUtils.resourceToString(res, StandardCharsets.UTF_8); } catch (IOException e) { throw new ViewEngineException("Unable to read view", e); } ScriptEngineManager scriptEngineManager = new ScriptEngineManager(); ScriptEngine scriptEngine = scriptEngineManager.getEngineByExtension("rb"); //$NON-NLS-1$ Object responseObject; try { Bindings bindings = scriptEngine.createBindings(); bindings.put("models", context.getModels().asMap()); bindings.put("__template", template); responseObject = scriptEngine.eval("require 'erb'\nERB.new(__template).result(binding)", bindings); } catch (ScriptException e) { throw new ViewEngineException("Unable to execute script", e); } try (Writer writer = new OutputStreamWriter(context.getOutputStream(), charset)) { writer.write(String.valueOf(responseObject)); } catch (IOException e) { throw new ViewEngineException("Unable to write response", e); } } |
The resolveCharsetAndSetContentType
and resolveView
methods come from ViewEngineBase
and do basically what their names imply. The rest of the code here reads in the ERB template file and passes it to the script engine. This is extremely similar to the generic JSR 223 implementation, but diverges in that the actual Ruby code is always the same, since it exists just to evaluate the template.
If I continue down this path, I'll put in some time to make this more streamable and to provide access to CDI beans, but it did the job to prove that it's quite doable.
All in all, I found this exactly as pleasant and straightforward as it should be.