乐于分享
好东西不私藏

Spring Boot 模板引擎混搭实战:多引擎共存架构设计与生产落地

Spring Boot 模板引擎混搭实战:多引擎共存架构设计与生产落地

Spring Boot 模板引擎混搭实战:多引擎共存架构设计与生产落地

关键词:Spring Boot、Thymeleaf、FreeMarker、ViewResolver、灰度迁移、模板治理、可观测性、Kubernetes


一、为什么“多模板引擎共存”会成为生产问题

在很多团队的想象里,模板引擎只是一个页面渲染组件,选 Thymeleaf 还是 FreeMarker,往往被视为“开发习惯”问题。但一旦系统进入持续演进阶段,模板引擎就不再只是语法偏好的选择,而会变成一次真实的架构治理问题。

典型场景并不少见:

  1. 1. 老系统长期使用 FreeMarker,新团队希望用 Thymeleaf 开发新的运营页面。
  2. 2. 同一个单体应用仍承担后台、运营、营销活动等多类页面,模板风格与协作方式不同。
  3. 3. 存量页面不能一次性推倒重写,只能按模块、按租户、按流量逐步迁移。
  4. 4. 页面渲染出错会直接暴露给用户,模板切换必须可灰度、可回滚、可定位。

很多系统在这一步会踩到三个常见误区:

  1. 1. 误把“多引擎共存”理解成“引几个 starter 就行”。
  2. 2. 误把“能渲染”理解成“能在生产里稳定迁移”。
  3. 3. 误把“ViewResolver 顺序配置正确”理解成“迁移治理已经完成”。

真正的生产难点并不在于两个引擎同时存在,而在于下面这些问题如何被系统化解决:

  1. 1. 同一个应用内如何稳定路由不同模板引擎。
  2. 2. 如何避免错误模板被错误解析,产生静默兜底。
  3. 3. 如何做按模块、按租户、按灰度批次的迁移。
  4. 4. 如何在高并发下控制模板缓存、首屏延迟、预热与回退。
  5. 5. 如何把模板引擎治理纳入发布、监控、告警和故障演练体系。

所以,这篇文章不讨论“模板语法哪个好”,而讨论一个更接近生产的问题:

当你必须让多套模板引擎在 Spring Boot 中长期共存时,怎样把它做成一个可迁移、可扩展、可治理的工程方案。


二、真实业务背景:不是为了炫技,而是为了平滑迁移

先看一个更真实的业务链路。

某零售平台后台系统长期使用 FreeMarker,承担订单、履约、库存、财务等核心页面。后来增长团队介入,需要快速迭代活动页、招商页、运营编排页,希望使用更接近原生 HTML 的 Thymeleaf,以便前端同学直接参与页面结构调整。

这时团队面临的不是“选 A 还是选 B”,而是下面这组现实约束:

  1. 1. 核心交易域页面不能因为模板迁移而停机。
  2. 2. 新页面上线节奏快,不能被旧模板体系拖慢。
  3. 3. 旧模块迁移是一个持续几个月的过程,不可能一次性切换。
  4. 4. 任何切换都必须可灰度,可按请求特征、租户、页面、环境维度控制。
  5. 5. 一旦新引擎渲染异常,需要在分钟级回退。

从这个场景出发,我们可以把问题抽象为四个面:

  1. 1. 接入面:请求进入哪个 Controller,最终返回哪个逻辑视图名。
  2. 2. 路由面:这个视图名该由哪一个引擎渲染。
  3. 3. 状态面:当前哪些页面已经迁移,哪些仍走旧引擎,灰度策略是什么。
  4. 4. 治理面:缓存、预热、监控、回滚、发布、审计如何闭环。

如果只看 ViewResolver 顺序,你只能解决“能不能跑”;如果把这四个面搭起来,才能解决“能不能长期稳定演进”。


三、先讲原理:Spring MVC 为什么允许多模板引擎共存

3.1 视图解析链的本质

Spring MVC 对模板引擎的抽象并不复杂,关键接口是 ViewResolver。当 Controller 返回逻辑视图名,例如 "order/detail" 时,DispatcherServlet 不直接关心你用的是 Thymeleaf 还是 FreeMarker,而是把解析动作交给一组按顺序排列的 ViewResolver

简化链路如下:

HTTP Request
  -> DispatcherServlet
  -> HandlerMapping / HandlerAdapter
  -> Controller returns logical view name
  -> ViewResolver chain tries to resolve
  -> concrete View renders template
  -> HTML response

这意味着模板引擎在 Spring MVC 里天然是可插拔的。只要某个 ViewResolver 能把视图名解析成具体 View,它就能接入整个渲染流程。

3.2 多引擎可以共存,但“共存”不等于“可控”

理论上,Spring Boot 同时引入多个模板 starter 后,就会为不同引擎创建不同的自动配置对象和视图解析器。但生产里最大的风险在于:多个解析器同时存在时,如果路由边界不明确,就会出现误解析、顺序污染、静默兜底和问题难定位。

常见风险有三类:

  1. 1. 解析范围重叠:多个 ViewResolver 都声称自己可以解析某个视图名。
  2. 2. 顺序不稳定:一个新 starter 或自定义 Bean 改变了 resolver 顺序。
  3. 3. 缺少失败策略:模板不存在时不是显式失败,而是落到下一个解析器,最后渲染成错误页面。

所以,生产方案的关键不是“同时注册两个 ViewResolver”,而是建立一套明确、可审计、可灰度的模板路由机制

3.3 多模板引擎的三种常见路由方式

方式一:按视图名前缀路由

