可汗學院使用Go靜態上下文理順全域性變數和依賴 - khanacademy

banq發表於2022-04-08

可汗學院正在完成一個巨大的專案,將我們的後端從Python遷移到Go。雖然這個專案的主要目標是遷移到一個過時的平臺上,但我們看到了改進我們程式碼的機會,而不僅僅是直接移植。

我們想改進的一件大事是我們的Python程式碼庫中的隱性依賴關係。訪問當前請求或當前使用者是通過呼叫全域性函式完成的。同樣地,我們通過全域性函式或全域性裝飾器連線到其他內部服務和外部功能,如資料庫、儲存和快取層。

像這樣使用全域性函式使得我們很難知道一個程式碼塊是否觸及了某個資料或呼叫了某個服務。這也使程式碼測試變得複雜,因為所有隱含的依賴關係都需要被模擬出來。

我們考慮了許多可能的解決方案,包括將所有東西作為引數傳入,或者使用上下文來儲存所有的依賴關係,但每一種方法都有缺陷。

在這篇文章中,我將描述我們如何以及為什麼通過建立Go語言靜態型別的上下文來解決這些問題。
我們用函式來擴充套件上下文物件,以訪問這些共享資源,而函式則宣告介面,顯示它們需要哪些功能。結果是我們在編譯時明確列出了依賴關係並進行了驗證,但呼叫和測試一個函式仍然很容易。

func DoTheThing(
    ctx interface {
        context.Context
        RequestContext
        DatabaseContext
        HttpClientContext
        SecretsContext
        LoggerContext
    },
    thing string,
) {...}

我將介紹一下我們考慮過的各種想法,並說明我們為什麼會選擇這個解決方案。本文中所有的程式碼例項都可以在這裡。你可以在該資源庫中檢視工作例項以及靜態型別上下文的實現細節。
 

嘗試1:全域性變數
讓我們從一個代表性例子開始:

func DoTheThing(thing string) error {
    // Find User Key from request
    userKey, err := request.GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := database.Read(userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        err = httpClient.Post("www.dothething.example", user.GetName())
    }
    return err
}


這段程式碼相當直接,能處理錯誤,甚至還有註釋,但有幾個大問題。這裡的request是什麼?一個全域性變數!?database和httpClient是從哪裡來的?這些函式的任何依賴關係又是什麼?

以下是我們不喜歡全域性變數的一些原因。
  • 很難追蹤依賴關係的使用情況。
  • 由於每個測試都使用相同的全域性變數,所以很難模擬出測試中的依賴關係。
  • 我們不能針對不同的資料同時執行。

將所有這些依賴關係隱藏在globals全域性變數中,使得程式碼很難被跟蹤。
在Go中,我們喜歡顯式的!
與其隱含地依賴所有這些globals全域性變數,不如讓我們試著把它們作為引數傳入。

嘗試2:使用引數

func DoTheThing(
    thing string,
    request *Request,
    database *Database,
    httpClient *HttpClient,
    secrets *Secrets,
    logger *Logger,
    timeout *Timeout,
) error {
    // Find User Key from request
    userKey, err := request.GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := database.Read(userKey, secrets, logger, timeout)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        token, err := request.GetToken()
        if err != nil { return err }
 
        err = httpClient.Post("www.dothething.example", user.GetName(), token, logger)
        return err
    }
    return nil
}


現在DoTheThing所需要的所有功能都非常明顯了,而且很清楚哪個請求正在被處理,哪個資料庫正在被訪問,以及資料庫正在使用哪個祕密。如果我們想測試這個函式,很容易看到如何傳遞模擬物件。

不幸的是,現在的程式碼非常冗長。有些引數幾乎在每個函式中都是通用的,需要到處傳遞:例如,request、logger和secrets。DoTheThing有一堆引數,它們的存在只是為了讓我們能把它們傳遞給其他函式。有些函式可能需要取幾十個引數來包含它們需要的所有功能。

當每個函式都需要幾十個引數時,就很難把引數的順序搞清楚。當我們想傳入mock時,我們需要生成大量的mock並確保它們相互相容。

我們也許應該檢查每個引數以確保它不是nil,但在實踐中,如果呼叫者錯誤地傳遞了nils,很多開發者就會冒著恐慌的風險。

當我們為一個函式新增一個新的引數時,我們必須更新所有的呼叫站點,但呼叫函式也需要檢查它們是否已經有這個引數。如果沒有,他們需要把它作為自己的引數新增進去。這就導致了大量的非自動化程式碼的流失。

