Nello scorso articolo abbiamo introdotto concettualmente il template engine, spiegato le funzionalità e gli obiettivi che mi sono proposto prima di procedere con l'implementazione. Sviluppare un template engine può essere un'operazione molto complessa, con funzionalità che esulano dagli obiettivi di questo articolo. Oggi ci addentreremo nel codice di base fornito dal package taste.mvc.template
, che fornisce un'implementazione di tutte le classi introdotte nell'articolo precedente in modo che risultino utilizzabili direttamente nelle proprie applicazioni.
Nel prossimo articolo completeremo il core con l'implementazione del sistema di plugin e del plugin di default che espone le funzionalità standard elencate nell'articolo precedente.
Come sempre potete trovare i sorgenti allegati a questo articolo, nella sezione download.
Lo Scope delle variabili
La prima classe di cui andiamo ad analizzare l'implementazione è la classe Scope
, che si occupa di contenere lo stato attuale dei valori associati a determinate variabili. Normalmente lo scope contiene tutte le variabili associate al template engine a runtime attraverso la funzione assign
(la cui implementazione sarà discussa più avanti); in alcuni casi però, come all'interno di cicli repeat
oppure di blocchi XML particolari, ci si trova nella condizione di dover cambiare il valore di una variabile solamente all'interno di quel determinato ciclo o blocco (come avviene in un normale linguaggio di programmazione quando si definisce una variabile all'interno di un blocco di codice solitamente delimitato da parentesi graffe).
Lo Scope permette anche questo, fornendo delle funzioni che permettono di aggiungere un nuovo scope a quello corrente che andrà a sovrascrivere temporaneamente quello globale. In realtà non avviene una vera e propria sovrascrittura: come vedremo i dati sono memorizzati in un array associativo che normalmente viene letto quando vi è la necessità di recuperare determinate informazioni; quando viene aggiunto uno scope, viene inserito un nuovo array associativo, che viene letto prima di quello già presente. Se viene trovato qualcosa si restituisce quel valore, altrimenti si arretra nella lista di scope aggiunti. In questo modo si ha un'idea di sovrascrittura dei valori preservando comunque quelli globali.
Passiamo all'implementazione:
class Scope { private $scopes; private $blank_invalid; private $blank_null; private $template; public function __construct(Template $template) { $this->template = $template; $this->scopes = array($template->context); $this->blank_invalid = $template->config->get('invalidAsBlank', 'on') == 'on'; $this->blank_null = $template->config->get('nullAsBlank', 'on') == 'on'; } public function get($key, $expects='') { static $empty = array( 'array' => array(), 'number' => 0, 'string' => '', '' => '' ); $index = count($this->scopes) - 1; while($index >= 0) { $context = $this->scopes[$index--]; if(!array_key_exists($key, $context)) continue; $value = $context[$key]; if(is_null($value)) { if($this->blank_null) return $empty[$expects]; else break; } return $value; } if($this->blank_invalid) return $empty[$expects]; throw new TemplateException('Key not found: '.$key); } public function evaluateVar($key) { $chunks = explode(".", $key); $base = array_shift($chunks); $value = $this->get($base); if(count($chunks) > 0) { foreach($chunks as $chunk) { if(!is_array($value) or !array_key_exists($chunk, $value)) { if($this->blank_invalid) return ''; throw new TemplateException('Cant evaluate var: '.$key); } $value = $value[$chunk]; } } return $value; } public function set($key, $value) { $this->scopes[count($this->scopes) - 1][$key] = $value; } public function pushScope() { $this->scopes[] = array(); } public function popScope() { array_pop($this->scopes); } }
L'implementazione della classe Scope rispecchia il comportamento descritto precedentemente. Gli array contenenti le coppie chiave/valore associate allo scope corrente sono contenuti all'interno di un altro array ($scopes
) a cui vengono aggiunti o sottratti scope attraverso i metodi pushScope
e popScope
. Con set
assegnamo una coppia chiave valore allo scope corrente. Al contrario la funzione get cerca ricorsivamente nella lista degli scope al fine di trovare la variabile specificata come chiave. Se questa esiste viene restituita, altrimenti la classe controlla il valore dell'opzione invalidAsBlank
: in caso sia vera viene restituito un valore negativo (eventualmente valutato in base al valore del parametro expects, che indica che tipo di dato ci si aspetta di ritorno) altrimenti viene restituita l'eccezione TemplateException
.
Infine abbiamo un metodo di supporto chiamata evaluateVar
: il suo comportamento è simile a get con la differenza che il parametro passato come primo valore può essere una stringa formata da sottostringhe separate da punti. La prima sottostringa è utilizzata come chiave per cercare il valore corretto all'interno dello scope corrente, le successive invece vengono utilizzate come se fossero chiavi ricorsive di un array associativo (ex: test.chiave1.chiave2
è come se corrispondesse a $test['chiave1']['chiave2']
).
Il Template Engine
Dopo aver discusso l'implementazione della classe Scope, che risulta fondamentale al fine di permettere una corretta dinamicità al template, passiamo al cuore del template engine. Diversamente da come si potrebbe pensare, il codice necessario ad implementare correttamente questa classe è relativamente semplice e molto compatto; tutto questo è dovuto alle scelte implementative prese in precedenza ed alle limitazioni che mi sono imposto in fase di progettazione.
Il Template Engine, rappresentato dalla classe configurabile Template
, si basa fondamentalmente sulla navigazione dell'albero DOM estratto dai sorgenti di un template XML: dai nodi testuali vengono estratte e sostituite le variabili con i rispettivi valori (una variabile è dentificata da una sequenza di caratteri o punti anteposti al simbolo del dollaro), mentre ai nodi che corrispondono agli elementi vengono applicati gli eventuali plugin, che si occupano di modificare la struttura del DOM per adattarla alle funzionalità implementate.
Tramite il metodo assign
viene assegnata una variabile allo scope globale del template, mentre il metodo render si occupa di caricare il template assegnata in fase di costruzione dal filesystem ed eseguire su di essa le operazioni sopra descritte.
Vediamo insieme la parte di codice relativa alla valutazione dei nodi (il resto del codice potete trovarlo in taste/mvc/template/Template.php
):
class Template
{
...
public function evaluateNode($child, Scope $scope)
{
switch($child->nodeType)
{
case XML_TEXT_NODE:
$data = $child->data;
preg_match_all('/$(([a-zA-Z_][a-zA-Z_0-9]*)(.[a-zA-Z_][a-zA-Z_0-9]*)*)/m', $data, $matches);
if(count($matches) > 1)
{
$vars = $matches[1];
usort($vars, array($this, 'sortVars'));
foreach($vars as $variable)
$data = str_replace('$'.$variable, $scope->evaluateVar($variable), $data);
}
$child->data = $data;
break;
case XML_ELEMENT_NODE:
$evaluate_nodes = true;
$nsuri = $child->namespaceURI;
if(!is_null($nsuri))
{
$plugin = $this->getPlugin($nsuri);
$tag = $child->localName;
if(!$plugin->supportTag($tag))
throw new TemplateException('Plugin '.$nsuri.' does not support '.$tag.' tag');
$new_node = call_user_func(array($plugin, "tag_".$tag), $this, $scope, $child);
if(!is_null($new_node))
{
$child->parentNode->replaceChild($new_node, $child);
$child = $new_node;
}
$evaluate_nodes = false;
}
if($child->attributes)
{
$commands = array();
foreach($child->attributes as $node_attribute)
{
if(!$node_attribute->namespaceURI)
continue;
$nsuri = $node_attribute->namespaceURI;
if(!array_key_exists($nsuri, $commands))
$commands[$nsuri] = array();
$commands[$nsuri][] = array($node_attribute->localName, $node_attribute->value);
}
uksort($commands, array($this, "sortPlugins"));
foreach($commands as $uri => $cmd)
{
$plugin = $this->getPlugin($uri);
usort($cmd, array($plugin, 'sortCommands'));
$cmd = array($cmd[0]);
foreach($cmd as $command)
{
list($command, $expression) = $command;
if(!$plugin->supportAttribute($command))
throw new TemplateException('Plugin '.$uri.' does not support '.$command.' attribute');
// rimuovo automaticamente l'attributo dal tag
$child->removeAttributeNS($uri, $command);
$new_node = call_user_func(array($plugin, "attribute_".$command), $this, $scope, $child, $expression);
if(!is_null($new_node))
{
$child->parentNode->replaceChild($new_node, $child);
$child = $new_node;
}
}
$evaluate_nodes = false;
}
}
if($evaluate_nodes)
$this->evaluateTree($child, $scope);
break;
}
return $child;
}
public function evaluateTree($node, Scope $scope)
{
for($i = 0; $i < $node->childNodes->length; ++$i)
{
$child = $node->childNodes->item($i);
$this->evaluateNode($child, $scope);
}
return $node;
}
...
}
Il metodo evaluateTree
applica ad ogni figlio del nodo passato come primo argomento il metodo evaluateNode
; in questo metodo viene eseguito tutto il codice necessario ad applicare i plugin, valutare le variabili oppure restituire in output il nodo intatto: per ogni nodo viene analizzato prima il tag e successivamente gli attributi.
Per ognuno di questi viene recuperato il namespace assegnato ed in caso non fosse nullo viene caricato il plugin corrispondente nel quale vengono ricercati i metodi necessari alla trasformazione del nodo. In caso esistano vengono applicati al nodo in base all'ordine definito dall'implementazione del plugin, e si passa al nodo successivo. È compito del singolo metodo valutare i figli del nodo a cui viene applicato il plugin, come vedremo nel prossimo articolo.