在 Go 中實施簡潔架構

已經有很多關於 簡潔架構 [1] 的文章了。它的主要價值在於能夠維護無副作用的領域層,使我們能夠不需要利用沉重的 mock 來測試核心業務邏輯。

通過寫一個無需依賴的核心領域邏輯,以及外部適配器 (成爲它的數據庫存儲或者 API 層) 來實現的。這些適配器依賴於領域,而不是領域依賴適配器。

在這篇文章,我們會看一下簡潔架構是如何實現一個簡單的 Go 項目。我們會提及一些額外的主題,例如容器化以及用 Swagger 實現 OpenAPI 規範。

雖然我在文章中高亮了感興趣的點,但你也可以在 我的 Github[2] 上看看整個項目。

項目需求

我們要求提供一個 REST API 的實現來模擬一副牌。

我們將需要向你的 API 提供以下方法,以處理卡片和牌組:

創建一個新的牌組

它將創建標準的 52 張法國撲克牌,它包括 4 個花色的所有 13 個等級:梅花(♣),方塊(♦),紅心(♥)和黑桃(♠)。

在這個作業中,你不需要擔心小丑牌的問題。

請求響應需要返回一個 JSON,包含:

{
    "deck_id": "a251071b-662f-44b6-ba11-e24863039c59",
    "shuffled": false,
    "remaining": 30
}

打開牌組

它將通過 UUID 返回一個給定的牌組。如果牌組無法被獲取或者是無效的,它應該返回一個錯誤。這個方法將 “打開牌組”,意味着它將按照創建的順序,列出所有的牌。

請求響應需要返回一個 JSON,包含:

{
    "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"
        }
    ]
}

設計領域

由於領域是我們應用程序的一個組成部分,我們將從領域開始設計我們的系統。

讓我們把 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 編碼爲 shape 和 rank 的組合。

type Card struct {
    Rank  Rank
    Shape Shape
}

領域驅動設計的功能之一是使非法狀態無法表現 [3],但由於所有 rank 和 shape 的組合都是有效的,所以創建一張卡片是非常直觀的。

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 多個返回值 [4] 的功能。

在這一點上,我們可以觀察到簡潔架構的一個重要好處:核心領域邏輯沒有外部依賴,這大大簡化了單元測試。雖然大多數都是微不足道的,爲了簡潔起見,我們將省略它們,接着讓我們看看那些驗證是否洗牌的測試。

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

很明顯,我們無法驗證洗好的牌的順序。我們可以做的是驗證洗好的牌是否滿足我們感興趣的屬性,即我們的每張牌都在,並且沒有重複的牌。這樣的技術非常類似於基於屬性的測試 [5]。

另外,值得一提的是,爲了消除模板式的斷言代碼,我們利用了 testify[6] 庫的優勢。

提供 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 請求而不是 POST。

然而,GET 請求的一個重要前提是,它們表現出一致性 [7],即每次請求的結果是一致的,而這個路由不是這樣的。這就是我們堅持使用 POST 的原因。

路由對應的 Handler 遵循相同的模式。我們解析查詢參數,根據這些參數創建一個領域實體,對其進行操作,更新存儲並返回專屬的 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 規範。在這裏 [8] 你可以查到格式。

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

路由對應的 Handler 也是如此。讓我們以其中一個爲例看看:

// 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:

swag init -g main.go --output docs

這個指令會在 docs 文件夾生成所需文件。

下一步在我們的 main.go 文件加入必要的 import:

_ "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 容器化

最後但並非最不重要的是我們將如何部署我們的應用程序。傳統的方法是在一個專門的服務器上安裝,並在安裝的服務器上運行應用程序。

容器化是將 runtime 與應用程序一起打包的一種便捷方式,這可能在當我們想使用自動擴展功能,並且我們可能沒有所有需要的服務器與環境安裝在我們手中時,會很方便。

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 中編寫簡潔架構 API 的整體過程。從經過測試的領域開始,爲其提供一個 API 層,使用 OpenAPI 標準對其進行記錄,並將我們的 runtime 與應用程序打包在一起,從而簡化了部署過程。

相關鏈接:

[1] https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

[2] https://github.com/Wkalmar/toggl-deck-management-api

[3] https://blog.janestreet.com/effective-ml-revisited/

[4] https://go.dev/doc/effective_go#multiple-returns

[5] https://dev.to/bohdanstupak1/property-based-tests-and-clean-architecture-are-perfect-fit-2f57

[6] https://github.com/stretchr/testify/assert

[7] https://www.restapitutorial.com/lessons/idempotency.html

[8] https://github.com/swaggo/swag#declarative-comments-format

原文地址:

Implementing Clean Architecture in GO | by Vidhyanshu Jain | Medium

原文作者:

Vidhyanshu Jain

本文永久鏈接: translator/w04_Implementing_clean_architecture_in_Go.md at master · gocn/translator (github.com)

譯者:zxmfke

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/gc3Tal_yuHfRSwFG4Zdg_w