[JAVA] Pourquoi l'injection de dépendance n'est pas si mauvaise

Aimez-vous la programmation fonctionnelle? Je déteste ça. J'aime la programmation avec des objets immuables sans effets secondaires, mais je déteste la programmation fonctionnelle. Je pense que vous ne devriez pas utiliser la programmation fonctionnelle uniquement pour montrer que vous êtes assez intelligent pour utiliser des techniques difficiles. Qu'il s'agisse de programmation fonctionnelle ou de programmation procédurale, il est important d'écrire un code facile à comprendre et à maintenir.

N'est-ce pas la cause de l'arrogance comme «pour l'interdiction de la peine» que le principe technique suprême que la personne qui connaît la technique difficile est grande et la personne qui écrit le code le plus ésotérique est grande? Je pense que le chemin emprunté par les modèles de conception suit la programmation fonctionnelle.

xkcd

Pourquoi l'injection de dépendances est mauvaise

(Le code de cette section est basé sur cette vidéo)

Ils ont ciblé les conteneurs DI comme prochaine cible.

Dans la programmation fonctionnelle, les fonctions doivent être pures. Vous devez toujours renvoyer la même sortie pour une entrée. L'objectif du conteneur IoC est, en premier lieu, de permettre la réécriture flexible de la mise en œuvre interne d'un module de l'extérieur. En premier lieu, il est en conflit avec l'objectif de la programmation fonctionnelle.

@ApplicationScoped
public class BusinessLogic{

    private final Config config;

    @Inject
    public BusinessLogic( Config config ) {
        this.config = config;
    }

    public Name readName() {
        var parts = config.getName().split( " " );
        return new Name( parts[0], parts[1] );
    }

    public Age readAge() {
        return new Age( config.getAge() );
    }

    public Person readPerson() {
        return new Person( readName(), readAge() );
    }
}

Chaque méthode dépend de Config. Config n'est pas passé directement comme argument de méthode, mais est obtenu indirectement par le conteneur DI. Des fonctions impures entraînent des effets secondaires. Les effets secondaires ne sont pas bons, non?

Ensuite, ce qu'il faut faire est de rendre la dépendance indirecte directe. Tout ce que vous avez à faire est de transmettre directement toutes les dépendances en arguments. Déplaçons l'acquisition de l'instance Config, qui a été laissée au conteneur DI, vers l'argument.

def readName(config: Config): Name = {
    val parts = config.name.split(" ")
    Name(parts(0), parts(1))
}

C'est bien en théorie, mais l'application réelle est pleine de dépendances. Paramètres, informations utilisateur, sessions, accès à la base de données, appels au serveur de cache, API externes, files d'attente de messages ... En pratique, vous vous retrouverez avec une tonne de plaques chauffantes qui ne peuvent pas être ridicules de Java, avec une tonne d'arguments pour chaque fonction (et beaucoup affirment encore que cela devrait être fait). Je suis sûr).

Une solution courante à ce problème consiste à utiliser une monade leader ou une monade d'État. Les monades permettent aux fonctions de rester pures et d'accéder au contexte.

(def read-person
  (domonad reader-m  ; wtf is this?
    [name read-name
     age read-age]
    {:name name :age age}))

Vous avez maintenant un beau code. Je suis heureux.

le contexte

En fait, à quel point le code avec des monades est-il meilleur que le code avec des conteneurs IoC? La clarté est subjective, mais "je dois utiliser des monades pour n'importe quoi!" Je ne pense pas que ce soit extrêmement facile à comprendre. Vaut-il la peine de présenter Monad simplement parce que vous voulez que la fonction soit pure?

Dans tous les cas, avec autant d'articles «qu'est-ce qu'une monade», vous pouvez conclure que les monades ne sont ni faciles ni directes (il existe de nombreux articles orientés objet et de commentaires). Cela peut être discuté, mais l'orientation des objets est également assez difficile (je ne sais toujours pas comment hériter des classes en Java). Et le plus dur est mauvais en soi. Cela crée un problème beaucoup plus important que celui que Monad essayait de résoudre.

