在这篇文章中,我们将讨论我最喜欢的一种在Go中构建网络应用和API的架构模式。它是服务对象和胖模型模式的混合体,所以我把它称为 "胖服务 "模式,但它可能有一个我不知道的更正式的名字。
它当然不是一个完美的模式(我们将在后面讨论一些优点和缺点)--但它简单、务实,而且我发现它通常对中小型项目很有效。
**注意:**在我们开始之前,我想强调的是,在Go中没有单一的 "正确 "方法来构造你的项目。不同的架构适合不同的项目和团队,而这只是一个可以考虑的选择。
在高层次上,胖子服务模式将你的项目代码分成了两个不同的 "层":
-
应用层:这包括与读写HTTP请求和响应、验证/授权请求、会话管理等相关的代码。
-
服务层:它包含了你的业务逻辑,定义了你的核心数据类型,也负责与任何持久性数据存储进行交互。
一个胖子服务的例子
让我们用一个JSON API的例子来说明这种模式是如何工作的。
具体来说,假设我们想建立一个带有POST /register 端点的API,用来注册一个新用户。当一个客户向这个端点发出请求时,让我们假设我们想采取以下行动:
- 将JSON输入解析成Go结构,这样我们就可以轻松地处理它了。
- 对数据进行一些验证检查(如果其中任何一项失败,则向客户端返回一个错误响应)。
- 创建一个新用户密码的哈希值。
- 在数据库中插入一条用户记录。
- 向Slack频道发送一个通知,说有一个新用户已经注册。
- 如果一切都成功的话,向客户端返回一个
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/sql和Query()方法来返回多行数据的SQL查询是相当冗长的。这些查询会占用大量的视觉空间,并给服务层的方法增加杂乱无章的内容--这最终开始降低了代码的可读性。使用jmoiron/sqlx或blockloop/scan可以在这里提供很大的帮助。
但总的来说,只要你不需要模拟你的数据库调用,我喜欢这种模式。在过去的3-4年中,我经常使用它,并且发现它的相对简单性和实用性大大超过了任何缺点。