IT knowledge base
CTRL+F per cercare la tua parola chiave

Il principio SOLID in Go

Saluti, Khabroviti, ho deciso di condividere con la community la traduzione abbastanza spesso (secondo le osservazioni personali) del citato post SOLID Go Design dal blog di Dave Cheney, che ho fatto per le mie esigenze, ma qualcuno ha detto che ho bisogno di Condividere. Forse per alcuni sarà utile.

Design SOLIDO Vai

Questo post si basa sul testo del keynote di GolangUK del 18 agosto 2016.
La registrazione della performance è disponibile su YouTube .

Quanti sono i programmatori Go nel mondo?

Quanti sono i programmatori Go nel mondo? Pensa al numero e tienilo in testa,
torneremo su questa domanda alla fine della conversazione.

Revisione del codice

Chi esegue la revisione del codice qui come parte del proprio lavoro? (la maggior parte del pubblico alza la mano, il che è rassicurante). Ok, perché stai facendo revisioni del codice? (qualcuno grida "per migliorare il codice")

Se la revisione del codice è necessaria per rilevare il codice errato, come fai a sapere se il codice che stai esaminando è buono o cattivo?

Ora va bene dire "questo codice è terribile" o "wow, questo codice è bellissimo", proprio come se dicessi "questo dipinto è bellissimo" o "questa stanza è bellissima", ma questi sono concetti soggettivi, e io sono cercando modi oggettivi, per parlare delle proprietà del codice buono o cattivo.

Codice errato

Quali potrebbero essere le proprietà del codice errato che puoi utilizzare durante la revisione?

  • Inflessibile . Il codice è inflessibile? Contiene un insieme rigido di tipi e parametri, rendendo difficile la modifica.
  • Fragile . Il codice è fragile? Il minimo cambiamento nel codebase causa il caos?
  • Immobile . Il codice è difficile da refactoring? È a una sequenza di tasti dall'importazione in loop?
  • Complicato . Questo codice esiste solo per il codice e non è troppo complicato?
  • Verboso . Usare il codice è faticoso? Puoi dire guardando il codice cosa sta cercando di fare?

Queste parole sono positive? Vorresti sentire queste parole durante la revisione del tuo codice?

Forse no.

Bel design

Ma questo è un miglioramento, ora possiamo dire qualcosa come "Non mi piace perché è troppo difficile da modificare", o "Non mi piace perché non posso dire cosa sta cercando di fare questo codice", ma cosa stai per avere una discussione positiva?

Non sarebbe fantastico se ci fosse un modo per descrivere le proprietà di un buon design, non solo un cattivo design, e poter ragionare in termini oggettivi?

SOLIDO

Nel 2002, Robert Martin ha pubblicato il suo libro Agile Software Development, Principles, Patterns and Practices . In esso, ha descritto cinque principi di progettazione del software riutilizzabile, che ha chiamato principi SOLID, un acronimo per i loro nomi.

  • Il principio della responsabilità esclusiva
  • Il principio di apertura/chiusura
  • Il principio di sostituzione di Barbara Liskov
  • Principio di separazione delle interfacce
  • Principio di inversione della dipendenza

Questo libro è un po' datato, le lingue di cui si parla erano in uso circa 10 anni fa. Ma forse ci sono alcuni aspetti del principio SOLID che possono fornirci indizi su come parlare di programmi Go ben progettati.

Questo è esattamente ciò di cui vorrei discutere con voi questa mattina.

Il principio della responsabilità esclusiva

Il primo principio di SOLID è S - il principio della responsabilità unica.

Una classe dovrebbe avere una e una sola ragione per cambiare.
-Robert S. Martin

Go non contiene alcuna classe, invece abbiamo un concetto di composizione molto più potente, ma se guardi alla storia dell'uso del concetto di classe, penso che abbia un senso.

Perché è così importante che un pezzo di codice abbia un solo motivo per cambiare? Bene, l'idea che il tuo codice possa cambiare è angosciosa, ma molto meno dolorosa dell'idea che anche il codice da cui dipende il tuo codice possa cambiare. E quando il tuo codice deve cambiare, dovrebbe farlo in base a un requisito specifico e non essere vittima di danni collaterali.

