Golang单体服务开发的最佳实践

Golang单体服务开发的最佳实践

为何撰写此最佳实践

  • 对于许多初创公司而言,我们应更专注于尽早交付应用。此时用户基数小,QPS 非常低,我们应采用更简单的技术架构来加速应用的交付,这正是单体架构的优势所在。
  • 正如我在演讲中常提到的,当我们使用单体架构快速交付应用时,也需要在开发应用时考虑未来的可能性,我们可以在单体内部清晰地拆分模块。
  • 许多开发者询问使用 go-zero 开发单体应用的最佳实践是什么。

由于 go-zero 是一个广泛使用的微服务框架,它是我在几个大型项目的完整开发过程中沉淀下来的。我们已充分考虑了单体服务开发的场景。

使用 go-zero 的单体架构,如图所示,同样可以支持大规模的业务量,其中 Service 是一个由多个 Pod 组成的单体服务。

单体架构示意图

我将详细分享如何使用 go-zero 快速开发一个包含多个模块的单体服务。

单体示例

让我们用一个上传和下载的单体服务来解释 go-zero 单体服务开发的最佳实践,为何使用这样的例子?

  • go-zero 社区经常询问如何为上传文件定义 API 文件,然后使用 goctl 自动生成代码。当我最初看到这类问题时,觉得很奇怪,为什么不使用像 OSS 这样的服务呢?后来我发现很多场景是用户需要上传 excel 文件,然后服务器解析后丢弃该文件。一是文件小,二是服务用户量不大,因此不需要引入 OSS,我认为这相当合理。
  • go-zero 社区也询问如何通过定义 API 文件来下载文件,然后由 goctl 自动生成。之所以通过 Go 提出这样的问题,通常有两个原因:一是业务刚刚起步,部署一个服务来完成所有事情更容易;二是希望利用 go-zero 内置的 JWT 认证优势。

这只是一个示例,无需深入探讨上传和下载是否应该用 Go 编写。那么,让我们看看如何使用 go-zero 解决这样一个我们称之为文件服务的单体服务。

单体实现

API 定义

使用过 go-zero 的开发者都知道,我们提供了一种 API 格式文件来描述 RESTful API,然后我们可以通过 goctl 一键生成相应的代码,我们只需要在 logic 文件中填入相应的业务逻辑。让我们看看 downloadupload 服务是如何定义 API 的。

Download API 定义

示例需求如下。

  • 通过路径 /static/<filename> 下载名为 <filename> 的文件
  • 直接返回文件内容即可

我们在 api 目录下创建一个名为 download.api 的文件,内容如下。

syntax = "v1"

type DownloadRequest {
  File string `path: "file"`
}

service file-api {
  @handler DownloadHandler
  get /static/:file(DownloadRequest)
}

zero-api 的语法相对不言自明,含义如下。

  1. syntax = "v1" 表示这是 zero-apiv1 语法
  2. type DownloadRequest 定义了 Download 的请求格式
  3. service file-api 定义了 Download 的请求路由

Upload API 定义

示例需求如下。

  • 通过路径 /upload 上传文件
  • 通过 json 返回上传状态,其中 code 可用于表达比 HTTP code 更丰富的场景

我们在 api 目录下创建一个名为 upload.api 的文件,内容如下。

syntax = "v1"

type UploadResponse {
  Code int `json: "code"`
}

service file-api {
  @handler UploadHandler
  post /upload returns (UploadResponse)
}

解释如下。

  1. syntax = "v1" 表示这是 zero-apiv1 语法
  2. type UploadResponse 定义了 Upload 的返回格式
  3. service file-api 定义了 Upload 的请求路由

问题来了

我们已经定义了 DownloadUpload 服务,但如何将它们放入一个服务中呢?

