Symfony 2, Dependency Injection

Od jakiegoś czasu bawię się w wolnych chwilach Symfony 2. Projekt jest jeszcze w bardzo wczesnym stadium (szczerze mówiąc nie wierzę w powakacyjną datę premiery :P), ale jest bardzo obiecujący. O nim samym się nie chciałem rozpisywać - już jest bardzo wiele opisów, a będzie jeszcze więcej, poza tym przy obecnym stanie rzeczy dane bardzo szybko się dezaktualizują. Ja jak zwykle muszę jednak robić coś po swojemu i szukać dziury w całym, dlatego doskwierała mi bardzo pewna sprawa, którą tutaj opiszę. Chodzi mianowicie o Dependency Injection, który jest sercem całego systemu. O nim samym też nie będę się zbyt wiele rozpisywał, bo jak ktoś chce to ma wszystko ładnie opisane, natomiast w skrócie powiem, że jest to mechanizm utrzymywania zależności pomiędzy obiektami (usługami) w systemie. To właśnie brak pewnej funkcjonalności w tym komponencie zmusił mnie do działania.

Dlaczego w ogóle korzystać z DI? Bo cholernie ułatwia życie. Nawet jeśli tego nie widać na początku, to po przyzwyczajeniu się do konieczności opisywania każdego obiektu dodatkowo schematem zależności szybko będziemy mogli zapomnieć o tym, co w każdym większym systemie jest udręką - martwieniem się o zależności. Koniec z zagadką "skąd wziąć instancję" - DI ją przekaże. Koniec z singletonami tylko dlatego, że nie chce nam się przekazywać referencji - DI ją wszędzie ma. A sam kod pozostanie czysty i przejrzysty co sprzyja jego wymienności. Oczywiście sam temat jest o wiele głębszy, ale to nie o tym chcę się rozpisywać.

Czego mi brakuje - dereferencja

Cały mechanizm jest bardzo fajny, dopóki nie chcesz wyjść poza założenia potrzeb samego Symfony. Jeśli spróbujesz przepisać na DI swój bardziej rozbudowany system możesz napotkać ograniczenia. Mi brakowało możliwości dereferencji wywołań funkcji danej usługi podczas przekazywania jej jako parametr. Chodzi o konstrukcję typu:

$fooService = new FooClass( $barService->getBaz() );

Oczywiście zawsze możemy przekazywać kontener i wyciągać wartość w obiekcie docelowym, ale kiedy przenosimy projekt, o spójnej strukturze, albo po prostu chcemy zapisać definicje dla zewnętrznych bibliotek nie specjalnie mamy możliwość (i chęć) ingerencji w istniejący kod.

Na szczęście dorobienie tej funkcjonalności nie jest specjalnie trudne i pracochłonne, dlatego pokusiłem się o napisanie własnej nakładki na komponent DI. Na dodatek w dużej mierze kod jest jedynie reimplementacją oryginalnego kodu Symfony z uwzględnieniem tego jednego małego dodatku.

Klasa Reference

Na początek najprostsze - sama klasa oznaczająca referencję do usługi musi móc przechowywać i zwracać listę wywołań wraz z ich argumentami. Mechanizm jest praktycznie skopiowany z klasy Symfony\Components\DependencyInjection\Definition:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection;

