Perché si usa l'iniezione di dipendenza?


536

Sto cercando di capire le iniezioni di dipendenza (DI) e ancora una volta ho fallito. Sembra sciocco. Il mio codice non è mai un casino; Scrivo a malapena funzioni e interfacce virtuali (anche se lo faccio una volta in una luna blu) e tutta la mia configurazione è magicamente serializzata in una classe usando json.net (a volte usando un serializzatore XML).

Non capisco bene quale problema risolva. Sembra un modo per dire: "ciao. Quando ti imbatti in questa funzione, restituisci un oggetto di questo tipo e usa questi parametri / dati."
Ma ... perché dovrei mai usarlo? Nota che non ho mai avuto bisogno di usare objectanche io, ma capisco a cosa serve.

Quali sono alcune situazioni reali nella costruzione di un sito Web o di un'applicazione desktop in cui si utilizzerebbe DI? Posso facilmente trovare casi per cui qualcuno potrebbe voler usare interfacce / funzioni virtuali in un gioco, ma è estremamente raro (abbastanza raro che non riesca a ricordare una singola istanza) usarlo in un codice non di gioco.


3
Potrebbe essere utile anche questa informazione: martinfowler.com/articles/injection.html
ta.speot.is




5
Un'altra spiegazione molto semplice di DI: codearsenal.net/2015/03/…
ybonda

Risposte:


840

In primo luogo, voglio spiegare un presupposto che ho fatto per questa risposta. Non è sempre vero, ma abbastanza spesso:

Le interfacce sono aggettivi; le classi sono sostantivi.

(In realtà, ci sono anche interfacce che sono sostantivi, ma voglio generalizzare qui.)

Ad esempio, un'interfaccia può essere qualcosa come IDisposable, IEnumerableo IPrintable. Una classe è un'implementazione effettiva di una o più di queste interfacce: Listo Mapentrambe possono essere implementazioni di IEnumerable.

Per capire il punto: spesso le tue lezioni dipendono l'una dall'altra. Ad esempio potresti avere una Databaseclasse che accede al tuo database (ah, sorpresa! ;-)), ma vuoi anche che questa classe effettui il log per accedere al database. Supponi di avere un'altra classe Logger, quindi Databaseabbia una dipendenza Logger.

Fin qui tutto bene.

Puoi modellare questa dipendenza all'interno della tua Databaseclasse con la seguente riga:

var logger = new Logger();

e tutto va bene. Va bene fino al giorno in cui ti rendi conto che hai bisogno di un sacco di logger: a volte vuoi accedere alla console, a volte al file system, a volte utilizzando TCP / IP e un server di log remoto, e così via ...

E ovviamente NON vuoi cambiare tutto il tuo codice (nel frattempo ne hai milioni) e sostituire tutte le righe

var logger = new Logger();

di:

var logger = new TcpLogger();

Innanzitutto, non è divertente. In secondo luogo, questo è soggetto a errori. Terzo, questo è un lavoro stupido e ripetitivo per una scimmia addestrata. Allora cosa fai?

Ovviamente è una buona idea introdurre un'interfaccia ICanLog(o simile) implementata da tutti i vari logger. Quindi il passaggio 1 nel tuo codice è che lo fai:

ICanLog logger = new Logger();

Ora l'inferenza del tipo non cambia più tipo, hai sempre un'unica interfaccia su cui sviluppare. Il prossimo passo è che non vuoi avere new Logger()più e più volte. Quindi metti l'affidabilità per creare nuove istanze in una singola classe di fabbrica centrale e ottieni codice come:

ICanLog logger = LoggerFactory.Create();

La fabbrica stessa decide quale tipo di logger creare. Al tuo codice non importa più, e se vuoi cambiare il tipo di logger in uso, lo cambi una volta : All'interno della fabbrica.

Ora, ovviamente, puoi generalizzare questa fabbrica e farla funzionare per qualsiasi tipo:

ICanLog logger = TypeFactory.Create<ICanLog>();

Da qualche parte questo TypeFactory necessita di dati di configurazione su quale classe effettiva creare un'istanza quando viene richiesto un tipo di interfaccia specifico, quindi è necessario un mapping. Ovviamente puoi fare questa mappatura all'interno del tuo codice, ma poi un cambio di tipo significa ricompilare. Ma potresti anche inserire questa mappatura in un file XML, ad es. Questo ti permette di cambiare la classe effettivamente usata anche dopo il tempo di compilazione (!), Ciò significa dinamicamente, senza ricompilare!

