Skip to content

Latest commit

 

History

History
764 lines (574 loc) · 30.9 KB

symfony-escalable-y-mantenible-codelytv.md

File metadata and controls

764 lines (574 loc) · 30.9 KB

Symfony mantenible y escalable

by Dani Santamaría, Javier Ferrer – CodelyTV


Aprende herramientas y prácticas con Symfony para conseguir una mayor mantenibilidad y escalabilidad de tus aplicaciones.

Available resources

🏷️ Tags: course, 2021, codelytv, symfony, backend, ...


Introducción, historia, filosofía, arquitectura Symfony

  • Se dan notas históricas sobre Symfony. Aquí unas notas
  • Uso del patrón DataMapper reemplazando al ActiveRecord utilizado inicialmente
  • Symfony Flex
  • Definición de servicios vía autowiring
  • Parametrización vía variables de entorno
  • Symfony trata de segregar interfaces con propósitos muy concretos, permitiendo que los clientes puedan usarlas y expandirlas si quieren. Un ejemplo es \Symfony\Component\HttpKernel\Kernel
  • Componentes Symfony EventDispatcher y Symfony Messenger
  • Iteración del framework para permitir definición de todo como servicios, incluso los controladores. Era típico el buscar cómo definir controladores como servicios
  • Uso de event dispatchers de Symfony y middleware de Laravel

Event dispatcher and middleware (Referencia)

Symfony request handling

Migración progresiva del Legacy a Symfony

  • La clave del proceso es hacerlo progresivo. No tratar de reemplazar el sistema legacy por el nuevo de un solo golpe, sino ir haciendo cambios y despliegues progresivos
  • Explican 2 posibles estrategias para ir reemplazando el legacy por Symfony (sidenote: aunque siempre hay más alternativas. Elegir una u otra puede depender de qué tipo de legacy hay que migrar o de su complejidad)

Fallback al front controller Legacy

  • Se crea un nuevo proyecto Symfony totalmente separado del legacy
  • El front controller de Symfony es el punto de entrada a la aplicación (public/index.php)
  • A grandes rasgos, se modifica el front controller de tal modo que todas aquellas rutas que dan una respuesta 404, se ejecuta el controlador legacy correspondiente para que maneje la petición
  • El controlador legacy actuaría de fallback en este caso

Cargar rutas Legacy

  • Aquí se añaden las rutas legacy a la instancia de RouteCollection que Symfony crea en base a la configuración de rutas del nuevo proyecto
  • Para las rutas legacy, se crea un nuevo custom Symfony Loader que se etiqueta en el inyector de dependencias como tal para que sea cargado
  • En este custom Loader, es donde se crea el nuevo controlador para manejar las rutas legacy, asociando estas rutas legacy a un nuevo controlador manejado en la aplicación de Symfony
  • En este controlador es donde se maneja la ejecución del controlador legacy para cuando esta ruta no tenga todavía un reemplazo en la configuración de rutas del proyecto
  • Aquí ya no hablamos de fallback, pues las rutas legacy son ahora manejadas como rutas del proyecto Symfony al mismo nivel (e.g. se puede ver utilzando el comando debug:router)

Configurar y adaptar Symfony para mejorar la mantenibilidad

  • Detalles sobre el Contenedor de Inyección de Dependencias de Symfony (componente)
  • Creación de distintos Kernels para distintos front controllers para distintas aplicaciones desarrolladas en un único monorepo
  • Uso del autoconfigure de Symfony y la opción _instanceofdel contenedor para tanguear servicios delegando en Symfony sin tener que hacerlo manualmente. Autoconfiguring tags y Reference tagged services

Optimizaciones habituales en peticiones HTTP