我不知道你是否注意到了一些细节。

  1. 无论是 Download 还是 Upload,我们都为 requestresponse 数据定义添加了前缀,没有直接使用诸如 RequestResponse 这样的名称
  2. 我们在 download.apiupload.api 中定义 service 时,都使用了 file-api 作为 service name,而不是分别使用 download-apiupload-api

这样做的目的是,当我们将两个服务放入单体服务时,能够自动生成相应的 Go 代码。让我们看看如何将 DownloadUpload 合并在一起~

定义单体服务 API

为简单起见,goctl 仅支持接受单个 API 文件作为参数,接受多个 API 文件的问题此处不讨论,如果我们找到简单高效的解决方案,后续可能会支持。

我们在 api 目录中创建一个新的 file.api 文件,内容如下。

syntax = "v1"

import "download.api"
import "upload.api"

这样我们就导入了 DownloadUpload 服务,类似于 C/C++ 中的 #include。但有几件事需要注意。

  1. 定义的结构体不能重名
  2. 所有文件中的 service name 必须相同

最外层的 API 文件也可以包含部分相同的 service 定义,但我们建议保持对称,除非这些 API 确实属于父级别,例如与 DownloadUpload 处于同一逻辑级别,那么它们就不应该在 file.api 中定义。

至此,我们有了以下文件结构。

.
└── api
    ├── download.api
    ├── file.api
    └── upload.api

生成单体服务

现在我们已经定义了 API 接口,下一步对于 go-zero 来说就相当直接了(当然,定义 API 本身也很直接,不是吗?)。让我们使用 goctl 来生成单体服务代码。

$ goctl api go -api api/file.api -dir .

让我们看一下生成的文件结构。

.
├── api
│ ├── download.api
│ ├── file.api
│ └── upload.api
├── etc
│ └── file-api.yaml
├── file.go
├─ go.mod
├── go.sum
└── internal
    ├─ config
    │ └─ config.go
    ├─ handler
    │ ├── downloadhandler.go
    │ ├── routes.go
    │ └── uploadhandler.go
    ├─ logic
    │ ├── downloadlogic.go
    │ └── uploadlogic.go
    ├── svc
    │ └── servicecontext.go
    └─ types
        └─ types.go

让我们按目录解释项目的布局。

  • api 目录:我们之前定义的 API 接口描述文件,无需多言
  • etc 目录:用于存放 yaml 配置文件,所有配置项都可以写在 file-api.yaml 文件中
  • file.go:包含 main 函数的文件,与 service 同名,去掉了 -api 后缀
  • internal/config 目录:服务的配置定义
  • internal/handler 目录:API 文件中定义的路由的 handler 实现
  • internal/logic 目录:用于放置每个路由对应的业务处理逻辑,区分 handlerlogic 的原因是为了最小化业务处理部分的依赖,隔离 HTTP 请求 与逻辑处理代码,并便于后续根据需要拆分为 RPC 服务
  • internal/svc 目录:用于定义业务逻辑处理的依赖项,我们可以在 main 中创建依赖资源,并通过 ServiceContext 传递给 handlerlogic
  • internal/types 目录:定义 API 请求和响应的数据结构

我们暂时不做任何更改,直接运行看看效果。

$ go run file.go -f etc/file-api.yaml
Starting server at 0.0.0.0:8888...

实现业务逻辑

接下来我们需要实现相关的业务逻辑,但这里的逻辑真的只是为了演示,所以不要过多关注实现细节,只需理解我们应该在 logic 层编写业务逻辑。

这里做了以下几件事。

  • 在配置项中添加 Path 设置,用于放置上传的文件,默认我写了当前目录,因为是示例,如下。
type Config struct {
  RestConf
  // 新增
  Path string `json:",default=." `
}
  • 调整了请求体大小限制,如下。
Name: file-api
Host: localhost
Port: 8888
# 新增
MaxBytes: 1073741824
  • 由于 Download 需要将文件写入客户端,我们将 ResponseWriter 作为 io.Writer 传递到 logic 层,修改后的代码如下
