thymeleaf的简单使用

博客的页面渲染逻辑在https://www.qyh.me/space/java/article/blog5-page-data-fragement这里已经介绍过了,不过当时对Thymeleaf了解的比较少,所以这里再次说下Thymeleaf的一些简单用法

Thymeleaf 是一个模板渲染引擎,这个博客的所有页面都是通过它来渲染的,Thymeleaf自身提供了对Spring的支持(ThymeleafView),因此配置非常简单,就跟配置jsp的view一样。

<bean id="templateResolver"
       class="org.thymeleaf.templateresolver.SpringResourceTemplateResolver">
  <property name="prefix" value="/WEB-INF/templates/" />
  <property name="suffix" value=".html" />
  <property name="templateMode" value="HTML" />
  <property name="cacheable" value="true" />
</bean>
<bean id="templateEngine"
      class="org.thymeleaf.spring4.SpringTemplateEngine">
  <property name="templateResolver" ref="templateResolver" />
</bean>

配置完成之后稍微了解下thymeleaf的语法就可以开始尝试渲染页面了,但是我的博客需要利用它来动态渲染保存在数据库中的页面,因此要多一些额外的操作:

从数据库中获取页面并渲染

在上面的配置中,可以在SpringTemplateEngine的属性中发现templateResolver这个属性,它的默认实现是SpringResourceTemplateResolver,具体作用就是用来获取页面资源,查看它源码可以发现它是通过 ApplicationContext.get(resourceName)来寻找模板资源的,最终返回一个ServletContextResource资源(/WEB-INF/templates/{resourceName}.html)。通过对它有一定的了解之后就可以利用它来获取数据库中保存的模板,例如:


public class DBResourceResolver extends SpringResourceTemplateResolver {

	@Override
	protected String computeResourceName(IEngineConfiguration configuration, String ownerTemplate, String template,
			String prefix, String suffix, Map<String, String> templateAliases,
			Map<String, Object> templateResolutionAttributes) {
		//这里的template是不包含前缀和后缀的
        //如果controller中返回了index那么template就是index
        //super.computeResourceName()将会返回/WEB-INF/templates/{template}.html
        //如果这里可以对template增加一些额外的标记来判断是否是数据库资源,比如template=databaseResource:resourceId
		return super.computeResourceName(configuration, ownerTemplate, template, prefix, suffix, templateAliases,
				templateResolutionAttributes);
	}

	@Override
	protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, String ownerTemplate,
			String template, String resourceName, String characterEncoding,
			Map<String, Object> templateResolutionAttributes) {
        //根据模板名寻找模板资源
        //判断是否是数据库模板资源,比如:template.startWith('databaseResource:')
		if(isDatabaseResource(template)){
        //如果是数据库资源,根据模板名查询模板数据之后返回自定义的ITemplateResource
        	return DatabaseResource();
        }
        //否则返回ServletContextResource
		return super.computeTemplateResource(configuration, ownerTemplate, template, resourceName, characterEncoding,
				templateResolutionAttributes);
	}
}

利用DBResourceResolver替换templateResovler,即完成了一个简单的数据库模板资源的查找和渲染。

获取Thymeleaf解析的内容

为了更加严格的控制模板的渲染,准备先获取Thymleaf的渲染结果,然后再将结果输出到页面中去,要实现这个也很简单。 用过spring mvc都知道view就是一个视图,通过ViewResolver来解析视图,InternalResourceViewResolver这个就是很常用的jsp,jstl的视图解析器,Thymeleaf也提供了一个ThymeleafViewResolver,配置也非常简单:

<bean class="org.thymeleaf.spring4.view.ThymeleafViewResolver">
  <property name="templateEngine" ref="templateEngine" />
  <!-- NOTE 'order' and 'viewNames' are optional -->
  <property name="order" value="1" />
  <property name="viewNames" value="*.html,*.xhtml" />
</bean>