Per darti un utile esempio per questo: pensa a un software che non si registra normalmente, ma quando il tuo cliente chiama e chiede aiuto perché ha un problema, tutto ciò che gli invii è un file di configurazione XML aggiornato, e ora ha registrazione abilitata e il tuo supporto può utilizzare i file di registro per aiutare il tuo cliente.

E ora, quando sostituisci un po 'i nomi, finisci con una semplice implementazione di un Service Locator , che è uno dei due schemi per Inversion of Control (poiché inverti il ​​controllo su chi decide quale classe esatta istanziare).

Tutto sommato ciò riduce le dipendenze nel codice, ma ora tutto il codice ha una dipendenza dal localizzatore di servizi singolo centrale.

L'iniezione di dipendenze è ora il passo successivo in questa linea: basta sbarazzarsi di questa singola dipendenza dal localizzatore di servizi: invece di varie classi che chiedono al localizzatore di servizi un'implementazione per una specifica interfaccia, tu - ancora una volta - ripristini il controllo su chi crea un'istanza di cosa .

Con l'iniezione di dipendenza, la tua Databaseclasse ora ha un costruttore che richiede un parametro di tipo ICanLog:

public Database(ICanLog logger) { ... }

Ora il tuo database ha sempre un logger da usare, ma non sa più da dove provenga questo logger.

Ed è qui che entra in gioco un framework DI: configuri di nuovo i tuoi mapping e poi chiedi al tuo DI framework di creare un'istanza per te. Poiché la Applicationclasse richiede ICanPersistDataun'implementazione, Databaseviene iniettata un'istanza di - ma per questo deve prima creare un'istanza del tipo di logger per cui è configurata ICanLog. E così via ...

