Noticias

Sincronización de apis con anotaciones doctrine

Las anotaciones de doctrine son un mecanismo potente que permite desde indicar en nuestras entidades los tipos de campo y datos de validación de los mismos hasta "anotar" información sobre el nombre de qué tabla se generará para la entidad o el repositorio que queremos enlazar a la misma.

Pero nada nos impide crear nuestras propias anotaciones adaptadas a la lógica de negocio que necesitemos. En la entrada de hoy, vamos crear un par de anotaciones asociadas a una entidad de Doctrine. En base a estas anotaciones crearemos un evento que leerá esta anotación y realizará una lógica en función de su contenido.

Escenario

Vamos a considerar el siguiente supuesto. Un sitio web que cuenta con un formulario de contacto, cuando este formulario se envía se almacena en base de datos para su posterior revisión. Se requiere a partir de ahora que la información se sincronice en un servicio externo via api. En lugar de un formulario de contacto, podría ser un pedido, una factura, o un cliente. Y la api a conectar podría ser bien un crm, un sistema propio o un sitio web convencional sobre el que queramos informar a una api externa cuando se registra un nuevo contacto.

La comunicación en las apis, normalmente se realiza enviando algún tipo de dato ya sea bien en formato json o xml. La nomenclatura de los campos y la obligatoriedad o no de incluirlos viene determinada por la propia api contra la que comunicamos. Un buen mecanismos abstracto de comunicación, debería tener en cuenta estos detalles.

Vamos a abordar este problema desde uno de los posibles acercamientos con la creación de anotaciones.

La librería de gestión de anotaciones de Doctrine, surgió como necesidad del ORM de poder leer información embebida dentro de una clase. La librería es independiente, de forma que puede ser utilizada en nuestros proyectos.

Doctrine entiende dos tipos de anotaciones básicas. Las asociadas a una clase y las asociadas a los componentes de una clase.

Lo primero que debemos plantear, es la forma de diferenciar las clases a las que queremos dar un tratamiento especial. Para ello vamos a crear una anotación de clase específica. La llamaremos ApiSync.

Para que el lector de anotaciones de Doctrine tenga en cuenta nuestra anotación, crearemos una clase describiéndola. Para ello vamos a generar de nuestro bundle una carpeta Annotations, en la cual creamos un fichero ApiSync.php con este contenido.

<?php

namespace Acme\DemoBundle\Annotation;

/**
  * Class ApiSyncEntity
  * @package Acme\DemoBundle\Annotations
  *
  * @Annotation
  * @Target("CLASS")
*/
final class ApiSyncEntity
{
  /**
    * @var entity
    */
  private $entity;

  /**
    * @param $data
    */
  public function __construct($data)
  {
    $this->entity = $data['entity'];
  }

  /**
    * @return mixed
    */
  public function getEntity()
  {
    return $this->entity;
  }
}

Bien, ya tenemos generada la anotación. Fíjate en la annotación “@Target(“CLASS”)”, con ello estamos indicando a doctrine que la anotación se situará en la definición de la clase. Ahora nos queda utilizar la anotación en una de nuestras clases.

 

<?php

namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Acme\DemoBundle\Annotation\ApiSyncEntity;

/**
 * Class Contact
 * @package Acme\DemoBundle\Entity
 *
 * @ORM\Table(name="acme_contact")
 * @ORM\Entity()
 * @ApiSyncEntity(entity="Contact")
 */
class Contact
{
  // ...
}

 

Una vez marcada la entidad con nuestra anotación es necesario detectar cuando la entidad es persistida, de manera que podamos ver si cuenta con nuestra anotación y actuar en consecuencia. Para ello vamos a utilizar los eventos de doctrine. En concreto vamos a suscribirnos al evento postPersist que se producirá cuando una entidad sea persistida. Creemos el servicio 

// Acme/DemoBundle/Resources/config/services.xml
// …
        <service id="api_annotation_subscriber" class="Acme\DemoBundle\EventListener\ApiSyncAnnotationSubscriber">
            <tag name="doctrine.event_subscriber" connection="default" />
        </service>