Quindi il codice che esegue una singola attività avrà meno motivi per cambiare.

Connettività e unità

Due parole che descrivono quanto sia facile apportare modifiche al tuo programma sono connettività e coesione.

La coesione è semplicemente un concetto che descrive un cambiamento simultaneo in due pezzi di codice, dove un cambiamento in un punto significa un cambiamento obbligatorio nell'altro.

Un concetto correlato ma separato, questa unità è il potere di attrazione reciproca.

Nel contesto del software, l'unità è una proprietà che descrive il fatto che le sezioni del codice sono naturalmente correlate tra loro.

Per descrivere l'implementazione dei principi di connettività e unità in un programma Go, potremmo parlare di funzioni e metodi, come spesso accade quando si parla di SRP (Single Responsibility Principle), ma credo che tutto inizi con il sistema di pacchetti in Go .

Nomi dei pacchetti

In Go, tutto il codice esiste all'interno dei pacchetti e un buon design del pacchetto inizia con il suo nome. Il nome del pacchetto è sia una descrizione del suo scopo che un prefisso dello spazio dei nomi. Esempi di buoni nomi di pacchetti dalla libreria standard Go includono:

  • net / http , che fornisce un client e un server http.
  • os / exec che esegue comandi esterni.
  • encoding / json , che implementa la codifica e la decodifica dei documenti JSON.

Quando usi i simboli di un altro pacchetto all'interno del tuo, questo viene fatto usando la parola chiaveimport che stabilisce la connettività a livello di sorgente tra due pacchetti. Ora sanno dell'esistenza l'uno dell'altro.

Nomi di pacchetti errati

Questa attenzione alla denominazione non è solo pedanteria. I pacchetti mal denominati perdono l'opportunità di descrivere il loro compito, anche se ce l'hanno.

Che opportunità hapackage server ?..potrebbe essere un server, ma che protocollo implementa?

Che opportunità hapackage private ? Cose che non dovrei vedere? Dovrebbe avere dei simboli pubblici?

Epackage common , esattamente come il suo complicepackage utils sono spesso vicini ad altri delinquenti odiosi.

Portare pacchetti come questo rende il codice un dump perché hanno molte responsabilità e spesso cambiano senza motivo.

Philisophia UNIX a Go

Dal mio punto di vista, nessuna discussione sul design diviso sarebbe completa senza menzionare la Filosofia di UNIX di Douglas McLroy; strumenti piccoli e affilati che si combinano per risolvere problemi più grandi, spesso non previsti dagli autori originali. Penso che i pacchetti Go incarnino lo spirito della filosofia UNIX. In realtà, ogni pacchetto Go è esso stesso un piccolo programma Go, un unico punto di cambiamento con un'unica responsabilità.

Il principio di apertura/chiusura

Il secondo principio O è il principio aperto/chiuso di Bertrand Meyer, che scrisse nel 1988:

Gli oggetti del programma devono essere aperti per l'estensione e chiusi per la modifica.
-Bertrand Meyer, Costruire software orientato agli oggetti

In che modo questo consiglio si applicava alle lingue create 21 anni fa?

package main

type A struct {
        year int
}

func (a A) Greet() { fmt.Println("Hello GolangUK", a.year) }

type B struct {
        A
}

func (b B) Greet() { fmt.Println("Welcome to GolangUK", b.year) }

func main() {
        var a A
        a.year = 2016
        var b B
        b.year = 2016
        a.Greet() // Hello GolangUK 2016
        b.Greet() // Welcome to GolangUK 2016
}

Abbiamo un tipoA , con campoyear e metodoGreet ... Abbiamo il secondo tipoB che è incorporatoA , chiamate di metodoB chiamate di metodo sovrapposteA nella misura in cuiA incorporato come un campo inB eB offre il suo metodoGreet nasconderne uno simile inA ...

Ma l'inlining non è solo per i metodi, fornisce anche l'accesso ai campi di tipo inline. Come puoi vedere, poiché entrambiA eB definito in un unico pacchetto,B può accedere al campo privatoyear nelA come se fosse decisamente dentroB ...