ThymeleafViewResolver可以解析出一个ThymeleafView,利用ThymeleafView的renderFragment方法,便可以达到目的。至于这里为什么不直接通过TemplateEngine来解析是因为ThymeleafView在解析模板之前会生成一个相当复杂 的IContext对象:

 protected void renderFragment(final Set<String> markupSelectorsToRender, final Map<String, ?> model, final HttpServletRequest request,
            final HttpServletResponse response)
            throws Exception {

        //IContext构造等逻辑操作

        response.setLocale(templateLocale);
        if (templateContentType != null) {
            response.setContentType(templateContentType);
        } else {
            response.setContentType(DEFAULT_CONTENT_TYPE);
        }
        if (templateCharacterEncoding != null) {
            response.setCharacterEncoding(templateCharacterEncoding);
        }

        viewTemplateEngine.process(templateName, processMarkupSelectors, context, response.getWriter());

    }

这里可以看到renderFragment最终用于输出的实际上是HttpServletResponse的writer对象,为了不直接输出,可以利用HttpServletResonseWrapper来替代HttpServletResponse:

private static final class ResponseWrapper extends HttpServletResponseWrapper {

		private FastStringWriter writer = new FastStringWriter(100);

		public ResponseWrapper(HttpServletResponse response) {
			super(response);
		}

		@Override
		public PrintWriter getWriter() throws IOException {
			return new PrintWriter(writer);
		}

		public String getRendered() {
			return writer.toString();
		}
	}

这样通过wrapper的getRendered()方法便可以获取到模板解析后的内容,至于为什么要先获取内容后再输出而不是边解析边输出,这和我的逻辑有关,很多时候我需要预览页面并给出一些错误提示,如果直接输出的话很难办到。

错误日志的关闭

有些时候,我并不需要Thymleaf自己的错误日志记录,比如在我页面预览的时候,查看Thymleaf的日志纪录器可以看到它是通过slf4j来纪录日志的,刚好我日志也是采用的logback,所以只需要在配置中禁用TemplateEngine的日志记录即可:

<logger name="org.thymeleaf.TemplateEngine" level="OFF" />

同时由于采用了先解析后输出的方式,所以可以在controller层中记录日志。 非常重要的一点,Thymleaf渲染过程中发生的任何异常都会被包装成ThymleafProcessException!!!,如果你在解析过程中抛出了自己的业务异常,那么应该通过org.apache.commons.lang3.exception.ExceptionUtils.indexOfThrowable()或者类似的方法来判断

自定义标签处理器

Thymeleaf提供了丰富的IProcessor来处理模板数据,其中IElementTagProcessor可以用来处理对应的标签,一般都是直接继承AbstractElementTagProcessor,它提供了一个

protected final void doProcess(ITemplateContext context, IProcessableElementTag tag,
   		IElementTagStructureHandler structureHandler) 

方法来处理标签数据,另外文档中明确提到了:

The IProcessableElementTag object argument is immutable, so all modifications to this object or any instructions to be given to the engine should be done through the specified IElementTagStructureHandler handler.

也就是说标签的内容只能通过IElementTagStructureHandler来进行更改,几个解析过程中可能用到的方法:

1.通过SpringContextUtils.getApplicationContext(ITemplateContext)可以获取到ApplicationContext

2.通过context.getModelFactory().parse(ITemplateContext.getTemplateData(), “template to parse”)方法可以用来解析一些字符串文本

3.通过ITemplateContext.getConfiguration().getTemplateManager().parseAndProcess(new TemplateSpec(templateName, null, TemplateMode.HTML, null), context, writer)可以通过模板名来解析另一个模板(如果允许的话会被缓存)

4.在Web环境下,ITemplateContext可以转化为IWebContext:IWebContext webContext = (IWebContext) context;通过这个webContext可以获取request和response等对象,通过request.setAttribute()可以向页面追加一些额外的对象,虽然我觉得很low