Quindi, per farla breve: l'iniezione di dipendenza è uno dei due modi per rimuovere le dipendenze dal codice. È molto utile per le modifiche di configurazione dopo il tempo di compilazione ed è un'ottima cosa per i test unitari (in quanto semplifica l'iniezione di stub e / o mock).

In pratica, ci sono cose che non puoi fare senza un localizzatore di servizi (ad esempio, se non sai in anticipo quante istanze hai bisogno di un'interfaccia specifica: un framework DI inietta sempre solo un'istanza per parametro, ma puoi chiamare un localizzatore di servizi all'interno di un loop, ovviamente), quindi molto spesso ogni framework DI fornisce anche un localizzatore di servizi.

Ma sostanzialmente, tutto qui.

PS: Quello che ho descritto qui è una tecnica chiamata iniezione del costruttore , c'è anche l' iniezione di proprietà in cui non sono i parametri del costruttore, ma le proprietà vengono utilizzate per definire e risolvere le dipendenze. Pensa all'iniezione di proprietà come una dipendenza opzionale e all'iniezione del costruttore come dipendenze obbligatorie. Ma la discussione su questo va oltre lo scopo di questa domanda.


7
Ovviamente puoi farlo anche in questo modo, ma poi devi implementare questa logica in ogni singola classe che fornirà supporto per la scambiabilità dell'implementazione. Ciò significa un sacco di codice duplicato e ridondante, e questo significa anche che è necessario toccare una classe esistente e riscriverla parzialmente, una volta che si decide di averne bisogno ora. DI ti permette di usarlo su qualsiasi classe arbitraria, non devi scriverle in un modo speciale (tranne che per definire le dipendenze come parametri nel costruttore).
Golo Roden,

138
Ecco la cosa che non capisco mai di DI: rende l'architettura molto più complicata. Eppure, a mio modo di vedere, l'uso è piuttosto limitato. Gli esempi sono sicuramente sempre gli stessi: logger intercambiabili, modello intercambiabile / accesso ai dati. Vista a volte intercambiabile. Ma questo è tutto. Questi pochi casi giustificano davvero un'architettura software molto più complessa? - Completa divulgazione: ho già usato DI con grande efficacia, ma era per un'architettura plug-in molto speciale da cui non avrei generalizzato.
Konrad Rudolph,

17
@GoloRoden, perché chiami l'interfaccia ICanLog invece di ILogger? Ho lavorato con un altro programmatore che lo faceva spesso e non riuscivo mai a capire la convenzione? Per me è come chiamare IEnumerable ICanEnumerate?
DermFrench,

28
L'ho chiamato ICanLog, perché lavoriamo troppo spesso con parole (nomi) che non significano nulla. Ad esempio, cos'è un broker? Un manager? Anche un repository non è definito in un modo unico. E avere tutte queste cose come nomi è una malattia tipica delle lingue OO (vedi steve-yegge.blogspot.de/2006/03/… ). Quello che voglio esprimere è che ho un componente che può fare il logging per me - quindi perché non chiamarlo in quel modo? Naturalmente, questo sta giocando anche con l'Io come prima persona, quindi ICanLog (ForYou).
Golo Roden,

18
@David Unit testing funziona bene - dopotutto, un'unità è indipendente da altre cose (altrimenti non è un'unità). Ciò che non funziona senza i contenitori DI è un test di simulazione. Abbastanza giusto, non sono convinto che il vantaggio del deridere superi la complessità aggiunta dell'aggiunta di contenitori DI in tutti i casi. Faccio rigorosi test unitari. Raramente faccio beffe.
Konrad Rudolph,

499

Penso che molte volte le persone si confondano sulla differenza tra l' iniezione di dipendenza e un framework di iniezione di dipendenza (o un contenitore come viene spesso chiamato).

L'iniezione di dipendenza è un concetto molto semplice. Invece di questo codice:

public class A {
  private B b;

  public A() {
    this.b = new B(); // A *depends on* B
  }

  public void DoSomeStuff() {
    // Do something with B here
  }
}

public static void Main(string[] args) {
  A a = new A();
  a.DoSomeStuff();
}

scrivi codice in questo modo:

public class A {
  private B b;

  public A(B b) { // A now takes its dependencies as arguments
    this.b = b; // look ma, no "new"!
  }

  public void DoSomeStuff() {
    // Do something with B here
  }
}

public static void Main(string[] args) {
  B b = new B(); // B is constructed here instead
  A a = new A(b);
  a.DoSomeStuff();
}

E questo è tutto. Sul serio. Questo ti offre molti vantaggi. Due importanti sono la capacità di controllare la funzionalità da un posto centrale (la Main()funzione) invece di diffonderla in tutto il programma e la possibilità di testare più facilmente ogni classe in isolamento (perché puoi invece passare beffe o altri oggetti falsi nel suo costruttore di un valore reale).

Lo svantaggio, ovviamente, è che ora hai una mega-funzione che conosce tutte le classi utilizzate dal tuo programma. Ecco cosa possono aiutare i framework DI. Ma se hai difficoltà a capire perché questo approccio è prezioso, ti consiglio di iniziare con l'iniezione manuale delle dipendenze, in modo da poter apprezzare meglio ciò che i vari framework là fuori possono fare per te.


7
Perché dovrei preferire il secondo codice piuttosto che il primo? il primo ha solo la nuova parola chiave, come può essere d'aiuto?
user962206

17
@ user962206 pensa a come testeresti A indipendentemente da B
jk.

66
@ user962206, inoltre, pensa a cosa accadrebbe se B avesse bisogno di alcuni parametri nel suo costruttore: per poterlo istanziare, A dovrebbe conoscere quei parametri, qualcosa che potrebbe essere completamente estraneo ad A (vuole solo dipendere da B , non da cosa B dipende). Passare una B già costruita (o qualsiasi sottoclasse o beffa di B per quella materia) al costruttore di A risolve ciò e fa A dipendere solo da B :)
epidemia

17
@ acidzombie24: Come molti modelli di progettazione, DI non è davvero utile a meno che la tua base di codice non sia sufficientemente grande da rendere un semplice approccio un problema. La mia impressione è che DI non sarà effettivamente un miglioramento fino a quando l'applicazione non avrà più di circa 20.000 righe di codice e / o più di 20 dipendenze da altre librerie o framework. Se la tua applicazione è più piccola di quella, potresti comunque preferire programmare in uno stile DI, ma la differenza non sarà altrettanto drammatica.
Daniel Pryden,

2
@DanielPryden non credo che la dimensione del codice sia importante quanto la dinamica del tuo codice. se aggiungi regolarmente nuovi moduli che si adattano alla stessa interfaccia, non dovrai cambiare il codice dipendente con la stessa frequenza.
FistOfFury

35

