在 Go 中实现干净架构





5.00/5 (3投票s)
用 Go 实现的干净架构示例
引言
关于 Clean Architecture 的讨论已经有很多了。它的主要价值在于能够维护一个不受副作用影响的领域层,从而我们可以在不依赖繁重 mock 的情况下测试核心业务逻辑。
这通过编写不依赖于外部的、核心领域逻辑和外部适配器(无论是数据库存储还是 API 层)来实现的,这些适配器依赖于领域,而不是反过来。
在本文中,我们将通过一个 Go 项目示例来了解 Clean Architecture 的实现方式。我们将涵盖一些附加主题,例如容器化和使用 Swagger 实现 OpenAPI 规范。
虽然我将重点介绍文章中的要点,但你也可以在 我的 Github 上查看整个项目。
项目需求
我们需要实现一个 REST API 来模拟一副牌。
我们需要为你的 API 提供以下方法来处理牌和牌组:
- 创建一副新的
牌组
- 打开一副
牌组
- 抽取一张
牌
创建一副新牌组
这将创建一个标准的 52 张法国扑克牌牌组,包含四种花色(梅花 ♣、方块 ♦、红心 ♥ 和黑桃 ♠)的十三种点数。此任务不需要考虑 Joker 牌。
- 牌组是否需要洗牌——默认情况下,牌组是顺序排列的:A 黑桃、2 黑桃、3 黑桃……然后是方块、梅花、红心。
- 牌组是完整的还是部分的——默认情况下,它返回标准的 52 张牌,否则请求将接受所需的牌,如下例所示
?cards=AS,KD,AC,2C,KH
响应需要返回一个 JSON,其中包含:
- 牌组 ID (UUID)
- 牌组属性,例如是否洗牌 (boolean) 以及此牌组中剩余的总牌数 (integer)
{
"deck_id": "a251071b-662f-44b6-ba11-e24863039c59",
"shuffled": false,
"remaining": 30
}
打开一副牌组
它将返回指定 UUID 的牌组。如果牌组未提供或无效,则应返回错误。此方法将“打开牌组”,这意味着它将按创建顺序列出所有牌。
响应需要返回一个 JSON,其中包含: - 牌组 ID (UUID
) - 牌组属性,例如是否洗牌 (boolean) 和此牌组中剩余的总牌数 (integer) - 所有剩余的牌 (card
对象)。
{
"deck_id": "a251071b-662f-44b6-ba11-e24863039c59",
"shuffled": false,
"remaining": 3,
"cards": [
{
"value": "ACE",
"suit": "SPADES",
"code": "AS"
},
{
"value": "KING",
"suit": "HEARTS",
"code": "KH"
},
{
"value": "8",
"suit": "CLUBS",
"code": "8C"
}
]
}
抽取一张牌
我们将抽取给定 Deck
的一张或多张牌。如果牌组未提供或无效,则应返回错误。需要提供 count 参数来定义要从牌组中抽取多少张牌。
响应需要返回一个 JSON,其中包含:所有抽取的牌 (card object)
{
"cards": [
{
"value": "QUEEN",
"suit": "HEARTS",
"code": "QH"
},
{
"value": "4",
"suit": "DIAMONDS",
"code": "4D"
}
]
}
设计领域
由于领域是我们应用程序不可或缺的一部分,我们将从领域开始设计我们的系统。
我们将 Shape
和 Rank
类型编码为 iota
。如果你熟悉其他语言,可能会将其视为 enum
,这非常方便,因为我们的任务假设某种内置顺序,所以我们可以为此目的利用底层的数值。
type Shape uint8
const (
Spades Shape = iota
Diamonds
Clubs
Hearts
)
type Rank int8
const (
Ace Rank = iota
Two
Three
Four
Five
Six
Seven
Eight
Nine
Ten
Jack
Queen
King
)
完成此操作后,我们可以将 Card
编码为其花色和点数的组合。
type Card struct {
Rank Rank
Shape Shape
}
领域驱动设计的一个功能是 使非法状态无法表示,但由于所有点数和花色的组合都是有效的,因此创建一张牌相当简单。
func CreateCard(rank Rank, shape Shape) Card {
return Card{
Rank: rank,
Shape: shape,
}
}
现在让我们看看 Deck
type Deck struct {
DeckId uuid.UUID
Shuffled bool
Cards []Card
}
Deck
将执行三个操作:创建一副牌、抽取牌和计算剩余牌数。
func CreateDeck(shuffled bool, cards ...Card) Deck {
if len(cards) == 0 {
cards = initCards()
}
if shuffled {
shuffleCards(cards)
}
return Deck{
DeckId: uuid.New(),
Shuffled: shuffled,
Cards: cards,
}
}
func DrawCards(deck *Deck, count uint8) ([]Card, error) {
if count > CountRemainingCards(*deck) {
return nil, errors.New("DrawCards: Insufficient amount of cards in deck")
}
result := deck.Cards[:count]
deck.Cards = deck.Cards[count:]
return result, nil
}
func CountRemainingCards(d Deck) uint8 {
return uint8(len(d.Cards))
}
请注意,在抽取牌时,我们会检查是否有足够的牌来执行操作。为了在无法继续进行时发出信号,我们利用了 Go 的 多返回值 功能。
此时,我们可以看到 Clean Architecture 的一个关键优势:核心领域逻辑没有任何外部依赖,这极大地简化了单元测试。虽然其中大多数都微不足道,并且为了简洁起见我们将省略它们,但让我们看看那些验证牌组是否已洗牌的测试。
func TestCreateDeck_ExactCardsArePassed_Shuffled(t *testing.T) {
jackOfDiamonds := CreateCard(Jack, Diamonds)
aceOfSpades := CreateCard(Ace, Spades)
queenOfHearts := CreateCard(Queen, Hearts)
cards := []Card{jackOfDiamonds, aceOfSpades, queenOfHearts}
deck := CreateDeck(false, cards...)
deckCardsCount := make(map[Card]int)
for _, resCard := range deck.Cards {
value, exists := deckCardsCount[resCard]
if exists {
value++
deckCardsCount[resCard] = value
} else {
deckCardsCount[resCard] = 1
}
}
for _, inputCard := range cards {
value, found := deckCardsCount[inputCard]
assert.True(t, found, "Expected all cards to be present")
assert.Equal(t, 1, value, "Expected cards not to be duplicate")
}
}
显然,我们无法验证洗牌后牌的顺序。相反,我们可以验证洗牌后的牌组是否满足我们关心的属性,即每张牌都存在且牌组中没有重复的牌。这种技术非常类似于 基于属性的测试。
顺便提一句,值得一提的是,为了消除样板断言代码,我们利用了 testify 库。
提供 API
让我们从定义路由开始。
func main() {
r := gin.Default()
r.POST("/create-deck", api.CreateDeckHandler)
r.GET("/open-deck", api.OpenDeckHandler)
r.PUT("/draw-cards", api.DrawCardsHandler)
r.Run()
}
一些读者可能会对 create-deck 端点根据上面列出的要求接受 URL 请求中的参数感到困惑,并可能考虑将此端点接受 GET
请求而不是 POST
。然而,GET
请求的一个重要先决条件是它们具有 幂等性,而这个端点不具备。这就是我们坚持使用 POST
的确切原因。
处理程序遵循相同的模式。我们解析查询参数,基于它们创建一个领域实体,对其执行操作,更新存储并返回专门的 DTO。让我们详细看看。
type CreateDeckArgs struct {
Shuffled bool `form:"shuffled"`
Cards string `form:"cards"`
}
type OpenDeckArgs struct {
DeckId string `form:"deck_id"`
}
type DrawCardsArgs struct {
DeckId string `form:"deck_id"`
Count uint8 `form:"count"`
}
func CreateDeckHandler(c *gin.Context) {
var args CreateDeckArgs
if c.ShouldBind(&args) == nil {
var domainCards []domain.Card
if args.Cards != "" {
for _, card := range strings.Split(args.Cards, ",") {
domainCard, err := parseCardStringCode(card)
if err == nil {
domainCards = append(domainCards, domainCard)
} else {
c.String(400, "Invalid request. Invalid card code "+card)
return
}
}
}
deck := domain.CreateDeck(args.Shuffled, domainCards...)
storage.Add(deck)
dto := createClosedDeckDTO(deck)
c.JSON(200, dto)
return
} else {
c.String(400, "Ivalid request.
Expecting query of type ?shuffled=<bool>&cards=<card1>,<card2>,...<cardn>")
return
}
}
func OpenDeckHandler(c *gin.Context) {
var args OpenDeckArgs
if c.ShouldBind(&args) == nil {
deckId, err := uuid.Parse(args.DeckId)
if err != nil {
c.String(400, "Bad Request. Expecing request in format ?deck_id=<uuid>")
return
}
deck, found := storage.Get(deckId)
if !found {
c.String(400, "Bad Request. Deck with given id not found")
return
}
dto := createOpenDeckDTO(deck)
c.JSON(200, dto)
return
} else {
c.String(400, "Bad Request. Expecing request in format ?deck_id=<uuid>")
return
}
}
func DrawCardsHandler(c *gin.Context) {
var args DrawCardsArgs
if c.ShouldBind(&args) == nil {
deckId, err := uuid.Parse(args.DeckId)
if err != nil {
c.String(400, "Bad Request. Expecing request in format ?deck_id=<uuid>")
return
}
deck, found := storage.Get(deckId)
if !found {
c.String(400, "Bad Request.
Expecting request in format ?deck_id=<uuid>&count=<uint8>")
return
}
cards, err := domain.DrawCards(&deck, args.Count)
if err != nil {
c.String(400, "Bad Request. Failed to draw cards from the deck")
return
}
var dto []CardDTO
for _, card := range cards {
dto = append(dto, createCardDTO(card))
}
storage.Add(deck)
c.JSON(200, dto)
return
} else {
c.String(400, "Bad Request.
Expecting request in format ?deck_id=<uuid>&count=<uint8>")
return
}
}
日志记录
运行软件的一个重要方面是能够了解软件的当前状态。编写日志对此有帮助,因此我们将在代码中添加一些日志。
记录什么
在编写日志过少和过于冗长导致日志存储充斥着过多的嘈杂消息之间找到平衡非常重要。在我们的项目中,我们将遵循 此建议,并将日志视为服务边界上的事件。在我们的情况下,当对牌组进行操作时,这些事件发生在领域层内部。此外,我们将在 API 级别记录传入的请求,以查看哪些请求正在进来。如果我们使用了真实的存储,记录生成的查询(调试级别)会很好,但由于使用真实存储不在我们文章的范围内,我们将省略它。
记录领域事件
为了记录,我们将使用 zap,因为它具有日志级别,支持结构化日志,并在基准测试中显示出良好的结果。话虽如此,让我们做一些日志记录。
func CreateDeck(shuffled bool, cards ...Card) Deck {
logger, _ := zap.NewProduction()
defer logger.Sync()
sugar := logger.Sugar()
sugar.Infow("Create deck.", "shuffled", shuffled, "cards", cards)
//omitted for brevity
sugar.Infow("Create deck completed", "deck", result)
return result
}
func DrawCards(deck *Deck, count uint8) ([]Card, error) {
logger, _ := zap.NewProduction()
defer logger.Sync()
sugar := logger.Sugar()
sugar.Infow("Draw cards.", "deck", deck, "count", count)
//omitted for brevity
sugar.Infow("Draw cards completed.", "result", result, "deck", deck)
return result, nil
}
上面的代码相当直观。我们使用预定义的配置创建一个 logger。然后我们 defer 刷新 logger。调用 Sugar
方法允许我们接受具有可变数量参数的松散类型日志记录。然后我们调用 Infow
方法写入 Info 级别的日志消息。
记录 API
Gin 框架默认提供日志记录,但由于我们希望支持结构化日志记录,我们将定义一个模仿 Gin 内置日志记录的自定义日志记录中间件。
func StructuredLogger() gin.HandlerFunc {
return func(c *gin.Context) {
logger, _ := zap.NewProduction()
defer logger.Sync()
sugar := logger.Sugar()
start := time.Now()
c.Next()
stop := time.Now()
latency := stop.Sub(start)
if c.Writer.Status() >= 500 {
sugar.Errorw("error", c.Errors.ByType(gin.ErrorTypePrivate).String(),
"IP", c.ClientIP(),
"method", c.Request.Method,
"status", c.Writer.Status(),
"size", c.Writer.Size(),
"path", c.Request.URL.Path,
"latency", latency)
} else {
sugar.Infow("request",
"IP", c.ClientIP(),
"method", c.Request.Method,
"status", c.Writer.Status(),
"size", c.Writer.Size(),
"path", c.Request.URL.Path,
"latency", latency)
}
}
}
在这里,根据结果,我们采用不同的日志级别。
我们在下面注册我们的中间件
r := gin.New()
r.Use(api.StructuredLogger())
r.Use(gin.Recovery())
定义 OpenAPI 规范
我们应该如何看待 OpenAPI 规范,它不仅仅是一个漂亮的文档生成器(尽管对于我们的文章来说已经足够了),而且还是一个描述 REST API 的标准,可以简化客户端对其的使用。
让我们从装饰我们主函数的声明性注释开始。这些注释稍后将用于自动生成 Swagger 规范。你可以在 这里 查看格式。
// @title Deck Management API
// @version 0.1
// @description This is a sample server server.
// @termsOfService https://swagger.org.cn/terms/
// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email support@swagger.io
// @license.name Apache 2.0
// @license.url https://apache.ac.cn/licenses/LICENSE-2.0.html
// @host localhost:8080
// @BasePath /
// @schemes http
func main() {
我们的处理程序也一样。让我们以其中一个为例。
// CreateDeckHandler godoc
// @Summary Creates new deck.
// @Description Creates deck that can be either shuffled or unshuffled.
// It can accept the list of exact cards which can be shuffled or unshuffled as well.
// In case no cards provided, it returns a deck with 52 cards.
// @Accept */*
// @Produce json
// @Param shuffled query bool true "indicates whether deck is shuffled"
// @Param cards query array false "array of card codes i.e. 8C,AS,7D"
// @Success 200 {object} ClosedDeckDTO
// @Router /create-deck [post]
func CreateDeckHandler(c *gin.Context) {
现在让我们拉取 Swagger 库。
go get -v github.com/swaggo/swag/cmd/swag
go get -v github.com/swaggo/gin-sagger
go get -v github.com/swaggo/files
现在我们将生成规范
swag init -g main.go --output docs
此命令将在 docs 文件夹内生成所需文件。
下一步是使用必要的导入更新我们的 main.go 文件
_ "toggl-deck-management-api/docs"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
以及端点
url := ginSwagger.URL("/swagger/doc.json")
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url))
完成所有这些之后,现在我们可以运行我们的应用程序并查看 Swagger 生成的文档。
容器化 API
最后但同样重要的是,我们将如何部署我们的应用程序。传统的部署方式是在专用服务器上安装运行时,并在安装的运行时上运行应用程序。
容器化是一种方便的打包运行时和应用程序的方式,如果我们想利用自动扩展功能,并且我们可能没有所有需要的服务器以及安装好的环境,这会很方便。
Docker 是最流行的容器化解决方案,所以我们将利用它。为此,我们将在项目根目录创建一个 Dockerfile。
我们首先选择我们将基于应用程序的运行时镜像
FROM golang:1.18-bullseye
之后,我们将源文件复制到工作目录并进行构建
RUN mkdir /app
COPY . /app
WORKDIR /app
RUN go build -o server .
最后一步是向外部世界公开端口并运行应用程序
EXPOSE 8080
CMD [ "/app/server" ]
现在,假设 Docker 已在我们的机器上安装,我们可以使用以下命令运行应用程序:
docker build -t <image-name> .
docker run -it --rm -p 8080:8080 <image-name>
结论
在本文中,我们涵盖了在 Go 中编写 Clean Architecture API 的整体过程。从经过充分测试的领域开始,为其提供 API 层,使用 OpenAPI 标准对其进行文档化,并将我们的运行时与应用程序打包在一起,从而简化部署过程。
历史
- 2022 年 11 月 21 日 - 发布初始版本
- 2023 年 5 月 25 日 - 添加日志记录部分