1.5 從單檔到多檔案
Go 程式變大的第一個拆分單位通常是檔案,不是架構。學習與開發常從一個 main.go 開始,等到入口程式太長,再把相關函式拆到同一個 package 的其他檔案;只有當某組概念需要形成獨立 API 邊界時,才搬到新的資料夾成為新的 package。
本章目標
學完本章後,你將能夠:
- 判斷何時保留單一
main.go - 理解同 package 多檔案如何互相呼叫
- 分辨「拆檔案」和「拆 package」的差異
- 看懂跨 package 呼叫、exported 名稱與 import path 的關係
- 避免過早把小程式拆成複雜架構
【觀察】單檔是合理起點
單一 main.go 的核心價值是降低初期理解成本。程式還小的時候,把入口、設定、簡單函式放在同一個檔案,通常比一開始拆成多個資料夾更容易閱讀。
1notify/
2├── go.mod
3└── main.go一個最小 HTTP 服務可以先長這樣:
1package main
2
3import (
4 "fmt"
5 "net/http"
6)
7
8func main() {
9 http.HandleFunc("/health", healthHandler)
10
11 if err := http.ListenAndServe(":8080", nil); err != nil {
12 panic(err)
13 }
14}
15
16func healthHandler(w http.ResponseWriter, r *http.Request) {
17 fmt.Fprintln(w, "ok")
18}這個階段不需要急著建立 handler、service 或 domain 資料夾。讀者一眼能看懂程式如何啟動,比形式上的分層更重要。
【判讀】main.go 膨脹時,先拆同 package 多檔案
同 package 多檔案的核心規則是:同一個資料夾、同一個 package 名稱的 Go 檔案會被一起編譯,彼此可以直接呼叫,不需要 import。
當 main.go 開始同時包含設定、handler、資料型別與啟動流程,可以先拆成這樣:
1notify/
2├── go.mod
3├── main.go
4├── config.go
5├── server.go
6└── message.go每個檔案仍然使用:
1package main因此 main.go 可以直接呼叫 loadConfig():
1func main() {
2 cfg := loadConfig()
3 server := newServer(cfg)
4
5 if err := server.ListenAndServe(); err != nil {
6 panic(err)
7 }
8}config.go 可以提供這個函式:
1package main
2
3type config struct {
4 Port string
5}
6
7func loadConfig() config {
8 return config{Port: ":8080"}
9}這是同 package 內的檔案切分。loadConfig 即使用小寫開頭,main.go 也可以呼叫,因為它們都屬於 package main。
【策略】先拆檔案,再拆 package
拆分的核心判斷是:檔案用來降低閱讀負擔,package 用來建立 API 邊界。這兩者成本不同,不應混在一起。
| 拆分方式 | 使用時機 | 呼叫方式 | 成本 |
|---|---|---|---|
| 同 package 多檔案 | 檔案太長、概念需要分段 | 直接呼叫 | 成本低,沒有新 API 邊界 |
| 新 package | 概念可獨立、需要被其他 package 使用 | import 後呼叫 exported 名稱 | 成本較高,需要設計 API |
例如 message.go 只是放一些內部型別時,可以留在 package main:
1package main
2
3type message struct {
4 Title string
5 Body string
6}如果 notification 概念開始有自己的建構規則、驗證規則與測試需求,就可以搬成獨立 package:
1notify/
2├── go.mod
3├── main.go
4└── notification/
5 ├── notification.go
6 └── validate.go此時 notification package 要明確決定哪些名稱是對外 API。
【執行】跨 package 呼叫需要 import 與 exported 名稱
跨 package 呼叫的核心規則是:其他 package 只能使用大寫開頭的 exported 名稱,並且必須透過 import path 引入。
假設 module path 是:
1module example.com/notifynotification/notification.go 可以這樣寫:
1package notification
2
3import "strings"
4
5type Notification struct {
6 Title string
7 Body string
8}
9
10func New(title, body string) Notification {
11 return Notification{
12 Title: strings.TrimSpace(title),
13 Body: strings.TrimSpace(body),
14 }
15}
16
17func isEmpty(n Notification) bool {
18 return n.Title == "" && n.Body == ""
19}main.go 要使用這個 package,必須 import:
1package main
2
3import (
4 "fmt"
5
6 "example.com/notify/notification"
7)
8
9func main() {
10 n := notification.New("Deploy finished", "Version 1.2.0 is live")
11 fmt.Println(n.Title)
12}notification.New 和 notification.Notification 可以被外部使用,因為它們是大寫開頭。isEmpty 不能被 main.go 呼叫,因為它是 package 內部實作細節。
【判讀】import cycle 是依賴方向錯了
import cycle 的核心意義是兩個 package 互相依賴,Go 會直接拒絕編譯。Go 工具鏈透過這個限制強迫你把依賴方向想清楚。
例如這種結構容易出問題:
1notify/
2├── main.go
3├── handler/
4│ └── handler.go
5└── notification/
6 └── notification.go如果 handler import notification,同時 notification 又 import handler,就會形成循環依賴。
1handler -> notification -> handler修正的核心做法是讓低層概念不要依賴高層協定。notification 應該描述通知資料與規則,不應知道 HTTP handler;handler 可以把 HTTP request 轉成 notification command 或 value。
1handler -> notification這個方向比「互相知道」更容易測試,也更容易重構。
常見拆分路線
Go 服務常見的成長路線是漸進式的。每一步都應該解決當下的閱讀、測試或依賴問題,而不是為了看起來正式。
階段一:單一檔案
1notify/
2├── go.mod
3└── main.go適合小工具、實驗程式、剛開始的服務雛形。
階段二:同 package 多檔案
1notify/
2├── go.mod
3├── main.go
4├── config.go
5├── server.go
6└── notification.go適合 main.go 開始太長,但概念還沒有明確 API 邊界的階段。
階段三:多 package
1notify/
2├── go.mod
3├── main.go
4├── config/
5│ └── config.go
6├── notification/
7│ ├── notification.go
8│ └── validate.go
9└── transport/
10 └── http.go適合設定、通知規則、HTTP transport 已經能清楚分成不同責任的階段。
階段四:服務邊界更清楚
1notify/
2├── go.mod
3├── cmd/
4│ └── notify-server/
5│ └── main.go
6└── internal/
7 ├── notification/
8 ├── transport/
9 └── storage/適合服務已經有明確部署入口、內部 package 不想被外部 module 引用的階段。這是程式長大後的選擇。
設計檢查
檢查一:檔案切分跟著 Go package 模型
Go 的檔案不是 class。把每個 struct 都拆成一個檔案,通常只會增加跳轉成本,不會讓設計更清楚。
檢查二:資料夾跟著邊界成長
程式還小時就建立 domain、application、infrastructure,會讓讀者先學資料夾,再學行為本身。Go 更適合先讓程式跑起來,再根據壓力拆邊界。
檢查三:exported 名稱代表公開承諾
exported 名稱就是 package 對外承諾。還不確定會被外部使用的型別與函式,先保持 unexported,等 API 真的穩定再開放。