请点击上方蓝字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深度学习与精进之旅!👇

参考资料
[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语言之禅
- GoCN社区Go读书会第二期:《Go语言精进之路》直播文字稿