Come affermato dalle altre risposte, l'iniezione delle dipendenze è un modo per creare dipendenze al di fuori della classe che la utilizza. Li inietti dall'esterno e prendi il controllo della loro creazione dall'interno della tua classe. Questo è anche il motivo per cui l'iniezione di dipendenza è una realizzazione del principio Inversion of control (IoC).

IoC è il principio, dove DI è il modello. Il motivo per cui potresti "aver bisogno di più di un logger" non è mai effettivamente soddisfatto, per quanto riguarda la mia esperienza, ma il vero motivo è che ne hai davvero bisogno, ogni volta che provi qualcosa. Un esempio:

La mia caratteristica:

Quando guardo un'offerta, desidero contrassegnare che l'ho guardata automaticamente, in modo da non dimenticare di farlo.

Puoi testarlo in questo modo:

[Test]
public void ShouldUpdateTimeStamp
{
    // Arrange
    var formdata = { . . . }

    // System under Test
    var weasel = new OfferWeasel();

    // Act
    var offer = weasel.Create(formdata)

    // Assert
    offer.LastUpdated.Should().Be(new DateTime(2013,01,13,13,01,0,0));
}

Quindi da qualche parte nel OfferWeasel, ti costruisce un'offerta Oggetto come questo:

public class OfferWeasel
{
    public Offer Create(Formdata formdata)
    {
        var offer = new Offer();
        offer.LastUpdated = DateTime.Now;
        return offer;
    }
}

Il problema qui è che questo test molto probabilmente fallirà sempre, poiché la data che si sta impostando differirà dalla data che viene asserita, anche se hai appena inserito DateTime.Nowil codice del test potrebbe essere spento di un paio di millisecondi e quindi sempre fallire. Una soluzione migliore ora sarebbe quella di creare un'interfaccia per questo, che ti consenta di controllare a che ora verrà impostato:

public interface IGotTheTime
{
    DateTime Now {get;}
}

public class CannedTime : IGotTheTime
{
    public DateTime Now {get; set;}
}

public class ActualTime : IGotTheTime
{
    public DateTime Now {get { return DateTime.Now; }}
}

public class OfferWeasel
{
    private readonly IGotTheTime _time;

    public OfferWeasel(IGotTheTime time)
    {
        _time = time;
    }

    public Offer Create(Formdata formdata)
    {
        var offer = new Offer();
        offer.LastUpdated = _time.Now;
        return offer;
    }
}

L'interfaccia è l'astrazione. Uno è la cosa REALE e l'altro ti permette di fingere un po 'di tempo dove è necessario. Il test può quindi essere modificato in questo modo:

[Test]
public void ShouldUpdateTimeStamp
{
    // Arrange
    var date = new DateTime(2013, 01, 13, 13, 01, 0, 0);
    var formdata = { . . . }

    var time = new CannedTime { Now = date };

    // System under test
    var weasel= new OfferWeasel(time);

    // Act
    var offer = weasel.Create(formdata)

    // Assert
    offer.LastUpdated.Should().Be(date);
}

In questo modo, hai applicato il principio "inversione del controllo", iniettando una dipendenza (ottenendo l'ora corrente). Il motivo principale per farlo è per un più semplice test unitario isolato, ci sono altri modi per farlo. Ad esempio, un'interfaccia e una classe qui non sono necessarie poiché in C # le funzioni possono essere passate come variabili, quindi al posto di un'interfaccia è possibile utilizzare a Func<DateTime>per ottenere lo stesso. Oppure, se segui un approccio dinamico, devi semplicemente passare qualsiasi oggetto che abbia il metodo equivalente ( digitando anatra ) e non hai bisogno di un'interfaccia.

Non avrai quasi mai bisogno di più di un logger. Tuttavia, l'iniezione di dipendenza è essenziale per codice tipicamente statico come Java o C #.

E ... Va anche notato che un oggetto può adempiere correttamente al suo scopo solo in fase di esecuzione, se tutte le sue dipendenze sono disponibili, quindi non è molto utile nella configurazione dell'iniezione di proprietà. Secondo me, tutte le dipendenze dovrebbero essere soddisfatte quando viene chiamato il costruttore, quindi l'iniezione del costruttore è la cosa giusta da fare.

Spero che ciò abbia aiutato.


4
In realtà sembra una soluzione terribile. Scriverei sicuramente un codice più simile a quello suggerito dalla risposta di Daniel Pryden, ma per quel test unitario specifico farei semplicemente DateTime. Adesso prima e dopo la funzione e controlla se è il tempo? Aggiungere più interfacce / molte più righe di codice mi sembra una cattiva idea.

3
Non mi piacciono gli esempi A (B) generici e non ho mai sentito che un logger deve avere 100 implementazioni. Questo è un esempio che ho riscontrato di recente ed è uno dei 5 modi per risolverlo, in cui uno è stato effettivamente incluso utilizzando PostSharp. Illustra un approccio di iniezione ctor classico di classe. Potresti fornire un migliore esempio del mondo reale di dove hai incontrato un buon uso di DI?
cessatore

2
Non ho mai visto un buon uso di DI. Ecco perché ho scritto la domanda.

2
Non l'ho trovato utile. Il mio codice è sempre facile da testare. Sembra che DI sia buono per basi di codice di grandi dimensioni con codice errato.

1
Tieni presente che anche in piccoli programmi funzionali, ogni volta che hai f (x), g (f) stai già usando l'iniezione di dipendenza, quindi ogni continuazione in JS conta come un'iniezione di dipendenza. La mia ipotesi è che lo stai già utilizzando;)
cessatore

