Maison  >  Article  >  développement back-end  >  Un guide simple pour l'interface Go

Un guide simple pour l'interface Go

DDD
DDDoriginal
2024-09-18 18:39:141059parcourir

En tant que développeur passant de JavaScript à Go, j'ai trouvé le concept d'interfaces difficile lorsque j'ai commencé à apprendre Go.
Ce guide vise à fournir une explication simple des interfaces Go pour ceux qui se trouvent dans des situations similaires, que vous veniez d'un contexte JavaScript ou que vous soyez nouveau en programmation.

L'objectif est de démontrer la puissance et la flexibilité des interfaces Go, tout en fournissant des informations pratiques. À la fin de cet article, j'espère que vous aurez une solide compréhension de la façon d'utiliser efficacement les interfaces dans vos projets Go.

 

Aperçu

Prenons du recul par rapport à Go et utilisons une analogie simple pour comprendre ce qu'est une interface à un niveau élevé.

Imaginez que nous vivons dans un monde sans Uber, Lyft ou tout autre service de covoiturage.
Il y a une personne nommée Tony qui possède différents types de véhicules, dont une voiture, un camion et un avion. Il se demande : « Comment puis-je tirer le meilleur parti de tous ces véhicules ? »

Pendant ce temps, il y a Tina, une vendeuse dont le métier nécessite des déplacements fréquents. Elle n'aime pas conduire et n'a pas de permis de conduire, elle prend donc généralement des taxis. Cependant, au fur et à mesure qu'elle obtient une promotion, son emploi du temps devient de plus en plus chargé et dynamique, et les taxis ne répondent parfois pas à ses besoins.

Regardons l'emploi du temps de Tina du lundi au mercredi :

  • Lundi : Elle doit arriver au bureau du client A avant 14h et doit utiliser son ordinateur portable pendant le trajet en raison d'un rendez-vous important à 13h.
  • Mardi : Elle doit être au bureau du client B à 17 heures. Le trajet dure environ 2 heures, elle a donc besoin d'une voiture où elle puisse s'allonger et faire une sieste.
  • Mercredi : Elle doit arriver à l'aéroport avant 10h avec beaucoup de bagages, nécessitant un véhicule spacieux.

Un jour, Tina découvre que Tony possède plusieurs types de véhicules différents et qu'elle peut choisir celui qui convient le mieux en fonction de ses besoins.

A straightforward guide for Go interfaceDans ce scénario, chaque fois que Tina veut aller quelque part, elle doit rendre visite à Tony et écouter ses explications sur tous les détails techniques avant de choisir une voiture adaptée. Cependant, Tina ne se soucie pas de ces détails techniques et n’a pas besoin de les connaître. Elle a juste besoin d'une voiture qui répond à ses exigences.

Voici un schéma simple illustrant la relation entre Tina et Tony dans ce scénario :
A straightforward guide for Go interfaceComme on peut le voir, Tina demande directement à Tony. En d'autres termes, Tina dépend de Tony car elle doit le contacter directement chaque fois qu'elle a besoin d'une voiture.

 

Afin de faciliter la vie de Tina, elle crée un contrat avec Tony, qui est essentiellement une liste d'exigences pour la voiture. Tony choisira ensuite la voiture la plus adaptée en fonction de cette liste.
A straightforward guide for Go interfaceDans cet exemple, le contrat avec une liste d'exigences aide Tina à faire abstraction des détails de la voiture et à se concentrer uniquement sur ses exigences. Tout ce que Tina doit faire est de définir les exigences du contrat et Tony choisira la voiture la plus adaptée pour elle.

Nous pouvons illustrer davantage la relation dans ce scénario avec ce diagramme :
A straightforward guide for Go interfaceAu lieu de demander directement à Tony, Tina peut désormais utiliser le contrat pour obtenir la voiture dont elle a besoin. Dans ce cas, elle ne dépend plus de Tony ; au lieu de cela, elle dépend du contrat. L'objectif principal du contrat est de faire abstraction des détails de la voiture, de sorte que Tina n'a pas besoin de connaître les détails spécifiques de la voiture. Tout ce qu'elle doit savoir, c'est que la voiture répond à ses exigences.

