Статьи

Макеты шаблонов Thymeleaf в приложении Spring MVC без расширений

После нескольких лет работы с JSP / JSTL и Apache Tiles я начал открывать Thymeleaf для своих приложений Spring MVC. Thymeleaf — это действительно отличный движок для просмотра, который упрощает и ускоряет разработку, несмотря на недостаток хорошей поддержки IntelliJ (голосуйте здесь: http://youtrack.jetbrains.com/issue/IDEABKL-6713 ) (есть Eclipse). хотя плагин ). Изучая, как использовать Thymeleaf, я исследовал различные возможности работы с макетами.

Помимо встроенного механизма включения фрагментов, есть как минимум два варианта работы с макетами: интеграция Thymeleaf с Apache Tile и Thymeleaf Layout Dialect . Оба, кажется, работают нормально, но, вдохновленный этим комментарием о простой и настраиваемой опции, я попробовал. В этом посте я покажу, что создал решение.

Создайте проект Spring MVC с поддержкой Thymeleaf

Для начала я использовал Spring MVC Archetype с поддержкой Thymeleaf 2.1. Я создал проект, просто вызвав архетип, а затем импортировал его в IntellJ.

Создание файла макета

В каталоге WEB-INF / views я создал папку раскладок, куда я поместил мой первый файл раскладки с именем default.html: переменная $ {view} будет содержать имя представления, возвращаемое @Controller, и фрагмент содержимого из $ {view} файл будет размещен здесь.

Создание файла вида

Я отредактировал WEB-INF / views / homeNotSignedIn.html и определил фрагмент содержимого следующим образом: единственное изменение — определение фрагмента с именем content и удаление дублированных включений фрагментов. Никаких дополнительных изменений не требуется. @Controller возвращает исходное имя представления, как это было раньше:

1
2
3
4
5
6
7
8
@Controller
class HomeController {
 
 @RequestMapping(value = "/", method = RequestMethod.GET)
 String index(Principal principal) {
  return principal != null ? "home/homeSignedIn" : "home/homeNotSignedIn";
 }
}

Я изменил другие взгляды соответственно.

Создание перехватчика и интеграция с Spring MVC

Чтобы закончить «новую структуру макета», я создал обработчик-перехватчик, который сделает всю работу:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
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.hasView()) {
            return;
        }
        String originalViewName = modelAndView.getViewName();
        modelAndView.setViewName(DEFAULT_LAYOUT);
        modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName);
    }   
}

ThymeleafLayoutInterceptor получает исходное имя представления, возвращенное из метода обработчика, и заменяет его именем макета (которое определено в WEB-INF / views / layouts / default.html). Исходный вид помещается в модель как переменная вида, поэтому его можно использовать в файле макета. Я переопределил метод postHandle, так как он выполняется непосредственно перед отображением представления.

Добавить перехватчик легко:

1
2
3
4
5
6
7
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new ThymeleafLayoutInterceptor());
    }
}

И это все для базовой конфигурации. С тех пор не ракета. Результат после перехода на localhost: 8080. Это то, что я ожидал. Работает как шарм. Поэтому я пытаюсь зарегистрироваться для учетной записи и что я вижу после отправки формы:

1
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

Конечно,
редирект: / после отправки формы. Мне нужно было изменить перехватчик следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
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.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:");
    }  
}

И это сработало, как и ожидалось. Но я понял, что мне нужно определить и дополнительный макет, потому что регистрация и вход в систему использовали это раньше, а не после применения вышеуказанных изменений.

Создание дополнительных макетов

Я создал новый макет с именем blank.html и поместил его в папку WEB-INF / views / layouts. Но как использовать выбрать макет? Вероятно, есть много способов сделать это. Один из самых простых способов — вернуть имя макета из @Controller, просто добавив атрибут модели с именем layout. Если макет не указан, используется по умолчанию, в противном случае — заданный. Просто. Но я хотел более надежное решение. Поэтому я подумал, может быть, аннотацию, которую я мог бы использовать так

1
2
3
4
5
6
7
8
9
@Controller
class SigninController {
 
    @Layout(value = "layouts/blank")
    @RequestMapping(value = "signin")
    String signin() {
        return "signin/signin";
    }
}

Для меня это звучало как хорошее решение. Так я это и реализовал.

Выбор макета

Я создал аннотацию уровня метода @Layout, которую я поместил в пакет org.thymeleaf.spring.support (вместе с ThymeleafLayoutInterceptor):

01
02
03
04
05
06
07
08
09
10
package org.thymeleaf.spring.support;
 
import java.lang.annotation.*;
 
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Layout {
    String value() default "";
}

Я изменил перехватчик следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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.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();
        }
    }
}

Теперь, когда метод-обработчик помечен аннотацией @Layout, он получает атрибут value. Прекрасно работает. Но когда я начал менять RegistrationController, я понял, что мне нужно аннотировать оба метода. Было бы лучше, если бы мою аннотацию можно было использовать для всех методов одновременно, аннотируя класс @Controller:

1
2
3
4
5
@Controller
@Layout(value = "layouts/blank")
class SignupController {
 
}

Так я и сделал.

Последние штрихи

Во-первых, я изменил аннотацию, чтобы она могла быть нацелена на уровень типа:

1
2
3
4
5
6
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Layout {
    String value() default "";
}

И перехватчик:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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.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;
    }
}

Как вы можете видеть, аннотация на уровне метода важнее аннотации на уровне типа, что дает некоторую гибкость. Кроме того, я добавил возможность настроить перехватчик. Я думал, что установка имени макета по умолчанию и имени атрибута представления может быть полезной.

Резюме

Представленное решение может потребовать некоторой доработки, чтобы использовать его в работе, но оно показывает, насколько просто мы можем создавать макеты шаблонов, не добавляя дополнительные библиотеки в наш проект и используя только основные функции Thymeleaf. Пожалуйста, поделитесь своими комментариями и мнениями о решении.