web-dev-qa-db-fra.com

Entités de doctrine et logique métier dans une application Symfony

Toutes les idées/commentaires sont les bienvenus :) 

Je rencontre un problème dans la manière de gérer la logique métier autour de mes entités Doctrine2 dans une grosse application Symfony2. (Désolé pour la longueur du post)

Après avoir lu de nombreux blogs, livres de cuisine et autres ressources, je constate que:

  • Les entités peuvent être utilisées uniquement pour la persistance de la cartographie des données ("modèle anémique"),
  • Les contrôleurs doivent être le plus mince possible,
  • Les modèles de domaine doivent être découplés de la couche de persistance (l'entité ignore le gestionnaire d'entités).

Ok, je suis tout à fait d’accord avec ça, mais: où et comment gérer les règles de gestion complexes des modèles de domaine?


Un exemple simple

NOS MODELES DE DOMAINE:

  • un groupe peut utiliser rôles
  • un rôle peut être utilisé par différents groupes
  • un utilisateur peut appartenir à plusieurs groupes avec plusieurs rôles,

Dans une couche de persistance SQL, nous pourrions modéliser ces relations comme suit:

enter image description here

NOS REGLES D'AFFAIRES SPECIFIQUES:

  • L'utilisateur peut avoir Roles dans Groups _ ​​uniquement si Roles est associé au groupe.
  • Si nous séparons un rôle R1 d'un groupe G1, tous les UserRoleAffectation avec le groupe G1 et le rôle R1 doivent être supprimés}

Ceci est un exemple très simple, mais j'aimerais connaître le (s) meilleur (s) moyen (s) de gérer ces règles de gestion.


Solutions trouvées

1- Mise en oeuvre dans la couche de service

Utilisez une classe de service spécifique en tant que:

class GroupRoleAffectionService {

  function linkRoleToGroup ($role, $group)
  { 
    //... 
  }

  function unlinkRoleToGroup ($role, $group)
  {
    //business logic to find all invalid UserRoleAffectation with these role and group
    ...

    // BL to remove all found UserRoleAffectation OR to throw exception.
    ...

    // detach role  
    $group->removeRole($role)

    //save all handled entities;
    $em->flush();   
}
  • (+) un service par classe/par règle de gestion
  • (-) Les entités API ne représentent pas le domaine: il est possible d'appeler $group->removeRole($role) à partir de ce service.
  • (-) Trop de classes de service dans une grosse application?

2 - Implémentation dans les gestionnaires d'entités de domaine

Encapsulez ces Business Logic dans un "gestionnaire d'entités de domaine" spécifique, appelez également les fournisseurs de modèles:

class GroupManager {

    function create($name){...}

    function remove($group) {...}

    function store($group){...}

    // ...

    function linkRole($group, $role) {...}

    function unlinkRoleToGroup ($group, $role)
    {

    // ... (as in previous service code)
    }

    function otherBusinessRule($params) {...}
}
  • (+) toutes les règles métier sont centralisées
  • (-) Les entités API ne représentent pas le domaine: il est possible d'appeler $ group-> removeRole ($ role) du service ...
  • (-) Les gestionnaires de domaine deviennent des gestionnaires de FAT?

3 - Utilisez des écouteurs si possible

Utilisez les écouteurs d'événements Symfony et/ou Doctrine:

class CheckUserRoleAffectationEventSubscriber implements EventSubscriber
{
    // listen when a M2M relation between Group and Role is removed
    public function getSubscribedEvents()
    {
        return array(
            'preRemove'
        );
    }

   public function preRemove(LifecycleEventArgs $event)
   {
    // BL here ...
   }

4 - Implémentation de modèles enrichis en étendant des entités

Utilisez les entités en tant que classe sous/parent des classes de modèles de domaine, qui encapsulent beaucoup de logique de domaine. Mais cette solution semble plus confuse pour moi.


Pour vous, quel (s) meilleur (s) moyen (s) de gérer cette logique métier en privilégiant le code plus propre, découplé et testable? Vos commentaires et bonnes pratiques? Avez-vous des exemples concrets?

Ressources principales:

53
Koryonik

Je trouve la solution 1) comme la solution la plus facile à maintenir dans une perspective plus longue. Solution 2 mène la classe "Manager" gonflée qui sera éventuellement divisée en fragments plus petits. 

http://c2.com/cgi/wiki?DontNameClassesObjectManagerHandlerOrData

"Trop de classes de services dans une grosse application" n'est pas une raison pour éviter SRP. 

En termes de langage de domaine, le code suivant est similaire: 

$groupRoleService->removeRoleFromGroup($role, $group);

et

$group->removeRole($role);

De plus, d'après ce que vous avez décrit, la suppression/l'ajout d'un rôle à un groupe nécessite de nombreuses dépendances (principe d'inversion de dépendance), ce qui peut s'avérer difficile avec un gestionnaire FAT/ballonné. 

La solution 3) ressemble beaucoup à 1) - chaque abonné reçoit un service automatiquement déclenché en arrière-plan par Entity Manager et peut fonctionner dans des scénarios plus simples. par exemple. quel utilisateur a effectué l'action, à partir de quelle page ou de tout autre type de validation complexe. 

