Golang 简洁架构实战

Posted 腾讯技术工程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang 简洁架构实战相关的知识,希望对你有一定的参考价值。

作者:bearluo,腾讯 IEG 运营开发工程师

文中项目代码位置:https://github.com/devYun/go-clean-architecture

由于 golang 不像 java 一样有一个统一的编码模式,所以我们和其他团队一样,采用了 Go 面向包的设计和架构分层这篇文章介绍的一些理论,然后再结合以往的项目经验来进行分包:

├── cmd/
│   └── main.go //启动函数
├── etc
│   └── dev_conf.yaml              // 配置文件
├── global
│   └── global.go //全局变量引用,如数据库、kafka等
├── internal/
│       └── service/
│           └── xxx_service.go //业务逻辑处理类
│           └── xxx_service_test.go
│       └── model/
│           └── xxx_info.go//结构体
│       └── api/
│           └── xxx_api.go//路由对应的接口实现
│       └── router/
│           └── router.go//路由
│       └── pkg/
│           └── datetool//时间工具类
│           └── jsontool//json 工具类

其实上面的这个划分只是简单的将功能分了一下包,在项目实践的过程中还是有很多问题。比如:

  • 对于功能实现我是通过 function 的参数传递还是通过结构体的变量传递?
  • 使用一个数据库的全局变量引用传递是否安全?是否存在过度耦合?
  • 在代码实现过程中几乎全部都是依赖于实现,而不是依赖于接口,那么将 mysql 切换为 MongDB 是不是要修改所有的实现?
  • 所以现在在我们工作中随着代码越来越多,代码中各种 init,function,struct,全局变量感觉也越来越乱。每个模块不独立,看似按逻辑分了模块,但没有明确的上下层关系,每个模块里可能都存在配置读取,外部服务调用,协议转换等。久而久之服务不同包函数之间的调用慢慢演变成网状结构,数据流的流向和逻辑的梳理变得越来越复杂,很难不看代码调用的情况下搞清楚数据流向。

    不过就像《重构》中所说:先让代码工作起来-如果代码不能工作,就不能产生价值;然后再试图将它变好-通过对代码进行重构,让我们自己和其他人更好地理解代码,并能按照需求不断地修改代码。

    所以我觉得是时候自我改变一下。

    The Clean Architecture

    在简洁架构里面对我们的项目提出了几点要求:

    1. 独立于框架。该架构不依赖于某些功能丰富的软件库的存在。这允许你把这些框架作为工具来使用,而不是把你的系统塞进它们有限的约束中。
    2. 可测试。业务规则可以在没有 UI、数据库、Web 服务器或任何其他外部元素的情况下被测试。
    3. 独立于用户界面。UI 可以很容易地改变,而不用改变系统的其他部分。例如,一个 Web UI 可以被替换成一个控制台 UI,而不改变业务规则。
    4. 独立于数据库。你可以把 Oracle 或 SQL Server 换成 Mongo、BigTable、CouchDB 或其他东西。你的业务规则不受数据库的约束。
    5. 独立于任何外部机构。事实上,你的业务规则根本不知道外部世界的任何情况。

    上图中同心圆代表各种不同领域的软件。一般来说,越深入代表你的软件层次越高。外圆是战术实现机制,内圆的是战略核心策略。对于我们的项目来说,代码依赖应该由外向内,单向单层依赖,这种依赖包含代码名称,或类的函数,变量或任何其他命名软件实体。

    对于简洁架构来说分为了四层:

  • Entities:实体
  • Usecase:表达应用业务规则,对应的是应用层,它封装和实现系统的所有用例;
  • Interface Adapters:这一层的软件基本都是一些适配器,主要用于将用例和实体中的数据转换为外部系统如数据库或 Web 使用的数据;
  • Framework & Driver:最外面一圈通常是由一些框架和工具组成,如数据库 Database, Web 框架等;
  • 那么对于我的项目来说,也分为了四层:

  • models
  • repo
  • service
  • api
  • 代码分层

    models

    封装了各种实体类对象,与数据库交互的、与 UI 交互的等等,任何的实体类都应该放在这里。如:

    import "time"

    type Article struct 
     ID        int64     `json:"id"`
     Title     string    `json:"title"`
     Content   string    `json:"content"`
     UpdatedAt time.Time `json:"updated_at"`
     CreatedAt time.Time `json:"created_at"`

    repo

    这里存放的是数据库操作类,数据库 CRUD 都在这里。需要注意的是,这里不包含任何的业务逻辑代码,很多同学喜欢将业务逻辑也放到这里。

    如果使用 ORM,那么这里放入的 ORM 操作相关的代码;如果使用微服务,那么这里放的是其他服务请求的代码;

    service

    这里是业务逻辑层,所有的业务过程处理代码都应该放在这里。这一层会决定是请求 repo 层的什么代码,是操作数据库还是调用其他服务;所有的业务数据计算也应该放在这里;这里接受的入参应该是 controller 传入的。

    api

    这里是接收外部请求的代码,如:gin 对应的 handler、gRPC、其他 REST API 框架接入层等等。

    面向接口编程

    除了 models 层,层与层之间应该通过接口交互,而不是实现。如果要用 service 调用 repo 层,那么应该调用 repo 的接口。那么修改底层实现的时候我们上层的基类不需要变更,只需要更换一下底层实现即可。

    例如我们想要将所有文章查询出来,那么可以在 repo 提供这样的接口:

    package repo

    import (
     "context"
     "my-clean-rchitecture/models"
     "time"
    )

    // IArticleRepo represent the article\'s repository contract
    type IArticleRepo interface 
     Fetch(ctx context.Context, createdDate time.Time, num int) (res []models.Article, err error)


    这个接口的实现类就可以根据需求变更,比如说当我们想要 mysql 来作为存储查询,那么只需要提供一个这样的基类:

    type mysqlArticleRepository struct 
     DB *gorm.DB


    // NewMysqlArticleRepository will create an object that represent the article.Repository interface
    func NewMysqlArticleRepository(DB *gorm.DB) IArticleRepo 
     return &mysqlArticleRepositoryDB


    func (m *mysqlArticleRepository) Fetch(ctx context.Context, createdDate time.Time,
     num int)
     (res []models.Article, err error)
     

     err = m.DB.WithContext(ctx).Model(&models.Article).
      Select("id,title,content, updated_at, created_at").
      Where("created_at > ?", createdDate).Limit(num).Find(&res).Error
     return

    如果改天想要换成 MongoDB 来实现我们的存储,那么只需要定义一个结构体实现 IArticleRepo 接口即可。

    那么在 service 层实现的时候就可以按照我们的需求来将对应的 repo 实现注入即可,从而不需要改动 service 层的实现:

    type articleService struct 
     articleRepo repo.IArticleRepo


    // NewArticleService will create new an articleUsecase object representation of domain.ArticleUsecase interface
    func NewArticleService(a repo.IArticleRepo) IArticleService 
     return &articleService
      articleRepo: a,
     


    // Fetch
    func (a *articleService) Fetch(ctx context.Context, createdDate time.Time, num int) (res []models.Article, err error) 
     if num == 0 
      num = 10
     
     res, err = a.articleRepo.Fetch(ctx, createdDate, num)
     if err != nil 
      return nil, err
     
     return

    依赖注入 DI

    依赖注入,英文名 dependency injection,简称 DI 。DI 以前在 java 工程里面经常遇到,但是在 go 里面很多人都说不需要,但是我觉得在大型软件开发过程中还是有必要的,否则只能通过全局变量或者方法参数来进行传递。

    至于具体什么是 DI,简单来说就是被依赖的模块,在创建模块时,被注入到(即当作参数传入)模块的里面。想要更加深入的了解什么是 DI 这里再推荐一下 Dependency injection 和 Inversion of Control Containers and the Dependency Injection pattern 这两篇文章。

    如果不用 DI 主要有两大不方便的地方,一个是底层类的修改需要修改上层类,在大型软件开发过程中基类是很多的,一条链路改下来动辄要修改几十个文件;另一方面就是就是层与层之间单元测试不太方便。

    因为采用了依赖注入,在初始化的过程中就不可避免的会写大量的 new,比如我们的项目中需要这样:

    package main

    import (
     "my-clean-rchitecture/api"
     "my-clean-rchitecture/api/handlers"
     "my-clean-rchitecture/app"
     "my-clean-rchitecture/repo"
     "my-clean-rchitecture/service"
    )

    func main() 
     // 初始化db
     db := app.InitDB()
     //初始化 repo
     repository := repo.NewMysqlArticleRepository(db)
     //初始化service
     articleService := service.NewArticleService(repository)
     //初始化api
     handler := handlers.NewArticleHandler(articleService)
     //初始化router
     router := api.NewRouter(handler)
     //初始化gin
     engine := app.NewGinEngine()
     //初始化server
     server := app.NewServer(engine, router)
     //启动
     server.Start()


    那么对于这么一段代码,我们有没有办法不用自己写呢?这里我们就可以借助框架的力量来生成我们的注入代码。

    在 go 里面 DI 的工具相对来说没有 java 这么方便,技术框架一般主要有:wire、dig、fx 等。由于 wire 是使用代码生成来进行注入,性能会比较高,并且它是 google 推出的 DI 框架,所以我们这里使用 wire 进行注入。

    wire 的要求很简单,新建一个 wire.go 文件(文件名可以随意),创建我们的初始化函数。比如,我们要创建并初始化一个 server 对象,我们就可以这样:

    //+build wireinject

    package main

    import (
     "github.com/google/wire"
     "my-clean-rchitecture/api"
     "my-clean-rchitecture/api/handlers"
     "my-clean-rchitecture/app"
     "my-clean-rchitecture/repo"
     "my-clean-rchitecture/service"
    )

    func InitServer() *app.Server 
     wire.Build(
      app.InitDB,
      repo.NewMysqlArticleRepository,
      service.NewArticleService,
      handlers.NewArticleHandler,
      api.NewRouter,
      app.NewServer,
      app.NewGinEngine)
     return &app.Server

    需要注意的是,第一行的注解:+build wireinject,表示这是一个注入器。

    在函数中,我们调用wire.Build()将创建 Server 所依赖的类型的构造器传进去。写完 wire.go 文件之后执行 wire 命令,就会自动生成一个 wire_gen.go 文件。

    // Code generated by Wire. DO NOT EDIT.

    //go:generate go run github.com/google/wire/cmd/wire
    //+build !wireinject

    package main

    import (
     "my-clean-rchitecture/api"
     "my-clean-rchitecture/api/handlers"
     "my-clean-rchitecture/app"
     "my-clean-rchitecture/repo"
     "my-clean-rchitecture/service"
    )

    // Injectors from wire.go:

    func InitServer() *app.Server 
     engine := app.NewGinEngine()
     db := app.InitDB()
     iArticleRepo := repo.NewMysqlArticleRepository(db)
     iArticleService := service.NewArticleService(iArticleRepo)
     articleHandler := handlers.NewArticleHandler(iArticleService)
     router := api.NewRouter(articleHandler)
     server := app.NewServer(engine, router)
     return server

    可以看到 wire 自动帮我们生成了 InitServer 方法,此方法中依次初始化了所有要初始化的基类。之后在我们的 main 函数中就只需调用这个 InitServer 即可。

    func main() 
     server := InitServer()
     server.Start()

    测试

    在上面我们定义好了每一层应该做什么,那么对于每一层我们应该都是可单独测试的,即使另外一层不存在。

  • models 层:这一层就很简单了,由于没有依赖任何其他代码,所以可以直接用 go 的单测框架直接测试即可;
  • repo 层:对于这一层来说,由于我们使用了 mysql 数据库,那么我们需要 mock mysql,这样即使不用连 mysql 也可以正常测试,我这里使用 github.com/DATA-DOG/go-sqlmock 这个库来 mock 我们的数据库;
  • service 层:因为 service 层依赖了 repo 层,因为它们之间是通过接口来关联,所以我这里使用 github.com/golang/mock/gomock 来 mock repo 层;
  • api 层:这一层依赖 service 层,并且它们之间是通过接口来关联,所以这里也可以使用 gomock 来 mock service 层。不过这里稍微麻烦了一点,因为我们接入层用的是 gin,所以还需要在单测的时候模拟发送请求;
  • 由于我们是通过 github.com/golang/mock/gomock 来进行 mock ,所以需要执行一下代码生成,生成的 mock 代码我们放入到 mock 包中:

    mockgen -destination .\\mock\\repo_mock.go -source .\\repo\\repo.go -package mock

    mockgen -destination .\\mock\\service_mock.go -source .\\service\\service.go -package mock

    上面这两个命令会通过接口帮我自动生成 mock 函数。

    repo 层测试

    在项目中,由于我们用了 gorm 来作为我们的 orm 库,所以我们需要使用 github.com/DATA-DOG/go-sqlmock 结合 gorm 来进行 mock:

    func getSqlMock() (mock sqlmock.Sqlmock, gormDB *gorm.DB) 
     //创建sqlmock
     var err error
     var db *sql.DB
     db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
     if err != nil 
      panic(err)
     
     //结合gorm、sqlmock
     gormDB, err = gorm.Open(mysql.New(mysql.Config
      SkipInitializeWithVersion: true,
      Conn:                      db,
     ), &gorm.Config)
     if nil != err 
      log.Fatalf("Init DB with sqlmock failed, err %v", err)
     
     return


    func Test_mysqlArticleRepository_Fetch(t *testing.T) 
     createAt := time.Now()
     updateAt := time.Now()
     //id,title,content, updated_at, created_at
     var articles = []models.Article
      1"test1""content", updateAt, createAt,
      2"test2""content2", updateAt, createAt,
     

     limit := 2
     mock, db := getSqlMock()

     mock.ExpectQuery("SELECT id,title,content, updated_at, created_at FROM `articles` WHERE created_at > ? LIMIT 2").
      WithArgs(createAt).
      WillReturnRows(sqlmock.NewRows([]string"id""title""content""updated_at""created_at").
       AddRow(articles[0].ID, articles[0].Title, articles[0].Content, articles[0].UpdatedAt, articles[0].CreatedAt).
       AddRow(articles[1].ID, articles[1].Title, articles[1].Content, articles[1].UpdatedAt, articles[1].CreatedAt))

     repository := NewMysqlArticleRepository(db)
     result, err := repository.Fetch(context.TODO(), createAt, limit)

     assert.Nil(t, err)
     assert.Equal(t, articles, result)

    service 层测试

    这里主要就是用我们 gomock 生成的代码来 mock repo 层:

    func Test_articleService_Fetch(t *testing.T) 
     ctl := gomock.NewController(t)
     defer ctl.Finish()
     now := time.Now()
     mockRepo := mock.NewMockIArticleRepo(ctl)

     gomock.InOrder(
      mockRepo.EXPECT().Fetch(context.TODO(), now, 10).Return(nilnil),
     )

     service := NewArticleService(mockRepo)

     fetch, _ := service.Fetch(context.TODO(), now, 10)
     fmt.Println(fetch)

    api 层测试

    对于这一层,我们不仅要 mock service 层,还需要发送 httptest 来模拟请求发送:

    func TestArticleHandler_FetchArticle(t *testing.T) 

     ctl := gomock.NewController(t)
     defer ctl.Finish()
     createAt, _ := time.Parse("2006-01-02""2021-12-26")
     mockService := mock.NewMockIArticleService(ctl)

     gomock.InOrder(
      mockService.EXPECT().Fetch(gomock.Any(), createAt, 10).Return(nilnil),
     )

     article := NewArticleHandler(mockService)

     gin.SetMode(gin.TestMode)

     // Setup your router, just like you did in your main function, and
     // register your routes
     r := gin.Default()
     r.GET("/articles", article.FetchArticle)

     req, err := http.NewRequest(http.MethodGet, "/articles?num=10&create_date=2021-12-26"nil)
     if err != nil 
      t.Fatalf("Couldn\'t create request: %v\\n", err)
     

     w := httptest.NewRecorder()
     // Perform the request
     r.ServeHTTP(w, req)

     // Check to see if the response was what you expected
     if w.Code != http.StatusOK 
      t.Fatalf("Expected to get status %d but instead got %d\\n", http.StatusOK, w.Code)
     

    总结

    以上就是我对 golang 的项目中发现问题的一点点总结与思考,思考的先不管对不对,总归是解决了我们当下的一些问题。不过,项目总归是需要不断重构完善的,所以下次有问题的时候下次再改呗。

    对于我上面的总结和描述感觉有不对的地方,请随时指出来一起讨论。

    项目代码位置:https://github.com/devYun/go-clean-architecture

    Reference

    https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

    https://github.com/bxcodec/go-clean-arch

    https://medium.com/hackernoon/golang-clean-archithecture-efd6d7c43047

    https://farer.org/2021/04/21/go-dependency-injection-wire/


    保姆级教程!Golang微服务简洁架构实战

    导语 | 本文从简洁架构的理论出发,依托trpc-go目录规范,简单阐述了整体代码架构如何划分,具体trpc-go服务代码实现细节,和落地步骤,并讨论了和DDD的区别。文章源于我们组内发起的go微服务最佳实践的第一部分,希望从开发和阅读学习中总结出一套go微服务开发的方法论,互相分享一下在寻求最佳的实践过程中的思考和取舍的过程。本次主要讨论目录如何组织,目录的组织其实就是架构的设计,一套通用架构的设计,可以让开发专注于逻辑设计和具体场景的代码设计,好的架构设计可以预防代码的腐败,并且相关的规范操作简单,可以按步骤根据情况分步落地,可操作性强。

    引言

    现在有了高效的go语言和成熟的trpc-go框架和一系列的中台SDK,发布平台,一个新手也可以通过教程快速写出简单功能的微服务,以此入门,开始go的微服务开发,并应对大部分开发需求。

    但是一旦开始了就会发现随着需求的增加我们常常不得不去花很多时间去维护代码,变更已有的逻辑,不断的抽象,提高部分常用能力的可扩展性,但往往随着多个人在同一份微服务代码里协作,维护这件事情越来越难做了,不仅仅是因为大家的抽象风格不同,对于抽象的标准,模块的分割,数据的流向,分层的逻辑都是不同的,看每个服务都像是一个新的生命,千姿百态。

    千姿百态的代码库不是我们希望的,我们希望在代码的架构上保持易读性可扩展性可维护性,这样除了对于代码细节的一致性(代码标准)外,还希望有架构上的标准,让开发专注于逻辑设计和具体场景的代码设计,在海量之道的知识下把服务相关内容做好,而不是将时间和精力浪费在纠结如何重新组织和解乱麻、重构等工作上,如果每个服务的架构都足够简洁清晰,团队内部每个仓库都像是自己写的,上手也会很快,团队的效率就会几何的速度提升。

    一、 开发现状

    不同的业务场景不太一样,在增值的业务场景下,大部分的需求边界或者服务全部职能一开始并不能确定,一般就是一个小需求开始的微服务,后续可能随着业务的增长慢慢变得复杂的,仿佛是从一颗小树苗渐渐长成一颗枝繁叶茂的大树,可能一开始这个服务的职责很单一,很简单,一个service搭配一个logic就ok了,但后面加入了各种依赖,logic就开始变复杂,更可怕的是,因为来一个需求做一个需求(假设最坏的情况下无法预测产品的需求),对于落后的开发模式,或者没有架构概念来说,多一个需求,无非就是加个函数,加个分支,需要啥,import啥就完事了,渐渐地,绝大多数服务成为:

    • 没有合理分包,或者仅逻辑职责分包(分目录)

    • 面向过程编程,函数调用链很长,在各种包之间穿插。

    • 没有依赖注入,依赖是以全局引入的方式存在,作用域很大,潜在的并发问题。

    最终导致

    • 不通用,一切都不通用,每次修改一个逻辑部分可能要牵扯到多个函数调用的地方去修改参数关系。

    • 测试用例难写,import进来的函数,是mock呢还是不mock呢,mock函数一个个写,monkey mock是否合理?

    • 每个模块不独立,看似按逻辑分了模块,比如order_hanlder,conf,XXX_helper,database等,但没有明确的上下层关系,每个模块里可能都存在配置读取,外部服务调用,协议转换等。

    先看目前的微服务代码状态,截几个常见的微服务目录组织风格:

    四种目前常见的微服务目录组织方式,从左至右分别为1,2,3,4,可以看到:

    • 服务1除了main全部都放在logic中,logic实际上已经职责不清了。

    • 服务2全部平铺式的,为什么作者要这么干,因为他写了很多monkey func mock,因为没有抽象,不同函数之间调用导致很多函数的mock需要复用,但测试文件中的内容不支持import,所以为了避免底层逻辑函数要重复在不同包里写mock,干脆平铺了。

    • 服务3常见组织方式,以逻辑为单元进行模块分包解耦,基本符合单一职责原则,但这种微服务随着需求的增长会产生网状调用的问题。

    • 服务4对外部调用有一定抽象的目录设计,但组织方式并不一眼清晰,没有合理的分包,逻辑代码写在接入层。

    (一)没有架构

    如上述例子中,大多数服务没有架构上的概念,多数业务是以逻辑单元的方式去分包(分目录),每个包之间关系是平级关系,每个包的逻辑独立,理论上使用包功能时import进来即可,随着服务的成长:

    • 服务不同包函数之间的调用慢慢演变成网状结构。

    • 数据流的流向和逻辑的梳理变得越来越复杂。

    • 很难不看代码调用的情况下搞清楚数据流向。

    这是目前常见的一个实际问题,业务增长过程中,微服务很容易长成一个垃圾山,开发心累,改不动的情况出现。

    所谓的代码腐败即在代码增量到一定程度后,服务内部的函数调用组织是网状结构,没有层级结构,即使微观上可能是解耦的,但宏观上是乱成一团的,DDD等设计思想都是为了解决这样的问题。

    (二)没有分层

    常见的微服务只有分包没有分层的概念,数据流没有分层,因为没有合理的分层,自然没有上下调用的关系,最多就是逻辑上分个包而已,用到啥import进来就完善,没有层次的系统就是一盘散沙,一盘散沙的接口,互相随意调用,关系乱成一团,这就是日后维护和调试的噩梦。

    二、 探索最佳架构实践


    (一)简洁架构

    出自《架构整洁之道》,此架构模型是不区分前后端的广义上的抽象架构我们希望每个微服务的代码在微观上也是符合简洁架构。

    在后台服务的场景下,以trpc-go目录规范可以抽象出一种金字塔结构的架构:

    这种结构的优势体现在:

    • 标准结构:层+模块

    1. 结构分层,每层之间划分模块。

    2. 数据流向固定,自上而下单一方向。

    3. 架构清晰,需求代码增长是结构化的,组织关系不是网状。

    • 一致性

    1. 架构通用,可以统一规范。

    2. 协作开发时不同服务的架构一样,无理解成本。

    • 易于操作

    1. 相关概念简单,易于操作,符合开发直觉,便于正确分类代码。

    2. 不涉及领域建模等额外问题。

    • 减缓代码膨胀

    1. 分层将代码上升或下层,以三层的结构可以一定程度上降低每一层的代码膨胀的速度。

    (二)目录规范

    分层按数据流向分为接口层(网关层)、逻辑层,外部依赖层,划分方式和理解成本都不会很高,详细如下:

    • gateway

    • 接口实现的地方,服务接口入口处,对应trpc-go的service。

    • 只进行协议解析转换,协议的整理,不涉及业务逻辑。

    • logic

    • 服务核心业务逻辑实现的地方。

    • 内部实现分模块分包。

    • repo

    • 外部依赖层,包括外部数据库、RPC调用等。

    • 每个包提供抽象接口,将外部数据调用并整理后以接口的方式提供给logic。

    • 仅做外部调用和数据整理,不包含业务逻辑。

    • entity

    • 贯穿整个服务的数据结构,类似常量,错误码。

    • 贫血模型,即仅包含数据结构读写方法的对象。

    • 防腐层

    • 每层对外暴露的都以抽象接口方式,用依赖倒置的方式实现每层之间的防腐。

    • 抽象接口天然可以gomock生成桩代码,上层单测时只需要用下层对应的桩代码mock下层依赖即可。

    三、 实现规范

    在实践过程将代码目录按标准划分归类只是第一步,重要的是层与层之间的隔离和模块与模块之间的解耦,所以需要用到依赖倒置、依赖注入、封装、测试规范来实现具体的代码,其中测试规范是反向校验代码设计是否合格的一把尺子,如果每个接口无法使用gomock打桩,那么依赖倒置就是没有做好。

    (一)依赖倒置、接口隔离

    • 依赖倒置

    • 上层模块不应该依赖底层模块,它们都应该依赖于抽象。

    • 抽象不应该依赖于细节,细节应该依赖于抽象。

    • 接口隔离

    • 客户端不应该依赖它不需要的接口。

    • 模块间的依赖应该建立在最小的接口之上。

    实现要求:不同层之间对外的接口一律以interface的方式提供,并且单一职责的设计,接口尽可能简单清晰,接口文件单独存放,不放在具体实现的文件中,依赖参数定义和接口声明放在一起。

    例,msg包下api.go定义消息接口:

    (二)依赖注入

    依赖注入(DI,Dependency Injection)指的是实现控制反转(IOC,Inversion of Control)的一种方式,其实很好理解就是内部依赖什么,不要在内部创建,而是通过参数由外部注入。例:

    • 内部封装

    • 高内聚低耦合。

    • 合理的抽象函数,分子函数,聚类等。

    例:

    (三)不引入gomock以外的mock包

    如果一定要monkey mock来对函数打桩时, 说明代码没有符合接口原则。并且Monkey mock的mock函数不可导出 在每个调用的此函数的包内单测时,都需要重新写一遍mock。

    Gomock桩代码可自动生成,上层需要mock下层依赖时,只需要将mock的桩作为依赖注入即可。

    (四)配置(远程配置)

    现在几乎每个服务除了框架配置外,会接入远程配置(七彩石配置),读取远程配置的逻辑几乎每个服务都要重新实现一遍,因为配置的最终输出一定是一个个性化的结构体(每个服务的配置肯定都不一样),所以很难用一套代码解决,这里采用了一个包替换的方式,将出口的结构体通过引入不同的config entity定义,来实现代码的通用(仅是通用,还实现不了零copy)

    • 每个服务一个远程配置。

    • 远程配置为json格式(yaml一样,内部统一即可)

    • 远程配置定义在entity/config包中,结构体为Config。

    这样可以复用如下远程配置实现:

    这里如果服务有多个配置:

    例:这个服务是重构过的,之前没有规范,所以弄了三个不同的远程配置(实际上一个即可):

    因为Get返回的结构不同,所以不同配置使用不同的接口实例来实现,每个不同结构的配置在解析时是固定的结构体,get返回也是固定的结构体,在go模板特性未支持的情况下每个不同文件的配置,以不同实现impl来完成解析, 看起来代码上有一些重复,但这样表达能保证清晰易懂,一般情况一个服务业务配置放在一个文件中。

    一个服务一个配置,对于配置初始化等代码的减少,有很大的帮助。

    (五)配置的使用

    接口化的配置很方便实现依赖注入,摒弃之前那种引入配置包,读取全局配置的方式,通过依赖注入来实现配置作用域减小,避免很多并发问题:

    四、落地方法

    理想很丰满现实很骨感,需求进度和代码质量的矛盾,如果要一步到位,在实践中等于一步也实行不下去。

    实际情况往往是需求很紧急,并没有太多时间给开发用来设计和优化代码,所以我们希望走第一步的时候不会占用开发太多的时间,最好时间分配可以从1:9的方式开始,并且在任何阶段都可以以需求快速完成为优先(即容忍一定程度的不遵守也不会破坏整体),即一开始你可以在90%的自由度上保持你自己旧的风格,抽出10%的时间来设计,这样落实规范并不会很痛苦。

    整体落地的步骤可以分为三个阶段(不是必要经历的,时间不紧张可以直接按标准实现来)

    根据当前需求的紧急程度和个人时间安排来分阶段实践即可。

    五、总结

    微服务代码架构的一致性和实现规范的一致性可以带来很多好处:

    (一)为什么不是DDD

    其实之所以要提DDD,是因为这是个避不开的问题,但答案其实已经有了DDD是把控中大型项目的杀手锏,但使用DDD并不能使开发新项目变得更快,更方便,而是为了今后考虑,让一个庞大的系统可以更快的迭代,更新,也就是说新的项目不用太在意领域驱动设计,甚至新项目开始可以不用领域驱动设计。

    DDD的优势和劣势

    不同的业务可能面临不一样的问题,很多实践中的需求往往不是一开始就有顶层设计的大需求、大项目,甚至很多微服务还没确定自己领域内的元素,就伴随着业务死亡了,创建服务之初领域模型和边界并不清楚,一个一个接口的新服务,从一开始设计时就去事件风暴、划分元素、子域等也是不切实际的,所以越是微小的服务,越是不需要DDD。很多时候我们不得不考虑团队的新成员快速成长的问题,一个新同学或者实习生同学很难快速上手DDD,并把DDD落地到每个服务里,不能全部落地,这样就会存在不同需求服务之间的不一致性,接手同事的服务时,还是会存在理解结构的心智负担。

    后记

    整体的规则描述了大概,但是实践的过程中,对于内部具体细节,函数的抽象,聚类,子模块的划分,都是经验和实践的积累,还是很考验一个人的代码功底,这点架构规范并不能给予帮助。

    好的架构或者说目录设计像是垃圾分类的垃圾桶,预先设置好分类规则,垃圾就可以很轻松的进行分类,分类好的垃圾就可以变废为宝,成为可利用的资源,所以面对垃圾山一样的代码,重构时我们首先要遵循正确的架构进行垃圾分类。

    虽然进行了有效的分层,但是对于logic层里面的模块拆分并不要求严格,即提供了抽象接口之后,具体实现是细节问题,随着需求的增长实际上还是面临增长之后带来复杂度关系,但由于拆分了外部调用在repo和数据实例在entity,微服务最终logic的代码并不会膨胀的很快,三层结构可以一定程度的减缓复杂度膨胀的速度,如果有一天膨胀大了,那么使用DDD进行重构可能是另一种解法。

    本文是记录一下在寻求最佳的实践过程中的思考和取舍的过程,毕竟对于微服务代码架构的实践没有银弹,不存在哪一种更好的情况,只有相对容易落地和简单有效的方案才比较通用

    参考资料:

    1.《架构整洁之道》

    2.《tRPC-Go目录规范》

    3.《go-clean-arch》

     作者简介

    杨帅

    腾讯后台开发工程师

    腾讯后台开发工程师,深圳大学毕业,目前负责社交增值商业广告相关后台开发,主要开发语言为go语言,在渠道投放系统管理端建设,和增值商业广告中台建设等领域积累了丰富的开发经验。

     推荐阅读

    来,5W1H分析法帮你系统掌握缓存!(图文并茂)

    模型也可以上网课?手把手教你在query-doc匹配模型上实现蒸馏优化!

    2种方式!带你快速实现前端截图

    C++反射:深入探究function实现机制!

    以上是关于Golang 简洁架构实战的主要内容,如果未能解决你的问题,请参考以下文章

    Golang 简洁架构实战

    保姆级教程!Golang微服务简洁架构实战

    大型网站架构——百万PV网站

    Golang Gin 实战| 快速安装入门

    Flutter 项目实战 架构模式四

    ArcGIS水文分析实战教程细说流向与流量