學習Golang時遇到的似懂非懂的概念

嘉沐發表於2023-05-08

背景?

這是我學習golang的第三天,大致已經掌握了golang的語法,但是著手開發的時候,卻遇到了許多問題,例如golang導包機制、golang的專案管理規範、go mod生成project怎麼管理依賴的等等。其實這些概念之前也瞭解過,但是也只是如蜻蜓點水般的瞭解。正好,現在遇到了這些問題,那便認真總結一番。

問題總結

一個優秀的Go專案的佈局是怎樣的??

這個我在網上搜了很多的資料,不管是部落格還是影片,他們大部分教的是在Go ENV路徑下建立你的project,然後cd到你的project,接著在該專案資料夾下建立bin、src 和pkg目錄。目錄佈局大致如下:

.
├── bin
├── pkg
├── src
│   ├── github.com
│   │   ├── user
│   │   │   └── project1
│   │   └── user
│   │       └── project2
│   ├── main.go
│   └── other.go
├── vendor
├── Makefile
└── README.md
  • bin 目錄放置編譯生成的可執行檔案。
  • pkg 目錄放置編譯生成的包檔案。
  • src 目錄是原始碼目錄,它包含了專案的所有Go原始碼,外部依賴庫的原始碼也可以放在該目錄下。
  • vendor 目錄儲存了第三方依賴庫的程式碼,類似於其他語言中的 node_modules 目錄或 pipvirtualenv 機制。
  • Makefile 包含了專案的構建與管理規則,如編譯、測試、部署等。
  • README.md 檔案包含了專案的說明文件和使用說明。

這種目錄佈局其實還挺清晰的,但是自從go引入go modules做依賴管理,專案佈局結構會變得更精簡,更靈活。具體目錄佈局如下所示:

.
├── cmd
│   └── main.go
├── internal
├── pkg
├── vendor
├── go.mod
└── README.md
  • cmd 目錄是程式的入口程式碼,即 main 函式的實現。
  • internal 目錄用於存放應用程式的私有程式碼,不能被其他專案引用。
  • pkg 目錄用於存放應用程式的公共庫程式碼,可以被其他專案引用。
  • vendor 目錄用於存放依賴庫的程式碼,類似於其他語言中的 node_modules 目錄或 pipvirtualenv 機制。
  • go.mod 是 Go modules 的配置檔案,用於管理依賴關係和版本控制。
  • README.md 檔案包含了專案的說明文件和使用說明。

當然根據業務要求,我們還可以新增docs目錄存放專案文件,新增test目錄存放單元測試程式碼。

Tonybai大佬的圖畫的相當不錯,我這邊引用一下,大家看完圖就知道一個Go語言的經典佈局該是什麼樣的了。

ps:圖有點糊,將就一下......

專案結構目錄重點突出一個清晰明瞭,我們需要清楚每個目錄代表的含義是什麼,每個目錄下的檔案有哪些,目錄檔案的呼叫關係,目錄檔案的隱蔽性等等。

為什麼一定是go mod??

我提出這個問題並不是吹毛求疵,而是真心想了解在沒go module管理時,大家不也寫的好好的麼,為什麼go module出來後,大家會立馬拋棄以前的做法。這 go module到底帶來了什麼好處,如此吸引人。

我還是從專案佈局上理解,在沒有go module管理時,大家的專案佈局應該長這樣:

.
├── bin
├── pkg
├── src
│   ├── github.com
│   │   ├── user
│   │   │   └── project1
│   │   └── user
│   │       └── project2
│   ├── main.go
│   └── other.go
├── vendor
├── Makefile
└── README.md

首先在 Go 1.11 版本之前,如果要在 $GOPATH 中執行一個專案,該專案必須存放在 $GOPATH/src 目錄下。佈局裡就包含了project1和project2兩個專案檔案目錄。這會導致你的src目錄越來越腫大。

其次是手動管理依賴問題。在Go 1.11版本之前,Go 語言沒有官方的依賴管理工具,因此在專案中引入外部依賴庫的時候,通常需要手動將依賴庫的程式碼複製到 $GOPATH/src 目錄下。這個光不是手動下載的事,你還要考慮到手動更新,依賴之間的衝突問題,部署的問題等等。在倡導DEVOPS的時代,哪一個都是讓人分心頭痛的事?。

