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
, ...
- 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
- Notas de arquitectura
- Uso de patrón Front Controller para manejar todas las peticiones partiendo del mismo punto
- La página del curso tiene un resumen del flujo por escrito
- Componentes principales: HttpFoundation, HttpKernel, EventDispatcher
- 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)
- 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
- 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
)
- 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
_instanceof
del contenedor para tanguear servicios delegando en Symfony sin tener que hacerlo manualmente. Autoconfiguring tags y Reference tagged services
-
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
- Ejemplo de enviar un email tras devolver la respuesta
- Hacer uso del
kernel.terminate
para todo lo que no sea necesario procesar para darle la respuesta al usuario
-
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'); } }
-
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') ) ); } // ...
-
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(); } // ...
-
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); } // ...
-
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
-
Una opción es reaccionando de nuevo a
kernel.request
public function onKernelRequest(RequestEvent $event) { // ... $response->setStatusCode(Response::HTTP_SERVICE_UNAVAILABLE) $event->setResponse($response); }
- 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
- Symfony\Component\HttpFoundation\StreamedResponse
- StreamedResponse allows to serve small chunks of data to the client
$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
- Componente Symfony VarDumper como alternativa a
var_dump
- Debug y profiling con el componente Symfony Web Profiler: consta principalmente del Symfony Profiler que recopila toda la información de la ejecución y el Web Debug Toolbar que es el UI con el que se puede acceder y visualizar toda esa información
- Es extensible y los bundles pueden añadir sus propias plantillas a la web toolbar
- La API pública del componente permite acceder a toda la información del profiler mediante código usando el servicio
@profiler
- Mitigating Risks: Securing Symfony Profiler from Unwanted Exposure
- PHPUnit Bridge: Trigger Deprecation Notices
-
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
-
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 testprotected 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 inicialprotected 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
- Symfony Testing documentation
- Componente PHPUnit Bridge
-
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
- Testear peticiones HTTP
- O un caso que se ejecutan como reacción a un evento que ha ocurrido en nuestro sistema
-
Contextos de tests E2E con un propósito
- 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
- 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
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);
}
}
//...
- Un enfoque con multitenant. Visto ()link)
- Symfony HTTP Kernel Logger, opción simplificada para casos muy pequeños
- Monolog Bundle
-
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 }
-
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
-
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) ydeprecation
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
- Hacemos PR a la librería para añadir soporte pero hasta que los mantenedores no lo integren, nuestro
- Ejemplo de uso es cuando
- Plugin composer-patches: permite aplicar parches a las dependencias instaladas (web)
-
Actualizar return y argument types
- A partir de la versión 6.0 Symfony ha introducido muchos tipos de retorno y de argumentos que faltaban en su código. Hasta ahora se seguía manteniendo sin tipos para mantener la retrocompatibilidad.
- Symfony 6: PHP 8 Native Types & Why we Need You
- Symfony 7.0 Type Declarations
- Referencias extra: 1,
-
Algunas referencias