ZURÜCK

Doctrine2 ORM standalone Demo

Erstellung einer Webanwendung mit Doctrine 2 ORM ohne Symfony.

Datum: 2014-04, Tags: PHP, Doctine 2, Twig, Howto
An english translation is availlable at github: https://github.com/limitland/Doctrine-2-standalone-Demo.


Mit Symfony 2 zu arbeiten ist für mich eine große Erleichterung. Aber gerade bei kleineren Webanwendungen möchte ich nicht immer das komplette Framework einsetzen, sondern nur einzelne Komponenten. Das sind insbesondere Twig als Template-Engine und Doctrine DBAL und ORM. Gerade der Object Relation Mapper ORM erleichtert mir den Umgang mit der Datenbank erheblich.

Beide Projekte werden mit der Standard-Version von Symfony mit ausgeliefert, können aber auch unabhängig davon für eine einfache Webanwendung eingesetzt werden. Wie so eine Webanwendung aufsetzt werden kann, beschreibe ich in dem folgenden Artikel.


Installationen


Für die Webanwendung wird eine LAMP Umgebung benutzt, als minimale PHP-Version wird 5.3.3 benötigt. Die Installation der Komponenten im Projekt wird durch den Abhängigkeiten-Manager Composer bewerkstelligt. Dieser benötigt die PHP-Erweiterung PHAR. Das wird bei meiner Distribution als Paket php5-phar installiert.

Weiterhin wird die Konfigurations-Sprache Yaml benutzt, dafür wird die PHP-Erweiterung YAML benötigt. Dieses muss ggf. per pecl insall yaml kompiliert und in der php.ini als extension aktiviert werden:

php.ini
; extension=yaml.dll on windiows
extension=yaml.so


Projekt Setup


Als Erstes wird eine einfache Projekt-Struktur erzeugt, die für den Webserver erreichbar ist.

mkdir doctrine2-standalone
cd doctrine2-standalone
mkdir -p app/config controller model/Entity model/EntityProxy views htdocs

Dann wird Composer installiert, die benötigten Komponenten konfiguriert und die Abhängigkeiten installiert.

curl -sS https://getcomposer.org/installer | php
echo "{}" > composer.json
php composer.phar require php>=5.3.3 twig/twig:1.*@dev doctrine/dbal:2.5.* doctrine/orm:2.5.*

Dabei wird das vendor Verzeichnis angelegt und besonders darin die Datei autoload.php, über die die Eingebungenen Bibliotheken geladen werden können.


Aufbau der Anwendung


Zuerst wird die Konfigurationsdatei app/config/parameters.yml erstellt und darin grundlegende Anwendungseinstellungen und der Datenbank-Zugriff konfiguriert.

app/config/parameters.yml
locale: 'de_DE'

site:
  name: 'Doctrine 2 standalone Demo'

database:
  dbname: 'demodb'
  user: 'root'
  password: 'password'
  host: 'localhost'
  driver: 'pdo_mysql'
  charset: 'utf8'
  driverOptions:
    1002: 'SET NAMES utf8'

Dann wird in dem Verzeichnis htdocs (das Wurzelverzeichnis des Webservers) nach dem Vorbild einer Symfony2-Anwendung eine .htaccess-Datei erstellt, mit der die index.php maskiert wird.

htdocs/.htaccess
# index.php rewriting, taken from symfony2.
DirectoryIndex index.php
<IfModule mod_rewrite.c>
    RewriteEngine On

    RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$
    RewriteRule ^(.*) - [E=BASE:%1]

    RewriteCond %{ENV:REDIRECT_STATUS} ^$
    RewriteRule ^index\.php(/(.*)|$) %{ENV:BASE}/$2 [R=301,L]

    # If the requested filename exists, simply serve it.
    # We only want to let Apache serve files and not directories.
    RewriteCond %{REQUEST_FILENAME} -f
    RewriteRule .? - [L]

    # Rewrite all other queries to the front controller.
    RewriteRule .? %{ENV:BASE}/index.php [L]
</IfModule>

<IfModule !mod_rewrite.c>
    <IfModule mod_alias.c>
        # When mod_rewrite is not available, we instruct a temporary redirect of
        # the startpage to the front controller explicitly so that the website
        # and the generated links can still be used.
        RedirectMatch 302 ^/$ /index.php/
        # RedirectTemp cannot be used instead
    </IfModule>