例如:

  1. 1. thymeleaf/marketing/home
  2. 2. freemarker/order/detail

优点是简单、直接、可读性好,最适合单体内模块拆分和迁移初期。

方式二:按请求特征动态路由

例如根据租户、渠道、活动版本、灰度标签决定引擎。

优点是灵活,适合多租户和灰度迁移;缺点是实现复杂度高,必须引入规则治理和审计。

方式三:按页面元数据路由

例如为每个页面维护一份 template_route 配置,内容包含:

  1. 1. 页面编码
  2. 2. 目标引擎
  3. 3. 模板路径
  4. 4. 灰度策略
  5. 5. 回滚默认值

这种方式更接近平台化治理,也是本文推荐的演进方向。


四、生产视角下的目标架构:不只是双引擎,而是模板渲染治理

为了让问题更清晰,我们把整套方案拆成四层。

4.1 分层架构

                     +------------------------------+
                     |        Controller Layer      |
                     | returns pageCode/viewName    |
                     +--------------+---------------+
                                    |
                                    v
                     +------------------------------+
                     |   Template Route Registry    |
                     | pageCode -> engine + path    |
                     | tenant/env/gray rules        |
                     +--------------+---------------+
                                    |
                      +-------------+-------------+
                      |                           |
                      v                           v
           +----------------------+   +----------------------+
           | Thymeleaf Resolver   |   | FreeMarker Resolver  |
           | cache / preheat      |   | cache / preheat      |
           +----------+-----------+   +----------+-----------+
                      |                          |
                      v                          v
           +----------------------+   +----------------------+
           | Thymeleaf Templates  |   | FreeMarker Templates |
           +----------+-----------+   +----------+-----------+
                      \                          /
                       \                        /
                        v                      v
                     +------------------------------+
                     | Observability & Governance   |
                     | metrics/log/trace/audit      |
                     | gray release / rollback      |
                     +------------------------------+

4.2 四个核心设计原则

原则一:路由规则从“代码硬编码”演进到“配置可治理”

迁移初期可以用前缀路由,到了中后期必须把页面与引擎的映射沉淀到统一注册表,否则每加一页都要动代码,灰度与回滚不可控。

原则二:模板路径必须显式,不允许模糊匹配

不要依赖“某个解析器刚好能找到这个模板”。模板路径、引擎类型、页面编码最好同时存在,避免误命中。

原则三:失败要显式暴露,不要静默兜底

如果页面设计要求走 Thymeleaf,而 Thymeleaf 模板缺失,那么系统应记录结构化错误并触发降级或回滚,而不是静默换成 FreeMarker 去渲染另一个页面。

原则四:模板迁移要纳入发布治理

模板切换不只是代码行为,也是发布行为。它应该有:

  1. 1. 灰度开关
  2. 2. 审计记录
  3. 3. 命中率监控
  4. 4. 渲染异常告警
  5. 5. 快速回退策略

五、落地方案选型:什么时候用前缀路由,什么时候上注册中心

5.1 阶段一:模块级前缀路由

适用场景:

  1. 1. 迁移刚开始。
  2. 2. 页面数量不多。
  3. 3. 模块边界清晰,例如营销域和交易域分离明显。

这时 Controller 直接返回带前缀的逻辑视图名:

  1. 1. thymeleaf/marketing/campaign-home
  2. 2. freemarker/order/detail

优点是成本低;缺点是 Controller 与模板引擎耦合明显,不利于后续切换。

5.2 阶段二:页面注册表路由

适用场景:

  1. 1. 页面数量变多。
  2. 2. 同一模块内部需要分批迁移。
  3. 3. 需要按租户、环境、渠道、灰度比例切换。

这时 Controller 返回页面编码,例如 PAGE_ORDER_DETAIL,再由注册表解析出:

  1. 1. engine = FREEMARKER
  2. 2. path = order/detail

或者:

  1. 1. engine = THYMELEAF
  2. 2. path = marketing/campaign-home

这种模式把“业务返回页面”和“实际使用哪个引擎”解耦,迁移过程更稳。

5.3 阶段三:动态配置中心路由

适用场景:

  1. 1. 多集群、多租户、多活动版本并行。
  2. 2. 路由规则需要不停机切换。
  3. 3. 模板渲染已经成为一项平台能力。

此时应将路由规则放入配置中心或数据库,并提供管理后台、发布审批和回滚入口。


六、生产级实现:Spring Boot 3 中的双引擎共存配置

下面给出一套更接近真实项目的实现。这个版本不追求最短代码,而追求下面几件事:

  1. 1. 引擎边界明确。
  2. 2. 路由可扩展。
  3. 3. 异常有日志、有指标。
  4. 4. 预热、缓存、降级可接入。

6.1 依赖定义

<properties>
    <java.version>
17</java.version>
    <spring-boot.version>
3.3.2</spring-boot.version>
</properties>


<dependencies>

    <dependency>

        <groupId>
org.springframework.boot</groupId>
        <artifactId>
spring-boot-starter-web</artifactId>
    </dependency>


    <dependency>

        <groupId>
org.springframework.boot</groupId>
        <artifactId>
spring-boot-starter-thymeleaf</artifactId>
    </dependency>


    <dependency>

        <groupId>
org.springframework.boot</groupId>
        <artifactId>
spring-boot-starter-freemarker</artifactId>
    </dependency>


    <dependency>

        <groupId>
org.springframework.boot</groupId>
        <artifactId>
spring-boot-starter-actuator</artifactId>
    </dependency>


    <dependency>

        <groupId>
