ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go编码规范与项目结构最佳实践

Go编码规范与项目结构最佳实践 Go编码规范与项目结构最佳实践摘要Go语言编码规范与项目结构最佳实践涵盖命名约定、标准目录布局cmd/internal/pkg、错误处理模式error wrapping/sentinel errors/errors.As、接口设计原则、gofmt与golangci-lint工具链使用。去年我接手了一个Go项目的重构。打开代码库的那一刻我直接愣住了所有业务逻辑塞在一个main.go里三千多行代码变量命名有驼峰有下划线还有拼音缩写错误处理一会儿return err一会儿直接panic。这个项目跑了半年三个开发人员各写各的谁也不服谁。我花了一周梳理目录结构又花一周统一编码风格。重构完之后新来的同事看代码说这项目结构清楚得跟官方示例似的。说实话Go的编码规范比Java和Python都简单关键是从头就遵守半路改成本太高。这篇是模块一的收官篇。前面我们讲了Go的基础语法、数据结构、函数和方法现在把这些东西组织成规范的项目。我从命名、目录、错误处理、接口四个方面讲每个都配代码。一、命名规范Go的命名规则一句话概括简洁至上。包名用小写单词不用下划线不用驼峰。变量名用驼峰导出的用大写字母开头私有的用小写字母开头。接口名如果只有一个方法习惯加er后缀。packageuser// 包名用小写单词简短有意义// 用user而不是user_service或userService// 导出类型用大写开头外部包可以访问typeUserServicestruct{// 私有字段用小写开头仅包内可见db DB cache Cache}// 私有类型用小写开头包外无法访问typeuserOptionsstruct{timeoutint}// 单方法接口加er后缀这是Go社区的惯例typeReaderinterface{Read(p[]byte)(nint,errerror)}// 多方法接口用大写开头一般不加er后缀typeUserServiceinterface{GetUser(idint)(*User,error)CreateUser(u*User)error}有个容易踩的坑缩写词的大小写处理。URL、ID、HTTP这些词要么全大写要么全小写别搞成Url、Id、Http。// 正确写法缩写词在导出时全大写typeUserstruct{IDint// 不是IdURLstring// 不是UrlHTTPstring// 不是Http}// 私有字段中缩写词全小写typeconfigstruct{urlstring// 私有时全小写httpstring// 私有时全小写}// 函数命名也一样缩写词保持一致funcparseURL(rawURLstring)(*url.URL,error){// URL全大写不管在什么位置returnurl.Parse(rawURL)}二、项目目录结构Go社区有一个广泛参考的目录布局核心思想是把可执行入口、内部包、公共包分开。myproject/ ├── cmd/ # 可执行程序入口 │ └── server/ │ └── main.go # server的入口文件 │ └── cli/ │ └── main.go # cli工具的入口文件 ├── internal/ # 内部包外部项目无法import │ ├── handler/ # HTTP处理器 │ ├── service/ # 业务逻辑 │ ├── repository/ # 数据访问层 │ └── model/ # 数据模型定义 ├── pkg/ # 公共包外部可以import │ ├── logger/ # 日志工具 │ └── utils/ # 通用工具函数 ├── api/ # API定义文件proto/openapi ├── configs/ # 配置文件 ├── go.mod └── go.suminternal目录是Go编译器层面强制的外部项目import你的internal包会直接报错。pkg目录没有强制限制约定俗成放公共工具。main.go只做初始化和启动业务逻辑全放internal里。// cmd/server/main.go// 入口文件只做三件事解析配置、初始化依赖、启动服务packagemainimport(myproject/configsmyproject/internal/handlermyproject/internal/service)funcmain(){// 第一步加载配置文件cfg:configs.Load()// 第二步初始化依赖从底层往上层组装userSvc:service.NewUserService(cfg.DB)userHandler:handler.NewUserHandler(userSvc)// 第三步启动HTTP服务// 路由注册的具体逻辑放在handler包里userHandler.Start(cfg.Port)}三、错误处理规范Go的错误处理没有try-catch用显式的error返回值。社区有两个约定错误要及时处理不要吞掉用fmt.Errorf做错误包装保留调用链。packageserviceimport(errorsfmt)// 哨兵错误用于特定的错误判断// 调用方可以用errors.Is来匹配var(ErrUserNotFounderrors.New(user not found)ErrInvalidInputerrors.New(invalid input))// 自定义错误类型携带更多上下文信息typeValidationErrorstruct{Fieldstring// 哪个字段出了问题Messagestring// 具体的错误描述}// 实现error接口的方法func(e*ValidationError)Error()string{returnfmt.Sprintf(validation failed on field %s: %s,e.Field,e.Message)}funcGetUser(idint)(*User,error){// 参数校验返回具体的错误类型ifid0{returnnil,ValidationError{Field:id,Message:must be positive,}}user,err:repo.FindByID(id)iferr!nil{// 用fmt.Errorf包装错误加上当前层的信息// %w动词保留原始错误调用方可以用errors.Is/errors.As穿透判断returnnil,fmt.Errorf(get user by id %d: %w,id,err)}ifusernil{// 返回哨兵错误调用方可以用errors.Is判断returnnil,ErrUserNotFound}returnuser,nil}调用方判断错误的方式。packagehandlerimporterrorsfuncHandleGetUser(idint){user,err:service.GetUser(id)iferr!nil{// 用errors.Is判断是不是某个哨兵错误// 比直接用比较更安全能穿透fmt.Errorf的包装iferrors.Is(err,service.ErrUserNotFound){writeJSON(404,user not found)return}// 用errors.As判断是不是某种错误类型// 能从错误包装链中提取出原始的错误结构体varvalidErr*service.ValidationErroriferrors.As(err,validErr){writeJSON(400,validErr.Error())return}// 其他未知错误返回500并记录日志log.Printf(unexpected error: %v,err)writeJSON(500,internal error)return}writeJSON(200,user)}四、接口设计原则Go的接口设计哲学是小接口大组合。标准库里Reader和Writer各自只有一个方法组合起来就是ReadWriter。接口定义在调用方而不是实现方这一点跟Java完全相反。packageservice// 接口定义在调用方只声明自己需要的方法// 不用关心实现是MySQL还是RedistypeUserRepointerface{FindByID(idint)(*User,error)Save(u*User)error}// 具体实现不需要显式声明实现了哪个接口// Go的接口是隐式实现的鸭子类型typemysqlUserRepostruct{db*sql.DB}func(r*mysqlUserRepo)FindByID(idint)(*User,error){// 从MySQL查询用户数据row:r.db.QueryRow(SELECT id, name FROM users WHERE id ?,id)// ...具体查询逻辑省略returnUser{},nil}func(r*mysqlUserRepo)Save(u*User)error{// 保存用户到MySQL数据库_,err:r.db.Exec(INSERT INTO users (name) VALUES (?),u.Name)returnerr}// 构造函数返回接口类型// 调用方只依赖接口不依赖具体实现funcNewUserRepo(db*sql.DB)UserRepo{returnmysqlUserRepo{db:db}}Go接口设计有一句口诀接受接口返回结构体。函数参数用接口类型让调用方传入任何实现了该接口的类型。返回值用具体结构体给调用方更多灵活性。独家踩坑循环导入有一次我重构项目把user相关的逻辑拆成了三个包handler调serviceservice调repository。一切看起来没毛病。但是product包需要查询用户信息我就直接import了user包的service。user包的service又要查商品库存又import了product包。编译的时候直接报错import cycle not allowed。排查了半天画了张依赖图才看清楚问题所在。Go不允许循环导入A引用B的同时B又引用A编译器直接拒绝编译。解决方案是抽接口。在user包里定义一个需要的接口product包实现这个接口在main里组装的时候把product的实现传给user。依赖方向就变成单向了。// internal/user/service.gopackageuser// 在user包里定义接口只声明需要的方法// 不依赖product包打破循环依赖typeStockCheckerinterface{CheckStock(productIDint)(int,error)}typeUserServicestruct{// 依赖接口不依赖product包的具体实现stockChecker StockChecker}// 通过构造函数注入实现funcNewUserService(sc StockChecker)*UserService{returnUserService{stockChecker:sc}}func(s*UserService)BuyProduct(userID,productIDint)error{// 调用接口方法不关心具体实现来自哪个包stock,err:s.stockChecker.CheckStock(productID)iferr!nil{returnfmt.Errorf(check stock: %w,err)}ifstock0{returnerrors.New(out of stock)}// ...后续购买逻辑returnnil}// internal/product/service.gopackageproduct// product包实现user包定义的接口// 但product包本身不需要import user包typeProductServicestruct{db*sql.DB}// 实现StockChecker接口隐式实现不需要implements关键字func(s*ProductService)CheckStock(productIDint)(int,error){varstockinterr:s.db.QueryRow(SELECT stock FROM products WHERE id ?,productID,).Scan(stock)returnstock,err}funcNewProductService(db*sql.DB)*ProductService{returnProductService{db:db}}// cmd/server/main.go// 在main里组装依赖关系打破循环funcmain(){db:initDB()// 先创建product serviceproductSvc:product.NewProductService(db)// 把product service传给user service作为StockChecker// 编译器会检查ProductService是否实现了StockChecker接口userSvc:user.NewUserService(productSvc)_userSvc// 启动服务...}对比分析跟Java比Go的项目结构简单很多。Java用Maven或Gradle管理依赖一个项目十几个模块每个模块有pom.xml或build.gradle。Go只要一个go.mod文件目录结构靠约定不需要构建工具配置。跟Python比Go有编译器层面的internal包保护外部项目无法import。Python的私有包靠命名约定下划线前缀实际还是能被导入。Go的错误处理是显式的每个可能出错的调用都要处理。Python靠try-except容易漏掉异常。Go的接口是隐式实现的Java需要implements关键字显式声明。Go的接口更适合事后抽象先写实现再提炼接口。Java需要先定义接口再写实现设计成本更高。总结这篇讲了Go的编码规范和项目结构核心就四条。命名要简洁一致目录用cmd/internal/pkg分层错误处理用fmt.Errorf包装加errors.Is判断接口设计要小而精且定义在调用方。模块一到这里就结束了。我们走完了Go的基础语法、数据结构、函数方法、编码规范这条线。从下一篇开始进入模块二并发编程。Go最拿手的就是并发goroutine和channel的设计让并发编程变得异常简单。下一篇我们聊聊goroutine看看它为什么比Java的线程轻那么多。
返回列表