3
Tomas Dermisek

Voir ici: Sf2: utiliser un service dans une entité

Peut-être que ma réponse ici aide. Cela répond simplement à ceci: comment "découpler" le modèle, la persistance et les couches de contrôleur.

Dans votre question spécifique, je dirais qu'il y a un "truc" ici ... qu'est-ce qu'un "groupe"? C'est "seul"? ou ça quand ça concerne quelqu'un?

Initialement, vos classes Model pourraient probablement ressembler à ceci:

UserManager (service, entry point for all others)

Users
User
Groups
Group
Roles
Role

UserManager aurait des méthodes pour obtenir les objets du modèle (comme indiqué dans cette réponse, vous ne devriez jamais faire une new). Dans un contrôleur, vous pouvez faire ceci:

$userManager = $this->get( 'myproject.user.manager' );
$user = $userManager->getUserById( 33 );
$user->whatever();

Alors ... User, comme vous dites, peut avoir des rôles, qui peuvent être attribués ou non.

// Using metalanguage similar to C++ to show return datatypes.
User
{
    // Role managing
    Roles getAllRolesTheUserHasInAnyGroup();
    void  addRoleById( Id $roleId, Id $groupId );
    void  removeRoleById( Id $roleId );

    // Group managing
    Groups getGroups();
    void   addGroupById( Id $groupId );
    void   removeGroupById( Id $groupId );
}

J'ai simplifié, bien sûr, vous pouvez ajouter par ID, ajouter par objet, etc.

Mais quand vous pensez cela en "langage naturel" ... voyons ...

  1. Je sais qu'Alice appartient à un photographe.
  2. Je reçois l'objet Alice.
  3. J'interroge Alice sur les groupes. Je récupère le groupe de photographes.
  4. J'interroge les photographes sur les rôles.

Voir plus en détail:

  1. Je sais que Alice est l’utilisateur id = 33 et qu’elle fait partie du groupe des photographes.
  2. Je demande à Alice auprès de UserManager via $user = $manager->getUserById( 33 );
  3. J'accède au groupe Photographers par Alice, peut-être avec `$ group = $ user-> getGroupByName ('Photographers');
  4. J'aimerais ensuite voir les rôles du groupe ... Que dois-je faire?
    • Option 1: $ group-> getRoles ();
    • Option 2: $ group-> getRolesForUser ($ userId);

La seconde est comme redondante, alors que le groupe passait par Alice. Vous pouvez créer une nouvelle classe GroupSpecificToUser qui hérite de Group.

Similaire à un jeu ... qu'est-ce qu'un jeu? Le "jeu" comme "les échecs" en général? Ou le "jeu" spécifique d '"échecs" que vous et moi avons commencé hier?

Dans ce cas, $user->getGroups() renverrait une collection d'objets GroupSpecificToUser.

GroupSpecificToUser extends Group
{
    User getPointOfViewUser()
    Roles getRoles()
}

Cette seconde approche vous permettra d’encapsuler de nombreuses autres choses qui apparaîtront tôt ou tard: Cet utilisateur est-il autorisé à faire quelque chose ici? vous pouvez simplement interroger la sous-classe du groupe: $group->allowedToPost();, $group->allowedToChangeName();, $group->allowedToUploadImage();, etc.

Dans tous les cas, vous pouvez éviter de créer cette classe bizarre et demander à l'utilisateur des informations sur cette information, comme une approche $user->getRolesForGroup( $groupId );.

Le modèle n'est pas une couche de persistance

J'aime "oublier" la peristance lors de la conception. Je m'assieds habituellement avec mon équipe (ou avec moi-même, pour des projets personnels) et je passe 4 à 6 heures à réfléchir avant d'écrire une ligne de code. Nous écrivons une API dans un doc txt. Puis répétez-le en ajoutant, en supprimant des méthodes, etc.

Une possible API de "point de départ" de votre exemple pourrait contenir des requêtes telles que des triangles:

User
    getId()
    getName()
    getAllGroups()                     // Returns all the groups to which the user belongs.
    getAllRoles()                      // Returns the list of roles the user has in any possible group.
    getRolesOfACertainGroup( $group )  // Returns the list of groups for which the user has that specific role.
    getGroupsOfRole( $role )           // Returns all the roles the user has in a specific group.
    addRoleToGroup( $group, $role )
    removeRoleFromGroup( $group, $role )
    removeFromGroup()                  // Probably you want to remove the user from a group without having to loop over all the roles.
    // removeRole() ??                 // Maybe you want (or not) remove all admin privileges to this user, no care of what groups.

Group
    getId()
    getName()
    getAllUsers()
    getAllRoles()
    getAllUsersWithRole( $role )
    getAllRolesOfUser( $user )
    addUserWithRole( $user, $role )
    removeUserWithRole( $user, $role )
    removeUser( $user )                 // Probably you want to be able to remove a user completely instead of doing it role by role.
    // removeRole( $role ) ??           // Probably you don't want to be able to remove all the roles at a time (say, remove all admins, and leave the group without any admin)

