ข้ามไปยังเนื้อหา

The Go Toolchain

binary go รวมทุกเครื่องมือที่นักพัฒนา Go ต้องการไว้ใน executable เดียว ไม่มี build system แยกต่างหาก ไม่ต้องใช้ Makefile สำหรับ workflow พื้นฐาน และไม่มี plugin ecosystem ที่ต้องตั้งค่า คำสั่งต่อไปนี้ครอบคลุม 90% ของการพัฒนา Go ประจำวัน

go run compile ไฟล์ที่ระบุและรัน binary ที่ได้ทันที เหมาะสำหรับ scripts และการทดสอบรวดเร็ว — ไม่มีไฟล์ output เหลือบน disk

Terminal window
go run main.go
go run . # run the package in the current directory
go run ./cmd/server # run a specific cmd sub-package

go build compile package และเขียน binary ลง disk โดยไม่มี flag -o binary จะตั้งชื่อตาม element สุดท้ายของ package path (./cmd/server → server); ที่ module root ก็คือ element สุดท้ายของ module (go build . ใน github.com/yourname/myapp เขียน myapp)

Terminal window
go build . # build current package, output: ./myapp
go build -o bin/server ./cmd/server # build to a specific path

go fmt รัน gofmt กับไฟล์ .go ทั้งหมดใน package และเขียนผลลัพธ์กลับลง disk มีวิธีเดียวเท่านั้นในการจัดรูปแบบโค้ด Go

Terminal window
go fmt ./... # format every package in the module
gofmt -w -l ./ # equivalent: write changes, list modified files

ใน CI ตรวจสอบว่าโค้ดถูกจัดรูปแบบแล้ว:

Terminal window
gofmt -l . | grep -q . && echo "unformatted files found" && exit 1 || echo "all formatted"

go vet ไม่ใช่การตรวจแค่แบบเดียว — มันคือชุด analyzer อิสระหลายตัว แต่ละตัวมีชื่อของตัวเอง ที่คำสั่ง go รันรวมกัน go doc cmd/vet แสดงรายการทั้งหมด; หกตัวด้านล่างจับ pattern ที่พบบ่อยในโค้ด generated แต่ละตัวยืนยันด้วย bug จริงและข้อความจริงของ go vet:

copylocks — struct ที่มี sync.Mutex (หรือคล้ายกัน) ถูก copy ทำให้สำเนามี lock state เป็นอิสระที่ไม่มีประโยชน์:

type Safe struct {
mu sync.Mutex
count int
}
s := Safe{}
s2 := s // copies the mutex
main.go:12:8: assignment copies lock value to s2: example.com/vetdemo/copylocks.Safe contains sync.Mutex

lostcancel — cancel function จาก context.WithCancel ถูกทิ้งไป ทำให้ context leak จนกว่า parent จะจบ:

ctx, _ := context.WithCancel(context.Background())
main.go:6:7: the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leak

unusedresult — function ที่ return value สำคัญ (standard library mark ไว้บางตัว เช่น errors.New และ fmt.Errorf) ถูกเรียกแล้วทิ้งผลลัพธ์:

errors.New("boom")
main.go:9:2: result of errors.New call not used

httpresponse — response จาก http.Get ถูกใช้ (ที่นี่คือ defer resp.Body.Close()) ก่อนเช็ก error ทำให้ request ที่ fail dereference *http.Response ที่เป็น nil:

resp, err := http.Get("http://example.com")
defer resp.Body.Close()
if err != nil {
fmt.Println(err)
return
}
main.go:10:8: using resp before checking for errors

waitgroup (Go 1.25) — wg.Add(1) ถูกเรียกจากข้างใน goroutine ที่มันควรจะ track ซึ่ง race กับ wg.Wait() ใน parent — Add ต้องเกิดก่อน go statement:

go func() {
wg.Add(1)
defer wg.Done()
}()
wg.Wait()
main.go:8:9: WaitGroup.Add called from inside new goroutine

printf — format verb ไม่ตรงกับ type ของ argument:

fmt.Printf("%d\n", "hello")
main.go:6:14: fmt.Printf format %d has arg "hello" of wrong type string
Terminal window
go vet ./...

