Erstellen einer Template-Engine von Grund auf (Wie Jinja2 oder Django Templates)
Daniel Hayes
Full-Stack Engineer · Leapcell

Implementierung und Prinzipanalyse einer einfachen Template-Engine
Wir werden mit dem Schreiben einer einfachen Template-Engine beginnen und deren zugrunde liegenden Implementierungsmechanismus eingehend untersuchen.
Sprachdesign
Das Design dieser Template-Sprache ist äußerst einfach und verwendet hauptsächlich zwei Arten von Tags: Variablentags und Blocktags.
Variablentags
Variablentags verwenden {{
und }}
als Kennungen. Hier ist ein Beispielcode:
// Variablen verwenden `{{` und `}}` als Kennungen <div>{{template_variable}}</div>
Blocktags
Blocktags verwenden {%
und %}
als Kennungen. Die meisten Blöcke benötigen ein schließendes Tag {% end %}
, um zu enden. Das Folgende ist ein Beispiel:
// Blöcke verwenden `{%` und `%}` als Kennungen {% each item_list %} <div>{{current_item}}</div> {% end %}
Diese Template-Engine kann grundlegende Schleifen- und Bedingungsanweisungen verarbeiten und unterstützt auch das Aufrufen von Callable Objects innerhalb von Blöcken. Es ist sehr bequem, jede Python-Funktion im Template aufzurufen.
Schleifenstruktur
Die Schleifenstruktur kann verwendet werden, um über Sammlungen oder Iterable Objects zu iterieren. Der Beispielcode ist wie folgt:
// Iteriere über die Personen-Sammlung {% each person_list %} <div>{{current_person.name}}</div> {% end %} // Iteriere über die [1, 2, 3]-Liste {% each [1, 2, 3] %} <div>{{current_num}}</div> {% end %} // Iteriere über die Datensatz-Sammlung {% each record_list %} <div>{{..outer_name}}</div> {% end %}
In den obigen Beispielen sind person_list
und dergleichen Sammlungen, und current_person
und dergleichen verweisen auf das aktuell iterierte Element. Ein durch Punkte getrennter Pfad wird als ein Wörterbuchattribut geparst, und ..
kann verwendet werden, um auf Objekte im äußeren Kontext zuzugreifen.
Bedingungsanweisungen
Die Logik von Bedingungsanweisungen ist relativ intuitiv. Diese Sprache unterstützt if
- und else
-Strukturen sowie Operatoren wie ==
, <=
, >=
, !=
, is
, <
, >
. Das Beispiel ist wie folgt:
// Gib unterschiedliche Inhalte gemäß dem Wert von num aus {% if num > 5 %} <div>mehr als 5</div> {% else %} <div>weniger als oder gleich 5</div> {% end %}
Aufrufende Blöcke
Callable Objects können über den Template-Kontext übergeben und mit gewöhnlichen Positionsargumenten oder benannten Argumenten aufgerufen werden. Aufrufende Blöcke erfordern keine Verwendung von end
zum Schließen. Die Beispiele sind wie folgt:
// Verwende gewöhnliche Argumente <div class='date'>{% call format_date date_created %}</div> // Verwende benannte Argumente <div>{% call log_message 'hier' verbosity='debug' %}</div>
Kompilierungsprinzip und -prozess
Schritt 1: Template-Tokenisierung (tokenize)
Prinzip
Die Template-Tokenisierung ist der Startschritt der Kompilierung, und ihr Hauptziel ist es, den Template-Inhalt in unabhängige Fragmente zu unterteilen. Diese Fragmente können gewöhnlicher HTML-Text oder Variablentags oder Blocktags sein, die im Template definiert sind. Mathematisch gesehen ist dies ähnlich dem Aufteilen einer komplexen Zeichenkette, wobei sie gemäß bestimmten Regeln in mehrere Unterzeichenketten zerlegt wird.
Implementierung
Verwendet reguläre Ausdrücke und die Funktion split()
, um die Textaufteilung abzuschließen. Hier ist das spezifische Codebeispiel:
import re # Definiere die Start- und End-Kennungen von Variablentags VAR_TOKEN_START = '{{' VAR_TOKEN_END = '}}' # Definiere die Start- und End-Kennungen von Blocktags BLOCK_TOKEN_START = '{%' BLOCK_TOKEN_END = '%}' # Kompiliere den regulären Ausdruck zum Abgleichen von Variablentags oder Blocktags TOK_REGEX = re.compile(r"(%s.*?%s|%s.*?%s)" % ( VAR_TOKEN_START, VAR_TOKEN_END, BLOCK_TOKEN_START, BLOCK_TOKEN_END ))
Die Bedeutung des regulären Ausdrucks TOK_REGEX
besteht darin, Variablentags oder Blocktags abzugleichen, um die Aufteilung des Textes zu erreichen. Die äußersten Klammern des Ausdrucks werden verwendet, um den übereinstimmenden Text zu erfassen, und ?
steht für eine nicht-gierige Übereinstimmung, die sicherstellt, dass der reguläre Ausdruck bei der ersten Übereinstimmung stoppt. Das Beispiel ist wie folgt:
# Zeige tatsächlich den Aufteilungseffekt des regulären Ausdrucks >>> TOK_REGEX.split('{% each vars %}<i>{{it}}</i>{% endeach %}') ['{% each vars %}', '<i>', '{{it}}', '</i>', '{% endeach %}']
Anschließend wird jedes Fragment in ein Fragment
-Objekt gekapselt, das den Typ des Fragments enthält und als Parameter für die Kompilierungsfunktion verwendet werden kann. Es gibt insgesamt vier Arten von Fragmenten:
# Definiere Fragmenttyp-Konstanten VAR_FRAGMENT = 0 OPEN_BLOCK_FRAGMENT = 1 CLOSE_BLOCK_FRAGMENT = 2 TEXT_FRAGMENT = 3
Schritt 2: Aufbau eines abstrakten Syntaxbaums (AST)
Prinzip
Ein abstrakter Syntaxbaum (AST) ist eine Datenstruktur, die den Quellcode auf strukturierte Weise darstellt und die syntaktische Struktur des Codes in Form eines Baums darstellt. Bei der Template-Kompilierung besteht der Zweck des Aufbaus eines AST darin, die aus der Tokenisierung erhaltenen Fragmente in einer hierarchischen Struktur zu organisieren, was die anschließende Verarbeitung und das Rendering erleichtert. Mathematisch gesehen ist dies ähnlich dem Erstellen eines Baumdiagramms, wobei jeder Knoten eine syntaktische Einheit darstellt und die Beziehungen zwischen Knoten die logische Struktur des Codes widerspiegeln.
Implementierung
Nach Abschluss der Tokenisierung iteriere über jedes Fragment und erstelle den Syntaxbaum. Verwende die Klasse Node
als Basisklasse für Baumknoten und erstelle Unterklassen für jeden Knotentyp. Jede Unterklasse muss die Methoden process_fragment
und render
bereitstellen. process_fragment
wird verwendet, um den Fragmentinhalt weiter zu parsen und die erforderlichen Attribute im Objekt Node
zu speichern. Die Methode render
ist dafür verantwortlich, den Inhalt des entsprechenden Knotens mithilfe des bereitgestellten Kontexts in HTML zu konvertieren.
Hier ist die Definition der Basisklasse Node
:
class TemplateNode(object): def __init__(self, fragment=None): # Speichere untergeordnete Knoten self.children = [] # Markiere, ob ein neuer Gültigkeitsbereich erstellt werden soll self.creates_scope = False # Verarbeite das Fragment self.process_fragment(fragment) def process_fragment(self, fragment): pass def enter_scope(self): pass def render(self, context): pass def exit_scope(self): pass def render_children(self, context, children=None): if children is None: children = self.children def render_child(child): child_html = child.render(context) return '' if not child_html else str(child_html) return ''.join(map(render_child, children))
Hier ist die Definition des Variablenknotens:
class TemplateVariable(_Node): def process_fragment(self, fragment): # Speichere den Variablennamen self.name = fragment def render(self, context): # Löse den Variablenwert im Kontext auf return resolve_in_context(self.name, context)
Um den Typ des Node
zu bestimmen und die richtige Klasse zu initialisieren, ist es notwendig, den Typ und den Text des Fragments zu überprüfen. Text- und Variablenfragmente können direkt in Textknoten und Variablenknoten konvertiert werden, während Blockfragmente eine zusätzliche Verarbeitung erfordern, und ihre Typen werden durch die Blockbefehle bestimmt. Zum Beispiel ist {% each items %}
ein Blockknoten des Typs each
.
Ein Knoten kann auch einen Gültigkeitsbereich erstellen. Während der Kompilierung erfassen wir den aktuellen Gültigkeitsbereich und machen den neuen Knoten zu einem untergeordneten Knoten des aktuellen Gültigkeitsbereichs. Sobald das richtige schließende Tag gefunden wird, wird der aktuelle Gültigkeitsbereich geschlossen, und der Gültigkeitsbereich wird aus dem Gültigkeitsbereichs-Stack entfernt, wodurch die Oberseite des Stacks zum neuen Gültigkeitsbereich wird. Der Beispielcode ist wie folgt:
def template_compile(self): # Erstelle den Wurzelknoten root = _Root() # Initialisiere den Gültigkeitsbereichs-Stack scope_stack = [root] for fragment in self.each_fragment(): if not scope_stack: raise TemplateError('nesting issues') # Hole den aktuellen Gültigkeitsbereich parent_scope = scope_stack[-1] if fragment.type == CLOSE_BLOCK_FRAGMENT: # Verlasse den aktuellen Gültigkeitsbereich parent_scope.exit_scope() # Entferne den aktuellen Gültigkeitsbereich scope_stack.pop() continue # Erstelle einen neuen Knoten new_node = self.create_node(fragment) if new_node: # Füge den neuen Knoten zur untergeordneten Knotenliste des aktuellen Gültigkeitsbereichs hinzu parent_scope.children.append(new_node) if new_node.creates_scope: # Füge den neuen Knoten zum Gültigkeitsbereichs-Stack hinzu scope_stack.append(new_node) # Tritt in den neuen Gültigkeitsbereich ein new_node.enter_scope() return root
Schritt 3: Rendern
Prinzip
Rendern ist der Prozess der Konvertierung des erstellten AST in die endgültige HTML-Ausgabe. In diesem Prozess müssen gemäß dem Typ der AST-Knoten und Kontextinformationen die Variablen und die Logik im Template durch tatsächliche Werte und Inhalte ersetzt werden. Mathematisch gesehen ist dies ähnlich dem Durchlaufen und Auswerten einer Baumstruktur, wobei die Informationen jedes Knotens gemäß den Regeln konvertiert und kombiniert werden.
Implementierung
Der letzte Schritt ist das Rendern des AST in HTML. Dieser Schritt besucht alle Knoten im AST und ruft die Methode render
mit dem Parameter context
auf, der an das Template übergeben wird. Während des Rendering-Prozesses löst render
kontinuierlich die Werte von Kontextvariablen auf. Sie können die Funktion ast.literal_eval
verwenden, um Zeichenketten, die Python-Code enthalten, sicher auszuführen. Der Beispielcode ist wie folgt:
import ast def eval_expression(expr): try: return 'literal', ast.literal_eval(expr) except (ValueError, SyntaxError): return 'name', expr
Wenn Kontextvariablen anstelle von Literalen verwendet werden, müssen ihre Werte im Kontext gesucht werden. Hier ist es notwendig, Variablennamen zu behandeln, die Punkte enthalten, und Variablen, die mit zwei Punkten auf den äußeren Kontext zugreifen. Hier ist die Implementierung der Funktion resolve
:
def resolve(name, context): if name.startswith('..'): # Hole den äußeren Kontext context = context.get('..', {}) name = name[2:] try: for tok in name.split('.'): # Suche die Variable im Kontext context = context[tok] return context except KeyError: raise TemplateContextError(name)
Fazit
Es ist zu hoffen, dass Sie durch dieses einfache Beispiel ein vorläufiges Verständnis des Funktionsprinzips der Template-Engine erlangen können. Obwohl dieser Code noch weit von einem Produktionsniveau entfernt ist, kann er als Grundlage für die Entwicklung vollständigerer Tools dienen.
Referenz: https://github.com/alexmic/microtemplates
Leapcell: The Best of Serverless Web Hosting
Abschließend möchte ich eine Plattform empfehlen, die sich am besten für die Bereitstellung von Python-Diensten eignet: Leapcell
🚀 Build with Your Favorite Language
Entwickeln Sie mühelos in JavaScript, Python, Go oder Rust.
🌍 Deploy Unlimited Projects for Free
Zahlen Sie nur für das, was Sie nutzen – keine Anfragen, keine Gebühren.
⚡ Pay-as-You-Go, No Hidden Costs
Keine Leerlaufgebühren, nur nahtlose Skalierbarkeit.
🔹 Follow us on Twitter: @LeapcellHQ