「每週譯 Go」理解 Go 中包的可見性


介紹

當創建一個《Go 中的包》(點擊跳轉查看往期推文)時,最終的目標通常是讓其他開發者可以使用這個包,無論是高階包還是整個程序。通過《導入包》(點擊跳轉查看往期推文),你的這段代碼可以作爲其他更復雜的工具的構建模塊。然而,只有某些包是可以導入的。這是由包的可見性決定的。

這裏的 _可見性 _是指一個包或其他構造可以被引用的文件空間。例如,如果我們在一個函數中定義一個變量,那麼這個變量的可見性(範圍)只在定義它的那個函數中。同樣,如果你在一個包中定義了一個變量,你可以讓它只在該包中可見,或允許它在包外也可見。

在編寫符合人體工程學的代碼時,仔細控制包的可見性是很重要的,特別是在考慮到將來可能要對你的包進行修改時。如果你需要修復一個錯誤,提高性能,或改變功能,你會希望以一種不會破壞使用你的包的人的代碼的方式進行改變。儘量減少破壞性修改的一個方法是隻允許訪問你的包中需要正常使用的部分。通過限制訪問,你可以在內部對包進行修改,而減少影響其他開發者使用你的包的機會。

在這篇文章中,將學習如何控制包的可見性,以及如何保護代碼中只應在包內使用的部分。爲了做到這一點,我們將創建一個基本的記錄器來記錄和調試信息,使用具有不同程度的項目可見性的包。

前提條件

要遵循本文中的示例,你將需要:

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

可導出與不可導出

不同於其他程序語言,如 Java 和 Python 使用_訪問修飾符_如publicprivateprotected來指定範圍不同,Go 通過其聲明方式來決定一個項目是否exportedunxported。在這種情況下,導出一個項目會使它在當前包之外是 "可見的"。如果它沒有被導出,它只能在它被定義的包內可見和使用。

這種外部可見性是通過將聲明的項目的第一個字母大寫來控制的。所有以大寫字母開頭的聲明,如 "類型"、"變量"、"常量"、"函數" 等,在當前包外是可見的。

讓我們看看下面的代碼,仔細注意一下大寫字母。

package greet

import "fmt"

var Greeting string

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

這段代碼聲明它是在greet包中。然後聲明瞭兩個符號,一個叫做 Greeting 的變量和一個叫做 Hello 的函數。因爲它們都以大寫字母開頭,所以它們都被 "可導出" 的,可供任何外部程序使用。如前所述,精心設計一個限制訪問的包將允許更好的 API 設計,並使內部更新你的包更容易,而不會破壞任何依賴此包的代碼。

定義包的可見性

爲了仔細看看包的可見性在程序中是如何工作的,讓我們創建一個logging包,記住哪些信息我們希望包外可見,哪些我們不希望它可見。這個日誌包將負責把我們程序的任何信息記錄到控制檯。它還將查看我們在什麼_級別_上進行的日誌記錄,一個級別描述了日誌的類型,它將是三種狀態之一:信息警告錯誤

首先,在你的 src 目錄下,創建一個名爲 logging 的目錄來放置日誌文件:

mkdir logging

進入目錄:

cd logging

然後,使用 nano 這樣的編輯器,創建一個名爲logging.go的文件:

nano logging.go

在剛剛創建的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的拼寫相同,但函數是大寫的,變量不是。這使得它們成爲具有不同作用域的不同聲明。

保存並退出該文件。

爲了在我們代碼的其他地方使用這個包,我們可以import它到一個新的包。我們將創建這個新的包,但需要一個新的目錄來首先存儲這些源文件。

讓我們離開logging目錄,創建一個名爲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 模塊來配置導入資源的軟件包依賴性。Go 模塊是放置在你的包目錄中的配置文件,它告訴編譯器從哪裏導入包。雖然對模塊的學習超出了本文的範圍,但我們可以只寫幾行配置來使這個例子在本地工作。

cmd目錄下打開以下go.mod文件:

nano 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文件用於我們的logging包。讓我們回到logging目錄中,創建一個go.mod文件。

cd ../logging
nano go.mod

在文件中加入以下內容:

module github.com/gopherguides/logging

這告訴編譯器,我們創建的logging包實際上是github.com/gopherguides/logging包。這使得在 main 包中導入該包成爲可能,之前寫了以下這一行:

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程序:

cd ../cmd
go run main.go

你將得到類似以下的輸出:

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

該程序將以 RFC 3339 格式打印出當前時間,後面是我們發送給記錄器的任何語句。RFC 3339 是一種時間格式,被設計用來表示互聯網上的時間,通常用於日誌文件。

因爲DebugLog函數是從日誌包中導出的,我們可以在main包中使用它們。然而,logging包中的debug變量沒有被導出。試圖引用一個未導出的聲明將導致一個編譯時錯誤。

main.go中添加錯誤操作的一行fmt.Println(logging.debug)