A partir de ce schéma, nous pouvons identifier les caractéristiques suivantes :

  • Le contrat est défini par Tina ; c'est à elle de décider des exigences dont elle a besoin.
  • Le contrat fait office d'intermédiaire entre Tony et Tina. Ils ne dépendent pas directement les uns des autres ; ils dépendent plutôt du contrat.
    • Tina peut utiliser le même contrat avec d'autres si Tony cesse de fournir le service.
  • Il peut y avoir plusieurs voitures qui satisfont aux mêmes exigences
    • Par exemple, une Tesla Model S et une Mercedes-Benz Classe S pourraient répondre aux exigences de Tina.

J'espère que ce schéma vous semble logique car le comprendre est la clé pour appréhender le concept d'interfaces. Des diagrammes similaires apparaîtront dans les sections suivantes, renforçant ce concept important.

 

Qu'est-ce qu'une interface ?

Dans l'exemple précédent, un contrat avec une liste d'exigences est exactement ce qu'est une interface dans Go.

  1. Un contrat aide Tina à faire abstraction des détails de la voiture et à se concentrer uniquement sur ses besoins.
    • Une interface fait abstraction des détails d'une implémentation et se concentre uniquement sur le comportement.
  2. Un contrat est défini par une liste d'exigences.
    • Une interface est définie par une liste de signatures de méthodes.
  3. Toute voiture qui satisfait aux exigences est censée mettre en œuvre le contrat.
    • Tout type qui implémente toutes les méthodes spécifiées dans l'interface est censé implémenter cette interface.
  4. Un contrat appartient au consommateur (dans ce cas, Tina)
    • Une interface appartient à celui qui l'utilise (l'appelant)
  5. Un contrat fait office d'intermédiaire entre Tina et Tony
    • Une interface agit comme intermédiaire entre l'appelant et l'implémenteur
  6. Il peut y avoir plusieurs voitures qui satisfont aux mêmes exigences
    • Il peut y avoir plusieurs implémentations d'une interface

Une caractéristique clé qui distingue Go de nombreux autres langages est son utilisation de l'implémentation d'interface implicite. Cela signifie que vous n'avez pas besoin de déclarer explicitement qu'un type implémente une interface. Tant qu'un type définit toutes les méthodes requises par une interface, il implémente automatiquement cette interface.

Lorsque vous travaillez avec des interfaces dans Go, il est important de noter qu'il ne fournit que la liste des comportements, pas l'implémentation détaillée. Une interface définit les méthodes qu'un type doit avoir, et non comment elles doivent fonctionner.

 

Exemple simple

Parcourons un exemple simple pour illustrer le fonctionnement des interfaces dans Go.

Tout d'abord, nous allons définir une interface Car :

type Car interface {
    Drive()
}

Cette interface Car simple a une seule méthode, Drive(), qui ne prend aucun argument et ne renvoie rien. Tout type possédant une méthode Drive() avec cette signature exacte est considéré comme implémentant l'interface Car.

Maintenant, créons un type Tesla qui implémente l'interface Car :

type Tesla struct{}

func (t Tesla) Drive() {
    println("driving a tesla")
}

Le type Tesla implémente l'interface Car car il possède une méthode Drive() avec la même signature que celle définie dans l'interface.

Pour démontrer comment nous pouvons utiliser cette interface, créons une fonction qui accepte n'importe quelle voiture :

func DriveCar(c Car) {
    c.Drive()
}

func main() {
    t := Tesla{}
    DriveCar(t)
}
/*
Output:
driving a tesla
*/

Ce code prouve que le type Tesla implémente l'interface Car car on peut transmettre une valeur Tesla à la fonction DriveCar, qui accepte n'importe quelle voiture.
Remarque : Vous pouvez trouver le code complet dans ce référentiel.

Il est important de comprendre que Tesla implémente implicitement l'interface Car. Il n'y a pas de déclaration explicite comme la structure de type Tesla implémente l'interface Car. Au lieu de cela, Go reconnaît que Tesla implémente Car simplement parce qu'il dispose d'une méthode Drive() avec la signature correcte.

Visualisons la relation entre le type Tesla et l'interface Car avec un schéma :
A straightforward guide for Go interfaceCe schéma illustre la relation entre le type Tesla et l'interface Car. Notez que l'interface Car ne sait rien du type Tesla. Il ne se soucie pas du type qui l'implémente, et il n'a pas besoin de le savoir.

