写点什么

基于 Go-Kit 的 Golang 整洁架构实践

作者:俞凡
  • 2023-11-26
    上海
  • 本文字数:5183 字

    阅读完需:约 17 分钟

如何用 Golang 实现简洁架构?本文介绍了基于 Go-Kit 实现简洁架构的尝试,通过示例介绍了简洁架构的具体实现。原文: Why is Go-Kit Perfect For Clean Architecture in Golang?


简介

Go 是整洁架构(Clean Architecture)的完美选择。整洁架构本身只是一种方法,并没有告诉我们如何构建源代码,在尝试用新语言实现时,认识到这点非常重要。


自从我有了使用 Ruby on Rails 的经验后,尝试了好几次编写第一个服务,而且我读过的大多数关于 Go 的整洁架构的文章都以一种非 Go 惯用的方式介绍结构布局。部分原因是这些例子中的包是根据层命名的——controllermodelservice等等……如果你有这些类型的包,这是第一个危险信号,告诉你应用程序需要重新设计。在 Go 中,包名应该描述包提供了什么,而不是包含了什么。



然后我开始了解 go-kit,特别是它提供的发货示例,并决定在应用程序中实现相同的结构。后来,当我深入研究整洁架构(Clean Architecture)时,惊喜的发现 go-kit 方法是多么完美。


本文将介绍使用 Go-Kit 方法编写服务是如何符合整洁架构理念的。

整洁架构(Clean Architecture)

整洁架构(Clean Architecture)是由 Bob 大叔(Robert Martin)创建的一种软件架构设计。目标是分离关注点,允许开发人员封装业务逻辑,并使其独立于交付和框架机制。许多架构范例(如 Onion 和 Hexagon 架构)也有相同的目标,都是通过将软件划分成层来实现解耦。



圆圈中的箭头表示依赖规则。如果在外部循环中声明了某些内容,则不得在内部循环代码中引用。它既适用于实际的源代码依赖关系,也适用于命名。内层不依赖于任何外层。


外层包含低级组件,如 UI、DB、传输或任何第三方服务,都可以被认为是应用程序的细节或插件。其思想是,外层的变化一定不会引起内层的任何变化。


不同模块/组件之间的依赖关系可以描述如下:



请注意,跨越边界的箭头只指向一个方向,边界后面的组件属于外层,包括 controller、presenter 和 database。Interactor 是实现 BL 的地方,可以将其视为用例层。


请注意Request ModelResponse Model。这些对象分别描述了内层需要和返回的数据。controller 将请求(在 web 的情况下是 HTTP 请求)转换为请求模型(Request Model),presenter 将响应模型(Response Model)格式化为可以由视图模型(View Model)呈现的数据。


还要注意接口,用于反转控制流以与依赖规则相对应。Interactor通过Boundary接口与presenter对话,并通过Entity Gateway接口与数据层对话。


这是整洁架构的主要思想,通过依赖注入分离不同的层,使用依赖反转反转控制流。Interactor(BL)和实体对传输和数据层一无所知。这一点很重要,因为如果我们改变了外层细节,内层就不会发生级联变化。

什么是 Go-Kit?

Go kit是包的集合,可以帮助我们构建健壮、可靠、可维护的微服务。


对于来自 Ruby on Rails 的我来说,重要的是 Go-Kit 不是 MVC 框架。相反,它将应用程序分为三层:


  • Transport(传输)

  • Endpoint(端点)

  • Service(服务)

Transport

传输层是唯一熟悉交付机制(HTTP、gRPC、CLI…)的组件,这一点非常强大,因为我们可以通过提供不同的传输层来同时支持 HTTP 和 CLI。


稍后我们将看到传输层是如何对应于上图中的controllerpresenter的。

Endpoint
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
复制代码


端点层表示应用程序中的单个 RPC,将交付连接到 BL。这是根据输入和输出实际定义用例的地方,在整洁架构术语中是Request ModelResponse Model


注意,端点是接收请求并返回响应的函数,都是interface{},是RequestModelResponseModel。理论上也可以用类型参数(泛型)来实现。

Service

服务层(interactor)是实现 BL 的地方。服务层不知道端点层,服务层和端点层都不知道传输域(比如 HTTP)。


