跳转到主要内容

介绍


在 Go 中创建包时,最终目标通常是使包可供其他开发人员使用,无论是高阶包还是整个程序。通过导入包,您的代码可以作为其他更复杂工具的构建块。但是,只有某些包可用于导入。这是由包的可见性决定的。

在此上下文中的可见性意味着可以引用包或其他构造的文件空间。例如,如果我们在函数中定义一个变量,该变量的可见性(范围)仅在定义它的函数内。同样,如果您在包中定义变量,则可以使其仅对该包可见,或者也允许它在包外可见。

在编写符合人体工程学的代码时,仔细控制包的可见性很重要,尤其是在考虑到您可能希望对包进行的未来更改时。如果您需要修复错误、提高性能或更改功能,您需要以不会破坏任何使用您的包的人的代码的方式进行更改。最小化重大更改的一种方法是只允许访问包中正确使用它所需的部分。通过限制访问,您可以在内部对您的包进行更改,而不会影响其他开发人员如何使用您的包。

在本文中,您将学习如何控制包的可见性,以及如何保护只应在包内使用的部分代码。为此,我们将创建一个基本的记录器来记录和调试消息,使用具有不同程度项目可见性的包。

先决条件


要遵循本文中的示例,您将需要:

  • 按照如何安装 Go 和设置本地编程环境设置的 Go 工作区。本教程将使用以下文件结构:
.
├── bin 
│ 
└── src
    └── github.com
        └── gopherguides


导入的和未导入的事项


与 Java 和 Python 等使用访问修饰符(例如 public、private 或 protected)来指定范围的其他程序语言不同,Go 通过声明项目的方式确定是否导出和取消导出。在这种情况下,导出项目使其在当前包之外可见。如果它没有被导出,它只能在它定义的包中可见和可用。

这种外部可见性是通过将声明的项目的第一个字母大写来控制的。所有以大写字母开头的声明,例如类型、变量、常量、函数等,在当前包之外都是可见的。

让我们看下面的代码,注意大小写:

greet.go

package greet

import "fmt"

var Greeting string

func Hello(name string) string {
    return fmt.Sprintf(Greeting, name)
}

 

这段代码声明它在 greet 包中。然后它声明了两个符号,一个名为 Greeting 的变量和一个名为 Hello 的函数。因为它们都以大写字母开头,所以它们都可以导出并可供任何外部程序使用。如前所述,制作一个限制访问的包将允许更好的 API 设计,并更容易在内部更新你的包,而不会破坏依赖于你包的任何人的代码。

定义包可见性


为了更详细地了解包可见性在程序中的工作原理,让我们创建一个日志记录包,记住我们希望在包外显示的内容以及不显示的内容。这个日志包将负责将我们的任何程序消息记录到控制台。它还将查看我们正在登录的级别。级别描述了日志的类型,将是以下三种状态之一:信息、警告或错误。

首先,在您的 src 目录中,让我们创建一个名为 logging 的目录来放置我们的日志文件:

mkdir logging


接下来进入该目录:

cd logging


然后,使用 nano 之类的编辑器,创建一个名为 logging.go 的文件:

nano logging.go


将以下代码放入我们刚刚创建的 logging.go 文件中:

logging/logging.go

package logging

import (
    "fmt"
    "time"
)

var debug bool

func Debug(b bool) {
    debug = b
}

func Log(statement string) {
    if !debug {
        return
    }

    fmt.Printf("%s %s\n", time.Now().Format(time.RFC3339), statement)
}

该代码的第一行声明了一个名为 logging 的包。在这个包中,有两个导出的函数:Debug 和 Log。这些函数可以被任何其他导入日志包的包调用。还有一个名为 debug 的私有变量。此变量只能从日志记录包中访问。需要注意的是,虽然函数 Debug 和变量 debug 的拼写相同,但函数是大写的,而变量不是。这使得它们具有不同范围的不同声明。

保存并退出文件。

要在我们代码的其他区域使用这个包,我们可以将它导入到一个新包中。我们将创建这个新包,但首先需要一个新目录来存储这些源文件。

让我们移出日志目录,创建一个名为 cmd 的新目录,然后进入该新目录:

cd ..
mkdir cmd
cd cmd


在我们刚刚创建的 cmd 目录中创建一个名为 main.go 的文件:

nano main.go


现在我们可以添加以下代码:

package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

    logging.Log("This is a debug statement...")
}

我们现在已经编写了整个程序。但是,在我们可以运行这个程序之前,我们还需要创建几个配置文件以使我们的代码正常工作。 Go 使用 Go Modules 来配置用于导入资源的包依赖项。 Go 模块是放置在你的包目录中的配置文件,它告诉编译器从哪里导入包。虽然学习模块超出了本文的范围,但我们只需编写几行配置即可使此示例在本地工作。

