专栏首页01ZOOGo项目架构指南
原创

Go项目架构指南

项目结构

尽量从一个模版开始,比如这个,关于每个文件夹应该如何组织可以参考 Readme 或者 这篇文章, 几个大原则:

  1. 结构清晰,文件夹的结构对应 分层、分模块
  2. 运行自动化、本地化,这点关系到 makefile 如何编写的问题,一个可以参考的 makefile,关于本地化,可以多使用 docker、docker-compose、vagrant 等工具。
  3. 使用 go mod 处理外部依赖,国内环境建议打开 vendor 模式,将 vendor 作为项目的一部分一起提交。

分层/分模块

为什么要分层/分模块

现代软件往往是复杂而多变的,对于一个大型系统,一个最为显而易见且直接的办法是对这个大型系统进行拆分,一种最常见的拆分方式是:

  1. 由高到低按照逻辑层次进行拆分
  2. 同一个逻辑层次按照功能模块进行模块拆分 这种拆分会极大的降低系统的复杂度,也利于这个系统开发工作的分工。

分层有很多好处,以 TCP/IP 为例:

  1. 分层有利标准化工作,TCP/IP 就是关于他们各个层次如何工作的标准
  2. 层次依赖降低,某层的实现可以替换而不影响其他层的工作
  3. 降低整体的复杂度,TCP/IP 各个层次对行为、标准变得简单
tcp/ip分层

分层与设计原则的关系

SOLID 原则尽管大多数时候用来描述对象设计的原则,但是其实也可以看成是解决分层、分模块之后彼此之间如何依赖和协作的一些参考原则,比如:

  1. 单一职责原则(Single Responsibility Principle),接口隔离原则(InterfaceSegregation Principles) 描述了 模块如何定位的解决指导观点:模块功能应该单一,对外暴露的知识应该越少越好,彼此的依赖应该简单清晰。依赖倒置原则(Dependence Inversion Principle)进一步的描述了模块直接依赖的原则,应该依赖抽象而不是一个具体的实现。
  2. 开放封闭原则(Open Close Principle),里氏替换原则(Liskov Substitution Principle)则描述了,在一个小的模块之内,如何构建出可维护、拓展的模型的问题。

Go 项目的分层

分层的方式多种多样,《企业应用架构模式》中主要采用这样的分层结构:

  • 表现层:提供服务,处理、验证 Http 请求等
  • 领域层:逻辑核心
  • 数据源层:与数据库、消息队列以及其他软件通信

类似但是稍有不同的还有 其他划分办法 (Marinescu/Brown 等5层结构,Nilson 7层结构等),但是如同 tcp/ip 的7层/5层划分,尽管多层次让系统变得更为清晰,但是同时也带来了更多复杂度:

  1. 太多分层会让架构变得复杂,理解起来更为困难,调用的层级太深。
  2. 影响性能,多次调用,尽管内部调用的性能很好,但是不同层次之间数据往往要经过多次转换。
  3. 修改更复杂,层次不能封装所有东西,当你发现其中一个层次的协议需要修改,往往涉及从外到内所有层次都要修改。

这里参考 阿里Java 中提出的分层模式设计了一套 Golang 适用的分层结构

Go分层
  1. 表现层:进行 Http/Rpc 接口的验证、处理、网关安全控制、流量控制等。
  2. 服务层:核心、具体的业务逻辑服务层。
  3. Manager 层:通用业务处理层和服务层的界限没那么清晰,最大的区别在于,Manger 层中业务相关的逻辑较少,和服务层相比,可以跨 Service 被复用的程度高。
  4. 数据持久层:DAO 层与底层数据库、消息队列、其他服务进行交互,对于上层来讲,他负责存取数据。

这里的分层和上面讲的 3 层结构基本一致,只是由 Manager 层提供更多分层的灵活度。

分层与数据对象

另外和分层相关的一个重要问题是数据对象应该在各个层次如何使用。