Gestión de errores

  • Control de una excepción no capturada transformándola en una respuesta controlada con un determinado HTTP status code

  • Cada vez que en un controlador hay que añadir el control de una nueva excepción, hay que modificar la clase del controlador. Si se quiere aplicar SOLID aquí, se podría no modificar la clase y controlar las excepciones de todos los controladores en un punto común

  • Varias formas de hacerlo. La manera propuesta

    abstract class ApiController
    {
        public function __construct(
            // ...
            ApiExceptionsHttpStatusCodeMapping $exceptionHandler
        ) {
            each(
                fn(int $httpCode, string $exceptionClass) => $exceptionHandler->register($exceptionClass, $httpCode),
                $this->exceptions()
            );
        }
    
        // ...
    
        abstract protected function exceptions(): array;
    }
    
    final class CoursesCounterGetController extends ApiController
    {
        public function __invoke(): JsonResponse
        { /* ... */ }
    
        protected function exceptions(): array
        {
            return [
                CoursesCounterNotExist::class => Response::HTTP_NOT_FOUND,
                // ...
            ];
        }
    }
    final class ApiExceptionsHttpStatusCodeMapping
    {
        private const DEFAULT_STATUS_CODE = Response::HTTP_INTERNAL_SERVER_ERROR;
    
        // ...
    
        public function statusCodeFor(string $exceptionClass): int
        {
            return get(
                key: $exceptionClass,
                collection: $this->exceptions,
                default: self::DEFAULT_STATUS_CODE
            );
        }
    }
    
    final class ApiExceptionListener
    {
        public function __construct(private ApiExceptionsHttpStatusCodeMapping $exceptionHandler)
        {
        }
    
        public function onException(ExceptionEvent $event): void
        {
            $exception = $event->getThrowable();
    
            $event->setResponse(new JsonResponse(
                [
                    'code'    => $this->exceptionCodeFor($exception),
                    'message' => $exception->getMessage(),
                ],
                $this->exceptionHandler->statusCodeFor($exception::class)
            ));
    
            // ...
        }
    }

    ApiController: Clase abstracta que usa el patron template method en el método exceptions para que los controladores definan el un diccionario que mapee la excepción con un código de respuesta HTTP

    ApiExceptionsHttpStatusCodeMapping: Maneja el mapeo de códigos

    ApiExceptionListener: Symfony EventListener donde se hace uso de la clase anterior; reacciona a las excepciones para construir una respuesta de error HTTP

Optimizar el rendimiento

  • Ejemplo de enviar un email tras devolver la respuesta

Procesado de eventos de dominio en Event Subscriber

  • Hacer uso del kernel.terminate para todo lo que no sea necesario procesar para darle la respuesta al usuario

Otras optimizaciones

