主題 達人專欄

迴避 Golang package 的各種坑!一篇文就能上手

解凍豬腳 | 2022-05-18 14:15:02 | 巴幣 498 | 人氣 785

 
本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版



很久沒有來更新一下 Golang 系列的文章了。

先前說完變數、函式、結構、介面、切片、迴圈,至此你應該已經具有 Golang 的基本操作能力了,只差在你需要花些時間累積經驗來把 Golang 裡面那些常用的函式庫慢慢弄熟。

在 Golang 當中,我們可以把常用的一套流程組合成一個 func(函式),每當我們需要使用的時候就呼叫它,例如:

package main

import "fmt"

func add(x, y int) int {
    return x + y
}

func multiply(x, y int) int {
    return x * y
}

func power(x, y int) int {
    result := 1
    for i := 0; i < y; i++ {
        result *= x
    }
    return result
}

func main() {
    fmt.Println(power(3, 7))
}

由於這些是函式的基本用法,就不多講。今天來講講在 Golang 裡 package 的引用和建立吧!


► 性質相近的函式,該怎麼分類和引用?

以前曾經說過,我們可以把程式碼分成好幾個 go 檔案,把同類的函式丟到同一個 go 的原始碼檔案裡面,以此初步分類。比方說:資料夾裡目前有一個裝有 main() 函式的 main.go 和一個裝有 add、multiply、power 函式的 calculator.go,這樣我們在開發的時候就可以很快地找到這些專門用來計算數值的函式。

然而對於一個有規模的程式來說,僅僅這麼做還是遠遠不夠的,畢竟整個程式裡面可能會有各式各樣的不同函式,一旦多起來仍然會混雜在一起,就好像即使我們把上千個學生按照編號分班,一旦把他們通通塞到體育館裡面,看起來還是雜亂無章。

舉個例子,假設你正在利用 Golang 開發某個遊戲的核心,你也許正好使用到一個計算次方用的函數 power(x, y),又有另一個函數叫做 getPower(monsterID) 用來取得怪物的力量數值。倘若你只是把它們分別放到 calculator.go 和 monster.go,這些函式實際上仍然都隸屬於 package main 底下:


在呼叫函式的時候,這些函式也可能因為名字長得太像,讓人容易混淆、難以閱讀:

func main() {
    monsterID := 5
    monsterPower := getPower(monsterID)
    monsterPowerEx := power(monsterPower, 2)
    fmt.Printf("%d 號怪物的力量是 %d, 進化之後力量變成 %d!\n", monsterID, monsterPower, monsterPowerEx)
}

這種時候,我們就需要使用更上一層的 package,來把它們真正地分門別類——在 Golang 裡,一堆語句組合起來可以封裝成一個 func,那麼當遇到很多個相關的結構、變數、常數、函數,我們可以再進一步封裝成一個 package。因此,我們的初步想法是把跟怪物相關的封裝成一個 package、把跟數值計算相關的封裝成一個 package。

封裝成 package 的方法很簡單,我們只需要在程式底下建立資料夾,然後把同類的 go 原始碼檔案放進去就可以了:


注意,如果你的專案底下沒有 go.mod 這個檔案,代表你遺漏了 go module 的初始化步驟,這會導致你的專案原始碼沒辦法正常獨立編譯。在你的資料夾裡面執行這條指令就可以替你的專案建立 go module 並取名為 myproject:

go mod init myproject

程式的結構就變成了這個樣子:


而分別放進 monster 跟 calculator 的那些程式碼,它們的 package 在放進去之後也不應該再叫做 main 了(注意這些 code 的最上面一行):


這樣一來,我們就成功把 calculator 跟 monster 各自獨立成一個 package。當我們需要在 package main 裡面引入底下的 monster 包來使用的時候,我們直接使用:

import "myproject/monster"

注意 import 語句裡的 “/monster” 是指資料夾名稱的 monster。如果你的資料夾名稱取名叫做 monster1,那引用的語句就該是:

import "myproject/monster1"

如果沒有特殊的需求,通常我們只要把資料夾名稱取得和 package 名稱一樣就好了。

當我們想要在 package main 裡面呼叫 monster 包底下的 GetPower 函式,直接使用:

monster.GetPower(monsterID)

也因為這些函式前面冠上了所屬的 package,我們可以一眼就能看懂這個 GetPower 函式和 calculator 裡面的 Power 函式一點關係也沒有,提升了程式碼的可讀性:

func main() {
    monsterID := 5
    monsterPower := monster.GetPower(monsterID)
    monsterPowerEx := calculator.Power(monsterPower, 2)
    fmt.Printf("%d 號怪物的力量是 %d, 進化之後力量變成 %d!\n", monsterID, monsterPower, monsterPowerEx)
}

