Go项目工程化

1,230 阅读5分钟

本文章旨在结合实例给出Go工程化的参考,特别在Web后端服务项目中强调:

  1. 使用protobuffer保证服务调用者和提供者保证信息一致以及在某些场景下的性能提升
  2. 使用wire,dig等依赖注入工具实现代码简洁,模块间依赖清晰,模块可插拔
  3. 使用ddd分层思想以满足构建微服务项目的需求,在biz层实现抽象应对改变
  4. 一些细节(参考More)

根据项目功能,可将所有项目大致分为三类:

  1. Web后端服务项目 实例项目地址 gitee.com/alexwillbeg…
  2. 定时任务类项目(待更新)
  3. 公共库类项目(待更新)

Web后端服务类项目

基于DDD思想的工程化

实例项目地址 gitee.com/alexwillbeg…

大致目录结构

project
|   .gitignore
|   .gitlab-ci.yml
|   go.mod
|   go.sum
|   install.sh
|   makefile
|   run.bat
|   
|___api
|   |   README.md
|   |
|   |___sub_project1
|   |      xxx.pb.go
|   |      xxx.proto
|   |
|   |___sub_project2
|          xxx.pb.go
|          xxx.proto
|
|___CHANGE_LOG
|      20211125.md
|      README.md
|
|___cmd
|   |   README.md
|   |
|   |___subproject1
|   |      project1.go
|   |      main.go
|   |      wire_gen.go
|   |      wire.go
|   |
|   |___subproject2
|          project1.go
|          main.go
|          wire_gen.go
|          wire.go
|
|___configs
|      config-live.yaml
|      config-qa-sz.yaml
|      config-stg.yaml
|      config.yaml
|
|___deploy
|      start.sh
|
|___docs
|   |   docs.go
|   |   README.md
|   |   swagger.json
|   |   swagger.yaml
|   |___sqls
|
|___internal
|___pkg
|___test

cmd 主要包含main入口文件,以及wire相关文件,其中init和main函数中依赖于wire生成的对象(cmd-subproject1 cmd-subproject2表示两个可以单独部署的服务) api 主要包含一个请求的出参和入参的定义,使用protobuffer文件进行定义 pkg 如若本项目期望提供公共包给外部项目可放在此位置 docs 文档文件(swagger文件等) test 单元测试相关文件 internal 主要的业务逻辑实现的位置,利用go编译机制命名internal,避免会有其他外部项目引用该包里面的任何内容(详细参考下文)

DDD分层下的目录

从传统三层架构到DDD分层架构的变化可参考 www.jianshu.com/p/b35befc6d… 架构中对象分类可参考 zhuanlan.zhihu.com/p/86047251

工程化角度来看,可简单归纳为将传统三层架构中需要处理大量业务逻辑的BLL层分割成了Service,Domain,Repository层,由于基于DDD理论将业务划分成不同的领域,使得领域和领域之间几乎没有联系,领域之间不支持相互调用。

internal
|   README.md
|
|___server
|       README.md
|       server.go
|       http_subproject1.go
|       rpc_subproject1.go
|   
|___service
|       README.md
|       service.go
|       xxxservice.go
|___biz
|      README.md
|      biz.go
|      xxx.go   
|___data
|      README.md
|      data.go
|      xxx.go
|___ent
|___pkg
|   |   README.md
|   |
|   |___constant
|   |___enums
|   |___errgroup
|   |___ffmpeg
|   |___logging
|   |___middleware
|   |___util
|   |___setting
|   |___ ...
|
|___conf
       conf.pb.go
       conf.proto

server(服务器层) 根据子项目名+Server类型(http,rpc)分成不同server文件,可以引用gin框架,路由的引入也在此处。 service(服务层) biz(领域层)根据业务领域划分形成,包含一些实际的case,领域之间不互相依赖 data(仓储层)封装对象的基础方法 ent(数据持久层)当前例子中使用entgo作为orm框架(可随意替换),直接与数据库进行交互。 pkg 包含只在该项目中所需要共享的包,相当于基础设施

More

项目中的依赖关系

使用wire或者dig依赖注入容器(本例子使用wire) 其中server,service,biz(domain),data都包含有和目录相同名称的文件,server->service->biz(domain)->data依次向下依赖,启动时统一进行注入

//cmd/wire.go
func initApp(*conf.Conf, *logging.Logger) (*App, func(), error) {
	panic(wire.Build(server.ProviderSet, service.ProviderSet, biz.ProviderSet, data.ProviderSet, NewApp))
}
//server.go
var ProviderSet = wire.NewSet(NewAppHttpServer)
//service.go
var ProviderSet = wire.NewSet(
	NewApiHistoryBaseService,
	NewApplicationService,
)
//biz.go
var ProviderSet = wire.NewSet(
	NewCAUseCase,
	NewApiHistoryUseCase,
	ca.NewCA,
	ca.NewGDCA,
)
//data.go
var ProviderSet = wire.NewSet(
	NewEntGoClient,
	NewData,
	NewApiHistoryRepo,
)

NewXXXX函数为构造器函数

type ApplicationService struct {
	ca     *biz.CAUseCase
	logger *logging.Logger
}

func NewApplicationService(
	ca *biz.CAUseCase,
	logger *logging.Logger) *ApplicationService {
	return &ApplicationService{
		ca:     ca,
		logger: logger,
	}
}

项目中的实体类型

