介紹

使用 testcontainers 是在本地開發 Golang 應用程式的一個高效方式。這可以讓我們在不需要依賴外部環境的情況下,模擬應用程式在實際生產環境中的運行狀況。

安裝 testcontainers

在 Go 專案中,我們可以通過以下指令來導入 testcontainers

go get github.com/testcontainers/testcontainers-go

透過 Redis 實踐一個 rate limiter

package user

type Limiter struct {
    client *redis.Client

    limit         int
    limitPeriod   time.Duration // 1 hour for limitPeriod
    counterWindow time.Duration // 1 minute for example, 1/60 of the period
}

func NewLimiter(client *redis.Client, limit int, period, expiry time.Duration) *Limiter {
    return &Limiter{
        client: client,

        limit:         limit,
        limitPeriod:   period,
        counterWindow: expiry,
    }
}

func (r *Limiter) AllowRequest(ctx context.Context, key string, incr int) error {
    now := time.Now()
    timestamp := fmt.Sprint(now.Truncate(r.counterWindow).Unix())

    val, err := r.client.HIncrBy(ctx, key, timestamp, int64(incr)).Result()
    if err != nil {
        return err
    }

    if val >= int64(r.limit) {
        return ErrRateLimitExceeded(0, r.limit, r.limitPeriod, now.Add(r.limitPeriod))
    }

    r.client.Expire(ctx, key, r.limitPeriod)

    result, err := r.client.HGetAll(ctx, key).Result()
    if err != nil {
        return err
    }

    threshold := fmt.Sprint(now.Add(-r.limitPeriod).Unix())

    total := 0
    for k, v := range result {
        if k > threshold {
            i, _ := strconv.Atoi(v)
            total += i
        } else {
            r.client.HDel(ctx, key, k)
        }
    }

    if total >= int(r.limit) {
        return ErrRateLimitExceeded(0, r.limit, r.limitPeriod, now.Add(r.limitPeriod))
    }

    return nil
}

type RateLimitExceeded struct {
    Remaining int
    Limit     int
    Period    time.Duration
    Reset     time.Time
}

func ErrRateLimitExceeded(remaining int, limit int, period time.Duration, reset time.Time) error {
    return RateLimitExceeded{
        Remaining: remaining,
        Limit:     limit,
        Period:    period,
        Reset:     reset,
    }
}

func (e RateLimitExceeded) Error() string {
    return fmt.Sprintf(
        "rate limit of %d per %v has been exceeded and resets at %v",
        e.Limit, e.Period, e.Reset)
}

創建和啟動 Redis 容器

正式的產品通常會使用 config 來管理 Redis 位置,這個 demo 中直接使用 localhost:6379 來展示。

package cache

import "github.com/redis/go-redis/v9"

func NewRedisClient() *redis.Client {
    client := redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    return client
}

寫點測試

建立 test suite

package user_test

type LimiterTestSuite struct {
    suite.Suite
}

func TestLimiter(t *testing.T) {
    suite.Run(t, new(LimiterTestSuite))
}

透過 testcontainers 啟動 Redis 來進行測試

使用 testcontainers 來創建和啟動 Redis 容器,並將其用於測試限流器。執行測試時會呼叫本地的 docker socket 來啟動 container,並透過 endpoint, err := container.Endpoint(ctx, "") 來獲取連線位置。

func (s *LimiterTestSuite) TestLimiterWithTestContainers() {
    ctx := context.Background()

    request := testcontainers.ContainerRequest{
        Image:        "redis:latest",
        ExposedPorts: []string{"6379/tcp"},
        WaitingFor:   wait.ForLog("Ready to accept connections"),
    }

    container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: request,
        Started:          true,
    })
    s.NoError(err)

    endpoint, err := container.Endpoint(ctx, "")
    s.NoError(err)

    client := redis.NewClient(&redis.Options{
        Addr: endpoint,
    })

    limiter := user.NewLimiter(client, 10, time.Second*5, time.Second)

    for i := 0; i < 9; i++ {
        s.NoError(limiter.AllowRequest(ctx, "55688", 1), "request %d should be allowed", i+1)
        time.Sleep(time.Millisecond)
    }

    s.Error(limiter.AllowRequest(ctx, "55688", 1), "request should be denied")
}

透過真實的連線來測試

我們仍然可以透過 docker run -p 6379:6379 redis:latest 來創見一個真實的 redis 實例。

func (s *LimiterTestSuite) TestLimiterWithRealConn() {
    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    limiter := user.NewLimiter(client, 10, time.Second*5, time.Second)

    ctx := context.Background()

    for i := 0; i < 9; i++ {
        s.NoError(limiter.AllowRequest(ctx, "55688", 1), "request %d should be allowed", i+1)
        time.Sleep(time.Millisecond)
    }

    s.Error(limiter.AllowRequest(ctx, "55688", 1), "request should be denied")
}

寫在最後

testcontaienrs 的理想是「每一個」測試都會有最乾淨的依賴,假如專案內有 1000 個需要用到 cache/db 的 test function,就會至少有多 1000 個 container 的資源佔用。

若是我們只創建一個 container,並透過 random db name 來給不同的 test case,或許可以解決資源佔用的問題,但就違反了 testcontaienrs 得初衷了。

對於資源有限的 CI/CD 機器更是難以負荷大量的 container 創建/刪除等等,或許可以在專案初期採用,直到專案複雜度提昇,總是會需要在架構乾淨與成長性等等做權衡。