至此,我們就完成了程式碼的初步封裝。


► 使用的 Package 和其他變數撞名了怎麼辦?

畢竟 package 的名字多是簡短的,我們在開發程式的時候遇到變數名字剛好跟 package 相同的情況也是在所難免。

就以剛才的 monster 為例,如果我們引用了 monster 這個包之後還把其他的變數取名叫做「monster」,那勢必會引發問題。面對這種情況,我們有兩種解決方法:

1. 變數避免使用 monster 這個名字

既然 monster 這個名字不能用,那就別用了。(這是廢話)

2. 引用 package 的時候,自行定義別名

在引用 package 的時候,如果在該行 import 語句前面加上別名,就可以幫 package 自訂名稱了。本來這個 package 叫做 monster,在引用的時候前面加上自訂的名字(比方說我們把它取名叫做 mst),那它的名字就會變成 mst:

package main

import (
    "fmt"
    "myproject/calculator"
    mst "myproject/monster"
)

func main() {
    monsterID := 2
    monsterPower := mst.GetPower(monsterID)
    monsterPowerEx := calculator.Power(monsterPower, 2)
    fmt.Printf("%d 號怪物的力量是 %d, 進化之後力量變成 %d!\n", monsterID, monsterPower, monsterPowerEx)
    monster := "AAA123456789"
    fmt.Println(monster)
}

這種情況下就算把新的變數取名為 monster 也不會有問題了,可以正常運作。


► Package 裡頭的 init 函式

init(initialize),顧名思義就是「初始化」,在正式執行之前做好必要的準備。

在 Golang 裡面,一個可以獨立執行的專案,執行的時候會從 main package 底下的 main 函式作為程式的起點。如果我們遇上了一些需要初始化的東西(比如全域變數的初始值),希望它在 main 之前先執行,那我們就可以定義一個 init 函式,像這樣:

package main

import "fmt"

var (
    name string
    money int
)

func init() {
    name = "豬腳"
    money = 100
}

func main() {
    fmt.Printf("嗨, 我的名字叫%s, 我的戶頭還有 %d 元", name, money)
}

init 是個特殊的函式,即便我們不主動去呼叫它,它也會在 main 函數執行之前自動被執行。也因此,當我們執行 main 函數的時候,你會發現 name 和 money 已經先被 init 函數更動過了:


當然上面這是 init() 出現在 main package 裡的情況。假如今天是從外面引進來的 package,他們的 init 就會在 package 被引入的時候自動執行:


某些特殊場合下,我們也許會遇到「只需要執行某個 package 裡面的 init 函式,卻沒有要用到裡頭的其他函式或變數」的情況,那只要把它 import 進來,並且在前面加上底線就可以了。

比如我們想要用 Golang 來做 MySQL 的資料庫讀寫,那除了引入 sql 的標準庫以外,可能還會需要去執行 go-sql-driver/mysql 底下的 init 函數來跟標準庫配合,把 sql 庫的設定初始化成 MySQL 資料庫專用的設定,那麼開頭就會像這樣:

import (
    "database/sql"
    
    _ "github.com/go-sql-driver/mysql"
)

// ...

這屬於比較例外的情況,使用一些結構比較複雜的 package 時也許會用到,不過通常我們不太需要擔心要花很多時間理解這個,參考別人範例的時候你自然就會知道該怎麼做了。


► Exported & Unexported

如果你夠細心,你應該會發現這篇文範例裡 power、add、multiply 這些函式被我包裝成另一個 package 之後,我把它們分別取名成了開頭大寫的 Power、Add、Multiply。

實際上這是 Golang 特有的規範。用最簡單的話來說,在 Golang 裡只要名字開頭是大寫的東西(包括函式、結構、變數、常數、型態),它就可以被其他的 package 直接取用,稱為 exported(直譯就是向外輸出);反之,如果名稱開頭是小寫的東西,它就只能被同一個 package 取用,稱為 unexported。

舉例來說,我們如果在 calculator 這個包定義了一個從圓半徑求面積的函式 CircleArea(r),然後在 package 裡面自行定義了 pi=3.14159265358979,但我們同時認為這個 pi 只是為了 calculator 內部計算需要而定義的常數,即便把這個 pi 的數值對外提供也沒有什麼太大的意義,那我們就可以把 pi 的開頭設為小寫:

package calculator

const pi = 3.14159265358979

func CircleArea(radius float64) float64 {
return radius * radius * pi
}

// ...

如此一來,我們就能確保這個 package 對外只會很純粹地提供 Power、Add、Multiply、CircleArea 這些函式,使用這個 package 的人完全不必在乎這個 pi 的存在或具體內容(就算刻意去呼叫 calculator.pi 也會失敗)。在封裝 package 的過程時時注意哪些東西要對外開放、哪些不要,程式碼就會變得很乾淨,把 package 獨立提供給其他人使用的時候也能讓人很清楚哪些是可以直接使用的完整功能、哪些是函式內部的小零件,大大提升程式的可除錯性。