/**
 * Symfony DI reference extended with method calls. Methods related to calls are pretty much the same that in {@link \Symfony\Components\DependencyInjection\Definition \Symfony\Components\DependencyInjection\Definition}.
 *
 * <p>
 * A small note - method calls are chained. It means that even thought they are assigned to the same reference they won't be called from same context - each call is performed on the result of previous call. Only first call is executed on service instance itself, it is.
 * </p>
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class Reference extends \Symfony\Components\DependencyInjection\Reference
{
    /**
     * Method calls.
     *
     * @var array
     */
    protected $calls = array();

    /**
     * Sets the methods to call after service initialization.
     *
     * @param array $calls An array of method calls.
     * @return Reference The current instance.
     */
    public function setMethodCalls(array $calls = array() )
    {
        // reset calls list
        $this->calls = array();

        foreach($calls as $call)
        {
            $this->addMethodCall($call[0], $call[1]);
        }

        return $this;
    }

    /**
     * Adds a method to call after service initialization.
     *
     * @param string $method The method name to call.
     * @param array $arguments An array of arguments to pass to the method call.
     * @return Reference The current instance.
     */
    public function addMethodCall($method, array $arguments = array() )
    {
        $this->calls[] = array($method, $arguments);

        return $this;
    }

    /**
     * Gets the methods to call after service initialization.
     *
     * @return array An array of method calls.
     */
    public function getMethodCalls()
    {
        return $this->calls;
    }
}

Builder

Kolejną sprawą jest, aby builder, który odpowiedzialny jest za tworzenie instancji usług wywoływał te metody. Tutaj też zbyt wiele do zrobienia nie ma - musimy pobierać listę wywołań i w pętli wykonywać kolejne z nich:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection;

/**
 * Extended DI builder that supports ChillDev extended structures.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class Builder extends \Symfony\Components\DependencyInjection\Builder
{
    /**
     * Replaces service references by the real service instance.
     *
     * <p>
     * This is extended version of Symfony DI resolver - it allows inline dereference of service methods.
     * </p>
     *
     * @param mixed $value A value.
     * @return mixed The same value with all service references replaced by the real service instances.
     */
    public function resolveServices($value)
    {
        if( \is_array($value) )
        {
            foreach($value as &$v)
            {
                $v = $this->resolveServices($v);
            }
        }
        elseif( \is_object($value) && $value instanceof \Symfony\Components\DependencyInjection\Reference)
        {
            $service = $this->getService( (string) $value, $value->getInvalidBehavior() );

            // here comes ChillDev magic - additional calls
            if($value instanceof Reference)
            {
                // chains all calls
                foreach( $value->getMethodCalls() as $call)
                {
                    $services = \Symfony\Components\DependencyInjection\Builder::getServiceConditionals($call[1]);

                    // checks if all required services exist
                    $ok = true;
                    foreach($services as $s)
                    {
                        if( !$this->hasService($s) )
                        {
                            $ok = false;
                            break;
                        }
                    }

                    // executes this call and replaces current context
                    if($ok)
                    {
                        $service = \call_user_func_array( array($service, $call[0]), $this->resolveServices( \Symfony\Components\DependencyInjection\Builder::resolveValue($call[1], $this->parameters) ) );
                    }
                }
            }

            // final result
            $value = $service;
        }

        return $value;
    }
}

Obsługa XML

Teraz już trudniejsza część - musimy przerobić mechanizmy ładowania i zapisywania konfiguracji usług. Na pierwszy ogień pójdzie XML. Tutaj mamy dwie klasy do nadpisania - samego loadera, oraz klasy SimpleXMLElement. Właściwe przetwarzanie danych znajduje się właśnie w tej drugiej klasie. W samym loaderze musimy sprawić, aby:

  • korzystał właśnie z naszej nakładki na klasę SimpleXMLElement,
  • sprawić, aby obsługę importów z innych formatów również obsługiwał z wykorzystaniem naszych klas,
  • oraz obsłużyć walidację danych (o tym jeszcze na końcu, jak zobaczycie w tej kwestii po prostu walidacji brakuje).

Na początek musimy w ogóle opracować sposób przechowywania dodatkowych danych. Chodzi nam o to, aby na usługach przekazywanych jako parametry można było od razu wywoływać metody. Parametry opisujemy elementem <argument>. Dla wywołań metod też mamy w DI element - <call>. Wystarczy zatem, aby argument będący usługą zawierał w sobie listę metod, które należy na nim wywołać:

<service id="service" class="ServiceClass">
    <argument type="service" id="fooService">
        <call method="getBar"/>
        <call method="getBaz"/>
    </argument>