io.micrometer</groupId>
        <artifactId>
micrometer-registry-prometheus</artifactId>
    </dependency>


    <dependency>

        <groupId>
org.springframework.boot</groupId>
        <artifactId>
spring-boot-starter-validation</artifactId>
    </dependency>

</dependencies>

这里重点不是依赖本身,而是:

  1. 1. 加上 Actuator,为模板渲染治理暴露健康检查和指标。
  2. 2. 加上 Micrometer,为慢模板、异常模板、命中分布提供观测基础。
  3. 3. 保持 Java 17,与当前主流 Spring Boot 3 项目栈对齐。

6.2 配置文件

server:
  port:
 8080
  shutdown:
 graceful

spring:

  lifecycle:

    timeout-per-shutdown-phase:
 30s

  thymeleaf:

    enabled:
 false
    prefix:
 classpath:/templates/thymeleaf/
    suffix:
 .html
    mode:
 HTML
    encoding:
 UTF-8
    cache:
 true
    check-template:
 true
    check-template-location:
 true

  freemarker:

    enabled:
 false
    template-loader-path:
 classpath:/templates/freemarker/
    suffix:
 .ftl
    charset:
 UTF-8
    cache:
 true
    check-template-location:
 true
    settings:

      number_format:
 0.##########
      default_encoding:
 UTF-8
      output_encoding:
 UTF-8
      locale:
 zh_CN
      template_exception_handler:
 rethrow
      log_template_exceptions:
 false
      wrap_unchecked_exceptions:
 true

management:

  endpoints:

    web:

      exposure:

        include:
 health,info,prometheus
  endpoint:

    health:

      probes:

        enabled:
 true

app:

  template:

    preheat-enabled:
 true
    fallback-enabled:
 false
    default-engine:
 FREEMARKER

几个关键点:

  1. 1. 关闭模板引擎自动启用,改为手工装配,避免默认行为不受控。
  2. 2. fallback-enabled 默认关闭,生产里更推荐显式失败而不是静默兜底。
  3. 3. FreeMarker 使用 rethrow,确保错误能进入统一异常治理链路。

6.3 页面路由领域模型

先不要急着写 ViewResolver,先定义“我们到底在路由什么”。

public enum TemplateEngineType {
    THYMELEAF,
    FREEMARKER
}
public record TemplateRouteDefinition(
        String pageCode,
        TemplateEngineType engineType,
        String templatePath,
        boolean
 canaryEnabled,
        String description)
 {

    public
 TemplateRouteDefinition {
        if
 (pageCode == null || pageCode.isBlank()) {
            throw
 new IllegalArgumentException("pageCode must not be blank");
        }
        if
 (templatePath == null || templatePath.isBlank()) {
            throw
 new IllegalArgumentException("templatePath must not be blank");
        }
    }
}

这个模型把过去隐藏在 Controller 里的东西显式表达出来了:

  1. 1. 页面编码
  2. 2. 使用的引擎
  3. 3. 模板路径
  4. 4. 是否允许灰度
  5. 5. 页面说明

6.4 路由注册表

public interface TemplateRouteRegistry {

    Optional<TemplateRouteDefinition> find(String pageCode, HttpServletRequest request);
}
@Component
public
 class InMemoryTemplateRouteRegistry implements TemplateRouteRegistry {

    private
 final Map<String, TemplateRouteDefinition> routes;

    public
 InMemoryTemplateRouteRegistry() {
        this
.routes = Map.of(
                "PAGE_OPERATION_HOME"
,
                new
 TemplateRouteDefinition(
                        "PAGE_OPERATION_HOME"
,
                        TemplateEngineType.THYMELEAF,
                        "operation/home"
,
                        true
,
                        "运营首页"
),
                "PAGE_ORDER_DETAIL"
,
                new
 TemplateRouteDefinition(
                        "PAGE_ORDER_DETAIL"
,
                        TemplateEngineType.FREEMARKER,
                        "order/detail"
,
                        false
,
                        "订单详情页"
)
        );
    }

    @Override

    public
 Optional<TemplateRouteDefinition> find(String pageCode, HttpServletRequest request) {
        return
 Optional.ofNullable(routes.get(pageCode));
    }
}

生产里这份注册表可以来自配置中心、数据库或 GitOps 配置文件,但接口先稳定下来,后续演进成本就小很多。

6.5 统一页面返回模型

让 Controller 不再返回具体模板路径,而返回页面编码,更有利于迁移。

public record TemplatePage(String pageCode, Map<String, Object> model) {

    public
 static TemplatePage of(String pageCode, Map<String, Object> model) {
        return
 new TemplatePage(pageCode, model);
    }
}
@Controller
@RequestMapping("/orders")

public
 class OrderPageController {

    private
 final OrderQueryService orderQueryService;

    public
 OrderPageController(OrderQueryService orderQueryService) {
        this
.orderQueryService = orderQueryService;
    }

    @GetMapping("/{orderId}")

    public
 ModelAndView detail(@PathVariable Long orderId) {
        OrderDetailView
 order = orderQueryService.getOrderDetail(orderId);
        Map<String, Object> model = new HashMap<>();
        model.put("order", order);
        model.put("pageTitle", "订单详情");
        return
 new ModelAndView("page-router", model)
                .addObject("templatePage",
                        TemplatePage.of("PAGE_ORDER_DETAIL", model));
    }
}

这里 page-router 不是最终模板,而是一个统一路由入口。真正的渲染逻辑交给我们的自定义解析器。

6.6 自定义路由型 ViewResolver

