MVC Design Pattern: les responsabilités
#1
:arrow: 
Salut!

J'allais poster sur Discord un message un peu long suite à une conversation avec Sephi-Chan mais parceque Arnaud me tanne pour que je poste sur le forum pour faire profiter le reste de la communauté je rédige le post ici en reprenant le sujet de notre désaccord.

J'ai une action, qui doit modifier deux modèles, j'étais sur le point de coder un contrôleur qui demanderait à ces deux modèles d'exécuter des actions de modifications. C'est à ce moment que Sephi s'est emporté, nous n'avions pas la même vision du design pattern MVC.

Bien que je ne remets pas en cause les propos du maître Jedi qu'il est, j'ai du mal à me faire à sa vision dans laquelle il exécuterai une méthode sur un seul des deux modèles, qui se chargerais d'appeler le second.

L'exemple en question pour être explicite est un bâtiment qui, sur action de l'utilisateur détruirait certaines des unités de la cité pour en contrepartie gagné des ressources de nourriture. Les deux actions en question étant:
  • supprimer N unités
  • augmenter la nourriture
Ma logique aurais était un contrôleur Bâtiment qui exécuterai un appel à deux fonctions des modèles Unitiy>kill() Et City>increaseFoodForSacrifice().

Je comprends le point de vue de Sephi: ces deux méthodes appelées depuis un contrôleur ressemble à de la logique lié directement aux données et qui n'as donc pas lieu d'être ici mais j'ai du mal à me résoudre à modifier un modèle à partir d'un modèle "cousin".

Du coup m'es venu à l'esprit de créer un modèle propre à ce contrôleur Bâtiment qui aurait en propriété les modèles City et Unity et à qui reviendrait cette charge d'appeler les méthodes des modèles Unity et City:  Unitiy>kill() Et City>increaseFoodForSacrifice()

Mon contrôleur  ressemblerait donc à un simple appel du genre Batiment>sacrifyForFood().

Pour ceux utilisant ce design pattern, qu'en pensez-vous ? Comment auriez-vous structuré votre contrôleur et vos modèles ?

Je vous laisse semer la pagaille dans mon esprit,
Maz
Répondre
#2
Si ça peut t'aider à le concevoir, tu as le droit d'avoir des modèles qui ne correspondent pas à ta base de données. Généralement on appelle ça des service objects.

