El tutorial Jobeet

20.3. El plugin Jobeet

Inicializar un plugin es tan sencillo como crear un nuevo directorio bajo el directorio plugins/. Para el plugin de Jobeet, crea un directorio llamado sfJobeetPlugin:

$ mkdir plugins/sfJobeetPlugin

Nota El nombre de todos los plugins debe acabar con la palabra Plugin. También es recomendable utilizar el prefijo sf, aunque no es obligatorio.

20.3.1. El modelo

En primer lugar, mueve el archivo config/schema.yml a plugins/sfJobeetPlugin/config/:

$ mkdir plugins/sfJobeetPlugin/config/
$ mv config/schema.yml plugins/sfJobeetPlugin/config/schema.yml

Nota Todos los comandos que mostramos en este tutorial son los apropiados para los entornos tipo Unix. Si utilizas Windows, puedes copiar y pegar los archivos utilizando el explorador de archivos. Si utilizas Subversion o cualquier otra herramienta para gestionar tu código, utiliza las herramientas que incluyen para mover código (como por ejemplo svn mv para mover los archivos).

A continuación, mueve todos los archivos del modelo, formularios y filtros al directorio plugins/sfJobeetPlugin/lib/:

$ mkdir plugins/sfJobeetPlugin/lib/
$ mv lib/model/ plugins/sfJobeetPlugin/lib/
$ mv lib/form/ plugins/sfJobeetPlugin/lib/
$ mv lib/filter/ plugins/sfJobeetPlugin/lib/

Si ahora ejecutas la tarea propel:build-model, Symfony sigue generando todos sus archivos en el directorio lib/model/, que es justo lo que no queremos. El directorio en el que Propel genera sus archivos se puede configurar mediante la opción package. Abre el archivo schema.yml y añade la siguiente configuración:

# plugins/sfJobeetPlugin/config/schema.yml
propel:
  _attributes:      { package: plugins.sfJobeetPlugin.lib.model }

Ahora Symfony genera sus archivos en el directorio plugins/sfJobeetPlugin/lib/model/. Los generadores de formularios y de filtros también tienen en consideración esta configuración cuando generan sus archivos.

La tarea propel:build-sql genera un archivo SQL para crear las tablas de la base de datos. Como el archivo se llama igual que el paquete, elimina el archivo actual:

$ rm data/sql/lib.model.schema.sql

Si ejecutas ahora la tarea propel:build-all-load, Symfony genera todos sus archivos en el directorio lib/model/ del plugin:

$ php symfony propel:build-all-load --no-confirmation

Después de ejecutar la tarea anterior, asegúrate de que no se ha creado un directorio llamado lib/model/. Sin embargo, la tarea anterior si que ha creado los directorios lib/form/ y lib/filter/. Estos directorios incluyen las clases base de todos los formularios Propel del proyecto.

Como estos archivos son globales para un proyecto, puedes eliminarlos en el plugin:

$ rm plugins/sfJobeetPlugin/lib/form/BaseFormPropel.class.php
$ rm plugins/sfJobeetPlugin/lib/filter/BaseFormFilterPropel.class.php

Nota Si utilizas Symfony 1.2.0 o 1.2.1, el archivo del formulario base de los filtros se encuentra en el directorio plugins/sfJobeetPlugin/lib/filter/base/.

También puedes mover el archivo Jobeet.class.php al plugin:

$ mv lib/Jobeet.class.php plugins/sfJobeetPlugin/lib/

Como hemos movido muchos archivos y clases, no te olvides de borrar la cache de Symfony:

$ php symfony cc

Nota Si utilizas un acelerador de PHP tipo APC, es posible que se produzcan algunos errores en este punto, por lo que te recomendamos que reinicies Apache.

Después de mover todos los archivos del modelo al plugin, ejecuta las pruebas automáticas para comprobar que todo sigue funcionando correctamente:

$ php symfony test:all

20.3.2. Los controladores y las vistas

El siguiente paso lógico consiste en mover los módulos al directorio del plugin. Para evitar duplicidades con el nombre de los módulos, te aconsejamos prefijar el nombre de cada módulo con el nombre del propio plugin:

$ mkdir plugins/sfJobeetPlugin/modules/
$ mv apps/frontend/modules/affiliate plugins/sfJobeetPlugin/modules/sfJobeetAffiliate
$ mv apps/frontend/modules/api plugins/sfJobeetPlugin/modules/sfJobeetApi
$ mv apps/frontend/modules/category plugins/sfJobeetPlugin/modules/sfJobeetCategory
$ mv apps/frontend/modules/job plugins/sfJobeetPlugin/modules/sfJobeetJob
$ mv apps/frontend/modules/language plugins/sfJobeetPlugin/modules/sfJobeetLanguage

No te olvides de modificar también el nombre de la clase en todos los archivos actions.class.php y components.class.php de cada módulo (por ejemplo, la clase affiliateActions se debe renombrar a sfJobeetAffiliateActions).