@Slf4j
public
 class RoutingTemplateViewResolver implements ViewResolver, Ordered {

    private
 final TemplateRouteRegistry routeRegistry;
    private
 final ViewResolver thymeleafViewResolver;
    private
 final ViewResolver freemarkerViewResolver;
    private
 final MeterRegistry meterRegistry;
    private
 final boolean fallbackEnabled;

    public
 RoutingTemplateViewResolver(
            TemplateRouteRegistry routeRegistry,
            ViewResolver thymeleafViewResolver,
            ViewResolver freemarkerViewResolver,
            MeterRegistry meterRegistry,
            boolean
 fallbackEnabled)
 {
        this
.routeRegistry = routeRegistry;
        this
.thymeleafViewResolver = thymeleafViewResolver;
        this
.freemarkerViewResolver = freemarkerViewResolver;
        this
.meterRegistry = meterRegistry;
        this
.fallbackEnabled = fallbackEnabled;
    }

    @Override

    public
 View resolveViewName(String viewName, Locale locale) throws Exception {
        if
 (!"page-router".equals(viewName)) {
            return
 null;
        }
        return
 new RoutingTemplateView(routeRegistry,
                thymeleafViewResolver,
                freemarkerViewResolver,
                meterRegistry,
                fallbackEnabled,
                locale);
    }

    @Override

    public
 int getOrder() {
        return
 Ordered.HIGHEST_PRECEDENCE;
    }
}

再看真正执行路由的 View

@Slf4j
public
 class RoutingTemplateView implements View {

    private
 final TemplateRouteRegistry routeRegistry;
    private
 final ViewResolver thymeleafViewResolver;
    private
 final ViewResolver freemarkerViewResolver;
    private
 final MeterRegistry meterRegistry;
    private
 final boolean fallbackEnabled;
    private
 final Locale locale;

    public
 RoutingTemplateView(
            TemplateRouteRegistry routeRegistry,
            ViewResolver thymeleafViewResolver,
            ViewResolver freemarkerViewResolver,
            MeterRegistry meterRegistry,
            boolean
 fallbackEnabled,
            Locale locale)
 {
        this
.routeRegistry = routeRegistry;
        this
.thymeleafViewResolver = thymeleafViewResolver;
        this
.freemarkerViewResolver = freemarkerViewResolver;
        this
.meterRegistry = meterRegistry;
        this
.fallbackEnabled = fallbackEnabled;
        this
.locale = locale;
    }

    @Override

    public
 String getContentType() {
        return
 "text/html;charset=UTF-8";
    }

    @Override

    @SuppressWarnings("unchecked")

    public
 void render(Map<String, ?> model,
                       HttpServletRequest request,
                       HttpServletResponse response)
 throws Exception {
        TemplatePage
 templatePage = (TemplatePage) model.get("templatePage");
        if
 (templatePage == null) {
            throw
 new IllegalStateException("templatePage is required");
        }

        long
 start = System.nanoTime();
        TemplateRouteDefinition
 route = routeRegistry.find(templatePage.pageCode(), request)
                .orElseThrow(() -> new IllegalStateException(
                        "No template route found for pageCode="
 + templatePage.pageCode()));

        try
 {
            View
 delegate = resolveDelegate(route, locale);
            delegate.render(templatePage.model(), request, response);
            recordSuccess(route, System.nanoTime() - start);
        } catch (Exception ex) {
            recordFailure(route, ex);
            if
 (fallbackEnabled && route.canaryEnabled()) {
                View
 fallback = freemarkerViewResolver.resolveViewName(
                        "freemarker/fallback/error-page"
, locale);
                fallback.render(templatePage.model(), request, response);
                return
;
            }
            throw
 ex;
        }
    }

    private
 View resolveDelegate(TemplateRouteDefinition route, Locale locale) throws Exception {
        String
 resolvedViewName = switch (route.engineType()) {
            case
 THYMELEAF -> "thymeleaf/" + route.templatePath();
            case
 FREEMARKER -> "freemarker/" + route.templatePath();
        };

        ViewResolver
 resolver = switch (route.engineType()) {
            case
 THYMELEAF -> thymeleafViewResolver;
            case
 FREEMARKER -> freemarkerViewResolver;
        };

        View
 view = resolver.resolveViewName(resolvedViewName, locale);
        if
 (view == null) {
            throw
 new IllegalStateException("Resolved view is null, route=" + route);
        }
        return
 view;
    }

    private
 void recordSuccess(TemplateRouteDefinition route, long costNanos) {
        Timer.builder("template.render.duration")
                .tag("pageCode", route.pageCode())
                .tag("engine", route.engineType().name())
                .register(meterRegistry)
                .record(costNanos, TimeUnit.NANOSECONDS);
    }

    private
 void recordFailure(TemplateRouteDefinition route, Exception ex) {
        Counter.builder("template.render.error")
                .tag("pageCode", route.pageCode())
                .tag("engine", route.engineType().name())
                .tag("exception", ex.getClass().getSimpleName())
                .register(meterRegistry)
                .increment();
        log.error("Template render failed, pageCode={}, engine={}, templatePath={}",
                route.pageCode(), route.engineType(), route.templatePath(), ex);
    }
}

这段代码比“直接按前缀解析”多了一层,但多出来的正是生产价值:

  1. 1. 路由逻辑集中管理。
  2. 2. 模板错误按页面编码打指标。
  3. 3. 灰度页面可以单独配置降级策略。
  4. 4. Controller 不再绑定某个具体模板引擎。

6.7 具体引擎的 Resolver 配置

