这很复杂,但不必如此
应用程序结构复杂。
良好的应用结构可以改善开发者的体验。它可以帮助你隔离他们正在工作的内容,而不需要把整个代码库记在脑子里。一个结构良好的应用程序可以通过解耦组件并使其更容易编写有用的测试来帮助防止错误。
一个结构不良的应用程序可能会起到相反的作用;它可能会使测试更加困难,它可能会使找到相关的代码成为挑战,它可能会引入不必要的复杂性和冗长的语言,使你的速度变慢而没有任何实际好处。
最后一点很重要 - 使用比需求复杂得多的结构实际上对项目的伤害大于帮助。
我在这里写的东西可能对任何人都不是新闻。程序员很早就被告知组织他们的代码的重要性。无论是命名变量和函数,还是命名和组织文件,这几乎是每一个编程课程中早期涉及的主题。
所有这些都引出了一个问题——为什么要弄清楚如何构建 Go 代码这么难?
按上下文组织
在过去的 Go Time 问答集中,我们被问到有关构建 Go 应用程序的问题,Peter Bourgon 回答如下:
很多语言都有这样的惯例(我猜),对于同一类型的项目,所有的项目结构都大致相同......比如,如果你用 Ruby 做一个 web 服务,你会有这样的布局,包会以你使用的架构模式命名。例如,MVC;控制器等等。但是在Go中,这不是我们真正要做的。我们有包和项目结构,基本上反映了我们正在实现的东西的领域。不是我们使用的模式,不是脚手架,而是我们正在工作的项目领域中的特定类型和实体。
因此,根据定义,它总是在习惯上因项目而异。在一个地方有意义的东西在另一个地方就没有意义了。不是说这是做事的唯一方法,但这是我们倾向于做的事情......因此,是的,没有答案,那种关于语言中成语的真相让很多人感到非常困惑,结果可能是错误的选择......我不知道,但我想这是主要的问题。
Peter Bourgon on Go Time #147. 重点是我加的。
总的来说,大多数成功的 Go 应用程序的结构并不是可以从一个项目复制/粘贴到另一个项目。也就是说,我们不能采用通用文件夹结构并把其复制到一个新的应用程序中,并期望它能正常工作,因为新的应用程序很可能有一套独特的上下文来工作。
其寻找要复制的模板,不如开始考虑应用程序的上下文。为了帮助您理解我的意思,让我们尝试了解如何构建用于托管我的 Go Courses 的 Web 应用程序。
背景信息:Go courses 应用程序是一个网站,学生在这里注册课程并查看课程中的个别课程。大多数课程都有一个视频部分,链接到课程中使用的代码,以及其他相关信息。如果你曾经使用过任何视频课程网站,你应该对它的外观有一个大致的了解,但如果你想进一步挖掘,你可以免费注册 Gophercises。
在这一点上,我对应用程序的需求相当熟悉,但我要试着引导你了解我最初开始创建应用程序时的思考过程,因为这是你也要经常开始的状态。
让我们开始,我想考虑两个主要界面:
- The student context. 学生界面
- The admin/teacher context. 管理员老师界面
学生界面是大多数人所熟悉的。在这种情况下,用户登录帐户,查看包含他们有权访问的课程的仪表板,然后可以向下导航到课程中的各个课程。
管理员的界面有点不同,大多数人不会看到。作为一个管理员,我们不太担心消费课程,而更关心如何管理它们。我们需要能够在课程中添加新的课程,更新现有课程的视频,以及更多。除了能够管理课程之外,管理员还需要管理用户、购买和退款。
为了创建这种分离,我的 repo 将从两个包开始:
admin/ ... (some go files here) student/ ... (some go files here)
通过分离这两个包,我能够在每个上下文中以不同的方式定义实体。例如,
从学生的角度来看,课程 Lesson
主要由指向资源的 URL 组成,并且它具有用户特定的信息,如 CompletedAt
字段,该字段用来显示该特定用户何时/是否完成了这门课程。
package student type Lesson struct { Name string // Name of the lesson, eg: "How to run a test" Video string // URL to the video for this lesson. Empty if the user // doesn't have access to this. SourceCode string // URL to the source code for this lesson. CompletedAt *time.Time // A boolean representing whether or not the lesson // was completed by this user. // + more }
同时,管理员界面的课程类型就没有 CompletedAt
字段,因为这在此上下文中没有意义。该信息仅与查看课程的登录用户的上下文相关,而不是作为管理课程内容的管理员。
相反,管理员课程类型将提供对 Requirement
等字段的访问,这些字段将被用来确定用户是否可以访问内容。其他字段看起来也会有些不同; Video
字段可能不是视频的URL,而是关于视频的托管地点的信息,因为这是管理员更新内容的方式。
package admin // Using inline structs for brevity in this example type Lesson struct { Name string // A video's URL can be constructed dynamically (and in some cases with time // limited access tokens) using this information. Video struct { Provider string // Youtube, Vimeo, etc ExternalID string } // Information needed to determine the URL of a repo/branch SourceCode struct { Provider string // Github, Gitlab, etc Repo string // eg "gophercises/quiz" Branch string // eg "solution-p1" } // Used to determine if a user has access to this lesson. // Usually a string like "twg-base", then when a user purchases // a course license they will have these permission strings linked to // their account. Prob not the most efficient way to do things, but works // well enough for now and makes it really easy to make packages down the // road that provide access to multiple courses. Requirement string }
我选择走这条路是因为我相信这两种情况的变化足以证明分离是合理的,但我也怀疑两者都不会发展到足以证明任何进一步组织的合理性。
我可以以不同的方式组织这段代码吗?绝对地!
我可能改变结构的一种方式是将其进一步分开。例如,一些进入 admin
包的代码与管理用户有关,而另一些代码与管理课程有关。把它分成两个背景是很容易的。另外,我可以把所有与认证有关的代码--注册、修改密码等--拉出来,放在一个 auth
包里。
与其想太多,我发现选择看起来相当合适的东西并根据需要进行调整更有用。
包作为层
另一种分解应用程序的方法是依赖。 Ben Johnson 在 gobeyond.dev 上对此进行了很好的讨论,特别是在文章 包为层,而不是组中。这个概念与 Kat Zien 在她的 GopherCon 演讲“你如何构建你的 Go 应用程序”中提到的 六边形架构非常相似。
在高层次上,我们的想法是我们有一个核心领域,我们可以在其中定义我们的资源以及我们用来与之交互的服务。
package app type Lesson struct { ID string Name string // ... } type LessonStore interface { Create(*Lesson) error QueryByPermissions(...Permission) ([]Lesson, error) // ... }
使用 Lesson
这样的类型和 LessonStore
这样的接口,我们可以编写一个完整的应用程序。如果没有 LessonStore
的实现,我们就无法运行我们的程序,但我们可以编写所有的核心逻辑,而不必担心它是如何实现的。
当我们准备好实现像 LessonStore
这样的接口时,我们会给我们的应用程序添加一个新的层。在这种情况下,它可能是以 sql
包的形式出现的。
package sql type LessonStore struct { db *sql.DB } func Create(l *Lesson) error { // ... } func QueryByPermissions(perms ...Permission) ([]Lesson, error) { // ... }
如果您想阅读有关此策略的更多信息,我强烈建议您在 https://www.gobeyond.dev/ 上查看 Ben 的文章。
按层打包的方法似乎与我在 Go courses 中选择的方法大相径庭,但实际上混合匹配这些策略要比最初看起来容易得多。例如,如果我们把管理员和学生各自当作一个定义了资源和服务的域,我们就可以使用按层打包的方法来实现这些服务。下面是一个使用 admin
包域和 sql
包的例子,sql
包有一个 admin.LessonStore
的实现。
package admin type Lesson struct { // ... same as before } type LessonStore interface { Create(*Lesson) error // ... }
package sql import "github.com/joncalhoun/my-app/admin" type AdminLessonStore struct { ... } func (ls *AdminLessonStore) Create(lesson *admin.Lesson) error { ... }
这是应用程序的正确选择吗?我不知道。
使用这样的接口确实可以更容易地测试较小的代码段,但这只有在它提供真正的好处时才重要。否则,我们最终会编写接口、解耦代码并创建新的包,而没有真正的好处。基本上,我们正在为自己创造忙碌的工作。
唯一错误的决定就是没有决定
除了这些结构之外,还有无数种结构(或没有结构)代码的方法,根据不同的环境,这些方法也是有意义的。我曾在一些项目中尝试过使用扁平结构--一个单一的包,我仍然对这种结构的效果感到震惊。当我刚开始写 Go 代码时,我几乎只使用 MVC 。这不仅比整个社区可能导致你相信的要好,而且它让我摆脱了因不知道如何构造我的应用程序而导致的决策瘫痪,因此,甚至不知道从哪里开始。
在 Q&A Go Time 一集中,我们被问到如何构建 Go 代码,Mat Ryer 表达了没有一套固定的代码结构方式的好处:
我认为虽然说没有真正的方法可以做到这一点可能会很*,这也意味着你不能真的做错。它适合您的情况。
现在我有很多使用 Go 的经验,我完全同意 Mat。能够决定适合每个应用程序的结构是一种解放。我喜欢没有固定的做事方式,也没有真正错误的方式。尽管现在有这种感觉,但我还记得在我经验不足的时候,因为没有具体的例子而感到非常沮丧。
事实是,如果没有一些经验,决定哪种结构适合您的情况几乎是不可能的,但感觉就像我们在获得任何经验之前*做出决定一样。这是一个 catch-22,甚至在我们开始之前就阻止了我们。
catch-22,象征人们处在一种荒谬的两难之中。翻译家黄文范把这种状况翻译为“坑人二十二”。如:一个人因为没有工作经验而不能得到工作,但是他又因为没有工作而得不到工作经验。
我没有放弃,而是选择了使用我所知道的东西--MVC。这使我能够编写代码,获得一些工作,并从这些错误中学习。随着时间的推移,我开始理解其他的代码结构方式,我的应用程序与MVC的相似度越来越低,但这是一个非常渐进的过程。我怀疑,如果我强迫自己立即弄好应用程序的结构,我根本就不会成功。我最多只能在经历了大量的挫折之后获得成功。
绝对正确的是,MVC 永远不会像为项目量身定做的应用结构那样提供清晰的信息。同样正确的是,对于一个几乎没有围棋代码结构经验的人来说,发现项目的理想应用结构并不是一个现实的目标。它需要实践、实验和重构来获得正确的结果。MVC 是简单而平易近人的。当我们没有足够的经验或背景来想出更好的东西时,它是一个合理的起点。
总的来说
正如我在本文开头所说,良好的应用程序结构旨在改善开发人员体验。它旨在帮助您以对您有意义的方式组织代码。这并不意味着让新人瘫痪并不确定如何继续。
如果您发现自己卡住了并且不确定如何继续,请问自己什么更有效率 - 保持僵住,还是选择任何应用程序结构并尝试一下?
前者什么也做不了。对于后者,即使你做错了,你也可以从中吸取教训,下次做得更好。这听起来比从不开始要好得多。