Quindi l'inlining è uno strumento potente che consente ai tipi in Go di essere estensibili.

package main

type Cat struct {
        Name string
}

func (c Cat) Legs() int { return 4 }

func (c Cat) PrintLegs() {
        fmt.Printf("I have %d legs\n", c.Legs())
}

type OctoCat struct {
        Cat
}

func (o OctoCat) Legs() int { return 5 }

func main() {
        var octo OctoCat
        fmt.Println(octo.Legs()) // 5
        octo.PrintLegs()         // I have 4 legs
}

In questo esempio, abbiamo un tipoCat chi può contare il numero di gambe usando il suo metodoLegs ... Incorporiamo questo tipoCat in un nuovo tipoOctoCat e dichiaro cheOctocatS ha cinque gambe. in cuiOctoCat definisce il proprio metodoLegs che restituisce 5 quando viene chiamato il metodoPrintLegs , restituisce 4.

Questo è perchéPrintLegs definito all'interno del tipoCat ... lui accettaCat come destinatario e si riferisce al metodoLegs genereCat ...Cat deve essere consapevole del tipo in cui è stato inserito, quindi il suo metodo non può essere modificato da inline.

Da questo possiamo dire che i tipi in Go sono aperti per estensione e chiusi per modifica .

In effetti, i metodi in Go sono più che semplici zuccheri sintattici attorno a una funzione con parametri formali prefissati, sono ricevitori.

func (c Cat) PrintLegs() {
        fmt.Printf("I have %d legs\n", c.Legs())
}

func PrintLegs(c Cat) {
        fmt.Printf("I have %d legs\n", c.Legs())
}

Il ricevitore è esattamente ciò che ci passi dentro, il primo parametro della funzione e poiché Go non supporta il sovraccarico della funzione,OctoCat non intercambiabile con il tipo normaleCats ... Il che mi porta al mio prossimo principio.

Il principio di sostituzione di Barbara Liskov

Inventato da Barbara Liskov, il principio di sostituzione di Liskov afferma che due tipi sono intercambiabili se mostrano un comportamento tale che il chiamante non può dire la differenza.

In un linguaggio basato sulle classi, il principio di sostituzione di Liskov è spesso interpretato come una specifica per una classe astratta con vari sottotipi concreti. Ma Go non ha classi o ereditarietà, quindi la sostituzione non può essere implementata in termini di una gerarchia di classi astratta.

Interfacce

Invece, la sostituzione è di competenza delle interfacce in Go. In Go, i tipi non sono necessari per implementare un'interfaccia specifica; invece, qualsiasi tipo che implementa un'interfaccia contiene semplicemente un metodo la cui firma corrisponde alla dichiarazione dell'interfaccia.

Diciamo che in Go le interfacce sono soddisfatte implicitamente invece che esplicitamente, e questo ha un profondo effetto sul modo in cui vengono utilizzate nel linguaggio.

Un'interfaccia ben congegnata, è molto probabilmente una piccola interfaccia; l'idioma prevalente è che un'interfaccia contiene un solo metodo. È abbastanza logico che una piccola interfaccia contenga un'implementazione semplice, poiché è difficile fare diversamente. Da cui ne consegue che i pacchetti sono una soluzione di compromesso per semplici implementazioni associate a comportamenti comuni .

io.Reader

type Reader interface {
        // Read reads up to len(buf) bytes into buf.
        Read(buf []byte) (n int, err error)
}

Il che mi porta aio.Reader la mia interfaccia Go preferita.

Interfacciaio.Reader molto semplice;Read legge i dati nel buffer specificato e restituisce al codice chiamante il numero di byte letti e gli eventuali errori che possono verificarsi durante il processo di lettura. Sembra semplice, ma è molto potente.

Nella misura in cuiio.Reader si occupa di tutto ciò che può essere espresso come flusso di byte, possiamo costruire oggetti di lettura letteralmente da qualsiasi cosa; stringa costante, array di byte, stdin, flusso di rete, gzip tar, stdout o comando eseguito in remoto tramite ssh.

