Wir beschäftigen uns mit Schnittstellen in Go


In den letzten Monaten habe ich eine Studie durchgeführt, in der Menschen gefragt wurden, was für sie in Go schwer zu verstehen ist. Und mir ist aufgefallen, dass das Konzept der Schnittstellen in den Antworten regelmäßig erwähnt wurde. Go war die erste Schnittstellensprache, die ich verwendet habe, und ich erinnere mich, dass dieses Konzept zu dieser Zeit sehr verwirrend schien. Und in diesem Handbuch möchte ich Folgendes tun:

  1. In menschlicher Sprache erklären, was Schnittstellen sind.
  2. Erklären Sie, wie nützlich sie sind und wie Sie sie in Ihrem Code verwenden können.
  3. Sprechen Sie darüber, was interface{} (eine leere Schnittstelle).
  4. Gehen Sie einige nützliche Schnittstellentypen durch, die Sie in der Standardbibliothek finden.

Was ist eine Schnittstelle?


Der Schnittstellentyp in Go ist eine Art Definition . Es definiert und beschreibt die spezifischen Methoden, die ein anderer Typ haben sollte .

Einer der Schnittstellentypen aus der Standardbibliothek ist die Schnittstelle fmt.Stringer :

 type Stringer interface { String() string } 

Wir sagen, dass etwas diese Schnittstelle erfüllt (oder diese Schnittstelle implementiert ), wenn dieses „Etwas“ eine Methode mit einem bestimmten Signaturzeichenfolgenwert String() .

Beispielsweise erfüllt der Buchtyp die Schnittstelle, da er über die String() String-Methode verfügt:

 type Book struct { Title string Author string } func (b Book) String() string { return fmt.Sprintf("Book: %s - %s", b.Title, b.Author) } 

Es spielt keine Rolle, welcher Typ das Book oder was es tut. Alles was zählt ist, dass es eine Methode namens String() gibt, die einen String-Wert zurückgibt.

Hier ist ein weiteres Beispiel. Der Count Typ erfüllt auch die Schnittstelle fmt.Stringer , da er über eine Methode mit demselben Signaturzeichenfolgenwert String() .

 type Count int func (c Count) String() string { return strconv.Itoa(int(c)) } 

Es ist wichtig zu verstehen, dass wir zwei verschiedene Arten von Book und Count , die unterschiedlich wirken. Sie sind sich jedoch dadurch einig, dass beide die fmt.Stringer Schnittstelle erfüllen.

Sie können es von der anderen Seite betrachten. Wenn Sie wissen, dass das Objekt die Schnittstelle fmt.Stringer erfüllt, können Sie davon ausgehen, dass es eine Methode mit dem Signaturzeichenfolgenwert String() , den Sie aufrufen können.

Und jetzt das Wichtigste.

Wenn in Go eine Deklaration (einer Variablen, eines Funktionsparameters oder eines Strukturfelds) mit einem Schnittstellentyp angezeigt wird, können Sie ein Objekt eines beliebigen Typs verwenden, solange es die Schnittstelle erfüllt.

Nehmen wir an, wir haben eine Funktion:

 func WriteLog(s fmt.Stringer) { log.Println(s.String()) } 

Da WriteLog() in der Parameterdeklaration den Schnittstellentyp fmt.Stringer , können wir jedes Objekt übergeben, das die Schnittstelle fmt.Stringer erfüllt. Beispielsweise können wir die zuvor in der WriteLog() -Methode erstellten WriteLog() und Count Typen übergeben, und der Code funktioniert WriteLog() .

Da das übergebene Objekt die Schnittstelle fmt.Stringer erfüllt, wissen wir außerdem , dass es eine String() -Methode hat, die von der WriteLog() -Funktion sicher aufgerufen werden kann.

Lassen Sie uns alles in einem Beispiel zusammenfassen und die Leistungsfähigkeit von Schnittstellen demonstrieren.

 package main import ( "fmt" "strconv" "log" ) //   Book,    fmt.Stringer. type Book struct { Title string Author string } func (b Book) String() string { return fmt.Sprintf("Book: %s - %s", b.Title, b.Author) } //   Count,    fmt.Stringer. type Count int func (c Count) String() string { return strconv.Itoa(int(c)) } //   WriteLog(),    , //   fmt.Stringer   . func WriteLog(s fmt.Stringer) { log.Println(s.String()) } func main() { //   Book    WriteLog(). book := Book{"Alice in Wonderland", "Lewis Carrol"} WriteLog(book) //   Count    WriteLog(). count := Count(3) WriteLog(count) } 

Das ist cool. In der Hauptfunktion haben wir verschiedene Arten von Book und Count , diese jedoch an dieselbe WriteLog() Funktion übergeben. Und sie rief die entsprechenden String() -Funktionen auf und schrieb die Ergebnisse in das Protokoll.