當然,struct 底下的欄位也會因開頭大小寫不同而有 exported/unexported 的效果,當你在一個 package 裡面自訂了一個 struct,一樣可以思考:「這個欄位的變數,我會需要對外提供嗎?還是僅供這個 struct 內部使用?」來決定欄位的開頭是否大寫。你甚至可以利用 unexported 無法被外部直接存取的特性,替 struct 設計一個具有 readonly 性質的欄位:


比方說,在上面這個例子裡,我令 student 這個 struct 的名稱首字小寫,這麼做的話外部就不能直接使用 &school.student{number: 1, name: "xxx"} 來得到一個新的學生。接著,我另外設計一個 NewStudent 函式用來產生 student 實體,我就可以藉此確保每一個 student 都是經過 NewStudent() 函式而得。

這樣的做法,就能避免不合規範的產生模式(例如忘記填入名字或編號,會導致 student 的名字是空的或是編號為 0,進而潛在地成為以後可能會出現的 bug)。而在 student 內部,number 跟 name 也是 unexported,無法被外部直接利用 s.name = "xxxx" 之類的方式任意竄改(詳細可以參考《Go 語言是物件導向嗎?淺談 struct》),道理都是一樣的。

對外開放的範圍拿捏得好,這個 package 才能成熟而獨立,甚至拿給其他的專案重複使用,一定程度上避免未來可能會出現的耦合、牽一髮動全身的問題——就像你平常不會把所有的變數都設為全域變數一樣。


► 該怎麼把自製函式庫推到 GitHub 上面?

一個專案沒有 main package 就不能獨立執行(因為程式的進入點永遠是 main package 底下的 main function),而 Golang 規定不同 package 的程式碼不能混在同一個資料夾裡,所以如果我們想開發一個 package 又要能夠一邊測試,就必須把 git 儲存庫連結在子資料夾底下。

延續前面 calculator package 的例子。倘若我們想要把 calculator 這個 package 推到 GitHub 上面,那麼我們應該先在 GitHub 建立一個 repo:


回到 command line 輸入:

cd calculator

當你的 terminal 進到 calculator\ 底下之後,替你的這一個 package 建立 go module(畢竟這個 package 也可能引用其他的 package),建立引用的函式庫列表:

go mod init calculator
go mod tidy

接著把這個 package 連結到 git 剛建立好的 repo 上面:

git init
git add .
git commit -m "First commit"
git branch -M main
git remote add origin https://github.com/你的GitHub帳號/calculator.git
git push -u origin main

完成 repo 的建立跟第一次推送。

因為這篇不是純 GitHub 的教學,細節就不著墨太多。接下來當你想要發布新版本供人使用的時候,在 repo 的 code 頁面右邊找到 Releases 點進去,選擇 Create (或是 Draft) a new release,點選 Choose a tag,打上你要發布的版本號:


接著 Publish release,你的版本就正式發布了。記得版本號盡量用 vA.B.C 的形式,避免 Golang 出現無法偵測 package 版本新舊的問題。

之後當你(或是別人)想要使用這個 package 的時候,他只需要這麼做:

go get -u github.com/放有該package的GitHub帳號/calculator

接著就可以使用你親手製作的 calculator 包(或是取得最新版本)了。

Golang package 大部分的應用場合跟注意事項就是這樣。有了上面這些理解以後,你就具備「從零建立一個可以提供給別人使用的 package」的能力,並且自由開發你想寫的專案了。

※ 貼心提醒:如果你的 repo 設定為 public(公開),那你推送上去的程式碼就不應該含有任何帳號、密碼、憑證、存取權杖等資訊,在 commit/push 之前請一定要再三檢查。



HackMD 好讀版:https://hackmd.io/@upk1997/go-package

縮圖素材原作者:Renée French(CC BY-SA 3.0)
送禮物贊助創作者 !
0
留言

創作回應

愛德莉雅.萊茵斯提爾
(゚ω゚;)讚歎豬腳
2022-05-18 15:57:44
解凍豬腳
https://truth.bahamut.com.tw/s01/202108/27a13e09533c84a4953f3ad729a6d9e6.JPG
2022-05-19 08:06:55
A2266604
什麼時候一起<3
2022-05-18 17:11:42
解凍豬腳
綠頭噁心糙
2022-05-19 08:07:06
絕對黑油的一個人
真希望幾個月前看這篇 那就可以節省4個小時的時間
2022-05-18 19:27:12
解凍豬腳
這樣你就可以提前四個小時整理完你的色圖收藏了
2022-05-19 08:13:03

更多創作