Bonjour à tous!
Nous avons enfin un contrat pour mettre à jour le livre de Mark Siman "
Dependency Injection in .NET " - l'essentiel est qu'il l'achève dès que possible. Nous avons également un
livre dans l'éditeur du respecté Dinesh Rajput sur les modèles de conception au printemps 5, où l'un des chapitres est également consacré à la mise en œuvre des dépendances.
Nous recherchons depuis longtemps des documents intéressants qui rappelleront les forces du paradigme DI et clarifieront notre intérêt pour lui - et maintenant il a été trouvé. Certes, l'auteur a préféré donner des exemples dans Go. Nous espérons que cela ne vous empêche pas de suivre ses pensées et aide à comprendre les principes généraux de l'inversion de contrôle et de travailler avec des interfaces, si ce sujet est proche de vous.
La coloration émotionnelle de l'original est un peu plus silencieuse, le nombre de points d'exclamation dans la traduction est réduit. Bonne lecture!
L'utilisation d'
interfaces est une technique compréhensible qui vous permet de créer du code facile à tester et facilement extensible. J'ai été à plusieurs reprises convaincu qu'il s'agit de l'outil de conception d'architecture le plus puissant de tous.
Le but de cet article est d'expliquer ce que sont les interfaces, comment elles sont utilisées et comment elles fournissent l'extensibilité et la testabilité du code. Enfin, l'article devrait montrer comment les interfaces peuvent aider à optimiser la gestion de la livraison de logiciels et à simplifier la planification!
InterfacesL'interface décrit le contrat. Selon le langage ou le framework, l'utilisation des interfaces peut être dictée explicitement ou implicitement. Ainsi, dans le langage Go, les
interfaces sont dictées explicitement . Si vous essayez d'utiliser une entité comme interface, mais qu'elle ne sera pas entièrement cohérente avec les règles de cette interface, une erreur de compilation se produira. Par exemple, en exécutant l'exemple ci-dessus, nous obtenons l'erreur suivante:
prog.go:22:85: cannot use BadPricer literal (type BadPricer) as type StockPricer in argument to isPricerHigherThan100: BadPricer does not implement StockPricer (missing CurrentPrice method) Program exited.
Les interfaces sont un outil pour aider à détacher l'appelant de l'appelé, cela se fait à l'aide d'un contrat.
Concrétisons ce problème à l'aide d'un exemple de programme d'échange automatique de devises. Le programme de négociation sera appelé avec un prix d'achat défini et un symbole boursier. Ensuite, le programme ira à la bourse pour connaître la cotation actuelle de ce ticker. De plus, si le prix d'achat de ce ticker ne dépasse pas le prix fixé, le programme effectuera un achat.

