Go项目设计的“七宗罪”?警惕那些流行的“反模式”

请点击上方蓝字TonyBai订阅公众号!

大家好,我是Tony Bai。

在软件开发这个行当里,“最佳实践”、“设计模式”、“标准规范”这些词汇总是自带光环。它们总是承诺会带来更好的代码质量、可维护性和扩展性。然而,当这些“圣经”般的原则被生搬硬套到Go语言的语境下时,有时非但不能带来预期的好处,反而可能把我们引入“歧途”,滋生出一些看似“专业”实则有害的“反模式”。

最近我也拜读了几篇国外开发者关于Go项目布局和设计哲学的文章,结合我自己这些年的实践和观察,我愈发觉得,Go社区中确实存在一些需要警惕的、流行的设计“反模式”。这些“反模式”很多人都或多或少的使用过,包括曾经的我自己。

在这篇文章中,我就总结一下我眼中的Go项目设计“七宗罪”,希望能帮助大家在实践中保持清醒,做出更符合Go精神的决策。

第一宗罪:为了结构而结构——过度分层与分组


表现:项目伊始,不假思索地创建pkg/、internal/、cmd/、util/、model/、handler/、service/ 等层层嵌套的目录,美其名曰“组织清晰”、“符合标准”。

危害:

  • 违背简洁:Go 的核心哲学是简洁。不必要的目录层级增加了认知负担和导航成本。

  • 过早抽象/耦合:在需求尚不明确时就划分 service、handler 等,可能导致错误的抽象边界和不必要的耦合。

  • pkg/ 的迷思:pkg/ 是一个过时的、缺乏语义的约定,Go官方在Go 1.4[1]时将Go项目中的pkg层次去掉了,Go官方的module布局指南[2]中也使用了更多有意义的名字代替了pkg。

  • internal/ 的滥用:它是 Go 工具链的一个特性,用于保护内部实现不被外部导入。但如果你的项目根本不作为库被外部依赖,或者需要保护的代码很少,强制使用 internal/ 只会徒增复杂性。

  • cmd/ 的误用:除非你的仓库包含多个独立的可执行文件,否则将单一的main.go放入cmd/毫无必要。

解药:保持扁平!从根目录开始,根据实际的功能或领域需要创建有意义的包。让结构随着项目的增长有机演化,而不是一开始就套用模板。

注:笔者当年也是pkg的“忠实粉丝”,新创建一个项目,无论规模大小,总喜欢先将pkg目录预创建出来。现在是时候根据项目的演进和规模的增长来判断是否需要"pkg"这个有点像“namespace”的目录了,即当你有多个希望公开的库时,是否用pkg/作为一个顶层分组,这个是要基于项目的实际情况进行判断的。

第二宗罪:无效的“美化运动”——无价值的重构与移动


表现:为了让代码看起来“更干净”、“更符合某种设计模式”或“消除Linter警告”,在没有明确收益(修复 Bug、增加功能、提升性能、解决安全问题)的情况下,大规模地移动代码、修改变量名、调整文件结构。

危害:

  • 浪费时间精力:投入大量时间做无意义的表面文章。

  • 引入风险:任何修改都有引入新 Bug 的风险,没有价值的修改更是得不偿失。

  • 增加 Code Review 负担:团队成员需要花费时间理解这些非功能性的变更。

  • 违背价值驱动:软件工程的核心是交付价值,而不是追求代码的“艺术感”。

解药:坚持价值驱动的变更!在做任何结构或代码调整前,严格拷问自己:这个改动解决了什么真实的、当前存在的问题?它的收益是否能明确衡量并大于风险?

第三宗罪:接口的“原罪”——过早、过度的抽象


表现:

  • 在只有一个具体实现的情况下,就为其定义接口。

  • 定义庞大、臃肿的接口,包含过多方法。

  • 为了“可测试性”而无脑地给所有东西加上接口。

危害:

  • 不必要的抽象:接口是为了解耦和多态。在不需要这些时引入接口,只会增加代码量和理解成本。

  • 弱化抽象能力:“接口越大,抽象越弱”(来自Go谚语[3])。大接口难以实现和维护,它变得模糊,难以理解哪些方法是真正必要的,也失去了其作为“契约”的精准性。

  • 阻碍演化:过早定义接口可能锁定不成熟的设计,后续修改成本更高。

  • 测试的借口:Go拥有强大的测试工具(如表驱动测试[4]),很多时候并不需要接口来实现可测试性。为测试而引入的接口可能扭曲生产代码的设计。

