在Go中构建网络应用和API的架构模式

72 阅读7分钟

在这篇文章中,我们将讨论我最喜欢的一种在Go中构建网络应用和API的架构模式。它是服务对象胖模型模式的混合体,所以我把它称为 "胖服务 "模式,但它可能有一个我不知道的更正式的名字。

它当然不是一个完美的模式(我们将在后面讨论一些优点和缺点)--但它简单、务实,而且我发现它通常对中小型项目很有效。

**注意:**在我们开始之前,我想强调的是,在Go中没有单一的 "正确 "方法来构造你的项目。不同的架构适合不同的项目和团队,而这只是一个可以考虑的选择。

在高层次上,胖子服务模式将你的项目代码分成了两个不同的 "层":

  • 应用层:这包括与读写HTTP请求和响应、验证/授权请求、会话管理等相关的代码。

  • 服务层:它包含了你的业务逻辑,定义了你的核心数据类型,也负责与任何持久性数据存储进行交互。

一个胖子服务的例子

让我们用一个JSON API的例子来说明这种模式是如何工作的。

具体来说,假设我们想建立一个带有POST /register 端点的API,用来注册一个新用户。当一个客户向这个端点发出请求时,让我们假设我们想采取以下行动:

  1. 将JSON输入解析成Go结构,这样我们就可以轻松地处理它了。
  2. 对数据进行一些验证检查(如果其中任何一项失败,则向客户端返回一个错误响应)。
  3. 创建一个新用户密码的哈希值。
  4. 在数据库中插入一条用户记录。
  5. 向Slack频道发送一个通知,说有一个新用户已经注册。
  6. 如果一切都成功的话,向客户端返回一个204 No Content 响应。

使用这种模式,我们可以构建我们的项目,使目录和文件布局看起来像这样:

.
├── cmd
│   └── api
|       ├── handlers.go
│       └── main.go
└── internal
    └── service
        ├── service.go
        └── users.go

cmd/api 包将包含应用层代码,而internal/service 包将包含服务层代码。

然后,非常粗略地,我们服务层的代码可能看起来像这样。

文件: internal/service/service.go

File: internal/service/service.go
package service

import (
    "database/sql"
    "errors"
)

var ErrFailedValidation = errors.New("failed validation")

type Service struct {
    DB              *sql.DB
    SlackWebhookURL string
}

文件: internal/service/users.go

File: internal/service/users.go
package service

import (
    "github.com/slack-go/slack"
    "golang.org/x/crypto/bcrypt"
)

type RegisterUserInput struct {
    Username         string            `json:"username"`
    Password         string            `json:"password"`
    ValidationErrors map[string]string `json:"-"`
}

func (s *Service) RegisterUser(input *RegisterUserInput) error {
    input.ValidationErrors = make(map[string]string)

    if input.Username == "" {
        input.ValidationErrors["username"] = "must be provided"
    }

    // And any other validation checks...

    if len(input.ValidationErrors) > 0 {
        return ErrFailedValidation
    }

    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
    if err != nil {
        return err
    }

    _, err = s.DB.Exec("INSERT INTO (username, hashed_password) VALUES ($1, $2)", input.Username, string(hashedPassword))
    if err != nil {
        return err
    }

    msg := slack.WebhookMessage{
        Username: "robot",
        Channel:  "#general",
        Text:     "A new user has signed up!",
    }

    return slack.PostWebhook(s.SlackWebhookURL, &msg)
}

而我们应用层的代码可能是这样的(为了简洁起见,我省略了辅助函数)。

文件:cmd/api/main.go

File: cmd/api/main.go
package main

import (
    "database/sql"
    "flag"
    "log"
    "net/http"
    "os"

    "example.com/internal/service"

    "github.com/alexedwards/flow"
    _ "github.com/mattn/go-sqlite3"
)

type application struct {
    logger  *log.Logger
    service *service.Service
}

func main() {
    dsn := flag.String("dsn", "./db.sqlite", "sqlite3 DSN")
    slackWebhookURL := flag.String("slack-webhook-url", "https://hooks.slack.com/services/example", "slack webhook URL for notifications")

    flag.Parse()

    logger := log.New(os.Stdout, "", log.LstdFlags|log.Llongfile)

    db, err := sql.Open("sqlite3", *dsn)
    if err != nil {
        logger.Fatal(err)
    }
    defer db.Close()

    app := &application{
        logger:  logger,
        service: &service.Service{DB: db, SlackWebhookURL: *slackWebhookURL},
    }

    mux := flow.New()
    mux.HandleFunc("/register", app.registerUserHandler, "POST")

    logger.Print("starting server on :3000")
    err = http.ListenAndServe(":3000", mux)
    logger.Fatal(err)
}