</IfModule>

Im webroot wird die Datei index.php erzeugt, die in diesem Fall einfach nur auf den Frontend-Controller verweist:

htdocs/index.php
<?php

require_once __DIR__ . '/../controller/FrontendController.php';

Dieser Frontend Controller leitet sich von einer Klasse "Application" ab, in der wiederum die Requests und Responses vorbereitet werden. Ansonsten wird in diesem Fall einfach nur die Konfiguration and die View index.html.twig übergeben, die Ansicht gerendert und ausgegeben.

controller/FrontendController.php
<?php

require_once __DIR__ . '/../app/app.php';

class FrontendController extends Application
{

    public function handleRequests( array $request )
    {
        $view = '';

        $viewdata = array(
            'config' => $this->config
        );

        $view = $this->twig->render('index.html.twig', $viewdata);

        return $view;
    }
}

$app = new FrontendController();
$view = $app->handleRequest();
$app->handleResponse( $view );

Dies ist die Application-Klasse:

app/app.php
<?php

define('DS', DIRECTORY_SEPARATOR);

class Application
{
    protected $config;
    protected $twig;
    protected $em;
    protected $conn;

    public function __construct()
    {
        require_once 'bootstrap.php';

        $config['base'] = $_SERVER['BASE'];

        $this->config = $config;
        $this->twig = $twig;
        $this->conn = $conn;
        $this->em = $em;
    }

    /**
     * Handle the client request.
     *
     * @return unknown
     */
    public function handleRequest()
    {
        // sanitize the request uri
        $uri = $_SERVER['REQUEST_URI'];
        $uri = str_replace($this->config['base'], '', $uri);
        $uri = $this->trimpath($uri);

        $request = array();
        if( strlen($uri) > 0 ) {
          $request = explode('/', $uri);
        }

        $view = $this->handleRequests( $request );
        return $view;
    }

    /**
     * Process the response.
     *
     * @param unknown $view
     */
    public function handleResponse( $view )
    {
        echo $view;
    }

    /**
     * Trim a path from leading or trailing spaces, dots, slashes and backslashes.
     */
    public function trimpath( $path )
    {
        return trim($path, '.\/ ');
    }

}

Die Application benutzt bei der Initialisierung die Datei bootstrap.php, in der die Twig- und Doctrine-Bilbliotheken initialisiert werden. Die innerhalb der bootstrap.php erzeugten Objekte, insbesondere der Doctrine EntityManager, werden in der Application in Variablen gespeichert und können so vom Controller benutzt werden.

In der Datei bootstrap.php werden zuerst die Bibliotheks-Namensräume über die Datei autoload.php des Composers eingebunden. Dann werden nacheinander die Konfiguration gelesen, dann Twig, Doctrine DBAL und Doctrine ORM initialisiert, der Doctrine EntityManager instanziert und am Ende der Doctrine ClassLoader für die benötigten Namespaces geladen. Für das Objekt-Mapping werden in diesem Fall Annotations benutzt, das Kapitel 2. Installation and Configuration in der Doctrine-Dokumentation zeigt noch andere Wege.

app/bootstrap.php
<?php

use Doctrine\Common\Cache\ArrayCache as Cache;

require_once __DIR__ . '/../vendor/autoload.php';

// Read application Configuration
$config = yaml_parse_file(__DIR__ . '/../app/config/parameters.yml');
setlocale(LC_ALL, $config['locale']);

// Twig
$loader = new Twig_Loader_Filesystem(__DIR__ . '/../views');
$twig = new Twig_Environment($loader);

// Doctrine DBAL
$dbalconfig = new Doctrine\DBAL\Configuration();
$conn = Doctrine\DBAL\DriverManager::getConnection($config['database'], $dbalconfig);

// Doctrine ORM
$ormconfig = new Doctrine\ORM\Configuration();
$cache = new Cache();
$ormconfig->setQueryCacheImpl($cache);
$ormconfig->setProxyDir(__DIR__ . '/../model/EntityProxy');
$ormconfig->setProxyNamespace('EntityProxy');
$ormconfig->setAutoGenerateProxyClasses(true);

// ORM mapping by Annotation
Doctrine\Common\Annotations\AnnotationRegistry::registerFile(
        __DIR__ . '/../vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/Driver/DoctrineAnnotations.php');