Go-Kit 提供了创建服务器(HTTP 服务器/gRPC 服务器等)的功能。例如 HTTP:


package http // under go-kit/kit/transport/http
type DecodeRequestFunc func(context.Context, *http.Request) (request interface{}, err error)type EncodeResponseFunc func(context.Context, http.ResponseWriter, interface{}) error
func NewServer( e endpoint.Endpoint, dec DecodeRequestFunc, enc EncodeResponseFunc, options ...ServerOption,) *Server
复制代码


  • DecodeRequestFunc将 HTTP 请求转换为Request Model,并且

  • EncodeResponseFunc格式化Response Model并将其编码到 HTTP 响应中。

  • 返回的*server实现http.Server(有ServeHTTP方法)。


传输层使用这个函数来创建http.Server,解码器和编码器在传输中定义,端点在运行时初始化。


简短示例:(基于发货示例)

简易服务

我们将描述一个具有两个 API 的简单服务,用于从数据层创建和读取文章,传输层是 HTTP,数据层只是一个内存映射。可以在这里找到GitHub源代码


注意文件结构:


- inmem  - articlerepo.go- publishing  - transport.go   - endpoint.go  - service.go  - formatter.go- article  - article.go
复制代码


我们看看如何表示整洁架构的不同层。


  • article —— 这是实体层,不包含 BL、数据层或传输层的知识。

  • inmem —— 这是数据层。

  • transport —— 这是传输层。

  • endpoint+service —— 组成了边界+交互器。

从服务开始:
import (  "context"  "fmt"  "math/rand"   "github.com/OrenRosen/gokit-example/article")
type ArticlesRepository interface { GetArticle(ctx context.Context, id string) (article.Article, error) InsertArticle(ctx context.Context, thing article.Article) error}
type service struct { repo ArticlesRepository}
func NewService(repo ArticlesRepository) *service { return &service{ repo: repo, }}
func (s *service) GetArticle(ctx context.Context, id string) (article.Article, error) { return s.repo.GetArticle(ctx, id)}
func (s *service) CreateArticle(ctx context.Context, artcle article.Article) (id string, err error) { artcle.ID = generateID() if err := s.repo.InsertArticle(ctx, artcle); err != nil { return "", fmt.Errorf("publishing.CreateArticle: %w", err) } return artcle.ID, nil}
func generateID() string { // code emitted}
复制代码


服务对交付和数据层一无所知,它不从外层(HTTP、inmem…)导入任何东西。BL 就在这里,你可能会说这里没有真正的 BL,这里的服务可能是冗余的,但需要记住这只是一个简单示例。

实体
package article
type Article struct { ID string Title string Text string}
复制代码


实体只是一个 DTO,如果有业务策略或行为,可以添加到这里。

端点

endpoint.go定义了服务接口:


type Service interface {   GetArticle(ctx context.Context, id string) (article.Article, error)   CreateArticle(ctx context.Context, thing article.Article) (id string, err error)}
复制代码


然后为每个用例(RPC)定义一个端点。例如,对于获取文章:


type GetArticleRequestModel struct {   ID string}
type GetArticleResponseModel struct { Article article.Article}
func MakeEndpointGetArticle(s Service) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (response interface{}, err error) { req, ok := request.(GetArticleRequestModel) if !ok { return nil, fmt.Errorf("MakeEndpointGetArticle failed cast request") } a, err := s.GetArticle(ctx, req.ID) if err != nil { return nil, fmt.Errorf("MakeEndpointGetArticle: %w", err) } return GetArticleResponseModel{ Article: a, }, nil }}
复制代码


注意如何定义RequestModelResponseModel,这是 RPC 的输入/输出。其思想是,可以看到所需数据(输入)和返回数据(输出),甚至无需读取端点本身的实现,因此我认为端点代表单个 RPC。服务具有实际触发 BL 的方法,但是端点是 RPC 的应用定义。理论上,一个端点可以触发多个 BL 方法。

传输

transport.go注册 HTTP 路由:


type Router interface {   Handle(method, path string, handler http.Handler)}
func RegisterRoutes(router *httprouter.Router, s Service) { getArticleHandler := kithttp.NewServer( MakeEndpointGetArticle(s), decodeGetArticleRequest, encodeGetArticleResponse, ) createArticleHandler := kithttp.NewServer( MakeEndpointCreateArticle(s), decodeCreateArticleRequest, encodeCreateArticleResponse, ) router.Handler(http.MethodGet, "/articles/:id", getArticleHandler) router.Handler(http.MethodPost, "/articles", createArticleHandler)}
复制代码


传输层通过MakeEndpoint函数在运行时创建端点,并提供用于反序列化请求的解码器和用于格式化和编码响应的编码器。


例如:


func decodeGetArticleRequest(ctx context.Context, r *http.Request) (request interface{}, err error) {   params := httprouter.ParamsFromContext(ctx)   return GetArticleRequestModel{      ID: params.ByName("id"),   }, nil}
func encodeGetArticleResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error { res, ok := response.(GetArticleResponseModel) if !ok { return fmt.Errorf("encodeGetArticleResponse failed cast response") } formatted := formatGetArticleResponse(res) w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(formatted)}
func formatGetArticleResponse(res GetArticleResponseModel) map[string]interface{} { return map[string]interface{}{ "data": map[string]interface{}{ "article": map[string]interface{}{ "id": res.Article.ID, "title": res.Article.Title, "text": res.Article.Text, }, }, }}
复制代码


你可能会问,为什么要使用另一个函数来格式化article,而不是在article实体上添加 JSON 标记?


这是个非常重要的问题。在article实体上添加 JSON 标记意味着article知道它是如何格式化的。虽然没有显式导入到 HTTP,但打破了抽象,使实体包依赖于传输层。


例如,假设你想将对客户端的响应从"title"更改为"header",此更改仅涉及传输层。但是,如果此需求导致需要更改实体,则意味着该实体依赖于传输层,这就破坏了简洁架构原则。


我们看看这个简单应用的依赖关系图:



哇,你一定注意到了它们的相似性!article实体没有依赖关系(只有向内箭头)。外层,transport 和 inmem,只有指向 BL 和实体内层的箭头。

一切都和转换有关

跨界就是不同层次语言之间的转换。


BL 层只使用应用语言,也就是说,只知道实体(没有 HTTP 请求或 SQL 查询)。为了跨越边界,流中的某个组件必须将应用语言转换为外层语言。


在传输层,有解码器(将 HTTP 请求转换为RequestModel的应用语言)和编码器(将应用语言ResponseModel转换为 HTTP 响应)。


数据层实现了 repo,在我们的例子中是inmem。在另一种情况下,我们可能会让sql包负责将应用语言转换为 SQL 语言(查询和原始结果)。

"ing"包

你可能会说传输和服务不应该在同一个包中,因为它们位于不同的层,这是一个正确的论点。我从 go-kit 的shipping例子中取了一个例子,含有这种设计,ing包包含了传输/端点/服务,我发现从长远来看非常方便。话虽如此,如果我现在写的话,可能会用不同的包。

最后关于"尖叫架构(Screaming Architecture)"的一句话

Go 非常适合简洁架构的另一个原因是包的命名及其思想。尖叫架构(Screaming Architecture) 和构建应用程序有关,以便应用程序的意图显而易见。在 Ruby On Rails 中,当查看结构时,就知道它是用 Ruby On Rails 框架编写的(控制器、模型、视图……)。在我们的应用程序中,当查看结构时,可以看出这是一个关于文章的应用程序,有发布用例,并使用 inmem 数据层。

总结

简洁架构只是一种方法,并不会告诉你如何构建源代码,其实现艺术在于了解所用语言的使用惯例和工具。希望这篇文章对你有所帮助,重要的是要意识到,那些争论设计问题解决方案的文章并不总是对的,当然也包括这篇😀




你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

发布于: 刚刚阅读数: 3
用户头像

俞凡

关注

公众号:DeepNoMind 2017-10-18 加入

俞凡,Mavenir Systems研发总监,关注高可用架构、高性能服务、5G、人工智能、区块链、DevOps、Agile等。公众号:DeepNoMind

评论

发布
暂无评论
基于Go-Kit的Golang整洁架构实践_golang_俞凡_InfoQ写作社区