15

Penso che la risposta classica sia quella di creare un'applicazione più disaccoppiata, che non ha conoscenza di quale implementazione verrà utilizzata durante il runtime.

Ad esempio, siamo un fornitore di pagamenti centrale, che collabora con molti fornitori di servizi di pagamento in tutto il mondo. Tuttavia, quando viene effettuata una richiesta, non ho idea di quale processore di pagamento chiamerò. Potrei programmare una lezione con una tonnellata di custodie, come:

class PaymentProcessor{

    private String type;

    public PaymentProcessor(String type){
        this.type = type;
    }

    public void authorize(){
        if (type.equals(Consts.PAYPAL)){
            // Do this;
        }
        else if(type.equals(Consts.OTHER_PROCESSOR)){
            // Do that;
        }
    }
}

Ora immagina che ora dovrai mantenere tutto questo codice in una singola classe perché non è correttamente disaccoppiato, puoi immaginare che per ogni nuovo processore che supporterai, dovrai creare un nuovo caso if // switch per ogni metodo, questo diventa solo più complicato, tuttavia, usando Dependency Injection (o Inversion of Control - come viene talvolta chiamato, il che significa che chiunque controlla l'esecuzione del programma è noto solo in fase di esecuzione e non complicazioni), potresti ottenere qualcosa molto pulito e mantenibile.

class PaypalProcessor implements PaymentProcessor{

    public void authorize(){
        // Do PayPal authorization
    }
}

class OtherProcessor implements PaymentProcessor{

    public void authorize(){
        // Do other processor authorization
    }
}

class PaymentFactory{

    public static PaymentProcessor create(String type){

        switch(type){
            case Consts.PAYPAL;
                return new PaypalProcessor();

            case Consts.OTHER_PROCESSOR;
                return new OtherProcessor();
        }
    }
}

interface PaymentProcessor{
    void authorize();
}

** Il codice non verrà compilato, lo so :)


+1 perché sembra che tu stia dicendo che ti serve dove usi metodi / interfacce virtuali. Ma è ancora raro. Lo passerei comunque come se new ThatProcessor()preferissi un framework

@ItaiS È possibile evitare gli innumerevoli interruttori con il modello di progettazione di fabbrica di classe. Usa reflection System.Reflection.Assembly.GetExecutingAssembly (). CreateInstance ()
domenicr

@domenicr ofcourse! ma volevo spiegarlo in un esempio semplificato
Itai Sagi,

Sono d'accordo con la spiegazione sopra, tranne la necessità di una classe di fabbrica. Nel momento in cui implementiamo la classe di fabbrica è semplicemente una selezione rozza. La migliore spiegazione di cui sopra che ho trovato nel capitolo del polimorfismo e della funzione virtuale di Bruce Erkel. Il vero DI dovrebbe essere libero dalla selezione e il tipo di oggetto dovrebbe essere deciso in fase di esecuzione automaticamente tramite l'interfaccia. Questo è anche il vero comportamento polimorfico.
Arvind Krmar,