@Configuration
public
 class MultiTemplateEngineConfiguration {

    @Bean

    public
 SpringResourceTemplateResolver thymeleafTemplateResolver(
            ApplicationContext applicationContext,
            ThymeleafProperties properties)
 {
        SpringResourceTemplateResolver
 resolver = new SpringResourceTemplateResolver();
        resolver.setApplicationContext(applicationContext);
        resolver.setPrefix(properties.getPrefix());
        resolver.setSuffix(properties.getSuffix());
        resolver.setTemplateMode(TemplateMode.HTML);
        resolver.setCharacterEncoding(properties.getEncoding().name());
        resolver.setCacheable(properties.isCache());
        resolver.setCheckExistence(true);
        resolver.setOrder(1);
        return
 resolver;
    }

    @Bean("thymeleafEngineViewResolver")

    public
 ViewResolver thymeleafViewResolver(
            SpringTemplateEngine templateEngine,
            ThymeleafProperties properties)
 {
        ThymeleafViewResolver
 resolver = new ThymeleafViewResolver();
        resolver.setTemplateEngine(templateEngine);
        resolver.setCharacterEncoding(properties.getEncoding().name());
        resolver.setContentType("text/html;charset=UTF-8");
        resolver.setCache(properties.isCache());
        resolver.setViewNames(new String[]{"thymeleaf/*"});
        resolver.setOrder(10);
        return
 resolver;
    }

    @Bean

    public
 FreeMarkerConfigurer freeMarkerConfigurer(FreeMarkerProperties properties) {
        FreeMarkerConfigurer
 configurer = new FreeMarkerConfigurer();
        configurer.setTemplateLoaderPaths(properties.getTemplateLoaderPath());
        return
 configurer;
    }

    @Bean("freemarkerEngineViewResolver")

    public
 ViewResolver freemarkerViewResolver(FreeMarkerProperties properties) {
        FreeMarkerViewResolver
 resolver = new FreeMarkerViewResolver();
        resolver.setPrefix("");
        resolver.setSuffix(properties.getSuffix());
        resolver.setContentType("text/html;charset=UTF-8");
        resolver.setCache(properties.isCache());
        resolver.setViewNames(new String[]{"freemarker/*"});
        resolver.setOrder(20);
        resolver.setExposeSpringMacroHelpers(true);
        return
 resolver;
    }

    @Bean

    public
 ViewResolver routingTemplateViewResolver(
            TemplateRouteRegistry routeRegistry,
            @Qualifier("thymeleafEngineViewResolver")
 ViewResolver thymeleafViewResolver,
            @Qualifier("freemarkerEngineViewResolver")
 ViewResolver freemarkerViewResolver,
            MeterRegistry meterRegistry,
            @Value("${app.template.fallback-enabled:false}")
 boolean fallbackEnabled)
 {
        return
 new RoutingTemplateViewResolver(
                routeRegistry,
                thymeleafViewResolver,
                freemarkerViewResolver,
                meterRegistry,
                fallbackEnabled);
    }
}

这里要注意两个实践细节:

  1. 1. 真实 resolver 只处理自己前缀范围内的模板,避免跨引擎误解析。
  2. 2. 总路由 resolver 放在最高优先级,负责把页面编码转成具体引擎模板。

6.8 模板目录结构建议

src/main/resources/templates
├── thymeleaf
│   ├── operation
│   │   └── home.html
│   ├── marketing
│   │   └── campaign-home.html
│   └── fragments
│       ├── header.html
│       └── footer.html
└── freemarker
    ├── order
    │   ├── detail.ftl
    │   └── list.ftl
    ├── settlement
    │   └── bill-detail.ftl
    └── fallback
        └── error-page.ftl

目录结构尽量体现业务域,而不是体现开发者个人习惯。模板层也是一套需要长期维护的资产。


七、实际业务案例:订单详情页如何分批迁移

很多文章讲到这里就结束了,但生产里真正困难的是“如何迁移”,而不是“如何配置”。

下面以订单详情页为例,给出一个更接近真实项目的迁移路径。

7.1 迁移前状态

订单域页面全部由 FreeMarker 渲染,Controller 中直接写:

return "freemarker/order/detail";

这有两个问题:

  1. 1. Controller 与模板引擎强耦合。
  2. 2. 后续要切到 Thymeleaf 时,需要改 Controller 代码并重新发布。

7.2 第一步:引入页面编码

先不切引擎,只把业务代码改成页面编码:

return new ModelAndView("page-router", model)
        .addObject("templatePage",
                TemplatePage.of("PAGE_ORDER_DETAIL", model));

路由注册表里仍指向 FreeMarker。这样做的意义是先把“业务入口”和“模板引擎选择”解耦。

7.3 第二步:双模板并存

新增 Thymeleaf 版本的订单详情页:

  1. 1. templates/freemarker/order/detail.ftl
  2. 2. templates/thymeleaf/order/detail.html

此时页面编码 PAGE_ORDER_DETAIL 还指向旧引擎,但新模板已经可以在测试环境验证。

7.4 第三步:按灰度策略切流

在注册表中加入灰度规则,例如:

  1. 1. 指定测试租户走 Thymeleaf。
  2. 2. 指定 Header 命中灰度标记时走 Thymeleaf。
  3. 3. 指定 5% 流量走 Thymeleaf。

伪代码如下:

@Override
public
 Optional<TemplateRouteDefinition> find(String pageCode, HttpServletRequest request) {
    TemplateRouteDefinition
 base = routes.get(pageCode);
    if
 (base == null) {
        return
 Optional.empty();
    }

    String
 tenantId = request.getHeader("X-Tenant-Id");
    String
 canaryTag = request.getHeader("X-Template-Canary");

    if
 ("PAGE_ORDER_DETAIL".equals(pageCode)
            && ("tenant_gray".equals(tenantId) || "thymeleaf".equals(canaryTag))) {
        return
 Optional.of(new TemplateRouteDefinition(
                pageCode,
                TemplateEngineType.THYMELEAF,
                "order/detail"
,
                true
,
                "订单详情页灰度到 Thymeleaf"
));
    }
    return
 Optional.of(base);
}

7.5 第四步:观测与回滚

灰度切流后要重点盯四类指标:

  1. 1. template.render.duration:不同引擎的渲染耗时。
  2. 2. template.render.error:渲染异常数。
  3. 3. 页面级 5xx 比例:由网关或应用指标采集。
  4. 4. 首屏关键业务指标:例如下单转化、点击率、停留时长。

如果新引擎错误率升高,第一优先级不是修模板,而是先把路由切回旧引擎。模板切换必须具备“分钟级回退”能力。


八、高并发与可扩展性:模板渲染层真正会卡在哪里

很多人会把“模板引擎性能”当作核心问题,但在大多数页面型系统里,真正的瓶颈通常不只在模板引擎本身。

8.1 一个完整页面请求的耗时拆分

页面请求通常至少包含:

  1. 1. 业务查询
  2. 2. 聚合组装
  3. 3. 模板渲染
  4. 4. 网络输出

如果把模板引擎当成全部性能问题,往往会误诊。更合理的方式是把页面渲染拆成三个阶段看:

  1. 1. 数据准备阶段:数据库、缓存、RPC 是否拖慢。
  2. 2. 模板执行阶段:表达式求值、片段拼装、循环渲染是否过重。
  3. 3. 输出传输阶段:响应体大小、Gzip、网关超时是否合理。

8.2 高频页面的五个性能优化点

优化一:强制开启模板缓存

模板缓存关闭只适合本地开发。生产环境下关闭缓存,会让每次请求重新解析模板,代价远高于很多人预期。

优化二:热点模板预热

首次访问某个模板时,可能触发类加载、模板解析、片段关联与缓存填充,造成冷启动毛刺。对首页、订单页、运营页等热点模板,建议启动时预热。

@Component
@ConditionalOnProperty(prefix = "app.template", name = "preheat-enabled", havingValue = "true")

public
 class TemplatePreheater implements ApplicationRunner {

    private
 static final List<String> THYMELEAF_TEMPLATES = List.of(
            "thymeleaf/operation/home"
,
            "thymeleaf/marketing/campaign-home"

    );

    private
 static final List<String> FREEMARKER_TEMPLATES = List.of(
            "freemarker/order/detail"
,
            "freemarker/settlement/bill-detail"

    );

    private
 final ViewResolver thymeleafViewResolver;
    private
 final ViewResolver freemarkerViewResolver;

    public
 TemplatePreheater(
            @Qualifier("thymeleafEngineViewResolver")
 ViewResolver thymeleafViewResolver,
            @Qualifier("freemarkerEngineViewResolver")
 ViewResolver freemarkerViewResolver)
 {
        this
.thymeleafViewResolver = thymeleafViewResolver;
        this
.freemarkerViewResolver = freemarkerViewResolver;
    }

    @Override

    public
 void run(ApplicationArguments args) throws Exception {
        for
 (String viewName : THYMELEAF_TEMPLATES) {
            thymeleafViewResolver.resolveViewName(viewName, Locale.SIMPLIFIED_CHINESE);
        }
        for
 (String viewName : FREEMARKER_TEMPLATES) {
            freemarkerViewResolver.resolveViewName(viewName, Locale.SIMPLIFIED_CHINESE);
        }
    }
}

优化三:控制模板中的复杂表达式

模板引擎适合表现层拼装,不适合承担复杂业务计算。不要在模板里写:

  1. 1. 复杂金额汇总逻辑
  2. 2. 多层嵌套条件判断
  3. 3. 大列表过滤与排序
  4. 4. 依赖上下文副作用的表达式

这些逻辑应该提前在服务层准备成适合展示的 ViewModel。

优化四:片段复用但避免过度碎片化

公共头部、导航、按钮组适合抽成片段;但如果每个小区块都拆 fragment,就会让模板维护成本和渲染复杂度上升。

优化五:热点页面数据尽量读缓存而不是让模板“等数据库”

模板渲染本身常常不是压垮系统的原因,真正压垮系统的是“每个页面都要同步查询多个下游”。在高并发页面场景里,常见做法是:

  1. 1. 静态配置和字典项本地缓存。
  2. 2. 低频变更信息走 Redis。
  3. 3. 重查询页面使用读模型或预聚合表。

8.3 扩展性不是支持更多引擎,而是支持更多治理策略

从架构角度看,多模板共存的“可扩展”并不是以后还能加 Mustache 或 Groovy,而是能否扩展下面这些策略:

  1. 1. 按租户切换引擎。
  2. 2. 按页面批次迁移。
  3. 3. 按环境差异配置。
  4. 4. 按时间窗口灰度。
  5. 5. 按故障阈值自动回退。

真正的平台化能力,扩展的是治理规则,而不是模板语法列表。


九、故障场景与风险边界:生产里最容易被忽略的问题

9.1 模板缺失导致错误页面被静默渲染

这是多引擎共存里最隐蔽的问题之一。某页面本该走 Thymeleaf,但 Thymeleaf 模板没打进包里,结果因为后续 resolver 还能解析同名模板,用户看到了另一个页面。