go vet exit ด้วย non-zero status หากพบปัญหา ทำให้ fail CI pipeline ได้ง่าย มันยังรัน analyzer กลุ่มเล็ก ๆ ที่ตายตัวเหล่านี้อัตโนมัติก่อน go test และ go build ทุกครั้ง — hostport (Go 1.25) แนะนำ net.JoinHostPort แทน fmt.Sprintf("%s:%d", host, port) ซึ่งพังกับ IPv6 และ stdversion (Go 1.27) รายงานการเรียก API ของ standard library ที่ใหม่กว่าบรรทัด go ของ module

go vet ระมัดระวังโดยตั้งใจ — มันรายงานเฉพาะปัญหาที่พิสูจน์ได้ว่าน่าจะเป็น bug จริงเกือบแน่นอน staticcheck และ golangci-lint รัน analyzer มากกว่ามาก รวมถึงหลายตัวที่มีความเสี่ยง false-positive ที่ go vet หลีกเลี่ยง

staticcheck (honnef.co/go/tools/cmd/staticcheck) รันแบบ standalone และรายงานโค้ดของตัวเองที่ขึ้นต้นด้วย SA กับ bug printf และ unusedresult ตัวเดิมด้านบน ยืนยันแล้วจริง (staticcheck 2026.2.1):

main.go:6:13: Printf format %d has arg #1 of wrong type string (SA5009)
main.go:9:2: New doesn't have side effects and its return value is ignored (SA4017)

golangci-lint รวม linter หลายสิบตัว — รวมถึง go vet เอง, staticcheck และอีกมากมาย — ไว้หลังคำสั่งเดียวและ config file เดียว รูปแบบ config ของ version 2 (ยืนยันแล้วจริงกับ golangci-lint 2.13.2) เริ่มจาก:

version: "2"
linters:
default: standard

default: standard เปิดชุด baseline ที่คัดสรรมาแล้ว (govet, staticcheck, unused และอื่น ๆ) แทนที่จะเปิดทุกตัว — ชุด default ของ golangci-lint v1 หายไปใน v2 และ default เป็นตัวเลือกที่ระบุชัดเจนแล้ว การรันกับ bug copylocks ด้านบน ยืนยันแล้วจริง ห่อ finding ของ go vet ด้วยชื่อ linter ที่รายงานและเพิ่ม issue จริงที่ไม่เกี่ยวข้องในไฟล์เดียวกัน:

main.go:12:8: copylocks: assignment copies lock value to s2: ...Safe contains sync.Mutex (govet)
main.go:13:6: copylocks: assignment copies lock value to _: ...Safe contains sync.Mutex (govet)
main.go:6:2: field mu is unused (unused)
main.go:7:2: field count is unused (unused)

suffix (govet) และ (unused) บอกว่า linter ตัวไหนอยู่เบื้องหลังแต่ละบรรทัด มีประโยชน์เมื่อต้องตัดสินใจว่าจะ suppress อะไรและแก้อะไร

govulncheck เทียบ dependency ของคุณ (แม่นยำกว่านั้นคือ function ที่โค้ดคุณเรียกจริง ๆ) กับฐานข้อมูล vulnerability ของ Go

Terminal window
govulncheck ./...

ยืนยันแล้วจริงบน module ที่สะอาดไม่มี dependency ภายนอก:

No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 0 vulnerabilities in packages you import and 8
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.

ความแตกต่างในบรรทัดสุดท้ายนั้นคือประเด็นทั้งหมดของเครื่องมือนี้: การมี vulnerable function อยู่ที่ไหนสักแห่งใน dependency tree ไม่เหมือนกับโปรแกรมของคุณเรียกมัน govulncheck ทำ call-graph analysis เพื่อแยกสองอย่างนี้ออกจากกัน ซึ่งเป็นเหตุผลที่มันรายงานผลแบบ “ต้องแก้เดี๋ยวนี้” น้อยกว่า tool ที่แค่ “scan go.sum เทียบ CVE list” แบบไร้เดียงสามาก

go fix ถูกสร้างใหม่ใน Go 1.26 บน analysis framework เดียวกับ go vet แทน fixer ในอดีตไม่กี่ตัว ตอนนี้มี modernizer เป็นสิบ ๆ ตัว: analyzer ที่เขียน idiom แบบเก่าใหม่ให้เป็น standard library ปัจจุบัน

Terminal window
go fix ./... # apply every modernizer to the module
go fix -diff ./... # preview the rewrite without touching files

