首页 > 后端开发 > Golang > Go 界面的简单指南

Go 界面的简单指南

DDD
发布: 2024-09-18 18:39:14
原创
1093 人浏览过

作为一名从 JavaScript 过渡到 Go 的开发人员,当我第一次开始学习 Go 时,我发现接口的概念具有挑战性。
本指南旨在为处于类似情况的人提供 Go 接口的简单解释 - 无论您是有 JavaScript 背景还是编程新手。

目标是展示 Go 接口的强大功能和灵活性,同时提供实用的见解。在本文结束时,我希望您能够充分了解如何在 Go 项目中有效地使用接口。

 

概述

让我们从 Go 退后一步,用一个简单的类比来从高层次上理解什么是接口。

想象一下我们生活在一个没有 Uber、Lyft 或任何乘车共享服务的世界。
有一个名叫托尼的人,拥有各种类型的车辆,包括汽车、卡车和飞机。他想知道,“我怎样才能充分利用所有这些车辆?”

与此同时,蒂娜是一名销售员,她的工作需要经常出差。她不喜欢开车,也没有驾照,所以她通常乘坐出租车。然而,随着她的升职,她的日程变得更加忙碌和充满活力,出租车有时无法满足她的需求。

让我们看看蒂娜周一到周三的日程安排:

  • 周一:她需要在下午 2 点之前到达客户 A 的办公室,并且由于下午 1 点有一个重要会议,因此必须在行程中使用笔记本电脑。
  • 星期二:她必须在下午 5 点之前到达客户 B 的办公室。通勤时间大约2个小时,所以她需要一辆车,让她可以躺下小睡。
  • 周三:她需要在上午 10 点之前到达机场,带着大量行李,需要一辆宽敞的车辆。

有一天,蒂娜发现托尼​​有好几种不同类型的车辆,她可以根据自己的需要选择最合适的。

A straightforward guide for Go interface在这种情况下,每次蒂娜想去某个地方,她都必须拜访托尼并听他解释所有技术细节,然后才能选择合适的汽车。不过,蒂娜并不关心这些技术细节,也不需要知道。她只是需要一辆符合她要求的车。

这是一个简单的图表,说明了此场景中蒂娜和托尼之间的关系:
A straightforward guide for Go interface正如我们所见,蒂娜直接询问托尼。换句话说,蒂娜依赖托尼,因为她需要汽车时需要直接联系他。

 

为了让蒂娜的生活更轻松,她与托尼签订了一份合同,该合同本质上是对汽车的要求清单。然后托尼将根据此列表选择最合适的汽车。
A straightforward guide for Go interface在此示例中,包含一系列要求的合同有助于蒂娜抽象出汽车的细节,只关注她的要求。 Tina 需要做的就是在合同中明确要求,Tony 就会为她选择最合适的车。

我们可以用这张图进一步说明这个场景中的关系:
A straightforward guide for Go interface蒂娜现在可以使用合同来获得她需要的汽车,而不是直接询问托尼。在这种情况下,她不再依赖托尼了;她不再依赖托尼了。相反,她依赖合同。合约的主要目的是抽象出汽车的细节,因此蒂娜不需要知道汽车的具体细节。她只需要知道这辆车满足她的要求即可。

从这张图中,我们可以识别出以下特征:

  • 合约由Tina定义;由她决定她需要什么要求。
  • 合约充当托尼和蒂娜之间的中间人。他们并不直接相互依赖;相反,他们取决于合同。
    • 如果托尼停止提供服务,蒂娜可以与其他人使用相同的合同。
  • 可能有多辆车满足相同的要求
    • 例如,特斯拉 Model S 和奔驰 S 级都可以满足 Tina 的要求。

我希望这张图对您有意义,因为理解它是掌握接口概念的关键。类似的图表将出现在以下各节中,强化这一重要概念。

 

什么是接口?