在阿里巴巴编码规约中列举了下面几个领域模型规约:

  1. DO(Data Object):与数据库表结构、或者外部对象一一对应,通过DAO层向上传输数据源对象。
  2. DTO(Data Transfer Object):数据传输对象,Service 或 Manager向外传输的对象。
  3. BO(Business Object):业务对象。由Service层输出的封装业务逻辑的对象。
  4. AO(Application Object):应用对象。在Web层与Service层之间抽象的复用对象模型,极为贴近展示层,复用度不高。
  5. VO(View Object):显示层对象,通常是Web向模板渲染引擎层传输的对象。

一般来说,结构的分层对应了数据的分层使用。而实际上,太多的数据分层带来各个层次间数据转换的复杂度。一般来说,我们的项目中使用两层数据模型就可以了,即:

  1. DO:和数据库表、消息队列对象、外部对象对应,表述数据存取层的输出,Serivce、Manager、数据存取层使用的数据格式。
  2. DTO:数据传输对象,表现层内部使用、Service层对外输出的对象格式。

依赖

这里所指的依赖指的是各个层次、模块之间的依赖关系

依赖倒置原则

依赖倒置原则是大原则,直接描述了各个层次或者模型之间的依赖指导原则,在这种原则之下,一个高层次不应该依赖低层次的实现,而是依赖一套接口,依赖接口有很多好处

  1. 接口不变,实现可以修改和替换而上层无需变化
  2. 方便测试、Mock

依赖注入与工厂模式

依赖对象新建一般来说常见有几种方法

  1. 直接新建:A 依赖 B,直接 new B
  2. 工厂:通过 B 工厂或者一个 通用工厂生产 B
  3. 依赖注入:自动新建注入 A 依赖的 B

依赖注入的研发模式在 JAVA 工程中应用非常广泛,而在 Go 项目中尚为普及,比如facebook inject、uber dig、google wire,这些框架使用起来并没有特别方便,这是和 golang 的动态 能力不够有关系。大部分的依赖注入框架使用 tag 标记或者代码生成的方式进行处理,往往并不像 JAVA 中那么 自动化

大部分时候我们退而求其次,使用(抽象)工厂模式解决对象依赖新建的问题。

举个例子,在 k8s 的代码中 (抽象)工厂模式使用非常常见,比如我们熟悉的 SharedInformerFactory 就是一个抽象工厂

//k8s.io/kubernetes/pkg/client/informers/informers_generated/internalversion/factory.go
func NewSharedInformerFactory(client internalclientset.Interface, defaultResync time.Duration) SharedInformerFactory {
	return &sharedInformerFactory{
		client:           client,
		defaultResync:    defaultResync,
		informers:        make(map[reflect.Type]cache.SharedIndexInformer),
		startedInformers: make(map[reflect.Type]bool),
	}
}

// SharedInformerFactory provides shared informers for resources in all known
// API group versions.
type SharedInformerFactory interface {
	internalinterfaces.SharedInformerFactory
	ForResource(resource schema.GroupVersionResource) (GenericInformer, error)
	WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool

	Admissionregistration() admissionregistration.Interface
	Apps() apps.Interface
	Autoscaling() autoscaling.Interface
	Batch() batch.Interface
	Certificates() certificates.Interface
	Core() core.Interface
	Extensions() extensions.Interface
	Networking() networking.Interface
	Policy() policy.Interface
	Rbac() rbac.Interface
	Scheduling() scheduling.Interface
	Settings() settings.Interface
	Storage() storage.Interface
}

// sharedInformerFactory 是具体的stuct
func (f *sharedInformerFactory) Apps() apps.Interface {
	return apps.New(f)
}

我们也可以参考依赖注入的实现方式,在 Golang 中实现一套手动注入的抽象工厂,把依赖对象的创建逻辑集中到一处,比如下面的这个例子,本质也是一套抽象工厂

var st store.Interface
var storeOnce sync.Once

func ProvideStore() storeInterface {
	storeOnce.Do(func() {
			st = NewStore(config.DbUri)
	})
	return st
}

func ProvideRemoteAClient() remoteAInterface {
	return NewRemoteAClient(config.remoteAUri)
}

func ProvideServiceA() serviceAInterface {
	return NewServiceA(ProvideStore())
}

func ProvideServiceB() serviceBInterface {
	return NewServiceB(ProvideStore(), ProvideRemoteAClient())
}