</service>

Oto klasa do przetwarzania danych:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection;

/**
 * Symfony DI SimpleXMLElement extended with service calls dereference features.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class SimpleXMLElement extends \Symfony\Components\DependencyInjection\SimpleXMLElement
{
    /**
     * Generates logical structure from parsed XML.
     *
     * @param string $name Argument name.
     * @return array PHP-ized argument.
     */
    public function getArgumentsAsPhp($name)
    {
        $arguments = array();

        foreach($this->$name as $arg)
        {
            $key = isset($arg['key']) ? (string) $arg['key'] : (!$arguments ? 0 : \max( \array_keys($arguments) ) + 1);

            // parameter keys are case insensitive
            if('parameter' == $name)
            {
                $key = \strtolower($key);
            }

            switch($arg['type'])
            {
                case 'service':
                    $invalidBehavior = \Symfony\Components\DependencyInjection\Container::EXCEPTION_ON_INVALID_REFERENCE;

                    if( isset($arg['on-invalid']) && 'ignore' == $arg['on-invalid'])
                    {
                        $invalidBehavior = \Symfony\Components\DependencyInjection\Container::IGNORE_ON_INVALID_REFERENCE;
                    }
                    elseif( isset($arg['on-invalid']) && 'null' == $arg['on-invalid'])
                    {
                        $invalidBehavior = \Symfony\Components\DependencyInjection\Container::NULL_ON_INVALID_REFERENCE;
                    }

                    $arguments[$key] = new Reference( (string) $arg['id'], $invalidBehavior);

                    // adds chained calls
                    foreach($arg->call as $call)
                    {
                        $arguments[$key]->addMethodCall( (string) $call['method'], $call->getArgumentsAsPhp('argument') );
                    }

                    break;

                case 'collection':
                    $arguments[$key] = $arg->getArgumentsAsPhp($name);
                    break;

                case 'string':
                    $arguments[$key] = (string) $arg;
                    break;

                case 'constant':
                    $arguments[$key] = \constant( (string) $arg);
                    break;

                default:
                    $arguments[$key] = \Symfony\Components\DependencyInjection\SimpleXMLElement::phpize($arg);
            }
        }

        return $arguments;
    }
}

A teraz czas na sam loader:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection\Loader;

/**
 * Extended DI XML parser that supports ChillDev extended structures.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class XmlFileLoader extends \Symfony\Components\DependencyInjection\Loader\XmlFileLoader
{
    /**
     * Parses XML file using ChillDev extended XML element.
     *
     * @param string $file Filename.
     * @throws \InvalidArgumentException When loading of XML file returns error.
     */
    protected function parseFile($file)
    {
        $dom = new \DOMDocument();
        \libxml_use_internal_errors(true);
        if( !$dom->load($file, \LIBXML_COMPACT) )
        {
            throw new \InvalidArgumentException( \implode("\n", $this->getXmlErrors() ) );
        }

        $dom->validateOnParse = true;
        $dom->normalizeDocument();
        \libxml_use_internal_errors(false);
        $this->validate($dom, $file);

        return \simplexml_import_dom($dom, 'ChillDev\\DependencyInjection\\SimpleXMLElement');
    }

    /**
     * Loads imports using ChillDev extended classes.
     *
     * @param \Symfony\Components\DependencyInjection\BuilderConfiguration $configuration Main configuration.
     * @param array $import Import definition.
     * @param string $file Filename.
     * @return \Symfony\Components\DependencyInjection\BuilderConfiguration Configuration.
     */
    protected function parseImport(\Symfony\Components\DependencyInjection\BuilderConfiguration $configuration, $import, $file)
    {
        $class = null;
        if( isset($import['class']) && $import['class'] !== \get_class($this) )
        {
            $class = (string) $import['class'];
        }
        else
        {
            // try to detect loader with the extension
            switch( \pathinfo( (string) $import['resource'], \PATHINFO_EXTENSION))
            {
                case 'yml':
                    $class = 'ChillDev\\DependencyInjection\\Loader\\YamlFileLoader';
                    break;
                case 'ini':
                    $class = 'Symfony\\Components\\DependencyInjection\\Loader\\IniFileLoader';
                    break;
            }
        }

        $loader = null === $class ? $this : new $class($this->paths);

        $importedFile = $this->getAbsolutePath( (string) $import['resource'], \dirname($file) );

        return $loader->load($importedFile, false, $configuration);
    }

    /**
     * Validates XML tree.
     *
     * @param \DOMDocument $dom DOM tree.
     * @param stirng $file Filename.
     */
    protected function validateSchema($dom, $file)
    {
        //TODO
    }
}

