Saturday, November 9, 2013

Thymeleaf template layouts in Spring MVC application with no extensions

After some years with JSP/JSTL and Apache Tiles I started discovering Thymeleaf for my Spring MVC applications. Thymeleaf is a really great view engine and it simplifies and speeds up the development despite that lack of good IntelliJ (vote here: http://youtrack.jetbrains.com/issue/IDEABKL-6713) support at the moment (there is an Eclipse plugin though). While learning how to use Thymeleaf I investigated different possibilities of working with layouts.

Apart from the native fragment inclusion mechanism there are at least two options to work with layouts: Thymeleaf integration with Apache Tile and Thymeleaf Layout Dialect. Both seem to work fine, but inspired by this comment about a simple and custom option, I gave it a try. In this post I will show I created the solution.

Change Log

  • 04/12/2016 - Source code updated. Thymeleaf 3.0.2
  • 26/07/2016 - Source code updated. No Layout option introduced (@Layout(Layout.NONE)), integration tests, POM improvements and updates.

Create a Spring MVC project with Thymeleaf support

To get started quickly I used my Spring MVC Archetype with Thymeleaf 2.1 support. I created a project by simply invoking the archetype and then imported it to IntellJ.

Creating the layout file

In WEB-INF/views directory I created a layouts folder where I placed the my first layout file called default.html:


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>...</head>
<body>
<div th:raplace="fragments/header :: header">
    Header
</div>
<div th:replace="${view} :: content">
    Content
</div>
<div th:replace="fragments/footer :: footer">
    Footer
</div>
</body>
</html>

The ${view} variable will contain the view name returned by the @Controller and the content fragment from ${view} file will be placed here.

Creating the view file

I edited WEB-INF/views/homeNotSignedIn.html and I defined the content fragment like this:


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>...</head>
<body>
<div class="container" th:fragment="content">
    <!-- /* Handle the flash message */-->
    <th:block th:if="${message != null}">
        <div th:replace="fragments/alert :: alert (type=${#strings.toLowerCase(message.type)}, message=${message.message})">&nbsp;</div>
    </th:block>
    <p>
        Hello <span th:text="${#authentication.name}">User</span>!
        Welcome to the Spring MVC Quickstart application!
    </p>
</div>
</body>
</html>

So the only change was defining the fragment named content and removing duplicated fragment inclusions. No additional changes are required. The @Controller returns the original view name, as it was before:

@Controller
class HomeController {
 
 @RequestMapping(value = "/", method = RequestMethod.GET)
 String index(Principal principal) {
  return principal != null ? "home/homeSignedIn" : "home/homeNotSignedIn";
 }
}

I changed other views accordingly.

Creating the interceptor and integrating with Spring MVC

To finish the "new layout framework" I created a handler interceptor that will do the work:


public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        modelAndView.setViewName(DEFAULT_LAYOUT);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    }    
}

ThymeleafLayoutInterceptor gets the original view name returned from the handler's method and replaces it with the layout name (that is defined in WEB-INF/views/layouts/default.html). The original view is placed in the model as a view variable, so it can be used in the layout file. I overrode the postHandle method, as it is executed just before rendering the view.
Adding the interceptor is easy:

@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new ThymeleafLayoutInterceptor());
    }
}
And that's it for the basic configuration. Not a rocket since. The result after going to localhost:8080. This is what I expected. Works like a charm. So I try to signup for an account and what I see after submitting a form:
500 returned for /signup with message Error resolving template "redirect:/", template might not exist or might not be accessible by any of the configured Template Resolvers
Of course, redirect:/ after the form submission. I needed to modify the interceptor like this:

public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        if (isRedirectOrForward(originalViewName)) {
            return;
        }
        modelAndView.setViewName(DEFAULT_LAYOUT);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    } 
    private boolean isRedirectOrForward(String viewName) {
        return viewName.startsWith("redirect:") || viewName.startsWith("forward:");
    }   
}

And it worked as expected. But I realized that I need to define and additional layout because Signup and Signin used this before, but not after applying the above changes.

Creating additional layouts

