Go 程式變大的第一個拆分單位通常是檔案,不是架構。學習與開發常從一個 main.go 開始,等到入口程式太長,再把相關函式拆到同一個 package 的其他檔案;只有當某組概念需要形成獨立 API 邊界時,才搬到新的資料夾成為新的 package。

本章目標

學完本章後,你將能夠:

  1. 判斷何時保留單一 main.go
  2. 理解同 package 多檔案如何互相呼叫
  3. 分辨「拆檔案」和「拆 package」的差異
  4. 看懂跨 package 呼叫、exported 名稱與 import path 的關係
  5. 避免過早把小程式拆成複雜架構

【觀察】單檔是合理起點

單一 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}

這個階段不需要急著建立 handlerservicedomain 資料夾。讀者一眼能看懂程式如何啟動,比形式上的分層更重要。

【判讀】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/notify

notification/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.Newnotification.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 都拆成一個檔案,通常只會增加跳轉成本,不會讓設計更清楚。

檢查二:資料夾跟著邊界成長

程式還小時就建立 domainapplicationinfrastructure,會讓讀者先學資料夾,再學行為本身。Go 更適合先讓程式跑起來,再根據壓力拆邊界。

檢查三:exported 名稱代表公開承諾

exported 名稱就是 package 對外承諾。還不確定會被外部使用的型別與函式,先保持 unexported,等 API 真的穩定再開放。