防范方式:

  1. 1. 开启模板存在性校验。
  2. 2. 显式限定 resolver 的 viewNames 范围。
  3. 3. 对路由页使用页面编码和模板路径双校验。
  4. 4. 关闭或严格限制自动兜底。

9.2 模板中的数字格式影响前端脚本

FreeMarker 默认数字格式化很容易影响脚本变量输出,例如订单号、用户 ID、金额字段。

建议:

  1. 1. 数字型脚本输出用 ?c 或在服务层提前格式化。
  2. 2. 金额、时间、枚举统一走展示 DTO,不要把领域对象直接透出到模板。

9.3 模板层承载了过多业务判断

当一个页面经历多轮迭代后,经常会出现这样的代码味道:

  1. 1. 模板里嵌套多层 if/else
  2. 2. 直接判断订单状态机细节
  3. 3. 根据权限、渠道、实验组拼接大量 UI 分支

这意味着模板层已经侵入业务编排。此时应引入页面组装服务,把复杂条件下沉到 Java 代码,模板仅负责展示。

9.4 灰度配置没有审计,导致回滚困难

如果路由规则来自配置中心,但没有版本记录和变更审批,线上一旦出现问题,很难知道是谁、何时、因为什么把页面切到了新引擎。

模板路由配置至少要具备:

  1. 1. 变更人
  2. 2. 变更时间
  3. 3. 变更前后差异
  4. 4. 回滚版本
  5. 5. 生效环境

9.5 多副本部署下的预热不一致

一个 Pod 完成模板预热,不代表所有 Pod 都完成。若流量在刚扩容后立刻打进新副本,首批请求仍可能出现冷启动抖动。

更稳妥的做法是:

  1. 1. 启动期预热热点模板。
  2. 2. readiness probe 在预热完成后再置为 ready。
  3. 3. 大促或高峰前提前扩容并观察预热指标。

十、可观测性设计:模板渲染也应该进入监控体系

10.1 至少要有的四类指标

第一类:渲染耗时

按页面、引擎、状态统计:

  1. 1. P50
  2. 2. P95
  3. 3. P99

第二类:渲染异常

例如:

  1. 1. 模板不存在
  2. 2. 变量求值失败
  3. 3. 片段加载失败
  4. 4. 空指针或类型转换异常

第三类:路由命中分布

对迁移中的页面,要知道当前有多少请求走旧引擎,多少请求走新引擎。否则灰度是否生效都无法确认。

第四类:回退次数

如果系统启用了失败回退逻辑,那么自动回退次数本身就是一个重要治理指标,它意味着新模板稳定性可能有问题。

10.2 结构化日志建议

模板层日志不要只打“render error”,而要带上以下字段:

  1. 1. traceId
  2. 2. requestUri
  3. 3. pageCode
  4. 4. engineType
  5. 5. templatePath
  6. 6. tenantId
  7. 7. grayTag
  8. 8. exceptionClass

这样在排查时,才能把“哪个请求、哪张页面、哪个租户、哪套引擎”快速串起来。

10.3 链路追踪建议

如果页面请求本身链路很长,可以在模板渲染前后加 span,把渲染时间从控制器耗时中拆出来。对于复杂后台页面,这个拆分非常有价值,因为它能帮你区分:

  1. 1. 是模板慢
  2. 2. 还是服务查询慢
  3. 3. 还是下游 RPC 慢

十一、容器化与 Kubernetes 落地:把模板治理接到发布链路上

11.1 Dockerfile 示例

FROM maven:3.9.8-eclipse-temurin-17 AS builder
WORKDIR
 /build
COPY
 pom.xml .
RUN
 mvn -B -q dependency:go-offline
COPY
 src ./src
RUN
 mvn -B -q clean package -DskipTests

FROM
 eclipse-temurin:17-jre
WORKDIR
 /app