数据传入与传出

数据传输

输入数据 与 context

除了输入数据,context 在各层之间的数据传输和控制也很有用:

  1. context 的控制作用可以用于控制各层之间的数据控制逻辑,比如在高层收到 cancel 请求,可以利用 context 取消在底层的计算逻辑。参考1参考2
  2. 尽管 context 不常用来传输数据,但是实际上在大量库中,context 都被用来传输一些和上下文有关的数据:比如对于请求来说,请求用户的用户信息,用于追踪请求的 Id 等等,这些信息存储对于程序的实现或者Debug非常方便。

举个例子,当我们在表现层收到 Http 请求,在请求头有提取出了 RequestId 用于追踪请求,我们可以讲 RequestId 存于 Context 中,附加到所有相关链路的日志内容中,这样,关于这个请求在表现层、服务层、Manger层等的日志数据都可以得到很好的追踪.

输出数据 与 error

除了正常的数据输出之外,Go程序习惯使用 error 对象来返回错误信息。这样 error 本身也就成为了一种 DO 或者 DTO,error 对象应该在每层如何表现?如果 A 层调用B层发生错误,A层怎么知道错误发生在 B 层,还是B层下面的 C层,D层呢。

一种推荐的处理方式和 DO,DTO 的处理方式类似,我们可以定义为 DEO,DTEO

  1. DEO 用于 Service层、Manager层、数据传输层,一般来说可以是一种自定义的 Error 格式,符合 error Interface。error 的信息应该包括 error 发生的具体层次、模块、原因甚至堆栈,在向上传输时,使用 wrap 方法进行包装。
  2. DTEO 用于表现层,对外传输,一般来说一个 Code + Message 已经足够,debug 阶段 Message 可以包含 DEO 的所有堆栈信息,但在生产阶段,这部分信息无需对外暴露。Message 使用对 error 信息对解包进行转换返回。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 从零开发无服务函数管理器:jupyter lab 插件

    这个插件将分为两个部分,一部分是 server 部分,一部分是前端部分. 我们将先创建后端部分。

    王磊-AI基础
  • 高效jupyter notebook

    类似vim,notebook也有命令模式和编辑模式。在编辑模式中按下esc就会进入命令模式,点击任何一个cell,或者按下enter可以进入编辑模式。如果你用过...

    王磊-AI基础
  • Golang Annotation 系统 - Gengo 实战

    代码生成的技术在各种语言中都很常用,尤其是静态语言,利用代码生成的技术可以实现一些大幅提高生产效率的工具。

    王磊-AI基础
  • 使用ZXing生成二维码,可设置中间icon,边缘白色宽度为0

    Xiaolei123
  • 机器学习算法之集成学习

    "We won't be distracted by comparison if we are captivated with purpose.—— Bob G...

    小闫同学啊
  • 大数据应用之HBase数据插入性能优化之多线程并行插入测试案例

      上篇文章提起关于HBase插入性能优化设计到的五个参数,从参数配置的角度给大家提供了一个性能测试环境的实验代码。根据网友的反馈,基于单线程的模式实现的数据插...

    数据饕餮
  • Windows 技术篇-设置计划任务,每天自动关机

    我们的原理就是让电脑在指定时间用cmd执行 C:\Windows\System32\shutdown.exe -s 命令来关机。

    小蓝枣
  • 进程队列补充、socket实现服务器并发、线程完结

    解释型语言单个进程下多个线程不可以并行,但是向C语言等其他语言中在多核情况下是可以实现并行的,所有语言在单核下都是无法实现并行的,只能并发。

    GH
  • Nginx能为前端开发带来什么?

    本文作者:IMWeb 黎腾 原文出处:IMWeb社区 未经同意,禁止转载 Nginx那么好,我想去看看。 接连逛了两个书城后,我发现并没有Nginx相...

    IMWeb前端团队
  • java并发之无同步方案-ThreadLocal

    1.ThreadLocal  介绍2.ThreadLocal  应用3.ThreadLocal  源码解析3.1解决 Hash 冲突4.ThreadLocal ...

    Java宝典

扫码关注云+社区

领取腾讯云代金券