在前面的示例中,包含需求列表的合约正是 Go 中的接口。

  1. 合同帮助蒂娜抽象出汽车的细节,只关注她的要求。
    • 接口抽象了实现的细节,只关注行为。
  2. 合同是由一系列要求定义的。
    • 接口由方法签名列表定义。
  3. 任何满足要求的汽车都被称为执行合同。
    • 任何实现接口中指定的所有方法的类型都被称为实现该接口。
  4. 合约由消费者拥有(在本例中为蒂娜)
    • 接口由使用它的人(调用者)拥有
  5. 合约充当蒂娜和托尼之间的中间人
    • 接口充当调用者和实现者之间的中间人
  6. 可能有多辆车满足相同的要求
    • 一个接口可能有多种实现

Go 与许多其他语言不同的一个关键特性是它使用隐式接口实现。这意味着您不需要显式声明类型实现接口。只要类型定义了接口所需的所有方法,它就会自动实现该接口。

在 Go 中使用接口时,请务必注意,它仅提供行为列表,而不提供详细实现。接口定义了类型应该具有哪些方法,而不是它们应该如何工作。

 

简单的例子

让我们通过一个简单的示例来说明 Go 中的接口如何工作。

首先,我们定义一个 Car 接口:

type Car interface {
    Drive()
}
登录后复制

这个简单的 Car 接口有一个方法 Drive(),它不接受任何参数,也不返回任何内容。任何具有具有此确切签名的 Drive() 方法的类型都被视为实现了 Car 接口。

现在,让我们创建一个实现 Car 接口的 Tesla 类型:

type Tesla struct{}

func (t Tesla) Drive() {
    println("driving a tesla")
}
登录后复制

Tesla 类型实现了 Car 接口,因为它有一个 Drive() 方法,其签名与接口中定义的相同。

为了演示如何使用此接口,让我们创建一个接受任何 Car 的函数:

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

func main() {
    t := Tesla{}
    DriveCar(t)
}
/*
Output:
driving a tesla
*/
登录后复制

此代码证明 Tesla 类型实现了 Car 接口,因为我们可以将 Tesla 值传递给 DriveCar 函数,该函数接受任何 Car。
注意:您可以在此存储库中找到完整的代码。

了解 Tesla 隐式实现 Car 接口非常重要。没有像 Tesla struct 类型实现 Car 接口那样的显式声明。相反,Go 识别 Tesla 实现 Car 只是因为它有一个带有正确签名的 Drive() 方法。

我们用一张图来形象化一下 Tesla 类型和 Car 接口之间的关系:
A straightforward guide for Go interface该图说明了 Tesla 类型和 Car 接口之间的关系。请注意,Car 接口不了解 Tesla 类型的任何信息。它不关心哪种类型正在实现它,也不需要知道。

我希望这个例子有助于阐明Go中接口的概念。如果您想知道在这个简单场景中使用界面的实际好处,请不要担心。我们将在下一节中探讨更复杂情况下界面的强大功能和灵活性。

 

用例

在本节中,我们将探讨一些实际示例,以了解接口为何有用。

多态性

接口之所以如此强大,是因为它们能够在 Go 中实现多态性。
多态性是面向对象编程中的一个概念,它允许我们以相同的方式处理不同类型的对象。简单来说,多态只是“具有多种形式”的一个花哨的词。
在 Go 世界中,我们可以将多态视为“一个接口,多个实现”。

让我们通过一个例子来探讨这个概念。想象一下,我们想要构建一个简单的 ORM(对象关系映射),它可以与不同类型的数据库一起使用。我们希望客户端能够轻松地从数据库中插入、更新和删除数据,而不必担心特定的查询语法。
对于这个例子,假设我们目前只支持 mysql 和 postgres,并且我们将只关注插入操作。最终,我们希望客户像这样使用我们的 ORM:

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

 

首先,让我们看看如何在不使用接口的情况下实现这一目标。
我们首先定义 MySQL 和 Postgres 结构,每个结构都有一个 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
}
登录后复制

接下来,我们将定义一个带有驱动程序字段的 ORM 结构:

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.

以上是Go 界面的简单指南的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板