Pular para conteúdo

0025 — Views server-render com JTE

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-12 · Decidido em: 2026-08-25

Contexto

A decisão 0010 unificou o UI de admin/ops/dev da casa em server-render com Thymeleaf (micronaut-views-thymeleaf), e o kit da casa (0015/0017) nasceu nesse motor. Depois, a 0022 tornou native-image o alvo de deploy do server elegível.

Os dois não convivem bem. Micronaut Views usa Thymeleaf standalone → OGNL, e OGNL resolve todo acesso a modelo por reflexão (Class.getDeclaredMethods + Method.invoke) — inclusive propriedade (${m.status} → getStatus()) e método (${k.nome()}), e accessors aninhados (${v.mdfe().placa()}). Sob AOT (closed-world), cada tipo alcançado por qualquer template precisa de @ReflectiveAccess; @Introspected/@Serdeable/@MappedEntity não cobrem (a introspecção do Micronaut é reflection-free, o OGNL não a usa). A falha aparece em runtime (500/TemplateProcessingException), não no build — achada clicando tela por tela. O conjunto é ilimitado e drifta (view nova = tipo novo = mais um rebuild). Custo real medido: o int-sascar exigiu 15 tipos anotados em 3 rebuilds native, mais o MensagemM2m da própria lib xadm-mensageria (a view /admin/mensageria que ela fornece). É a mesma classe "compila verde, quebra em runtime" que a §Native da engenharia já documentava.

Decisão

As views server-render da casa usam JTE (gg.jte via micronaut-views-jte), não Thymeleaf. JTE compila cada .jte em uma classe Java no build; o princípio da 0010 (UI de admin/ops unificada, server-render) fica — muda o motor.

Por que JTE resolve a raiz:

  • Type-check no build. O template declara @param tipados (@param ClienteForm form, @param List<SascarConta> contas); ${k.nome()} vira chamada Java compilada. Campo/método errado = erro de compile, não 500 em runtime. É o gate compile-time que a casa prefere.
  • Native: reflexão fechada, não whack-a-mole. Precompilado, o JTE não reflete no modelo — ele reflete só nas classes de template geradas (gg.jte.generated.precompiled.Jte<Nome>Generated, instanciadas por nome) e lê os **/*.bin. É um conjunto estático, 1-por-.jte, num pacote fixo, capturado uma vez (tracing agent) e que não drifta — o oposto do "todo getter que o OGNL possa tocar". (Honesto: não é zero reflexão; é reflexão bounded e build-verificável.)
  • Imagem/boot menores (sem o motor de expressão dinâmico) e XSS escape content-aware por default (contentType = Html; $unsafe{} para cru).

Mecanismo (detalhe operável na engenharia): plugin gg.jte.gradle na mesma versão do runtime gg.jte:jte (gerenciado pelo BOM via micronaut-views-jte); modo generate() (emite .java compilado junto do app → binário native self-contained; micronaut.views.jte.dynamic=false em prod/native); .jte em src/main/jte; @View("path") + model Map/POJO casa nos @param por nome; layout via @template.kit.layout(title=…, content = @…) com @param gg.jte.Content content. A casa fornece o reflect-config/resource-config das classes geradas (o micronaut-views-jte não embarca esse hint) — receita na engenharia.

Correção 2026-09-03: o "não drifta" acima vale só com a extensão que auto-enumera as templates (jteExtension('gg.jte.nativeimage.NativeResourcesExtension') + dep jteGenerate('gg.jte:jte-native-resources')), que gera o reflection-config.json de todas as *Generated no build. A lista à mão drifta: view nova esquecida é classe podada no AOT, 404 só no native. A extensão é a norma e a lista à mão é proibida — detalhe em java-micronaut §JTE.

Correção 2026-09-08: view empacotada em lib mora sob subpasta própria do src/main/jte; na raiz, ela gera a mesma FQCN que a view do app, e uma sombreia a outra sem aviso. Norma em Bibliotecas da casa.

Alternativas descartadas

  • Manter Thymeleaf + @ReflectiveAccess por tipo. É o whack-a-mole: ilimitado, drifta a cada view nova, falha em runtime, e a lista é achada clicando tela por tela (15 tipos / 3 rebuilds no piloto). Não se paga com native como default.
  • Thymeleaf + GraalVM Feature por pacote (registrar <app>.** por reflexão). É o "glob" real, mas exige a dep svm (coordenada incerta, não-cacheada), só valida em build native, over-registra o pacote inteiro, e não cura a falta de type-check nem o tamanho da imagem.
  • Thymeleaf + tracing agent. Automatiza a captura dos hints, mas branch th:if nunca renderizado no run do agente = hint não capturado → mesma classe de bug (falha em runtime numa tela pouco exercitada). Não elimina, só automatiza a caça.
  • Rocker (também compilado). Mesma categoria de ganho que o JTE, mas a manutenção desacelerou (poucos releases; risco de abandono) e o escape XSS é menos content-aware. A única vantagem clara do Rocker — perf near-zero-copy — é irrelevante para telas admin/CRUD (não-hot-path).

Consequências

  • O kit da casa é re-feito em JTE (layout.jte + headerRight.jte — o bloco direito, duplicado nos dois fragments Thymeleaf, vira um template chamado dos dois). Os templates rastreados de kit Thymeleaf (0015/0017) passam a apontar para o kit JTE; a versão Thymeleaf vira legado durante a migração.
  • Migração faseada da frota, app por app (Thymeleaf sai quando a última view do app é portada). É mecânica: th:each→@for, th:if→@if, ${x.metodo()}→${x.metodo()} (agora compilado), fragment-replace→@template.layout(content=@…). As views são admin dev-edit (não designer), então perder "HTML abrível no browser" quase não pesa.
  • A xadm-comum-web/xadm-mensageria seguem o modelo (a view /admin/mensageria da mensageria vira .jte; os @ReflectiveAccess de view viram desnecessários e saem no review native).
  • Um hint native novo, mas fechado: o reflect-config das classes gg.jte.generated.precompiled.*
  • resource-config **/*.bin, fornecido pela casa (template/receita), capturado uma vez.