템플릿
WebResponse.template(name, model)은 렌더링된 페이지로 응답한다.
이름에는 확장자가 없다: 엔진이 자기 확장자를 덧붙인다.
app.get("/decks/{deckId}", req ->
WebResponse.template("deck", Model.of("deck", service.deck(req.pathParamLong("deckId")))));
이를 위해 설정할 것은 없다.
App은 jte로 classpath:/jte의 템플릿을 렌더링하며 .jte를 덧붙이므로, 위의 핸들러는 classpath:/jte/deck.jte를 렌더링한다.
모델의 항목은 이름으로 jte의 파라미터에 전달되고, ContentType.Html이 ${} 출력을 대신 이스케이프한다.
Model은 불변이므로, 핸들러가 아직 쥐고 있는 어떤 것도 페이지를 바꾸지 못한다.
null 값을 담은 모델
Model.of(…)는 템플릿 모델을 식 하나로 만들며 null 값을 받는다.
Map.of는 null 값에 NullPointerException을 던지는데, 페이지 모델은 null을 흔히 담는다: req.flashed(…), req.paramOrNull(…), req.session().get(…)은 보여줄 것이 없으면 null을 답한다.
app.get("/decks/{deckId}", req -> WebResponse.template("deck", Model.of(
"deck", service.deck(req.pathParamLong("deckId")),
"message", req.flashed("message"))));
Model.of는 Map.of처럼 키와 값을 열 쌍까지 받으며, 항목이 주어진 순서를 유지한다.
null 키는 NullPointerException을, 두 번 주어진 키는 IllegalArgumentException을 던지며, 이는 Map.of가 던지는 것과 같은 예외이다.
Model은 맵이 아니라 불변 값이다.
조건에 따라 항목이 늘어나는 모델은 새 모델을 답하는 with(key, value)로 항목을 더한다:
Model model = Model.of("decks", service.decks());
if (req.session().get("user") instanceof User user) {
model = model.with("user", user);
}
return WebResponse.template("decks", model);
with는 모델이 이미 가진 키에 IllegalArgumentException을 던진다.
asMap()은 null 값과 순서를 유지한 읽기 전용 맵으로 항목을 답하며, 맵을 받는 엔진에 항목을 넘기는 렌더러가 쓴다.
직접 정하는 루트나 접미사
app.templates(new JteTemplates("templates") // classpath:/templates
.suffix(".html")); // .../deck.html
접미사는 덧붙일 뿐 검사하지 않으므로, 확장자를 아직 달고 있는 이름은 접미사가 두 번 붙은 채로 조회된다.
다른 엔진
TemplateRenderer는 템플릿 이름, Model, 렌더링 결과를 쓸 라이터를 받는 메서드 하나다.
jte가 아닌 엔진은 이 인터페이스를 구현하고, templates(renderer)에 전달해 기본값을 대체한다.
맵을 받는 엔진에는 model.asMap()을 넘긴다.
app.templates((template, model, out) -> mustache.compile(template + ".mustache").execute(out, model.asMap()));
세 엔진은 루트, 접미사, 이스케이프가 이미 정해진 별도 모듈로 제공된다: FreeMarker, Handlebars, Thymeleaf다.
app.templates(new FreeMarkerTemplates("freemarker")); // classpath:/freemarker/deck.ftlh
app.templates(new HandlebarsTemplates("hbs")); // classpath:/hbs/deck.hbs
app.templates(new ThymeleafTemplates("thymeleaf")); // classpath:/thymeleaf/deck.html
그 밖의 엔진은 위의 Mustache 예처럼 한 줄로 연결한다.
모듈은 compile(name).render(model) 외에 정해 둘 것이 있을 때만 만들 가치가 있다.
렌더링은 핸들러의 예외 처리가 아직 적용되는 동안 일어난다.
따라서 예외를 던지는 템플릿은 다른 실패와 마찬가지로 app.exception(…)에 도달하고, 렌더링된 페이지에 대한 HEAD는 GET이 보냈을 길이를 보고한다.
핸들러는 템플릿의 이름과 모델을 반환하고, AppServlet이 응답을 쓰기 전에 이를 렌더링한다:
개발과 프로덕션
jte는 기본적으로 클래스패스에서 템플릿을 읽어 런타임에 컴파일한다.
설정할 것은 없지만, 배포 머신에 JDK가 필요하고, 편집한 템플릿은 클래스패스 사본을 다시 빌드하기 전까지 보이지 않는다.
엔진에는 그 트레이드오프의 양쪽에 각각 모드가 있고, JteTemplates에는 각각에 맞는 생성자가 있다.
개발 모드는 클래스패스 대신 소스 디렉터리에서 템플릿을 찾는다:
app.templates(new JteTemplates(
new DirectoryCodeResolver(Path.of("src/main/resources/jte"))));
렌더링할 때마다 jte는 템플릿 파일과 템플릿이 의존하는 모든 것의 수정 타임스탬프를 확인하고, 바뀐 것을 다시 컴파일한다.
.jte 파일을 저장하고 브라우저를 새로 고치는 것이 루프의 전부다.
프로덕션 모드는 jte Gradle 플러그인으로 빌드 시점에 모든 템플릿을 사전 컴파일한다:
plugins {
id 'gg.jte.gradle' version '3.2.4'
}
jte {
sourceDirectory = file('src/main/resources/jte').toPath()
contentType = gg.jte.ContentType.Html
generate()
}
spider-silk 플러그인의 spiderSilk { jte() }가 정확히 이 블록을 대신 작성하며, jte의 네이티브 이미지 리소스 확장도 함께 붙인다.
generate()는 각 템플릿을 애플리케이션과 함께 컴파일되는 Java 클래스로 바꾸고, 그에 맞는 생성자가 엔진에 그 클래스들을 건넨다:
app.templates(new JteTemplates(TemplateEngine.createPrecompiled(ContentType.Html)));
그러면 jar는 컴파일된 클래스로 렌더링한다: 런타임 컴파일러도, 머신의 JDK도 필요 없고, 컴파일되지 않는 템플릿은 요청 대신 빌드를 실패시킨다.
접미사는 어느 쪽이든 적용되므로, 핸들러는 바뀌지 않는다.
예제 프로젝트는 --dev 플래그로 두 모드를 전환한다.
네이티브 이미지에는 런타임 컴파일러가 아예 없으므로, 거기서는 사전 컴파일이 유일한 모드다.
jte-models로 타입을 검사하는 템플릿 호출
jte 확장 jte-models는 템플릿마다 Java 메서드를 생성하므로, 핸들러가 템플릿을 부르는 호출을 컴파일러가 검사한다.
Model은 페이지를 렌더링할 때에야 이름으로 템플릿의 파라미터에 맞춰지므로, 철자가 틀린 키나 타입이 틀린 값은 빌드가 아니라 요청을 실패시킨다.
생성된 메서드는 대신 템플릿의 @param 선언을 파라미터로 받는다.
이 확장은 spiderSilk { jte() } 곁에 두 줄을 더하는 것으로 충분하며, spiderSilk { jte() }가 이미 jte 플러그인을 적용하고 generate()를 호출한다:
jte {
jteExtension('gg.jte.models.generator.ModelExtension')
}
dependencies {
jteGenerate 'gg.jte:jte-models:3.2.4'
implementation 'gg.jte:jte-models:3.2.4'
}
그러면 빌드는 템플릿마다 메서드 하나를 가진 인터페이스 gg.jte.generated.precompiled.Templates와, 그 구현 두 개를 생성한다.
StaticTemplates는 사전 컴파일된 클래스를 렌더링하고, DynamicTemplates는 건네받은 TemplateEngine으로 렌더링한다.
기본값이 있는 것을 포함해 모든 `@param`이 메서드의 파라미터다.
메서드는 JteModel을 답하고, 그 render()가 페이지를 문자열로 답하므로, WebResponse.html이 그것을 보낸다:
Templates templates = new StaticTemplates();
// deck.jte declares @param Deck deck and @param String message = null
app.get("/decks/{deckId}", req -> WebResponse.html(
templates.deck(service.deck(req.pathParamLong("deckId")), req.flashed("message")).render()));
html이 이미 렌더링된 문자열을 보내므로, core에는 jte-models를 위한 API가 따로 없다.
이 호출은 template()과 세 가지가 다르다:
-
페이지가 핸들러 안에서 렌더링되므로, 예외를 던지는 템플릿은 핸들러의 다른 예외와 마찬가지로
app.exception(…)에 도달한다. jte는JteTemplates가 렌더링할 때와 마찬가지로 실패를TemplateException으로 감싼다. -
페이지가 after-route 필터보다 먼저 렌더링되므로, 필터는
Template본문이 아니라 페이지를 담은Text본문을 본다. -
App.templates(…)를 거치지 않으므로, 거기 설정한 렌더러와 그 접미사는 적용되지 않는다.
개발 중에는 앞의 개발 모드처럼 소스 디렉터리를 읽는 엔진을 DynamicTemplates에 건넨다:
Templates templates = dev
? new DynamicTemplates(TemplateEngine.create(
new DirectoryCodeResolver(Path.of("src/main/resources/jte")),
Path.of("build/jte-classes"), ContentType.Html))
: new StaticTemplates();
템플릿 본문을 고치면 다음 요청에 바로 반영된다.
@param 선언을 고치면 생성되는 Templates 인터페이스가 바뀌므로 다시 빌드해야 한다.