$driver = new Doctrine\ORM\Mapping\Driver\AnnotationDriver(
    new Doctrine\Common\Annotations\AnnotationReader(),
    array(__DIR__ . '/../model/Entity')
);
$ormconfig->setMetadataDriverImpl($driver);
$ormconfig->setMetadataCacheImpl($cache);

// EntityManager
$em = Doctrine\ORM\EntityManager::create($config['database'],$ormconfig);

// The Doctrine Classloader
require __DIR__ . '/../vendor/doctrine/common/lib/Doctrine/Common/ClassLoader.php';
$classLoader = new Doctrine\Common\ClassLoader('Entity', __DIR__ . '/../model');
$classLoader->register();

Wenn nun die Twig-Dateien index.html.twig und layout.html.twig erzeugt werden, kann die Anwendung schon einmal getestet werden.

views/index.html.twig
{% extends "layout.html.twig" %}
{% block content %}
<div id="content">
    <h1>Hello Twig!</h1>
</div>
{% endblock %}
views/layout.html.twig
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="content-type" content="text/html; charset=UTF-8; IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="">
    <meta name="author" content="">
    <title>{% block title %}{{ config.site.name }}{% endblock %}</title>
    <base href="{{ config.base }}">
</head>
  <body>
    <div class="container">
{% block content %}{% endblock %}
    </div> <!-- /container -->
  </body>
</html>


Die ORM-Konsole

Bisher benutzt die Anwendung jedoch "nur" Twig, nicht die Doctrine-Komponenten. Deren Stärke liegt aber nicht nur in der Datenbank-Abstraktion und dem Objekt-Mapping, ein besonders hilfreiches Werkzeug ist die ORM-Konsole. Diese wird über PHP aus einer Shell heraus angesprochen und ist unter anderem in der Lage, die Datenbankstruktur mit den Anwendungs-Entities synchron zu halten.

Für den Konsole-Client (app/console in Symfony 2) wird das script cli.php geschrieben. Darin werden die Initialisierungen aus der bootstrap.php geladen und die Konsole mittels EntityManager initialisiert.

app/cli.php
<?php

use Symfony\Component\Console\Helper\HelperSet,
    Doctrine\ORM\Tools\Console\Helper\EntityManagerHelper,
    Doctrine\DBAL\Tools\Console\Helper\ConnectionHelper,
    Doctrine\ORM\Tools\Console\ConsoleRunner;

require_once __DIR__ . '/bootstrap.php';

$helperSet = new HelperSet(array(
    'em' => new EntityManagerHelper($em),
    'conn' => new ConnectionHelper($em->getConnection())
));

ConsoleRunner::run($helperSet);

Wenn diese nun aus einer Shell heraus gestartet wird, wird die Kurzanleitung der ORM-Konsole ausgegeben.

php app/cli.php
Doctrine Command Line Interface version 2.5.0

Usage:
 command [options] [arguments]

Options:
 --help (-h)           Display this help message
 --quiet (-q)          Do not output any message
 --verbose (-v|vv|vvv) Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
 --version (-V)        Display this application version
 --ansi                Force ANSI output
 --no-ansi             Disable ANSI output
 --no-interaction (-n) Do not ask any interactive question

Available commands:
 help                             Displays help for a command
 list                             Lists commands
dbal
 dbal:import                      Import SQL file(s) directly to Database.
 dbal:run-sql                     Executes arbitrary SQL directly from the command line.
orm
 orm:clear-cache:metadata         Clear all metadata cache of the various cache drivers.
 orm:clear-cache:query            Clear all query cache of the various cache drivers.
 orm:clear-cache:result           Clear all result cache of the various cache drivers.
 orm:convert-d1-schema            Converts Doctrine 1.X schema into a Doctrine 2.X schema.
 orm:convert-mapping              Convert mapping information between supported formats.
 orm:convert:d1-schema            Converts Doctrine 1.X schema into a Doctrine 2.X schema.
 orm:convert:mapping              Convert mapping information between supported formats.
 orm:ensure-production-settings   Verify that Doctrine is properly configured for a production environment.
 orm:generate-entities            Generate entity classes and method stubs from your mapping information.
 orm:generate-proxies             Generates proxy classes for entity classes.
 orm:generate-repositories        Generate repository classes from your mapping information.
 orm:generate:entities            Generate entity classes and method stubs from your mapping information.
 orm:generate:proxies             Generates proxy classes for entity classes.
 orm:generate:repositories        Generate repository classes from your mapping information.
 orm:info                         Show basic information about all mapped entities
 orm:mapping:describe             Display information about mapped objects
 orm:run-dql                      Executes arbitrary DQL directly from the command line.
 orm:schema-tool:create           Processes the schema and either create it directly on EntityManager Storage Connection or generate the SQL output.
 orm:schema-tool:drop             Drop the complete database schema of EntityManager Storage Connection or generate the corresponding SQL output.
 orm:schema-tool:update           Executes (or dumps) the SQL needed to update the database schema to match the current mapping metadata.
 orm:validate-schema              Validate the mapping files.