COPY
 --from=builder /build/target/*.jar app.jar
EXPOSE
 8080
ENTRYPOINT
 ["java", "-jar", "app.jar"]

11.2 K8s 部署要点

apiVersion: apps/v1
kind:
 Deployment
metadata:

  name:
 template-render-app
spec:

  replicas:
 3
  selector:

    matchLabels:

      app:
 template-render-app
  template:

    metadata:

      labels:

        app:
 template-render-app
    spec:

      containers:

      -
 name: app
        image:
 registry.example.com/template-render-app:20260706
        ports:

        -
 containerPort: 8080
        env:

        -
 name: JAVA_TOOL_OPTIONS
          value:
 "-XX:MaxRAMPercentage=70 -XX:+HeapDumpOnOutOfMemoryError"
        -
 name: APP_TEMPLATE_PREHEAT_ENABLED
          value:
 "true"
        readinessProbe:

          httpGet:

            path:
 /actuator/health/readiness
            port:
 8080
          initialDelaySeconds:
 20
          periodSeconds:
 5
        livenessProbe:

          httpGet:

            path:
 /actuator/health/liveness
            port:
 8080
          initialDelaySeconds:
 30
          periodSeconds:
 10
        resources:

          requests:

            cpu:
 "500m"
            memory:
 "512Mi"
          limits:

            cpu:
 "1"
            memory:
 "1Gi"

11.3 发布策略建议

模板引擎迁移不要和大业务改动绑在同一个发布窗口。更稳妥的顺序通常是:

  1. 1. 先发布支持双引擎共存的代码,但路由仍指向旧引擎。
  2. 2. 验证所有 Pod 的模板预热、指标暴露、日志字段齐全。
  3. 3. 在非高峰时段通过配置变更切小流量。
  4. 4. 观察一段时间后再扩大灰度。
  5. 5. 任何异常优先回切路由,再定位具体模板问题。

这个顺序的核心思想是:把“发布代码”和“切换路由”拆成两个动作。


十二、测试策略:不能只测页面能打开

模板迁移类项目最怕“手工点开看了没问题”,因为很多问题并不会立即暴露。

12.1 路由单元测试

class InMemoryTemplateRouteRegistryTest {

    private
 final InMemoryTemplateRouteRegistry registry =
            new
 InMemoryTemplateRouteRegistry();

    @Test

    void
 shouldResolveOrderDetailRoute() {
        MockHttpServletRequest
 request = new MockHttpServletRequest();
        Optional<TemplateRouteDefinition> definition =
                registry.find("PAGE_ORDER_DETAIL", request);
        assertThat(definition).isPresent();
        assertThat(definition.get().engineType()).isEqualTo(TemplateEngineType.FREEMARKER);
    }
}

12.2 页面集成测试

@SpringBootTest
@AutoConfigureMockMvc

class
 TemplateRenderIntegrationTest {

    @Autowired

    private
 MockMvc mockMvc;

    @Test

    void
 shouldRenderOrderDetailByFreemarker() throws Exception {
        mockMvc.perform(get("/orders/1001"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("订单详情")));
    }

    @Test

    void
 shouldRenderOperationHomeByThymeleaf() throws Exception {
        mockMvc.perform(get("/operation/home"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("运营首页")));
    }
}

12.3 灰度回归测试

迁移中的页面要至少覆盖三类用例:

  1. 1. 不带灰度标记,必须走旧引擎。
  2. 2. 带灰度标记,必须走新引擎。
  3. 3. 新引擎异常时,是否符合预期策略:显式失败或回退。

12.4 性能测试关注点

不要只压“模板渲染函数”,更应该压真实页面请求,并观察:

  1. 1. 首次访问与热身后的耗时差异。
  2. 2. 扩容后新副本的冷启动抖动。
  3. 3. 复杂页面在高并发下的 GC、线程、连接池表现。
  4. 4. 模板切换前后的业务成功率变化。

如果一定要做微基准,也应该清楚说明它只能衡量“单机渲染开销”,不能替代真实生产流量表现。


十三、演进路线:从双引擎共存到模板平台化

13.1 第一阶段:共存

目标是让旧页面不动、新页面可接入,重点解决:

  1. 1. 多 resolver 边界
  2. 2. 模板路径规范
  3. 3. 基础监控

13.2 第二阶段:迁移治理

目标是页面级灰度、批次迁移、快速回退,重点解决:

  1. 1. 页面注册表
  2. 2. 配置中心接入
  3. 3. 灰度路由规则
  4. 4. 审计与回滚

13.3 第三阶段:平台化

目标是把模板渲染做成一套可治理能力,重点解决:

  1. 1. 页面生命周期管理
  2. 2. 可视化路由配置
  3. 3. 多环境差异控制
  4. 4. 指标看板与告警
  5. 5. 模板质量校验流水线

当系统走到这一步时,模板引擎就不再只是“页面技术栈”,而是发布体系的一部分。


十四、上线检查清单

正式上线前,至少确认以下事项:

  1. 1. ViewResolver 顺序已经打印并校验,路由链条明确。
  2. 2. 所有模板路径命名规范统一,没有跨引擎同名污染。
  3. 3. 热点页面完成模板预热,readiness 在预热完成后才放量。
  4. 4. 渲染耗时、渲染异常、路由命中率指标已接入监控。
  5. 5. 结构化日志字段完整,能按 pageCode + engineType + traceId 排查。
  6. 6. 灰度规则有审计记录,回切旧引擎的操作路径已演练。
  7. 7. 业务高峰期不做大批量模板切换。
  8. 8. 新旧模板输出结果经过核心字段比对,避免页面能打开但数据展示错误。
  9. 9. 模板中没有承载复杂业务计算,核心判断已下沉到组装层。
  10. 10. 灰度阶段已定义清晰的成功标准和回滚阈值。

十五、总结:多模板引擎问题,本质上是演进治理问题

Spring Boot 支持多模板引擎共存,这件事从框架机制上并不复杂;复杂的是,当系统进入持续迁移和多团队协作阶段后,模板引擎就从一个局部实现细节,变成了一项需要治理的架构能力。

如果只停留在“配两个 starter、调一下 resolver 顺序”,你解决的只是 demo 级共存。真正的生产落地,至少要把下面几件事做完整:

  1. 1. 让页面返回与模板引擎解耦。
  2. 2. 让模板路由规则可配置、可灰度、可回滚。
  3. 3. 让异常显式暴露,而不是静默兜底。
  4. 4. 让模板渲染进入监控、日志、链路追踪体系。
  5. 5. 让发布与切换分离,降低迁移风险。

说到底,模板引擎混搭不是为了炫技,而是为了让存量系统在不停机的前提下完成平滑演进。只要你把“路由、状态、治理”三件事真正搭起来,Thymeleaf 和 FreeMarker 的共存就不会是一个临时方案,而会成为一条足够稳、足够工程化的迁移路径。


参考建议

建议在团队内部继续补充三类资产:

  1. 1. 页面编码与模板路由规范文档。
  2. 2. 模板迁移灰度与回滚 SOP。
  3. 3. 模板渲染指标看板与故障演练记录。

当这些资产补齐后,多模板引擎共存就不只是“会写”,而是真正具备了可复制的生产落地能力。