DTO类型为请求传入的类型,由api目录下pb文件定义。但是建议在全文仅使用这一个对象,还应至少包含DO(Domain Object)提高编码灵活性,对象转换顺序为DTO->DO(Domain Object)->PO(Persistent Object)

// biz/base_apihistory.go中定义DO
type ApiHistory struct {
	Url       string
	Header    string
	Body      string
	Response  string
	StartTime time.Time
	EndTime   time.Time
	Duration  int
}

领域层实现抽象

使用抽象增加灵活性,没有必要在所有层都去定义抽象,然后下层去实现抽象,可选择仅在领域层来定义抽象,仓储层去实现抽象

// biz/base_apihistory.go中定义interface
type ApiHistoryRepo interface {
	CreateApiHistory(context.Context, *ApiHistory) error
}

// data/apihistory.go中实现该抽象
type apiHistoryRepo struct {
	data *Data
}

func (r *apiHistoryRepo) CreateApiHistory(ctx context.Context, ah *biz.ApiHistory) error {
	c := r.data.ec.c
	// ctx := r.data.ec.ctx
	_, err := c.APIHistory.Create().
		SetHeader(ah.Header).
		SetBody(ah.Body).
		SetURL(ah.Url).
		SetDuration(ah.Duration).
		SetStartTime(ah.StartTime).
		SetEndTime(ah.EndTime).
		SetResponse(ah.Response).
		Save(ctx)
	return err
}

基础服务统一注册

本文基础服务指的是数据库某个实体对外直接暴露的增删查改等基础的操作,这类接口对于后台系统来说很有用 按照约定俗称的方式(命名以及实现特定方法)去定义这些基础服务类,然后在路由注册的时候通过反射统一注册这些基础服务。此处使用反射是在注册服务阶段,不会影响服务性能

RegisterBaseRoutes(r,
		apiHistoryBaseService,
	)
...
func RegisterBaseRoutes(ge *gin.Engine, services ...interface{}) {

	if len(services) > 0 && services != nil {
		RegisterBaseServices(services...)
	}

	for k, v := range baseServices {
		for _, op := range crudOps {
		    entGroup := ge.Group(fmt.Sprintf("/%v", k))
			path := fmt.Sprintf("/%v", op)
			mName := fmt.Sprintf("%v%v", op, k)
			ser_val := reflect.ValueOf(v)
			m := ser_val.MethodByName(mName)
			//skip if target service have no designate method
			//funny to see IsValid returns false when MethodByName method returns Zero value
			if !m.IsValid() {
				continue
			}

			//In(0) default to be context.Context type
			in := m.Type().In(1)
			out := m.Type().Out(0)
			inVal := reflect.New(in.Elem())
			outVal := reflect.New(out.Elem())
			_ = outVal
			handler := func(c *gin.Context) {
				//request data validation should be done here
				req := inVal.Interface()
				c.Bind(req)
				ret := m.Call([]reflect.Value{reflect.ValueOf(context.Background()), inVal})[0]
				c.JSON(http.StatusOK, gin.H{
					"data": ret.Interface(),
				})
			}
			switch op {
			case "Create":
				entGroup.POST(path, handler)
			case "Update":
				entGroup.PUT(path, handler)
			case "Delete":
				entGroup.DELETE(path, handler)
			case "Get":
				entGroup.GET(path, handler)
			default:
			}
		}
	}
}

启动时使用context以及errgroup控制server的生命周期

同时启动多个server,其中任何一个server出现错误都希望能够将其他所有的server全部关闭,并且希望能监听系统信号,可使用contex以及耳热group组合来实现。

var (
	g, ctx = errgroup.WithContext(context.Background())
	c      = make(chan os.Signal, 1)
)
//use errgroup to control server life cycle
func (app *App) Run() error {
	//shutdown all server if any task goes wrong
	go func() {
		<-ctx.Done()
		ctx, cf := context.WithTimeout(context.Background(), time.Second)
		defer cf()
		shutDown(app, ctx)
	}()

	go func() {
		<-c
		ctx, cf := context.WithTimeout(context.Background(), time.Second)
		defer cf()
		shutDown(app, ctx)
	}()

	//specified server task
	g.Go(func() error {
		return app.appHttpServer.ListenAndServe()
	})

	//elegant shut down
	signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
	errChan := make(chan error, 1)
	go func() {
		errChan <- g.Wait()
	}()

	select {
	case err := <-errChan:
		return err
	case <-c:
		return errors.New("receiving terminal signal")
	}
}

使用makefile简化命令操作

项目中命令操作包括使用pb工具生成实体以及生成swagger文档等,可使用makefile进行简化。

# 生成pb文件,区分大小写
gen-protobuf:gen-protobuf-api gen-protobuf-conf

# api pb文件生成
gen-protobuf-api:
	protoc -I $(GOPATH)/src ucgo/api/app/ucgo.proto --go_out=.  

# conf pb文件生成
gen-protobuf-conf:
	protoc -I $(GOPATH)/src ucgo/internal/conf/conf.proto --go_out=.  

项目优化点

  1. 使用gogofaster-protobuffer替代官方pb库(消除反射带来的性能影响)
  2. 使用easyjson替换官方json库(消除反射!)
  3. swagger的使用,使用protobuffer定义的实体去定义swagger因为受到tag字段限制无法完全发挥swagger的功能,但是重新定义一套实体去满足swagger功能又会增加维护成本
  4. 配置改为“选项配置”更为友好
//example
err = l.StartTLS(&tls.Config{InsecureSkipVerify: true})
    if err != nil {
        log.Fatal(err)
    }
  1. 日志系统