func (l *DownloadLogic) Download(req *types.DownloadRequest) error {
  logx.Infof("download %s", req.File)
  body, err := ioutil.ReadFile(req.File)
  if err ! = nil {
    return err
  }

  n, err := l.writer.Write(body)
  if err ! = nil {
    return err
  }

  if n &lt; len(body) {
    return io.ErrClosedPipe
  }

  return nil
}
  • 由于 Upload 需要读取用户上传的文件,我们将 http.Request 传递到 logic 层,修改后的代码如下。
func (l *UploadLogic) Upload() (resp *types.UploadResponse, err error) {
  l.r.ParseMultipartForm(maxFileSize)
  file, handler, err := l.r.FormFile("myFile")
  if err ! = nil {
    return nil, err
  }
  defer file.Close()

  logx.Infof("upload file: %+v, file size: %d, MIME header: %+v",
    handler.Filename, handler.Size, handler.Header)

  tempFile, err := os.Create(path.Join(l.svcCtx.Config.Path, handler.Filename))
  if err ! = nil {
    return nil, err
  }
  defer tempFile.Close()
  io.Copy(tempFile, file)

  return &amp;types.UploadResponse{
    Code: 0,
  }, nil
}

完整代码:zero-examples/monolithic at main · zeromicro/zero-examples · GitHub

我们可以通过运行以下命令启动 file 单体服务。

$ go run file.go -f etc/file-api.yaml

Download 服务可以使用 curl 验证:

$ curl -i "http://localhost:8888/static/file.go"
HTTP/1.1 200 OK
Traceparent: 00-831431c47d162b4decfb6b30fb232556-dd3b383feb1f13a9-00
Date: Mon, 25 Apr 2022 01:50:58 GMT
Content-Length: 584
Content-Type: text/plain; charset=utf-8

...

示例仓库包含 upload.html,浏览器可以打开此文件来尝试 Upload 服务。

单体开发总结

让我总结一下使用 go-zero 开发单体服务的完整流程,如下所示。

  1. 为每个子模块定义 API 文件,例如 download.apiupload.api
  2. 定义通用的 API 文件,例如 file.api。用它来 import 第 1 步中定义的各个子模块的 API 文件
  3. 通过 goctl api go 命令生成单体服务框架代码
  4. 添加和调整配置,实现相应子模块的业务逻辑

此外,goctl 可以根据 SQL 一键生成 CRUDcache 代码,这可以帮助你更快速地开发单体服务。

项目地址

欢迎使用 go-zerostar 支持我们!


更多关于Golang单体服务开发的最佳实践的实战教程也可以访问 https://www.itying.com/category-94-b0.html

1 回复

更多关于Golang单体服务开发的最佳实践的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


使用 go-zero 开发单体服务确实能有效平衡开发效率与架构清晰度。以下针对你提供的示例,补充一些关键实践和代码细节。

1. 配置管理优化

etc/file-api.yaml 中,除了基础配置,建议明确区分环境配置(如开发、测试、生产)。可通过 go-zeroRestConf 结构体扩展支持:

// internal/config/config.go
type Config struct {
    rest.RestConf
    Path    string `json:",default=./uploads"`
    MaxBytes int64  `json:",default=1073741824"` // 1GB
    Env      string `json:",optional,default=dev"` // 环境标识
}

对应 yaml 文件:

Name: file-api
Host: 0.0.0.0
Port: 8888
MaxBytes: 1073741824
Path: ./uploads
Env: prod  # 根据环境切换

2. 统一错误处理

logic 层实现自定义错误类型,确保错误信息结构化返回:

// internal/logic/baseerror.go
type BizError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

func (e *BizError) Error() string {
    return e.Message
}

// internal/logic/downloadlogic.go
func (l *DownloadLogic) Download(req *types.DownloadRequest) error {
    if !strings.HasSuffix(req.File, ".go") {
        return &BizError{Code: 1001, Message: "仅支持下载.go文件"}
    }
    data, err := os.ReadFile(req.File)
    if err != nil {
        return &BizError{Code: 1002, Message: "文件读取失败"}
    }
    _, err = l.writer.Write(data)
    return err
}

