$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. Un service,
    kézako ?
    Romaric Drigon @ AFUP Day 2021 Lille, 28/05/2021

    View Slide

  2. Romaric Drigon, @romaricdrigon
    Responsable des développements à l'ASIT

    View Slide

  3. Définition

    View Slide

  4. « C’est un truc, qui fait quelque
    chose, plutôt de complexe »
    — RD, 2021 (probablement un lundi à 8h...)

    View Slide

  5. Un web service ?
    Source: Borja Sotomayor, 2004-2005

    View Slide

  6. 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

    View Slide

  7. À quoi cela sert ?
    1. À ne pas se répéter
    $message = <<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', /*...*/);

    View Slide

  8. 2. À simplifier l'utilisation
    $boundary = uniqid('boundary');
    $message = <<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




    Confirmation d'inscription
    Merci pour votre...


    --$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 = <<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', /*...*/);

    View Slide

  9. 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 = <<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',
    ]);
    }
    }

    View Slide

  10. 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'));
    }
    }

    View Slide

  11. 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)
    {
    // ...
    }
    }

    View Slide

  12. « 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

    View Slide

  13. Ce qu'un service
    n'est pas

    View Slide

  14. 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']

    View Slide

  15. 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...

    View Slide

  16. 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;
    }
    // ...
    }

    View Slide

  17. Un (mauvais)
    exemple

    View Slide

  18. 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 {}
    }

    View Slide

  19. 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 {}
    }

    View Slide

  20. View Slide

  21. 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

    View Slide

  22. 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

    View Slide

  23. 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

    View Slide

  24. 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

    View Slide

  25. 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

    View Slide

  26. 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() {}
    }

    View Slide

  27. 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/

    View Slide

  28. 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)

    View Slide

  29. Un cas spécifique :
    service métier

    View Slide

  30. « 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. »

    View Slide

  31. 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()) {
    // ...
    }
    // ...
    }
    }

    View Slide

  32. 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

    View Slide

  33. 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);

    View Slide

  34. Implémentation

    View Slide

  35. 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.

    View Slide

  36. 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;
    }
    }

    View Slide

  37. 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();

    View Slide

  38. 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);
    }

    View Slide

  39. Comment
    découper
    structurer
    organiser
    ses services

    View Slide

  40. 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);
    }
    }

    View Slide

  41. É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)

    View Slide

  42. Workflow
    ⭐⭐⭐
    Image issue de la documentation symfony/workflow

    View Slide

  43. Bus de commande
    ⭐⭐⭐⭐
    speakerdeck.com/romaricdrigon/eviter-un-controleur-de-1000-lignes-vite-un-bus-de-commande
    Implémentation avec tactician.thephpleague.com

    View Slide

  44. 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

    View Slide

  45. Questions ?
    @romaricdrigon / romaricdrigon.github.io

    View Slide