插件通过 Hook Halo 框架添加自定义的 Dialect 实现访问 HTML 模版
在开发插件的时候想获取到整个 HTML 页面, 发现只能用 AdditionalWebFilter, 这已经进入了响应阶段. 我想在更早的时候比如渲染页面的时候获取整个 HTML 页面, Halo 没有开放这样的接口
虽然我不懂 Spring 和 Thymeleaf, 通过不断看代码和不断调试, 了解到可以在 Dialect 上下手, 于是就有了下面的玩具
原理解析
Thymeleaf 使用 TemplateEngine 来保存需要渲染的 HTML 模板, Dialect 以及各种配置, 并允许通过实现 IDialect 来扩展渲染流程
通过 IDialect 的子接口 IProcessorDialect 的 getProcessors 方法获取一组可自定义的 IProcessor, 在渲染的时候就会执行 IProcessor 的 doProcessor 方法
IProcessor 的子接口 IElementModelProcessor 可以提供访问正在渲染的 HTML 的能力, 于是可以通过这个接口来操作 HTML 干预其渲染
通过 Halo 内置的 TemplateHeadProcessor 等这些处理标签的处理器, 查找其在框架什么地方有引用, 可以直接找到是在 HaloProcessorDialect 中使用, 而这个类的实例会通过依赖注入注入到 TemplateEngineManager 中, 从而得知想要访问 Halo 中的整个 HTML 核心是拿到 TemplateEngineManager, 通过反射添加自定义的 Dialect
获取 TemplateEngineManager
首先是要获取到 TemplateEngineManager, 由于这个类在 application 模块, 不暴露给插件, 并且插件运行在独立的子容器中, 无法通过 ApplicationContext::getBean 获取到, 需要先获取到其父容器也就是框架的 context
在 Bean 中注入 ApplicationContext, 然后通过 getParent() 就可以拿到框架的 context
但是如果这样就会发现同样无法获取到这个 TemplateEngineManager
ApplicationContext root = applicationContext.getParent().getBean("templateEngineManager");
上面执行后会报错, 找不到这个 Bean
由于不了解其运行原理, 只能通过断点进去看到底父容器里都装了些什么
看到其中的 BeanFactory 中只有下面这些 Bean, 并没有目标

由于不知道原理, 只能翻翻看这些单例对象里面都有些什么
翻到 extensionGetter 的时候发现其中有一个 beanFactory, 并且里面有大量的 Bean, 看起来像是整个框架的 Bean 了

其中一些有趣的
Bean:
- systemEnvironment: 保存了 Halo 系统环境信息
- environment: 保存所有环境信息包括一些系统变量
- systemInfoGetter: 可以通过此 Bean 获取系统配置, 包括一些端口信息等
- systemProperties: 保存一些系统变量
- extensionGetter: 其
beanFactory中有非常多的 Bean, 应该是框架运行的全部了 (加上此context中的Bean)- cryptoService: JWK 以及一些加密相关的内容
- userDetailsService: 用户相关, 可以获取加密密码用的 Key
于是就可以尝试:
- 通过
ApplicationContext::getParent()获取框架的主容器 - 取出其中的
extensionGetter, 再通过其持有的beanFactory获取templateEngineManager
于是调用流程就是这样的: PluginBeanContext::getParent -> FramworkBeanContext::getBean -> extensionGetter::beanFactory::getBean -> TemplateEngineManager
ApplicationContext root = applicationContext.getParent();
Object extensionGetter = root.getBean("extensionGetter");
try {
Field beanFactoryField = extensionGetter.getClass().getDeclaredField("beanFactory");
beanFactoryField.setAccessible(true);
BeanFactory beanFactory = (BeanFactory) beanFactoryField.get(extensionGetter);
Object templateEngineManager = beanFactory.getBean("templateEngineManager");
} catch (Exception e) {
}
通过上面的代码就成功获取到了 templateEngineManager