Parsear JSON del request body

  • La solución que se explica es interesante siempre que los argumentos de entrada vengan todos en el body de la petición HTTP (nota: aunque no está limitado al body, y podrían leerse cabeceras o cualquier información pública del objeto de la request)

  • El método invocado del controlador recibe un argumento con un DTO ya instanciado (resuelto y deserializado) con los datos de la petición, sin necesidad de tratar el objecto Http Request

  • Se hace uso de Symfony Argument Resolver

    final class RegisterUserPutController
    {
        public function __invoke(RegisterUserCommand $command): JsonResponse
        {
    // ...
    use CodelyTv\Shared\Domain\Bus\Command\Command;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
    use Symfony\Component\Serializer\SerializerInterface;
    // use ...
    
    final class CommandValueResolver implements ArgumentValueResolverInterface
    {
        public function __construct(private SerializerInterface $serializer)
        {
        }
        
        public function supports(Request $request, ArgumentMetadata $argument): bool
        {
            return is_subclass_of($argument->getType(), Command::class);
        }
    
        public function resolve(Request $request, ArgumentMetadata $argument): Generator
        {
            yield $this->serializer->deserialize($request->getContent(), $argument->getType(), 'json');
        }
    }

Serializar respuestas automáticamente a JSON

  • En lugar de devolver un objeto Http Response, se devuelve un DTO de la capa de aplicación que será serializado a JSON por Symfony

    final class CourseCounterGetController
    {
        public function __invoke(): CoursesCounterResponse
        {
            // ...
            return new CoursesCounterResponse(10);
        }
    }
  • Implementar un EventListener que escuche al evento KernelView de Symfony

    // ...
    public function onKernelView(ViewEvent $event)
    {
       if (!$event->getControllerResult() instanceof Response) { return; }
    
       $event->setResponse(
           new JsonResponse(
               $this->serializer->serialize($event->getControllerResult(), 'json')
           )
       );
    }
    // ...

Añadir headers de forma global

  • Si todas las respuestas tienen una o varias cabeceras que son siempre las mismas (e.g. CORS, o caché)

  • Se puede hacer también vía Symfony Event Listener

    // ...
    public function onKernelResponse(ResponseEvent $event)
    {
       $response = $event->getResponse();
       $response->setMaxAge(180);
       $response->setPublic();
    }
    // ...

Feature flags y Dark Launching

  • Tener funcionalidades a producción donde solo ciertos usuarios pueden verlas (dark launching), o que las podamos activar sin necesidad de deployar código (feature flag / toggle)

  • Una opción que se ve es para dark launching vía el evento de Symfony Kernel Request

    // ...
    public function onKernelRequest(RequestEvent $event)
    {
    		// lógica para determinar si el usuario puede ver la funcionalidad
    
    		// en caso negativo, podemos enviarle una respuesta 404 Not Found
        $event->setResponse($response);
    }
    // ...

Progressive Rollout y A/B testing

  • Caso de tener 2 controladores, uno nuevo y otro el actual para un mismo caso, y redirigir peticiones a uno u otro. Se propone varias alternativas

  • Modificar el valor de _controller

    • El componente kernel de Symfony define un listener para reaccionar ante una request, es el kernel.request. Aquí añade la referencia al controlador en el atributo _controller

      // ...
      public function onKernelRequest(RequestEvent $event)
      {
          // lógica para determinar qué controlador usar
      
          $event->getRequest()->attributes->set('_controller', $controller);
      }
      // ...
    • Con un EventListener propio se puede modificar esta referencia. Hay que tener en cuenta las prioridades de llamada de los event listener, y usar una prioridad más baja que la usada por Symfony

      $ bin/console debug:event-dispatcher kernel.request
      
      Registered Listeners for "kernel.request" Event
      ===============================================
      
       ------- --------------------------------------------------------------------------------------- ----------
        Order   Callable                                                                                Priority
       ------- --------------------------------------------------------------------------------------- ----------
        #1      Symfony\Component\HttpKernel\EventListener\DebugHandlersListener::configure()           2048
        #2      Symfony\Component\HttpKernel\EventListener\ValidateRequestListener::onKernelRequest()   256
        #3      Symfony\Component\HttpKernel\EventListener\SessionListener::onKernelRequest()           128
        #4      Symfony\Component\HttpKernel\EventListener\LocaleListener::setDefaultLocale()           100
        #5      Symfony\Component\HttpKernel\EventListener\RouterListener::onKernelRequest()            32
        #6      Symfony\Component\HttpKernel\EventListener\LocaleListener::onKernelRequest()            16
       ------- --------------------------------------------------------------------------------------- ----------
  • Reaccionar al evento Controller

    • Settear el método de llamada del controlador justo antes de que se llame al mismo

      // ...
      public function onKernelController(ControllerEvent $event)
      {
          // ...
          $event->setController($controllerCallable);
      }
      // ...
  • Implementar un ControllerResolver

    • Crear un controller resolver propio y establecer en él el controlador a llamar
    • Algunas referencias: 1, 2

Poner la web en estado de mantenimiento

  • Una opción es reaccionando de nuevo a kernel.request

    public function onKernelRequest(RequestEvent $event)
    {
        // ...
        $response->setStatusCode(Response::HTTP_SERVICE_UNAVAILABLE)
        $event->setResponse($response);
    }

Persistencia con Doctrine

ORM, Dbal, SQL ¿Cuándo usar cada uno?

  • Si se elige usar ORM, tener siempre en cuenta que las SQL queries que se ejecutarán finalmente van a depender mucho de cómo están modeladas las entidades implicadas. Se puede usar el Symfony profiler para analizar estas queries y ver si se pueden mejorar en caso de ser necesario
  • Siempre binding de valores a parámetros tanto con PDO como con Dbal para evitar SQL injection

Streaming de datos: procesar archivo y enviar respuesta HTTP

$response = new StreamedResponse();
$response->setCallback(function () use ($fileHandle) {
    while (!feof($fileHandle)) {
        if (!$line = fgets($fileHandle)) {
            break;
        }
				echo $line; // sends data
    }

    fclose($fileHandle);
});
$response->headers->set('Content-Type', 'text/plain');
  • It uses a function callback where is possible to iterate over a set of big data and stream it in small chunks
  • Next example is using Doctrine to stream data from the database
$response->setCallback(function() {
    $query = $this->entityManager->createQuery("SELECT f FROM App\Entity\Food f ORDER BY f.id DESC");
    /** @var Food $food */
    foreach ($query->toIterable() as $key => $food) {
        echo $food->id() . ' ' . $food->name() . PHP_EOL;

        $this->entityManager->clear($food);
    }
});
$response->headers->set('Content-Type', 'text/plain');
  • Doctrine allows it, and if the dbms you are using allows it as well and you have it correctly configured, then the data can be sent streamed to the HTTP responsedoc
  • toIterable() facility to iterate over the query results step by step instead of loading the whole result into memory at once — Doctrine Batch Processing, iterating results
  • Another reference: Streaming Files in Symfony

Streaming de datos y procesos en batch con Doctrine

Rendimiento, Profiling y DevEx

DevEx en Symfony: Profiling, Debug y PHPUnit Bridge

Testing de aplicaciones Symfony

Añadiendo las dependencias mínimas e indispensables con Symfony Flex

  • composer require --dev phpunit/phpunit symphony/test-pack

  • symfony/test-pack: Metapaquete de Composer (o un Recipe en Symfony Flex) que agrupa varias dependencias para hacer testing funcional y E2E en una aplicación Symfony

    {
        "phpunit/phpunit": "^9.5",
        "symfony/browser-kit": "*",
        "symfony/css-selector": "*",
        "symfony/phpunit-bridge": "*"
    }
  • En una aplicación Symfony, utiliza Symfony Flex para que autoconfigure las dependencias dentro del proyecto

Test de integración para base de datos

  • Preparar una base de datos limpia antes de cada test. Se plantea dos maneras

    • Hacer un TRUNCATE de todas las tablas antes de ejecutar cada test

      protected function setUp(): void
      {
          $this->clearDatabase();
      }
      
      protected function clearDatabase(): void
      {
          foreach ($this->connection()->getSchemaManager()->listTableNames() as $tableName) {
              $this->connection()->executeQuery('TRUNCATE ' . $tableName);
          }
      }
    • Envolver el test en una transacción de base de datos, y hacer un rollback de la misma al acabar el test para devolver la base de datos al estado inicial

      protected function setUp(): void
      {
          $this->connection()->beginTransaction();
      }
      
      protected function tearDown(): void
      {
          $this->connection()->rollBack();
      }
  • La preparación de datos de cada test se encarga el propio test-case, como parte de "arrange" en la típica estructura "arrange - act - assert" de un test-case

  • Relacionado con lo anterior, se evita el uso de fixtures globales para popular un estado inicial de datos no vacío / limpio

  • Referencias

Test de aceptación de API HTTP con Behat y Mink

  • Tests de aceptación con Behat, Mink, Behat Symfony Extension y Mink Symfony Extension.

  • Con Mink Symfony Extension, conseguimos que los tests sean más rápidos al no usar un navegador o web driver real

    • Utiliza el mismo thread de PHP que ejecutó el test para comunicarse con el proyecto Symfony
    • No se realizan llamadas HTTP sino que es el proyecto Symfony quien controla las peticiones directamente
    • Por lo que, a pesar de que puedan ser escritos y poder considerarse como tests de aceptación, no son llegan a ser tests E2E del todo, o son menos E2E al no realizar esas peticiones HTTP que nos ahorramos
  • Pequeños ejemplos

  • Contextos de tests E2E con un propósito

Test E2E con Panther: Login en Codely Pro

  • Symfony Panther nos permite hacer tests E2E, y al estar implementado como un Browser Kit, podemos reemplazar nuestros test E2E actuales de Symfony por Panther
  • Con Panther se puede hacer tanto web scraping como testing de navegador muy real. Testing de caja negra al tener el test que no conoce ni interacciona con ningún elemento interno de la aplicación
  • Usado para tests E2E, serán muy frágiles y lentos. Se vuelven la mejor opción para los tests E2E solo de la/s partes más críticas
  • Existen alternativas más maduras a Panther, como el super popular Cypress

Autenticación de APIs HTTP y SaaS con JWT

Autenticación sin estado con JWT: Certificado de curso

  • JSON Web Tokens (JWT) firmados por el servidor antes de ser enviados
    • El servidor emite tokens firmados con clave asimétrica que son verificables de forma segura
    • La información que contiene el token es pues verificable de forma segura
  • Tokens que caducan que no son persistidos en el servidor, solo emitidos
    • Al no desear almacenarlos, tampoco se tiene por qué revocar
    • Autenticación sin estado, tokens emitidos sin almacenamiento ni posibilidad de revocarlos
    • Invalidación / revocación de tokens introduce persistencia en algún punto del sistema
  • No usar JWT para crear un token de autenticación (access token) para almacenar variables de sesión
  • LexikJWTAuthenticationBundle
public function __invoke(): Response
{
    // \Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface
    $token = $this->jwtEncoder->encode([
        // ...
    ]);
// ...
$ php ./bin/console lexik:jwt:generate-keypair
# --
config/jwt
├── private.pem
└── public.pem

1 directory, 2 files

Autenticación en Symfony 6.0: API HTTP con JWT

config/packages/security.yaml

security:
    # Deprecated in >=6.2 https://github.com/symfony/symfony/pull/47890
    enable_authenticator_manager: true

    providers:
        jwt_user_provider:
            id: App\Infrastructure\Symfony\Security\JwtUserProvider

    encoders:
        App\Infrastructure\Symfony\Security\JwtUser:
            algorithm: auto

        firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        login:
            pattern: ^/login
            stateless: true
            provider: jwt_user_provider
            json_login:
                check_path: /login
                # successful auth. Service from LexikJWTAuthenticationBundle. Generates JSON response with JWT token
                success_handler: lexik_jwt_authentication.handler.authentication_success
                # login incorrect / not auth. Also by LexikJWTAuthenticationBundle
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        main:
            stateless: true
            # use the default settings defined by LexikJWTAuthenticationBundle, so no need to manually specify each
            # configuration parameter related to JWT
            jwt: ~

    access_control:
        - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

src/Infrastructure/Controller/ProfileGetController.php

// ...
use Symfony\Component\Security\Core\Security;
// ...
public function __invoke(): Response
{
    $jwtUser = $this->security->getUser();
    $student = ($this->findStudent)(
        new FindStudentRequest($jwtUser->getUserIdentifier())
    );
    return new JsonResponse($student->id());
}
// ...

src/Infrastructure/Symfony/Security/JwtUser.php

// ..
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

final class JwtUser implements UserInterface, PasswordAuthenticatedUserInterface
{
// ...

src/Infrastructure/Symfony/Security/JwtUserProvider.php

// ...
use App\Domain\StudentDoesNotExist;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

final class JwtUserProvider implements UserProviderInterface
{
    public function __construct(
        private FindStudent $findStudent
    ) {}
    // ...
    public function loadUser(string $identifier): JwtUser
    {
        try {
            $student = ($this->findStudent)(new FindStudentRequest($identifier));
            return new JwtUser(
                $student->studentId(),
                $student->studentEmail(),
                $student->studentPassword()
            );
        } catch (StudentDoesNotExist $exception) {
            throw new UserNotFoundException($identifier);
        }
    }
//...

Personalizar la autenticación JWT: Login aplicación SaaS

  • Un enfoque con multitenant. Visto ()link)

Login y Observabilidad

Exprimiendo Monolog: Consigue mayor contexto con el menor ruido en logs

Observabilidad en sistemas distribuidos: Añadir correlation id a todos tus logs

  • Monolog processor

    use Monolog\Processor\ProcessorInterface;
    use Symfony\Component\HttpFoundation\RequestStack;
    
    final class CorrelationIdProcessor implements ProcessorInterface
    {
        public function __construct(private RequestStack $requestStack) {}
    
        public function __invoke(array $record): array
        {
            $request = $this->requestStack->getMainRequest();
            if (!$request->headers->has('X-CID')) {
                return $record;
            }
    
            $record['extra']['correlation_id'] = $request->headers->get('X-CID');
    
            return $record;
        }
    }
  • Symfony DIC

    services:
        _defaults:
            autowire: true
            autoconfigure: true
    
        App\Monolog\CorrelationIdProcessor:
    #        tags:
    #            - { name: monolog.processor }

Volcando logs desde Monolog hasta ELK

  • config/packages/dev/monolog.yaml

    monolog:
        handlers:
            elastic:
                type: service
                id: Symfony\Bridge\Monolog\Handler\ElasticsearchLogstashHandler
                level: debug
                channels: [ "!event" ]
  • docker-compose.yml

    services:
      kibana:
        image: docker.elastic.co/kibana/kibana:7.13.2
        ports:
          - 5601:5601
        depends_on:
          - elasticsearch
      elasticsearch:
        image: docker.elastic.co/elasticsearch/elasticsearch:7.13.2
        ports:
          - 9200:9200
          - 9300:9300
        environment:
          - discovery.type=single-node

Mantener Symfony al día

¿Qué versión de Symfony me conviene más?

Tips para actualizar Symfony

  • Rector

  • How to Install or Upgrade to the Latest, Unreleased Symfony Version

  • Guías de actualización: Patch version, Minor version, Major version

  • Deprecation warnings

    • Symfony Profiler

    • PHPUnit y PHPUnit Bridge (SYMFONY_DEPRECATIONS_HELPER) eg. SYMFONY_DEPRECATIONS_HELPER=max php bin/phpunit (ref)

    • Logs en var/log

    • Monolog y los channel php (>=6.3.9, pr) y deprecation

      monolog:
          channels: ['deprecation']
          handlers:
              deprecation:
                  type: stream
                  path: "%kernel.logs_dir%/deprecations.log"
                  channels: [deprecation]
  • Composer

    • Plugin composer-patches: permite aplicar parches a las dependencias instaladas (web)
      • Ejemplo de uso es cuando composer update falla por una librería no tiene soporte para la versión de Symfony o PHP que necesitamos
        • Hacemos PR a la librería para añadir soporte pero hasta que los mantenedores no lo integren, nuestro composer update seguirá fallando
        • Lo típico es referenciar en composer.json a nuestro fork mientras tanto. El inconveniente es tener que mantener el fork con su upstream
        • Usar este plugin como alternativa: composer seguirá descargando la librería original y aplicará nuestro parche/s
  • Actualizar return y argument types

  • Algunas referencias