Roles
    getId()
    getName()
    getAllUsers()                  // All users that have this role in one or another group.
    getAllGroups()                 // All groups for which any user has this role.
    getAllUsersForGroup( $group )  // All users that have this role in the given group.
    getAllGroupsForUser( $user )   // All groups for which the given user is granted that role
    // Querying redundantly is natural, but maybe "adding this user to this group"
    // from the role object is a bit weird, and we already have the add group
    // to the user and its redundant add user to group.
    // Adding it to here maybe is too much.

Événements

Comme indiqué dans l'article pointu, je lancerais également des événements dans le modèle,

Par exemple, lors de la suppression d'un rôle d'un utilisateur d'un groupe, je pouvais détecter dans un "écouteur" que s'il s'agissait du dernier administrateur, je pouvais a) annuler la suppression du rôle, b) l'autoriser et quitter le groupe sans administrateur, c) autorisez-le, mais choisissez un nouvel administrateur parmi les utilisateurs du groupe, etc. ou quelle que soit la stratégie qui vous convient.

De la même manière, un utilisateur ne peut appartenir qu'à 50 groupes (comme dans LinkedIn). Vous pouvez ensuite simplement lancer un événement preAddUserToGroup et tout capteur pourrait contenir le jeu de règles consistant à interdire que lorsque l'utilisateur souhaite rejoindre le groupe 51.

Cette "règle" peut clairement quitter les classes d'utilisateurs, de groupes et de rôles et rester dans une classe de niveau supérieur contenant les "règles" permettant aux utilisateurs de rejoindre ou de quitter des groupes.

Je suggère fortement de voir l'autre réponse.

J'espère aider!

Xavi.

5
Xavi Montero

En tant que préférence personnelle, j'aime bien commencer simple et évoluer au fur et à mesure que davantage de règles métier sont appliquées. En tant que tel, j'ai tendance à préférer les auditeurs approchent mieux}. 

Vous venez 

  • ajouter plus d'auditeurs à mesure que les règles métier évoluent
  • ayant chacun une responsabilité unique _, 
  • et vous pouvez tester ces écouteurs indépendamment plus facilement. 

Quelque chose qui nécessiterait beaucoup de simulacres/bouts si vous avez une seule classe de service telle que:

class SomeService 
{
    function someMethod($argA, $argB)
    {
        // some logic A.
        ... 
        // some logic B.
        ...

        // feature you want to test.
        ...

        // some logic C.
        ...
    }
}
2
jorrel

Je suis en faveur de conscient des affaires entités. La doctrine fait beaucoup pour ne pas polluer votre modèle par des problèmes d’infrastructure; il utilise la réflexion pour que vous soyez libre de modifier les accesseurs à votre guise . Les 2 choses "Doctrine" qui peuvent rester dans vos classes d'entité sont des annotations (que vous pouvez éviter grâce au mappage YML), et la ArrayCollection. Ceci est une bibliothèque en dehors de Doctrine ORM (̀Doctrine/Common), donc aucun problème ici.

Donc, en s’appuyant sur les bases de DDD, les entités sont vraiment le lieu idéal pour mettre votre logique de domaine. Bien sûr, parfois cela ne suffit pas, alors vous êtes libre d’ajouter services de domaine , services sans souci d’infrastructure.

Doctrine référentiels sont plus intermédiaires: je préfère garder ceux-ci comme le seul moyen d'interroger des entités, même s'ils ne respectent pas le modèle de référentiel initial et je préfère supprimer les méthodes générées. L'ajout de manager service pour encapsuler toutes les opérations d'extraction/de sauvegarde d'une classe donnée était une pratique courante de Symfony il y a quelques années, je n'aime pas trop.

D'après mon expérience, le composant de formulaire Symfony risque de poser bien plus de problèmes que je ne sais pas si vous l'utilisez. Ils vont limiter sérieusement votre capacité à personnaliser le constructeur, alors vous pouvez utiliser plutôt des constructeurs nommés. Ajouter la balise PhpDoc @deprecated̀ donnera à vos paires un retour visuel, elles ne devraient pas poursuivre le constructeur original.

Dernier point, mais non le moindre, compter trop sur les événements de Doctrine finira par vous piquer. Il y a trop de limitations techniques là-bas, et je trouve cela difficile à suivre. Si nécessaire, j'ajoute domain events dispatch du contrôleur/commande au répartiteur d'événements Symfony.

0
romaricdrigon

J'envisagerais d'utiliser une couche de service en dehors des entités elles-mêmes. Les classes d'entités doivent décrire les structures de données et éventuellement d'autres calculs simples. Les règles complexes vont aux services.

Tant que vous utilisez des services, vous pouvez créer davantage de systèmes, de services, etc., découplés. Vous pouvez tirer parti de l'injection de dépendance et utiliser des événements (répartiteurs et auditeurs) pour établir la communication entre les services en les maintenant faiblement couplés.

Je le dis sur la base de ma propre expérience. Au début, je mettais toute la logique dans les classes d'entités (spécialement lorsque j'ai développé les applications symfony 1.x/doctrine 1.x). Tant que les applications grandissaient, elles devenaient très difficiles à maintenir. 

0
Omar Alves