在 Go 中实现清洁架构

2025-06-04

在 Go 中实现清洁架构

关于清洁架构 (Clean Architecture) 的文章已经有很多。它的主要价值在于能够维护不受副作用影响的领域层,这使我们能够在不利用繁重模拟的情况下测试核心业务逻辑。

这是通过编写依赖于域的无依赖核心域逻辑和外部适配器(数据库存储或 API 层)来实现的,反之亦然。

在本文中,我们将通过一个 Go 示例项目来了解如何实现“清晰架构”。此外,我们还将介绍一些其他主题,例如容器化以及如何使用 Swagger 实现 OpenAPI 规范。

虽然我会在文章中重点介绍感兴趣的点,但你可以在我的 Github上查看整个项目

项目要求

我们需要提供一个 REST API 的实现来模拟一副纸牌。

我们需要向您的 API 提供以下方法来处理卡片和卡组:

  • 创建新牌组
  • 打开一副
  • 抽一张

创建新牌组

它将创建一副标准的52张法式扑克牌,其中包含四种花色(梅花(♣)、方块(♦)、红心(♥)和黑桃(♠))的全部十三个点数。本次作业无需担心百搭牌。

  • 是否要洗牌 — — 默认情况下,牌组是按顺序排列的:黑桃 A、黑桃 2、黑桃 3……然后是方块、梅花、红桃。
  • 牌堆是满的还是部分的——默认情况下,它返回标准的 52 张牌,否则请求将接受想要的牌,就像这个例子一样?cards=AS,KD,AC,2C,KH

响应需要返回包含以下内容的 JSON:

  • 卡组 ID(UUID
  • 牌组属性,如洗牌(布尔值)和牌组中剩余的牌总数(整数
{
    "deck_id": "a251071b-662f-44b6-ba11-e24863039c59",
    "shuffled": false,
    "remaining": 30
}
Enter fullscreen mode Exit fullscreen mode

打开一副牌

它会根据 UUID 返回指定的卡牌组。如果卡牌组未传递或无效,则返回错误。此方法将“打开卡牌组”,这意味着它将按创建顺序列出所有卡牌。

响应需要返回包含以下内容的 JSON:

  • 卡组 ID (UUID)
  • 牌组属性,如洗牌(布尔值)和牌组中剩余的牌总数(整数)
  • 所有剩余的卡片 cards (卡片对象)
{
    "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"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

抽一张牌

我们会从给定的牌组中抽取一张或多张牌。如果牌组未被传递或无效,则返回错误。需要提供一个 count 参数来定义从牌组中抽取的牌张数量。

响应需要返回包含以下内容的 JSON:

  • 所有抽出的牌 cards (card 对象
{
    "cards": [
        {
            "value": "QUEEN",
            "suit": "HEARTS",
            "code": "QH"
        },
        {
            "value": "4",
            "suit": "DIAMONDS",
            "code": "4D"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

设计域

由于域是我们应用程序不可或缺的一部分,因此我们将从域开始设计我们的系统。

我们将ShapeRank类型编码为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
)
Enter fullscreen mode Exit fullscreen mode

完成后,我们可以将Card其编码为形状和等级的组合

type Card struct {
    Rank  Rank
    Shape Shape
}
Enter fullscreen mode Exit fullscreen mode

领域驱动设计的功能之一是使非法状态无法表示,但由于所有等级和形状的组合都是有效的,因此创建卡片非常简单

func CreateCard(rank Rank, shape Shape) Card {
    return Card{
        Rank:  rank,
        Shape: shape,
    }
}
Enter fullscreen mode Exit fullscreen mode

现在让我们看看甲板

type Deck struct {
    DeckId   uuid.UUID
    Shuffled bool
    Cards    []Card
}
Enter fullscreen mode Exit fullscreen mode

该牌组将展示三个操作:创建牌组、抽牌和计算剩余牌。

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: Insuffucient 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))
}
Enter fullscreen mode Exit fullscreen mode

注意,在抽牌时,我们会检查是否有足够的牌来执行操作。为了在无法继续操作时发出信号,我们利用了 Go 的多返回值特性。

此时,我们或许能看出“清晰架构”的一个关键优势:核心领域逻辑没有外部依赖,这极大地简化了单元测试。虽然大多数依赖项都很琐碎,为了简洁起见,我们不再赘述,但让我们先来看看那些用于验证代码是否被打乱的依赖项。

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

显然,我们无法验证洗牌后的顺序。我们可以做的是验证洗好的牌组是否满足我们感兴趣的属性,即牌组中所有牌都存在,并且没有重复的牌。这种技术与基于属性的测试非常相似。

顺便说一句,值得一提的是,为了消除样板断言代码,我们利用了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()
}
Enter fullscreen mode Exit fullscreen mode

有些读者可能会对 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
    }
}
Enter fullscreen mode Exit fullscreen mode

