理解 Go 中的包可见性

引言

在创建一个Go语言的包时,最终目标通常是使该包可供其他开发者使用,无论是在更高级别的包中还是整个程序中。通过导入该包,你的代码可以作为其他更复杂工具的构建模块。然而,只有某些包可供导入。这取决于包的可见性。

可见性在此上下文中指的是可以引用包或其他构造的文件空间。例如,如果我们在函数中定义了一个变量,那么该变量的可见性(作用域)仅限于定义它的函数内部。同样,如果你在包中定义了一个变量,你可以使其仅对该包可见,或者允许它在包外部也可见。

在编写符合人体工程学的代码时,仔细控制包的可见性至关重要,尤其是在考虑到未来可能对包进行的更改时。如果你需要修复错误、提升性能或改变功能,你希望以一种不会破坏使用你包的任何人代码的方式进行更改。减少破坏性更改的一种方法是仅允许访问正确使用包所需的部分。通过限制访问,你可以在内部对包进行更改,从而降低影响其他开发者使用包的可能性。

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

前提条件

要跟随本文的示例,你需要:

.
├── bin 
│ 
└── src
    └── github.com
        └── gopherguides

导出与未导出项

与Java和Python等使用访问修饰符publicprivateprotected来限定作用域的其他编程语言不同,Go通过声明方式来区分项是导出还是未导出。在此情况下,导出项使其在当前包外部可见。若未导出,则仅能在定义它的包内部可见和使用。

这种外部可见性通过声明项的首字母是否大写来控制。所有声明,如类型变量常量函数等,只要首字母大写,便在当前包外部可见。

让我们仔细观察以下代码,特别留意大小写:

greet.go
package greet

import "fmt"

var Greeting string

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

此代码声明其位于 greet 包中。接着声明了两个符号,一个名为 Greeting 的变量和一个名为 Hello 的函数。由于它们均以大写字母开头,因此都被 导出,可供任何外部程序使用。如前所述,构建一个限制访问的包将有助于更好地设计 API,并使您在内部更新包时,不会破坏依赖于您包的任何人的代码。

定义包的可见性

为了更深入地了解程序中包的可见性是如何工作的,让我们创建一个 logging 包,同时记住我们希望在包外可见的内容以及不希望公开的内容。这个日志包负责将我们程序的任何消息记录到控制台,并且会根据我们记录的 级别 来区分。级别描述了日志的类型,可以是以下三种状态之一:信息警告错误

首先,在你的 src 目录下,创建一个名为 logging 的目录来存放我们的日志文件:

  1. mkdir logging

接下来进入该目录:

  1. cd logging

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

  1. 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的包。在这个包中,有两个导出函数:DebugLog。任何导入logging包的其他包都可以调用这些函数。还有一个私有变量名为debug。此变量仅可在logging包内部访问。需要注意的是,尽管函数Debug和变量debug拼写相同,但函数名首字母大写,变量名则不是。这使得它们成为具有不同作用域的不同声明。

保存并退出文件。

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

让我们离开logging目录,创建一个名为cmd的新目录,并进入该新目录:

  1. cd ..
  2. mkdir cmd
  3. cd cmd

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

  1. nano main.go

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

cmd/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 文件:

  1. 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 目录中找到。

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

  1. cd ../logging
  2. nano go.mod

向文件中添加以下内容:

go.mod
module github.com/gopherguides/logging

这告诉编译器,我们创建的 logging 包实际上是 github.com/gopherguides/logging 包。这样,我们就可以在 main 包中使用以下我们之前写的行来导入这个包:

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 包运行 main 程序:

  1. cd ../cmd
  2. go run main.go

你将得到类似如下的输出:

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

程序会打印出当前时间,格式为 RFC 3339,随后是我们在日志记录器中发送的任何语句。RFC 3339 是一种为互联网上表示时间而设计的时间格式,常用于日志文件中。

由于 DebugLog 函数是从日志包中导出的,我们可以在 main 包中使用它们。然而,logging 包中的 debug 变量并未导出。尝试引用未导出的声明会导致编译时错误。

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包修改为以下内容,以重构代码并隔离日志记录器:

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` 的结构体。该结构体将包含我们的未导出状态,包括打印时间格式和 `debug` 变量的设置(`true` 或 `false`)。`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未被导出,因此无法从logging包中获取。

方法内的可见性

与结构体字段类似,方法也可以是导出或未导出的。

为了说明这一点,我们为日志记录器添加分级日志功能。分级日志是一种对日志进行分类的方法,以便能够搜索特定类型的事件日志。我们将在日志记录器中设置的级别包括:

  • 信息级info,代表通知用户某项操作的信息类型事件,如程序启动邮件已发送。这些有助于我们调试和跟踪程序的各个部分,以确认是否发生预期行为。

  • 警告级warning。这类事件标识出现非错误但意外的情况,如邮件发送失败,正在重试。它们帮助我们发现程序中那些不如预期顺利运行的部分。

  • 错误级别,表示程序遇到问题,如文件未找到。这通常会导致程序操作失败。

您可能还希望打开或关闭某些级别的日志记录,特别是如果您的程序没有按预期运行并且您希望调试程序时。我们将通过修改程序来实现这一功能,即当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方法的level来指定消息的级别。Log方法确定消息的级别是什么。如果是infowarning级别的消息,并且debug字段为true,则写入该消息。否则忽略该消息。如果是其他级别的消息,如error,它将写入消息。

大部分决定消息是否打印的逻辑存在于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

在这个例子中,cmd/main.go成功调用了导出的Log方法。

我们可以通过将debug切换为false来传递每条消息的level

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")

}

现在我们会看到只有error级别的消息被打印出来:

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

如果我们尝试从logging包外部调用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为假时关闭info级别的消息,你可以做出这一更改而不影响API的任何其他部分。我们还可以安全地更改日志消息,以包含更多信息,例如程序运行的目录。

结论

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

要深入了解Go中的包,请查阅我们的在Go中导入包如何在Go中编写包文章,或探索我们的整个如何在Go中编程系列

Source:
https://www.digitalocean.com/community/tutorials/understanding-package-visibility-in-go