การเขียนใหม่ที่พบบ่อย: for loop ที่หาค่าต่ำสุดด้วยมือกลายเป็น min(...); sort.Slice กลายเป็น slices.Sort; for i := 0; i < n; i++ กลายเป็น for i := range n; สามบรรทัด wg.Add(1) / go / defer wg.Done() กลายเป็น wg.Go(...) (waitgroupgo); การ copy ตัวแปร loop แบบ x := x ถูกลบ Go 1.27 เพิ่ม atomictypes, embedlit, slicesbackward และ unsafefuncs ผู้เขียน library ยัง mark wrapper ด้วย //go:fix inline ได้ เพื่อให้ go fix inline การเรียกในทุกจุดใช้งาน — กลไกเดียวกับที่ standard library ใช้ย้าย caller ออกจาก helper ที่ deprecated

บรรทัด //go:build ที่ด้านบนของไฟล์ (ก่อน clause package ตามด้วยบรรทัดว่าง) บอก toolchain ให้รวมหรือตัดไฟล์นั้นตาม boolean expression ของ tags ยืนยันแล้วจริงกับสองไฟล์ที่ต่างกันแค่ build tag:

//go:build dev
package main
func banner() string { return "DEV BUILD" }
//go:build !dev
package main
func banner() string { return "production" }
Terminal window
go run . # production
go run -tags dev . # DEV BUILD

การใช้งานที่พบบ่อย: สลับ implementation จริงเป็น fake ตอน test, ตัดไฟล์เฉพาะ platform ออก (Go เอา tag จาก suffix ชื่อไฟล์แบบ _linux.go / _windows.go ให้อัตโนมัติด้วย) หรือ gate โค้ดที่ต้องการ Go version ใหม่กว่า floor ของ module

comment //go:generate mark คำสั่งให้รันเมื่อสั่ง — ไม่รันอัตโนมัติเด็ดขาด ยืนยันแล้วจริง:

//go:generate echo "generation would run here"
Terminal window
go generate ./...
# generation would run here

go build และ go test ไม่เรียก go generate ให้คุณเลย มันต้องเป็นขั้นตอนที่ตั้งใจใน workflow หรือ CI ของคุณ ปกติใช้กับ String() method ที่ generate โดย stringer, โค้ด protobuf, หรือการ generate mock

-ldflags "-X pkg.var=value" ตั้งค่าตัวแปร string ระดับ package ตอน link time — วิธีมาตรฐานในการฝัง version string หรือ commit hash ลง binary โดยไม่ต้อง compile source ใหม่:

var version = "dev"
func main() { fmt.Println("version:", version) }

ยืนยันแล้วจริง:

Terminal window
go build -o app . # version: dev
go build -ldflags "-X main.version=1.2.3" -o app . # version: 1.2.3

ตัวแปรต้องเป็น string ที่ไม่ initialize ตอน runtime (ไม่ใช่ const, ไม่ใช่ค่า default ที่คำนวณ) และ flag ต้องการ path เต็ม (main.version หรือ example.com/mod/pkg.Var สำหรับ package ที่ไม่ใช่ main)

-trimpath ลบ filesystem path ของเครื่อง local ออกจาก binary ที่ compile แล้ว (ไม่งั้นมันจะรั่วเข้าไปใน panic stack trace และ debug info) — ยืนยันแล้วจริงว่า build ผ่านโดยไม่มี error และเป็น practice มาตรฐานสำหรับ binary ที่คุณแจกจ่าย:

Terminal window
go build -trimpath -o app .

การตั้ง GOOS และ GOARCH cross-compile สำหรับ platform อื่นได้เลย — ไม่ต้อง download toolchain แยก เพราะ Go compiler เป็น cross-compiler ตัวเดียวสำหรับทุก target ที่รองรับ ยืนยันแล้วจริง สร้าง binary Linux/arm64 จาก macOS/arm64:

Terminal window
GOOS=linux GOARCH=arm64 go build -o app-linux-arm64 .
file app-linux-arm64
# app-linux-arm64: ELF 64-bit LSB executable, ARM aarch64 ..., statically linked
Terminal window
# Cross-compile a stripped, static Linux/amd64 binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/server ./cmd/server