解药:

  • 拥抱具体:先写具体实现。

  • 发现接口,而非设计接口:只有当你确实需要多种实现(包括测试中的Mock[5],但要谨慎对待),或者需要打破循环依赖时,才考虑提取接口。

  • 保持接口小巧、正交:遵循接口隔离原则。

第四宗罪:“大杂烩”的诱惑——utils/common/shared 黑洞


表现:创建一个名为 utils、common、shared 或 helpers 的包,把各种看似“通用”的函数、类型塞进去。

危害:

  • 职责不清:这些包缺乏明确的领域或功能归属,成为代码的“垃圾抽屉”。

  • 依赖洼地:随着项目增长,这些包往往会依赖越来越多的其他包,同时也被越来越多的包依赖,极易引发循环依赖或成为构建瓶颈。

  • 降低内聚性:本应属于特定领域的功能被剥离出来,破坏了原有包的内聚性。

解药:

  • 就近原则:如果一个“工具函数”只被一个包使用,就把它放在那个包里(可以是私有的)。

  • 功能归类:如果一个“工具函数”被多个包使用,思考它真正属于哪个功能领域,为其创建一个有意义的新包(例如 applog 而不是 logutil)。

  • 思考依赖方向:真正通用的基础库(如自定义的 string 处理、时间处理)应该处于依赖关系图的底层,不应依赖上层业务逻辑。

注:坦白说,其他几项“罪过”或许还只是部分开发者的“偶发行为”,但这“第四宗罪”——随手创建 utils 或 common 包——恐怕是我们绝大多数人都曾犯过,甚至习以为常的“通病”。笔者也是如此:)。

第五宗罪:对 DRY 的“迷信”——为了“不重复”而引入不当依赖


表现:为了避免几行相似代码的重复,强行提取公共函数或类型,并为此引入新的包依赖,有时甚至导致复杂的依赖关系或循环依赖。

危害:

  • 错误的抽象:有时看似重复的代码,在不同的上下文中可能有细微的差别或独立演化的需求。强行合并可能导致错误的抽象。

  • 不必要的耦合:为了共享几行代码而引入整个包的依赖,增加了耦合度,可能比少量重复代码的维护成本更高。

  • 违背 Go 谚语:“A little copying is better than a little dependency.”(一点复制代码胜过一点点依赖)。Go 社区鼓励在权衡后接受适度的代码重复,以换取更低的耦合度和更高的独立性。

解药:

  • 批判性看待重复:看到重复代码时,先思考它们是否真的是“同一件事”?它们的演化趋势是否一致?

  • 权衡成本:引入依赖的成本(耦合、潜在冲突、维护负担)是否真的低于复制代码的成本?

  • 优先考虑简单:在不确定时,保持简单,适度复制代码通常更安全。

注:这种事儿,恐怕咱们自己或者团队里都遇到过不少:就为了用里面那一两个小函数,咔嚓一下,引入了一个庞大无比的依赖库。

第六宗罪:盲目崇拜与跟风——“伪标准”与“最佳实践”的陷阱