To jeszcze nie koniec obsługi formatu XML. Mamy już odczyt, ale musimy jeszcze napisać dumper, który będzie eksportował istniejący kontener do formatu definicji:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection\Dumper;

/**
 * Extended DI XML dumper that supports ChillDev extended structures.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class XmlDumper extends \Symfony\Components\DependencyInjection\Dumper\XmlDumper
{
    /**
     * Exports parameters (also arguments calls).
     *
     * @param array $parameters List of arguments.
     * @param string $type Single element name.
     * @param int $depth Indention depth.
     * @return stirng XML fragment.
     */
    protected function convertParameters($parameters, $type = 'parameter', $depth = 2)
    {
        $white = \str_repeat(' ', $depth);
        $xml = '';
        $withKeys = \array_keys($parameters) !== \range(0, \count($parameters) - 1);
        foreach($parameters as $key => $value)
        {
            $attributes = '';
            $key = $withKeys ? \sprintf(' key="%s"', $key) : '';
            if( \is_array($value) )
            {
                $value = "\n" . $this->convertParameters($value, $type, $depth + 2) . $white;
                $attributes = ' type="collection"';
            }

            if( \is_object($value) && $value instanceof \Symfony\Components\DependencyInjection\Reference)
            {
                $calls = '';

                // adds chained calls
                if($value instanceof \ChillDev\DependencyInjection\Reference)
                {
                    $indent = $white . '  ';

                    foreach( $value->getMethodCalls() as $call)
                    {
                        // parses arguments recursively
                        if( \count($call[1]) )
                        {
                            $calls .= \sprintf('%s<call method="%s">' . "\n" . '%s%s</call>' . "\n", $indent, $call[0], $this->convertParameters($call[1], 'argument', $depth + 4), $indent);
                        }
                        // simple call
                        else
                        {
                            $calls .= \sprintf('%s<call method="%s" />' . "\n", $indent, $call[0]);
                        }
                    }
                }

                if( empty($calls) )
                {
                    $xml .= \sprintf('%s<%s%s type="service" id="%s" %s/>' . "\n", $white, $type, $key, (string) $value, $this->getXmlInvalidBehavior($value) );
                }
                else
                {
                    $xml .= \sprintf('%s<%s%s type="service" id="%s" %s>' . "\n" . '%s%s</%s>' . "\n", $white, $type, $key, (string) $value, $this->getXmlInvalidBehavior($value), $calls, $white, $type);
                }
            }
            else
            {
                if( \in_array($value, array('null', 'true', 'false'), true) )
                {
                    $attributes = ' type="string"';
                }

                $xml .= \sprintf('%s<%s%s%s>%s</%s>' . "\n", $white, $type, $key, $attributes, \Symfony\Components\DependencyInjection\Dumper\XmlDumper::phpToXml($value), $type);
            }
        }

        return $xml;
    }
}

Obsługa YAML