5.默认情况下标签处理器内的属性是不会被动态解析的,例如<fragment th:name="{toParse}"/>th:name并不会被解析(可能我没有发现可以用来解析的方法),<div th:name="{toParse}"/>则会被解析,因为这里是fragment的标签处理器, 如果需要处理这些属性,可以观察下StandardDefaultAttributesTagProcessor这个默认的属性处理器,它的优先级是最最低的,这意味着它不会在我们的标签处理器之前执行,同时它也没有对外提供解析的方法,如果需要,只能拷贝它的 处理方法,值得注意的是,它默认处理是忽略属性名大小写的,比如dataName会被设置为dataname,这是TemplateMode决定的!!下面是我使用的保持大小写的方法(依旧采用th:前缀),作用是将解析后的属性名和属性值放入一个map中。

  private void processDefaultAttribute(final ITemplateContext context, final IProcessableElementTag tag,
  		final IAttribute attribute, Map<String, String> attMap) {

  	try {
  		final String attributeValue = EscapedAttributeUtils.unescapeAttribute(context.getTemplateMode(),
  				attribute.getValue());

  		final String newAttributeName = attribute.getAttributeCompleteName().substring(3);

  		if (newAttributeName.trim().isEmpty()) {
  			return;
  		}
  		final IStandardExpressionParser expressionParser = StandardExpressions
  				.getExpressionParser(context.getConfiguration());
  		final Object expressionResult;
  		if (attributeValue != null) {

  			final IStandardExpression expression = expressionParser.parseExpression(context, attributeValue);
  			if (expression != null && expression instanceof FragmentExpression) {
  				final FragmentExpression.ExecutedFragmentExpression executedFragmentExpression = FragmentExpression
  						.createExecutedFragmentExpression(context, (FragmentExpression) expression,
  								StandardExpressionExecutionContext.NORMAL);

  				expressionResult = FragmentExpression.resolveExecutedFragmentExpression(context,
  						executedFragmentExpression, true);

  			} else {

  				expressionResult = expression.execute(context);

  			}

  		} else {
  			expressionResult = null;
  		}
  		if (expressionResult == NoOpToken.VALUE) {
  			return;
  		}
  		final String newAttributeValue = HtmlEscape
  				.escapeHtml4Xml(expressionResult == null ? null : expressionResult.toString());
  		if (newAttributeValue == null || newAttributeValue.length() == 0) {
  			return;
  		} else {
  			attMap.put(newAttributeName, newAttributeValue == null ? "" : newAttributeValue);
  		}

  	} catch (final TemplateProcessingException e) {
  		if (!e.hasTemplateName()) {
  			e.setTemplateName(tag.getTemplateName());
  		}
  		if (!e.hasLineAndCol()) {
  			e.setLineAndCol(attribute.getLine(), attribute.getCol());
  		}
  		throw e;
  	} catch (final Exception e) {
  		throw new TemplateProcessingException("Error during execution of processor '"
  				+ StandardDefaultAttributesTagProcessor.class.getName() + "'", tag.getTemplateName(),
  				attribute.getLine(), attribute.getCol(), e);
  	}

  }

自定义processor的注册:Thymeleaf有一个setIDialet方法,可以用这个方法实现自定义标签处理器的注册,例如:

public class PageDialect extends AbstractProcessorDialect {

   private static final String DIALECT_NAME = "Page Dialect";

   public PageDialect() {
   	super(DIALECT_NAME, "page", StandardDialect.PROCESSOR_PRECEDENCE);
   }

   public Set<IProcessor> getProcessors(final String dialectPrefix) {
   	return ImmutableSet.of(new DataTagProcessor(dialectPrefix), new FragmentTagProcessor(dialectPrefix),
   			new LockTagProcessor(dialectPrefix));
   }
}

public class UITemplateEngine extends SpringTemplateEngine {

   public UITemplateEngine() {
   	super();
   	addDialect(new PageDialect());
   }
}

