$30 off During Our Annual Pro Sale. View Details »

Un service, kézako ?

Un service, kézako ?

Dans de nombreux frameworks PHP la notion de service s'est imposée. Nous avons tous dû déclarer de telles classes, et pourtant, la notion n'est pas évidente. Au jour le jour, on se demande ce que l'on met dedans, comment structure, comment diviser. Souvent, on cherche à les nommer et on ne trouve pas. Et parfois, on peste qu'ils sont difficiles à tester... Mais au fond, c'est quoi un service ? Est-ce qu'on ne bourrerait pas trop de choses dans cette notion ? Ou au contraire, est-ce qu'on n'oublierait pas l'essentiel ?

Ensemble, nous allons chercher et découvrir des réponses concrètes. Parfois via des patterns à redécouvrir, parfois via des best practices, et avec beaucoup de retours d'expériences bonnes ou mauvaises.

Conférence donnée à l'AFUP Day Lille 2021

Romaric Drigon

May 28, 2021
Tweet

More Decks by Romaric Drigon

Other Decks in Programming

Transcript

  1. « C’est un truc, qui fait quelque chose, plutôt de

    complexe » — RD, 2021 (probablement un lundi à 8h...)
  2. Une classe dans un container 1. Programmation Orientée-Object 2. Injection

    de dépendances (DI) : $translator = new Translator(); $m = new Mailer($translator); 3. Container aussi appelé Service container 4. Service ! => une classe PHP fabien.potencier.org/what-is-dependency-injection.html martinfowler.com/articles/injection.html
  3. À quoi cela sert ? 1. À ne pas se

    répéter $message = <<<EOT Merci pour votre inscription, veuillez svp... EOT; mail($user->getEmail(), 'Votre inscription', $message, [ 'From' => '[email protected]', 'Reply-To' => '[email protected]', 'Content-type' => 'text/plain; utf-8', ]); mail($user->getEmail(), 'Confirmation de commande', 'Merci pour votre commande..', [ 'From' => '[email protected]', 'Reply-To' => '[email protected]', 'Content-type' => 'text/plain; utf-8', ]); class Mailer { public function send(User $to, string $subject, string $message): bool { return mail($to->getEmail(), $subject, $message, [ 'From' => '[email protected]', 'Reply-To' => '[email protected]', 'Content-type' => 'text/plain; utf-8', ]); } } $mailer = new Mailer(); $mailer->send($user, 'Votre inscription', /*...*/); $mailer = new Mailer(); $mailer->send($user, 'Confirmation de commande', /*...*/);
  4. 2. À simplifier l'utilisation $boundary = uniqid('boundary'); $message = <<<EOT

    This is multipart message using MIME --$boundary Content-type: text/plain;charset=utf-8 Confirmation d'inscription Merci pour votre... --$boundary Content-type: text/html;charset=utf-8 <!DOCTYPE html> <html> <head></head> <body> <h1>Confirmation d'inscription</h1> <p>Merci pour votre...</p> </body> </html> --$boundary-- EOT; mail($user->getEmail(), "Confirmation d'inscription", $message, [ 'From' => '[email protected]', 'Reply-To' => '[email protected]', 'Content-type' => "Content-Type: " ."multipart/alternative;boundary=$boundary", 'MIME-Version' =>'1.0', ]); class Mailer { public function send(User $to, string $subject, string $txt, string $html): bool { $message = <<<EOT This is multipart message using MIME --$boundary Content-type: text/plain;charset=utf-8 $txt --$boundary Content-type: text/html;charset=utf-8 $html --$boundary-- EOT; return mail($to->getEmail(), $subject, $message, [ 'From' => '[email protected]', 'Reply-To' => '[email protected]', 'Content-type' => "Content-Type: multipart/alternative;...", 'MIME-Version' =>'1.0', ]); } } $mailer = new Mailer(); $mailer->send($user, 'Votre inscription', /*...*/);
  5. 3. À systématiser des règles class Mailer { public function

    send(User $to, string $subject, string $html): bool { if (!$user-getEmail() || !filter_var($user-getEmail(), FILTER_VALIDATE_EMAIL)) { throw new \RuntimeException('Adresse e-mail invalide'); } $txt = strip_tags($html); $message = <<<EOT This is multipart message using MIME --$boundary Content-type: text/plain;charset=utf-8 {strip_tags($html)} --$boundary Content-type: text/html;charset=utf-8 $html --$boundary-- EOT; return mail($to->getEmail(), $subject, $message, [ 'From' => '[email protected]', 'Reply-To' => '[email protected]', 'Content-type' => "Content-Type: multipart/...", 'MIME-Version' =>'1.0', ]); } }
  6. 4. À faciliter les tests class InscriptionService { public function

    __construct(private Mailer $mailer) {} public function register(string $email, string $password): bool { $user = new User(); // ... return $this->mailer->send($user, 'Votre inscription', /*...*/); } } class InscriptionServiceTest extends TestCase { public function test_it_sends_confirmation_email() { $mockMailer = $this->createMock(Mailer::class); $mockMailer->method('register')->willReturn(1); $sut = new InscriptionService($mockMailer); $this->assertTrue($sut->register('[email protected]', 'pa$$w0rd')); } }
  7. 5. À être appelé par le framework use Symfony\Component\EventDispatcher\EventSubscriberInterface; use

    Symfony\Component\HttpKernel\Event\KernelEvents; use Symfony\Component\HttpKernel\Event\RequestEvent; class MyHttpEventSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents() { return [ KernelEvents::REQUEST => 'onIncomingRequest', ]; } public function onIncomingRequest(RequestEvent $event) { // ... } }
  8. « C’est un objet, qui fait une opération complexe »

    — On retient donc quelques critères : On est dans l'action, pas dans les données Orchestre souvent des interactions entre di f férents sous composants Rend leur usage (ensemble) plus facile
  9. Pour un container de service, tout est service ! #

    github.com/symfony/demo, config/services.yaml services: App\: resource: '../src/' exclude: - '../src/DependencyInjection/' - '../src/Entity/' - '../src/Kernel.php' - '../src/Tests/' App\Controller\: resource: '../src/Controller/' tags: ['controller.service_arguments']
  10. Quelques exceptions — les points d'entrée — les classes qui

    contiennent les données — Entity/ dans notre exemple précédent — entités et value objects — éventuellement des classes "statiques" — Facade dans Laravel — exposant que des fonctions, par ex. Helper ou Utils => Str::random() dans Illuminate...
  11. Service ? On n'a qu'une seule instance, service singleton ?

    class Cart { public function add(Product $product) {} public function remove(Product $product) {} public function empty() {} public function getTotal(): int {} } Data object ? Un service retourne un data object namespace Symfony\Component\HttpFoundation; class RequestStack { private array $requests = []; public function getCurrentRequest(): ?Request { return end($this->requests) ?: null; } // ... }
  12. class UserAccountManager { public function __construct(Validator $v, Mailer $m, Database

    $db, PasswordHasher $hasher) { /* ... */ } public function create(string $email, string $password): Account { /* ... */ } public function confirm(string $confirmationToken); public function close(Account $account) {} public function changePassword(Account $account, string $newPassword) {} public function find(int $id): ?Account {} public function findByEmail(string $email): ?Account {} }
  13. class UserAccountManager { public function __construct(Validator $v, Mailer $m, Database

    $db, PasswordHasher $hasher) { /* ... */ } public function create(string $email, string $password): Account { /* ... */ } public function confirm(string $confirmationToken); public function close(Account $account) {} public function changePassword(Account $account, string $newPassword) {} public function find(int $id): ?Account {} public function findByEmail(string $email): ?Account {} }
  14. Problèmes ! Le service fait trop de choses ! —

    difficilement lisible (1000+ lignes...) — difficile à utiliser (nommage...) — trop de dépendances — tout est lié — difficilement testable (mocks...) Antipa!ern : god object
  15. Principe de responsabilité uniqueSRP « Une classe ne doit avoir

    qu'une seule responsabilité. » => « Une seule raison de changer » Une solution : « Diviser pour mieux régner », spécialisez SRP "Single responbility principle", Robert C. Martin, 2005
  16. Manager : un terme trop générique class UserAccountManager { public

    function create(string $email, string $password): Account { /* ... */ } // ==> AccountFactory public function changePassword(Account $account, string $newPassword) {} // ==> PasswordChanger public function find(int $id): ?Account {} public function findByEmail(string $email): ?Account {} // ==> AccountRepository } Ne me parlez plus de manager, sur le site de l'AFSY
  17. Manager : un terme trop générique class UserAccountManager { public

    function create(string $email, string $password): Account { /* ... */ } // ==> AccountFactory public function changePassword(Account $account, string $newPassword) {} // ==> PasswordChanger public function find(int $id): ?Account {} public function findByEmail(string $email): ?Account {} // ==> AccountRepository } Ne me parlez plus de manager, sur le site de l'AFSY
  18. Manager : un terme trop générique class UserAccountManager { public

    function create(string $email, string $password): Account { /* ... */ } // ==> AccountFactory public function changePassword(Account $account, string $newPassword) {} // ==> PasswordChanger public function find(int $id): ?Account {} public function findByEmail(string $email): ?Account {} // ==> AccountRepository } Ne me parlez plus de manager, sur le site de l'AFSY
  19. Spaghe!i ≯ ravioli ≯ lasagne class AccountFactory { public function

    create(string $email, string $password) {} } class NewAccountNotifier { public function notify(/* ... */) {} } class AccountConfirmedNotifier { public function notify(/* ... */) {} } class PasswordChanger { public function change() {} } class PasswordChangedNotifier { public function notify(/* ... */) {} } class Mailer { public function send() {} } class AccountRepository { public function find() {} public function findByEmail() {} } class AccountSaver { public function persist() {} } class Database { public function exec() {} }
  20. Quelques astuces Utiliser les frontières "naturelles" : — MVC :

    séparation données/ rendu — vocabulaire du framework — technique/métier — ... Limiter le nombre de dépendances (3-4 max) Ne pas prévoir à l'avance de la généricité, si le besoin n'est pas là. blog.codinghorror.com/rule-of-three/
  21. Ne pas dépendre du contexte class NewAccountNotifier { private Request

    $request; // Injecté par le framework public function send(Account $new, string $adminEmail): void { if ('fr' === $this->request->getLocale()) { $subject = "Un nouveau compte a été ouvert par $new->getEmail()"; } else { $subject = "A new account was opened by $new->getEmail()"; } mail($adminEmail, $subject, /*...*/); } } Dépendance de la requête HTTP => ☠ le jour du passage en asynchrome (contexte CLI)
  22. « A SERVICE is an operation offered as an interface

    that stands alone in the model, without encapsulating state »vFR — Eric Evans, Domain-Driven Design (p. 105) vFR « Un Service est une opération exposée par une interface, interface qui fait partie du Modèle, et qui ne contient pas d'état. »
  23. Un exemple "classique" class TransferProcessor { public function transfer(Account $source,

    Account $target, int $amount) { if ($source->getBalance() <= $amount) { throw new \DomainException(); } if ($source->getCurrency() !== $target->getCurrency()) { // ... } // ... } }
  24. Distinction service métier1 / service d'infrastructure Inscription à l'AFUP Day

    2021 : - gérer le formulaire (lire la requête HTTP...) => infrastructure - valider les données (pas inscrit en double...) => métier - créer une inscription => métier - sauvegarder l'inscription en base de donnée => infrastructure - composer un email de confirmation, un reçu => métier ? 1 Domain service en VO
  25. Pas d'état / pas de données Opinion : un service

    métier ne doit pas avoir d'état, de données propres. => les données sont les joyaux de notre application ! Rend le service plus simple à utiliser : $manager = new StatefulBankAccountManager(); $manager->setSource($source); $manager->setTarget($target); $manager->setSource($source); // erreur ? $manager->transfer(1000); $manager = new StatelessBankAccountManager(); $manager->transfer($source, $target, 1000);
  26. Nommer ses services Un service doit donc représenter "une chose

    que l'on fait", une seule responsabilité. => le nommer selon l'acteur correspondant Ex. "Mailer", "PaymentProcessor", "IssueMaker"... Dans certains cas, les designs patterns peuvent nous aiguiller sur comment exprimer cela dans notre code.
  27. Pour la création : Factory class UserFactory { public function

    create(string $email, string $password): User { if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !$password) { throw new \RuntimeException(); } $hashed = password_hash($password); $user = new User(); $user->setEmail($email); $user->setPassword($hashed); return $user; } }
  28. Pour la création2 : Builder namespace Symfony\Component\Form; interface FormBuilderInterface {

    public function add($child, string $type = null, array $options = []): self; public function setData($data): self; // ... // Pour récupérer le résultat public function getForm(): Form; } // Depuis un controller $form = $this->createFormBuilder() ->add('name', TextType::class) ->add('image', ImageType::class) ->add('createdAt', DateType::class) ->setData($blog) ->getForm();
  29. Pour le requêtage : Repository interface ArticleRepository { public function

    find(int $id): ?Article; public function findAll(): array; public function findOnlineByBlog(Blog $blog): array; // Dans la définition de base (P of EAA, Fowler), // le repo est aussi utilisé pour la suppression. public function remove(Article $article); }
  30. Décoration ⭐ interface MailerInterface { public function send($to, $subject, $message);

    } class BasicMailer implements MailerInterface { /* ... */ } class LoggableMailer implements MailerInterface { public function __construct(private MailerInterface $mailer, private LoggerInterface $logger) {} public function send($to, $subject, $message) { $this->logger->info("Envoi d'un e-mail !"); $this->mailer->send($to, $subject, $message); } }
  31. Évènements ⭐⭐ class AccountController { public function register(EventDispatcher $dispatcher): Response

    { $dispatcher->dispatch(new NewAccountEvent($account)); // ... } } class NewAccountListener implements EventSubscriberInterface { public function __construct(private MailerInterface $mailer) {} public function onNewAccount(NewAccountEvent $e) { $this->mailer->send($e->getEmail(), 'Bienvenue', /*...*/); } public function getSubscribedEvents() {} // ... } ⚠ Garder les listeners/subscribers découpés des services (métier)
  32. Récapitulatif Un service : — une classe PHP — une

    seule responsabilité — séparer les services des données — séparer du contexte — séparer de l'infrastructure — si flou, ne pas trop diviser dès le début — les design patterns peuvent aider