// …

O bien y formato yaml

// Acme/DemoBundle/Resources/config/services.yml
services:

    api_annotation_subscriber:
        class: Acme\DemoBundle\EventListener\ApiSyncAnnotationSubscriber
        tags:
            - { name: doctrine.event_subscriber, connection: default }

 

Y ahora creemos la clase ApiSyncAnnotationSubscriber. La crearemos dentro de la carpeta EventListener:

En este servicio, vamos a indicar a Doctrine que tenga en cuenta como suscriptora nuestra clase en la conexión configurada por defecto  Vamos a ver las partes clave de este subscriber.

Debemos indicar los eventos sobre los que queremos escuchar.

 

    /**
    * @return array
    */
    public function getSubscribedEvents()
    {
       return array(
           Events::postPersist
       );
    }

Ahora creamos la función postPersist, que será llamada cuando una entidad se persista.

    /**
     * @param LifecycleEventArgs $args
     */
    public function postPersist(LifecycleEventArgs $args)
    {
        $reader = new AnnotationReader;
        $entityClass = get_class($args->getEntity());
        $apySyncEntityAnnotation = $reader->getClassAnnotation(new \ReflectionClass($entityClass), self::API_SYNC_ENTITY_ANNOTATION_META);
        if (! empty($apySyncEntityAnnotation)) {
            $entity = $apySyncEntityAnnotation->getEntity();

            // Manage business logic
            $this->handlePostEventsApiSync($args, $entity);
        }
    }

Como has visto con la ayuda del reader de doctrine y  a través de la librería de ReflectionClass podemos leer la anotación y ver si coincide con con la que queremos procesar. Si se produce coincidencia realizamos el proceso adecuado. En nuestro caso realizar una llamada externa. Pero para poder crear esta llamada nos hace falta crear una segunda anotación, que nos permita marcar las propiedades que son relevantes en la comunicación a la api.

Pues bien, vamos a proceder a crear esta segunda anotación:

<?php

namespace Acme\DemoBundle\Annotation;

/**
 * @Annotation
 */
class ApiSyncContent
{
    /**
     * @var name
     */
    private $name;

    /**
     * @var required
     */
    private $required;

    /**
     * @var default
     */
    private $default;

    /**
     * @param $options
     */
    public function __construct($options)
    {
        foreach ($options as $key => $value) {
            if (! property_exists($this, $key)) {
                throw new \InvalidArgumentException(sprintf('Property "%s" does not exist', $key));
            }

            $this->$key = $value;
        }
    }

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

    /**
     * @return mixed
     */
    public function getRequired()
    {
        return (bool)$this->required;
    }

    /**
     * @return mixed
     */
    public function getDefault()
    {
        return $this->default;
    }
}

Esta anotación está configurada para contener tres tipos de valores:

  • Name: contendrá el nombre de campo en la api
  • Required: indicará si el campo es requerido
  • Default: contendrá un valor por defecto para el api


Ahora vamos a utilizar la anotación en la clase Contact.

 

<?php

namespace Acme\DemoBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Acme\DemoBundle\Annotation\ApiSyncEntity;
use Acme\DemoBundle\Annotation\ApiSyncContent;

/**
 * Class Contact
 * @package Acme\DemoBundle\Entity
 *
 * @ORM\Table(name="acme_contact")
 * @ORM\Entity()
 * @ApiSyncEntity(entity="Contact")
 */
class Contact
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @var name
     * @ORM\Column(type="string", length=50, nullable=true)
     * @ApiSyncContent(name="api_name")
     */
    private $name;

    /**
     * @param mixed $id
     */
    public function setId($id)
    {
        $this->id = $id;
    }

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

    /**
     * @param mixed $name
     */
    public function setName($name)
    {
        $this->name = $name;
    }

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

En estos momentos ya estamos preparados para extraer los datos durante la ejecución del evento Doctrine.

<?php

namespace Acme\DemoBundle\EventListener;

use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\Common\Annotations\AnnotationReader;

