在GO中实施干净的体系结构
#go #体系结构 #docker #swagger

它已经写了很多关于clean architecture的文章。它的主要价值是能够自由脱离副作用域层的能力,使我们能够测试核心业务逻辑而不利用重型模拟。

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

在本文中,我们将研究如何通过示例GO项目实现干净的体系结构。我们将介绍一些其他主题,例如容器化和用Swagger实施OpenAPI规范。

虽然我将重点介绍本文的兴趣点,但您可以在my Github

上查看整个项目

项目要求

我们必须提供REST API的实现来模拟卡片牌。

我们将需要为您的API HO句柄卡和甲板提供以下方法:

  • 创建一个新的甲板
  • 打开甲板

创建一个新甲板

它将创建标准的52张法国扑克牌甲板,其中包括四个西装中的每一个中的所有13个排名:俱乐部(£),钻石(钻石),Hearts(¥)和黑桃()。您无需担心这项任务的小丑卡。

  • 默认情况下要洗牌的甲板是顺序的: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)
  • 此甲板上剩余的甲板属性(像布尔)和总卡在此甲板(整数)中
  • 所有剩余的卡(卡对象)
{
    "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"
        }
    ]
}

画一张卡

我们会画一张给定甲板的卡。如果甲板未经传递或无效,则应返回错误。需要提供计数参数来定义从甲板上绘制多少张卡。

响应需要返回一个将包括:

的JSON
  • 所有抽奖卡(卡对象
{
    "cards": [
        {
            "value": "QUEEN",
            "suit": "HEARTS",
            "code": "QH"
        },
        {
            "value": "4",
            "suit": "DIAMONDS",
            "code": "4D"
        }
    ]
}

设计域

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

让我们将我们的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
)

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

type Card struct {
    Rank  Rank
    Shape Shape
}

域驱动设计的功能之一是making illegal states unrepresentable,但是由于所有等级和形状的组合都是有效的,创建卡都是很简单的

func CreateCard(rank Rank, shape Shape) Card {
    return Card{
        Rank:  rank,
        Shape: shape,
    }
}

现在让我们看一下甲板

type Deck struct {
    DeckId   uuid.UUID
    Shuffled bool
    Cards    []Card
}

甲板将展示三个操作:创建一个甲板,画卡并计数剩余的卡片。

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))
}

请注意,在绘制卡时,我们检查是否有足够数量的卡来执行操作。为了发出信号,如果我们无法继续进行,我们会利用Go multiple return values功能。

在这一点上,我们可能会观察到干净体系结构的关键好处之一:核心域逻辑没有外部依赖性,可以极大地简化单位测试。虽然他们中的大多数都是微不足道的,我们会为了简洁起见,让我们看看那些验证甲板是否被改组的人

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")
    }
}

显然,我们无法验证洗牌卡的顺序。相反,我们可以做的是验证改组的甲板是否满足感兴趣的属性,即我们有每张卡片,并且我们的甲板上没有重复的卡片。这样的技术与property-based testing密切相似。

作为旁注,值得一提的是,为了消除样板断言代码,我们利用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()
}

某些读者可能会因以下事实混淆:根据上面列出的要求,创建甲板端点作为URL请求的一部分接受参数,并且可能会考虑使此端点接受get请求而不是帖子。但是,获得请求的重要先决条件是,对于此端点,它们并非如此。这就是我们坚持帖子的确切原因。

处理程序遵循相同的模式。我们解析查询参数,基于它们,我们创建一个域实体,对其进行操作,更新存储并返回专业的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
    }
}

定义OpenAPI规范

我们应该对待OpenAPI规范的方式不仅是作为花哨的文档生成器(尽管这足以为我们的文章而言),还可以作为描述REST API的标准,可以简化客户的消费。

让我们从陈述性评论开始装饰我们的主要方法。这些评论将在稍后使用,以自动生成Swagger规范。 Here您可以查找格式。

// @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() {

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

// 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) {

现在让我们拉动宣传库

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

之后,我们将源将源复制到WorkDirectory并构建

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标准进行记录,并将运行时间与应用程序一起包装,从而简化了部署过程。