更多的IProcessor使用请参考:Extending Thymeleaf

当然在自定义标签上花这么多时间是有原因的,自定义标签才是我页面模板的根本,比如我有一个<data name=“文章列表” th:currentPage="${param[‘currentPage’]}"/>的标签,通过标签解析器,便可以将文章列表这个数据输出到页面中, 通过data标签的属性,更是可以非常灵活的指定查询的参数。

渲染前检查

如果你是通过数据库加载模板的,假设模板名为databaseResource:1,那么同样的,我只需要在页面中通过<div th:replace=“databaseResource:1”></div>这个片段即可渲染这个页面,这非常的灵活,但有时候也会比带来麻烦, 如果只希望这个模板资源在页面渲染过程中只加载一次,我们可以利用IPreProcessor来达到这个目的,IPreProcessor是Thymleaf3.0版本新引入的功能,具体可看:New Pre-Processor and Post-Processor APIs

IPreProcessor处理器和其他处理器一样,使用非常简单,不过它要求提供一个ITemplateHandler(通常通过AbstractTemplateHandler来实现),利用这个处理器就可以达到上述目的,例如,我不希望某一个页面资源在渲染页面的过程中被引入:

public final class PageCheckTemplateHandler extends AbstractTemplateHandler {
  public PageCheckTemplateHandler() {
  	super();
  }

  @Override
  public void setContext(ITemplateContext context) {
  	String template = context.getTemplateData().getTemplate();
  	if (TemplateUtils.isPageTemplate(template)) {
  		if (ParseContext.isStart()) {
  			throw new TemplateProcessingException("无法再次处理页面");
  		}
  		ParseContext.start();
  	}
  	super.setContext(context);
  }

}

ParseContext是一个ThreadLocal<Boolean>类似的解析上下文,当然这里存在一些逻辑漏洞。

ITemplateHandler的实例每次都会通过反射新建!

thymeleaf 限制模式

更新了thymeleaf到3.0.9版本之后发现浏览某些页面的时候,会爆出如下异常:

org.thymeleaf.exceptions.TemplateProcessingException: Access to variable "param" is forbidden in this context. Note some restrictions apply to variable access. For example, direct access to request parameters is forbidden in preprocessing and unescaped expressions, in TEXT template mode, in fragment insertion specifications and in some specific attribute processors.

看了下它的更新日志之后才发现,它一直都有一个restricted expression evaluation mode,这次更新影响到了src,href以及attr等属性,以前版本只对th:utext,th:replace,th:include等做限制(具体请见 thymeleaf #683),这些限制意味着我得去一个页面一个页面的去找,去改,于是打算一不做二不休,直接取消它这个特性,而且感觉它这个特性也不是很有用吗,就像下面两个

<div th:with="text=${param.text}">
  <h2 th:utext="${text}"></h2>
</div>

<h2 th:utext="${param.text}"></h2>

除了第二个会报异常之外,没觉得有什么不一样。 对错误进行了一番跟踪之后,解决方案如下:

private final class FuckRestrictedThymeleafEvaluationContext implements IThymeleafEvaluationContext {

      private final IThymeleafEvaluationContext evaluationContext;

      public FuckRestrictedThymeleafEvaluationContext(IThymeleafEvaluationContext context) {
          super();
          this.evaluationContext = context;
      }

      @Override
      public TypedValue getRootObject() {
          return evaluationContext.getRootObject();
      }

      @Override
      public List<ConstructorResolver> getConstructorResolvers() {
          return evaluationContext.getConstructorResolvers();
      }

      @Override
      public List<MethodResolver> getMethodResolvers() {
          return evaluationContext.getMethodResolvers();
      }

      @Override
      public List<PropertyAccessor> getPropertyAccessors() {
          return evaluationContext.getPropertyAccessors();
      }

      @Override
      public TypeLocator getTypeLocator() {
          return evaluationContext.getTypeLocator();
      }

      @Override
      public TypeConverter getTypeConverter() {
          return evaluationContext.getTypeConverter();
      }

      @Override
      public TypeComparator getTypeComparator() {
          return evaluationContext.getTypeComparator();
      }

      @Override
      public OperatorOverloader getOperatorOverloader() {
          return evaluationContext.getOperatorOverloader();
      }

      @Override
      public BeanResolver getBeanResolver() {
          return evaluationContext.getBeanResolver();
      }

      @Override
      public void setVariable(String name, Object value) {
          evaluationContext.setVariable(name, value);
      }

      @Override
      public Object lookupVariable(String name) {
          return evaluationContext.lookupVariable(name);
      }

      @Override
      public final boolean isVariableAccessRestricted() {
          // FUCK!!!
          return false;
      }

      @Override
      public void setVariableAccessRestricted(boolean restricted) {
          // do nothing
      }

      @Override
      public IExpressionObjects getExpressionObjects() {
          return evaluationContext.getExpressionObjects();
      }

      @Override
      public void setExpressionObjects(IExpressionObjects expressionObjects) {
          evaluationContext.setExpressionObjects(expressionObjects);
      }
  }

}