class ApiSyncAnnotationSubscriber implements EventSubscriber
{
    const API_SYNC_ENTITY_ANNOTATION_META = 'Acme\DemoBundle\Annotation\ApiSyncEntity';

    const API_SYNC_ENTITY_ANNOTATION_CONTENT = 'Acme\DemoBundle\Annotation\ApiSyncContent';
    
    // ...


    /**
     * @param LifecycleEventArgs $args
     * @param $entity
     */
    protected function handlePostEventsApiSync(LifecycleEventArgs $args, $entity)
    {
        // do something cool!
        $reader = new AnnotationReader;
        $class = new \ReflectionClass(get_class($args->getEntity()));
        $data = [];

        foreach ($class->getProperties() as $reflectionProperty) {
            $annotation = $reader->getPropertyAnnotation($reflectionProperty, self::API_SYNC_ENTITY_ANNOTATION_CONTENT);

            if (null !== $annotation) {
                $property = $reflectionProperty->getName();
                $entity = $args->getEntity();
                if (property_exists($entity, $property)) {
                    $getter = sprintf('get%s', ucfirst($property));
                    $value = $entity->$getter();
                    $apiName = $annotation->getName();
                    if (empty($value) && $annotation->getRequired()) {
                        $defaultValue = $annotation->getDefault();
                        $value = isset($defaultValue)
                            ? $defaultValue
                            : 'EMPTY_VALUE';
                    }

                    $data[] = ['name' => $apiName, 'value' => $value];
                }
            }
        }

        if (count($data) > 0) {
            $this->publish($args, $lead, 'newContact', 'MyCustomEvent');
        }
    }

    // ...

 

Como puedes ver, gracias a esta nueva anotación ya podemos leer las propiedades de la clase y construir el array $data con los datos a enviar al servicio web externo, para poder comunicar que se ha producido un nuevo contacto. En este momento sólo quedaría procesar este array, convertirlo al formato adecuado json o xml

Una vez con este array ya resulta trivial convertir el array en json o xml y comunicar con una api externa utilizando herramientas como guzzle.

Pongamos que queremos que cuando se guarde un pedido, se haga una llamada a una api externa notificando este nuevo pedido. Para ello vamos necesitamos alguna manera de mapear algunos campos del pedido, de manera que podamos extraer ciertos contenidos y generar una llamada a la api externa válida. Para lograrlo vamos a generar una nueva anotación.


A tener en cuenta

Un punto importante que habría que tratar a partir de este punto es el tratamiento de las llamadas de sincronización. Debido a que estamos trabajando en un proceso externo, es necesario tomar ciertas precauciones frente a posibles caídas de este servicio o cualquier tipo de problema en su funcionamiento. Para ello al menos tendríamos que mejorar dos puntos.

  • Punto 1: separar a otro proceso las llamadas a la sincronización:  la separación en otro proceso puedes lograrla encolando la petición a través de una herramiento como rabbit, tienes soluciones intermedias como SonataNottificationBundle, que es compatible con rabbit a la vez que te permite utilizar otros sistemas de encolado menos exigentes.
  • Punto 2: sería necesario indicar de alguna manera qué entidades han sido procesadas y el resultado del procesamiento: podríamos implementar un trait, con los campos necesarios.
  • Punto 3: generar un comando que se pueda ejecutar de forma periódica, de manera que pueda procesar las entidades que no se hayan podido sincronizar por cualquier motivo.

 

Conclusiones

Hemos generado dos anotaciones para controlar la sincronización entre nuestro sistema y una api externa. Mediante los eventos de doctrine hemos leído las anotaciones y podido crear un array conformado con los datos necesarios para la llamada a la api externa. Finalmente hemos visto algunas de las precauciones a tomar cuando llevamos a producción un sistema de este tipo dependiente de un servicio externo.

 

Espero que os sirva de ayuda el post. Todo el código del mismo lo podéis encontrar en mi cuenta de github.

Nos vemos en el próximo post ;-)


Referencias

Documentación anotaciones con doctrine: http://doctrine-common.readthedocs.org/en/latest/reference/annotations.html


Symfony2 Doctrine


Compartir mola!!