Je commence par écrire la tronche que je veux que ça ait, dans les tests (et donc aussi dans mon contrôleur quand je l'utiliserai vraiment) :

describe TestEggSacrifice do
  before do
    @corwin = Player.create(name: "Corwin")
    @montpellier = @corwin.cities.create(name: "Montpellier")
  end


  it "should explode if player doesn't exist" do
    assert_raises Player:10layerNotFound do
      EggSacrifice.new(nil, nil, 42)
    end
  end


  it "should explode if city doesn't belong to player" do
    mandor = Player.create(name: "Mandor")
    lyon = mandor.cities.create(name: "Lyon")

    assert_raises City::CityNotFound do
      EggSacrifice.new(@corwin.id, lyon.id, 42)
    end
  end


  it "should explode when city doesn't have enough eggs" do
    assert_raises EggSacrifice::NotEnoughEggs do
      EggSacrifice.new(@corwin.id, @montpellier.id, 42)
    end
  end


  it "should kill 5 eggs and add X resources to the city" do
    City.spawn_eggs(@montpellier.id, 10)

    result = EggSacrifice.new(@corwin.id, @montpellier.id, 5).execute()
    assert results.city_id == @montpellier.id
    assert results.remaining_eggs == 5
    assert results.added_resources == ... # Whatever the rules say.
    assert results.new_resources == ... # Whatever the rules say.

    assert_raises EggSacrifice::NotEnoughEggs do
      EggSacrifice.new(@corwin.id, @montpellier.id, 6).execute()
    end

    assert City.resources(@montpellier.id) == ... # Whatever the rules say.
  end
end

Ici, tu peux voir que je teste les valeurs de retour de la procédure (un value object tout simple dont j'aurais bien besoin pour construire la réponse à ma requête), puis le comportement de cette procédure vis-à-vis du système, c'est à dire qu'une fois qu'on a sacrifié 5 œufs sur les 10 créés, on ne peut plus en sacrifier 6 (ça pète).

Plutôt que de tester le stock de la colonie, je pourrais aussi effectuer une démarche qui nécessite d'avoir les ressources en question.


class EggSacrifice
  def initialize(city_id, count)
    @city_id = city_id
    @count = count

    raise City::CityNotFound unless current_player.cities.exists?(city_id)
    raise City::NotEnoughEggs unless City.has_enough_eggs?(city_id, count)
  end


  def execute()
    City.transaction do
      result = Egg.remove_n_from_city(@city_id, @count)
      new_resources = City.add_resources(@city_id, result.resources)
     
      {
        city_id: @city_id,
        remaining_eggs: result.remaining_eggs,
        added_resources: result.resources,
        new_resources: new_resources
      }
    end
  end
end

Le code est simple et trivial à suivre parce que je fais appel à quelques méthodes aux noms très explicites.

Tu noteras que je fais appel à de nombreuses méthodes statiques. Je trouve que dans les jeux, passer des ID a souvent de bien meilleurs résultats que des objets complets (souvent issus de l'ORM) qui nécessitent plein d'appels inutiles à la base. Ça colle parfaitement avec ta volonté d'utiliser ta base de données directement plutôt qu'à travers l'ORM.
Répondre
#3
Chez "certains" (pas des gens d'ici), cela aurait été fait sur le principe de micro services:
- Un service "playerAction" a la méthode "sacrifice" qui est appelée par le joueur quand il fait son action de jeu (/playerActionService/sacrifice)
- Ce service appelle un autre service "unitsService" qui a une méthode "deleteUnits" qui est appelée (/unitsService/deleteUnit) pour dire quelles unités supprimer (après, "évidemment", avoir appelé le /unitsService/getCityUnits qui retourne la liste des unités de la cité)
- Le service "sacrifice" appelle ensuite un autre service "resourcesService" qui a la méthode "saveResources" pour rajouter les nouvelles ressources

----
Pour MVC, je ne sais pas si tu classes ça dans "controlleur" ou dans "modèle" (je serait tenté de dire "controlleur" car cela contient de la logique) mais je rejoins l'approche de faire une autre "classe" (ou autre concept du langage) controlleur appelant les deux "classes" de modèles pour que ce controlleur dise aux deux modèles de faire la suppression d'unité et l'ajout de ressources

----
Et du côté de mes archis, la page "/game/action/city/sacrifice" serait un endpoint qui appelle sa procédure stockée dédiée, dans laquelle on aurait bêtement:

CREATE PROCEDURE game_action_city_sacrifice(
IN idPlayer INT UNSIGNED,
IN idCity INT UNSIGNED,
IN idSacrifiedUnit SMALLINT UNSIGNED
) BEGIN

INSERT INTO city_resources (id_resource, id_city, quantity)
(SELECT
uic.id,
ur.id_resource,
0.5 * ur.construct_quantity * uic.units_count
FROM units_resources AS ur
INNER JOIN units_in_city AS uic ON uic.id_unit = ur.id AND uic.id_player = idPlayer AND uic.id = idCity
WHERE id_unit = idSacrifiedUnit)
ON DUPLICATE KEY UPDATE
city_resources.id_city = city_resources.id_city,
city_resources.id_resource = city_resources.id_resource,
city_resources.quantity = city_resources.quantity + VALUES(city_resources.quantity)
;

DELETE FROM units_in_city
WHERE id_unit = idSacrifiedUnit
AND id_city = idCity;

END
$$

Je te laisse choisir quels noms de formalisme tu veux donner à cette dernière approche (sans l'auto-complétion, c'est un peu plus chiant de tapper du SQL dans le navigateur que dans l'IDE 2 )
Répondre
#4
Merci à vous deux pour votre vision de ce type de structure.

(03-22-2020, 12:26 PM)Sephi-Chan a écrit : Si ça peut t'aider à le concevoir, tu as le droit d'avoir des modèles qui ne correspondent pas à ta base de données. Généralement on appelle ça des service objects.
J'aime beaucoup le principe de "service objects", dans quel cas je pense créer comme je disais une classe controller + modèle propre à ce "Bâtiment" qui ne serais lié à aucune de mes tables directement et qui gérerais toutes les actions réalisables dans ce bâtiment: création d'unité, sacrifice, développement des unités etc...

Du coup ce schéma est aussi réutilisable pour tout mes "bâtiments" qui effectues des actions groupées sur différentes tables. J'aime cette idée.

(03-22-2020, 12:26 PM)Sephi-Chan a écrit : Tu noteras que je fais appel à de nombreuses méthodes statiques. Je trouve que dans les jeux, passer des ID a souvent de bien meilleurs résultats que des objets complets (souvent issus de l'ORM) qui nécessitent plein d'appels inutiles à la base. Ça colle parfaitement avec ta volonté d'utiliser ta base de données directement plutôt qu'à travers l'ORM.

+1 pour les fonctions statiques, effectivement je pense que dans des développement de jeux dans lesquels il y a énormément de relations entre les tables, certaines requêtes deviennent très complexes et inutiles avec les orm dans des relations.

(03-22-2020, 01:01 PM)Xenos a écrit : Pour MVC, je ne sais pas si tu classes ça dans "controlleur" ou dans "modèle" (je serait tenté de dire "controlleur" car cela contient de la logique) mais je rejoins l'approche de faire une autre "classe" (ou autre concept du langage) controlleur appelant les deux "classes" de modèles pour que ce controlleur dise aux deux modèles de faire la suppression d'unité et l'ajout de ressources
Corriges moi si je me trompes, mais je dénotes ici que tu serais plus pour mon approche initiale: un contrôleur différent (ni Unity, ni City) qui appelerais directement les deux modèles depuis le contrôleur pour leur demander d'exécuter le traitement des données.

Tu trouveras en PJ J'ai copié-collé en bas de cette message le début de la discussion avec Sephi, ça as été le point de désaccord.
(03-22-2020, 01:01 PM)Xenos a écrit : Et du côté de mes archis, la page "/game/action/city/sacrifice" serait un endpoint qui appelle sa procédure stockée dédiée, dans laquelle on aurait bêtement:

CREATE PROCEDURE game_action_city_sacrifice(
IN idPlayer INT UNSIGNED,
IN idCity INT UNSIGNED,
IN idSacrifiedUnit SMALLINT UNSIGNED
) BEGIN

-- Code raccourci

END
$$
Tu noteras sur la dernière ligne de la discussion Discord, les procédures ont été évoqué, le "soucis"(avis personnel), avec ce type de "logique déportée en base de données" bien que probablement très performante niveau benchmark pour des requêtes lourdes, je trouve que l'on y perds en visibilité dans le code du moteur du jeu.

Bien que le développeur initial sais très bien ce que va faire exactement la procédure "city_sacrifice", il est délicat pour un nouveau programmeur rejoignant le projet de le deviner, il doit donc aller chercher dans les migrations ou directement sur la base de donnée sa description, ou pour contrer ce phénomène, ajouter un docblock qui selon la complexité de la procédure serais cossue.

De plus, dans certains cas (comme celui que je dois résoudre), il me faut connaître des paramètre que je stock dans un fichier php de type "game config" du genre le taux d'incrémentation de la nourriture par unité sacrifiée.

Tout est réalisable dans cette configuration, mais cela rajoute je trouves une fine couche de complexité, à chacun d'en juger le coût et le gain et d'utiliser ou non une solution similaire.

Discord a écrit :Maz:
  oui, on est d'accord, quand je dis "supprimer le modèle"
  c'est genre
Code PHP :
eatEggs($nb_fourmis) {
Ant::delete($b_fourmis);
City::increaseFoodFor($nb_fourmis);
// Ici j'envoie la vue

  je vais pas aller taper un delete direct en sql dans le controlleur on est d'accord sur ça 34

Sephi-Chan:
  non
  c'est pas bon ça
  tu dois avoir une méthode eatEggs
  c'est elle qui sait que ça doit kill des oeufs et ajouter de la bouffe

Maz:
  donc toi: depuis le modèle Ant, tu modifierais la table cities pour augmenter la nourriture? ou éventuellement depuis le modèle Ant tu appele le modèle City pour augmenter sa nourriture
Sephi-Chan:
  et donc tu testes cette méthode pour t'assurer qu'il y a bien assez d'œufs (donc des fourmis dans cette phase en assez grand nombre)
Maz:
  le truc c'est que je dois modifier 2 modèles, c'est pour ça que ça me fait chier de faire que l'un appel l'autre.
Sephi-ChanHier à 23:1
  on s'en fout ça existe pas cette restriction
Maz
ou alors je cré une procédure SQL genre eatEggsFromCity(nb_ants) et qui modifieras la table... (joke: structure de ouff ;p)
Répondre
#5
Citation :un contrôleur différent (ni Unity, ni City) qui appelerais directement les deux modèles depuis le contrôleur pour leur demander d'exécuter le traitement des données.
Je ne suis pas sûr d'avoir saisis, alors je reformule (peut-être donc que je paraphrase): cela donnerait qqc du style [controlleur de la page] -> appelle -> ["sous"-controlleur qui gère la notion de sacrifice et qui a 2 modèles en dépendances (propriétés de classe)] -> appelle -> [modèle 1 pour ajouter des ressources puis modèle 2 pour virer les unités, puis d'autres modèles si besoin]

Citation : je trouve que l'on y perds en visibilité dans le code du moteur du jeu.
Je ne suis pas d'accord car cela dépend juste de comment tu gères la déclaration de ta structure de DB. Dans mon cas, j'ai un dossier avec les tables (éventuellements, un sous-dossier pour une table donnée + ses triggers s'il y en a), un dossier de procédures stockées "génériques" (par exemple, si plusieurs pages ont besoin de sacrifices, je fais une procédure stockée que je mets là-dedans et que j'appelle en disant qui sacrifie quoi, et la procédure fait le traitement de sacrifice) et d'autres dossiers pour les autres types d'objets stockés (event, view et functions).
Il est en plus possible de créer un (ou plusieurs) fichiers SQL définissant chacun une procédure stockée "limitée" au dossier où le fichier se trouve (par exemple, on pourrait avoir un fichier __city_sacrifice.sql définissant une procédure stockée de gestion de sacrifices qui se trouverait dans /variisapce/endpoint/city/sacrifice/ et qui ne serait donc accessible que par /variisapce/endpoint/city/sacrifice/unit/ et /variisapce/endpoint/city/sacrifice/building/ si on peut sacrifier des batiments ou des unités)

Citation :il doit donc aller chercher dans les migrations
Le problème (de beaucoup de gens apparemment?!) quand je parle de faire des trucs via les objets stockés en DB, c'est qu'ils sont dans des optiques de migrations pure. Or, il ne faut pas. Quand tu utilises un SVN, ce n'est pas toi qui écris des diff, puis dis au SVN "allez, applique ça à mon code"! T'imagines le bordel... En pratique, tu écris le code réelle, et tu laisses le SVN se débrouiller comme un grand pour faire le diff. Dans le cas des bases de données, je conseille de faire de même (c'est ce que je fais).

La procédure que j'ai écrite précédemment, c'est ce qui se trouve dans mon fichier SQL. Bon, ok, y'a "DELIMITER $$ DROP PROCEDURE IF EXISTS game_action_city_sacrifice$$" avant et "DELIMITER ;" après (j'avais hésité à ne pas les mettre mais l'IDE se perd ensuite, vu qu'il n'a pas le bon delimiter, alors, j'ai laissé comme ça). Quand je veux changer la logique de cette action, je change le contenu de ce fichier.

L'outil de déploiment, lors d'une mise à jour du jeu, n'a alors plus qu'à bêtement exécuter tous les fichiers SQL pour mettre la DB à jour. En pratique, j'ai optimisé et sécurisé: l'outil de déploiment purge d'abord une DB de test en virant tout son contenu, puis il charge tous les SQL dans cette DB de test et si rien n'a planté [parce que ça m'est arrivé que l'IDE ne repère pas une erreur de syntaxe SQL par exemple] alors l'outil fait le *diff* entre cette DB de test et la DB réelle du jeu, et l'exécute.

Je procède de même pour les functions, les triggers, les view, les event (attention alors à bien définir une date explicite de début de l'event, sinon, l'outil met "NOW", donc la date de déploiment, et il trouve donc toujours un diff avec la DB du jeu 2 oui, je me suis déjà fait avoir sur ce point!) et les tables. Pour ces dernières, le diff va consister à rajouter/retirer/modifier (les typages) des colonnes. Dans le cas d'une colonne qu'on devrait "drop", j'ai mis en place la règle "renomme d'abord la colonne, rends-la NULLable DEFAULT NULL, et tagge-la avec une date à partir de laquelle la colonne peut être réellement supprimée". Ca m'évite de supprimer des colonnes direct au déploiment, et de perdre des données. En cas de mise à jour foireuse, je peux donc revert mon code (merci Mercurial!) puis le redéployer: l'outil va alors voir le diff entre la DB du jeu et la DB "comme elle était avant", il va voir que des colonnes se nomment "x__old_datedesuppression" dans la DB du jeu alors qu'une colonne "x" existe dans le code. L'outil va alors dire "ok, cette colonne n'est plus dépréciée, je la renomme juste en 'x'" et je récupère les données du jeu 2

Donc au fond, c'est gérable, mais cela part d'un principe important: le jeu est "codé" en SQL. Dans cette optique, je n'ai donc *aucune* logique de jeu dans mon code PHP. PHP ne sert qu'au templating du jeu finalement, et à récupérer les paramètres d'entrée des pages (get/post/cookie). Bon, ok, j'en profite à ce moment-là pour faire une validation rapide (type "ce paramètre doit être un ID, donc, je vérifie que c'est un integer, strictement positif, et inférieur à 0x7fffffff" mais sans plus: si ces conditions sont respectées, pouf, c'est envoyé à la procédure stockée de la page, et à elle de tout gérer ensuite).

Les rares cas où j'ai besoin d'un lien PHP/SQL, par exemple, parce qu'une constante de jeu doit être appliquée dans la logique du jeu (SQL) et affichée au joueur pour qu'il comprenne ce qui se passe (PHP), alors le plus souvent, la constante se trouve dans la base de donnée (soit dans une vue, soit dans une table: ça dépend de la structure dont j'ai besoin) et est récupérée comme une donnée de page classique (si c'est récurrent entre les pages, aka si c'est une donnée qui est utilisée dans le template du jeu et non sur une page précise, alors le template du jeu fait appel à un genre de "fausse page web" [un endpoint qui n'est accessible que par PHP et non par une URL] qui fait appel à sa procédure stockée, laquelle récupère les infos, puis ce endpoint convertit ça en bean de données et le template du jeu le récupère et peut faire son formattage).
Une autre approche possible que j'ai en réserve consiste à créer une (ou plusieurs) classes de constantes PHP contenant les quelques valeurs utiles, et à les utiliser dans le code PHP, puis à faire un test unitaire (c'est à ça IMO que servent les tests: je m'en fous de figer les signatures de mes méthodes partout dans mon code, un test ne devrait servir qu'à combler les lacunes de l'IDE quand y'en a et à figer l'API publique du jeu) qui s'assure que les mêmes valeurs sont dans la DB (ou carrément, dans les fichiers SQL qui en ont besoin).

Woarf, je me suis étalé... probablement inutilement :p Je retourne à mon interface de création de vaisseaux! 2
Répondre




Utilisateur(s) parcourant ce sujet : 1 visiteur(s)