package me.qyh.fr;

import java.util.List;
import java.util.Map;

import org.springframework.expression.BeanResolver;
import org.springframework.expression.ConstructorResolver;
import org.springframework.expression.MethodResolver;
import org.springframework.expression.OperatorOverloader;
import org.springframework.expression.PropertyAccessor;
import org.springframework.expression.TypeComparator;
import org.springframework.expression.TypeConverter;
import org.springframework.expression.TypeLocator;
import org.springframework.expression.TypedValue;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.context.IContext;
import org.thymeleaf.context.IEngineContext;
import org.thymeleaf.context.IEngineContextFactory;
import org.thymeleaf.context.StandardEngineContextFactory;
import org.thymeleaf.engine.TemplateData;
import org.thymeleaf.expression.IExpressionObjects;
import org.thymeleaf.spring5.expression.IThymeleafEvaluationContext;
import org.thymeleaf.spring5.expression.ThymeleafEvaluationContext;

public class FuckRestrictedContextFactory implements IEngineContextFactory {

	private final StandardEngineContextFactory factory = new StandardEngineContextFactory();

	@Override
	public IEngineContext createEngineContext(IEngineConfiguration configuration, TemplateData templateData,
			Map<String, Object> templateResolutionAttributes, IContext context) {
		IEngineContext engineCtx = factory.createEngineContext(configuration, templateData,
				templateResolutionAttributes, context);
		IThymeleafEvaluationContext evaluationContext;
		if (engineCtx.containsVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME)) {
			evaluationContext = (IThymeleafEvaluationContext) engineCtx
					.getVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME);
			engineCtx.setVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
					new FuckRestrictedThymeleafEvaluationContext(evaluationContext));
		} else {
			throw new IllegalStateException();
		}
		return engineCtx;
	}
}
<bean id="templateEngine" class="org.thymeleaf.spring5.SpringTemplateEngine">
  <property name="templateResolver" ref="templateResolver" />
  <property name="enableSpringELCompiler" value="true" />
  <property name="engineContextFactory">
      <bean class="me.qyh.fr.FuckRestrictedContextFactory" />
  </property>
</bean>

去除3.0.10表达式限制

3.0.10版本对表达式做了进一步的限制,某些表达式执行的时候会爆出如下异常:

Only variable expressions returning numbers or booleans are allowed in this context, any other datatypes are not trusted in the context of this expression, including Strings or any other object that could be rendered as a text literal. A typical case is HTML attributes for event handlers (e.g. “onload”), in which textual data from variables should better be output to “data-*” attributes and then read from the event handler.