J'espère que cet exemple aidera à clarifier le concept d'interfaces dans Go. Ne vous inquiétez pas si vous vous interrogez sur les avantages pratiques de l'utilisation d'une interface dans ce scénario simple. Nous explorerons la puissance et la flexibilité des interfaces dans des situations plus complexes dans la section suivante.

 

Cas d'utilisation

Dans cette section, nous explorerons quelques exemples pratiques pour voir pourquoi les interfaces sont utiles.

Polymorphisme

Ce qui rend les interfaces si puissantes, c'est leur capacité à réaliser le polymorphisme dans Go.
Le polymorphisme, concept de la programmation orientée objet, permet de traiter différents types d'objets de la même manière. En termes plus simples, le polymorphisme n'est qu'un mot sophistiqué pour « avoir plusieurs formes ».
Dans le monde Go, nous pouvons considérer le polymorphisme comme « une interface, plusieurs implémentations ».

Explorons ce concept avec un exemple. Imaginez que nous souhaitions créer un ORM (Object-Relational Mapping) simple pouvant fonctionner avec différents types de bases de données. Nous voulons que le client puisse facilement insérer, mettre à jour et supprimer des données de la base de données sans se soucier de la syntaxe spécifique de la requête.
Pour cet exemple, disons que nous ne prenons en charge que MySQL et Postgres pour l'instant, et que nous nous concentrerons uniquement sur l'opération d'insertion. En fin de compte, nous voulons que le client utilise notre ORM comme ceci :

conn := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
orm := myorm.New(&myorm.MySQL{Conn: conn})
orm.Insert("users", user)

 

Voyons d'abord comment nous pourrions y parvenir sans utiliser d'interface.
Nous commencerons par définir les structures MySQL et Postgres, chacune avec une méthode Insert :

type MySQL struct {
    Conn *sql.DB
}

func (m *MySQL) Insert(table string, data map[string]interface{}) error {
    // insert into mysql using mysql query
}

type Postgres struct {
    Conn *sql.DB
}

func (p *Postgres) Insert(table string, data map[string]interface{}) error {
    // insert into postgres using postgres query
}

Ensuite, nous définirons une structure ORM avec un champ pilote :

type ORM struct {
    db any
}

The ORM struct will be used by the client. We use the any type for the driver field because we can't determine the specific type of the driver at compile time.

Now, let's implement the New function to initialize the ORM struct:

func New(db any) *ORM {
    return &ORM{db: db}
}

Finally, we'll implement the Insert method for the ORM struct:

func (o *ORM) Insert(table string, data map[string]interface{}) error {
    switch d := o.db.(type) {
    case MySQL:
        return d.Insert(table, data)
    case Postgres:
        return d.Insert(table, data)
    default:
        return fmt.Errorf("unsupported database driver")
    }
}

We have to use a type switch (switch d := o.db.(type)) to determine the type of the driver because the db field is of type any.

While this approach works, it has a significant drawback: if we want to support more database types, we need to add more case statements. This might not seem like a big issue initially, but as we add more database types, our code becomes harder to maintain.

 

Now, let's see how interfaces can help us solve this problem more elegantly.
First, we'll define a DB interface with an Insert method:

type DB interface {
    Insert(table string, data map[string]interface{}) error
}

Any type that has an Insert method with this exact signature automatically implements the DB interface.
Recall that our MySQL and Postgres structs both have Insert methods matching this signature, so they implicitly implement the DB interface.

Next, we can use the DB interface as the type for the db field in our ORM struct:

type ORM struct {
    db DB
}

Let's update the New function to accept a DB interface:

func New(db DB) *ORM {
    return &ORM{db: db}
}

Finally, we'll modify the Insert method to use the DB interface:

func (o *ORM) Insert(table string, data map[string]interface{}) error {
    return o.db.Insert(table, data)
}

Instead of using a switch statement to determine the database type, we can simply call the Insert method of the DB interface.

Now, clients can use the ORM struct to insert data into any supported database without worrying about the specific implementation details:

// using mysql
conn := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
orm := myorm.New(&myorm.MySQL{Conn: conn})
orm.Insert("users", user)

// using postgres
conn = sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=disable")
orm = myORM.New(&myorm.Postgres{Conn: conn})
orm.Insert("users", user)

With the DB interface, we can easily add support for more database types without modifying the ORM struct or the Insert method. This makes our code more flexible and easier to extend.