Ad esempio (come per c ++) abbiamo un'interfaccia comune che prende solo il riferimento alla classe base e implementa il comportamento della sua classe derivata senza selezione. void tune (Instrument & i) {i.play (middleC); } int main () {Flauto del vento; tune (flauto); } Lo strumento è la classe base, il vento ne deriva. Secondo c ++, la funzione virtuale rende possibile l'implementazione del comportamento della classe derivata attraverso un'interfaccia comune.
Arvind Krmar,

6

Il motivo principale per utilizzare DI è che si desidera mettere la responsabilità della conoscenza dell'implementazione in cui la conoscenza è lì. L'idea di DI è molto in linea con l'incapsulamento e il design per interfaccia. Se il front-end richiede alcuni dati dal back-end, non è importante per il front-end come il back-end risolva tale domanda. Dipende dal gestore della richiesta.

Questo è già comune in OOP da molto tempo. Molte volte creando pezzi di codice come:

I_Dosomething x = new Impl_Dosomething();

Lo svantaggio è che la classe di implementazione è ancora codificata, quindi ha il front-end la conoscenza dell'implementazione utilizzata. DI fa avanzare ulteriormente il design dell'interfaccia, che l'unica cosa che il front-end deve conoscere è la conoscenza dell'interfaccia. Tra DYI e DI è il modello di un localizzatore di servizi, perché il front-end deve fornire una chiave (presente nel registro del localizzatore di servizi) per consentire la risoluzione della sua richiesta. Esempio di localizzatore di servizio:

I_Dosomething x = ServiceLocator.returnDoing(String pKey);

DI esempio:

I_Dosomething x = DIContainer.returnThat();

Uno dei requisiti di DI è che il contenitore deve essere in grado di scoprire quale classe è l'implementazione di quale interfaccia. Quindi un contenitore DI richiede una progettazione fortemente tipizzata e una sola implementazione per ciascuna interfaccia contemporaneamente. Se hai bisogno di più implementazioni di un'interfaccia contemporaneamente (come una calcolatrice), hai bisogno del localizzatore di servizio o del modello di progettazione di fabbrica.

D (b) I: Iniezione delle dipendenze e progettazione per interfaccia. Questa restrizione non è tuttavia un problema pratico molto grande. Il vantaggio dell'uso di D (b) I è che serve la comunicazione tra il cliente e il fornitore. Un'interfaccia è una prospettiva su un oggetto o un insieme di comportamenti. Quest'ultimo è cruciale qui.

Preferisco la gestione dei contratti di servizio insieme a D (b) I nella codifica. Dovrebbero andare insieme. L'uso di D (b) I come soluzione tecnica senza amministrazione organizzativa dei contratti di servizio non è molto vantaggioso dal mio punto di vista, perché DI è quindi solo un ulteriore livello di incapsulamento. Ma quando puoi usarlo insieme all'amministrazione organizzativa, puoi davvero fare uso del principio organizzativo D (b) che offro. Può aiutarti a lungo termine a strutturare la comunicazione con il cliente e altri dipartimenti tecnici in argomenti come test, versioning e sviluppo di alternative. Quando hai un'interfaccia implicita come in una classe hardcoded, allora è molto meno comunicabile nel tempo rispetto a quando la rendi esplicita usando D (b) I. Tutto si riduce alla manutenzione, che è nel tempo e non alla volta. :-)


1
"L'inconveniente è che la classe di implementazione è ancora hardcoded" <- il più delle volte c'è solo un'implementazione e come ho detto non riesco a pensare al codice non-game che richiede un'interfaccia non già integrata (.NET ).

@ acidzombie24 Può essere ... ma confronta lo sforzo di implementare una soluzione usando DI dall'inizio allo sforzo di cambiare una soluzione non DI in seguito se hai bisogno di interfacce. Quasi sempre andrei con la prima opzione. È meglio pagare ora 100 $ invece di dover pagare 100.000 $ domani.
Golo Roden,

1
@GoloRoden In effetti, la manutenzione è il problema chiave usando tecniche come D (b) I. Questo è l'80% dei costi di un'applicazione. Un progetto in cui il comportamento richiesto è reso esplicito utilizzando le interfacce dall'inizio consente all'organizzazione di risparmiare molto tempo e denaro.
Loek Bergman,

Non capirò veramente fino a quando non dovrò pagarlo perché finora ho pagato $ 0 e finora ho ancora solo bisogno di pagare $ 0. Ma pago $ 0,05 per mantenere pulite ogni riga o funzione.
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.