Go modules และ packages คือการจัดระเบียบโค้ดสองระดับของ Go package คือโฟลเดอร์ที่มีไฟล์ .go ซึ่งถูก compile ไปด้วยกันและใช้ namespace เดียวกัน ส่วน module คือชุดของ package ที่มีเวอร์ชัน กำหนดด้วยไฟล์ go.mod ที่ root และเป็นหน่วยที่เราดาวน์โหลด ใส่เวอร์ชัน และประกาศเป็น dependency เมื่อเข้าใจว่าสองอย่างนี้ทำงานร่วมกันอย่างไร เรื่อง import, การมองเห็นชื่อ และการจัดการ dependency จะไม่สับสนอีกต่อไป
บทความนี้พาทำโปรเจกต์เล็ก ๆ ตั้งแต่ go mod init ไปจนถึงการใช้ package จากภายนอก ครอบคลุม package main, ชื่อแบบ exported และ unexported, go mod tidy และพื้นฐานการจัด layout ของโปรเจกต์
package main และ func main
ทุกไฟล์ .go ขึ้นต้นด้วย package และมีชื่อหนึ่งที่พิเศษ คือ package main ซึ่งบอก compiler ว่าให้สร้างเป็นโปรแกรมที่รันได้ และ func main() ใน package นี้คือจุดเริ่มต้นของโปรแกรม
package main
import "fmt"
func main() {
fmt.Println("hello, modules")
}Package ชื่ออื่นจะกลายเป็น library ที่ package อื่นนำไป import รันเองไม่ได้ ถ้าสั่ง go run จะเจอ error package ... is not a main package
กฎที่ควรรู้:
- ไฟล์
.goทุกไฟล์ในโฟลเดอร์เดียวกันต้องประกาศชื่อ package เดียวกัน ยกเว้นไฟล์ test ที่ลงท้าย_test.goซึ่งใช้ชื่อ<name>_testได้ mainไม่รับ argument และไม่คืนค่า ให้อ่าน argument จากos.Argsหรือ packageflagและจบโปรแกรมด้วย status ที่ไม่ใช่ศูนย์ผ่านos.Exitหรือlog.Fatal- ก่อน
mainจะรัน Go จะ initialize package ที่ถูก import รวมถึงตัวแปรระดับ package และฟังก์ชันinit()ควรใช้initแค่กับการลงทะเบียนเล็ก ๆ เพราะ side effect ที่ซ่อนอยู่ในนั้น debug ยาก
สร้าง module ด้วย go mod init
สร้างโฟลเดอร์แล้ว initialize module:
mkdir shapes && cd shapes
go mod init example.com/shapesคำสั่งนี้สร้างไฟล์ go.mod:
module example.com/shapes
go 1.25.1moduleคือ module path ซึ่งเป็น prefix ของทุก import path ภายใน module ถ้าจะเผยแพร่โค้ด ให้ใช้ที่อยู่ของ repository เช่นgithub.com/your-org/shapesเพื่อให้go getหาเจอ ถ้าเป็นโปรเจกต์ส่วนตัวหรือทดลอง ใช้ชื่อรูปแบบ domain อะไรก็ได้goคือเวอร์ชัน Go ขั้นต่ำที่ module ต้องการgo mod initจะใส่เวอร์ชันที่ติดตั้งอยู่ให้ ตั้งแต่ Go 1.21 ค่านี้ถูกบังคับใช้จริง toolchain ที่เก่ากว่าจะไม่ยอม build หรือจะดาวน์โหลดเวอร์ชันใหม่มาใช้ ขึ้นกับค่าGOTOOLCHAIN
แบ่งโค้ดเป็น package
ตัวอย่างนี้เป็น module ที่มี library package ชื่อ shape แบ่งเป็นสามไฟล์ และมี main package อยู่ที่ root:
shapes/
├── go.mod
├── main.go # package main
└── shape/
├── shape.go # package shape
├── circle.go # package shape
└── rectangle.go # package shape// shape/shape.go
package shape
// Shape is anything with an area and a perimeter.
type Shape interface {
Area() float64
Perimeter() float64
}// shape/circle.go
package shape
import "math"
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }// shape/rectangle.go
package shape
import "fmt"
type Rectangle struct {
width, height float64 // unexported: only package shape can access them
}
func NewRectangle(w, h float64) (Rectangle, error) {
if w <= 0 || h <= 0 {
return Rectangle{}, fmt.Errorf("invalid rectangle %vx%v", w, h)
}
return Rectangle{width: w, height: h}, nil
}
func (r Rectangle) Area() float64 { return r.width * r.height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.width + r.height) }main package import ด้วย module path ต่อด้วยชื่อโฟลเดอร์:
// main.go
package main
import (
"fmt"
"log"
"example.com/shapes/shape"
)
func main() {
rect, err := shape.NewRectangle(3, 4)
if err != nil {
log.Fatal(err)
}
for _, s := range []shape.Shape{shape.Circle{Radius: 1}, rect} {
fmt.Printf("%T area=%.2f perimeter=%.2f\n", s, s.Area(), s.Perimeter())
}
}รันด้วย go run . ซึ่ง build package ในโฟลเดอร์ปัจจุบัน ส่วน go build จะสร้าง binary และ go build ./... จะ compile ทุก package ใน module
สังเกตว่า circle.go ใช้ Shape จาก shape.go ได้โดยไม่ต้อง import อะไรเลย ไฟล์ใน package เดียวกันใช้ scope ร่วมกัน การแยก package เป็นหลายไฟล์จึงเป็นเรื่องความอ่านง่ายล้วน ๆ
ชื่อแบบ exported และ unexported
Go ไม่มี keyword public หรือ private การมองเห็นขึ้นกับตัวอักษรแรกของชื่อ:
| ชื่อ | มองเห็นได้จาก | ตัวอย่าง |
|---|---|---|
| ขึ้นต้นด้วยตัวพิมพ์ใหญ่ | ทุก package ที่ import | Circle, NewRectangle, Area, Radius |
| ขึ้นต้นด้วยตัวพิมพ์เล็ก | เฉพาะใน package เดียวกัน | width, height, ฟังก์ชัน helper |
กฎนี้ใช้กับ type, function, method, ตัวแปร, ค่าคงที่ และ field ของ struct ขอบเขตคือ package ไม่ใช่ไฟล์ rectangle.go กับ circle.go มองเห็นชื่อตัวพิมพ์เล็กของกันและกัน แต่ main มองไม่เห็น:
r, _ := shape.NewRectangle(3, 4)
fmt.Println(r.width)
// compile error: r.width undefined (cannot refer to unexported field width)นี่คือวิธีทำ encapsulation ของ Go Rectangle ซ่อน field ไว้ ทำให้สร้างได้ทางเดียวคือผ่าน NewRectangle ซึ่งตรวจ input ก่อน ผลข้างเคียงที่ต้องจำคือ encoding/json และ package อื่นที่ใช้ reflection จะมองข้าม field ที่ไม่ได้ export struct ที่ต้องการ serialize จึงต้องมี field แบบ exported พร้อม tag อ่านกลไกเหล่านี้โดยละเอียดได้ใน Go structs, methods และ interfaces
การตั้งชื่อ package
- ใช้ชื่อสั้น ตัวพิมพ์เล็ก คำเดียว เช่น
shape,auth,storageไม่ใช้ underscore หรือ camelCase - ตั้งชื่อโฟลเดอร์ให้ตรงกับชื่อ package แม้ Go จะยอมให้ต่างกันได้ แต่คนอ่านคาดหวังให้ตรงกัน
- หลีกเลี่ยงการพูดซ้ำ ผู้เรียกเขียน
shape.Circleอยู่แล้วshape.ShapeCircleจึงซ้ำซ้อน - หลีกเลี่ยงชื่อครอบจักรวาลอย่าง
util,commonหรือhelpersเพราะจะดึงโค้ดที่ไม่เกี่ยวกันมารวมไว้ และไม่บอกอะไรเลยตรงจุดที่เรียกใช้
Import package จากภายนอก
ถ้าจะใช้ package จาก module อื่น ให้ import แล้วปล่อยให้คำสั่ง go ดึงมาให้ จะเพิ่ม dependency ตรง ๆ ก็ได้:
go get github.com/google/uuid@latestหรือเขียน import ก่อนแล้วค่อยรัน go mod tidy:
import "github.com/google/uuid"
id := uuid.NewString()ไม่ว่าทางไหน จะมีสองไฟล์ที่เปลี่ยน:
go.modได้บรรทัดrequireเพิ่ม เช่นrequire github.com/google/uuid v1.6.0ส่วน dependency ที่เราไม่ได้ import ตรง ๆ จะมี// indirectกำกับgo.sumบันทึก checksum ของแต่ละเวอร์ชัน คำสั่ง go ใช้ตรวจไฟล์ที่ดาวน์โหลดมาเทียบกับไฟล์นี้และกับ checksum database สาธารณะ ต้อง commit ทั้งสองไฟล์
โดย default module จะถูกดาวน์โหลดผ่าน proxy.golang.org ถ้าเป็น repository ส่วนตัว ให้ตั้ง GOPRIVATE=github.com/your-org/* เพื่อให้ดึงตรงจาก repository และข้าม checksum database สาธารณะ
เวอร์ชันใช้ semantic versioning ตั้งแต่ v2 ขึ้นไป major version จะเป็นส่วนหนึ่งของ import path เช่น github.com/go-playground/validator/v10 ทำให้สอง major version อยู่ใน build เดียวกันได้ เมื่อ dependency หลายตัวต้องการ minor version ต่างกันของ module เดียวกัน Go จะเลือกเวอร์ชันต่ำสุดที่ตอบโจทย์ทุกตัว (minimal version selection) build จึง reproducible โดยไม่ต้องมี lock file แยก
เก็บกวาดด้วย go mod tidy
Go ไม่ยอม compile ไฟล์ที่มี import ซึ่งไม่ได้ใช้ แต่ไม่มีอะไรห้าม go.mod ไม่ให้เก็บ module ที่เลิกใช้ไปแล้ว go mod tidy ปรับ go.mod และ go.sum ให้ตรงกับ import ในโค้ด คือเพิ่มสิ่งที่ขาด และลบ requirement ที่ไม่มีใคร import รันทุกครั้งหลังเพิ่มหรือลบ import และใน CI ให้ตรวจว่ารันแล้วไม่มี diff
คำสั่งอื่นที่จะได้ใช้:
| คำสั่ง | หน้าที่ |
|---|---|
go get example.com/[email protected] | เพิ่มหรือเปลี่ยน dependency เป็นเวอร์ชันที่ระบุ |
go get -u ./... | อัปเกรด dependency เป็น minor และ patch version ที่ใหม่กว่า |
go get example.com/pkg@none | ลบ dependency |
go list -m all | แสดงทุก module ใน build |
go list -m -u all | แสดง module ที่มีเวอร์ชันใหม่ |
go mod why example.com/pkg | อธิบายว่าทำไมต้องใช้ module นี้ |
go mod verify | ตรวจ module cache เทียบกับ go.sum |
สำหรับเครื่องมือของนักพัฒนา เช่น code generator ตั้งแต่ Go 1.24 มี directive tool แล้ว go get -tool golang.org/x/tools/cmd/stringer จะบันทึกไว้ใน go.mod และ go tool stringer จะรันเวอร์ชันที่ pin ไว้
ทำงานกับสอง module พร้อมกัน
เมื่อต้องแก้ library และแอปไปพร้อมกัน ให้แอปชี้ไปที่ library ในเครื่อง directive replace ใน go.mod ทำได้ แต่เผลอ commit ขึ้นไปง่าย workspace ช่วยให้ override นี้อยู่นอก go.mod:
go work init ./app ./shapesคำสั่งนี้สร้างไฟล์ go.work ซึ่งทีมส่วนใหญ่ไม่เก็บเข้า version control
พื้นฐานการจัด layout ของโปรเจกต์
Module เล็ก ๆ วางแบบแบนได้ คือ main.go อยู่ที่ root และมี package ไม่กี่ตัวอยู่ข้าง ๆ เมื่อโปรเจกต์โตขึ้น มี convention สองอย่างที่ช่วยได้:
myservice/
├── go.mod
├── cmd/
│ ├── api/main.go # one directory per binary
│ └── worker/main.go
└── internal/
├── article/ # importable only inside myservice
└── storage/cmd/<name>/เก็บpackage mainหนึ่งตัวต่อหนึ่งโปรแกรม และควรบางที่สุด คืออ่าน config ประกอบ dependency แล้ว startinternal/ถูกบังคับโดย compiler package ที่อยู่ข้างในจะถูก import ได้เฉพาะจากโค้ดที่อยู่ใต้ parent ของinternalเท่านั้น module อื่นจึงพึ่งพาส่วนภายในของเราไม่ได้
ไม่จำเป็นต้องมีโฟลเดอร์ pkg/ หรือโครงสร้างที่ลึกหลายชั้น ให้จัด package ตามสิ่งที่มันทำ ไม่ใช่ตาม layer ทางเทคนิค Go error handling และ project layout อธิบายว่า layout และ error handling เติบโตไปด้วยกันอย่างไร และ คู่มือสร้าง REST API ด้วย Gin และ GORM แสดงการใช้กฎเหล่านี้กับ service จริง
คำถามที่พบบ่อย
Go module กับ package ต่างกันอย่างไร
Package คือโฟลเดอร์ของโค้ดที่ compile ไปด้วยกัน ส่วน module คือชุดของ package ที่มีเวอร์ชันและมี go.mod ที่ root เรา import เป็น package แต่ใส่เวอร์ชันและดาวน์โหลดเป็น module
go mod tidy ทำอะไร
เพิ่ม requirement ที่ขาดสำหรับ package ที่ import ลบ requirement ที่ไม่มีใคร import และอัปเดต go.sum ให้ตรงกัน
ควร commit go.sum ไหม
ควร เพราะทำให้ build ตรวจสอบได้และ reproducible ให้ commit คู่กับ go.mod เสมอ
go get กับ go install ต่างกันอย่างไร
go get เปลี่ยน dependency ใน go.mod ของ module ปัจจุบัน ส่วน go install example.com/cmd@latest build และติดตั้ง binary ลงใน $GOBIN โดยไม่แตะ go.mod
เช็กลิสต์
- มี
go.modหนึ่งไฟล์ที่ root ของ repository และ module path ตรงกับที่อยู่ของโค้ด - หนึ่งโฟลเดอร์คือหนึ่ง package และตั้งชื่อตามโฟลเดอร์
- เฉพาะชื่อที่ package อื่นต้องใช้เท่านั้นที่ขึ้นต้นด้วยตัวพิมพ์ใหญ่
- เพิ่ม dependency ด้วย
go getหรือ import แล้วgo mod tidyและ commit ทั้งgo.modและgo.sum - โปรแกรมที่รันได้อยู่ใน
cmd/และโค้ดภายในอยู่ในinternal/
การวาง module และ package ให้ถูกตั้งแต่แรกทำให้การ refactor ทุกครั้งหลังจากนั้นถูกลง ถ้าทีมของคุณกำลังเริ่ม codebase ภาษา Go และอยากได้คนช่วยวางโครงสร้าง Vectorkub ช่วยได้
