Introducción
El otro día estaba con un colega repasando un proyecto en el que iba a empezar a colaborar conmigo cuando empezamos a mirar una serie de clases que “olían” un poco raro: todas ellas tenían un fragmento de código que era literalmente igual en todas ellas.
Esta repetición de código no era casual: cada una de estas clases ejecutaba una validación de un array de entrada en base a un criterio, para ello usaba una librería de terceros que ayudaba a definir estos criterios y a detectar los errores. Luego trataba los errores de la validación para formar una excepción con todos ellos o bien devolvía true indicando que no había habido ninguna violación del criterio de validación.
Tenían un aspecto muy similar a este:
<?php declare(strict_types=1); | |
namespace App\RequestValidator; | |
use App\Exception\Http\InvalidRequestException; | |
use Symfony\Component\Validator\Constraints as Assert; | |
use Symfony\Component\Validator\Validation; | |
final class RegisterValidator | |
{ | |
use ViolationToError; | |
const PASSWORD_MIN_LENGTH = 6; | |
public function validate(array $data): bool | |
{ | |
$constraints = new Assert\Collection([ | |
'fields' => [ | |
'id' => new Assert\NotNull([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
'username' => [ | |
new Assert\Type([ | |
'type' => 'string', | |
'message' => ValidatorMessages::MUST_BE_STRING | |
]), | |
new Assert\NotNull([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
new Assert\NotBlank([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
], | |
'email' => new Assert\Email([ | |
'message' => ValidatorMessages::EMAIL_SHOULD_BE_VALID, | |
'checkMX' => true, | |
]), | |
'password' => [ | |
new Assert\NotBlank([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
new Assert\Length([ | |
'min' => self::PASSWORD_MIN_LENGTH, | |
'minMessage' => ValidatorMessages::TOO_SHORT, | |
]), | |
new Assert\Regex([ | |
'pattern' => '/^(?:[0-9]+[a-z]|[a-z]+[0-9])[a-z0-9]{5,}$/i', | |
'message' => ValidatorMessages::PASSWORD_SHOULD_BE_VALID, | |
]), | |
], | |
'referredBy' => new Assert\Optional( | |
new Assert\Type([ | |
'type' => 'string', | |
'message' => ValidatorMessages::MUST_BE_STRING, | |
]) | |
) | |
], | |
'missingFieldsMessage' => ValidatorMessages::FIELD_IS_MISSING, | |
'allowExtraFields' => false | |
]); | |
$validator = Validation::createValidator(); | |
$violations = $validator->validate($data, $constraints); | |
if (0 === $violations->count()) { | |
return true; | |
} | |
$errors = []; | |
foreach ($violations as $violation) { | |
$error = $this->getErrorFromViolation($violation); | |
$errors[] = $error; | |
} | |
throw InvalidRequestException::createFromArray($errors); | |
} | |
} |
<?php declare(strict_types=1); | |
namespace App\RequestValidator; | |
use Symfony\Component\Validator\ConstraintViolation; | |
trait ViolationToError | |
{ | |
private function getErrorFromViolation(ConstraintViolation $violation) | |
{ | |
$path = $violation->getPropertyPath(); | |
$path = substr($path, (strpos($path, '[') + 1), (strpos($path, ']') - 1)); | |
$message = $violation->getMessage(); | |
if ("This value should be of type iterable." === $message) { | |
$message = ValidatorMessages::MUST_BE_ARRAY; | |
} | |
return [ | |
'field' => $path, | |
'message' => $message, | |
]; | |
} | |
} |
Básicamente todo el código desde la línea 62 hasta el final era código repetido en cada clase, pero al menos había tenido la decencia de usar un trait para reaprovechar el código que convertía las ConstraintViolation en mi formato de error.
Cuando mi colega vio esto me recordó el patrón Template Method y lo fácil y limpio que sería hacer un refactor, así que nos pusimos manos a la obra!
Template Method
El patrón Template Method es un patrón comportamental, esto quiere decir que es un patrón que nos ayuda a definir la forma en la que los objetos interactúan entre ellos..
Concretamente, este patrón define el esqueleto de la ejecución de un algoritmo, delegando la definición de uno o más pasos a distintas subclases, de tal forma que podemos tener tantas variaciones del mismo algoritmo como necesitemos, simplemente escribiendo las nuevas clases que redefinen esos pasos.
Veamos un ejemplo con un sistema de pago que permite pagar con Paypal y con Stripe:
<?php declare(strict_types=1); | |
abstract class PaymentService | |
{ | |
private $invoiceRepository; | |
private $mailer; | |
public function __construct(InvoiceRepository $invoiceRepository, Mailer $mailer) | |
{ | |
$this->invoiceRepository = $invoiceRepository; | |
$this->mailer = $mailer; | |
} | |
public function payInvoice(Invoice $invoice) | |
{ | |
$amount = $invoice->getAmount(); | |
$invoiceNumber = $invoice->getNumber(); | |
$this->chargeAmount($amount, $invoiceNumber); | |
$this->markInvoiceAsPaid($invoice); | |
$this->notifyUser( | |
$invoice->getUser()->getEmail(), | |
$invoice->getUser()->getName(), | |
$invoiceNumber, | |
$amount | |
); | |
} | |
abstract private function chargeAmount(float $amount, string $invoiceNumber); | |
private function markInvoiceAsPaid(Invoice $invoice) | |
{ | |
$invoice->setStatus(Invoice::PAID); | |
$this->invoiceRepository->save($invoice); | |
} | |
private function notifyUser(string $userEmail, string $userName, string $invoiceNumber, float $amount) | |
{ | |
$data = [ | |
'username' => $userName, | |
'invoice_number' => $invoiceNumber, | |
'amount' => $amount | |
]; | |
$this->mailer->sendInvoicePaidNotification($userEmail, $data); | |
} | |
} |
<?php declare(strict_types=1); | |
class PaypalPayment extends PaymentService | |
{ | |
private $paypal; | |
public function __construct(PaypalClient $paypal, InvoiceRepository $invoiceRepository, Mailer $mailer) | |
{ | |
$this->paypal = $paypal; | |
parent::__construct($invoiceRepository, $mailer); | |
} | |
public function chargeAmount(float $amount, string $invoiceNumber) | |
{ | |
$this->paypal->charge($amount, 'Invoice #' . $invoiceNumber); | |
} | |
} |
<?php declare(strict_types=1); | |
class StripePayment extends PaymentService | |
{ | |
private $stripe; | |
public function __construct(StripeClient $stripe, InvoiceRepository $invoiceRepository, Mailer $mailer) | |
{ | |
$this->stripe = $stripe; | |
parent::__construct($invoiceRepository, $mailer); | |
} | |
public function chargeAmount(float $amount, string $invoiceNumber) | |
{ | |
$this->stripe->charge($amount, 'Invoice #' . $invoiceNumber); | |
} | |
} |
En este caso, PaymentService es una clase abstracta, conoce todo el algoritmo de pago, excepto un caso que puede ser diferente en cada servicio: la forma de realizar el pago.
De esta forma, hemos dejado a implementar el pago a cada uno de los servicios, dejando el código restante en la clase abstracta (marcar factura como pagada y notificar al usuario del pago).
Refactoring de la clase del validador
Dicho esto, el refactor de nuestro validador era obvio: el único elemento cambiante eran las reglas de validación, así que extraeríamos el resto a una clase abstracta:
<?php declare(strict_types=1); | |
namespace App\RequestValidator; | |
use App\Exception\Http\InvalidRequestException; | |
use Symfony\Component\Validator\ConstraintViolation; | |
use Symfony\Component\Validator\Validation; | |
abstract class AbstractValidator | |
{ | |
abstract protected function getConstraints(); | |
public function validate(array $data): bool | |
{ | |
$validator = Validation::createValidator(); | |
$violations = $validator->validate($data, $this->getConstraints()); | |
if (0 === $violations->count()) { | |
return true; | |
} | |
$errors = []; | |
foreach ($violations as $violation) { | |
$error = $this->getErrorFromViolation($violation); | |
$errors[] = $error; | |
} | |
throw InvalidRequestException::createFromArray($errors); | |
} | |
private function getErrorFromViolation(ConstraintViolation $violation) | |
{ | |
$path = $violation->getPropertyPath(); | |
$path = substr($path, (strpos($path, '[') + 1), (strpos($path, ']') - 1)); | |
$message = $violation->getMessage(); | |
if ("This value should be of type iterable." === $message) { | |
$message = ValidatorMessages::MUST_BE_ARRAY; | |
} | |
return [ | |
'field' => $path, | |
'message' => $message, | |
]; | |
} | |
} |
<?php declare(strict_types=1); | |
namespace App\RequestValidator; | |
use Symfony\Component\Validator\Constraints as Assert; | |
final class RegisterUserValidator extends AbstractValidator | |
{ | |
const PASSWORD_MIN_LENGTH = 6; | |
protected function getConstraints() | |
{ | |
$constraints = new Assert\Collection([ | |
'fields' => [ | |
'id' => new Assert\NotNull([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
'username' => [ | |
new Assert\Type([ | |
'type' => 'string', | |
'message' => ValidatorMessages::MUST_BE_STRING | |
]), | |
new Assert\NotNull([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
new Assert\NotBlank([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
], | |
'email' => new Assert\Email([ | |
'message' => ValidatorMessages::EMAIL_SHOULD_BE_VALID, | |
'checkMX' => true, | |
]), | |
'password' => [ | |
new Assert\NotBlank([ | |
'message' => ValidatorMessages::IS_REQUIRED | |
]), | |
new Assert\Length([ | |
'min' => self::PASSWORD_MIN_LENGTH, | |
'minMessage' => ValidatorMessages::TOO_SHORT, | |
]), | |
new Assert\Regex([ | |
'pattern' => '/^(?:[0-9]+[a-z]|[a-z]+[0-9])[a-z0-9]{5,}$/i', | |
'message' => ValidatorMessages::PASSWORD_SHOULD_BE_VALID, | |
]), | |
], | |
'referredBy' => new Assert\Optional( | |
new Assert\Type([ | |
'type' => 'string', | |
'message' => ValidatorMessages::MUST_BE_STRING, | |
]) | |
) | |
], | |
'missingFieldsMessage' => ValidatorMessages::FIELD_IS_MISSING, | |
'allowExtraFields' => false | |
]); | |
return $constraints; | |
} | |
} |
Incluso hemos podido prescindir el trait e integrarlo en la clase abstracta! Ahora todas nuestras clases de validación únicamente requieren implementar las reglas de validación, del resto se encarga la clase abstracta.
Top comments (0)