3. 文件上传安全增强

uploadlogic.go 中添加文件类型和大小校验:

func (l *UploadLogic) Upload() (*types.UploadResponse, error) {
    // 限制表单大小
    if err := l.r.ParseMultipartForm(l.svcCtx.Config.MaxBytes); err != nil {
        return nil, &BizError{Code: 2001, Message: "文件大小超限"}
    }
    
    file, header, err := l.r.FormFile("myFile")
    if err != nil {
        return nil, err
    }
    defer file.Close()

    // 校验文件类型
    buf := make([]byte, 512)
    if _, err = file.Read(buf); err != nil {
        return nil, err
    }
    if !strings.Contains(http.DetectContentType(buf), "image/") {
        return nil, &BizError{Code: 2002, Message: "仅支持图片格式"}
    }
    file.Seek(0, 0) // 重置文件指针

    // 保存文件
    dstPath := filepath.Join(l.svcCtx.Config.Path, header.Filename)
    dst, err := os.Create(dstPath)
    if err != nil {
        return nil, err
    }
    defer dst.Close()
    
    if _, err = io.Copy(dst, file); err != nil {
        return nil, err
    }
    
    return &types.UploadResponse{Code: 0}, nil
}

4. 中间件集成

file.go 中全局添加日志、限流等中间件:

// file.go
func main() {
    flag.Parse()
    
    var c config.Config
    conf.MustLoad(*configFile, &c)
    
    server := rest.MustNewServer(c.RestConf)
    defer server.Stop()

    // 全局中间件
    server.Use(middleware.NewLoggerMiddleware().Handle)
    server.Use(middleware.NewRateLimitMiddleware(time.Minute, 100).Handle)
    
    ctx := svc.NewServiceContext(c)
    handler.RegisterHandlers(server, ctx)
    
    fmt.Printf("启动服务 %s:%d\n", c.Host, c.Port)
    server.Start()
}

5. 数据库集成示例

若需添加数据库操作,可通过 goctl model 生成带缓存的 CRUD 代码:

# 生成 user 表模型
goctl model mysql datasource -url="user:pass@tcp(127.0.0.1:3306)/dbname" -table="user" -dir ./internal/model

internal/svc/servicecontext.go 中注入:

type ServiceContext struct {
    Config config.Config
    UserModel model.UserModel // 自动生成的模型
}

func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
        Config: c,
        UserModel: model.NewUserModel(sqlx.NewMysql(c.DataSource), c.Cache),
    }
}

6. 性能监控集成

config.go 中添加 Metrics 配置,并启用 Prometheus 指标收集:

type Config struct {
    rest.RestConf
    Path    string `json:",default=./uploads"`
    Metrics rest.MetricsConf `json:",optional"` // 监控配置
}

// file.go 中启用
server := rest.MustNewServer(c.RestConf, rest.WithMetrics(c.Metrics))

7. 完整目录结构

最终项目结构如下:

.
├── api
│   ├── download.api
│   ├── file.api
│   └── upload.api
├── etc
│   ├── file-api-dev.yaml
│   └── file-api-prod.yaml
├── file.go
├── go.mod
└── internal
    ├── config
    │   └── config.go
    ├── handler
    │   ├── downloadhandler.go
    │   ├── routes.go
    │   └── uploadhandler.go
    ├── logic
    │   ├── baseerror.go
    │   ├── downloadlogic.go
    │   └── uploadlogic.go
    ├── model
    │   └── usermodel.go    # 生成的模型
    └── svc
        └── servicecontext.go

这些实践确保了单体服务在快速开发的同时,保持代码可维护性和扩展性。当业务增长时,可通过 go-zeroRPC 工具将模块平滑拆分为微服务。

回到顶部