Le problème en premier lieu était d'éliminer la dépendance et de l'exprimer dans la fonction. Mais voici une question. Pourquoi Config n'a-t-il pas été passé en tant que paramètre dans le modèle de programmation utilisant DI en premier lieu?

La réponse est simple, car «Config» n'est pas un paramètre. Parce que c'est un ** contexte ** dans l'exécution de l'application. Dans la méthode de passage de paramètres, * l'appelant * détermine le contexte du contexte (c'est bien sûr l'appelant qui décide de ce qu'il faut passer dans l'argument). Dans le conteneur DI, ni l'appelant ni l'appelant ne sont responsables du contexte. C'est exactement ce dont le conteneur DI est responsable. C'est exactement au détriment d'essayer de tout passer en paramètres, c'est pourquoi de nombreuses fonctions ont des paramètres qu'elles n'ont pas besoin d'utiliser elles-mêmes.

Chef Monade

  1. La monade de leader est un moyen courant de transmettre le contexte
  2. En gros, enveloppez une lecture implicite dans une monade
  3. Avantage: Read est résumé en tant que type
  4. Mais je crois que c'est comme frapper une araignée avec un canon
  5. Les monades concernent le séquençage, pas le passage du contexte

Pourquoi l'injection de dépendance n'est pas si mauvaise

Pour résumer la section précédente, le contexte n'est pas une entrée, donc le contexte n'est pas passé en argument. L'injection de dépendances passe un argument est ** not **. Si vous combinez le contexte des paramètres et le considérez comme l'entrée de la fonction, il sera difficile de voir quel est le contexte et quel est le paramètre. Les monades sont un moyen d'abstraire le contexte et de le séparer des paramètres, mais au prix de la complexité des monades elles-mêmes.

Et par-dessus tout, le problème du rejet des dépendances est qu'au lieu de s'attaquer au problème original de traiter le «contexte», nous pensons que tous les problèmes peuvent être résolus par la programmation fonctionnelle d'une manière orientée vers la doctrine.

DI fait une distinction claire entre les paramètres et le contexte. Les paramètres sont passés à la fonction et le contexte est passé du conteneur DI via le constructeur. Les dépendances sont clairement affichées dans le constructeur. DI est très simple à sa base. En prime, le code avec des conteneurs DI a l'avantage d'être du code "normal". Tout programmeur maîtrisant le langage peut le lire naturellement (à part, je n'aime pas beaucoup utiliser l'injection de champ en Java pour cette raison même).

Ce qui sépare le contexte des paramètres est en fait assez vague. Même avec la Config donnée dans l'exemple ci-dessus, il y a bien sûr des situations où il est plus facile de comprendre si elle est traitée comme un paramètre plutôt que comme un contexte (par exemple, analyser une chaîne de configuration au format JSON dans une instance de Config est une bonne idée. Ce serait plus naturel d'être une fonction). Cependant, cet argument est toujours valable en ce que ** dans la fonction ** montrant clairement ce qui est dans le contexte et quel est le paramètre pour la personne qui lit le code réduit la charge cognitive. Parce que cela contribue.

Les monades de leader ont l'avantage distinct de séparer le contexte et les paramètres. La même chose est vraie pour les conteneurs DI. Et DI est beaucoup plus facile à démarrer que Monad.

Scala implicite

Bien sûr, Scala a Scalaz, et si vous n'utilisez pas Scalaz, vous serez considéré comme un programmeur Java et ridiculisé (!). Fait intéressant, Scala a également une fonctionnalité appelée «implicite» qui permet une abstraction contextuelle. Dans Vidéo utilisée pour l'exemple de code précédemment, je citerai le code présenté par le professeur Odelsky.

type Configured[T] = implicit Config => T

def readPerson: Configured[Option[Person]] =
  attempt(
    Some(Person(readName, readAge))
  ).onError(None)