表现:

  • 不加批判地复制某个“明星项目”或所谓的“Go 标准项目布局”(如已被社区诟病的golang-standards/project-layout[6])。

  • 将其他语言(如 Java, C#)的复杂模式生搬硬套到 Go 项目中。

  • 将任何 Linter 规则或所谓的“最佳实践”奉为圭臬,不考虑具体场景。

危害:

  • 脱离实际:别人的“最佳实践”是基于他们的特定问题和上下文演化而来的,未必适合你的项目。

  • 扼杀思考:放弃了基于自己项目需求进行独立思考和决策的机会。

  • 违背Go文化:Go 推崇实用主义和具体问题具体分析,而非僵化的教条。

解药:

  • 保持独立思考:理解每个模式或实践要解决的原始问题是什么,它是否在你的项目中真实存在?

  • 以我为主,兼收并蓄:学习和借鉴,但最终决策要基于你自己的项目需求、团队情况和对 Go 语言的理解。

  • 质疑“最佳”:没有万能的“最佳实践”,只有在特定上下文中的“较好实践”。

注:确实,很多Go初学者(甚至一些老手,包括我自己)都曾长期困惑甚至“抱怨”:官方为何不给出一个项目布局的指导呢?这个呼声持续多年后,Go官方终于在2023年发布了一份官方布局指南[7]。这份指南无疑是我们理解官方思路、开始设计Go项目布局的一个重要起点。

第七宗罪:与“引力”对抗——忽视 Go 的依赖约束


表现:

  • 设计出隐含循环依赖的架构(例如,某些复杂的 ORM 模式,或者 Service 层与 Repository 层相互调用具体类型)。

  • 当遇到 import cycle not allowed 错误时,不从根本上调整结构,而是通过滥用接口、全局变量或 init() 函数等“技巧”来绕过编译错误。

危害:

  • 与语言对抗:Go禁止循环依赖是其核心设计之一,旨在强制形成清晰的、可管理的依赖关系图 (DAG)。试图绕过它,本质上是在与语言的设计哲学对抗。

  • 隐藏的复杂性:用“技巧”解决循环依赖,只是将问题扫到地毯下,使得真实的依赖关系变得模糊不清,增加了维护难度。

  • 错失优化机会:循环依赖往往是代码职责不清、耦合过度的信号。解决循环依赖的过程,本身就是一次优化架构、厘清职责的好机会。

解药:

  • 拥抱 DAG:理解并尊重 Go 的依赖规则,将其视为架构设计的“向导”。

  • 分析依赖:当出现循环依赖时,深入分析其根源,理解是哪个环节的职责划分或耦合出了问题。

  • 结构性解决:优先使用移动代码、提取新包(向上或向下)等结构性方法来打破循环。接口解耦是可用手段,但不应是首选或唯一手段。

小结:回归常识,拥抱简洁

Go语言的设计哲学是务实和简洁。许多所谓的“最佳实践”和“复杂模式”,在Go的世界里可能水土不服。识别并避免上述这些“反模式”,需要我们:

  • 保持批判性思维:不盲从,不跟风,时刻追问“为什么”。

  • 坚持价值驱动:让每一个设计决策都服务于解决真实问题。

  • 深刻理解Go:尊重其核心约束(如无循环依赖),发挥其优势(如简洁性)。

  • 拥抱演化:从简单开始,让架构随着需求的明确而有机生长。

希望这篇“七宗罪”的总结能给大家带来一些警示和启发。你是否也曾在项目中遇到过这些“反模式”?你认为还有哪些Go设计中需要警惕的“坑”?欢迎在评论区分享你的看法和经验!

也别忘了点个【赞】和【在看】,让更多Gopher看到这篇“反模式”的总结!


避开这些设计“反模式”是迈向Go高手的关键一步。如果你渴望更深层次地理解Go语言精髓,与顶尖Gopher交流切磋,并紧跟Go+AI前沿动态…

那么,我的「Go & AI 精进营」知识星球正是你需要的!在这里,你可以沉浸式学习【Go原理/进阶/避坑】等独家深度专栏,随时向我提问获 得解析,并与高活跃社区成员碰撞思想火花。

扫码加入,开启你的Go深度学习与精进之旅!👇

img{512x368}

参考资料

[1]

Go 1.4:https://tonybai.com/2014/11/04/some-changes-in-go-1-4

[2]

Go官方的module布局指南:https://go.dev/doc/modules/layout

[3]

Go谚语:https://go-proverbs.github.io/

[4]

表驱动测试:https://tonybai.com/2024/01/01/go-testing-by-example

[5]

测试中的Mock:https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators

[6]

golang-standards/project-layout:https://github.com/golang-standards/project-layout

[7]

官方布局指南:https://tonybai.com/2023/10/05/the-official-guide-of-organizing-go-project/

点击下面标题,阅读更多干货!

-  Go语言之禅

Go项目目录该怎么组织?官方终于出指南了!

聊聊Go语言的全局变量

GoCN社区Go读书会第二期:《Go语言精进之路》直播文字稿

Go语言之父的反思:我们做对了什么,做错了什么

Go编程语言与环境:万字长文复盘导致Go语言成功的那些设计决策

AI会写Go代码了,初学者还需要系统学习吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值