使用来自数据库的 Qute 模板

简介

我是 Red Hat 团队的一员,该团队创建了一个多租户通知服务,用于发送来自多个 Red Hat Hybrid Cloud Console 应用程序(即租户)的通知。我们的服务可用于发送各种通知,包括电子邮件。每个租户都可以创建任意数量的电子邮件模板,并将其与触发通知的事件关联起来。

我们使用强大的 Qute 模板引擎 和存储在 src/main/resources/templates 文件夹中的模板来实现这一点。这使得我们的租户能够以最少的 Qute 知识来设计适合他们需求的模板。然而,我们很快意识到,编辑模板对租户来说是一个缓慢且繁重的过程。事实上,他们必须在我们的存储库中创建一个 GitHub 拉取请求,等待审核,然后再等待部署,之后才能测试模板。我们需要让租户更轻松地完成这个过程,最好是自助服务。

然后,我们决定使用 Qute 的 TemplateLocator 将模板从文件存储移动到数据库。这有助于我们为租户提供一种更轻松、无障碍且自助的方式来编辑模板。

before after

我们是这样做的。

显而易见的部分:将模板持久化到数据库

在将模板用于 Qute 之前,显然需要将模板持久化。这如何执行并不重要。任何形式的 Hibernate(响应式或非响应式,带 Panache 或不带 Panache)都可以正常工作。本文将展示基于 Hibernate with Panache 的示例。

接下来的章节将使用此 JPA 实体

package org.acme;

import io.quarkus.hibernate.orm.panache.PanacheEntityBase;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class DbTemplate extends PanacheEntityBase {

    @Id
    public String name; (1)

    public String content;
}
1 模板名称将是数据库主键。

有趣的部分:将 Qute 连接到数据库

现在模板可以持久化了,我们需要一种方法从 Qute 中使用它们。幸运的是,Qute 提供了一个非常有趣的接口,称为 TemplateLocator,可用于从任何位置加载模板,包括从数据库加载。

以下是如何将其与我们之前定义的 DbTemplate 实体一起使用

package org.acme;

import io.quarkus.logging.Log;
import io.quarkus.qute.EngineBuilder;
import io.quarkus.qute.TemplateLocator;
import io.quarkus.qute.Variant;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Observes;
import java.io.Reader;
import java.io.StringReader;
import java.util.Optional;

@ApplicationScoped
public class DbTemplateLocator implements TemplateLocator {

    @Override
    public Optional<TemplateLocation> locate(String name) {
        DbTemplate template = DbTemplate.findById(name);
        if (template == null) {
            Log.tracef("Template with [name=%s] not found in the database", name);
            return Optional.empty();
        } else {
            Log.tracef("Template with [name=%s] found in the database", name);
            return Optional.of(buildTemplateLocation(template.getContent()));
        }
    }

    @Override
    public int getPriority() { (1)
        return DEFAULT_PRIORITY - 1;
    }

    void configureEngine(@Observes EngineBuilder builder) { (2)
        builder.addLocator(this);
    }

    private TemplateLocation buildTemplateLocation(String templateContent) {
        return new TemplateLocation() {

            @Override
            public Reader read() {
                return new StringReader(templateContent);
            }

            @Override
            public Optional<Variant> getVariant() {
                return Optional.empty();
            }
        };
    }
}
1 如果您的 Quarkus 应用程序包含从文件系统和数据库加载的模板,您将需要覆盖模板定位器的默认优先级。否则,Quarkus 将尝试使用 DbTemplateLocator 从文件系统加载模板,这可能导致异常或不可预测的行为。
2 在 Quarkus 2.10 之前,将 DbTemplateLocator 与 Quarkus 提供的 Qute 引擎实例集成只能通过 CDI 观察者完成,如下所示。

Quarkus 2.10 最近引入了一个新的 @Locate 注解,使得 模板定位器注册 更加简单。

现在模板定位器已注册,我们可以使用 Qute 编译和渲染数据库中的模板。正如您在以下示例中所见,数据库模板的使用方式与文件模板完全相同

package org.acme;

import io.quarkus.qute.Engine;
import io.quarkus.qute.Template;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;

@ApplicationScoped
public class EmailSender {

    @Inject
    Engine engine;

    public void sendEmail(String templateName) {
        Template template = engine.getTemplate(templateName);
        if (template != null) {
            String rendered = template.render();
            // Send an email using the template.
        }
    }
}

注意 Qute 的内部缓存

每当 Qute 加载一个模板时,它都会存储在一个内部的 ConcurrentHashMap 中,并永远保留在内存中,除非 Qute 被指示另行处理。这意味着您需要在使用模板更新或删除数据库中的模板后,将其从 Qute 内部缓存中删除。

有几种方法可以实现这一点

package org.acme;

import io.quarkus.qute.Engine;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;

@ApplicationScoped
public class DbEngineCacheManager {

    @Inject
    Engine engine;

    public void removeTemplates(String name) {
        engine.removeTemplates(templateName -> templateName.equals(name)); (1)
    }

    public void clearAll() {
        engine.clearTemplates(); (2)
    }
}
1 这会删除映射 ID 与给定谓词匹配的模板。
2 这会从缓存中删除所有模板。

清除此内部缓存可能会变得棘手,特别是当您的应用程序运行在具有多个副本的 Kubernetes 集群中时。您确实需要一种方法将指令广播到所有 Pod(可能使用 Kafka 主题或数据库表),以从缓存中删除已更新或删除的模板。有一种更便宜(但很不完美)的方法可以保持所有 Pod 缓存同步,即使用计划任务

package org.acme;

import io.quarkus.qute.Engine;
import io.quarkus.scheduler.Scheduled;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;

@ApplicationScoped
public class DbEngineCacheScheduledCleaner {

    @Inject
    Engine engine;

    @Scheduled(every = "5m", delayed = "5m") (1)
    public void clearTemplates() {
        engine.clearTemplates();
    }
}
1 所有模板将在每 5 分钟后从内部缓存中清除。

防止删除包含的模板

Qute 模板可以包含在另一个模板中。如果内部模板被删除,那么外部模板的编译将失败,而这显然是在从数据库加载模板时需要避免的。

以下是在删除模板之前查找其是否被包含在另一个模板中的方法

package org.acme;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.transaction.Transactional;

@ApplicationScoped
public class TemplateRepository {

    @Inject
    EntityManager entityManager;

    @Transactional
    public void deleteTemplate(String name) {
        long count = entityManager.createQuery("SELECT COUNT(*) FROM DbTemplate WHERE name != :name AND content LIKE :include", Long.class)
                .setParameter("name", name)
                .setParameter("include", "%{#include " + name + "%")
                .getSingleResult();
        if (count > 0) {
            throw new IllegalStateException("Included templates can't be deleted, remove the inclusion or delete the outer template first");
        } else {
            entityManager.createQuery("DELETE FROM DbTemplate WHERE name = :name")
                    .setParameter("name", name)
                    .executeUpdate();
        }
    }
}

数据库模板验证

数据库模板有一个明显的缺点:Quarkus 不再能够执行 类型安全 验证。

语法验证也从构建时推迟到运行时,但这是意料之中的,因为模板可以在运行时创建或编辑。

特别感谢

感谢 Josejulio Martinez Magana 和 Martin Kouba 在我们的通知服务中实现数据库模板过程中提供的帮助!