定义 OpenAPI 规范

我们对待 OpenAPI 规范的方式不仅仅是将其视为一个花哨的文档生成器(尽管对于我们的文章来说它已经足够了),而且还应将其视为描述 REST API 的标准,以简化客户端的使用。

让我们从用声明式注释修饰 main 方法开始。这些注释稍后将用于自动生成 Swagger 规范。您可以在这里查看格式。

// @title Deck Management API
// @version 0.1
// @description This is a sample server server.
// @termsOfService http://swagger.io/terms/

// @contact.name API Support
// @contact.url http://www.swagger.io/support
// @contact.email support@swagger.io

// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html

// @host localhost:8080
// @BasePath /
// @schemes http
func main() {
Enter fullscreen mode Exit fullscreen mode

我们的处理程序也是如此。让我们以其中一个为例来看一下。

// 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) {
Enter fullscreen mode Exit fullscreen mode

现在让我们拉取 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
Enter fullscreen mode Exit fullscreen mode

现在我们要生成规范

swag init -g main.go --output docs
Enter fullscreen mode Exit fullscreen mode

此命令将在 docs 文件夹中生成所需的文件。

下一步是使用必要的导入更新我们的 main.go 文件

_ "toggl-deck-management-api/docs"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
Enter fullscreen mode Exit fullscreen mode

终点

url := ginSwagger.URL("/swagger/doc.json")
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url))
Enter fullscreen mode Exit fullscreen mode

完成所有操作后,我们现在可以运行我们的应用程序并查看 Swagger 生成的文档。

容器化 API

最后但同样重要的是,我们将如何部署我们的应用程序。传统的部署方式是将运行时安装在专用服务器上,然后在已安装的运行时上运行应用程序。

容器化是将运行时与应用程序一起打包的便捷方式,如果我们想要利用自动扩展功能并且可能没有安装环境所需的所有服务器,这可能会很方便。

Docker 是最流行的容器化解决方案,因此我们将利用它。为此,我们将在项目根目录下创建 dockerfile。

我们要做的第一件事是选择应用程序所基于的运行时映像

FROM golang:1.18-bullseye
Enter fullscreen mode Exit fullscreen mode

之后,我们将源代码复制到工作目录并构建它

RUN mkdir /app
COPY . /app
WORKDIR /app
RUN go build -o server .
Enter fullscreen mode Exit fullscreen mode

最后一步是将端口暴露给外界并运行应用程序

EXPOSE 8080
CMD [ "/app/server" ]
Enter fullscreen mode Exit fullscreen mode

现在,假设我们的机器上安装了 Docker,我们可以使用以下命令运行应用程序

docker build -t <image-name> .
docker run -it --rm -p 8080:8080 <image-name>
Enter fullscreen mode Exit fullscreen mode

结论

在本文中,我们介绍了用 Go 编写清晰架构 API 的完整过程。从经过充分测试的领域开始,为其提供 API 层,使用 OpenAPI 标准进行文档编写,并将运行时与应用程序一起打包,从而简化部署过程。

文章来源:https://dev.to/bohdanstupak1/implementing-clean-architecture-in-go-5417
PREV
凌晨4点起床的力量
NEXT
GraphQL 中的图表