然而,go module解決了上述問題(怪不得大家會極力擁抱這門新技術)。這裡舉個例子說明兩者的差別,讓思路更清晰。

假設有兩個專案 A 和專案 B,都依賴於 Go 語言的一個第三方庫 github.com/gin-gonic/gin。其中,專案 A 使用傳統的依賴管理方式,專案 B 使用 Go modules 來管理依賴。

一、使用傳統的依賴管理方式的專案 A:

專案 A 的目錄結構如下:

.
├── main.go
└── vendor
    └── github.com
        └── gin-gonic
            └── gin
                ├── LICENSE
                ├── README.md
                ├── bindings
                ├── contributing.md
                ├── favicon.ico
                ├── gin.go
                ├── go.mod
                ├── go.sum
                ├── handlers
                ├── logger.go
                ├── middleware.go
                ├── render
                ├── router.go
                └── vendor

main.go 檔案中引入了 github.com/gin-gonic/gin

package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	// ...
}

在專案 A 中,依賴庫 github.com/gin-gonic/gin 的程式碼被存放在 vendor 目錄中,無法和其他專案共享使用。需要說明地是,使用 vendor 目錄來管理依賴庫是 Go 語言在 Go 1.5 版本時引入的方式。在使用 vendor 目錄管理依賴庫的時候,你需要將依賴庫的程式碼複製到專案目錄下的 vendor 目錄下,然後在程式碼中引用這些依賴庫。如果這個依賴庫的版本發生升級,需要手動更新並重新複製程式碼到 vendor 目錄,容易出現版本衝突或遺漏問題。

二、使用 Go modules 的專案 B:

專案 B 的目錄結構如下:

.
├── go.mod
├── main.go
└── vendor

main.go 檔案中同樣引入了 github.com/gin-gonic/gin

package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	// ...
}

在專案 B 中,使用 go mod 命令來管理依賴庫和版本,無需手動複製依賴庫程式碼。執行以下命令會自動下載依賴庫程式碼至 $GOPATH/pkg/mod 目錄下:

go mod init example.com/B
go mod tidy

安裝完依賴庫後,可以把 $GOPATH/pkg/mod 目錄下的 github.com/gin-gonic/gin 目錄複製到其他專案中使用,從而實現依賴共享。如果需要升級或切換依賴庫的版本,只需要修改 go.mod 檔案中的版本號即可,不會影響到其他專案。

這裡可能有一個歧義的點,就是“複製到其它專案中使用”這個說法。依我看,go mod會自動安裝你所需要庫的依賴,它會在本地留下快取資訊。那麼如果另一個專案中也需要使用 github.com/gin-gonic/gin,可以直接在程式碼中引用該依賴庫,如下所示:

package main

import (
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	// ...
}

只要之前已經在任意一個專案中使用了 go mod 命令下載過並快取了 github.com/gin-gonic/gin,Go 會自動從快取中載入依賴庫程式碼,而不會重新下載依賴庫程式碼到 $GOPATH/pkg/mod 目錄下。

這種方式可以實現依賴庫的共享,避免了多個專案同時複製依賴庫程式碼,節省了磁碟空間。同時,如果需要使用不同的版本號,可以透過修改 go.mod 檔案來實現對特定版本的依賴管理。

當你修改了 go.mod 檔案中的依賴版本號或者新增了新的依賴項之後,可以使用 go mod tidy 命令來更新依賴關係,例如:

# 修改 go.mod 檔案中的依賴版本號
go mod edit -require github.com/gin-gonic/gin@v1.7.2

# 更新依賴關係
go mod tidy

go mod tidy 會根據 go.mod 檔案中的依賴關係自動下載並更新依賴庫程式碼,以保持依賴關係的一致性,並且會刪除未被引用的依賴項。

另外,如果你還希望移除某一個不再使用的依賴庫,可以使用 go mod tidy -v 命令,它會輸出垃圾收集的詳細資訊,包括哪些依賴項是被移除的。

Go的導包機制是什麼?✌️

我的人生信條就是優雅,如何優雅地導包也是我所追求的。

在瞭解怎麼導包之前,我們先需要了解包在Go裡面的具象化的體現是什麼?包和模組的區別是什麼?

拋開語言,從軟體角度考慮,我們認為子程式的集合稱為包,包的集合稱為模組,模組的集合稱為子系統,子系統的集合稱為系統。將這個說法往Go語言上代入,能發現我們編寫的go檔案就是子程式,go檔案所在資料夾就是包,根目錄的檔名就是模組名。這些具象化的體現還能從哪裡看出來呢?其實還能從我們建立的檔案中看出。