E tutte queste implementazioni sono intercambiabili perché soddisfano un semplice contratto.

Quindi il principio di sostituzione di Liskov è applicabile a Go, e quanto detto può essere riassunto nel bellissimo aforisma del compianto Jim Weirich:

Non chiedere di più, non prometti di meno.
-Jim Weirich

E questa è una grande transizione al quarto principio di SOLID.

Principio di separazione delle interfacce

Il quarto principio è il principio della separazione dell'interfaccia, che si legge come:

I client non dovrebbero essere costretti a dipendere da metodi che non usano.
-Robert S. Martin

In Go, l'applicazione del principio della separazione delle interfacce può essere intesa come il processo di isolamento del comportamento di una funzione che deve svolgere il proprio lavoro. Come esempio concreto, diciamo che ho il compito di scrivere una funzione che preservi la strutturaDocument su disco.

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

Posso definire una tale funzione. chiamiamolaSave , lei accetta*os.File come fonte per la registrazione di quanto fornitoDocument ... Ma qui ci sono diversi problemi.

FirmaSave impedisce la possibilità di scrivere dati a quale indirizzo sulla rete. Supponiamo che il NAS diventi un requisito in futuro e che la firma di questa funzione possa cambiare, interessando tutti coloro che lo chiamano.

Nella misura in cuiSave opera direttamente con i file sul disco, provarlo è piuttosto sgradevole. Per verificare le operazioni, i test devono leggere il contenuto del file dopo la scrittura. Inoltre, i test devono garantire chef è stato scritto nella memoria temporanea e viene sempre eliminato in seguito.

*os.File definisce anche molti metodi che non sono rilevantiSave come leggere le directory e controllare se il percorso è un collegamento simbolico. Sarebbe utile se la firma della nostra funzioneSave è stato descritto solo da quelle parti*os.File rilevanti per il suo compito.

Cosa possiamo fare per questi problemi?

// Save writes the contents of doc to the supplied ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error

Usandoio.ReadWriteCloser possiamo applicare il principio della separazione dell'interfaccia per l'overrideSave in modo che accetti un'interfaccia che descriva i compiti più generali delle operazioni sui file.

Con queste modifiche, qualsiasi tipo che implementa l'interfacciaio.ReadWriteCloser può essere sostituito dal precedente*os.File ... Questo rende l'applicazioneSave più ampio e spiega il chiamanteSave quale metodo di tipo*os.File attinenti all'operazione richiesta.

Come autoreSave Non dovrei più essere in grado di chiamare tutti i metodi irrilevanti da*os.File visto che sono nascosti dietroio.ReadWriteCloser interfaccia. Ma possiamo andare un po' oltre con il metodo di suddivisione dell'interfaccia.

Primo, è improbabileSave segue il principio della responsabilità unica, leggerà il file su cui ha appena scritto per verificarne il contenuto, che dovrebbe essere responsabilità di un altro pezzo di codice. Pertanto, possiamo restringere le specifiche dell'interfaccia a cui passiamoSave esclusivamente prima di aprire e chiudere il file.

// Save writes the contents of doc to the supplied WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error

In secondo luogo, fornendoSave con il meccanismo di chiusura del flusso, che abbiamo ereditato con il desiderio di renderlo simile al normale meccanismo per lavorare con un file, sorge la domanda in quali circostanzewc sarà chiuso. può essereSave causeràClose senza alcuna condizione, oClose verrà chiamato in caso di successo.

Tutto ciò rappresenta un problema per il chiamante.Save poiché potrebbe voler aggiungere ulteriori informazioni allo stream dopo che il documento è già stato scritto.

type NopCloser struct {
        io.Writer
}

// Close has no effect on the underlying writer.
func (c *NopCloser) Close() error { return nil }

Una decisione approssimativa sarebbe quella di definire un nuovo tipo che incorporiio.Writer e sovrascrive il metodoClose impedendo la chiamataSave da un flusso principale chiuso.

Ma molto probabilmente questa sarà una violazione del principio di sostituzione di Barbara Liskov, dal momento cheNopCloser in realtà non copre nulla.

