Thymeleaf 확장으로 새로운 dialect 추가해보기
최근에 몇몇 프로젝트를 Thymeleaf
를 템플릿 엔진으로 선정해서 진행을 하고 있다.
일단 기존의 개발자들이 JSP & JSTL을 사용하는 것에 너무 익숙하다보니 도입을 하는게 쉽진 않았으나 Spring Framework와 궁합도 잘 맞아보이고 개인 프로젝트를 몇개 해보니 확실히 도입하는 것이 낫다 싶어서 최근 프로젝트는 일단 Thymeleaf
로 진행을 하고 있다.
몇가지 살짝 아쉬운 기능들
Link URL Expressions
을 사용한 th:href
attribute를 사용함에 있어서 request에 넘어온 query string을 그대로 다음 링크로 넘겨야되는 경우가 발생하고는 하는데 전체 query string을 받아오는 기본 기능은 존재하지 않는다.
몇가지 방법이 존재하긴 하지만 다 맘에 들진 않는다.
<div th:with="currentUrl=(${#request.getRequestURI() + '?' +
#strings.defaultString(#request.getQueryString(), '')})">
<a th:href="@{${currentUrl}(myparam=test)}">click here</a>
</div>
javax.servlet.http.HttpServletRequest
를 직접 액세스할 수 있는 #request
표현을 사용하면 대충 원하는 기능을 만들어 볼순 있다.
하지만 파라미터가 중복인 경우에는 중복해서 계속 추가되고 무엇보다 저걸 매번 링크마다 써주는 아주 맘에 안드는 상황이 발생한다.
<div th:with="currentUrl=(${@currentUrlWithoutParam.apply('myparam')})">
<a th:href="@{${currentUrl}(myparam=test)}">click here</a>
</div>
@Bean
public Function<String, String> currentUrlWithoutParam() {
return param -> ServletUriComponentsBuilder.fromCurrentRequest()
.replaceQueryParam(param).toUriString();
}
혹은 Function
을 만들어서 간단하게 처리할 수도 있지만 역시나 이 방법도 그리 탐탁치 않다.
<span th:with="urlBuilder=${T(org.springframework.web.servlet.support.ServletUriComponentsBuilder).fromCurrentRequest()}"
th:text="${urlBuilder.replaceQueryParam('p2', '32').toUriString()}">
</span>
org.springframework.web.servlet.support.ServletUriComponentsBuilder
를 직접 불러와서 사용하는 방법도 찾아서 시도해보았지만 어느하나 th:with
를 써야하고 매번 링크를 만들때마다 매우 불편한 상황이 발생해서 자주쓰는 기능이다보니..
Thymeleaf
확장 기능을 통해 th:link
라는 새로운 attribute를 만들어서 처리해보기로 했다.
th:link attribute 추가를 위해 Thymeleaf를 확장해보자
관련 레퍼런스(Tutorial: Extending Thymeleaf)와 기존의 확장기능들 소스를 둘러보니 그리 어렵지 않게 기능을 확장할 수 있다.
Dialects and Processors
Thymeleaf Dialects
란 새롭게 만들 템플릿의 기능 집합이라고 보면 될 것 같다. Dialect
는 다수의 Processors
와 Expression
을 가질 수 있다.
여기서 얘기하는 Processor
는 만들려고 하는 Attribute나 Tag의 기능을 실제 처리하는 로직이고, Expression
은 #request
처럼 ThymeLeaf
의 기본 표현식을 확장하는 것을 얘기한다.
레퍼런스에는 org.thymeleaf.dialect.IDialect
를 구현해서 사용하면 된다라고 나와 있고 아래와 같은 sub interface들을 몇가지 제공한다.
- IProcessorDialect for dialects that provide processors.
- IPreProcessorDialect for dialects that provide pre-processors.
- IPostProcessorDialect for dialects that provide post-processors.
- IExpressionObjectDialect for dialects that provide expression objects.
- IExecutionAttributeDialect for dialects that provide execution attributes.
만드려는 확장의 기능에 맞게 적절한 인터페이스를 선택하면 된다.
Processors
는 Dialect
와 마찬가지로 org.thymeleaf.processor.IProcessor
인터페이스를 구현해서 사용하면 되고
- Template start/end
- Element Tags
- Texts
- Comments
- CDATA Sections
- DOCTYPE Clauses
- XML Declarations
- Processing Instructions
위의 타입에 맞게 알맞은 sub interface를 구현하면 된다.
ExtraLinkDialect 생성
ExtraLinkDialect
라는 Dialect
를 이제 실제로 만들어보자.
고맙게도 AbstractProcessorDialect
라는 IProcessorDialect
를 구현한 클래스를 제공해 주고 있다.
package me.hkwon.thymeleaf.dialect;
import java.util.LinkedHashSet;
import java.util.Set;
import me.hkwon.thymeleaf.dialect.processor.LinkAttrProcessor;
import org.thymeleaf.dialect.AbstractProcessorDialect;
import org.thymeleaf.processor.IProcessor;
import org.thymeleaf.templatemode.TemplateMode;
public class ExtraLinkDialect extends AbstractProcessorDialect {
public static final String NAME = "ExtraLink";
public static final String DEFAULT_PREFIX = "th";
public static final int PROCESSOR_PRECEDENCE = 800;
private String charset;
public ExtraLinkDialect(String charset) {
super("ExtraLink", "th", 800);
this.charset = charset;
}
public Set<IProcessor> getProcessors(String dialectPrefix) {
LinkedHashSet processors = new LinkedHashSet();
processors.add(new LinkAttrProcessor(TemplateMode.HTML,
dialectPrefix, this.charset));
return processors;
}
}
간단하다. 새로운 템플릿의 prefix는 th
를 사용했지만 원하는 걸로 변경하면 되고 실제 기능들을 담당할 Processors
를 추가시켜 주면 된다.
LinkAttrProcessor 생성
아래는 Link 처리를 위해 실제 구현한 LinkAttrProcessor
클래스다. 신규 Attribute를 처리 하기 위해서 AbstractAttributeTagProcessor
를 사용했다.
/**
* Thymeleaf Extra Link Processor
*
* @author hkwon
*/
@Slf4j
public class LinkAttrProcessor extends AbstractAttributeTagProcessor {
public static final int ATTR_PRECEDENCE = 1300;
public static final String ATTR_NAME = "link";
private static final char PARAMS_START_CHAR = '(';
private static final char PARAMS_END_CHAR = ')';
private static final char EXPRESSION_END_CHAR = '}';
private static final char PARAMS_DELIMITER = ',';
private String charset;
// 생성자
...
@Override
protected void doProcess(ITemplateContext context,
IProcessableElementTag tag, AttributeName attributeName,
String attributeValue, IElementTagStructureHandler structureHandler) {
final RequestContext requestContext = (RequestContext)context.getVariable(SpringContextVariableNames.SPRING_REQUEST_CONTEXT);
final LinkExpression linkExpression;
final Object expressionResult;
if (StringUtils.isEmptyOrWhitespace(attributeValue)) {
expressionResult = null;
} else {
// Get Attribute expression
linkExpression = (LinkExpression) StandardExpressions.getExpressionParser(context.getConfiguration()).parseExpression(context, attributeValue);
if (linkExpression == null) {
expressionResult = null;
} else {
if (requestContext.getQueryString() == null) {
expressionResult = linkExpression.execute(context);
} else {
// Append whole request parameters to attributeValue
URI uri = null;
List<NameValuePair> nvp = null;
try {
uri = new URI(requestContext.getRequestUri() + "?" + requestContext.getQueryString());
nvp = URLEncodedUtils.parse(uri, Charset.forName(charset));
} catch (URISyntaxException e) {
log.error("Passed URI has not valid syntax : " + uri, e);
}
// Exclude duplication query string
AssignationSequence assignationSequence = linkExpression.getParameters();
if (assignationSequence != null) {
for (Assignation assignation : assignationSequence) {
nvp.removeIf(e -> assignation.getLeft().getStringRepresentation().equals(e.getName()));
}
}
final String parameters = nvp.stream()
.map(nv -> nv.getName() + "=${'" + nv.getValue() + "'}")
.collect(Collectors.joining(","));
final StringBuilder sb = new StringBuilder();
if (linkExpression.hasParameters()) {
// Manipulate expression string with request parameters
final int lastIndex = attributeValue.lastIndexOf(PARAMS_END_CHAR);
sb.append(attributeValue.substring(0, lastIndex))
.append(PARAMS_DELIMITER)
.append(parameters)
.append(attributeValue.substring(lastIndex, attributeValue.length()));
} else {
sb.append(attributeValue.substring(0, attributeValue.lastIndexOf(EXPRESSION_END_CHAR)))
.append(PARAMS_START_CHAR)
.append(parameters)
.append(PARAMS_END_CHAR)
.append(EXPRESSION_END_CHAR);
}
attributeValue = sb.toString();
expressionResult = EngineEventUtils.computeAttributeExpression(context, tag, attributeName, attributeValue).execute(context);
}
}
}
structureHandler.setAttribute("href", HtmlEscape.escapeHtml4Xml(expressionResult == null ? null : expressionResult.toString()));
}
}
특별한 설명은 필요 없을 것 같고 처리는 아래의 순서로 진행했다.
- request에서 query string 얻어오기
- LinkExpression 검증
- LinkExpression에 파라미터 추가
- Expression 재생성
- attribute 설정
좀 해멨던 부분이 ITemplateContext context
에서 Request 객체를 얻어오기가 쉽지 않았는데 여기저기 뒤져보니
final RequestContext requestContext = (RequestContext)context.getVariable(SpringContextVariableNames.SPRING_REQUEST_CONTEXT);
처럼 Request 객체를 얻어와서 list에 넣어두고 신규로 들오어는 parameter가 있다면 대체하여 링크를 만들어 내는 로직을 추가했다.
별도의 expression을 만드는 것은 오바하는 것 같고 LinkExpression을 기본적으로 사용하는 것이 맞아 보여서 그렇게 구현을 했고,
표현식 자체가 멀티 파라미터를 @{link(param=1)(param=2)} 처럼 제공해주진 않기 때문에 )
여부만 파악하면 되므로 위와 같이 처리해보았다.
Dialect Engine에 등록
Spring boot 를 사용한다면 IProcessorDialect
클래스를 구현한 놈은 자동으로 등록해주기 때문에 별다른 설정 없어 Bean
을 추가해주면 된다.
@Configuration
public class ThymeleafConfig {
@Bean
public ExtraLinkDialect extraLinkDialect() {
return new ExtraLinkDialect("UTF-8");
}
}
혹은 SpringTemplateEngine
생성 시 addDialect로 추가해 주면된다.
@Bean
public SpringTemplateEngine templateEngine() {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setEnableSpringELCompiler(true);
templateEngine.setTemplateResolver(templateResolver());
templateEngine.setMessageSource(messageSource);
templateEngine.addDialect(new LayoutDialect());
templateEngine.addDialect(new SpringDataDialect());
templateEngine.addDialect(new ExtraLinkDialect("UTF-8"));
return templateEngine;
}
사용방법
사용 방법은 아래와 같다.
http://localhost:8080/users?pageNum=2&query=검색어&test=1%26encoding
게시판 목록조회 url을 이렇게 호출한 경우 상세조회 화면으로 가는 링크는
<a th:link="@{/users/__${user.userId}__}" th:text="${user.userId}">아이디</a>
처럼 th:link
를 사용했다. 그럼 실제 생성되는 link는
<a href="/users/hkwon?pageNum=2&query=%EA%B2%80%EC%83%89%EC%96%B4&test=1%26encoding">권혁</a>
처럼 생성이 된다. 상세조회 왔다가 다시 목록으로 오는 경우에 검색어 등 계속 끌고 다닐 파라미터가 있는 경우 사용하면 좋다.
페이징 처리시에도 pageNum 파라미터를 추가로 넘기면
<div th:fragment="pagination(page)">
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item" th:class="${page.getPrePage() == 0} ? 'disabled'">
<a class="page-link" th:link="@{''(pageNum=${page.getPrePage()})}" tabindex="-1" th:if="${page.getPrePage() > 0}">Previous</a>
</li>
<li class="page-item" th:each="i : ${#numbers.sequence(1, page.getPages())}" th:class="${page.getPageNum() == i} ? 'active'">
<a class="page-link" th:link="@{''(pageNum=${i})}" th:text="${i}">1</a>
</li>
<li class="page-item" th:class="!${page.isHasNextPage()} ? 'disabled'">
<a class="page-link" th:link="@{''(pageNum=${page.getNextPage()})}" th:if="${page.getNextPage() > 0}">Next</a>
</li>
</ul>
</nav>
</div>
중복되는 파라미터는 th:link="@{''(pageNum=${i})}"
표현식에 넣은 것 처럼 기존 것은 제외하고 생성되는걸 볼 수 있다.
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li>
<a class="page-link" href="?pageNum=1" tabindex="-1" )>Previous</a>
</li>
<li>
<a class="page-link" href="?pageNum=1&query=%EA%B2%80%EC%83%89%EC%96%B4&test=1%26encoding">1</a>
</li>
<li class="active">
<a class="page-link" href="?pageNum=2&query=%EA%B2%80%EC%83%89%EC%96%B4&test=1%26encoding">2</a>
</li>
<li>
<a class="page-link" href="?pageNum=3&query=%EA%B2%80%EC%83%89%EC%96%B4&test=1%26encoding">3</a>
</li>
<li>
<a class="page-link" href="?pageNum=4&query=%EA%B2%80%EC%83%89%EC%96%B4&test=1%26encoding">4</a>
</li>
<li>
<a class="page-link" href="?pageNum=3" )>Next</a>
</li>
</ul>
</nav>
확장에 굉장히 열려있는 엔진이기 때문에 필요한 기능들은 그때그때 만들어서 써도 무방할 것 같은 느낌이다.
전체 사용된 소스는 아래에서 확인할 수 있다.
https://github.com/hkwon77/thymeleaf-extra-link