Mit der Konsole kann die Datenbank mit den Objekt-Modellen synchron gehalten werden. Allerdings muss erst einmal eine Datenbank angelegt werden. Die Zugangsdaten zu dem MySQL-Server können in der Datei ~/.my.cnf hinterlegt werden.

cat ~/.my.cnf
[mysql]
user = root
password = password

mysql -e "create database demodb"

Die Demo-Anwendung soll Zitate von Personen ausgeben. Dazu werden erst einmal zwei Entitäten Author und Quote in dem Verzeichnis model/Entity angelegt. Die Annotation in den Klassen werden von Doctrine auf die Datenbank umgesetzt.

model/Entity/Author.php
<?php

namespace Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Author
 *
 * @ORM\Table(name="authors")
 * @ORM\Entity
 */
class Author
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\OneToMany(targetEntity="Quote", mappedBy="author")
     */
    private $quotes;

	  /**
	   * @ORM\Column(name="name", type="string", length=255)
	   */
    private $name;
}
model/Entity/Quote.php
<?php

namespace Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Quote
 *
 * @ORM\Table(name="quotes")
 * @ORM\Entity
 */
class Quote
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

  	/**
  	 * @ORM\ManyToOne(targetEntity="Author", inversedBy="quotes")
  	 * @ORM\JoinColumn(name="id_acount", referencedColumnName="id", onDelete="CASCADE")
  	 */
    private $author;

	  /**
	   * @ORM\Column(name="text", type="string", length=255)
	   */
    private $text;
}

Für diese beiden Klassen werden die passenden Getter- und Setter-Methoden benötigt, die von der ORM-Konsole generiert werden.

php app/cli.php orm:generate:entities model
Processing entity "Entity\Author"
Processing entity "Entity\Quote"

Entity classes generated to "/mnt/Data/www/doctrine2-standalone-demo/model"

Anschließend sehen die beiden Entity-Klassen so aus:

model/Entity/Author.php
<?php

namespace Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Author
 *
 * @ORM\Table(name="authors")
 * @ORM\Entity
 */
class Author
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\OneToMany(targetEntity="Quote", mappedBy="author")
     */
    private $quotes;

	  /**
	   * @ORM\Column(name="name", type="string", length=255)
	   */
    private $name;
    /**
     * Constructor
     */
    public function __construct()
    {
        $this->quotes = new \Doctrine\Common\Collections\ArrayCollection();
    }

    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set name
     *
     * @param string $name
     *
     * @return Author
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    /**
     * Get name
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Add quote
     *
     * @param \Entity\Quote $quote
     *
     * @return Author
     */
    public function addQuote(\Entity\Quote $quote)
    {
        $this->quotes[] = $quote;

        return $this;
    }

    /**
     * Remove quote
     *
     * @param \Entity\Quote $quote
     */
    public function removeQuote(\Entity\Quote $quote)
    {
        $this->quotes->removeElement($quote);
    }

    /**
     * Get quotes
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getQuotes()
    {
        return $this->quotes;
    }
}
model/Entity/Quote.php
<?php

namespace Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Quote
 *
 * @ORM\Table(name="quotes")
 * @ORM\Entity
 */
