当前位置: 首页 > 科技观察

如何组织Go代码?Go作者的回答惊呆了

时间:2023-03-15 10:38:24 科技观察

这是被问的最多的问题之一。您可以在Internet上找到此问题的答案。不过,我不确定我的设计是否100%正确,但希望能给大家一些参考。前段时间,我有幸见到了RobertGriesemer[1](Go的作者之一)。我们问他这个问题:“如何组织Go代码?”。他说:“我不知道。”-这显然不令人满意,我知道。当我们问他如何设计代码时,Robert说他总是从平面结构开始,并在必要时创建包。我花了很多时间在生产应用程序的两个宠物项目中尝试不同的方法。在本文中,我将向您展示所有选项并告诉您它们的优缺点。阅读这篇博文后,您将不会有“一种模式可以统治一切”。01在我们开始之前无论你如何组织你的代码,你都必须考虑阅读它的人。底线是你不应该让你的贡献者或同事思考。把所有东西都放在明显的地方。不要重新发明轮子。你还记得RobPike[2]对gofmt所说的话吗?没有人喜欢gofmt的风格,但gofmt是每个人的最爱。您可能不喜欢众所周知的模式。对我们和整个社区来说,继续前进会更好。但是,如果您有充分的理由做出不同的决定,那也很好。如果您的包设计良好,源代码树将很好地反映这一点。让我们从文档开始。每个开源Go项目在pkg.go.dev[3]上都有其文档。对于每个包,您首先看到的是包的概览。以net/http包为例,在描述每个公共函数、常量或类型之前,需要先描述一下这个包提供了什么。您可以从中了解如何使用API以及更深入的详细信息。概述是从哪个来源生成的?net/http包有一个doc.go[4]文件,作者在其中放置了包的一般描述。您可以将此概述放在文件夹中的任何其他文件中,但doc.go是公认的标准。那么自述文件中应该包含什么?首先,该项目的总体概述-它的目标。然后你应该有一个快速入门部分,你可以在其中描述你应该做什么来开始处理项目,包括任何依赖项,如Docker或我们正在使用的任何其他工具。您可以在此处找到基本示例或更详细地描述该项目的外部网站链接。您必须记住,应该留在这里的内容取决于项目。你必须从读者的角度来思考。什么信息对他们最重要?当您有更多文档要提供时,将它们放在docs文件夹中。不要将它们隐藏在/common/docs之类的地方。这种方法有一些好处:它很容易找到,并且您可以在一个拉取请求中更改公共API及其文档。您不必克隆另一个存储库并在它们之间同步更改。我的下一个建议可能会让您大吃一惊。我推荐使用众所周知的工具,例如make,而且我知道有一些替代品,例如sake[5]、mage[6]、zim[7]或opctl[8]。问题是要开始使用它们,您必须学习它们。如果任何项目使用不同的自动化工具,新维护者将更难上手。我的观点是你应该明智地选择你的工具。您使用的工具越多,开始就越难,尤其是对于新人。我一直在做一个项目,我必须在本地运行2个不同的依赖项,在CLI中登录AWS帐户,然后连接到虚拟网络以在我的PC上运行测试。基本设置需要一两天才能完成,我想我不必告诉你这些测试有多不稳定[9]。关于linting,推荐使用golangci-lint[10]。启用对您的项目来说似乎合理的任何linter。通常使用默认启用的linter规则可能是一个好的开始。02Flatstructure(singlepackage)让我们从最推荐的方法开始:只要不强制添加新包,就将整个代码保留在根文件夹中。在项目之初,这种做法确实不错。当我开始使用它并且对它最终如何工作有一个模糊的想法时,我发现它很有帮助。将所有内容放在一个地方有助于避免包之间的循环依赖。当你将一些东西放入一个单独的包中时,你发现你需要根文件夹中的其他东西,你不得不为这个共享依赖创建第三个包。随着项目的进展,情况变得更糟。您最终可能会得到许多包,其中大部分几乎完全依赖于其他包。许多函数或类型必须是公共的。这种情况模糊了API,使其更难阅读、理解和使用。使用单个包,您不必在文件夹之间跳转并考虑架构,因为一切都在一个地方。这并不意味着您必须将所有内容都保存在一个文件中,例如:courses/main.goserver.gouser_profile.golesson.gocourse.go在上面的示例中,每个逻辑部分都组织到一个单独的文件中。当您犯错并将结构放入错误的文件时,您所要做的就是将其剪切并粘贴到新位置。您可以这样想:单个文件代表应用程序的实体部分。您可以按代码(HTTP处理程序、数据库存储库)或它提供的内容(管理用户配置文件)对代码进行分组。当您需要某样东西时,您知道在哪里可以找到它。什么时候创建新包?如果您有充分的理由这样做,例如:1)当您有不止一种方式启动您的应用程序时假设您有一个项目并希望以两种模式运行它:CLI命令和WebAPI。在这种情况下,通常创建一个/cmd包并包含cli和web子包。courses/cmd/cli/main.goconfig.goweb/main.goserver.goconfig.gouser_profile.golesson.gocourse.go您可以将多个main()函数放入单个文件夹中的单独文件中。要运行它们,您必须提供一个明确的要编译的文件列表,并且只有其中一个是main()。这使得应用程序的运行非常复杂。更简单的方法是直接输入gorun./cmd/cli。当您有子包时,使用./cmd/文件夹可能听起来过于复杂。我发现它在我需要添加时很有用,例如,使用来自消息代理的消息。本主题在拆分依赖项部分中有更详细的介绍。2)当您想提取更详细的实现时,标准库是一个很好的例子。让我们看看net/http/pprof[11]包。net包为网络I/O提供了一个可移植的接口,包括TCP/IP、UDP、域名解析和Unix域套接字。您可以根据此软件包提供的内容构建您想要的任何协议。net/http包使我们能够发送或接收HTTP请求。HTTP协议使用TCP/UDP,那么http包自然是net的子包。net/http/pprof包中的所有类型和方法都可以返回HTTP协议,自然是一个http子包。database/sql包也是如此。如果你有更多的非关系数据库的实现,它们将被放在数据库包下,与sql包处于同一级别。你能看出其中的规律吗?数据包在树中越深,传递的细节就越多。换句话说,每个子包都在父包上提供了更具体的实现。3)当你开始为密切相关的事物添加公共前缀时一段时间后,你可能会注意到你开始为函数或类型添加前缀或后缀以避免误解或命名冲突。通过这样做,我们试图在我们的项目中模拟一个丢失的包,这可能是一个好兆头。很难判断何时拉取了新的子包。每当你看到一个新的子包提高API可读性并使代码更清晰时,就拉入一个新的子包。r:=networkReader{}//r:=network.Reader{}如您所见,扁平结构既简单又强大。在某些用例中,您可能会发现它有用且有帮助。这种组织代码的方式不仅适用于小型项目或新项目。这是一个遵循单包模式的库示例:https://github.com/go-yaml/yamlhttps://github.com/tidwall/gjson值得记住的是,您不需要坚持这个组织不惜一切代价的代码方式。保持简单是有原因的,但添加更多包可能会使您的代码更好。不幸的是,没有灵丹妙药。您需要做的是尝试询问您的同事或维护者哪个选项对他们来说更具可读性。03模块化前面介绍的代码组织方式在某些场景下可能效率不高。我花了很多时间试图获得“正确”的项目结构。一段时间后,我注意到对于业务应用程序,我开始尝试另一种类似的组织代码的方式。当我们开发直接为客户服务的应用程序时,扁平结构可能效率不高。您想要创建模块来提供一组与控制器、基础设施或您的业务领域的一部分相关的功能。让我们仔细看看两种最流行的方法,并谈谈它们的优缺点。按种类组织此模型很受欢迎。我认识的人中没有人提倡使用这种策略来组织代码,但我在新旧项目中都发现了它。按种类组织是一种策略,它试图通过根据结构将部分放入桶中来为过于复杂的代码单元带来秩序。通常将包称为存储库或模型。这样做的结果是,您将创建像utils或helpers这样的包,因为您认为应该将函数或结构放在一个地方,但找不到合适的地方。.├──handlers│├──course.go│├──lecture.go│├──profile.go│└──user.go├──main.go├──models│├──course.go│├──lecture.go│└──user.go├──repositories│├──course.go│├──lecture.go│└──user.go├──services│├──course.go│└──user.go└──utils└──stings.go在上面的示例中,您可以看到项目是按类型组织的。您什么时候想添加新功能或修复与课程相关的错误,您从哪里开始寻找?在一天结束时,您将开始从一个包跳到另一个包,希望在那里找到有用的东西。显示包之间依赖关系的图表这种方法会产生后果。每个类型、常量或函数都必须是公共的,以便在项目的另一部分中可以访问。您最终将大多数类型标记为公共。即使是那些不应该出现在公众视线中的人。它混淆了应用程序这一部分中的重要内容。其中许多是随时可能更改的详细信息。另一方面,按类别组织对我们来说很自然。我们是在处理程序或数据库表的范畴内思考的技术专家。我们就是这样长大的,也是这样被教导的。如果您没有经验,此方法可能会更有用,因为它可以帮助您更快地入门。从长远来看,您可能会遇到不便,但这并不意味着您的项目会失败——恰恰相反,有很多成功的应用程序都是通过这种方式设计的。按组件组织组件是应用程序的一部分,提供独立的功能,很少或没有外部依赖性。您可以将它们视为插件,当您删除其中一个时,整个应用程序仍然可以运行,但功能有限。它可能发生在运行数月或数年的生产应用程序中。一个应用程序可能有一个或多个提供商业价值的核心组件。在领域驱动设计术语中,组件是有界上下文。我们将在以后的文章中在Go的上下文中描述DDD。包的API应该描述包提供了什么,仅此而已。它不应该公开任何从消费者的角度来看不重要的低级细节。它应该尽可能简约。消费者可以是另一个包或另一个导入我们代码的开发人员。该组件应包含交付业务价值所需的一切。这意味着,每个商店、HTTP处理程序或业务模型都应该存储在一个文件夹中。.├──课程│├──httphandler.go│├──model.go│├──repository.go│└──service.go├──main.go└──profile├──httphandler.go├──model.go├──repository.go└──service.go由于代码是这样组织的,当你有与任务相关的类时,你知道从哪里开始寻找。它没有分布在整个应用程序中。然而,实现良好的模块化并不容易。可能需要多次迭代才能获得良好的包装器API。还有一个挑战。如果这些包相互依赖怎么办?假设您想在用户的个人资料中显示最近的课程。他们应该共享相同的存储库或服务吗?在这种特殊情况下,从配置文件的角度来看,该类是一个外部依赖项。解决此问题的最佳方法是在配置文件必须要求的方法包中创建一个接口。typeCoursesinterface{MostRecent(ctxcontext.Context,userIDstring,maxint)([]course.Model,error)}在课程包中,您公开实现此接口的服务。typeCoursesstruct{//maybesomeunexportedfields}Func(cCourses)MostRecent(ctxcontext.Context,userIDstring,maxint)([]Model,error){//returnmostrecentcoursesforspecificuser}在main.go中,您从课程包中创建Courses结构实例并将其调用通过到配置文件包。在配置文件包中的测试中,您创建了一个模拟实现。因此,即使没有课程实施包,您也可以开发和测试配置文件功能。如您所见,模块化使代码更具可维护性和可读性,但它需要您更加认真地考虑您的决定和依赖性。该逻辑似乎很适合新包,但它似乎太小了。另一方面,在项目工作期间,现有包的部分可能会开始增长并在一段时间后提升为自治代码部分。当代码在包内增长时,您可能会问自己:如何在单个模块内组织代码?这是另一个很难回答的问题。在本节中,我展示了使用应用程序组件时的平面结构。但是,有时这还不够……04清洁架构您可能听说过以下术语:清洁架构[12]、洋葱架构或类似术语。Bob大叔写了一本书[13]详细说明了每一层的含义以及应该包含或不应该包含的内容。这个想法很简单。您的应用程序或模块有4层(取决于您的代码库有多大):域、应用程序、端口、适配器。在某些来源中,名称可能不同。例如,作者使用的是Inbound和Outbound,而不是端口和适配器。核心思想是相似的。让我们用例子来描述每一层。域这是我们应用程序的核心。每个业务逻辑都应该放在这里。这意味着如果更改或添加任何业务需求,您必须更新我们的域部分。这个包不应该有任何外部依赖。它不应该知道这段代码是在哪个上下文中执行的。这意味着,它不应该依赖于任何基础设施部分或知道任何UI细节。course:=NewCourse("HowtouseGowithsmartdevices?")s:=course.AddSection("Gettingstarted")l:=s.AddLecture("InstallingGo")l.AddAttachement("https://attachement.com/download")//等等请注意,此时您不关心课程的存储位置或添加新课程的方式(使用HTTP请求或使用CLI)。在域包中,您描述类可能包含的内容以及您可以使用它做什么。就这样!应用层包含应用程序的每个用例。它是基础设施和域之间的胶点。这是您获取输入(来自任何地方)的地方,将其应用于域对象,然后将其保存或发送到其他地方。func(cCourse)Enroll(ctxcontext.Context,courseID,userIDstring)error{course,err:=c.courseStorage.FindCourse(ctx,courseID)iferr!=nil{returnfmt.Errorf("找不到课程:%w")}用户,err:=c.userStorage.Find(ctx,userID)iferr!=nil{returnfmt.Errorf("cannotfindtheuser:%w")}iferr=user.EnrollCourse(course);err!=nil{returnfmt.Errorf("cannotenrollthecourse:%w")}iferr=c.userStorage(ctx,user);err!=nil{returnfmt.Errorf("cannotsavetheuser:%w")}returnnil}在上面的代码中,可以找到用例用户注册课程的地方。它是两部分的组合:与域对象(用户、课程)和基础设施(存储和检索数据)交互。AdaptersAdapters也被称为Outbound或Infrastructure(基础设施)。该层负责与外界存储和检索数据。它可以是数据库、blob存储、文件系统或其他(微)服务。通常,该层在应用层的接口中有其表示。它有助于在不运行数据库或将文件写入文件系统的情况下测试应用层。适配器是对低级细节的抽象,因此您软件的其他部分不必“知道”您正在使用哪个数据库版本、您的SQL查询是什么样的,或者您存储文件的位置。端口端口(称为Inbound)是应用程序的一部分,负责从用户那里获取数据。它可以是HTTP处理程序、事件处理程序或CLI命令。它接受用户输入并将其传递给应用层。此操作的结果返回给端口。funcenrollCourse(whttp.ResponseWriter,r*http.Request){body,err:=io.ReadAll(r.Body)iferr!=nil{w.WriteHeader(http.StatusBadRequest)logger.Errorf("无法读取正文:%s",err)return}req:=enrollCourseRequest{}iferr=json.Unmarshal(body,&req);err!=nil{w.WriteHeader(http.StatusBadRequest)logger.Errorf("cannotunmarshaltherequest:%s",err)return}iferr=validate.Struct(req);err!=nil{w.WriteHeader(http.StatusBadRequest)logger.Errorf("cannotvalidatetherequest:%s",err)返回}iferr=app.EnrollCourse(req.CourseID,req.UserID);err!=nil{w.WriteHeader(http.StatusInternalServerError)logger.Errorf("cannotenrollthecourse:%s",err)return}}请注意,编写执行相同逻辑的CLI命令很简单。唯一的区别是输入的来源。varuserIDstringvarcourseIDstringvarenroleCourseCmd=&cobra.Command{Use:"courseIDuserID",Args:cobra.MinimumNArgs(2),Run:func(cmd*cobra.Command,args[]string){iferr=app.EnrollCourse(courseID,userID);呃!=nil{w.WriteHeader(http.StatusInternalServerError)logger.Errorf("cannotenrollthecourse:%s",err)return}},}保持这些层的清洁和一致可以为您的代码增加很多价值。它很容易测试,职责明确,而且从哪里开始寻找要更改的代码也更加明显。如果是课程相关的错误,是业务逻辑问题,你就要开始检查领域或应用层。另一方面,可能很难保持界限清晰和一致。要做到正确,需要大量的自律、经验和至少几次迭代。这就是为什么很多人在这个领域失败的原因。05总结组织代码很难。更困难的是,应用程序的架构在其生命周期中可能会发生多次变化,不断发展。您可以从平面结构开始,但最终会得到多个模块和许多子包。不要指望第一次就做对。它可能需要多次迭代并收集其他人的反馈。此外,您可以根据应用程序的不同部分混合使用不同的代码组织方法。在您的业务逻辑中,您可以从模块化开始。但是,许多应用程序需要不适合现有包的实用程序,您可以在其中遵循平面结构模式。原文链接:https://developer20.com/how-to-structure-go-code/参考文献[1]RobertGriesemer:https://en.wikipedia.org/wiki/Robert_Griesemer[2]RobPike:https://www.youtube.com/watch?v=PAAkCSZUG1c[3]pkg.go.dev:https://pkg.go.dev/[4]doc.go:https://github.com/golang/go/blob/master/src/net/http/doc.go[5]清酒:http://tonyfischetti.github.io/sake/[6]法师:https://github.com/magefile/mage[7]zim:https://github.com/fugue/zim/[8]opctl:https://opctl.io/[9]不稳定:https://testing.googleblog.com/2020/12/test-flakiness-one-of-main-challenges.html[10]golangci-lint:https://github.com/golangci/golangci-lint[11]net/http/pprof:https://pkg.go.dev/net/http/pprof[12]CleanArchitecture:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html[13]写了一本书:https://www.amazon。com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164本文转载自微信公众号「幽灵」,可通过以下二维码关注。转载本文请联系有鬼公众号。