// Save writes the contents of doc to the supplied Writer.
func Save(w io.Writer, doc *Document) error

Una soluzione molto migliore sarebbe quella di sovrascrivereSave prendendo soloio.Writer , privandolo completamente della capacità di fare altro che scrivere dati nel flusso.

Ma applicando il principio della separazione dell'interfaccia alla nostra funzioneSave , il risultato diventa contemporaneamente una funzione più specifica in termini di requisiti, l'unica cosa di cui ha bisogno è qualcosa dove scrivere e la cosa più importante in questa funzione è ciò che possiamo usareSave per salvare i nostri dati in qualsiasi luogo in cui è implementata l'interfacciaio.Writer ...

Un'importante regola pratica per Go è accettare interfacce e restituire strutture .
-Jack Lindamud

La citazione sopra è un meme interessante che ha permeato lo spirito di Go negli ultimi anni.

Questa versione del tweet standard manca di una sfumatura e questo non è colpa di Jack, ma penso che rappresenti uno dei motivi principali per il design del linguaggio Go.

Principio di inversione della dipendenza

Il principio SOLIDO finale è il Principio di inversione delle dipendenze, che afferma:

I moduli di livello superiore non dovrebbero dipendere da moduli di livello inferiore. Entrambi i livelli dovrebbero dipendere dalle astrazioni.
Le astrazioni non dovrebbero dipendere dai loro dettagli. I dettagli dovrebbero dipendere dall'astrazione.
-Robert S. Martin

Ma cosa significa in pratica l'inversione delle dipendenze per un programmatore Go?

Se applichi tutti i principi di cui abbiamo parlato fino a questo punto, il tuo codice dovrebbe già trovarsi in pacchetti discreti, ciascuno con una dipendenza o uno scopo singolo e ben definito. Il tuo codice dovrebbe descrivere le sue dipendenze in termini di interfacce e tali interfacce dovrebbero mirare a descrivere solo il comportamento richiesto da queste funzioni. In altre parole, non dovrebbe essere rimasto molto lavoro.

Quindi il modo in cui immagino ciò di cui Martin sta parlando qui, principalmente nel contesto di Go, è la struttura del tuo grafico di importazione.

In Go, il tuo grafico di importazione deve essere aciclico. Il tentativo di ignorare l'aciclicità risulterà in un errore di compilazione, ma un errore molto più grave potrebbe essere nell'architettura. A parità di condizioni, il grafico di importazione di un programma Go ben progettato dovrebbe essere ampio e relativamente piatto anziché alto e stretto. Se hai un pacchetto le cui funzioni non possono essere eseguite senza l'aiuto di un altro pacchetto, potrebbe essere un segnale che i confini del pacchetto non sono ben definiti.

Il principio di inversione delle dipendenze ti incoraggia a trasferire responsabilità specifiche il più in alto possibile nel grafico delle importazioni nel tuo pacchetto.main o al livello superiore del gestore, lasciando che il livello inferiore del codice si occupi di astrazioni e interfacce.

Design SOLIDO Vai

In sintesi, quando ciascuno dei principi SOLID viene applicato a Go, sono un potente strumento di progettazione, ma usati insieme, sono il tema principale.

Il Principio della Responsabilità Unica ti incoraggia a strutturare funzioni, tipi e metodi in pacchetti che sono naturalmente correlati; tipi e funzioni insieme servono a un unico scopo.

Il principio aperto/chiuso ti incoraggia a scendere a compromessi tra tipi più semplici e quelli più complessi attraverso l'uso dell'incorporamento.

Il principio di sostituzione di Barbara Liskov ti incoraggia a esprimere le dipendenze tra i tuoi pacchetti in termini di interfacce piuttosto che di tipi specifici. Definendo piccole interfacce, possiamo essere più sicuri che le implementazioni soddisferanno i loro contratti.

Il principio della separazione delle interfacce continua questa idea e ti incoraggia a definire funzioni e metodi che dipendono solo dal comportamento di cui hanno bisogno. Se le tue funzioni necessitano solo di un parametro del tipo di interfaccia con un singolo metodo, molto probabilmente quelle funzioni hanno un'unica responsabilità.