文件:cmd/api/handlers.go

File: cmd/api/handlers.go
package main

import (
    "errors"
    "net/http"

    "example.com/internal/service"
)

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
    var input service.RegisterUserInput

    err := app.decodeJSON(r.Body, &input)
    if err != nil {
        app.badRequest(w, r, err)
        return
    }

    err = app.service.RegisterUser(&input)
    if err != nil {
        if errors.Is(err, service.ErrFailedValidation) {
            app.failedValidation(w, r, input.ValidationErrors)
        } else {
            app.serverError(w, r, err)
        }
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

希望你能得到大致的概念。

基本上,我们的服务层包含一个Service.RegisterUser() 方法,它执行所有与注册用户相关的验证检查、业务逻辑和SQL查询。

这个方法的预期输入是简单的、标准的、service.RegisterUserInput Go结构。

而在我们应用层的registerUserHandler() 处理程序中,我们可以将JSON请求体直接解码为该结构,并将其传递给服务层,必要时处理任何返回的错误。

优点和缺点

就好处而言,这种模式有很多好的地方:

  • 它相当简单。在阅读代码时,需要跳过的脑筋急转弯的数量相对较少。你不需要翻阅大量的包和函数来了解代码在做什么--这意味着你的项目的新人相对容易理解(甚至是你自己在长期休息后)。

  • 关注点的分离使我们的registerUserHandler() 代码主要集中在读写HTTP请求和响应上。对于有几个以上端点的应用程序,我发现不要试图在你的处理程序中做所有的事情,这有助于使你的代码库更容易浏览和推理。

  • 服务层中的代码可以被其他应用程序重复使用。例如,我们可以在cmd/cli 下创建一个CLI应用程序,其任务也可以调用Service.RegisterUser() 方法。

  • 这一点比较个人化,但我发现当输入是一个具有正确类型的定义良好的Go结构(而不是像JSON字符串或HTML编码的表单数据这样更 "混乱 "的输入)时,推理我的业务逻辑并为其编写代码会更容易。

  • 这对API和网络应用来说真的很实用。您可以将请求主体中的JSON或HTML表单数据直接解析为处理程序中的service.RegisterUserInput 结构,然后将该结构传递给服务层进行处理。你不需要在处理程序中创建临时类型来保存解码后的请求数据,也不需要将数据从一个结构复制到另一个结构。

  • 服务层中的方法有可能从代码中的多个点返回验证错误,而你可以在处理程序中一次性处理所有这些错误。例如,如果我们的用户INSERT ,因为我们试图插入一个用户名重复的记录而失败,那么除了预先的INSERT 验证检查之外,我们还可以从服务层返回一个 "用户名已被占用 "的验证错误。

  • 与数据库事务一起工作是很容易的。如果我们想在一个事务中执行多个SQL语句作为注册用户的一部分,我们可以初始化sql.TX ,执行所有必要的语句,并在我们的Service.RegisterUser() 方法中提交该事务。我们不需要把sql.TX 传递到我们代码库中的一堆不同地方。

  • 如果你只想测试你的应用层逻辑,这种模式很适合创建一个接口类型来描述service.Service 结构上的方法,然后你可以用一个模拟实现来满足它。

但这并不完美,也有一些缺点:

  • 当你在看处理程序的代码时,你不能立即看到预期的输入是什么。你必须导航到service 包并查看service.RegisterUserInput 结构的字段。对于大多数现代的文本编辑器来说,这只是一个点击的过程,但它仍然引入了一些 "晦涩",对我来说感觉不是很理想。

  • 如果没有一个单独的数据库逻辑抽象,就很难在将来把一个数据库换成另一个(比如从SQLite换成PostgreSQL)。

  • 你不能在测试中轻易地模拟数据库的调用。就我个人而言,我倾向于使用实际数据库的测试实例进行测试,所以我不认为这在大多数情况下是一个太大缺点。但如果你需要模拟数据库(即加快测试运行时间,或因为这是客户的硬性要求),那么这种模式就不太适合。

  • 最后,使用database/sqlQuery() 方法来返回多行数据的SQL查询是相当冗长的。这些查询会占用大量的视觉空间,并给服务层的方法增加杂乱无章的内容--这最终开始降低了代码的可读性。使用jmoiron/sqlxblockloop/scan 可以在这里提供很大的帮助。

但总的来说,只要你不需要模拟你的数据库调用,我喜欢这种模式。在过去的3-4年中,我经常使用它,并且发现它的相对简单性和实用性大大超过了任何缺点。