-ldflags="-s -w" ลบ symbol table และ DWARF debug info ออก ลดขนาด binary CGO_ENABLED=0 รับประกัน binary ที่ static อย่างสมบูรณ์ — ไม่มี dynamic libc dependency และจำเป็นสำหรับ cross-compilation target ส่วนใหญ่ เพราะ cgo ต้องการ C cross-toolchain ที่ go build เฉย ๆ ไม่มี

go env อ่านและเขียน configuration ถาวรของ toolchain (เก็บนอก module ของคุณ ใน config file ต่อผู้ใช้) ตัวแปรที่สำคัญที่สุดในชีวิตประจำวัน:

ตัวแปรหน้าที่
GOPROXYgo get/go mod download ดึง module จากไหน (default https://proxy.golang.org,direct)
GOPRIVATEglob pattern ของ module path ที่จะดึงตรง ข้าม proxy และ checksum database — สำหรับ repository ภายใน/private
GOSUMDBchecksum database ที่ใช้ verify การ download (default sum.golang.org); ตั้งเป็น off เฉพาะเมื่อคุณเชื่อ GOPROXY เต็มที่
GOFLAGSflag ที่ต่อเข้ากับทุกคำสั่ง go โดยปริยาย เช่น GOFLAGS=-mod=mod
GOTOOLCHAINควบคุมการสลับ toolchain อัตโนมัติ (default auto) — ดู directive toolchain ในบท Modules
Terminal window
go env GOPROXY GOPRIVATE GOSUMDB
# https://proxy.golang.org,direct
#
# sum.golang.org

go env -w GOFLAGS=-mod=mod เขียนค่าถาวร; go env GOPROXY เฉย ๆ แค่อ่านค่าปัจจุบัน

gopls คือ official Go language server — Go support ของ editor หลักทุกตัว (VS Code, Neovim, protocol เบื้องหลังของ GoLand) คุยกับ gopls สำหรับ autocomplete, go-to-definition, inline diagnostics และ refactoring มันรัน check สไตล์ go vet ต่อเนื่องขณะคุณพิมพ์ นี่คือเหตุผลที่ bug หลายตัวในบทนี้เห็นเป็นเส้นหยักสีแดงก่อนที่คุณจะรันคำสั่งใด ๆ ด้วยซ้ำ goimports คือ gofmt บวกการจัดการ import อัตโนมัติ: มันเพิ่ม import ที่ขาดและลบที่ไม่ใช้ editor ส่วนใหญ่รันมันแทน gofmt เฉย ๆ ตอน save

ตั้งแต่ Go 1.21 การวาง CPU profile ชื่อ default.pgo ไว้ใน directory ของ package main ทำให้ go build ใช้มันอัตโนมัติ: compiler inline มากขึ้นตาม hot path ที่ profile ระบุ ปกติเพิ่ม throughput ไม่กี่ percent โดยไม่ต้องแก้ source เลย generate profile จาก production workload ตัวแทน (-cpuprofile กลไกเดียวกับบท Benchmarks) commit default.pgo คู่กับ source แล้ว go build จะหยิบมันมาใช้โดยไม่ต้องใส่ flag เพิ่ม

go doc แสดง documentation สำหรับ package, type, function, หรือ method ใดก็ได้ — โดยไม่ต้องออกจาก terminal

Terminal window
go doc fmt # package overview
go doc fmt.Println # specific function
go doc net/http.Request # type in a sub-package
go doc -all net/http # every exported symbol in the package
go doc -ex strings.Cut # print the runnable examples (Go 1.27)
go doc golang.org/x/[email protected] # a specific module version (Go 1.27)

สำหรับมุมมองแบบ browser รัน go doc -http (Go 1.25+) หรือเรียกดูที่ pkg.go.dev

Terminal window
test -z "$(gofmt -l .)" && \
go vet ./... && \
govulncheck ./... && \
go test -race -coverprofile=coverage.out -covermode=atomic ./... && \
go build ./...

pipeline นี้ตรวจจับ formatting drift, static bugs (รวมถึง go vet analyzer หกตัวด้านบน), known vulnerability, data races และ compilation errors ก่อนที่ code จะถูก merge

flowchart TD
  A["source code"] --> B["gofmt -l (formatting check)"]
  B --> C["go vet (copylocks, lostcancel, printf, ...)"]
  C --> D["staticcheck / golangci-lint (broader analysis)"]
  D --> E["govulncheck (known CVEs actually called)"]
  E --> F["go test -race (behavior + data races)"]
  F --> G["go build (compiles to a binary)"]
quality gate ทั่วไปของ Go เช็กที่แคบและเร็วที่สุดก่อน
สิ่งที่ได้ประโยชน์ต้นทุน
go vetตรวจหา common mistakes ก่อน runtime, built-in, ไม่ต้อง configระมัดระวังโดยตั้งใจ — ใช้ staticcheck เพิ่มเพื่อความครอบคลุมมากขึ้น
golangci-lintlinter หลายสิบตัวหลังคำสั่งเดียวและ config file เดียวต้องมี config file เพื่อปรับ (default: standard ของ v2 เป็นจุดเริ่มต้นที่สมเหตุสมผล); ช้ากว่า go vet เดี่ยว ๆ
govulncheckรู้ call-graph — flag เฉพาะสิ่งที่คุณเรียกจริง ไม่ใช่แค่สิ่งที่อยู่ใน go.sumทันสมัยเท่าที่ vulnerability database มี — CVE ใหม่ต้องใช้เวลากว่าจะปรากฏ
go build -raceตรวจหา race condition ใน binarybinary ช้าลง 2-10x — ไม่เหมาะ production build
PGO (default.pgo)throughput เพิ่มขึ้นไม่กี่ percent โดยไม่ต้องแก้ sourceprofile อาจล้าสมัยเมื่อ codebase เปลี่ยน — ต้อง regenerate เป็นระยะ
gofmt (ผ่าน go fmt)ไม่มีถกเถียงเรื่อง formatting, consistent ทั้ง ecosystemไม่มี config — ต้องยอมรับ canonical style
  • go fmt เป็นแค่ style เท่านั้น — gofmt เป็น de facto standard — project ส่วนใหญ่บังคับ gofmt ใน CI
  • go vet ตรวจสอบครบทุก bug — go vet ตรวจ subset ของ common mistakes ที่มันพิสูจน์ได้ด้วยความมั่นใจสูง — ใช้ staticcheck หรือ golangci-lint เพิ่มเพื่อความครอบคลุมมากขึ้น
  • go generate ทำงานอัตโนมัติตอน build — go generate ต้องรันเอง ไม่ได้รันตอน go build — ต้อง explicit ใน Makefile หรือ CI
  • GOPATH ยังจำเป็น — Go modules (go.mod) แทน GOPATH อย่างสมบูรณ์ตั้งแต่ Go 1.16
  • govulncheck รายงาน dependency ที่มี vulnerability แปลว่าต้อง patch ทันที — มันแยกรายงาน vulnerability ใน module ที่ require กับ module ที่ call-graph ของโค้ดคุณเข้าถึงจริง — เฉพาะอย่างหลังคือความเสี่ยงที่ active
  • -ldflags -X ตั้งค่าตัวแปรอะไรก็ได้ รวมถึง constant — มันตั้งได้เฉพาะตัวแปร string ที่ไม่ initialize เท่านั้น; const, type ที่ไม่ใช่ string, หรือตัวแปรที่มี initializer แบบคำนวณ ล้วนไม่ได้รับผลกระทบแบบเงียบ ๆ
สิ่งที่เปลี่ยนใน toolchain, Go 1.25–1.27
  • go mod init เขียนบรรทัด go เป็นเวอร์ชันจริงของ toolchain ที่รันมัน — ไม่ใช่ต่ำกว่าหนึ่ง minor version ยืนยันในเครื่องแล้ว: Go 1.26.5 เขียน go 1.26.5; รันด้วย Go 1.27.0 จะเขียน go 1.27.0 ยกหรือตรึงบรรทัดนี้เองด้วย go get [email protected] เมื่อต้องการ floor เฉพาะ
  • go doc example.com/[email protected] (Go 1.27) แสดง doc ของ module version ที่ระบุ และ go doc -ex แสดงรายการ example ที่รันได้
  • go doc -http (Go 1.25) เสิร์ฟ documentation ในเบราว์เซอร์ ไม่ต้องใช้ pkgsite สำหรับงานในเครื่องอีกแล้ว
  • go tool pprof -http (Go 1.26) เปิดมาที่หน้า flame graph; go tool trace -http=:6060 (Go 1.27) bind เฉพาะ localhost เว้นแต่ระบุ address เต็ม
  • go version -m -json (Go 1.24) พิมพ์ build info เป็น JSON; go build -asan (Go 1.25) รายงาน memory leak ตอนจบโปรแกรม
  • go fix (Go 1.26) ถูกสร้างใหม่บน analysis framework ของ go vet พร้อม modernizer หลายสิบตัว; Go 1.27 เพิ่ม atomictypes, embedlit, slicesbackward และ unsafefuncs
  • analyzer waitgroup และ hostport ของ go vet (Go 1.25) และ stdversion ที่ go test รันด้วยแล้ว (Go 1.27)

โค้ด generated compile ผ่าน ดังนั้นความเงียบของ compiler ไม่พิสูจน์อะไรเลย รัน pipeline ด้านบนก่อนเชื่อมัน และเช็กเรื่องพวกนี้เป็นพิเศษ:

  • struct ที่มี sync.Mutex, sync.WaitGroup หรือคล้ายกัน ถูก copy บ้างไหม — assign, ส่งแบบ value, return แบบ value, หรือวนใน slice element? copylocks ของ go vet จับสิ่งนี้ได้ แต่เฉพาะถ้าคุณรันมันจริง
  • wg.Add(1) ถูกเรียกจากข้างใน goroutine ที่มันควร track แทนที่จะเรียกก่อน go statement หรือเปล่า? นี่คือ race จริงที่ waitgroup จับได้ — และ pattern ที่โค้ด generated สร้างขึ้นบ่อยมากเพราะมันอ่านเรียงจากซ้ายไปขวาแบบธรรมชาติ
  • การเรียก context.WithCancel/WithTimeout/WithDeadline แต่ละครั้ง function cancel ที่ return มาถูกเรียก (ปกติผ่าน defer) หรือถูกทิ้งด้วย _?
  • http.Response (หรือ .Body ของมัน) ถูกอ่านหรือปิดก่อนเช็ก error ที่มาคู่กันหรือเปล่า?
  • config file อ้างว่า version: "2" สำหรับ golangci-lint แต่มี option เฉพาะ v1 หรือกลับกัน — config ที่ no-op แบบเงียบ ๆ แทนที่จะ fail หรือเปล่า?
  • govulncheck ถูกรันจริงหรือเปล่า หรือ CI config ที่ generate มามีแค่ go vet แล้วสมมติว่าเพียงพอแล้ว?

🐛 หา bug ในโค้ดนี้

func fetchAndLog(url string) {
resp, err := http.Get(url)
defer resp.Body.Close()
if err != nil {
log.Println("fetch failed:", err)
return
}
log.Println("status:", resp.StatusCode)
}
เฉลย

defer resp.Body.Close() รันก่อนเช็ก err != nil แต่เมื่อ http.Get fail resp จะเป็น nil — ดังนั้น resp.Body จึง panic ด้วย nil pointer dereference บน error path ที่โค้ดนี้พยายามจะจัดการอย่างสวยงามเป๊ะ ๆ analyzer httpresponse ของ go vet จับสิ่งนี้ได้ตรง ๆ: using resp before checking for errors วิธีแก้คือเช็ก error ก่อน แล้วค่อย defer การปิดเมื่อรู้แล้วว่า resp ไม่ใช่ nil: ย้าย if err != nil { ...; return } ขึ้นไปอยู่เหนือ defer resp.Body.Close()

ความแตกต่างระหว่าง go run และ go build คืออะไร?
go vet analyzer ตัวไหน flag โค้ดนี้ และเพราะอะไร?
type Safe struct {
    mu    sync.Mutex
    count int
}
func process(s Safe) { // takes Safe by value
    s.mu.Lock()
    defer s.mu.Unlock()
    s.count++
}
go vet analyzer waitgroup รายงานข้อความจริงอะไรที่นี่ และ race จริง ๆ คืออะไร?
go func() {
    wg.Add(1)
    defer wg.Done()
    doWork()
}()
wg.Wait()
govulncheck รายงาน '0 vulnerabilities in packages you import' แต่ '8 vulnerabilities in modules you require' หมายความว่าอะไร?
go vet ตรวจจับ issues ประเภทใดที่ compiler ไม่ตรวจ?
CI config ที่ generate มารันแค่ go vet ./... และ go build ./... ก่อน merge pipeline นี้พลาด bug ประเภทไหนที่การเพิ่ม govulncheck ./... และ go test -race ./... จะจับได้?