Im vorherigen Beitrag haben wir einen kleinen REST-Server mit WebBroker gebaut, ohne irgendetwas, das nicht ohnehin in Delphi enthalten ist. Er antwortet mit JSON, und genau das möchte ein JavaScript-Frontend oder eine mobile App. Allerdings brauchen etliche Webanwendungen überhaupt kein separates Frontend. Ein Dashboard für das Support-Team, eine Admin-Seite für einen Dienst, eine Statusseite -- in vielen Fällen ist auf dem Server gerendertes HTML die einfachste Lösung, die überhaupt funktionieren kann.
Für diese Aufgabe bringt Delphi ein eigenes Werkzeug mit: WebStencils, eine Template-Engine, die Embarcadero mit RAD Studio 12.2 eingeführt hat. Sie schreiben gewöhnliche HTML-Dateien, markieren die Stellen, an denen Daten hingehören, mit einem @ und lassen Delphi den Rest ausfüllen.
In diesem Beitrag ergänzen wir den bestehenden Server um unsere erste WebStencils-Seite: ein Layout, eine Seite, die es verwendet, eine Schleife über eine Liste von Objekten und eine Bedingung. Am Ende antwortet dieselbe ausführbare Datei auf einer URL mit JSON und auf einer anderen mit HTML.
Was WebStencils ist und was nicht
Bevor wir Templates schreiben, lohnt es sich zu wissen, wo WebStencils einzuordnen ist. WebBroker kann schon sehr lange HTML erzeugen: mit der Komponente TPageProducer, die Tags wie <#name> in einer HTML-Datei ersetzt, indem sie für jedes einzelne einen Event-Handler aufruft. Das funktioniert, und es laufen reichlich Anwendungen in Produktion, die das nutzen. WebStencils ist der moderne Nachfolger. Laut Marco Cantùs Einführung implementiert sein Prozessor dasselbe Interface wie der Page Producer und kann ihn ersetzen, die Templates sind aber deutlich ausdrucksstärker: Sie können Eigenschaften von Delphi-Objekten direkt lesen, über Listen iterieren und ein gemeinsames Layout verwenden.
Wenn Sie beides nebeneinander sehen möchten: David Cornelius hat zwei WebBroker-Demos veröffentlicht, die dieselbe Website einmal mit Page Producern und einmal mit WebStencils umsetzen. Für mich war das ein sehr guter Weg, zu verstehen, was sich geändert hat.
Der Ablauf einer WebStencils-Seite ist einfach. Der Prozessor liest ein Template, sucht die Daten heraus, die wir unter einem Namen registriert haben, und erzeugt HTML.
Beachten Sie, was im Diagramm fehlt: Es gibt kein JavaScript-Framework und keinen Build-Schritt. Der Browser bekommt fertiges HTML. Außerdem ist WebStencils nicht an WebBroker gebunden. Derselbe Prozessor funktioniert mit RAD Server, und Marco weist darauf hin, dass er jedes beliebige Textformat erzeugen kann, nicht nur HTML.
WebStencils besteht aus zwei Komponenten. TWebStencilsProcessor rendert eine Datei. TWebStencilsEngine hält gemeinsame Einstellungen und kann URLs auf Template-Dateien abbilden. Für unsere erste Seite genügt der Prozessor.
Schritt 1: Die Daten für die Seite
WebStencils liest Daten aus ganz gewöhnlichen Delphi-Objekten. Wir registrieren ein Objekt unter einem Namen, und das Template greift mit @name.Property auf seine Eigenschaften zu. Legen Sie eine neue Unit HelloPageModel.pas mit zwei kleinen Klassen an:
unit HelloPageModel;
interface
type
TPageInfo = class
private
FTitle: string;
FRenderedAt: string;
public
constructor Create(const ATitle: string);
property Title: string read FTitle;
property RenderedAt: string read FRenderedAt;
end;
TEndpoint = class
private
FPath: string;
FDescription: string;
FIsHtml: Boolean;
public
constructor Create(const APath, ADescription: string; AIsHtml: Boolean);
property Path: string read FPath;
property Description: string read FDescription;
property IsHtml: Boolean read FIsHtml;
end;
implementation
uses
System.SysUtils;
{ TPageInfo }
constructor TPageInfo.Create(const ATitle: string);
begin
inherited Create;
FTitle := ATitle;
FRenderedAt := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now);
end;
{ TEndpoint }
constructor TEndpoint.Create(const APath, ADescription: string;
AIsHtml: Boolean);
begin
inherited Create;
FPath := APath;
FDescription := ADescription;
FIsHtml := AIsHtml;
end;
end.TPageInfo enthält den Seitentitel und den Zeitpunkt, zu dem die Seite gerendert wurde. TEndpoint beschreibt eine URL unseres Servers, und IsHtml verrät, ob sie HTML oder JSON liefert. Hier gibt es wirklich keine Magie: schlichte Klassen mit schreibgeschützten Eigenschaften. Wir brauchen weder published-Eigenschaften noch Attribute; die offiziellen WebStencils-Demos verwenden genau diese Art von Klassen mit public-Eigenschaften.
Schritt 2: Die Templates
Als Nächstes schreiben wir zwei HTML-Dateien in einem Ordner namens templates. Die erste ist das Layout, also der Rahmen, den alle Seiten unserer Website gemeinsam haben:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@info.Title</title>
<style>
body { margin: 0; padding: 3rem 1.5rem; background: #f5f5f7; color: #1d1d1f;
font: 17px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
main { max-width: 40rem; margin: 0 auto; padding: 2.5rem 3rem; background: #fff;
border-radius: 18px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06); }
h1 { margin: 0 0 0.75rem; font-size: 2.25rem; font-weight: 700; letter-spacing: -0.02em; }
p { color: #6e6e73; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
ul { padding-left: 1.2rem; }
li { margin: 0.4rem 0; }
table { width: 100%; border-collapse: collapse; margin: 1.25rem 0; }
th { text-align: left; font-size: 0.8rem; text-transform: uppercase;
letter-spacing: 0.04em; color: #86868b; font-weight: 600; }
th, td { padding: 0.65rem 0.5rem; border-bottom: 1px solid #e8e8ed; }
tr:last-child td { border-bottom: 0; }
hr { border: 0; border-top: 1px solid #e8e8ed; margin: 2rem 0 1rem; }
small { color: #86868b; font-size: 0.8rem; }
</style>
</head>
<body>
<main>
@RenderBody
<hr />
<small>Rendered by WebStencils at @info.RenderedAt -- write @@info to show a literal at sign.</small>
</main>
</body>
</html>Drei Dinge sind erwähnenswert:
@info.Titlewird durch die EigenschaftTitledes Objekts ersetzt, das wir alsinforegistrieren. Das ist die gesamte Syntax, um einen Wert zu lesen.@RenderBodymarkiert die Stelle, an der der Inhalt der eigentlichen Seite eingefügt wird.@@erzeugt ein wörtliches@. Das brauchen Sie öfter, als Sie denken -- jede E-Mail-Adresse und viele CDN-URLs enthalten eines.
Der <style>-Block sorgt nur dafür, dass die Seite ansehnlich aussieht, und WebStencils lässt ihn in Ruhe -- mit einer Ausnahme, die Punkt 3 schön veranschaulicht. WebStencils beansprucht jedes @ in einem Template für sich, auch die im CSS. Als ich eine @media-Regel ergänzt habe, kam sie im Browser als media. an, ohne jede Fehlermeldung, einfach als kaputtes CSS. Schreiben Sie stattdessen @@media.
Die zweite Datei ist die Seite selbst. Speichern Sie sie als templates/home.html:
@LayoutPage layout
<h1>@info.Title</h1>
<p>This page and the JSON service run in the same Delphi process.</p>
<ul>
@ForEach (var endpoint in endpoints) {
<li>
<a href="@endpoint.Path">@endpoint.Path</a> -- @endpoint.Description
@if (endpoint.IsHtml) {
(HTML)
} @else {
(JSON)
}
</li>
}
</ul>Wieder die Anmerkungen:
@LayoutPage layoutin der ersten Zeile besagt: Rendere mich innerhalb vonlayout.html. Der Name wird ohne Dateiendung angegeben.@ForEach (var endpoint in endpoints) { ... }wiederholt den Block für jedes Element der Liste, die wir alsendpointsregistrieren. Innerhalb des Blocks istendpointdas aktuelle Element.@if (endpoint.IsHtml) { ... } @else { ... }wählt anhand einer booleschen Eigenschaft einen von zwei Blöcken aus.
Schritt 3: Die Seite im Web-Modul rendern
Jetzt verbinden wir die Templates mit dem Web-Modul aus dem vorherigen Beitrag. Im Konstruktor brauchen wir eine weitere Route:
AddRoute('/hello', mtGet, HomeAction);Außerdem benötigen wir eine Hilfsfunktion, die die Templates findet, und den Action-Handler selbst. Fügen Sie die Units System.IOUtils, System.Generics.Collections, Web.Stencils und HelloPageModel zur uses-Klausel des implementation-Abschnitts hinzu und ergänzen Sie dann Folgendes:
function TemplateFileName(const AName: string): string;
begin
// the templates folder sits next to the executable
Result := TPath.Combine(
TPath.Combine(ExtractFilePath(ParamStr(0)), 'templates'), AName);
end;procedure THelloModule.HomeAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
var
Processor: TWebStencilsProcessor;
Endpoints: TObjectList<TEndpoint>;
begin
Processor := TWebStencilsProcessor.Create(nil);
try
// (7) the page to render; it names its own layout
Processor.InputFileName := TemplateFileName('home.html');
// (8) the data -- True hands ownership to the processor
Processor.AddVar('info', TPageInfo.Create('Hello from WebStencils!'), True);
Endpoints := TObjectList<TEndpoint>.Create;
Endpoints.Add(TEndpoint.Create('/hello/HelloService/Hello',
'returns a greeting', False));
Endpoints.Add(TEndpoint.Create('/hello/HelloService/Add?A=2&B=3',
'adds two integers', False));
Endpoints.Add(TEndpoint.Create('/hello', 'this page', True));
Processor.AddVar('endpoints', Endpoints, True);
// (9) Content runs the template engine and returns the HTML
Response.ContentType := 'text/html; charset=utf-8';
Response.Content := Processor.Content;
finally
Processor.Free;
end;
end;Die Nummerierung setzt dort an, wo der vorherige Beitrag aufgehört hat:
InputFileNameist das Template, das gerendert werden soll. Wir lassen es aufhome.htmlzeigen, und der Prozessor folgt der@LayoutPage-Zeile von selbst zulayout.htmlim selben Ordner.AddVarregistriert ein Objekt unter einem Namen. Der dritte Parameter,True, übergibt den Besitz an den Prozessor: Wenn wir den Prozessor freigeben, gibt erinfound die Liste frei. DaTObjectList<TEndpoint>seine Elemente besitzt, werden die Endpoints gleich mit freigegeben. Keine Speicherlecks und keintry..finallyfür jedes einzelne Objekt.- Das Lesen von
Contentstartet die Engine und liefert das fertige HTML, das wir an die Antwort übergeben.
Sie fragen sich vielleicht, warum wir für jede Anfrage einen neuen Prozessor erzeugen, statt einen auf das Web-Modul zu legen. Das ginge; die offiziellen Demos machen genau das bei ihren einfacheren Beispielen. Ein frischer Prozessor pro Anfrage bedeutet aber, dass Daten aus einer Anfrage niemals in der nächsten auftauchen können. Meiner Meinung nach ist diese Garantie die geringen Kosten wert, pro Anfrage ein Objekt zu erzeugen, und für eine erste Seite bevorzuge ich die Variante, die mich nicht überraschen kann.
Zum Schluss fügen Sie HelloPageModel zur uses-Klausel von HelloServer.dpr hinzu. Die vollständigen Quelltexte finden Sie am Ende dieses Beitrags.
Schritt 4: Starten
Kompilieren und starten Sie den Server. Bevor Sie den Browser öffnen, kopieren Sie allerdings den Ordner templates neben HelloServer.exe. TemplateFileName sucht ihn dort, und wenn Sie in der IDE kompilieren, landet die ausführbare Datei in einem Ausgabeordner wie Win32\Debug und nicht neben Ihren Quelltexten. Ich verspreche Ihnen: Das ist der erste Fehler, über den Sie mit WebStencils stolpern werden, und er hat mit den Templates selbst nichts zu tun. Ohne den Ordner antwortet /hello mit einem 500 und der Standard-Fehlerseite "Internal Application Error" von WebBroker, auf der Cannot create file "...\templates\home.html". The system cannot find the path specified steht. Immerhin verrät die Meldung genau, wo der Server gesucht hat -- allerdings verrät sie das jedem Besucher, lassen Sie es in der Produktion also nicht dabei.

templates neben der ausführbaren Datei, und WebBroker antwortet mit seiner Standard-500-Seite.Öffnen Sie nun http://localhost:8080/hello. Die Überschrift lautet „Hello from WebStencils!“, die Liste zeigt unsere drei URLs, die letzte als HTML gekennzeichnet, und die Fußzeile verrät Ihnen, wann die Seite gerendert wurde. Laden Sie die Seite neu, und die Uhrzeit ändert sich: Das ist serverseitiges Rendering in seiner einfachsten Form. Die JSON-URLs aus dem vorherigen Beitrag funktionieren genau wie zuvor -- und sind jetzt anklickbar.

home.html in layout.html, mit Schleife, Bedingung und der Renderzeit in der Fußzeile.Wo diese Einführung endet
Ich habe diese erste Seite bewusst klein gehalten, und WebStencils kann noch deutlich mehr. Es gibt @Import für wiederverwendbare Fragmente, @switch für Mehrfachverzweigungen und über @query Zugriff auf die Anfrage. Seit RAD Studio 13 arbeitet es außerdem mit der neuen Sitzungsverwaltung und den Authentifizierungskomponenten von WebBroker zusammen, die Embarcaderos Überblick über die Neuerungen in 13.0 beschreibt. Derselbe Überblick erwähnt eine Whitelist, die festlegt, auf welche Member ein Template zugreifen darf. Beachten Sie, dass sie ab Werk nur Nachfahren von TDataSet und TStrings einschränkt; jede öffentliche Eigenschaft Ihrer eigenen Klassen bleibt lesbar, bis Sie TWebStencilsProcessor.Whitelist selbst konfigurieren -- das lohnt sich, wenn Ihre Objekte Daten enthalten, die niemals in einer Seite landen sollten.
Fazit
Wir haben unseren WebBroker-Server um serverseitig gerendertes HTML erweitert, mit zwei Template-Dateien, einer Unit mit zwei schlichten Klassen und einem Action-Handler. WebStencils liest Eigenschaften registrierter Objekte mit @name.Property, iteriert mit @ForEach, entscheidet mit @if und teilt einen Rahmen zwischen Seiten mit @LayoutPage und @RenderBody.
Eine WebStencils-Seite ist HTML mit ein paar
@-Zeichen darin. Die Delphi-Seite muss nur sagen, welche Objekte unter welchen Namen bereitstehen.
Im nächsten Beitrag ersetzen wir die fest verdrahtete Liste durch echte Daten: Wir legen eine SQLite-Datenbank im Code an -- Datei, Tabelle und Beispieldatensätze -- und rendern eine Kundentabelle direkt aus einer FireDAC-Abfrage. Bis dahin: Ändern Sie die Templates, ergänzen Sie TPageInfo um eine Eigenschaft und schauen Sie, was passiert. Viel Spaß!
Vollständiger Quellcode
Zur Referenz hier die beiden Dateien, die sich gegenüber dem vorherigen Beitrag geändert haben. Die unveränderte HelloWebModule.dfm und die Templates sind weiter oben abgebildet.
program HelloServer;
{$APPTYPE CONSOLE}
uses
System.SysUtils,
Web.WebReq,
IdHTTPWebBrokerBridge,
HelloPageModel in 'HelloPageModel.pas',
HelloWebModule in 'HelloWebModule.pas' {HelloModule: TWebModule};
const
PORT = 8080;
var
Server: TIdHTTPWebBrokerBridge;
begin
try
// (1) tell WebBroker which class answers the requests
WebRequestHandler.WebModuleClass := THelloModule;
// (2) the HTTP server: Indy, bridged to WebBroker
Server := TIdHTTPWebBrokerBridge.Create(nil);
try
Server.DefaultPort := PORT;
Server.Active := True;
WriteLn('WebBroker server running on port ', PORT);
WriteLn('Try: http://localhost:8080/hello');
WriteLn('Press Enter to stop.');
ReadLn;
Server.Active := False;
finally
Server.Free;
end;
except
on E: Exception do
begin
WriteLn(E.ClassName, ': ', E.Message);
ExitCode := 1;
end;
end;
end.unit HelloWebModule;
interface
uses
System.SysUtils,
System.Classes,
System.JSON,
Web.HTTPApp;
type
THelloModule = class(TWebModule)
private
procedure AddRoute(const APathInfo: string; AMethod: TMethodType;
AHandler: THTTPMethodEvent; ADefault: Boolean = False);
procedure SendJson(Response: TWebResponse; AStatusCode: Integer;
AJson: TJSONObject);
procedure Cors(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
procedure HelloAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
procedure AddAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
procedure HomeAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
procedure NotFoundAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
public
constructor Create(AOwner: TComponent); override;
end;
implementation
uses
System.IOUtils,
System.Generics.Collections,
Web.Stencils,
HelloPageModel;
{$R *.dfm}
function TemplateFileName(const AName: string): string;
begin
// the templates folder sits next to the executable
Result := TPath.Combine(
TPath.Combine(ExtractFilePath(ParamStr(0)), 'templates'), AName);
end;
{ THelloModule }
constructor THelloModule.Create(AOwner: TComponent);
begin
inherited;
// (1) runs before any action -- adds the CORS headers
BeforeDispatch := Cors;
// (2) one action per URL
AddRoute('/hello/HelloService/Hello', mtGet, HelloAction);
AddRoute('/hello/HelloService/Add', mtGet, AddAction);
AddRoute('/hello', mtGet, HomeAction);
// (3) the default action answers everything else
AddRoute('', mtAny, NotFoundAction, True);
end;
procedure THelloModule.AddRoute(const APathInfo: string; AMethod: TMethodType;
AHandler: THTTPMethodEvent; ADefault: Boolean);
var
Item: TWebActionItem;
begin
Item := Actions.Add;
Item.PathInfo := APathInfo;
Item.MethodType := AMethod;
Item.Default := ADefault;
Item.OnAction := AHandler;
end;
procedure THelloModule.SendJson(Response: TWebResponse; AStatusCode: Integer;
AJson: TJSONObject);
begin
try
Response.StatusCode := AStatusCode;
Response.ContentType := 'application/json; charset=utf-8';
Response.Content := AJson.ToJSON;
finally
AJson.Free;
end;
end;
procedure THelloModule.Cors(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
begin
// (4) CORS: '*' allows EVERY origin, which effectively switches the
// browser's cross-origin protection off for this server. Fine for
// development; in production, replace '*' with the origin of your
// web application, e.g. 'https://app.example.com'.
Response.SetCustomHeader('Access-Control-Allow-Origin', '*');
// (5) answer the browser's preflight request right here
if SameText(Request.Method, 'OPTIONS') then
begin
Response.SetCustomHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
Response.SetCustomHeader('Access-Control-Allow-Headers', 'Content-Type');
Response.StatusCode := 204;
Handled := True;
end;
end;
procedure THelloModule.HelloAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
begin
SendJson(Response, 200,
TJSONObject.Create.AddPair('value', 'Hello from WebBroker!'));
end;
procedure THelloModule.AddAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
var
A, B: Integer;
begin
// (6) nobody converts the parameters for us -- we do it ourselves
if TryStrToInt(Request.QueryFields.Values['A'], A) and
TryStrToInt(Request.QueryFields.Values['B'], B) then
SendJson(Response, 200,
TJSONObject.Create.AddPair('value', TJSONNumber.Create(A + B)))
else
SendJson(Response, 400,
TJSONObject.Create.AddPair('error', 'A and B must be integers'));
end;
procedure THelloModule.HomeAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
var
Processor: TWebStencilsProcessor;
Endpoints: TObjectList<TEndpoint>;
begin
Processor := TWebStencilsProcessor.Create(nil);
try
// (7) the page to render; it names its own layout
Processor.InputFileName := TemplateFileName('home.html');
// (8) the data -- True hands ownership to the processor
Processor.AddVar('info', TPageInfo.Create('Hello from WebStencils!'), True);
Endpoints := TObjectList<TEndpoint>.Create;
Endpoints.Add(TEndpoint.Create('/hello/HelloService/Hello',
'returns a greeting', False));
Endpoints.Add(TEndpoint.Create('/hello/HelloService/Add?A=2&B=3',
'adds two integers', False));
Endpoints.Add(TEndpoint.Create('/hello', 'this page', True));
Processor.AddVar('endpoints', Endpoints, True);
// (9) Content runs the template engine and returns the HTML
Response.ContentType := 'text/html; charset=utf-8';
Response.Content := Processor.Content;
finally
Processor.Free;
end;
end;
procedure THelloModule.NotFoundAction(Sender: TObject; Request: TWebRequest;
Response: TWebResponse; var Handled: Boolean);
begin
SendJson(Response, 404,
TJSONObject.Create.AddPair('error', 'Not found: ' + Request.PathInfo));
end;
end.