Cambia también las llamadas a include_partial() y include_component() en las siguientes plantillas:

  • sfJobeetAffiliate/templates/_form.php (cambia affiliate por sfJobeetAffiliate)
  • sfJobeetCategory/templates/showSuccess.atom.php
  • sfJobeetCategory/templates/showSuccess.php
  • sfJobeetJob/templates/indexSuccess.atom.php
  • sfJobeetJob/templates/indexSuccess.php
  • sfJobeetJob/templates/searchSuccess.php
  • sfJobeetJob/templates/showSuccess.php
  • apps/frontend/templates/layout.php

Actualiza las acciones search y delete:

// plugins/sfJobeetPlugin/modules/sfJobeetJob/actions/actions.class.php
class sfJobeetJobActions extends sfActions
{
  public function executeSearch(sfWebRequest $request)
  {
    if (!$query = $request->getParameter('query'))
    {
      return $this->forward('sfJobeetJob', 'index');
    }

    $this->jobs = JobeetJobPeer::getForLuceneQuery($query);

    if ($request->isXmlHttpRequest())
    {
      if ('*' == $query || !$this->jobs)
      {
        return $this->renderText('No results.');
      }
      else
      {
        return $this->renderPartial('sfJobeetJob/list', array('jobs' => $this->jobs));
      }
    }
  }

  public function executeDelete(sfWebRequest $request)
  {
    $request->checkCSRFProtection();

    $jobeet_job = $this->getRoute()->getObject();
    $jobeet_job->delete();

    $this->redirect('sfJobeetJob/index');
  }

  // ...
}

Por último, modifica el archivo routing.yml para que tenga en cuenta todos los cambios anteriores:

# apps/frontend/config/routing.yml
affiliate:
  class:   sfPropelRouteCollection
  options:
    model:          JobeetAffiliate
    actions:        [new, create]
    object_actions: { wait: GET }
    prefix_path:    /:sf_culture/affiliate
    module:         sfJobeetAffiliate
  requirements:
    sf_culture: (?:fr|en)

api_jobs:
  url:     /api/:token/jobs.:sf_format
  class:   sfPropelRoute
  param:   { module: sfJobeetApi, action: list }
  options: { model: JobeetJob, type: list, method: getForToken }
  requirements:
    sf_format: (?:xml|json|yaml)

category:
  url:     /:sf_culture/category/:slug.:sf_format
  class:   sfPropelRoute
  param:   { module: sfJobeetCategory, action: show, sf_format: html }
  options: { model: JobeetCategory, type: object, method: doSelectForSlug }
  requirements:
    sf_format: (?:html|atom)
    sf_culture: (?:fr|en)

job_search:
  url:   /:sf_culture/search
  param: { module: sfJobeetJob, action: search }
  requirements:
    sf_culture: (?:fr|en)

job:
  class:   sfPropelRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: PUT, extend: PUT }
    prefix_path:    /:sf_culture/job
    module:         sfJobeetJob
  requirements:
    token: \w+
    sf_culture: (?:fr|en)

job_show_user:
  url:     /:sf_culture/job/:company_slug/:location_slug/:id/:position_slug
  class:   sfPropelRoute
  options:
    model: JobeetJob
    type:  object
    method_for_criteria: doSelectActive
  param:   { module: sfJobeetJob, action: show }
  requirements:
    id:        \d+
    sf_method: GET
    sf_culture: (?:fr|en)

change_language:
  url:   /change_language
  param: { module: sfJobeetLanguage, action: changeLanguage }

localized_homepage:
  url:   /:sf_culture/
  param: { module: sfJobeetJob, action: index }
  requirements:
    sf_culture: (?:fr|en)

homepage:
  url:   /
  param: { module: sfJobeetJob, action: index }

Si ahora accedes al sitio web de Jobeet, verás que se muestran excepciones indicando que los módulos no están activados. Como los plugins están disponibles en todas las aplicaciones de un mismo proyecto, debes indicar explícitamente en el archivo de configuración settings.yml los módulos que están activados en cada aplicación:

# apps/frontend/config/settings.yml
all:
  .settings:
    enabled_modules:
      - default
      - sfJobeetAffiliate
      - sfJobeetApi
      - sfJobeetCategory
      - sfJobeetJob
      - sfJobeetLanguage

El último paso de la migración consiste en arreglar las pruebas funcionales en las que probamos el nombre del módulo.

20.3.3. Las tareas

Mover las tareas al plugin es muy sencillo:

$ mv lib/task plugins/sfJobeetPlugin/lib/

20.3.4. Los archivos de internacionalización

Los plugins también pueden contener archivos en formato XLIFF:

$ mv apps/frontend/i18n plugins/sfJobeetPlugin/

20.3.5. El sistema de enrutamiento

Los plugins también pueden incluir sus propias reglas en el sistema de enrutamiento:

$ mv apps/frontend/config/routing.yml plugins/sfJobeetPlugin/config/

20.3.6. Los archivos CSS y JavaScript

