公司动态

Go代码的二十条最佳实践指南,都给你总结好了

📅 2026/8/10 11:15:27
Go代码的二十条最佳实践指南,都给你总结好了
很多 Go 教程教你怎么写能跑的代码但很少有人教你怎么写让队友容易读的代码。下面整理了二十条实践指南不是关于语法的而是关于让你的代码在凌晨两点被人看的时候能够被快速理解的。1. 错误优先减少嵌套把错误情况先处理掉让正常路径保持在最低的缩进层级。深层嵌套的代码读起来很费力。// 不推荐正常逻辑被层层包裹funcloadConfig(pathstring)(*Config,error){ifpath!{data,err:os.ReadFile(path)iferrnil{iflen(data)0{returnparseConfig(data)}}}returnnil,nil}// 推荐先处理异常情况funcloadConfig(pathstring)(*Config,error){ifpath{returnnil,errors.New(empty path)}data,err:os.ReadFile(path)iferr!nil{returnnil,fmt.Errorf(read file: %w,err)}iflen(data)0{returnnil,errors.New(empty config file)}returnparseConfig(data)}把校验、错误检查和边缘情况放在函数开头核心逻辑自然就清晰了。2. 避免重复提取公共逻辑把重复的逻辑提取成辅助函数。在 Go 中小而专注的函数是惯用法也更容易组合。// 不推荐重复的校验逻辑funccreateOrder(totalfloat64)error{iftotal0{returnerrors.New(total must be positive)}// ...}funcupdateOrder(totalfloat64)error{iftotal0{returnerrors.New(total must be positive)}// ...}// 推荐提取公共校验funcvalidateTotal(totalfloat64)error{iftotal0{returnerrors.New(total must be positive)}returnnil}funccreateOrder(totalfloat64)error{iferr:validateTotal(total);err!nil{returnerr}// ...}3. 重要代码放前面把最重要的类型、构造函数和导出函数放在文件顶部。读者习惯从上往下扫描。// Package account 管理账户相关操作packageaccount// Account 是核心领域类型 —— 第一个定义typeAccountstruct{IDstringBalanceint64}// New 是主要构造函数 —— 第二个定义funcNew(idstring)(*Account,error){/* ... */}// 公开方法放在构造函数之后func(a*Account)Deposit(amountint64)error{/* ... */}// 私有辅助函数放在最后funcvalidateAmount(amountint64)bool{/* ... */}4. 给代码写注释每个导出的符号都必须有文档注释。注释是你 API 的一部分会出现在 godoc 和 IDE 的提示里。// Max 返回 a 和 b 中的较大值。// 如果两者相等返回 a。funcMax(a,bint)int{ifab{returna}returnb}文档注释应以符号名开头。5. 清晰比简洁更重要短名字在某些场景有用但清晰和意图总是更重要的。目标是降低阅读代码时的认知负担。// 不推荐过度冗长funccalculateAverage(values[]float64)float64{sumOfAllValues:0.0for_,individualValue:rangevalues{sumOfAllValuesindividualValue}returnsumOfAllValues/float64(len(values))}// 推荐简洁且清晰funcavg(vals[]float64)float64{vartotalfloat64for_,v:rangevals{totalv}returntotal/float64(len(vals))}规则作用域越短名字可以越短。循环索引用i、j接收者用类型名的 1-2 个字母。6. 包多文件按职责拆分把大包按职责拆分成多个文件避免一个巨型文件难以维护。order/ order.go — 核心类型、New()、接口定义 order_create.go — 创建订单操作 order_cancel.go — 取消订单操作 order_test.go — 所有测试同一个包内的文件共享命名空间可以自由拆分而不改变公共 API。7. 让你的包可以被 go get使用符合 VCS 主机的模块路径用语义化版本打标签。// go.modmodule github.com/yourorg/toolkitgo1.22// 打标签// git tag v1.0.0 git push origin v1.0.0//// v2 版本需要在路径中加 /v2// module github.com/yourorg/toolkit/v28. 接受最小的接口函数应该接受满足其需要的最窄接口而不是具体类型。这能让测试和扩展更容易。// 不推荐强制调用者使用 *os.FilefunccountWords(f*os.File)int{scanner:bufio.NewScanner(f)// ...}// 推荐适用于任何 io.ReaderfunccountWords(r io.Reader)int{scanner:bufio.NewScanner(r)// ...}9. 保持包的独立性避免导入循环和不必要的耦合。不相互依赖的包更容易测试和复用。cmd/api — 依赖所有东西顶层 internal/handler — 依赖 service internal/service — 依赖 store仅接口 internal/store — 仅依赖数据库驱动 pkg/logger — 不依赖任何东西叶子包10. 不要在 API 里启动 goroutine不要在导出函数里启动 goroutine 而不让调用者控制其生命周期。// 不推荐隐藏的 goroutine调用者无法取消或等待func(w*Worker)Start(jobs[]Job){gofunc(){for_,j:rangejobs{w.handle(j)}}()}// 推荐同步执行调用者可以决定是否用 gofunc(w*Worker)Start(ctx context.Context,jobs[]Job)error{for_,j:rangejobs{iferr:ctx.Err();err!nil{returnerr}w.handle(j)}returnnil}11. 用 goroutine 管理状态用单一 goroutine 作为状态所有者通过 channel 通信。比用互斥锁更容易推理所有权。// 推荐goroutine 拥有状态typeMetricsstruct{incchanstruct{}getchanint}funcNewMetrics()*Metrics{m:Metrics{inc:make(chanstruct{}),get:make(chanint),}gofunc(){count:0for{select{case-m.inc:countcasem.get-count:}}}()returnm}func(m*Metrics)Inc(){m.inc-struct{}{}}func(m*Metrics)Count()int{return-m.get}12. 避免 goroutine 泄露每个启动的 goroutine 都必须有明确的退出条件。泄露的 goroutine 会导致内存持续增长。// 不推荐如果 ch 永远不会关闭这个 goroutine 会永远阻塞funcconsume(ch-chanint){gofunc(){forv:rangech{fmt.Println(v)}}()}// 推荐用 context 作为退出开关funcconsume(ctx context.Context,ch-chanint){gofunc(){for{select{case-ctx.Done():returncasev,ok:-ch:if!ok{return}fmt.Println(v)}}}()}13. Context 作为第一个参数Google 的 Go 风格指南规定任何执行 I/O、RPC 或耗时的函数都应该把context.Context作为第一个参数。func(s*PaymentService)Process(ctx context.Context,orderIDstring)error{// 传递给下游调用iferr:s.db.ExecContext(ctx,UPDATE orders SET statuspaid WHERE id$1,orderID);err!nil{returnfmt.Errorf(update order: %w,err)}// ...}不要将 Context 存储在结构体中而是显式传递给每个需要它的函数。14. 用%w包装错误Go 1.13 开始fmt.Errorf(...: %w, err)可以保留错误链供errors.Is和errors.As使用。funcfetchUser(idstring)(*User,error){u,err:repo.FindByID(id)iferr!nil{returnnil,fmt.Errorf(find user %s: %w,id,err)}returnu,nil}// 调用者可以检查// errors.Is(err, repo.ErrNotFound)15. 明智地使用泛型Go 1.18 引入了泛型可以消除重复的 slice/map 工具函数。但不要过度抽象泛型用于可复用的数据结构而不是业务逻辑。// 通用工具函数funcReduce[T,U any](s[]T,init U,fnfunc(U,T)U)U{result:initfor_,v:ranges{resultfn(result,v)}returnresult}Go 1.21 标准库中的slices和maps包提供了生产级的泛型工具优先使用它们。16. 使用 log/slog 结构化日志Go 1.21 标准库引入了log/slog支持结构化和分级日志不再需要为了 JSON 日志引入第三方依赖。slog.Info(payment processed,orderID,orderID,amount,amount,currency,currency,)// 输出: {level:INFO,msg:payment processed,// orderID:ord_123,amount:99.99,currency:USD}17. 表驱动测试 t.Run每个测试都是一组用例。并行子测试能让 CI 更快失败也更具体。funcTestCalculateDiscount(t*testing.T){tests:[]struct{namestringamountfloat64percentfloat64expectedfloat64}{{正常折扣,100.0,10.0,90.0},{零折扣,100.0,0.0,100.0},{百分百折扣,100.0,100.0,0.0},}for_,tc:rangetests{tc:tc t.Run(tc.name,func(t*testing.T){t.Parallel()got:ApplyDiscount(tc.amount,tc.percent)ifgot!tc.expected{t.Errorf(got %v, want %v,got,tc.expected)}})}}18. 通过构造函数注入依赖通过构造函数传递依赖而不是用包级全局变量。全局状态会让测试不稳定并发代码也不安全。typeNotificationServicestruct{email EmailSender// 接口不是具体类型}funcNewNotificationService(email EmailSender)*NotificationService{returnNotificationService{email:email}}19. 用好 sync.Once、sync.Pool 和 sync.Map这些原语解决特定的并发模式用它们比用原始互斥锁更能表达意图。// sync.Once —— 只初始化一次varconfig*AppConfigvaronce sync.OncefuncGetConfig()*AppConfig{once.Do(func(){configloadConfig()})returnconfig}20. 模块规范使用 internal/版本锁定把实现细节藏在internal/目录下。在 CI 中运行go mod tidy。用go tool在go.mod中锁定工具版本。myapp/ ├── cmd/api/main.go ├── internal/ — 模块外不可导入 │ ├── handler/ │ └── service/ └── pkg/ — 可被他人导入 └── apierrors/internal/由编译器强制执行——任何试图从模块外导入它的包都会得到构建错误。这二十条实践的核心思想是代码是写给将来的人看的包括你自己。能跑只是起点能读才是终点。