Wenn Sie den Code ausführen , erhalten Sie ein ähnliches Ergebnis:

 2009/11/10 23:00:00 Book: Alice in Wonderland - Lewis Carrol 2009/11/10 23:00:00 3 

Wir werden nicht im Detail darauf eingehen. Das Wichtigste, das Sie WriteLog() : Mit dem Schnittstellentyp in der Deklaration der WriteLog() -Funktion haben wir die Funktion für den Typ des empfangenen Objekts gleichgültig (oder flexibel) gemacht. Was zählt, ist, welche Methoden er hat .

Was sind nützliche Schnittstellen?


Es gibt eine Reihe von Gründen, warum Sie Schnittstellen in Go verwenden können. Und meiner Erfahrung nach sind die wichtigsten:

  1. Schnittstellen helfen dabei, Doppelarbeit zu reduzieren, dh die Menge an Boilerplate-Code.
  2. Sie erleichtern die Verwendung von Stubs in Komponententests anstelle von realen Objekten.
  3. Als Architekturwerkzeug helfen Schnittstellen dabei, Teile Ihrer Codebasis zu lösen.

Schauen wir uns diese Möglichkeiten der Verwendung von Schnittstellen genauer an.

Reduzieren Sie die Menge des Boilerplate-Codes


Angenommen, wir haben eine Customer , die Kundendaten enthält. In einem Teil des Codes möchten wir diese Informationen in bytes.Buffer schreiben, und im anderen Teil möchten wir Clientdaten in os.File auf der Festplatte schreiben. In beiden Fällen möchten wir jedoch zuerst die ustomer Struktur in JSON serialisieren.

In diesem Szenario können wir die Menge an Boilerplate-Code mithilfe von Go-Schnittstellen reduzieren.

Go hat einen io.Writer- Schnittstellentyp:

 type Writer interface { Write(p []byte) (n int, err error) } 

Und wir können die Tatsache ausnutzen, dass bytes.Buffer und der Typ os.File diese Schnittstelle erfüllen, da sie über die Methoden bytes.Buffer.Write () bzw. os.File.Write () verfügen.

Einfache Implementierung:

 package main import ( "encoding/json" "io" "log" "os" ) //   Customer. type Customer struct { Name string Age int } //   WriteJSON,   io.Writer   . //    ustomer  JSON,     // ,     Write()  io.Writer. func (c *Customer) WriteJSON(w io.Writer) error { js, err := json.Marshal(c) if err != nil { return err } _, err = w.Write(js) return err } func main() { //   Customer. c := &Customer{Name: "Alice", Age: 21} //    Buffer    WriteJSON var buf bytes.Buffer err := c.WriteJSON(buf) if err != nil { log.Fatal(err) } //   . f, err := os.Create("/tmp/customer") if err != nil { log.Fatal(err) } defer f.Close() err = c.WriteJSON(f) if err != nil { log.Fatal(err) } } 

Dies ist natürlich nur ein fiktives Beispiel (wir können den Code unterschiedlich strukturieren, um das gleiche Ergebnis zu erzielen). Die Vorteile der Verwendung von Schnittstellen werden jedoch gut veranschaulicht: Wir können die Customer.WriteJSON() -Methode einmal erstellen und jedes Mal aufrufen, wenn wir auf etwas schreiben müssen, das die io.Writer Schnittstelle erfüllt.

Wenn Sie Go noch nicht kennen, werden Sie einige Fragen haben: „ Woher weiß ich, ob die io.Writer-Oberfläche überhaupt vorhanden ist? Und woher weißt du im Voraus, dass er zufrieden ist bytes.Buffer und os.File ? ""

Ich fürchte, es gibt keine einfache Lösung. Sie müssen nur Erfahrungen sammeln, sich mit den Schnittstellen und verschiedenen Typen aus der Standardbibliothek vertraut machen. Dies hilft beim Lesen der Dokumentation für diese Bibliothek und beim Anzeigen des Codes einer anderen Person. Zum schnellen Nachschlagen habe ich am Ende des Artikels die nützlichsten Arten von Schnittstellentypen hinzugefügt.

Aber selbst wenn Sie keine Schnittstellen aus der Standardbibliothek verwenden, hindert Sie nichts daran, eigene Schnittstellentypen zu erstellen und zu verwenden. Wir werden weiter unten darüber sprechen.

Unit Testing und Stubs


Schauen wir uns ein komplexeres Beispiel an, um zu verstehen, wie Schnittstellen beim Testen von Einheiten helfen.

Angenommen, Sie haben ein Geschäft und Geschäftsinformationen über Verkäufe und die Anzahl der Kunden in PostgreSQL. Sie möchten einen Code schreiben, der den Umsatzanteil (bestimmte Anzahl der Verkäufe pro Kunde) für den letzten Tag berechnet und auf zwei Dezimalstellen gerundet wird.

Eine minimale Implementierung würde folgendermaßen aussehen:

 // : main.go package main import ( "fmt" "log" "time" "database/sql" _ "github.com/lib/pq" ) type ShopDB struct { *sql.DB } func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count) return count, err } func (sdb *ShopDB) CountSales(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count) return count, err } func main() { db, err := sql.Open("postgres", "postgres://user:pass@localhost/db") if err != nil { log.Fatal(err) } defer db.Close() shopDB := &ShopDB{db} sr, err := calculateSalesRate(shopDB) if err != nil { log.Fatal(err) } fmt.Printf(sr) } func calculateSalesRate(sdb *ShopDB) (string, error) { since := time.Now().Sub(24 * time.Hour) sales, err := sdb.CountSales(since) if err != nil { return "", err } customers, err := sdb.CountCustomers(since) if err != nil { return "", err } rate := float64(sales) / float64(customers) return fmt.Sprintf("%.2f", rate), nil } 

Jetzt möchten wir einen Komponententest für die Funktion calculateSalesRate() erstellen, um zu überprüfen, ob die Berechnungen korrekt sind.

Jetzt ist es problematisch. Wir müssen eine Testinstanz von PostgreSQL konfigurieren sowie Skripte erstellen und löschen, um die Datenbank mit gefälschten Daten zu füllen. Wir haben viel zu tun, wenn wir unsere Berechnungen wirklich testen wollen.

Und die Schnittstellen kommen zur Rettung!

Wir werden unseren eigenen Schnittstellentyp erstellen, der die Methoden CountSales() und CountCustomers() , auf die sich die Funktion calculateSalesRate() stützt. Aktualisieren Sie dann die Signatur calculateSalesRate() , um diesen Schnittstellentyp als Parameter anstelle des vorgeschriebenen *ShopDB Typs zu verwenden.

So:

 // : main.go package main import ( "fmt" "log" "time" "database/sql" _ "github.com/lib/pq" ) //    ShopModel.     //     ,     //  -,     . type ShopModel interface { CountCustomers(time.Time) (int, error) CountSales(time.Time) (int, error) } //  ShopDB    ShopModel,   //       -- CountCustomers()  CountSales(). type ShopDB struct { *sql.DB } func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count) return count, err } func (sdb *ShopDB) CountSales(since time.Time) (int, error) { var count int err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count) return count, err } func main() { db, err := sql.Open("postgres", "postgres://user:pass@localhost/db") if err != nil { log.Fatal(err) } defer db.Close() shopDB := &ShopDB{db} sr := calculateSalesRate(shopDB) fmt.Printf(sr) } //       ShopModel    //    *ShopDB. func calculateSalesRate(sm ShopModel) string { since := time.Now().Sub(24 * time.Hour) sales, err := sm.CountSales(since) if err != nil { return "", err } customers, err := sm.CountCustomers(since) if err != nil { return "", err } rate := float64(sales) / float64(customers) return fmt.Sprintf("%.2f", rate), nil } 

Nachdem wir dies getan haben, erstellen wir einfach einen Stub, der die ShopModel Oberfläche erfüllt. Dann können Sie es während des Unit-Tests der korrekten Funktionsweise der mathematischen Logik in der Funktion calculateSalesRate() . So:

 // : main_test.go package main import ( "testing" ) type MockShopDB struct{} func (m *MockShopDB) CountCustomers() (int, error) { return 1000, nil } func (m *MockShopDB) CountSales() (int, error) { return 333, nil } func TestCalculateSalesRate(t *testing.T) { //  . m := &MockShopDB{} //     calculateSalesRate(). sr := calculateSalesRate(m) // ,        //   . exp := "0.33" if sr != exp { t.Fatalf("got %v; expected %v", sr, exp) } } 

Führen Sie nun den Test aus und alles funktioniert einwandfrei.

Anwendungsarchitektur


Im vorherigen Beispiel haben wir gesehen, wie Sie mithilfe von Schnittstellen bestimmte Teile des Codes von der Verwendung bestimmter Typen entkoppeln können. Beispielsweise spielt die Funktion calculateSalesRate() keine Rolle, was Sie an sie übergeben, solange sie die ShopModel Oberfläche erfüllt.

Sie können diese Idee erweitern und in großen Projekten ganze „ungebundene“ Ebenen erstellen.
Angenommen, Sie erstellen eine Webanwendung, die mit einer Datenbank interagiert. Wenn Sie eine Schnittstelle erstellen, die bestimmte Methoden für die Interaktion mit der Datenbank beschreibt, können Sie über HTTP-Handler auf diese anstelle eines bestimmten Typs verweisen. Da sich HTTP-Handler nur auf die Schnittstelle beziehen, können Sie die HTTP-Ebene und die Interaktionsebene mit der Datenbank voneinander entkoppeln. Es wird einfacher sein, unabhängig mit Ebenen zu arbeiten, und in Zukunft können Sie einige Ebenen ersetzen, ohne die Arbeit anderer zu beeinträchtigen.

Ich habe in einem der vorherigen Beiträge über dieses Muster geschrieben, es gibt weitere Details und praktische Beispiele.

Was ist eine leere Schnittstelle?


Wenn Sie schon länger auf Go programmiert haben, sind Sie wahrscheinlich auf eine leere Schnittstelle vom Typ interface{} . Ich werde versuchen zu erklären, was es ist. Am Anfang dieses Artikels schrieb ich:

Der Schnittstellentyp in Go ist eine Art Definition . Es definiert und beschreibt die spezifischen Methoden, die ein anderer Typ haben sollte .

Ein leerer Schnittstellentyp beschreibt keine Methoden . Er hat keine Regeln. Und so erfüllt jedes Objekt eine leere Schnittstelle.

Im Wesentlichen ist der leere Schnittstellentyp interface{} eine Art Joker. Wenn Sie es in einer Deklaration (Variable, Funktionsparameter oder Strukturfeld) getroffen haben, können Sie ein Objekt eines beliebigen Typs verwenden .

Betrachten Sie den Code:

 package main import "fmt" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 fmt.Printf("%+v", person) } 

Hier initialisieren wir die Zuordnung zur person , die einen Zeichenfolgentyp für Schlüssel und einen leeren Schnittstellentyp interface{} für Werte verwendet. Wir haben drei verschiedene Typen als Map-Werte zugewiesen (String, Integer und Float32), und das ist kein Problem. Da Objekte jeglicher Art die leere Schnittstelle erfüllen, funktioniert der Code hervorragend.

Sie können diesen Code hier ausführen . Sie sehen ein ähnliches Ergebnis:

 map[age:21 height:167.64 name:Alice] 

Wenn Sie Werte aus einer Karte extrahieren und verwenden möchten, ist es wichtig, dies zu berücksichtigen. Angenommen, Sie möchten den age abrufen und um 1 erhöhen. Wenn Sie einen ähnlichen Code schreiben, wird dieser nicht kompiliert:

 package main import "log" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 person["age"] = person["age"] + 1 fmt.Printf("%+v", person) } 

Sie erhalten eine Fehlermeldung:

 invalid operation: person["age"] + 1 (mismatched types interface {} and int) 

Der Grund dafür ist, dass der in map gespeicherte Wert den Typ interface{} annimmt und seinen ursprünglichen Basis-Int-Typ verliert. Und da der Wert nicht mehr ganzzahlig ist, können wir ihm keine 1 hinzufügen.

Um dies zu umgehen, müssen Sie den Wert erneut ganzzahlig machen und ihn erst dann verwenden:

 package main import "log" func main() { person := make(map[string]interface{}, 0) person["name"] = "Alice" person["age"] = 21 person["height"] = 167.64 age, ok := person["age"].(int) if !ok { log.Fatal("could not assert value to int") return } person["age"] = age + 1 log.Printf("%+v", person) } 

Wenn Sie dies ausführen , funktioniert alles wie erwartet:

 2009/11/10 23:00:00 map[age:22 height:167.64 name:Alice] 

Wann sollten Sie einen leeren Schnittstellentyp verwenden?

Vielleicht nicht zu oft . Wenn Sie dazu kommen, hören Sie auf und überlegen Sie, ob es richtig ist, die interface{} . Als allgemeinen Rat kann ich sagen, dass es verständlicher, sicherer und produktiver ist, bestimmte Typen zu verwenden, dh nicht leere Schnittstellentypen. Im obigen Beispiel war es besser, eine Person mit entsprechend eingegebenen Feldern zu definieren:

 type Person struct { Name string Age int Height float32 } 

Eine leere Oberfläche ist dagegen nützlich, wenn Sie auf unvorhersehbare oder benutzerdefinierte Typen zugreifen und mit diesen arbeiten müssen. Aus irgendeinem Grund werden solche Schnittstellen an verschiedenen Stellen in der Standardbibliothek verwendet, z. B. in den Funktionen gob.Encode , fmt.Print und template.Execute .

Nützliche Schnittstellentypen


Hier ist eine kurze Liste der am häufigsten nachgefragten und nützlichen Schnittstellentypen aus der Standardbibliothek. Wenn Sie noch nicht mit ihnen vertraut sind, empfehle ich, die entsprechende Dokumentation zu lesen.


Eine längere Liste der Standardbibliotheken finden Sie auch hier .

Source: https://habr.com/ru/post/de463063/


All Articles