A pesar de que puede no parecer evidente, los plugins también pueden contener archivos web como imágenes, hojas de estilos y archivos JavaScript. Como no vamos a redistribuir Jobeet como plugin, no tiene sentido que añadamos todos estos archivos, pero si quieres hacerlo, crea un directorio llamado plugins/sfJobeetPlugin/web/ y copia en el todos estos archivos.

Para que los archivos web del plugin se puedan ver desde el navegador, es necesario hacerlos accesibles en el directorio web/ del proyecto. La tarea plugin:publish-assets se encarga de ello creando enlaces simbólicos en sistemas operativos Unix y copiando los archivos en sistemas operativos Windows:

$ php symfony plugin:publish-assets

20.3.7. El usuario

Mover los métodos de la clase myUser que se encargan de crear el historial de las ofertas de trabajo visitadas es un poco más complicado. Se podría crear una clase llamada JobeetUser y hacer que myUser herede de ella. No obstante, existe una forma mejor de hacerlo, sobre todo si varios plugins diferentes quieren añadir métodos a la clase.

Los objetos internos de Symfony notifican durante su tiempo de vida diferentes eventos que podemos escuchar. En nuestro caso, queremos escuchar el evento user.method_not_found, que se notifica cuando se invoca un método que no existe en el objeto sfUser.

Cuando se inicializa Symfony, también se inicializan todos los plugins que tienen una clase de configuración:

// plugins/sfJobeetPlugin/config/sfJobeetPluginConfiguration.class.php
class sfJobeetPluginConfiguration extends sfPluginConfiguration
{
  public function initialize()
  {
    $this->dispatcher->connect('user.method_not_found', array('JobeetUser', 'methodNotFound'));
  }
}

Las notificaciones de los eventos se gestionan mediante el objeto sfEventDispatcher. Registrar un listener (es decir, un método que escucha eventos) es tan sencillo como realizar una llamada al método connect(). El método connect() asocia un nombre de evento con un elemento ejecutable de PHP, también llamado "PHP callable".

Nota Un elemento ejecutable de PHP es una variable de PHP que se puede utilizar en la función call_user_func() y que devuelve true cuando se pasa a la función is_callable(). Si el elemento ejecutable es una función, se indica mediante una cadena de texto. Si el elemento ejecutable es el método de una clase u objeto, se indica mediante un array.

El código del ejemplo anterior hace que el objeto myUser invoque el método estático methodNotFound() de la clase JobeetUser cada vez que no se encuentre un método en ese objeto. Después, el método methodNotFound() se encarga de procesar o ignorar el método que no existe en myUser.

Elimina todos los métodos de la clase myUser y crea en su lugar la clase JobeetUser:

// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
}
// plugins/sfJobeetPlugin/lib/JobeetUser.class.php
class JobeetUser
{
  static public function methodNotFound(sfEvent $event)
  {
    if (method_exists('JobeetUser', $event['method']))
    {
      $event->setReturnValue(call_user_func_array(
        array('JobeetUser', $event['method']),
        array_merge(array($event->getSubject()), $event['arguments'])
      ));

      return true;
    }
  }

  static public function isFirstRequest(sfUser $user, $boolean = null)
  {
    if (is_null($boolean))
    {
      return $user->getAttribute('first_request', true);
    }
    else
    {
      $user->setAttribute('first_request', $boolean);
    }
  }

  static public function addJobToHistory(sfUser $user, JobeetJob $job)
  {
    $ids = $user->getAttribute('job_history', array());

    if (!in_array($job->getId(), $ids))
    {
      array_unshift($ids, $job->getId());
      $user->setAttribute('job_history', array_slice($ids, 0, 3));
    }
  }

  static public function getJobHistory(sfUser $user)
  {
    return JobeetJobPeer::retrieveByPks($user->getAttribute('job_history', array()));
  }

  static public function resetJobHistory(sfUser $user)
  {
    $user->getAttributeHolder()->remove('job_history');
  }
}

Cuando se invoca el método methodNotFound(), el encargado de notificar los eventos pasa como argumento un objeto de tipo sfEvent.

Si el método existe en la clase JobeetUser, se invoca y el valor devuelto se devuelve al notificador de eventos. Si no existe el método, Symfony utiliza el siguiente listener registrado para ese evento y si ya no existen más listeners, se lanza una excepción.

El método getSubject() se puede utilizar para determinar el notificador del evento, que en este caso sería el objeto myUser.

Como siempre que creas nuevas clases, no te olvides de borrar la cache de Symfony antes de probar la aplicación o antes de ejecutar las pruebas:

$ php symfony cc

20.3.8. Arquitectura por defecto vs. arquitectura de los plugins

Si utilizas la arquitectura de los plugins, puedes organizar tu código de una forma completamente diferente:

Diferencias entre la arquitectura tradicional y la arquitectura de los plugins

Figura 20.1 Diferencias entre la arquitectura tradicional y la arquitectura de los plugins