Consider the following diagram to illustrate the relationship between the ORM, MySQL, Postgres, and DB interfaces:
A straightforward guide for Go interfaceIn this diagram, the ORM struct depends on the DB interface, and the MySQL and Postgres structs implement the DB interface. This allows the ORM struct to use the Insert method of the DB interface without knowing the specific implementation details of the MySQL or Postgres structs.

This example demonstrates the power of interfaces in Go. We can have one interface and multiple implementations, allowing us to write more adaptable and maintainable code.

Note: You can find the complete code in this repository.

 

Making testing easier

Let's consider an example where we want to implement an S3 uploader to upload files to AWS S3. Initially, we might implement it like this:

type S3Uploader struct {
    client *s3.Client
}

func NewS3Uploader(client *s3.Client) *S3Uploader {
    return &S3Uploader{client: client}
}

func (s *S3Uploader) Upload(ctx context.Context, bucketName, objectKey string, data []byte) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(objectKey),
        Body:   bytes.NewReader(data),
    })

    return err
}

In this example, the client field in the S3Uploader struct is type *s3.Client, which means the S3Uploader struct is directly dependent on the s3.Client.
Let's visualize this with a diagram:
A straightforward guide for Go interfaceWhile this implementation works fine during development, it can pose challenges when we're writing unit tests. For unit testing, we typically want to avoid depending on external services like S3. Instead, we'd prefer to use a mock that simulates the behavior of the S3 client.

This is where interfaces come to the rescue.

We can define an S3Manager interface that includes a PutObject method:

type S3Manager interface {
    PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
}

Note that the PutObject method has the same signature as the PutObject method in s3.Client.

Now, we can use this S3Manager interface as the type for the client field in our S3Uploader struct:

type S3Uploader struct {
    client S3Manager
}

Next, we'll modify the NewS3Uploader function to accept the S3Manager interface instead of the concrete s3.Client:

func NewS3Uploader(client S3Manager) *S3Uploader {
    return &S3Uploader{client: client}
}

With this implementation, we can pass any type that has a PutObject method to the NewS3Uploader function.

For testing purposes, we can create a mock object that implements the S3Manager interface:

type MockS3Manager struct{}

func (m *MockS3Manager) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
    // mocking logic
    return &s3.PutObjectOutput{}, nil
}

We can then pass this MockS3Manager to the NewS3Uploader function when writing unit testing.

mockUploader := NewS3Uploader(&MockS3Manager{})

This approach allows us to test the Upload method easily without actually interacting with the S3 service.

After using the interface, our diagram looks like this:
A straightforward guide for Go interfaceIn this new structure, the S3Uploader struct depends on the S3Manager interface. Both s3.Client and MockS3Manager implement the S3Manager interface. This allows us to use s3.Client for the real S3 service and MockS3Manager for mocking during unit tests.

As you might have noticed, this is also an excellent example of polymorphism in action.

Note: You can find the complete code in this repository.

 

Decoupling

In software design, it's recommended to decouple dependencies between modules. Decoupling means making the dependencies between modules as loose as possible. It helps us develop software in a more flexible way.

To use an analogy, we can think of a middleman sitting between two modules:
A straightforward guide for Go interfaceIn this case, Module A depends on the middleman, instead of directly depending on Module B.

You might wonder, what's the benefit of doing this?
Let's look at an example.

Imagine we're building a web application that takes an ID as a parameter to get a user's name. In this application, we have two packages: handler and service.
The handler package is responsible for handling HTTP requests and responses.
The service package is responsible for retrieving the user's name from the database.

Let's first look at the code for the handler package:

package handler

type Handler struct {
    // we'll implement MySQLService later
    service service.MySQLService
}