這個想法的一個潛在轉折是建立一個伺服器物件,將這些依賴關係捆綁在一起。這種方法可以減少引數的數量,但現在它隱藏了一個函式究竟需要哪些依賴。在大量的小物件和少數的大物件之間有一個權衡,這些物件將一堆依賴關係捆綁在一起,而這些依賴關係有可能並沒有全部被使用。這些物件可以成為全能的實用類,這就否定了明確列出依賴關係的價值。整個物件都必須被模擬,即使我們只依賴其中的一小部分。

對於其中的一些功能,比如超時和請求,有一個標準的Go解決方案。上下文庫提供了一個持有當前請求資訊的物件,並提供了圍繞處理超時和取消的功能。

它可以進一步擴充套件到持有任何其他開發者想要到處傳遞的物件。在實踐中,很多程式碼庫將上下文作為一個容納所有常用物件的容器。這是否讓程式碼變得更漂亮?

嘗試3:上下文

func DoTheThing(
    ctx context.Context,
    thing string,
) error {
    // Find User Key from request
    userKey, err := ctx.Value("request").(*Request).GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := ctx.Value("database").(*Database).Read(ctx, userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        err = ctx.Value("httpClient").(*HttpClient).
            Post(ctx, "www.dothething.example", user.GetName())
        return err
    }
    return nil
}


這比列出所有內容要小得多,但如果ctx.Value(...)的任何呼叫返回nil或錯誤型別的值,程式碼就很容易在執行時出現恐慌。我們很難知道在呼叫這個之前哪些欄位需要在ctx上填充,以及預期的型別是什麼。我們也許應該檢查這些引數。

嘗試3:安全地上下文

func DoTheThing(
    ctx context.Context,
    thing string,
) error {
    // Find User Key from request
    request, ok := ctx.Value("request").(*Request)
    if !ok || request == nil { return errors.New("Missing Request") }
 
    userKey, err := request.GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    database, ok := ctx.Value("database").(*Database)
    if !ok || database == nil { return errors.New("Missing Database") }
 
    user, err := database.Read(ctx, userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        httpClient, ok := ctx.Value("httpClient").(*HttpClient)
        if !ok || httpClient == nil {
            return errors.New("Missing HttpClient")
        }
 
        err = httpClient.Post(ctx, "www.dothething.example", user.GetName())
        return err
    }
    return nil
}


所以現在我們要正確地檢查上下文是否包含我們需要的一切,並適當地處理錯誤。單一的ctx引數承載了所有常用的功能。這個上下文可以在少數集中的地方建立,用於不同的情況(例如,GetProdContext(), GetTestContext())。

不幸的是,現在的程式碼甚至比我們把所有的東西都作為引數傳入的時候還要長。增加的大部分程式碼都是無聊的模板,讓人很難看出程式碼到底在做什麼。

這個解決方案確實可以讓我們獨立地處理併發請求(每個請求都有自己的上下文),但它仍然受到globals解決方案的很多其他問題的影響。特別是,沒有簡單的方法來告訴一個函式需要什麼功能。例如,當你呼叫datastore.Get時,並不清楚ctx需要包含一個 "祕密",因此當你呼叫DoTheThing時也需要。

如果上下文缺少必要的功能,這段程式碼就會出現執行時故障。這可能導致生產中的錯誤。例如,如果我們CanDoTheThing很少返回true,我們可能不會意識到這個函式需要httpClient,直到它開始失敗。在編譯時沒有簡單的方法來保證上下文總是包含它所需要的一切。

我們的解決方案:靜態型別的上下文
我們想要的是明確列出我們函式的依賴關係,但不要求我們在每個呼叫點都列出它們。我們希望在編譯時驗證所有的依賴關係,但我們也希望能夠新增新的依賴關係,而不需要大規模地手動修改程式碼。

我們在可汗學院設計的解決方案是,用代表共享功能的介面來擴充套件上下文物件。每個函式都宣告一個介面,描述它從靜態型別的上下文中需要的所有功能。該函式可以通過訪問上下文來使用所宣告的功能。

上下文在函式簽名後被正常處理,被傳遞給其他函式。但是現在編譯器確保上下文為我們呼叫的每個函式實現了介面。

func DoTheThing(
    ctx interface {
        context.Context
        RequestContext
        DatabaseContext
        HttpClientContext
        SecretsContext
        LoggerContext
    },
    thing string,
) error {
    // Find User Key from request
    userKey, err := ctx.Request().GetUserKey()
    if err != nil { return err }
 
    // Lookup User in database
    user, err := ctx.Database().Read(ctx, userKey)
    if err != nil { return err }
 
    // Maybe post an http if can do the thing
    if user.CanDoThing(thing) {
        err = ctx.HttpClient().Post(ctx, "www.dothething.example", user.GetName())
    }
    return err
}


這個函式的主體幾乎和使用globals全域性的原始函式一樣簡單。函式簽名列出了這個程式碼塊的所有必要功能以及它所呼叫的函式。注意,呼叫ctx.Datastore().Read(ctx, ...)這樣的函式並不要求我們改變ctx,儘管Read只需要一個子集的功能。

當我們需要呼叫一個以前不屬於靜態型別上下文的新介面時,我們需要在我們的函式簽名中新增一個介面,只需一行。這就記錄了新的依賴關係,使我們能夠在上下文中呼叫新的函式。

如果我們的呼叫者的上下文中沒有新的介面,他們會得到一個錯誤資訊,描述他們缺少什麼介面,他們可以在簽名中新增同樣的上下文。開發者在進行改變的同時有機會確保新的依賴關係是合適的。像這樣的改變有時會在堆疊中產生漣漪,但這只是在每個受影響的函式中的一行改變,直到我們到達一個仍然擁有該介面的層次。對於深層呼叫棧來說,這可能有點煩人,但對於大的變化來說,這也是可以自動化的。

這些介面是由每個庫宣告的,通常由一個單一的呼叫組成,返回一個資料或該功能的客戶物件。例如,這裡是示例程式碼中的請求和資料庫上下文介面。

type RequestContext interface {
    Request() *Request
    context.Context
}
 
type DatabaseInterface interface {
    Read(
        ctx interface{
            context.Context
            SecretsContext
            LoggerContext
        },
        key DatabaseKey,
    ) (*User, error)
}
 
type DatabaseContext interface {
    Database() DatabaseInterface
    context.Context
}

我們有一個庫,為不同的情況提供上下文。在某些情況下,例如在我們的請求處理程式的開始,我們有一個基本的context.Context,需要將其升級為靜態型別的context。

func GetProdContext() ProdContext {...}
func GetTestContext() TestContext {...}
 
func Upgrade(ctx *context.Context) ProdContext {...}


這些預先構建的上下文通常滿足我們程式碼庫中所有的上下文介面,因此可以被傳遞給任何函式。ProdContext連線到我們生產中的所有服務,而我們的TestContext使用了一堆被設計成可以正常工作的模擬。

我們還有一些特殊的上下文,是為我們的開發者環境和在cron jobs中使用的。每個上下文的實現方式不同,但都可以傳遞給我們程式碼中的任何函式。

我們也有隻實現介面子集的上下文,比如只實現只讀介面的ReadOnlyContext。你可以把它傳遞給任何不需要在其上下文介面中寫入的函式。這就保證了在編譯時,不小心的寫入是不可能的。

我們有一個linter來確保每個函式都宣告瞭必要的最小介面。這保證了函式不會只是宣告它們需要 "一切"。你可以在示例程式碼中找到我們的linter的版本。

總結
我們在可汗學院使用靜態型別的上下文已經有兩年時間了。我們有十多個函式可以依賴的介面。它們使我們很容易跟蹤程式碼中的依賴關係,而且對於注入測試用的模擬也很有用。我們可以在編譯時保證所有的函式在被使用前都是可用的。

靜態型別的上下文並不總是驚人的。它們比不宣告你的依賴關係更囉嗦,而且當你 "只是想記錄一些東西 "時,它們可能需要擺弄你的上下文介面,但它們也能節省工作。當一個函式需要使用新的功能時,可以簡單地在上下文介面中宣告它,然後使用它。

靜態型別的上下文已經消除了整類的錯誤。我們永遠不會有未初始化的全域性或丟失的上下文值。我們再也不會有什麼東西突變了一個全域性而破壞了後來的請求。我們不會有一個函式意外地呼叫一個服務。Mocks總是能很好地配合,因為我們有一個全公司的慣例,在測試程式碼中注入依賴關係。

Go是一種鼓勵明確和使用靜態型別的語言,以提高可維護性。使用靜態型別的上下文可以讓我們在訪問全域性資源時實現這些目標。

banq:類似物件導向的依賴注入反轉,將依賴通過介面上下文注入進來。

相關文章