thymeleaf本身并没有直接提供去除这个限制的功能,如果需要去除,需要在StandardExpressionExecutionContext这个类上动手脚,看它的源码之后可以发现,它有三种不同的处理模式:

  public static final StandardExpressionExecutionContext RESTRICTED =
            new StandardExpressionExecutionContext(true, false,  false);
    public static final StandardExpressionExecutionContext RESTRICTED_FORBID_UNSAFE_EXP_RESULTS =
            new StandardExpressionExecutionContext(true, true,  false);
    public static final StandardExpressionExecutionContext NORMAL =
            new StandardExpressionExecutionContext(false, false, false);

    private static final StandardExpressionExecutionContext RESTRICTED_WITH_TYPE_CONVERSION =
            new StandardExpressionExecutionContext(true, false,true);
    private static final StandardExpressionExecutionContext RESTRICTED_FORBID_UNSAFE_EXP_RESULTS_WITH_TYPE_CONVERSION =
            new StandardExpressionExecutionContext(true, true,  true);
    private static final StandardExpressionExecutionContext NORMAL_WITH_TYPE_CONVERSION =
            new StandardExpressionExecutionContext(false, false, true);

但它们都是 static final的,但好在,它们还是可以被修改(https://stackoverflow.com/questions/3301635/change-private-static-final-field-using-java-reflection)
如果不是十分介意的话,可以通过如下代码将其他模式都改为普通模式:

static{
  setFinalStatic(StandardExpressionExecutionContext.class.getDeclaredField("RESTRICTED_FORBID_UNSAFE_EXP_RESULTS"),
          StandardExpressionExecutionContext.NORMAL);
  setFinalStatic(StandardExpressionExecutionContext.class.getDeclaredField("RESTRICTED"),
          StandardExpressionExecutionContext.NORMAL);

  Field field = StandardExpressionExecutionContext.class.getDeclaredField("NORMAL_WITH_TYPE_CONVERSION");
  field.setAccessible(true);
  Object normalWithTypeConversion = field.get(null);
  setFinalStatic(StandardExpressionExecutionContext.class.getDeclaredField("RESTRICTED_WITH_TYPE_CONVERSION"),
          normalWithTypeConversion);
  setFinalStatic(StandardExpressionExecutionContext.class.getDeclaredField(
          "RESTRICTED_FORBID_UNSAFE_EXP_RESULTS_WITH_TYPE_CONVERSION"), normalWithTypeConversion);
}
        
static void setFinalStatic(Field field, Object newValue) throws Exception {
    field.setAccessible(true);

    Field modifiersField = Field.class.getDeclaredField("modifiers");
    modifiersField.setAccessible(true);
    modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

    field.set(null, newValue);
 }        

否则,还可以通过如下方法修改:

  1. 在项目中新建一个名称为org.thymeleaf.standard.expression的包
  2. 创建一个和StandardExpressionExecutionContext同名的类
  3. 拷贝原StandardExpressionExecutionContext代码到同名类
  4. 将严格模式指向普通模式:
  public static final StandardExpressionExecutionContext NORMAL = new StandardExpressionExecutionContext(false, false,
          false);
  public static final StandardExpressionExecutionContext RESTRICTED = NORMAL;
  public static final StandardExpressionExecutionContext RESTRICTED_FORBID_UNSAFE_EXP_RESULTS = NORMAL;

  private static final StandardExpressionExecutionContext NORMAL_WITH_TYPE_CONVERSION = new StandardExpressionExecutionContext(
          false, false, true);
  private static final StandardExpressionExecutionContext RESTRICTED_WITH_TYPE_CONVERSION = NORMAL_WITH_TYPE_CONVERSION;
  private static final StandardExpressionExecutionContext RESTRICTED_FORBID_UNSAFE_EXP_RESULTS_WITH_TYPE_CONVERSION = NORMAL_WITH_TYPE_CONVERSION;

看起来也许更好

更多的请参考:https://github.com/mhlx/mblog/tree/master/src/main/java/me/qyh/blog/template/render/thymeleaf