Il principio di inversione delle dipendenze ti incoraggia a spostare la conoscenza delle dipendenze del tuo pacchetto dal momento della compilazione, in Go lo vediamo con la riduzione del numero di importazioni utilizzate da un particolare pacchetto, nell'esecuzione del codice.

Se vuoi riassumere questa conversazione, molto probabilmente lo sarà: le interfacce ti consentono di applicare i principi di SOLID nei programmi Go .

Perché le interfacce consentono ai programmatori Go di descrivere le capacità dei loro pacchetti, piuttosto che un'implementazione specifica. Questo è solo un altro modo per dire "disaccoppiamento", che è l'obiettivo, poiché il codice ad accoppiamento libero è più facile da modificare.

Come ha sottolineato Sandy Metz:

Il design è l'arte di organizzare il codice che dovrebbe funzionare oggi ed essere facile da modificare in ogni momento.
-Sandy Metz

Perché se Go intende essere il linguaggio in cui le aziende investono a lungo termine, il fattore chiave nella loro decisione sarà quanto sia facile mantenere il codice Go e quanto sia facile cambiarlo.

Conclusione

In conclusione, torniamo alla domanda con cui ho aperto questa conversazione. Quanti sono i programmatori Go nel mondo? Ecco la mia ipotesi:

Ci saranno 500.000 sviluppatori Go nel 2020.
-Dave Cheney

Cosa faranno mezzo milione di programmatori Go con il loro tempo? Bene, ovviamente scriveranno molto codice Go e, se siamo onesti, non tutto il codice sarà buono, parte del codice sarà cattivo.

Per favore, comprendi che non sto cercando di essere crudele, ma ognuno di voi in questa stanza con esperienza che si sviluppa in altre lingue, le lingue da cui sei venuto a Go, sa per esperienza personale che c'è del vero in questa previsione .

C'è un linguaggio molto più pulito ed evolutivo in C ++ che sta cercando di uscire.
-Bjorn Stroustrup, C++ Design ed Evoluzione

L'opportunità di aiutare il nostro linguaggio ad avere successo per ogni programmatore sta nel non creare il tipo di confusione di cui le persone iniziano a parlare quando oggi scherzano sul C++.

I racconti che prendono in giro altre lingue per essere un giorno gonfi, prolissi e sovraccarichi possono essere applicati a Go e non voglio che ciò accada, quindi ho una richiesta.

I programmatori di Go devono smettere di parlare di framework e iniziare a parlare di più di design. Dobbiamo smettere di concentrarci sulle prestazioni a tutti i costi e concentrarci invece sul non riutilizzare a tutti i costi.

Mi piacerebbe vedere le persone oggi parlare di come usare il linguaggio che abbiamo, indipendentemente dalle loro scelte e limiti, per creare soluzioni e risolvere problemi reali.

Mi piacerebbe sentire oggi come le persone parlano della progettazione di programmi Go in modo tale che siano ben progettati, disaccoppiati, riutilizzabili e reattivi al cambiamento.

...e un altro dettaglio

È fantastico ora che così tante persone siano venute oggi per ascoltare un così grande cast di oratori, ma la realtà è che non importa quanto cresca questa conferenza, rispetto al numero totale di persone che usano Go per tutta la sua vita, siamo solo piccola parte.

Quindi il nostro compito è dire al resto del mondo come dovrebbe essere scritto un buon software. Buon software, compatibile, modificabile e mostra loro come farlo usando Go. E inizia con te.

Voglio che inizi a parlare di design, magari usi alcune delle idee che ho presentato qui, spero che tu faccia le tue ricerche e applichi queste idee al tuo progetto. Allora voglio che tu:

-Ha scritto un post sul blog su di esso.
- Raccontaci al seminario di quello che hai fatto.
-Scrivi un libro su ciò che hai imparato.
E torna a questa conferenza l'anno prossimo e condividi ciò che hai raggiunto.

Facendo tutto questo, possiamo formare un ecosistema di sviluppatori Go che si prendono cura del loro software, progettato per continuare a funzionare.

Grazie.


Post originale di Dave Cheney