实现 IProcessorDialect
需要实现的是 IProcessorDialect, 可以继承 AbstractProcessorDialect 来实现
public class TestProcessorDialect extends AbstractProcessorDialect {
private static final String DIALECT_NAME = "testProcessorDialect";
public TestProcessorDialect() {
super(DIALECT_NAME, "", StandardDialect.PROCESSOR_PRECEDENCE);
}
@Override
public Set<IProcessor> getProcessors(String dialectPrefix) {
Set<IProcessor> set = new HashSet<>();
// 添加处理器
set.add(new TestHTMLProcessor(dialectPrefix));
return set;
}
static class TestHTMLProcessor extends AbstractElementModelProcessor {
// 处理整个 HTML
private static final String TAG_NAME = "html";
// 优先级
private static final int PRECEDENCE = 1000;
public TestHTMLProcessor(String dialectPrefix) {
super(TemplateMode.HTML, dialectPrefix, TAG_NAME, false, null, false, PRECEDENCE);
}
@Override
protected void doProcess(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
// do something
}
}
}
注入 Dialect
Halo 是在 TemplateEngineManager 中添加 Dialect 的, 先贴一个 TemplateEngineManager 部分源码:
package run.halo.app.theme;
@Component
public class TemplateEngineManager {
private static final int CACHE_SIZE_LIMIT = 5;
private final ConcurrentLruCache<CacheKey, ISpringWebFluxTemplateEngine> engineCache;
private final ThymeleafProperties thymeleafProperties;
private final ExternalUrlSupplier externalUrlSupplier;
private final PluginManager pluginManager;
private final ObjectProvider<ITemplateResolver> templateResolvers;
private final ObjectProvider<IDialect> dialects;
private final ThemeResolver themeResolver;
public TemplateEngineManager(ThymeleafProperties thymeleafProperties,
ExternalUrlSupplier externalUrlSupplier,
PluginManager pluginManager, ObjectProvider<ITemplateResolver> templateResolvers,
ObjectProvider<IDialect> dialects, ThemeResolver themeResolver) {
...
this.dialects = dialects;
...
engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator);
}
public ISpringWebFluxTemplateEngine getTemplateEngine(ThemeContext theme) {
CacheKey cacheKey = buildCacheKey(theme);
return engineCache.get(cacheKey);
}
private record CacheKey(String name, boolean active, ThemeContext context) {
}
CacheKey buildCacheKey(ThemeContext context) {
return new CacheKey(context.getName(), context.isActive(), context);
}
private ISpringWebFluxTemplateEngine templateEngineGenerator(CacheKey cacheKey) {
var engine = new HaloTemplateEngine(new ThemeMessageResolver(cacheKey.context()));
...
engine.addDialect(new HaloProcessorDialect());
...
dialects.orderedStream().forEach(engine::addDialect);
return engine;
}
}
构造 CacheKey 需要一个 ThemeContext , 而 ThemeContext 上有一个注解 @EqualsAndHashCode(of = "name") 也就是说 engineCache 中保存的是每个主题对应的 TemplateEngine 缓存, 这个类也就是用于管理某个主题的模板引擎缓存的, 其中包括 HTML 模板以及各种配置
通过上面的 engine.addDialect(new HaloProcessorDialect()); 可以看到在这一步把 HaloProcessorDialect 放到了 HaloTemplateEngine 中, 而 HaloProcessorDialect 通过 getProcessors 返回了处理器用于渲染页面
package run.halo.app.theme.dialect;
public class HaloProcessorDialect extends AbstractProcessorDialect
implements IExpressionObjectDialect, IPostProcessorDialect {
...
@Override
public Set<IProcessor> getProcessors(String dialectPrefix) {
final Set<IProcessor> processors = new HashSet<IProcessor>();
// add more processors
processors.add(new GlobalHeadInjectionProcessor(dialectPrefix));
processors.add(new TemplateFooterElementTagProcessor(dialectPrefix));
processors.add(new EvaluationContextEnhancer());
processors.add(new CommentElementTagProcessor(dialectPrefix));
processors.add(new CommentEnabledVariableProcessor());
processors.add(new InjectionExcluderProcessor());
return processors;
}
...
}
将模板缓存清空之后在, 通过断点调试可以得知执行顺序是这样的, 在主题安装或重载之后第一次访问页面之后: TemplateEngineManager::templateEngineGenerator -> HaloTemplateEngine::new 为主题生成了其模板引擎缓存在, 然后 ThymeleafReactiveView::render -> TemplateEngine::getConfiguration -> TemplateEngine::initialize -> IProcessorDialect::getProcessors
也就是说会在第一次渲染页面时去初始化 TemplateEngine, 而 getProcessors 只会在初始化的时候会调用, 用于配置 TemplateEngine
由于手动注入的 Dialect 时机肯定要在页面初始化之后, 于是就必须在注入之后手动初始化一遍, 否则就不会调用自定义的 IProcessorDialect 的 getProcessors 方法, IProcessor 也就不会被配置进 TemplateEngine 从而不会执行其中 doProcessor
所以现在的 hook 思路就是: 反射出 engineCache , 将遍历到的所有 TemplateEngine 都调用 engine.addDialect() 将实现的 IProcessorDialect 放进 TemplateEngine 中
要注意这里实际上不能直接调用 addDialect, 这个方法会判断 TemplateEngine 是否初始化, 如果已经初始化的话会抛错, 所以需要设置为 false 之后手动初始化一遍
public void addDialect(final IDialect dialect) {
Validate.notNull(dialect, "Dialect cannot be null");
checkNotInitialized();
this.dialectConfigurations.add(new DialectConfiguration(dialect));
}
可以把 hook 逻辑放在插件主类的 start 方法中
完整的逻辑:
@Slf4j
@Component
public class TestPlugin extends BasePlugin {
private final ApplicationContext applicationContext;
public TestPlugin(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void start() {
ApplicationContext root =
applicationContext.getParent();
Object extensionGetter = root.getBean("extensionGetter");
try {
Field beanFactoryField = extensionGetter.getClass().getDeclaredField("beanFactory");
beanFactoryField.setAccessible(true);
BeanFactory beanFactory = (BeanFactory) beanFactoryField.get(extensionGetter);
Object templateEngineManager = beanFactory.getBean("templateEngineManager");
Class<?> aClass = templateEngineManager.getClass();
Field engineCacheField = aClass.getDeclaredField("engineCache");
engineCacheField.setAccessible(true);
ConcurrentLruCache engineCache =
(ConcurrentLruCache) engineCacheField.get(templateEngineManager);
Field cacheField = engineCache.getClass().getDeclaredField("cache");
cacheField.setAccessible(true);
ConcurrentMap map = (ConcurrentMap) cacheField.get(engineCache);
map.values().forEach(value -> {
TemplateEngine templateEngine;
try {
Method valueMethod;
valueMethod = value.getClass().getDeclaredMethod("getValue");
valueMethod.setAccessible(true);
templateEngine = (TemplateEngine) valueMethod.invoke(value);
Field initialized =
templateEngine.getClass().getSuperclass().getSuperclass().getSuperclass()
.getDeclaredField("initialized");
initialized.setAccessible(true);
initialized.set(templateEngine, false);
templateEngine.addDialect(new TestProcessorDialect());
Method initialize =
templateEngine.getClass().getSuperclass().getSuperclass().getSuperclass()
.getDeclaredMethod("initialize");
initialize.setAccessible(true);
initialize.invoke(templateEngine);
} catch (NoSuchMethodException | InvocationTargetException |
IllegalAccessException | NoSuchFieldException e) {
}
});
} catch (NoSuchFieldException | IllegalAccessException e) {
}
}
@Override
public void stop() {
// TODO: 清理注入的对象
}
}
观察断点发现成功调用了实现的 TestHTMLProcessor 中的 doProcessor 方法
重构
写到这里我发现之前遗漏了一些东西, 就是: engineCache 是一个 ConcurrentLruCache, 其赋值是: engineCache = new ConcurrentLruCache<>(CACHE_SIZE_LIMIT, this::templateEngineGenerator)
查看 ConcurrentLruCache的 get 方法得知, 在其中没有缓存的时候会通过其 generator 也就是传入的 this::templateEngineGenerator 来构造对象并缓存
既然构造对象的方法是以参数的形式构造进去的, 那实际上就可以直接干预生成 TemplateEngine 的方法, 也就是 hook 掉 templateEngineGenerator, 将 ConcurrentLruCache 中的 generator 字段替换成一个自己的 Function 实现就可以, 这样替换完也不需要再去手动调用 TemplateEngine::initialize 了
@Slf4j
@Component
public class TestPlugin extends BasePlugin {
private final ApplicationContext applicationContext;
public TestPlugin(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void start() {
try {
Object extensionGetter = getExtensionGetter();
BeanFactory beanFactory = getBeanFactory(extensionGetter);
Object templateEngineManager = beanFactory.getBean("templateEngineManager");
ConcurrentLruCache<?, ?> engineCache = getEngineCache(templateEngineManager);
injectGenerator(engineCache, templateEngineManager);
log.info("TestPlugin: template engine dialect injection completed.");
} catch (Exception e) {
log.error("TestPlugin: failed to inject template engine dialect.", e);
throw new IllegalStateException("Dialect injection failure", e);
}
}
@Override
public void stop() {
// TODO: restore original generator if needed
log.info("TestPlugin stopped. (restore logic not implemented)");
}
private Object getExtensionGetter() {
ApplicationContext root = applicationContext.getParent();
if (root == null) {
throw new IllegalStateException("Root ApplicationContext is null.");
}
return root.getBean("extensionGetter");
}
private BeanFactory getBeanFactory(Object extensionGetter)
throws NoSuchFieldException, IllegalAccessException {
Field field = extensionGetter.getClass().getDeclaredField("beanFactory");
field.setAccessible(true);
Object result = field.get(extensionGetter);
if (!(result instanceof BeanFactory)) {
throw new IllegalStateException("beanFactory field is not a BeanFactory");
}
return (BeanFactory) result;
}
private ConcurrentLruCache<?, ?> getEngineCache(Object templateEngineManager)
throws NoSuchFieldException, IllegalAccessException {
Field field = templateEngineManager.getClass().getDeclaredField("engineCache");
field.setAccessible(true);
Object cache = field.get(templateEngineManager);
if (!(cache instanceof ConcurrentLruCache<?, ?>)) {
throw new IllegalStateException("engineCache is not a ConcurrentLruCache");
}
return (ConcurrentLruCache<?, ?>) cache;
}
@SuppressWarnings({"rawtypes"})
private void injectGenerator(
ConcurrentLruCache engineCache,
Object templateEngineManager
) throws NoSuchFieldException, IllegalAccessException {
Field generatorField = engineCache.getClass().getDeclaredField("generator");
generatorField.setAccessible(true);
Function<Object, ISpringWebFluxTemplateEngine> newGenerator = key -> {
TemplateEngine templateEngine = generateEngine(templateEngineManager, key);
if (templateEngine == null) {
return null;
}
try {
templateEngine.addDialect(new TestProcessorDialect());
} catch (Exception ex) {
log.error("Failed to add TestDialect.", ex);
}
return (ISpringWebFluxTemplateEngine) templateEngine;
};
generatorField.set(engineCache, newGenerator);
}
private TemplateEngine generateEngine(Object templateEngineManager, Object key) {
try {
Method method = templateEngineManager.getClass()
.getDeclaredMethod("templateEngineGenerator", key.getClass());
method.setAccessible(true);
return (TemplateEngine) method.invoke(templateEngineManager, key);
} catch (Exception e) {
log.error("Failed to generate TemplateEngine.", e);
return null;
}
}
}
在插件启动的时候就会尝试 hook, 最终在第一次访问页面的时候就会触发自己自定义的 IProcessorDialect

Comments