package main

import "github.com/gopherguides/logging"

func main() {
 logging.Debug(true)

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

 fmt.Println(logging.debug)
}

保存並運行該文件,你將收到一個類似於以下的錯誤:

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

現在我們已經瞭解了包中的 exportedunexported 項的行爲,接下來我們將看看如何從 structs 中導出 fieldsmethods

結構內的可見性

雖然在上一節中構建的記錄器中的可見性方案可能對簡單的程序有效,但它分享了太多的狀態,在多個包中都是有用的。這是因爲導出的變量可以被多個包所訪問,這些包可以將變量修改成相互矛盾的狀態。允許你的包的狀態以這種方式被改變,使得你很難預測你的程序將如何表現。例如,在目前的設計中,一個包可以將Debug變量設置爲true,而另一個包可以在同一實例中將其設置爲false。這將產生一個問題,因爲導入logging包的兩個包都會受到影響。

我們可以通過創建一個結構,然後把方法掛在它上面,使日誌記錄器隔離。這將允許我們創建一個日誌記錄器的instance實例,在每個使用它的包中獨立使用。

logging包改爲以下內容,以重構代碼並隔離記錄器:

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變量設置爲truefalseNew函數設置初始狀態來創建記錄器,例如時間格式和調試狀態。然後,它將內部給它的值存儲到未導出的變量timeFormatdebug中。我們還在Logger類型上創建了一個名爲Log的方法,該方法接收我們想要打印出來的語句。在Log方法內有一個對其本地方法變量l的引用,以獲得對其內部字段的訪問,如l.timeFormatl.debug

這種方法將允許在許多不同的包中創建一個Logger,並獨立於其他包的使用方式而使用它。

爲了在其他軟件包中使用它,讓我們把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

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

這將給出如下錯誤信息:

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

編譯器認識到logger.timeFormat沒有被導出,因此不能從logging包中檢索到。

方法中的可見性

與結構字段相同,方法也可以被導出或未導出。

爲了說明這一點,讓我們爲日誌器添加_級別_的日誌記錄。分級日誌是一種對日誌進行分類的方法,這樣就可以在日誌中搜索特定類型的事件。我們將在記錄器中加入的級別是。

你也可能希望打開和關閉某些級別的日誌記錄,特別是當你的程序沒有按照預期執行,你想調試程序的時候。我們將通過改變程序來增加這個功能,當debug被設置爲true時,它將打印所有級別的信息。否則,如果它是false,它將只打印錯誤信息。

通過對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方法決定了它是什麼級別的消息。如果是 infowarning 消息,並且 debug 字段是 true,,那麼它就會寫下該消息。否則,它將忽略該消息。如果是其他級別的信息,比如 error,它將寫出該信息。

大多數確定消息是否被打印出來的邏輯存在於Log方法中。我們還引入了一個未導出的方法,叫做 writewrite方法是實際輸出日誌信息的方法。

現在我們可以在其他軟件包中使用這種分級日誌,方法是將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")

}

運行這個將返回:

[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':

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 級別的信息會被打印出來:

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

如果我們試圖從logging包之外調用write方法,我們將收到一個編譯時錯誤:

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...")
}
cmd/main.go:16:8: logger.write undefined (cannot refer to unexported field or method logging.(*Logger).write)

當編譯器看到你試圖引用另一個包中以小寫字母開頭的東西時,它知道這個東西沒有被導出,因此拋出一個編譯器錯誤。

本教程中的記錄器說明了如何編寫代碼,只暴露出希望其他包消費的部分。因爲我們控制了包的哪些部分在包外是可見的,所以現在能夠在未來進行修改而不影響任何依賴包的代碼。例如,如果想只在debug爲 false 時關閉info級別的消息,你可以在不影響你的 API 的任何其他部分的情況下做出這個改變。我們也可以安全地對日誌信息進行修改,以包括更多的信息,如程序運行的目錄。

總結

這篇文章展示瞭如何在包之間共享代碼,同時也保護你的包的實現細節。這允許你輸出一個簡單的 API,爲了向後兼容而很少改變,但允許在你的包中根據需要私下改變,使其在未來更好地工作。這被認爲是創建包和它們相應的 API 時的最佳做法。

要了解更多關於 Go 中的包,請查看我們的《在 Go 中導入包》(點擊跳轉查看往期推文)和《如何在 Go 中編寫包》(點擊跳轉查看往期推文)文章,或者探索我們整個《如何在 Go 中編碼系列》。

相關鏈接:

Python:https://www.digitalocean.com/community/tutorial_series/how-to-code-in-python-3

Go 模塊:https://blog.golang.org/using-go-modules

RFC 3339:https://tools.ietf.org/html/rfc3339

如何在 Go 中編碼系列**:**_https://gocn.github.io/How-To-Code-in-Go/_

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NDC5tEG4GlGTDH_OcJLtfw