ʻImplicit` vous permet de passer des paramètres de contexte sans les tracas des arguments d'écriture manuscrite. Au fait, grâce au compilateur et au curry, c'est plus efficace que de faire une fermeture. Et comme DI, vous n'avez pas à écrire du code "étrange" comme Monad.

Je ne suis pas un programmeur Scala et je ne peux pas dire si «implicite» fonctionne. Cependant, il y a quelques inquiétudes.

Cependant, ce que je voulais dire ici, c'est qu'il existe différentes techniques pour gérer le contexte. Monad n'est pas le seul bon moyen.

Java EE

JSR 365 et CDI sont responsables de la fonction DI dans Java EE. Auparavant, l'impression que j'avais en utilisant Java EE au travail était la pire. Pire signifie mieux que EJB.

Cependant, récemment, j'ai lu les Spécifications en diagonale, et l'impression a considérablement changé. J'avais honte de dire que je critiquais CDI sans connaître les détails, seulement avec les connaissances que j'ai recherchées en ligne. Peut-être que tout le monde le savait, mais permettez-moi de faire semblant d'être oublié et de le présenter.

Le code CDI typique semble avoir été montré au début de cet article. Décrivez les modules dépendants comme paramètres du constructeur avec @ Inject.

class BillingService {
  private final CreditCardProcessor processor;
  private final TransactionLog transactionLog;

  @Inject
  BillingService(CreditCardProcessor processor, 
      TransactionLog transactionLog) {
    this.processor = processor;
    this.transactionLog = transactionLog;
  }

  public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard) {
    ...
  }
}

(Cet exemple est tiré de Guice, pas exactement de JSR 365 ... Guice's Wiki apprend DI Je pense que c'est une ressource très utile, alors jetez un œil)

Je pense que les frameworks DI dans n'importe quelle langue auront presque la même apparence. Lors du test, vous pouvez changer l'implémentation de CreditCardProcessor afin de ne pas avoir à payer pour la carte réelle.

Par ailleurs, dans Java EE, la classe HttpServletRequest peut être la cible de DI. Je ne pouvais pas m'empêcher de me demander cela tout le temps. IoC devrait être un mécanisme de découplage des implémentations en s'appuyant sur des modules abstraits. Mais la demande n'est que des données. N'est-il pas naturel de le passer comme un argument comme la programmation fonctionnelle? En fait, [Recevoir comme argument dans un servlet brut](https://docs.oracle.com/javaee/7/api/javax/ servlet / http / HttpServlet.html # doGet-javax.servlet.http.HttpServletRequest-javax.servlet.http.HttpServletResponse-) C'est vrai. Par exemple, n'est-il pas étrange de pouvoir injecter une requête dans un module singleton? Au niveau de l'implémentation, l'instance réellement injectée est obtenue via un proxy. Le proxy permute les instances appropriées pour chaque contexte, vous permettant d'injecter dans des modules de portée plus large. Mais est-il nécessaire de procéder ainsi?

CDI est donc plus qu'un simple framework DI. Il fonctionne non seulement comme un conteneur IoC, mais a également pour aspect de fournir des fonctions pour extraire et gérer activement le contexte.

Le code ci-dessous est un extrait d'un exemple de spécification.

@SessionScoped @Model
public class Login implements Serializable {
    @Inject Credentials credentials;
    @Inject @Users EntityManager userDatabase;

    private CriteriaQuery<User> query;
    private Parameter<String> usernameParam;
    private Parameter<String> passwordParam;

    private User user;

    @Inject
    void initQuery( @Users EntityManagerFactory emf ) {
        CriteriaBuilder cb = emf.getCriteriaBuilder();
        usernameParam = cb.parameter( String.class );
        passwordParam = cb.parameter( String.class );
        query = cb.createQuery( User.class );
        Root<User> u = query.from( User.class );
        query.select( u );
        query.where(
                cb.equal( u.get( User_.username ), usernameParam ),
                cb.equal( u.get( User_.password ), passwordParam )
        );
    }

    public void login() {
        List<User> results = userDatabase.createQuery( query )
                                         .setParameter( usernameParam, credentials.getUsername() )
                                         .setParameter( passwordParam, credentials.getPassword() )
                                         .getResultList();
        if ( !results.isEmpty() ) {
            user = results.get( 0 );
        }
    }

    public void logout() {
        user = null;
    }

    public boolean isLoggedIn() {
        return user != null;
    }

    @Produces @LoggedIn User getCurrentUser() {
        if ( user == null ) {
            throw new NotLoggedInException();
        } else {
            return user;
        }
    }
}

C'est certainement plein de redondance de type Java, et j'avoue qu'il y a beaucoup de parties désagréables (comme ʻinitQuery`). Mais lorsque vous plissez les yeux et que vous le regardez d'un point de vue contextuel, cette classe fonctionne parfaitement.