Następnym formatem jest YAML. Tutaj pojawia się pewna trudność - format ten jest niezwykle luźny, a struktura używana przez Symfony w tym miejscu niestety uniemożliwia bezpośrednie rozszerzenia - parametry dla wywołań są zapisywane jako proste stringi, przez co nie za bardzo jest jak dodać dodatkowe elementy opisujące kolejne wywołania. Zatem w przypadku tego formatu konieczne będzie opracowanie zupełnie własnej struktury zapisywanych danych. Nie możemy jednak uniemożliwić korzystania ze starej struktury w przypadku plików nie pisanych pod nasze rozszerzenie. Normalnie wywołania przechowywane są jako tablica z nazwą metody jako pierwszym elementem, oraz tablicą argumentów na drugim miejscu. Ta tablica jest listą zwykłych stringów:

calls:
    - [method1, [arg1, arg2]]
    - [method2, [arg1, @service]]

Odwołania do usług oznaczane są znakiem @. My musimy sprawić, aby argument mógł być bardziej złożoną strukturą posiadającą nazwę usługi oraz listę metod do wywołania. Ponieważ mamy tutaj pomieszanie formatu zwykłej listy z notacją skróconą, to nasza struktura również będzie musiała być przechowywana w formie skróconej:

calls:
    - [method1, [@service1, {service: @service2, calls: [[subMethod1, [arg1, arg2]], [subMethod2, [arg3, @service3]]]}]]

Na pierwszy rzut oka trochę dużo zagnieżdżeń, ale nie sama struktura jest dość prosta. Zatem przejdźmy do jej obsługi w kodzie loadera (nie możemy zapomnieć o nadpisaniu metody importującej obce formaty, aby wykorzystywała nasze rozszerzenie):

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection\Loader;

/**
 * Extended DI YAML parser that supports ChillDev extended structures.
 *
 * @author Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class YamlFileLoader extends \Symfony\Components\DependencyInjection\Loader\YamlFileLoader
{
    /**
     * Parses YAML values to resolve references to services.
     *
     * @param mixed $value List of values (or single value).
     * @return mixed Resolved tree.
     */
    protected function resolveServices($value)
    {
        // simple list
        if( \is_array($value) && !isset($value['service']) )
        {
            return \array_map( array($this, 'resolveServices'), $value);
        }

        $calls = array();

        // extended ChillDev reference
        if( \is_array($value) )
        {
            // has calls
            if( isset($value['calls']) )
            {
                $calls = $value['calls'];
            }

            $value = $value['service'];
        }

        // creates reference
        if( \is_string($value) && 0 === \strpos($value, '@') )
        {
            if('@@' == \substr($value, 0, 2) )
            {
                $value = new \ChillDev\DependencyInjection\Reference( \substr($value, 2), \Symfony\Components\DependencyInjection\Container::IGNORE_ON_INVALID_REFERENCE);
            }
            elseif( \is_string($value) && 0 === \strpos($value, '@') )
            {
                $value = new \ChillDev\DependencyInjection\Reference( \substr($value, 1) );
            }

            // adds calls
            foreach($calls as $call)
            {
                $value->addMethodCall($call[0], $this->resolveServices($call[1]) );
            }
        }

        return $value;
    }

    /**
     * Loads imports using ChillDev extended classes.
     *
     * @param \Symfony\Components\DependencyInjection\BuilderConfiguration $configuration Main configuration.
     * @param array $import Import definition.
     * @param string $file Filename.
     * @return \Symfony\Components\DependencyInjection\BuilderConfiguration Configuration.
     */
    protected function parseImport(\Symfony\Components\DependencyInjection\BuilderConfiguration $configuration, $import, $file)
    {
        $class = null;
        if( isset($import['class']) && $import['class'] !== \get_class($this) )
        {
            $class = (string) $import['class'];
        }
        else
        {
            // try to detect loader with the extension
            switch( \pathinfo( (string) $import['resource'], \PATHINFO_EXTENSION))
            {
                case 'xml':
                    $class = 'ChillDev\\DependencyInjection\\Loader\\XmlFileLoader';
                    break;
                case 'ini':
                    $class = 'Symfony\\Components\\DependencyInjection\\Loader\\IniFileLoader';
                    break;
            }
        }

        $loader = null === $class ? $this : new $class($this->paths);

        $importedFile = $this->getAbsolutePath( (string) $import['resource'], \dirname($file) );

        return $loader->load($importedFile, false, $configuration);
    }
}