Sous une forme simplifiée, l'architecture de ce programme peut être représentée comme suit. D'après l'exemple ci-dessus, il est clair que l'opération d'obtention du prix actuel dépend directement du protocole HTTP, par lequel le programme contacte le service d'échange.
L'état de l'
Action
également directement de HTTP. Ainsi, les deux États doivent comprendre parfaitement comment utiliser HTTP pour extraire des données d'échange et / ou effectuer des transactions.
Voici à quoi pourrait ressembler l'implémentation:
func analyze(ticker string, maxTradePrice float64) (bool, err) { resp, err := http.Get( "http://stock-service.com/currentprice/" + ticker ) if err != nil {
Ici, l'appelant (
analyze
) dépend directement de HTTP. Elle a besoin de savoir comment les requêtes HTTP sont formulées. Comment l'analyse est-elle effectuée? Comment gérer les tentatives, les délais d'attente, l'authentification, etc. Elle a une
prise en
main étroite sur
http
.
Chaque fois que nous appelons analyse, nous devons également appeler la bibliothèque http
.
Comment l'interface peut-elle nous aider ici? Dans le contrat fourni par l'interface, vous pouvez décrire le
comportement plutôt que l'
implémentation spécifique.
type StockExchange interface { CurrentPrice(ticker string) float64 }
Ce qui précède définit le concept de
StockExchange
. Il indique ici que
StockExchange
prend en charge l'appel de la seule fonction
CurrentPrice
. Ces trois lignes me semblent la technique architecturale la plus puissante de toutes. Ils nous aident à contrôler les dépendances des applications de manière beaucoup plus sûre. Fournir des tests. Fournir une extensibilité.
Injection de dépendanceAfin de bien comprendre la valeur des interfaces, vous devez maîtriser la technique appelée «injection de dépendance».
L'injection de dépendance signifie que l'appelant fournit quelque chose dont il a besoin. Habituellement, cela ressemble à ceci: l'appelant configure l'objet, puis le transmet à l'appelé. Ensuite, l'appelé résume la configuration et la mise en œuvre. Dans ce cas, il existe une médiation connue. Considérez une demande au service HTTP Rest. Pour implémenter le client, nous devons utiliser une bibliothèque HTTP qui peut formuler, envoyer et recevoir des requêtes HTTP.
Si nous plaçions la requête HTTP derrière l'interface, l'appelant pourrait être détaché, et elle ne serait "pas au courant" que la requête HTTP a réellement eu lieu.
L'appelant ne doit effectuer qu'un appel de fonction générique. Cela peut être un appel local, un appel distant, un appel HTTP, un appel RPC, etc. L'appelante n'est pas au courant de ce qui se passe, et cela lui convient généralement parfaitement, tant qu'elle obtient les résultats escomptés. Ce qui suit montre à quoi pourrait ressembler l'injection de dépendance dans notre méthode d'
analyze
.
func analyze(se StockExchange, ticker string, maxTradePrice float64) (bool, error) { currentPrice := se.CurrentPrice(ticker) var hasTraded bool var err error if currentPrice <= maximumTradePrice { err = doTrade(ticker, currentPrice) if err == nil { hasTraded = true } } return hasTraded, err }
Je ne cesse d'être étonné de ce qui se passe ici. Nous avons complètement inversé notre arbre de dépendance et commencé à mieux contrôler l'ensemble du programme. De plus, même visuellement, toute la mise en œuvre est devenue plus propre et plus compréhensible. Nous voyons clairement que la méthode d'analyse doit choisir le prix actuel, vérifier si ce prix nous convient et, dans l'affirmative, conclure un accord.
Plus important encore, dans ce cas, nous détachons l'appelant de l'appelant. Étant donné que l'appelant et l'implémentation entière sont séparés de l'appelé à l'aide de l'interface, vous pouvez étendre l'interface en en créant de nombreuses implémentations différentes. Les interfaces vous permettent de créer de nombreuses implémentations spécifiques différentes sans nécessiter de changer le code de l'appelé!

Le statut «obtenir le prix actuel» dans ce programme dépend uniquement de l'interface
StockExchange
. Cette implémentation ne sait pas comment contacter le service d'échange, comment les prix sont stockés ou comment les demandes sont faites. Une véritable ignorance béat. De plus, bilatéral. L'implémentation
HTTPStockExchange
ne sait également rien de l'analyse. A propos du contexte dans lequel l'analyse sera effectuée, quand elle est effectuée - parce que les défis se produisent indirectement.
Étant donné que les fragments de programme (ceux qui dépendent des interfaces) n'ont pas besoin d'être modifiés lors du changement / ajout / suppression d'implémentations spécifiques,
une telle conception s'avère durable . Supposons que nous trouvions que
StockService
très souvent indisponible.
En quoi l'exemple ci-dessus est différent de l'appel d'une fonction? Lors de l'application d'un appel de fonction, l'implémentation deviendra également plus propre. La différence est que lorsque vous appelez la fonction, nous devons toujours recourir à HTTP. La méthode d'
analyze
déléguera simplement la tâche de la fonction, qui devrait appeler
http
, plutôt que d'appeler directement
http
lui-même. Toute la force de cette technique réside dans l '«injection», c'est-à-dire que l'appelant fournit l'interface à l'appelé. C'est exactement comme cela que se produit l'inversion de dépendance, où les prix d'obtention dépendent uniquement de l'interface et non de l'implémentation.
Plusieurs implémentations prêtes à l'emploiÀ ce stade, nous avons la fonction d'
analyze
et l'interface
StockExchange
, mais nous ne pouvons rien faire d'utile. Je viens d'annoncer notre programme. Pour le moment, il est impossible de l'appeler, car nous n'avons toujours pas une seule implémentation spécifique qui répondrait aux exigences de notre interface.
L'accent principal dans le diagramme suivant est mis sur l'état «obtenir le prix actuel» et sa dépendance à l'interface
StockExchange
. Ce qui suit montre comment deux implémentations complètement différentes coexistent et que le prix actuel n'est pas connu. De plus, les deux implémentations ne sont pas liées l'une à l'autre, chacune d'entre elles ne dépend que de l'interface
StockExchange
.

La production
L'implémentation HTTP d'origine existe déjà dans l'implémentation d'
analyze
principale; il ne nous reste plus qu'à l'extraire et à l'encapsuler derrière une implémentation concrète de l'interface.
type HTTPStockExchange struct {} func (se HTTPStockExchange) CurrentPrice(ticker string) float64 { resp, err := http.Get( "http://stock-service.com/currentprice/" + ticker ) if err != nil {
Le code que nous avons précédemment lié à la fonction d'analyse est maintenant autonome et satisfait l'interface
StockExchange
, c'est-à-dire que nous pouvons maintenant le passer à
analyze
. Comme vous vous en souvenez des diagrammes ci-dessus, l'analyse n'est plus associée à la dépendance HTTP. À l'aide de l'interface, l'
analyze
n'imagine pas ce qui se passe dans les coulisses. Il sait seulement qu'on lui garantira un objet avec lequel il pourra appeler
CurrentPrice
.
Ici aussi, nous profitons des vertus typiques de l'encapsulation. Avant, lorsque les requêtes http étaient liées à l'analyse, la seule façon de communiquer avec l'échange via http était indirecte - via la méthode d'
analyze
. Oui, nous pourrions encapsuler ces appels dans des fonctions et exécuter la fonction indépendamment, mais les interfaces nous obligent à détacher l'appelant de l'appelant. Nous pouvons maintenant tester
HTTPStockExchange
quel que soit l'appelant. Cela affecte fondamentalement la portée de nos tests et la façon dont nous comprenons et répondons aux échecs des tests.
TestDans le code existant, nous avons la structure
HTTPStockService
, qui nous permet de nous assurer séparément qu'il peut communiquer avec le service d'échange et analyser les réponses reçues de celui-ci. Mais maintenant, assurons-nous que l'analyse peut gérer correctement la réponse de l'interface
StockExchange
, en outre, que cette opération est fiable et reproductible.
currentPrice := se.CurrentPrice(ticker) if currentPrice <= maxTradePrice { err := doTrade(ticker, currentPrice) }
NOUS POUVONS utiliser l'implémentation avec HTTP, mais cela aurait de nombreux inconvénients. Les appels réseau dans les tests unitaires peuvent être lents, en particulier pour les services externes. En raison de retards et d'une connexion réseau instable, les tests pourraient s'avérer peu fiables. De plus, si nous avions besoin de tests avec la déclaration que nous pouvons terminer la transaction et de tests avec la déclaration que nous pouvons filtrer les cas dans lesquels la transaction ne devrait PAS être conclue, il serait difficile de trouver de vraies données de production qui satisfassent de manière fiable ces deux conditions. On pourrait choisir
maxTradePrice
, en imitant artificiellement chacune des conditions de cette manière, par exemple, avec
maxTradePrice := -100
transaction ne devrait pas être terminée, et
maxTradePrice := 10000000
devrait évidemment se terminer avec la transaction.
Mais que se passe-t-il si un certain quota nous est attribué sur le service d'échange? Ou si nous devons payer l'accès? Allons-nous vraiment (et devrions-nous) payer ou dépenser notre quota en matière de tests unitaires? Idéalement, les tests devraient être exécutés aussi souvent que possible, donc ils devraient être rapides, bon marché et fiables. Je pense qu'à partir de ce paragraphe, il est clair pourquoi utiliser une version avec HTTP pur est irrationnel en termes de tests!
Il y a une meilleure façon, et cela implique d'utiliser des interfaces!Ayant une interface, vous pouvez fabriquer avec soin l'implémentation
StockExchange
, ce qui nous permettra d'
analyze
rapidement, en toute sécurité et de manière fiable.
type StubExchange struct { Price float64 } func (se StubExchange) CurrentPrice(ticker string) float64 { return se.Price } func TestAnalyze_MakeTrade(t *testing.T) { se := StubExchange{Price: 10} maxTradePrice := 11 traded, err := analyze(se, "TSLA", maxTradePrice) if err != nil { t.Errorf("expected err == nil received: %s", err) } if !traded { t.Error("expected traded == true") } } func TestAnalyze_DontTrade(t *testing.T) { se := StubExchange{Price: 10} maxTradePrice := 9 traded, err := analyze(se, "TSLA", maxTradePrice)
Le talon du service d'échange est utilisé ci-dessus, grâce auquel la branche qui nous intéresse dans l'
analyze
est lancée. Ensuite, des déclarations sont faites dans chacun des tests pour s'assurer que l'analyse fait ce qui est nécessaire. Bien qu'il s'agisse d'un programme de test, mon expérience suggère que les composants / architecture, où les interfaces sont utilisées à peu près de cette manière, sont également testés pour la durabilité dans le code de bataille !!! Grâce aux interfaces, nous pouvons utiliser le
StockExchange
contrôlé en mémoire, qui fournit des tests fiables, facilement configurables, faciles à comprendre, reproductibles et ultra-rapides !!!
Détacher - Configuration de l'appelantMaintenant que nous avons discuté de la façon d'utiliser les interfaces pour détacher l'appelant de l'appelé et de la façon de réaliser plusieurs implémentations, nous n'avons toujours pas abordé un aspect critique. Comment configurer et fournir une implémentation spécifique à un moment strictement défini? Vous pouvez appeler directement la fonction d'analyse, mais que faire dans la configuration de production?
C'est là que l'implémentation des dépendances est utile.
func main() { var ticker = flag.String("ticker", "", "stock ticker symbol to trade for") var maxTradePrice = flag.Float64("maxtradeprice", "", "max price to pay for a share of the ticker symbol." se := HTTPStockExchange{} analyze(se, *ticker, *maxTradePrice) }
Tout comme dans notre cas de test, l'implémentation concrète spécifique de StockExchange qui sera utilisée avec
analyze
est configurée par l'appelant en dehors de l'analyse. Ensuite, il est passé (injecté) pour
analyze
. Cela garantit que rien n'est analysé sur la façon dont
HTTPStockExchange
configuré. Peut-être que nous aimerions fournir le domaine http que nous allons utiliser sous la forme d'un indicateur de ligne de commande, puis l'analyse n'aura pas à changer. Ou que faire si nous devons fournir une sorte d'authentification ou de jeton pour accéder à
HTTPStockExchange
, qui sera extrait de l'environnement? Encore une fois, l'analyse ne devrait pas changer.
La configuration a lieu à un niveau extérieur à l'
analyze
, libérant ainsi complètement l'analyse de la nécessité de configurer ses propres dépendances. Ainsi, une stricte séparation des tâches est réalisée.
Décisions de mise en rayonLes exemples ci-dessus suffisent peut-être, mais il existe encore de nombreux autres avantages aux interfaces et à l'injection de dépendances. Les interfaces permettent de différer les décisions concernant des implémentations spécifiques. Bien que les décisions nous obligent à décider du comportement que nous prendrons en charge, elles nous permettent toujours de prendre des décisions sur des implémentations spécifiques plus tard. Supposons que nous savions que nous voulions effectuer des transactions automatisées, mais que nous ne savions pas encore quel fournisseur de devis utiliser. Une classe de solutions similaire est constamment traitée lorsque vous travaillez avec des entrepôts de données. Que doit utiliser notre programme: mysql, postgres, redis, système de fichiers, cassandra? En fin de compte, tout cela est des détails de mise en œuvre, et les interfaces nous permettent de différer les décisions finales sur ces questions. Ils nous permettent de développer la logique métier de nos programmes, et de basculer vers des solutions technologiques spécifiques au dernier moment!
Malgré le fait que cette technique seule laisse de nombreuses possibilités, quelque chose de magique se produit au niveau de la planification du projet. Imaginez ce qui se passera si nous ajoutons une dépendance supplémentaire à l'interface d'échange.

Ici, nous allons reconfigurer notre architecture sous la forme d'un graphe acyclique dirigé, de sorte que dès que nous nous mettrons d'accord sur les détails de l'interface d'échange, nous pouvons COMPETITEMENT continuer à travailler avec le pipeline à l'aide de
HTTPStockExchange
. Nous avons créé une situation dans laquelle l'ajout d'une nouvelle personne au projet nous aide à avancer plus rapidement. En modifiant notre architecture de cette manière, nous pouvons mieux voir où, quand et pendant combien de temps nous pouvons engager des personnes supplémentaires dans le projet afin d'accélérer la livraison de l'ensemble du projet. De plus, comme la connexion entre nos interfaces est faible, il est généralement facile de s'impliquer dans le travail, à commencer par les interfaces d'implémentation. Vous pouvez développer, tester et tester
HTTPStockExchange
complètement indépendamment de notre programme!
L'analyse des dépendances architecturales et la planification en fonction de ces dépendances peuvent accélérer considérablement les projets. Grâce à cette technique particulière, j'ai pu réaliser très rapidement des projets pour lesquels plusieurs mois ont été alloués.
DevantMaintenant, il devrait être plus clair comment les interfaces et la mise en œuvre des dépendances assurent la durabilité du programme conçu. Supposons que nous modifions notre fournisseur de devis ou que nous commencions à diffuser des quotas et les enregistrions en temps réel; il y a autant d'autres possibilités que vous le souhaitez. La méthode d'analyse dans sa forme actuelle prendra en charge toute implémentation adaptée à l'intégration avec l'interface
StockExchange
.
se.CurrentPrice(ticker)
Ainsi, dans de nombreux cas, vous pouvez vous passer de modifications. Pas du tout, mais dans les cas prévisibles que nous pouvons rencontrer. Nous sommes non seulement à l'abri de la nécessité de modifier le code d'
analyze
et de revérifier ses fonctionnalités clés, mais nous pouvons facilement proposer de nouvelles implémentations ou basculer entre les fournisseurs. Nous pouvons également étendre ou mettre à jour en douceur les implémentations spécifiques que nous avons déjà sans avoir besoin de modifier ou de revérifier l'
analyze
!
J'espère que les exemples ci-dessus démontrent de manière convaincante comment l'affaiblissement de la connexion entre les entités du programme par l'utilisation d'interfaces réoriente complètement les dépendances et sépare l'appelant de l'appelant. Grâce à ce détachement, le programme ne dépend pas d'une implémentation spécifique, mais il dépend d'un
comportement spécifique. Ce comportement peut être fourni par une grande variété d'implémentations. Ce principe de conception critique est également appelé
typage du canard .
Le concept d'interfaces et la dépendance au comportement, et non à l'implémentation, est si puissant que je considère les interfaces comme une langue primitive - oui, c'est assez radical. J'espère que les exemples discutés ci-dessus se sont avérés assez convaincants, et vous conviendrez que les interfaces et l'injection de dépendances devraient être utilisées dès le début du projet. Dans presque tous les projets sur lesquels j'ai travaillé, il fallait non pas une, mais au moins deux implémentations: pour la production et pour les tests.