class Quote
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

  	/**
  	 * @ORM\ManyToOne(targetEntity="Author", inversedBy="quotes")
  	 * @ORM\JoinColumn(name="id_acount", referencedColumnName="id", onDelete="CASCADE")
  	 */
    private $author;

	  /**
	   * @ORM\Column(name="text", type="string", length=255)
	   */
    private $text;

    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set text
     *
     * @param string $text
     *
     * @return Quote
     */
    public function setText($text)
    {
        $this->text = $text;

        return $this;
    }

    /**
     * Get text
     *
     * @return string
     */
    public function getText()
    {
        return $this->text;
    }

    /**
     * Set author
     *
     * @param \Entity\Author $author
     *
     * @return Quote
     */
    public function setAuthor(\Entity\Author $author = null)
    {
        $this->author = $author;

        return $this;
    }

    /**
     * Get author
     *
     * @return \Entity\Author
     */
    public function getAuthor()
    {
        return $this->author;
    }
}

Dann kann die ORM-Konsole die Datenbank synchronisieren.

php app/cli.php orm:schema-tool:create --dump-sql
CREATE TABLE authors (id INT AUTO_INCREMENT NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
CREATE TABLE quotes (id INT AUTO_INCREMENT NOT NULL, id_acount INT DEFAULT NULL, INDEX IDX_A1B588C517A67BCE (id_acount), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
ALTER TABLE quotes ADD CONSTRAINT FK_A1B588C517A67BCE FOREIGN KEY (id_acount) REFERENCES authors (id) ON DELETE CASCADE;

php app/cli.php orm:schema-tool:update --force
Updating database schema...
Database schema updated successfully! "3" queries were executed

Es werden Daten in der Datenbank benötigt, die über eine shell eingetragen werden können.

mysql demodb -e "INSERT INTO authors VALUES
(0,'Robin Williams'),
(0,'Lionel Richie'),
(0,'Leonard Nimoy'),
(0,'Bruce Willis')"

mysql demodb -e "INSERT INTO quotes VALUES
(0,1,'Nano Nano!'),
(0,2,'Hello!'),
(0,3,'LLAP!'),
(0,4,'Yippie-Ka-Yeah!'),
(0,1,'Gooood Morning!')"

Damit können nun die Entities über den Doctrine ORM im Controller geladen und an die View weitergegeben werden. Der Frontend-Controller und die index.html.twig sehen nun so aus:

app/FrontendController.php
<?php

require_once __DIR__ . '/../app/app.php';

class FrontendController extends Application
{
    public function handleRequests( array $request )
    {
        $view = '';

        $viewdata = array(
            'config' => $this->config
        );

        $viewdata['authors'] = $this->em->getRepository('Entity\Author')->findAll();

        $view = $this->twig->render('index.html.twig', $viewdata);

        return $view;
    }
}

$app = new FrontendController();
$view = $app->handleRequest();
$app->handleResponse( $view );
views/index.html.twig
{% extends "layout.html.twig" %}
{% block content %}

<div id="content">
    <h1>Famous Quotes</h1>
    <table cellpadding="10px">
      <thead>
        <tr>
          <th>Author</th>
          <th>Quote</th>
        </tr>
      </thead>
      {% for author in authors %}
        <tr>
          <td>{{ author.name }}</td>
          <td>
            {% for quote in author.quotes %}
              <em>{{ quote.text }}</em><br>
            {% endfor %}
          </td>
        </tr>
      {% endfor %}
    </table>
</div>

{% endblock %}

Und die Ausgabe:

Famous Quotes

Author Quote
Robin Williams Nano Nano!
Gooood Morning!
Lionel Richie Hello!
Leonard Nimoy LLAP!
Bruce Willis Yippie-Ka-Yeah!


Resümee

Wenn die Anwendung und die Datenbank komplizierter werden - gerade bei vielen n:1 und n:m Beziehungen - wird dieser Aufbau früher oder später zu Performance-Einbrüchen führen. So wie die Datenbankabfragen hier aufgebaut sind, ist die Anwendung weit entfernt von optimiert. Nicht sichtbar sind die 5 zusätzlichen SQL-Abfragen für die Ermittlung der eigentlichen Zitate, dafür sollte die Flexibilität des Object-Relation Mappings deutlich werden. Auch ist Vorsicht bei der Verarbeitung der Routen, Requests und Responses und ggf. von Formularen geboten. Das sind Punkte, die bei fertigen Frameworks wie Symfony 2 schon entsprechend abgefangen werden.

Abgesehen davon ist es einfach ein Genuß mit diesen Frameworks in so abgespeckter Weise zu arbeiten!



Download

Das fertige Projekt kann über Github heruntergeladen werden: https://github.com/limitland/Doctrine-2-standalone-Demo.