Zostaje jeszcze zrobienie dumpera, aby zapisywał w tym formacie:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection\Dumper;

/**
 * Extended DI YAML dumper that supports ChillDev extended structures.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class YamlDumper extends \Symfony\Components\DependencyInjection\Dumper\YamlDumper
{
    /**
     * Handles extened structure for methods dereference in arguments.
     *
     * @param string $id Service ID.
     * @param \Symfony\Components\DependencyInjection\Reference $reference Reference to service.
     * @return mixed Tree structure.
     */
    protected function getServiceCall($id, \Symfony\Components\DependencyInjection\Reference $reference = null)
    {
        if( isset($reference) && \Symfony\Components\DependencyInjection\Container::EXCEPTION_ON_INVALID_REFERENCE !== $reference->getInvalidBehavior() )
        {
            $service = \sprintf('@@%s', $id);
        }
        else
        {
            $service = \sprintf('@%s', $id);
        }

        // extended ChillDev structure
        if( isset($reference) && $reference instanceof \ChillDev\DependencyInjection\Reference)
        {
            $calls = $reference->getMethodCalls();

            // if reference has not calls chained to it then we don't need to use extended YAML structure
            if( \count($calls) > 0)
            {
                $service = array('service' => $service, 'calls' => array() );

                // adds all chains and their parameters
                foreach($calls as $call)
                {
                    $service['calls'][] = array($call[0], $this->dumpValue($call[1]) );
                }
            }
        }

        return $service;
    }
}

Obsługa PHP

Kolejnym ważnym formatem jest sam PHP, gdyż to do niego eksportowane są pliki cache'u. Musimy zmodyfikować metodą generującą odwołania do usług tak, aby doklejały kod kolejnych metod (wraz z ich parametrami):

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection\Dumper;

/**
 * Extended DI PHP dumper that supports ChillDev extended structures.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class PhpDumper extends \Symfony\Components\DependencyInjection\Dumper\PhpDumper
{
    /**
     * Returns code snippet that references to service.
     *
     * <p>
     * This is extended version of Symfony DI resolver - it allows inline dereference of service methods.
     * </p>
     *
     * @param string $id Service ID.
     * @param \Symfony\Components\DependencyInjection\Reference $reference Reference to service.
     * @return string PHP snippet.
     */
    protected function getServiceCall($id, \Symfony\Components\DependencyInjection\Reference $reference = null)
    {
        if('service_container' === $id)
        {
            $service = '$this';
        }
        elseif( isset($reference) && \Symfony\Components\DependencyInjection\Container::EXCEPTION_ON_INVALID_REFERENCE !== $reference->getInvalidBehavior() )
        {
            $service = \sprintf('$this->getService(\'%s\', \Symfony\Components\DependencyInjection\Container::NULL_ON_INVALID_REFERENCE)', $id);
        }
        else
        {
            if( $this->container->hasAlias($id) )
            {
                $id = $this->container->getAlias($id);
            }

            if( $this->container->hasDefinition($id) )
            {
                $service = \sprintf('$this->get%sService()', \Symfony\Components\DependencyInjection\Container::camelize($id) );
            }
            else
            {
                $service = \sprintf('$this->getService(\'%s\')', $id);
            }
        }

        // here comes ChillDev magic
        if( isset($reference) && $reference instanceof \ChillDev\DependencyInjection\Reference)
        {
            // chains all calls
            foreach( $reference->getMethodCalls() as $call)
            {
                $arguments = array();
                foreach($call[1] as $value)
                {
                    $arguments[] = $this->dumpValue($value);
                }

                $service .= \sprintf('->%s(%s)', $call[0], \implode(', ', $arguments) );
            }
        }

        return $service;
    }
}