以這樣的目錄結構為例:

project/
|- go.mod
|- main.go
|- controllers/
   |- user.go
   |- admin.go

其中:

  • go.mod 是模組檔案,位於專案根目錄。
  • main.go 是入口檔案,位於專案根目錄。
  • controllers/ 目錄下有兩個檔案 user.goadmin.go

一般來說,當你檢視go.mod檔案時,能看到第一行寫著

module project

這是告訴你模組名是project。如果以後你的程式碼開源了,別人想引用你的程式碼,首先就是要根據你的模組名再找對應的包名。這和其它語言引包的方式很相似。

module後面的模組名是允許修改的,你可以換成自定義的寫法,通常的寫法是域名+專案名

再開啟user.go,你能發現第一行寫著

package controllers

這是指user.go是controllers包下面的一個子程式。

理清楚這些概念,我們還需要記住一個總綱:“導包其實就是定址!導包其實就是定址!導包其實就是定址!”。重要的話說三遍!

還是以上面的目錄結構為例,如果需要在 main.go 中匯入 controllers/user.go 檔案中的程式碼,可以使用相對路徑 ./controllers 來匯入 user.go 檔案。

// main.go
package main

import "./controllers"

func main() {
    // ...
}

對於模組,可以使用模組路徑來引用相對路徑下的檔案。例如,假設你的模組路徑為 example.com/mymodule,則可以在 main.go 中使用 example.com/mymodule/controllers 來引用相對路徑下的 user.go 檔案。

// main.go
package main

import "example.com/mymodule/controllers"

func main() {
    // ...
}

需要注意的是,使用相對路徑匯入包時,包的實際路徑是相對於當前檔案所在的目錄。如果在其他檔案中也要使用相對路徑匯入 controllers/user.go,則需要將路徑設定為相對於這個檔案的路徑。同時,相對路徑只適用於模組內的程式碼,如果要在不同的模組之間匯入程式碼,必須使用完整的包路徑。

Go的測試程式碼怎麼生成??

在 Go 中,我們可以透過建立測試檔案來編寫測試程式碼。測試檔案應該遵循 Go 的命名規則,即在檔名後面加上 _test。例如,如果要編寫一個名為 sum 的函式的測試程式碼,那麼測試檔案的檔名應該是 sum_test.go,而被測試的函式前面要加個Test字首。

在測試檔案中,我們可以使用 testing 包提供的一系列函式來編寫測試程式碼,例如 testing.TErrorFailFatal 函式,以及 testing.BReportAllocsResetTimerStopTimer 等函式。

下面是一個簡單的測試示例,測試 sum 函式:

// sum_test.go
package main

import "testing"

func TestSum(t *testing.T) {
    tables := []struct {
        a, b, expected int
    }{
        {1, 1, 2},
        {2, 2, 4},
        {3, 3, 6},
    }

    for _, table := range tables {
        result := sum(table.a, table.b)
        if result != table.expected {
            t.Errorf("Sum of %d + %d was incorrect, expected %d but received %d", table.a, table.b, table.expected, result)
        }
    }
}

在這個測試檔案中,我們首先匯入 testing 包,並定義了一個名為 TestSum 的測試函式。tables 定義了一個結構體切片,其中包含了一組要測試的引數和期望結果。我們遍歷 tables 切片,逐一測試每組資料,判斷計算結果是否和期望的結果一致。如果不一致,我們使用 t.Errorf 函式輸出錯誤資訊。這樣就完成了一個簡單的測試檔案。

要執行測試檔案,可以在專案根目錄下使用 go test 命令執行。Go 會自動查詢並執行所有的測試檔案,並輸出測試結果。測試結果中會顯示測試用例的數量、測試是否透過以及每個測試用例的具體資訊。例如:

$ go test
PASS
ok      project 0.008s

在本示例中,我們只有一個測試用例,測試結果判定為 “ PASS ”,表示測試透過了。如果測試失敗,則測試結果會判定為 “ FAIL ”,並輸出具體的錯誤資訊。

參考資料

https://tonybai.com/2022/04/28/the-standard-layout-of-go-project/

寫的這麼詳細,諸位點個贊不過分吧!!!!

相關文章