在 cmd 目录中打开以下 go.mod 文件:

nano go.mod


然后将以下内容放入文件中:

go.mod

module github.com/gopherguides/cmd

replace github.com/gopherguides/logging => ../logging

这个文件的第一行告诉编译器cmd包的文件路径是github.com/gopherguides/cmd。第二行告诉编译器包 github.com/gopherguides/logging 可以在本地磁盘的 ../logging 目录中找到。

我们的日志包还需要一个 go.mod 文件。让我们回到日志目录并创建一个 go.mod 文件:

cd ../logging
nano go.mod


将以下内容添加到文件中:

go.mod

module github.com/gopherguides/logging


这告诉编译器我们创建的日志包实际上是 github.com/gopherguides/logging 包。这使得我们可以使用我们之前编写的以下行将包导入我们的主包中:

cmd/main.go

package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

    logging.Log("This is a debug statement...")
}

您现在应该具有以下目录结构和文件布局:

├── cmd
│   ├── go.mod
│   └── main.go
└── logging
    ├── go.mod
    └── logging.go


现在我们已经完成了所有配置,我们可以使用以下命令从 cmd 包中运行主程序:

cd ../cmd
go run main.go


您将获得类似于以下内容的输出:

Output
2019-08-28T11:36:09-05:00 This is a debug statement...


该程序将以 RFC 3339 格式打印出当前时间,然后是我们发送给记录器的任何语句。 RFC 3339 是一种时间格式,旨在表示 Internet 上的时间,通常用于日志文件。

因为 Debug 和 Log 函数是从 logging 包中导出的,所以我们可以在我们的 main 包中使用它们。但是,不会导出日志包中的调试变量。尝试引用未导出的声明将导致编译时错误。

将以下突出显示的行添加到 main.go:

cmd/main.go

package main

import "github.com/gopherguides/logging"

func main() {
    logging.Debug(true)

    logging.Log("This is a debug statement...")

    fmt.Println(logging.debug)
}

保存并运行该文件。您将收到类似于以下内容的错误:

Output
. . .
./main.go:10:14: cannot refer to unexported name logging.debug


现在我们已经了解了包中导出和未导出项的行为,接下来我们将看看如何从结构中导出字段和方法。

结构内的可见性


虽然我们在上一节中构建的记录器中的可见性方案可能适用于简单的程序,但它共享的状态太多,无法在多个包中使用。这是因为导出的变量可供多个包访问,这些包可能会将变量修改为相互矛盾的状态。允许以这种方式更改包的状态使得很难预测程序的行为方式。例如,在当前设计中,一个包可以将 Debug 变量设置为 true,而另一个包可以在同一实例中将其设置为 false。这会产生一个问题,因为正在导入日志记录包的两个包都会受到影响。

我们可以通过创建一个结构然后挂起方法来隔离记录器。这将允许我们创建一个记录器的实例,以便在每个使用它的包中独立使用。

将日志包更改为以下内容以重构代码并隔离记录器:

logging/logging.go

package logging

import (
    "fmt"
    "time"
)

type Logger struct {
    timeFormat string
    debug      bool
}

func New(timeFormat string, debug bool) *Logger {
    return &Logger{
        timeFormat: timeFormat,
        debug:      debug,
    }
}

func (l *Logger) Log(s string) {
    if !l.debug {
        return
    }
    fmt.Printf("%s %s\n", time.Now().Format(l.timeFormat), s)
}

在这段代码中,我们创建了一个 Logger 结构。 该结构将容纳我们未导出的状态,包括要打印的时间格式和调试变量设置的真或假。 New 函数设置用于创建记录器的初始状态,例如时间格式和调试状态。 然后它将我们在内部给它的值存储到未导出的变量 timeFormat 和 debug 中。 我们还在 Logger 类型上创建了一个名为 Log 的方法,该方法接受我们要打印的语句。 在 Log 方法中是对其局部方法变量 l 的引用,以访问其内部字段,例如 l.timeFormat 和 l.debug。

这种方法将允许我们在许多不同的包中创建一个 Logger,并独立于其他包如何使用它来使用它。

要在另一个包中使用它,让我们将 cmd/main.go 更改为如下所示:

cmd/main.go

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("This is a debug statement...")
}

运行此程序将为您提供以下输出:

Output
2019-08-28T11:56:49-05:00 This is a debug statement...


在这段代码中,我们通过调用导出的函数 New 创建了一个记录器的实例。 我们将对该实例的引用存储在 logger 变量中。 我们现在可以调用 logging.Log 来打印语句。

如果我们尝试从 Logger 引用未导出的字段,例如 timeFormat 字段,我们将收到编译时错误。 尝试添加以下突出显示的行并运行 cmd/main.go:

cmd/main.go

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("This is a debug statement...")

    fmt.Println(logger.timeFormat)
}

这将给出以下错误:

Output
. . .
cmd/main.go:14:20: logger.timeFormat undefined (cannot refer to unexported field or method timeFormat)


编译器识别 logger.timeFormat 未导出,因此无法从日志包中检索。

方法内的可见性


与结构字段一样,方法也可以导出或不导出。

为了说明这一点,让我们在记录器中添加分级记录。分级日志记录是一种对日志进行分类的方法,以便您可以在日志中搜索特定类型的事件。我们将放入记录器的级别是:

  • 信息级别,表示通知用户操作的信息类型事件,例如程序已启动或已发送电子邮件。这些帮助我们调试和跟踪程序的某些部分,以查看是否正在发生预期的行为。
  • 警告级别。这些类型的事件可识别何时发生非错误的意外事件,例如电子邮件发送失败、重试。它们帮助我们看到我们的程序中没有像我们预期的那样顺利的部分。
  • 错误级别,表示程序遇到问题,例如找不到文件。这通常会导致程序运行失败。

您可能还希望打开和关闭某些级别的日志记录,特别是如果您的程序没有按预期执行并且您想要调试程序。我们将通过更改程序来添加此功能,以便当 debug 设置为 true 时,它​​将打印所有级别的消息。否则,如果它是 false,它只会打印错误消息。

通过对 logging/logging.go 进行以下更改来添加分级日志记录:

logging/logging.go

package logging

import (
    "fmt"
    "strings"
    "time"
)

type Logger struct {
    timeFormat string
    debug      bool
}

func New(timeFormat string, debug bool) *Logger {
    return &Logger{
        timeFormat: timeFormat,
        debug:      debug,
    }
}

func (l *Logger) Log(level string, s string) {
    level = strings.ToLower(level)
    switch level {
    case "info", "warning":
        if l.debug {
            l.write(level, s)
        }
    default:
        l.write(level, s)
    }
}

func (l *Logger) write(level string, s string) {
    fmt.Printf("[%s] %s %s\n", level, time.Now().Format(l.timeFormat), s)
}

在此示例中,我们为 Log 方法引入了一个新参数。 我们现在可以传入日志消息的级别。 Log 方法确定它是什么级别的消息。 如果它是信息或警告消息,并且调试字段为真,那么它会写入消息。 否则它会忽略该消息。 如果它是任何其他级别,例如错误,它将无论如何都会写出消息。

大部分判断消息是否打印出来的逻辑都存在于 Log 方法中。 我们还介绍了一个未导出的方法,称为 write。 write 方法是实际输出日志消息的方法。

现在,我们可以通过将 cmd/main.go 更改为如下所示在我们的其他包中使用此级别的日志记录:

cmd/main.go

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("info", "starting up service")
    logger.Log("warning", "no tasks found")
    logger.Log("error", "exiting: no work performed")

}

运行它会给你:

Output
[info] 2019-09-23T20:53:38Z starting up service
[warning] 2019-09-23T20:53:38Z no tasks found
[error] 2019-09-23T20:53:38Z exiting: no work performed

我们现在可以通过将 debug 切换为 false 来传递每条消息的级别:

main.go

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, false)

    logger.Log("info", "starting up service")
    logger.Log("warning", "no tasks found")
    logger.Log("error", "exiting: no work performed")

}

现在我们将看到只打印错误级别的消息:

Output
[error] 2019-08-28T13:58:52-05:00 exiting: no work performed


如果我们尝试从日志包外部调用 write 方法,我们将收到一个编译时错误:

main.go

package main

import (
    "time"

    "github.com/gopherguides/logging"
)

func main() {
    logger := logging.New(time.RFC3339, true)

    logger.Log("info", "starting up service")
    logger.Log("warning", "no tasks found")
    logger.Log("error", "exiting: no work performed")

    logger.write("error", "log this message...")
}

Output
cmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)


当编译器看到您试图从另一个以小写字母开头的包中引用某些内容时,它知道它没有被导出,因此会引发编译器错误。

本教程中的记录器说明了我们如何编写只公开我们希望其他包使用的部分的代码。因为我们控制了包的哪些部分在包外可见,所以我们现在能够在不影响任何依赖于我们包的代码的情况下进行未来的更改。例如,如果我们只想在 debug 为 false 时关闭信息级别消息,您可以在不影响 API 的任何其他部分的情况下进行此更改。我们还可以安全地更改日志消息以包含更多信息,例如运行程序的目录。

结论


本文展示了如何在包之间共享代码,同时保护包的实现细节。这允许您导出一个简单的 API,该 API 很少更改以实现向后兼容性,但将允许根据需要在包中进行私有更改,以使其在未来更好地工作。在创建包及其相应的 API 时,这被认为是最佳实践。

文章链接