Graphviz

Żeby wszystko było solidnie pokusiłem się nawet o napisanie dumpera dla formatu Graphviz. Tutaj wielkiej filozofii nie było, po prostu wywołania dodane przez nas powodują kolejne zależności:

/**
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\DependencyInjection\Dumper;

/**
 * Extended DI Graphviz dumper that supports ChillDev extended structures.
 *
 * @author Rafa Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
class GraphvizDumper extends \Symfony\Components\DependencyInjection\Dumper\GraphvizDumper
{
    /**
     * Generates list of graph edges inluding references within calls.
     *
     * @param string $id Service ID.
     * @param array $arguments Lista of values.
     * @param bool $required Whether relation is required or note.
     * @param string $name Node name.
     * @return array List of edges.
     */
    protected function findEdges($id, $arguments, $required, $name)
    {
        $edges = array();

        foreach($arguments as $argument)
        {
            if( \is_object($argument) && $argument instanceof \Symfony\Components\DependencyInjection\Parameter)
            {
                $argument = $this->container->hasParameter($argument) ? $this->container->getParameter($argument) : null;
            }
            elseif( \is_string($argument) && \preg_match('/^%([^%]+)%$/', $argument, $match) )
            {
                $argument = $this->container->hasParameter($match[1]) ? $this->container->getParameter($match[1]) : null;
            }

            if( \is_object($argument) && $argument instanceof \Symfony\Components\DependencyInjection\Reference)
            {
                if( !$this->container->hasService( (string) $argument) )
                {
                    $this->nodes[ (string) $argument] = array('name' => $name, 'required' => $required, 'class' => '', 'attributes' => $this->options['node.missing']);
                }

                // extended ChillDev calls
                if($argument instanceof \ChillDev\DependencyInjection\Reference)
                {
                    foreach( $argument->getMethodCalls() as $call)
                    {
                        $edges = \array_merge($edges, $this->findEdges($id, $call[1], false, $call[0] . '()') );
                    }
                }

                $edges[] = array('name' => $name, 'required' => $required, 'to' => $argument);
            }
            elseif( \is_array($argument) )
            {
                $edges = \array_merge($edges, $this->findEdges($id, $argument, $required, $name) );
            }
        }

        return $edges;
    }
}

Kernel

Ostatnia sprawa to sam kernel. Tutaj musimy podmienić metodę odpowiadającą za budowanie kontenera zależności. W samej logice w sumie nic nie musimy zmieniać, ale musi on korzystać z naszych rozszerzonych klas (kontenera i dumpera):

/**
 * @author Rafał Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */

namespace ChillDev\Application;

/**
 * Kernel that uses ChillDev extension for DI.
 *
 * @author Rafał Wrzeszcz / Wrzasq <wrzasq@gmail.com>
 * @copyright 2010 (C) by Wrzasq.
 */
abstract class Kernel extends \Symfony\Foundation\Kernel
{
/**
 * Builds container using ChillDev extension for extended calls.
 *
 * @param string $class Target class name.
 * @param stirng $file Cache target file.
 */
    protected function buildContainer($class, $file)
    {
        $container = new \ChillDev\DependencyInjection\Builder( $this->getKernelParameters() );

        $configuration = new \Symfony\Components\DependencyInjection\BuilderConfiguration();
        foreach($this->bundles as $bundle)
        {
            $configuration->merge( $bundle->buildContainer($container) );
        }
        $configuration->merge( $this->registerContainerConfiguration() );
        $container->merge($configuration);
        $this->optimizeContainer($container);

        foreach( array('cache', 'logs') as $name)
        {
            $dir = $container->getParameter( \sprintf('kernel.%s_dir', $name) );
            if( !\is_dir($dir) )
            {
                if(false === \mkdir($dir, 0777, true) )
                {
                    die( \sprintf('Unable to create the %s directory (%s)', $name, \dirname($dir) ) );
                }
            }
            elseif( !\is_writable($dir) )
            {
                die( \sprintf('Unable to write in the %s directory (%s)', $name, $dir) );
            }
        }

        // cache the container
        $dumper = new \ChillDev\DependencyInjection\Dumper\PhpDumper($container);
        $content = $dumper->dump( array('class' => $class) );
        if(!$this->debug)
        {
            $content = self::stripComments($content);
        }
        $this->writeCacheFile($file, $content);

        if($this->debug)
        {
            // add the Kernel class hierarchy as resources
            $parent = new \ReflectionObject($this);
            $configuration->addResource( new \Symfony\Components\DependencyInjection\FileResource( $parent->getFileName() ) );
            while($parent = $parent->getParentClass() )
            {
                $configuration->addResource( new \Symfony\Components\DependencyInjection\FileResource( $parent->getFileName() ) );
            }

            // save the resources
            $this->writeCacheFile( $this->getCacheDir() . '/' . $class . '.meta', \serialize( $configuration->getResources() ) );
        }
    }
}

Uniwersalność

Co ważne, ponieważ rozwiązanie tutaj przedstawione jest po prostu nakładką na cały mechanizm resolvera zależności, konstrukcja z wywołaniami zadziała wszędzie, gdzie podajemy usługę jako parametr - zarówno w parametrach konstruktora, jak i innych wywołań.

Kontekst

Kolejna ważna sprawa to kontekst wywołań, a może to być dość mylące. Mimo tego, iż wszystkie wywołania przypisane są do konkretnej referencji do usługi, to tylko pierwsze z nich będzie wywołane jako metoda tej usługi. Każde następne wywołanie jest łańcuchowane i wywoływane na obiekcie zwracanym przez poprzednie. Na przykład definicja:

<service id="service" class="ServiceClass">
    <argument type="service" id="fooService">
        <call method="getBar"/>
        <call method="getBaz"/>
    </argument>
</service>

Spowoduje utworzenie następującego łańcucha wywołań:

$service = new ServiceClass( $container->getService('fooService')->getBar()->getBaz() );

Zagnieżdżanie

To jeszcze nie koniec. Ponieważ nasze call jest takim samym odwołaniem jak te z oryginalnego DI, możemy robić z nim te same rzeczy, w tym przekazywać do niego dodatkowe parametry. Oznacza to, że możemy stosować nasz mechanizm również wobec nich:

<service id="service" class="ServiceClass">
    <argument type="service" id="fooService">
        <call method="getBar">
            <argument type="service" id="bazService">
                <call method="getQux"/>
            </argument>
        </call>
    </argument>
</service>

Powyższa definicja opisuje następujące wywołanie:

$service = new ServiceClass( $container->getService('fooService')->getBar( $container->getService('bazService')->getQux() ) );

XML Schema

W tym wszystkim jest tylko jeden mankament. Tak jak wspominałem (co zresztą widać w kodzie) nadpisałem walidację XML Schema pustą metodą. Problem w tym, że trzeba by się za dużo z tym pocić, a Symfony 2 jest jeszcze zbyt niestabilne, aby potem to przerabiać. Po pierwsze trzeba by wykorzystane przez nas elementy <call> zamknąć w osobnym namespacie xmlns, gdyż schemat service-1.0.xsd z DI nie pozwala na umieszczanie go w elemencie <argument>. Wtedy powstaje problem z umiejscowieniem naszego schematu dla walidatora. Symfony lokalnie podstawia ścieżki jedynie dla namespaców zaczynających się od http://www.symfony-project.org/. To jednak i tak nie rozwiązuje problemu, gdyż nawet takiego obcego elementu istniejący schemat nie obsługuje. Tak więc jeśli ktoś chce to zrobić naprawdę solidnie, to musi się zająć też tym aspektem, ale jak dla mnie w tym momencie jest to zbyt zmienne środowisko do takich zmagań.

Tagi: , , , , .

Aby pisać komentarze musisz być zalogowany.