func (h *Handler) GetUserName(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")

    if !isValidID(id) {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }

    userName, err := h.service.GetUserName(id)
    if err != nil {
        http.Error(w, fmt.Sprintf("Error retrieving user name: %v", err), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(userName)
}

This code is straightforward. The Handler struct has a service field. For now, it depends on the MySQLService struct, which we'll implement later. It uses h.service.GetUserName(id) to get the user's name. The handler package's job is to handle HTTP requests and responses, as well as validation.

Now, let's look at the service package:

package service

type MySQLService struct {
    sql *sql.DB
}

func NewMySQLService(sql *sql.DB) *MySQLService {
    return &MySQLService{sql: sql}
}

func (s *MySQLService) GetUserName(id string) (string, error) {
    // get user name from database
}

Here, the MySQLService struct has an sql field, and it retrieves the user's name from the database.

In this implementation, the Handler struct is directly dependent on the MySQLService struct:
A straightforward guide for Go interfaceThis might not seem like a big deal at first, but if we want to switch to a different database, we'd have to modify the Handler struct to remove the dependency on the MySQLService struct and create a new struct for the new database.
A straightforward guide for Go interfaceThis violates the principle of decoupling. Typically, changes in one package should not affect other packages.

 

To fix this problem, we can use an interface.
We can define a Service interface that has a GetUserName method:

type Service interface {
    GetUserName(id string) (string, error)
}

We can use this Service interface as the type of the service field in the Handler struct:

package handler

type Service interface {
    GetUserName(id string) (string, error)
}

type Handler struct {
    service Service // now it depends on the Service interface
}

func (h *Handler) GetUserName(w http.ResponseWriter, r *http.Request) {
    // same as before

    // Get the user from the service
    user, err := h.service.GetUserName(id)
    if err != nil {
        http.Error(w, "Error retrieving user: "+err.Error(), http.StatusInternalServerError)
        return
    }

    // same as before
}

In this implementation, the Handler struct is no longer dependent on the MySQLService struct. Instead, it depends on the Service interface:
A straightforward guide for Go interfaceIn this design, the Service interface acts as a middleman between Handler and MySQLService.
For Handler, it now depends on behavior, rather than a concrete implementation. It doesn't need to know the details of the Service struct, such as what database it uses. It only needs to know that the Service has a GetUserName method.

When we need to switch to a different database, we can just simply create a new struct that implements the Service interface without changing the Handler struct.
A straightforward guide for Go interface

When designing code structure, we should always depend on behavior rather than implementation.
It's better to depend on something that has the behavior you need, rather than depending on a concrete implementation.

Note: You can find the complete code in this repository.

 

Working With the Standard Library

As you gain more experience with Go, you'll find that interfaces are everywhere in the standard library.
Let's use the error interface as an example.

In Go, error is simply an interface with one method, Error() string:

type error interface {
    Error() string
}

This means that any type with an Error method matching this signature implements the error interface. We can leverage this feature to create our own custom error types.

Suppose we have a function to log error messages:

func LogError(err error) {
    log.Fatal(fmt.Errorf("received error: %w", err))
}

While this is a simple example, in practice, the LogError function might include additional logic, such as adding metadata to the error message or sending it to a remote logging service.

Now, let's define two custom error types, OrderError and PaymentDeclinedError. These have different fields, and we want to log them differently:

// OrderError represents a general error related to orders
type OrderError struct {
    OrderID string
    Message string
}

func (e OrderError) Error() string {
    return fmt.Sprintf("Order %s: %s", e.OrderID, e.Message)
}

// PaymentDeclinedError represents a payment failure
type PaymentDeclinedError struct {
    OrderID string
    Reason  string
}

func (e PaymentDeclinedError) Error() string {
    return fmt.Sprintf("Payment declined for order %s: %s", e.OrderID, e.Reason)
}

Because both OrderError and PaymentDeclinedError have an Error method with the same signature as the error interface, they both implement this interface. Consequently, we can use them as arguments to the LogError function:

LogError(OrderError{OrderID: "123", Message: "Order not found"})
LogError(PaymentDeclinedError{OrderID: "123", Reason: "Insufficient funds"})

This is another excellent example of polymorphism in Go: one interface, multiple implementations. The LogError function can work with any type that implements the error interface, allowing for flexible and extensible error handling in your Go programs.

Note: You can find the complete code in this repository.

 

Summary

In this article, we've explored the concept of interfaces in Go, starting with a simple analogy and gradually moving to more complex examples.

Key takeaways about Go interfaces:

  • They are all about abstraction
  • They are defined as a set of method signatures
  • They define behavior without specifying implementation details
  • They are implemented implicitly (no explicit declaration needed)

I hope this article has helped you gain a better understanding of interfaces in Go.

 

Reference

  • Learning Go: An Idiomatic Approach to Real-World Go Programming
  • 100 Go Mistakes and How to Avoid Them
  • Golang Interfaces Explained

 

As I'm not an experienced Go developer, I welcome any feedback. If you've noticed any mistakes or have suggestions for improvement, please leave a comment. Your feedback is greatly appreciated and will help enhance this resource for others.

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn