Go語言:編寫一個 WebsiteRacer 的函式,用來對比請求兩個 URL 來「比賽」,並返回先響應的 URL。如果兩個 URL 在 10 秒內都未返回結果,返回一個 error。

slowlydance2me發表於2023-03-29

問題:

你被要求編寫一個叫做 WebsiteRacer 的函式,用來對比請求兩個 URL 來「比賽」,並返回先響應的 URL。如果兩個 URL 在 10 秒內都未返回結果,那麼應該返回一個 error

 
實現這個功能我們需要用到
  •  net/http 用來呼叫 HTTP 請求
  •  net/http/httptest 用來測試這些請求
  •  Go 程(goroutines)
  •  select

 

先寫測試

我們從最幼稚的做法開頭把事情開展起來。
func TestRacer(t *testing.T) {
    slowURL := "http://www.facebook.com"
    fastURL := "http://www.quii.co.uk"

    want := fastURL
    got := Racer(slowURL, fastURL)

    if got != want {
        t.Errorf("got '%s', want '%s'", got, want)
    }
}

我們知道這樣不完美並且有問題,但這樣可以把事情開展起來。重要的是,不要徘徊在第一次就想把事情做到完美。

嘗試執行測試

./racer_test.go:14:9: undefined: Racer

為測試的執行編寫最少量的程式碼,並檢查失敗測試的輸出

func Racer(a, b string) (winner string) {
    return
}

racer_test.go:25: got '', want 'http://www.quii.co.uk'

編寫足夠的程式碼使程式透過

func Racer(a, b string) (winner string) {
    startA := time.Now()
    http.Get(a)
    aDuration := time.Since(startA)

    startB := time.Now()
    http.Get(b)
    bDuration := time.Since(startB)

    if aDuration < bDuration {
        return a
    }

    return b
}
對每個 URL:
  • 1.我們用 time.Now() 來記錄請求 URL 前的時間。
  • 2.然後用 http.Get 來請求 URL 的內容。這個函式返回一個 http.Response 和一個 error,但目前我們不關心它們的值。
  • 3.time.Since 獲取開始時間並返回一個 time.Duration 時間差。

我們完成這些後就可以透過對比請求耗時來找出最快的了。

 

問題

 
這可能會讓你的測試透過,也可能不會。問題是我們透過訪問真實網站來測試我們的邏輯。
使用 HTTP 測試程式碼非常常見,Go 標準庫有這類工具可以幫助測試。
 在前兩章模擬和依賴注入章節中,我們講到了理想情況下如何不依賴外部服務來進行測試,因為它們可能
  • 速度慢
  •  不可靠
  •  無法進行邊界條件測試
在標準庫中有一個 net/http/httptest 包,它可以讓你輕易建立一個 HTTP 模擬伺服器(mock HTTP server)。
我們改為使用模擬測試,這樣我們就可以控制可靠的伺服器來測試了。
func TestRacer(t *testing.T) {

    slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(20 * time.Millisecond)
        w.WriteHeader(http.StatusOK)
    }))

    fastServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))

    slowURL := slowServer.URL
    fastURL := fastServer.URL

    want := fastURL
    got := Racer(slowURL, fastURL)

    if got != want {
        t.Errorf("got '%s', want '%s'", got, want)
    }

    slowServer.Close()
    fastServer.Close()
}

語法看著有點兒複雜,沒關係,慢慢來。

httptest.NewServer 接受一個我們傳入的 匿名函式 http.HandlerFunc
http.HandlerFunc 是一個看起來類似這樣的型別:type HandlerFunc func(ResponseWriter, *Request)
這些只是說它是一個需要接受一個 ResponseWriterRequest引數的函式,這對於 HTTP 伺服器來說並不奇怪。
結果呢,這裡並沒有什麼彩蛋,這也是如何在 Go 語言寫一個 真實的 HTTP 伺服器的方法。唯一的區別就是我們把它封裝成一個易於測試的 httptest.NewServer,它會找一個可監聽的埠,然後測試完你就可以關閉它了。
我們讓兩個伺服器中慢的那一個短暫地 time.Sleep 一段時間,當我們請求時讓它比另一個慢一些。然後兩個伺服器都會透過 w.WriteHeader(http.StatusOK) 返回一個 OK 給呼叫者。
如果你重新執行測試,它現在肯定會透過並且會更快完成。你可以調整 sleep 時間故意破壞測試。
 

重構

我們在主程式程式碼和測試程式碼裡都有一些重複。

func Racer(a, b string) (winner string) {
    aDuration := measureResponseTime(a)
    bDuration := measureResponseTime(b)

    if aDuration < bDuration {
        return a
    }

    return b
}

func measureResponseTime(url string) time.Duration {
    start := time.Now()
    http.Get(url)
    return time.Since(start)
}

這樣簡化程式碼後可以讓 Racer 函式更加易讀。

func TestRacer(t *testing.T) {

    slowServer := makeDelayedServer(20 * time.Millisecond)
    fastServer := makeDelayedServer(0 * time.Millisecond)

    defer slowServer.Close()
    defer fastServer.Close()

    slowURL := slowServer.URL
    fastURL := fastServer.URL

    want := fastURL
    got := Racer(slowURL, fastURL)

    if got != want {
        t.Errorf("got '%s', want '%s'", got, want)
    }
}

func makeDelayedServer(delay time.Duration) *httptest.Server {
    return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(delay)
        w.WriteHeader(http.StatusOK)
    }))
}

我們透過一個名為 makeDelayedServer 的函式重構了模擬伺服器,以將一些不感興趣的程式碼移出測試並減少了重複程式碼。

defer

在某個函式呼叫前加上 defer 字首會在 包含它的函式結束時 呼叫它。
有時你需要清理資源,例如關閉一個檔案,在我們的案例中是關閉一個伺服器,使它不再監聽一個埠。
你想讓它在函式結束時執行(關閉伺服器),但要把它放在你建立伺服器語句附近,以便函式內後面的程式碼仍可以使用這個伺服器。
我們的重構是一次改進,並且目前是涵蓋 Go 語言特性提供的合理解決方案,但我們可以讓它更簡單。

程式同步

  • Go 在併發方面很在行,為什麼我們要一個接一個地測試哪個網站更快呢?我們應該能夠同時測試兩個。
  •  我們並不關心請求的 準確響應時間,我們只是需要知道哪個更快返回而已。
 
想實現這個,我們要介紹一個叫 select 的新構造(construct),它可以幫我們輕易清晰地實現程式同步。
func Racer(a, b string) (winner string) {
    select {
    case <-ping(a):
        return a
    case <-ping(b):
        return b
    }
}

func ping(url string) chan bool {
    ch := make(chan bool)
    go func() {
        http.Get(url)
        ch <- true
    }()
    return ch
}

ping

我們定義了一個可以建立 chan bool 型別並返回它的 ping 函式。
在這個案例中,我們並不 關心 channel 中傳送的型別, 我們只是想傳送一個訊號 來說明已經傳送完了,所以返回 bool 就可以了。
同樣在這個函式中,當我們完成 http.Get(url)時啟動了一個用來給 channel 傳送訊號的 Go 程(goroutine)。

select

如果你記得併發那一章的內容,你可以透過 myVar := <-ch 來等待值傳送給 channel。這是一個 阻塞 的呼叫,因為你需要等待值返回。
select 則允許你同時在 多個 channel 等待。第一個傳送值的 channel「勝出」,case 中的程式碼會被執行。
我們在 select 中使用 ping 為兩個 URL 設定兩個 channel。無論哪個先寫入其 channel 都會使 select 裡的程式碼先被執行,這會導致那個 URL 先被返回(勝出)。
做了這些修改後,我們的程式碼背後的意圖就很明確了,實現起來也更簡單。
 
超時
 最後的需求是當 Racer 耗時超過 10 秒時返回一個 error。

先寫測試

t.Run("returns an error if a server doesn't respond within 10s", func(t *testing.T) {
serverA := makeDelayedServer(11 * time.Second)
serverB := makeDelayedServer(12 * time.Second)

defer serverA.Close()
defer serverB.Close()

_, err := Racer(serverA.URL, serverB.URL)

if err == nil {
t.Error("expected an error but didn't get one")
}
})

為了練習這個場景,現在我們要使模擬伺服器超過 10 秒後返回兩個值,勝出的 URL(這個測試中我們用 _ 忽略掉了)和一個 error

嘗試執行測試

./racer_test.go:37:10: assignment mismatch: 2 variables but 1 values

 

為測試的執行編寫最少量的程式碼,並檢查失敗測試的輸出

func Racer(a, b string) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    }
}
修改 Racer 的函式簽名來返回勝出者和一個 error。返回 nil 僅用於模擬順利的場景(happy cases)。
編譯器會報怨你的 第一個測試 只期望一個值,所以把這行改為 got, _ := Racer(slowURL, fastURL),要知道順利的場景中我們不應得到一個 error
 現在執行測試會在超過 11 秒後失敗。

--- FAIL: TestRacer (12.00s)
--- FAIL: TestRacer/returns_an_error_if_a_server_doesn't_respond_within_10s (12.00s)
racer_test.go:40: expected an error but didn't get one

 

編寫足夠的程式碼使程式透過

func Racer(a, b string) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    case <-time.After(10 * time.Second):
        return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
    }
}
使用 select 時,time.After 是一個很好用的函式。當你監聽的 channel 永遠不會返回一個值時你可以潛在地編寫永遠阻塞的程式碼,儘管在我們的案例中它沒有發生。time.After 會在你定義的時間過後傳送一個訊號給 channel 並返回一個 chan 型別(就像 ping 那樣)。
對我們來說這完美了;如果 ab 誰勝出就返回誰,但如果測試達到 10 秒,那麼 time.After 會傳送一個訊號並返回一個 error
 

慢速測試

現在的問題是這個測試要耗時 10 秒以上。對這麼簡單的邏輯來說可不好。
 我們可以做的就是讓超時時間(timeout)可配置,這樣測試就可以設定一個非常短的時間,並且程式碼在真實環境中可以被設定成 10 秒。
func Racer(a, b string, timeout time.Duration) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    case <-time.After(timeout):
        return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
    }
}
現在程式碼不能編譯了,因為我們沒提供超時時間。
在急於將這個預設值新增到測試前,先讓我們 聆聽他們
  •  在順利的情況「happy test」下我們是否關心超時時間?
  • 需求對超時時間很明確

鑑於以上資訊,我們再做一次小的重構來讓我們的測試和程式碼的使用者合意

var tenSecondTimeout = 10 * time.Second

func Racer(a, b string) (winner string, error error) {
    return ConfigurableRacer(a, b, tenSecondTimeout)
}

func ConfigurableRacer(a, b string, timeout time.Duration) (winner string, error error) {
    select {
    case <-ping(a):
        return a, nil
    case <-ping(b):
        return b, nil
    case <-time.After(timeout):
        return "", fmt.Errorf("timed out waiting for %s and %s", a, b)
    }
}

我們的使用者和第一個測試可以使用 Racer(使用 ConfigurableRacer),不順利的場景測試可以使用 ConfigurableRacer

func TestRacer(t *testing.T) {

    t.Run("compares speeds of servers, returning the url of the fastest one", func(t *testing.T) {
        slowServer := makeDelayedServer(20 * time.Millisecond)
        fastServer := makeDelayedServer(0 * time.Millisecond)

        defer slowServer.Close()
        defer fastServer.Close()

        slowURL := slowServer.URL
        fastURL := fastServer.URL

        want := fastURL
        got, err := Racer(slowURL, fastURL)

        if err != nil {
            t.Fatalf("did not expect an error but got one %v", err)
        }

        if got != want {
            t.Errorf("got '%s', want '%s'", got, want)
        }
    })

    t.Run("returns an error if a server doesn't respond within 10s", func(t *testing.T) {
        server := makeDelayedServer(25 * time.Millisecond)

        defer server.Close()

        _, err := ConfigurableRacer(server.URL, server.URL, 20*time.Millisecond)

        if err == nil {
            t.Error("expected an error but didn't get one")
        }
    })
}

在第一個測試最後加了一個檢查來驗證我們沒得到一個 error

 

總結

我們學到了什麼?

select
  •  可幫助你同時在多個 channel 上等待。
  •  有時你想在你的某個「案例」中使用 time.After
httptest
  •  一種方便地建立測試伺服器的方法,這樣你就可以進行可靠且可控的測試。
  •  使用和 net/http
 

相關文章