@ SessionScoped indique que l'instance sera instanciée pour chaque session HTTP.

<f:view>
    <h:form>
        <h:panelGrid columns="2" rendered="#{!login.loggedIn}">
            <h:outputLabel for="username">Username:</h:outputLabel>
            <h:inputText id="username" value="#{credentials.username}"/>
            <h:outputLabel for="password">Password:</h:outputLabel>
            <h:inputText id="password" value="#{credentials.password}"/>
        </h:panelGrid>
        <h:commandButton value="Login" action="#{login.login}" rendered="#{!login.loggedIn}"/>
        <h:commandButton value="Logout" action="#{login.logout}" rendered="#{login.loggedIn}"/>
    </h:form>
</f:view>

La grande chose est que les classes qui utilisent cette classe n'ont pas du tout à se soucier de l'état de la session ou de la connexion à la base de données. Vous pouvez simplement @ Injecter la classe Login et accéder au contexte sans même savoir ce qui se passe dans les coulisses. Vous n'avez même pas besoin d'être conscient que la classe Login est limitée à la session. C'est une astuce qui ne peut être imitée en programmation fonctionnelle (la classe Login elle-même est un objet avec état qui incarne le contexte du processus de connexion). Cette classe est un objet mutable, mais l'état de connexion est essentiellement différent en premier lieu. Les changements sont modélisés directement dans le code. D'autres fonctionnalités puissantes telles que les hooks de cycle de vie, les événements, les intercepteurs, etc. sont fournies et ne sont pas traitées en détail ici.

C'est pourquoi le DI de Java n'est pas seulement DI, il est nommé ** Contexte et ** Injection de dépendances.

En fait, je suis encore assez sceptique lorsqu'on me demande si je veux écrire un programme en Java EE. L'inconvénient de la redondance est sérieux, et je ne pense pas que le contrôle de contexte mentionné ici fonctionnera dans * tous * les cas. Le contrôle d'état variable peut être tragique si vous faites une erreur. Néanmoins, il est clair que la personne qui a créé la spécification JSR 365 a pris la question du contexte au sérieux et l'a conçue très soigneusement (bien que ce soit très impoli d'écrire cela). Je regrette qu'il ne soit pas bon de critiquer sans rien savoir.

Résumé

Le problème avec le rejet de dépendance est qu'il ignore les problèmes de contexte. Les monades sont trop difficiles pour résumer le contexte. DI n'est pas parfait, mais il vous permet de travailler avec le contexte de manière relativement simple. Vous n'avez pas à vous sentir inférieur simplement parce que vous utilisez Java et que vous ne connaissez pas Monad.

Si vous lisez cet article et adoptez Jakarta EE et que votre projet brûle, je ne suis pas responsable.

Recommended Posts

Pourquoi l'injection de dépendance n'est pas si mauvaise
Dagger2 - Injection de dépendance Android
DI: Qu'est-ce que l'injection de dépendance?
Résumer l'injection de rubis et de dépendances