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를 만들어서 처리해보기로 했다.

관련 레퍼런스(Tutorial: Extending Thymeleaf)와 기존의 확장기능들 소스를 둘러보니 그리 어렵지 않게 기능을 확장할 수 있다.

Dialects and Processors

Thymeleaf Dialects란 새롭게 만들 템플릿의 기능 집합이라고 보면 될 것 같다. Dialect는 다수의 ProcessorsExpression을 가질 수 있다.

여기서 얘기하는 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.

만드려는 확장의 기능에 맞게 적절한 인터페이스를 선택하면 된다.

ProcessorsDialect와 마찬가지로 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&amp;query=%EA%B2%80%EC%83%89%EC%96%B4&amp;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&amp;query=%EA%B2%80%EC%83%89%EC%96%B4&amp;test=1%26encoding">1</a>
    </li>
    <li class="active">
      <a class="page-link" href="?pageNum=2&amp;query=%EA%B2%80%EC%83%89%EC%96%B4&amp;test=1%26encoding">2</a>
    </li>
    <li>
      <a class="page-link" href="?pageNum=3&amp;query=%EA%B2%80%EC%83%89%EC%96%B4&amp;test=1%26encoding">3</a>
    </li>
    <li>
      <a class="page-link" href="?pageNum=4&amp;query=%EA%B2%80%EC%83%89%EC%96%B4&amp;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

참조