I created a new layout called blank.html and placed it to WEB-INF/views/layouts folder. But how to use select the layout? Probably there are many ways to do this. One of the easiest I though is to return the layout name from the @Controller by simply adding a model attribute named layout. If no layout is given, default one is used, otherwise the given one. Simple. But I wanted a more robust solution. So I thought maybe an annotation that I could use like this:


@Controller
class SigninController {

    @Layout(value = "layouts/blank")
    @RequestMapping(value = "signin")
    String signin() {
        return "signin/signin";
    }
}

To me it sounded like a good solution. So I implemented it.

Selecting the layout

I created a method level @Layout annotation that I placed in org.thymeleaf.spring.support package (together with ThymeleafLayoutInterceptor):


package org.thymeleaf.spring.support;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Layout {
    String value() default "";
}


I changed the interceptor as follows:

public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        if (isRedirectOrForward(originalViewName)) {
            return;
        }
        String layoutName = getLayoutName(handler);
        modelAndView.setViewName(layoutName);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    }

    private boolean isRedirectOrForward(String viewName) {
        return viewName.startsWith("redirect:") || viewName.startsWith("forward:");
    }

    private String getLayoutName(Object handler) {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Layout layout = handlerMethod.getMethodAnnotation(Layout.class);
        if (layout == null) {
            return DEFAULT_LAYOUT;
        } else {
            return layout.value();
        }
    }
}

Now, when the handler method is annotated with @Layout annotation, it get its value attribute. Works great. But when I started to change SignupController I realized I need to annotate both methods. It would be better if my annotation can be used for all methods at once, by annotating the @Controller class:

@Controller
@Layout(value = "layouts/blank")
class SignupController {

}

So I did.

Final touches

Firstly, I changed the annotation so it can be targeted at the type level:


@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Layout {
    String value() default "";
}

And the interceptor:

public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter {

    private static final String DEFAULT_LAYOUT = "layouts/default";
    private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view";

    private String defaultLayout = DEFAULT_LAYOUT;
    private String viewAttributeName = DEFAULT_VIEW_ATTRIBUTE_NAME;

    public void setDefaultLayout(String defaultLayout) {
        Assert.hasLength(defaultLayout);
        this.defaultLayout = defaultLayout;
    }

    public void setViewAttributeName(String viewAttributeName) {
        Assert.hasLength(defaultLayout);
        this.viewAttributeName = viewAttributeName;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        if (modelAndView == null || !modelAndView.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        if (isRedirectOrForward(originalViewName)) {
            return;
        }
        String layoutName = getLayoutName(handler);
        modelAndView.setViewName(layoutName);
        modelAndView.addObject(this.viewAttributeName, originalViewName);
    }

    private boolean isRedirectOrForward(String viewName) {
        return viewName.startsWith("redirect:") || viewName.startsWith("forward:");
    }

    private String getLayoutName(Object handler) {
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Layout layout = getMethodOrTypeAnnotation(handlerMethod);
        if (layout == null) {
            return this.defaultLayout;
        } else {
            return layout.value();
        }
    }

    private Layout getMethodOrTypeAnnotation(HandlerMethod handlerMethod) {
        Layout layout = handlerMethod.getMethodAnnotation(Layout.class);
        if (layout == null) {
            return handlerMethod.getBeanType().getAnnotation(Layout.class);
        }
        return layout;
    }
}

As you can see method level annotation is more important than type level annotation, which gives some flexibility. In addition, I added a possibility to configure the interceptor. I thought, that setting the default layout name and view attribute name may be useful.

Summary

The presented solution may need some polishing in order to use it in production, but it shows how simply we can build template layouts without adding extra libraries to our project and utilizing only core Thymeleaf features. Please share you comments and opinions about the solution.

Please find the source code on GitHub: https://github.com/kolorobot/thymeleaf-custom-layout

20 comments:

  1. That looks great!

    isRedirectOrForward is what I haven't written in my "draft" but of course I have the same code :)

    The @Layout annotation is what I haven't thought about but it's great!

    I was using the same technique with JSPs few years ago, but parameterized th:replace looks much better!

    Thanks for this blog post - I hop to see it on DZone - you get my +1!

    regards
    Grzegorz Grzybek

    ReplyDelete
  2. Grzegorz,

    Thanks for the comment and for the inspiration.

    ReplyDelete
  3. Hello

    I hope you don't mind I've wrote this: http://forum.thymeleaf.org/Layouts-and-quot-tiles-quot-without-Apache-Tiles-and-without-LayoutDialect-td4027049.html ?

    regards
    Grzegorz Grzybek

    ReplyDelete
  4. Of couse not. Spread the message. I wonder if I should include this in my archetype (https://github.com/kolorobot/spring-mvc-quickstart-archetype) as it is now supports Thymeleaf 2.1. In case you have any suggestions regarding the archetype itself, I am also open to hear to them!

    ReplyDelete
  5. Hi

    I am new to thymeleaf and your article was very clear and useful for any beginner like me who has a great passion for learning new technologies..Anyway My small suggestion is that it would b more clear and helpful if you add some simple examples along with the html pages in githib..

    ReplyDelete
    Replies
    1. Thanks for the comment!

      I will try to improve!

      Delete
  6. Thank you for the great tutorial. It helped my immensely with my Spring/Thymeleaf studies.

    ReplyDelete
  7. I had to change WebMvcConfig to extend from WebMvcConfigurerAdapter instead of WebMvcConfigurationSupport to get @AuthenticationPrincipal to work.

    When extending from WebMvcConfigurationSupport it seems like @EnableWebMvcSecurity is ignored and the AuthenticationPrincipalArgumentResolver is never added to the argument resolvers in the WebMvcSecurityConfiguration class.

    Extending from WebMvcConfigurerAdapter instead fixed this problem for me.

    ReplyDelete
  8. Hello, Your post is really good. When i am using Thymeleaf and JSP view resolver together, my thymeleaf view resolver not work, by default the call goes to JSP view resolver. Following is my question detail :

    http://stackoverflow.com/questions/28494408/integrate-thymeleaf-tiles-and-jsp-view-resolver-together-in-spring-mvc

    ReplyDelete
  9. Hi, Your post help me a lot but I still got problem with resource references in head tag of layout file for view placed under subfolder.
    Templates/
        | layouts/
        |    |__default.html
        |    |__blank.html
        | user/
        |    |__view1.html
        |    |__view2.html
        |__sharedview1.html
        |__sharedview2.html
    ....

    Any help would be much appreciated.

    ReplyDelete
  10. Hi!

    Nice tutorial! I have implemented it and it works like a charm!

    Thought, i don't understand the usage of blank.html file. I dont know when I jhave to use the annotation, maybe for my english level :(. Could you giveme any more clue? All my Webapp is working well and i dont use the anotation,

    The unique thing I had to change was for ajax post, that I modified the Interceptor like this:

    Add this in the postHandle method:
    if(isFragment(originalViewName)){
    return;
    }

    and the method is as follows

    private boolean isFragment(String viewName){
    return viewName.contains("::");
    }

    Maybe with the Layout annotation this change is not needed?

    Thanks again for this tutoriall!

    ReplyDelete
    Replies
    1. The idea of the annotation is to have the possibility to use multiple layouts in one project. That is basically it!

      Delete
    2. Yay! Think I finally got it!

      so for example if I have an AJAX call, the controller that take care of this call should be annotated with the blank layout, because it return a fragment that a $load call render.

      Delete
  11. Hi,

    i am working on a small prototype using this method, and was testing, if it is possible to extend it to spring-webflow as well. unfortunately i dont have so easy access to the resolved view name in webflow as far as i see. Do you have any idea, if it is possible to achieve the same result ? I tried it using FlowExecutionListeners and Custom FlowHandlerAdapters to no avail, but maybe im missing something.

    ReplyDelete
    Replies
    1. I am not working with WF so I can't help. Sorry. Maybe someone else?

      Delete
  12. Nice post. I'm using your solution. Thanks

    ReplyDelete
  13. Thanks for Great Explanation ... two thumbs up

    ReplyDelete