依賴組成

基於對傳統基於架構的依賴注入的挫折,我採用了一種組合策略,利用部分應用程式將內容注入模組中。結合測試驅動開發作為設計流程,並專注於函式而非類別,模組可以保持清晰、乾淨,並且幾乎沒有意外耦合。

2023 年 5 月 23 日


Photo of Daniel Somerfield

作為一名軟體開發人員,有時也是一名經理,擁有超過二十年的軟體產業經驗,我致力於設計系統,幫助人們過著充實、有意義的生活,並獲得身體健康和心理安全等基本人類需求。在 Measurabl,我專注於開發讓客戶滿意、建立我們的業務,並在減少建築營運的負面氣候影響方面取得顯著進展的產品。


起源故事

一切始於幾年前,當時我的一個團隊成員問道:「我們應該採用什麼模式進行依賴注入 (DI)」?該團隊的堆疊是 Node.js 上的 Typescript,這不是我非常熟悉的,所以我鼓勵他們自己想辦法。我後來很失望地得知,該團隊實際上決定不決定,留下大量模式來連結模組。一些開發人員使用工廠方法,另一些開發人員在根模組中進行手動依賴注入,還有一些物件在類別建構函式中。

結果並不如理想:以不同的方式組裝面向物件和函式模式的大雜燴,每種模式都需要截然不同的測試方法。有些模組可以進行單元測試,而另一些模組則缺乏測試入口點,因此簡單的邏輯需要複雜的 HTTP 感知架構來執行基本功能。最重要的是,程式碼庫中某一部分的變更有時會導致不相關區域的合約中斷。有些模組在命名空間之間相互依賴;另一些模組則有完全扁平的模組集合,沒有子網域的區別。

事後諸葛,我繼續思考最初的決定:我們應該選擇什麼 DI 模式。最後我得出了一個結論:那是一個錯誤的問題。

依賴注入是一種手段,而非目的

回顧過去,我應該引導團隊提出不同的問題:我們的程式碼庫需要具備哪些特質,我們應該採用哪些方法來達成這些特質?我希望我當時有主張以下事項

  • 離散模組,即使以部分重複類型為代價,也要將附帶耦合降至最低
  • 商業邏輯不與管理傳輸的程式碼(例如 HTTP 處理常式或 GraphQL 解析器)混雜
  • 商業邏輯測試不具備傳輸感知,或具有複雜的架構
  • 當新的欄位新增到類型時,測試不會中斷
  • 極少數的類型公開在模組外部,甚至更少數的類型公開在它們所在的目錄外部。

在過去幾年中,我已確定一種方法,可以引導採用它的開發人員朝向這些特質。由於我來自測試驅動開發 (TDD) 背景,我自然從那裡開始。TDD 鼓勵增量主義,但我希望更進一步,所以我採取了「功能優先」的極簡主義方法來進行模組組成。我不會繼續描述這個過程,而是會示範它。以下是建立在相對簡單架構上的範例網路服務,其中控制器模組會呼叫網域邏輯,而網域邏輯又會呼叫持久層中的儲存庫函式。

問題描述

想像一個類似這樣的使用者故事

身為 RateMyMeal 的註冊使用者,也是一位不知道有什麼餐點可用的潛在餐廳顧客,我希望根據其他顧客的評分,取得一份我所在區域的推薦餐廳排名清單。

驗收標準

  • 餐廳清單會從最推薦到最不推薦進行排名。
  • 評分流程包含以下潛在評分等級
    • 優良 (2)
    • 優於平均 (1)
    • 平均 (0)
    • 低於平均 (-1)
    • 很差 (-2)。
  • 整體評分為所有個別評分的總和。
  • 被視為「值得信賴」的使用者,其評分會乘以 4 倍。
  • 使用者必須指定城市,以限制回傳餐廳的範圍。

建構解決方案

我的任務是使用 Typescript、Node.js 和 PostgreSQL 建立 REST 服務。我從建立一個非常粗略的整合開始,作為一個動態骨架,定義我要解決的問題的界限。此測試盡可能使用基礎架構。如果我使用任何存根,那是針對無法在本地執行的第三方雲端供應商或其他服務。即使如此,我還是使用伺服器存根,因此我可以使用真正的 SDK 或網路用戶端。這成為我手邊任務的驗收測試,讓我保持專注。我將只涵蓋一個執行基本功能的「順利路徑」,因為測試要建構得夠強健會很耗時。我會找到較低成本的方式來測試臨界狀況。為了這篇文章,我假設我有一個骨架資料庫結構,如果需要,我可以修改它。

測試通常有given/when/then結構:一組給定的條件、一個參與動作和一個驗證結果。我比較喜歡從when/then開始,再回到given,以幫助我專注於我試圖解決的問題。

When我呼叫我的推薦端點,then我預期會收到一個 OK 回應和一個載荷,其中包含根據我們的評分演算法評分最高的餐廳」。在程式碼中,這可能是

test/e2e.integration.spec.ts…

  describe("the restaurants endpoint", () => {
    it("ranks by the recommendation heuristic", async () => {
      const response = await axios.get<ResponsePayload>( 
        "https://127.0.0.1:3000/vancouverbc/restaurants/recommended",
        { timeout: 1000 },
      );
      expect(response.status).toEqual(200);
      const data = response.data;
      const returnRestaurants = data.restaurants.map(r => r.id);
      expect(returnRestaurants).toEqual(["cafegloucesterid", "burgerkingid"]); 
    });
  });
  
  type ResponsePayload = {
    restaurants: { id: string; name: string }[];
  };

有幾個細節值得一提

  1. Axios是我選擇使用的 HTTP 用戶端程式庫。Axios get函數採用類型引數 (ResponsePayload),定義回應資料的預期結構。編譯器將確保response.data的所有使用都符合該類型,但是,此檢查只能在編譯時發生,因此無法保證 HTTP 回應主體實際包含該結構。我的斷言需要做到這一點。
  2. 與其檢查回傳餐廳的全部內容,我只檢查其 ID。這個小細節是經過深思熟慮的。如果我檢查整個物件的內容,我的測試就會變得脆弱,只要新增一個欄位,就會中斷。我希望撰寫一個測試,可以適應我程式碼的自然演進,同時驗證我感興趣的特定條件:餐廳清單的順序。

沒有我的 given 條件,這個測試沒有什麼價值,所以我接下來新增它們。

test/e2e.integration.spec.ts…

  describe("the restaurants endpoint", () => {
    let app: Server | undefined;
    let database: Database | undefined;
  
    const users = [
      { id: "u1", name: "User1", trusted: true },
      { id: "u2", name: "User2", trusted: false },
      { id: "u3", name: "User3", trusted: false },
    ];
  
    const restaurants = [
      { id: "cafegloucesterid", name: "Cafe Gloucester" },
      { id: "burgerkingid", name: "Burger King" },
    ];
  
    const ratingsByUser = [
      ["rating1", users[0], restaurants[0], "EXCELLENT"],
      ["rating2", users[1], restaurants[0], "TERRIBLE"],
      ["rating3", users[2], restaurants[0], "AVERAGE"],
      ["rating4", users[2], restaurants[1], "ABOVE_AVERAGE"],
    ];
  
    beforeEach(async () => {
      database = await DB.start();
      const client = database.getClient();
  
      await client.connect();
      try {
        // GIVEN
        // These functions don't exist yet, but I'll add them shortly
        for (const user of users) {
          await createUser(user, client);
        }
  
        for (const restaurant of restaurants) {
          await createRestaurant(restaurant, client);
        }
  
        for (const rating of ratingsByUser) {
          await createRatingByUserForRestaurant(rating, client);
        }
      } finally {
        await client.end();
      }
  
      app = await server.start(() =>
        Promise.resolve({
          serverPort: 3000,
          ratingsDB: {
            ...DB.connectionConfiguration,
            port: database?.getPort(),
          },
        }),
      );
    });
  
    afterEach(async () => {
      await server.stop();
      await database?.stop();
    });
  
    it("ranks by the recommendation heuristic", async () => {
      // .. snip

我的 given 條件在 beforeEach 函式中實作。 beforeEach 可容納更多測試,如果我想使用相同的設定架構,並讓前置條件與測試的其他部分保持乾淨的獨立性。你會注意到很多 await 呼叫。多年來使用 Node.js 等反應式平台的經驗告訴我,除了最直接的函式之外,我必須為所有函式定義非同步合約。任何最終會受到 I/O 限制的函式,例如資料庫呼叫或檔案讀取,都應該是非同步的,而同步實作很容易用 Promise 包裝起來,如果需要的話。相比之下,選擇同步合約,然後發現它需要變成非同步,是一個更難解決的問題,我們稍後會看到。

我故意延後為使用者和餐廳建立明確的類型,承認我還不知道它們的樣子。透過 Typescript 的結構化類型,我可以繼續延後建立該定義,並在我模組 API 開始穩固時,仍然獲得類型安全的優點。正如我們稍後將看到的,這是一個讓模組保持解耦的重要方法。

在這個階段,我有一個測試外殼,缺少測試依賴項。下一個階段是透過先建立 stub 函式讓測試可以編譯,然後實作這些輔助函式,來充實這些依賴項。這是一項不平凡的工作,但它也高度依賴於情境,而且超出了本文的範圍。總之,它通常會包含

  • 啟動依賴服務,例如資料庫。我通常使用 testcontainers 來執行 docker 化服務,但這些服務也可以是網路假資料或記憶體中元件,隨你喜好。
  • 填入 create... 函式,以預先建構測試所需的實體。在本範例中,這些是 SQL INSERT
  • 啟動服務本身,在這個階段只是一個簡單的 stub。我們將深入探討服務初始化,因為它與組合的討論有關。

如果你有興趣了解測試依賴項是如何初始化的,你可以查看 GitHub 儲存庫中的 結果

在繼續之前,我執行測試以確保它會如我預期般失敗。由於我尚未實作服務的 start,因此預期在進行 http 要求時會收到連線拒絕的錯誤。確認後,我停用大型整合測試,因為它暫時無法通過,並提交。

進入控制器

我通常由外而內建置,因此我的下一步是處理主要的 HTTP 處理函式。首先,我將建置控制器單元測試。我從確保 200 個空回應具有預期標頭的內容開始

test/restaurantRatings/controller.spec.ts…

  describe("the ratings controller", () => {
    it("provides a JSON response with ratings", async () => {
      const ratingsHandler: Handler = controller.createTopRatedHandler();
      const request = stubRequest();
      const response = stubResponse();
  
      await ratingsHandler(request, response, () => {});
      expect(response.statusCode).toEqual(200);
      expect(response.getHeader("content-type")).toEqual("application/json");
      expect(response.getSentBody()).toEqual({});
    });
  });

我已經開始進行一些設計工作,這將產生我承諾的高度解耦模組。大多數程式碼都是相當典型的測試架構,但如果您仔細觀察重點標示的函式呼叫,您可能會覺得它不尋常。

這個小細節是邁向 部分應用 的第一步,或函式傳回具有內容的函式。在接下來的段落中,我將展示它是如何成為組合方法建立的基礎。

接下來,我建置待測單元的 stub,這次是控制器,並執行它以確保我的測試運作如預期

src/restaurantRatings/controller.ts…

  export const createTopRatedHandler = () => {
    return async (request: Request, response: Response) => {};
  };

我的測試預期 200,但我沒有呼叫 status,因此測試失敗。對我的 stub 進行小幅調整後,它通過了

src/restaurantRatings/controller.ts…

  export const createTopRatedHandler = () => {
    return async (request: Request, response: Response) => {
      response.status(200).contentType("application/json").send({});
    };
  };

我提交並繼續充實預期有效負載的測試。我還不知道將如何確切處理此應用程式的資料存取或演算法部分,但我知道我想要委派,讓這個模組只負責在 HTTP 協定和網域之間進行轉換。我也知道我想要委派什麼。具體來說,我希望它載入評價最高的餐廳,無論它們是什麼或來自何處,因此我建立了一個具有函式來傳回頂級餐廳的「依賴項」stub。這成為我的工廠函式中的參數。

test/restaurantRatings/controller.spec.ts…

  type Restaurant = { id: string };
  type RestaurantResponseBody = { restaurants: Restaurant[] };

  const vancouverRestaurants = [
    {
      id: "cafegloucesterid",
      name: "Cafe Gloucester",
    },
    {
      id: "baravignonid",
      name: "Bar Avignon",
    },
  ];

  const topRestaurants = [
    {
      city: "vancouverbc",
      restaurants: vancouverRestaurants,
    },
  ];

  const dependenciesStub = {
    getTopRestaurants: (city: string) => {
      const restaurants = topRestaurants
        .filter(restaurants => {
          return restaurants.city == city;
        })
        .flatMap(r => r.restaurants);
      return Promise.resolve(restaurants);
    },
  };

  const ratingsHandler: Handler =
    controller.createTopRatedHandler(dependenciesStub);
  const request = stubRequest().withParams({ city: "vancouverbc" });
  const response = stubResponse();

  await ratingsHandler(request, response, () => {});
  expect(response.statusCode).toEqual(200);
  expect(response.getHeader("content-type")).toEqual("application/json");
  const sent = response.getSentBody() as RestaurantResponseBody;
  expect(sent.restaurants).toEqual([
    vancouverRestaurants[0],
    vancouverRestaurants[1],
  ]);

由於關於 getTopRestaurants 函式如何實作的資訊很少,我該如何建立它的 stub?我足夠了解,可以設計一個基本客戶端檢視,說明我在依賴項 stub 中隱含建立的合約:一個簡單的未繫結函式,非同步傳回一組餐廳。此合約可以由簡單的靜態函式、物件執行個體上的方法或 stub(如上方的測試)來滿足。這個模組不知道、不在乎,也不需要知道。它只接觸到執行工作所需的最低限度,沒有更多。

src/restaurantRatings/controller.ts…

  
  interface Restaurant {
    id: string;
    name: string;
  }
  
  interface Dependencies {
    getTopRestaurants(city: string): Promise<Restaurant[]>;
  }
  
  export const createTopRatedHandler = (dependencies: Dependencies) => {
    const { getTopRestaurants } = dependencies;
    return async (request: Request, response: Response) => {
      const city = request.params["city"]
      response.contentType("application/json");
      const restaurants = await getTopRestaurants(city);
      response.status(200).send({ restaurants });
    };
  };

對於喜歡視覺化這些內容的人,我們可以將到目前為止的生產程式碼視覺化為處理函式,它需要使用 球窩 符號來實作 getTopRatedRestaurants 介面的東西。

controller.ts

handler()

getTopRestaurants()

測試建立這個函式和所需函式的 stub。我可以透過使用不同的顏色來表示測試,以及使用插座符號來表示介面的實作,來顯示這一點。

controller.ts controller.spec.ts

handler()

getTop
Restaurants()

spec

getTopRestaurants()

這個 controller 模組在這個階段很脆弱,因此我需要充實我的測試以涵蓋替代程式碼路徑和臨界狀況,但這有點超出本文的範圍。如果您有興趣看到更徹底的 測試結果控制器模組,兩個都可以在 GitHub 儲存庫中找到。

深入領域

在此階段,我有一個需要不存在函數的控制器。我的下一步是提供一個模組來履行 getTopRestaurants 合約。我會透過撰寫一個笨拙的單元測試開始這個流程,並在之後為了清晰度而重構它。只有在這個時候,我才開始思考如何實作我先前建立的合約。我回到我的原始驗收標準,並嘗試以最小的方式設計我的模組。

test/restaurantRatings/topRated.spec.ts…

  describe("The top rated restaurant list", () => {
    it("is calculated from our proprietary ratings algorithm", async () => {
      const ratings: RatingsByRestaurant[] = [
        {
          restaurantId: "restaurant1",
          ratings: [
            {
              rating: "EXCELLENT",
            },
          ],
        },
        {
          restaurantId: "restaurant2",
          ratings: [
            {
              rating: "AVERAGE",
            },
          ],
        },
      ];
  
      const ratingsByCity = [
        {
          city: "vancouverbc",
          ratings,
        },
      ];
  
      const findRatingsByRestaurantStub: (city: string) => Promise< 
        RatingsByRestaurant[]
      > = (city: string) => {
        return Promise.resolve(
          ratingsByCity.filter(r => r.city == city).flatMap(r => r.ratings),
        );
      }; 
  
      const calculateRatingForRestaurantStub: ( 
        ratings: RatingsByRestaurant,
      ) => number = ratings => {
        // I don't know how this is going to work, so I'll use a dumb but predictable stub
        if (ratings.restaurantId === "restaurant1") {
          return 10;
        } else if (ratings.restaurantId == "restaurant2") {
          return 5;
        } else {
          throw new Error("Unknown restaurant");
        }
      }; 
  
      const dependencies = { 
        findRatingsByRestaurant: findRatingsByRestaurantStub,
        calculateRatingForRestaurant: calculateRatingForRestaurantStub,
      }; 
  
      const getTopRated: (city: string) => Promise<Restaurant[]> =
        topRated.create(dependencies);
      const topRestaurants = await getTopRated("vancouverbc");
      expect(topRestaurants.length).toEqual(2);
      expect(topRestaurants[0].id).toEqual("restaurant1");
      expect(topRestaurants[1].id).toEqual("restaurant2");
    });
  });
  
  interface Restaurant {
    id: string;
  }
  
  interface RatingsByRestaurant { 
    restaurantId: string;
    ratings: RestaurantRating[];
  } 
  
  interface RestaurantRating {
    rating: Rating;
  }
  
  export const rating = { 
    EXCELLENT: 2,
    ABOVE_AVERAGE: 1,
    AVERAGE: 0,
    BELOW_AVERAGE: -1,
    TERRIBLE: -2,
  } as const; 
  
  export type Rating = keyof typeof rating;

我已經在這個時候引入了許多新的概念到這個領域,所以我會一次處理一個

  1. 我需要一個「尋找器」,用來傳回一組每間餐廳的評分。我會從建立一個存根開始。
  2. 驗收標準提供了演算法,用來驅動整體評分,但我選擇現在忽略它,並說,不知怎麼地,這組評分會提供整體餐廳評分,作為一個數值。
  3. 這個模組要運作,它會依賴兩個新概念:找到餐廳的評分,以及給定那組評分,產生一個整體評分。我建立另一個「依賴項」介面,其中包含兩個存根函數,使用天真、可預測的存根實作,讓我可以繼續前進。
  4. RatingsByRestaurant 代表一組特定餐廳的評分。RestaurantRating 是其中一個評分。我在我的測試中定義它們,以表示我的合約意圖。這些型別可能會在某個時間點消失,或者我可能會將它們提升到生產程式碼中。就目前而言,這是一個很好的提醒,告訴我我的目標。在像 Typescript 這樣的結構化型別語言中,型別非常便宜,所以這樣做的成本非常低。
  5. 我也需要 rating,根據 AC,它由 5 個值組成:「優良 (2)、優於平均 (1)、平均 (0)、低於平均 (-1)、很差 (-2)」。這部分我也會在測試模組中擷取,等到「最後的負責時刻」再決定是否將它拉到生產程式碼中。

在我測試的基本結構就定位後,我嘗試使用極簡實作讓它編譯。

src/restaurantRatings/topRated.ts…

  interface Dependencies {}
  
  
  export const create = (dependencies: Dependencies) => { 
    return async (city: string): Promise<Restaurant[]> => [];
  }; 
  
  interface Restaurant { 
    id: string;
  }  
  
  export const rating = { 
    EXCELLENT: 2,
    ABOVE_AVERAGE: 1,
    AVERAGE: 0,
    BELOW_AVERAGE: -1,
    TERRIBLE: -2,
  } as const;
  
  export type Rating = keyof typeof rating; 
  1. 再次,我使用我的部分套用函式工廠模式,傳入依賴項並傳回函式。測試當然會失敗,但看到它以我預期的方式失敗,讓我確信它是合理的。
  2. 在我開始實作測試中的模組時,我找出一些應該提升至生產程式碼的網域物件。特別是,我將直接依賴項移至測試中的模組。任何非直接依賴項,我將保留在測試程式碼中的位置。
  3. 我也做了一個預期動作:我將 Rating 類型萃取至生產程式碼。我感到很自在,因為這是一個通用的明確網域概念。值在驗收準則中特別點出,這表示對我來說,耦合不太可能是偶然的。

請注意,我定義或移至生產程式碼的類型不會從其模組匯出。這是故意的選擇,我稍後會更深入地討論。簡單來說,我尚未決定是否要讓其他模組繫結至這些類型,建立更多可能證明是不需要的耦合。

現在,我完成 getTopRated.ts 模組的實作。

src/restaurantRatings/topRated.ts…

  interface Dependencies { 
    findRatingsByRestaurant: (city: string) => Promise<RatingsByRestaurant[]>;
    calculateRatingForRestaurant: (ratings: RatingsByRestaurant) => number;
  }
  
  interface OverallRating { 
    restaurantId: string;
    rating: number;
  }
  
  interface RestaurantRating { 
    rating: Rating;
  }
  
  interface RatingsByRestaurant {
    restaurantId: string;
    ratings: RestaurantRating[];
  }
  
  export const create = (dependencies: Dependencies) => { 
    const calculateRatings = (
      ratingsByRestaurant: RatingsByRestaurant[],
      calculateRatingForRestaurant: (ratings: RatingsByRestaurant) => number,
    ): OverallRating[] =>
      ratingsByRestaurant.map(ratings => {
        return {
          restaurantId: ratings.restaurantId,
          rating: calculateRatingForRestaurant(ratings),
        };
      });
  
    const getTopRestaurants = async (city: string): Promise<Restaurant[]> => {
      const { findRatingsByRestaurant, calculateRatingForRestaurant } =
        dependencies;
  
      const ratingsByRestaurant = await findRatingsByRestaurant(city);
  
      const overallRatings = calculateRatings(
        ratingsByRestaurant,
        calculateRatingForRestaurant,
      );
  
      const toRestaurant = (r: OverallRating) => ({
        id: r.restaurantId,
      });
  
      return sortByOverallRating(overallRatings).map(r => {
        return toRestaurant(r);
      });
    };
  
    const sortByOverallRating = (overallRatings: OverallRating[]) =>
      overallRatings.sort((a, b) => b.rating - a.rating);
  
    return getTopRestaurants;
  };
  
  //SNIP ..

完成後,我已

  1. 填入單元測試中建模的 Dependencies 類型
  2. 導入 OverallRating 類型以擷取網域概念。這可以是餐廳 ID 和數字的元組,但如我先前所述,類型很便宜,而且我相信額外的清晰度很容易證明其最低成本的合理性。
  3. 從測試中萃取幾個類型,這些類型現在是我的 topRated 模組的直接相依性
  4. 完成工廠傳回的主要函式的簡單邏輯。

主要生產程式碼函式之間的相依性如下所示

controller.ts topRated.ts

handler()

topRated()

getTopRestaurants() findRatingsByRestaurant() calculateRatings ForRestaurants()

包含測試提供的程式碼片段時,它看起來像這樣

controller.ts topRated.ts controller.spec.ts

handler()

topRated()

calculateRatingFor
RestaurantStub()

findRatingsBy
RestaurantStub

spec

getTopRestaurants() findRatingsByRestaurant() calculateRatings ForRestaurants()

完成此實作(目前為止),我有一個通過測試的主網域函式,以及一個控制器測試。它們完全解耦。事實上,如此之多,以至於我覺得有必要向自己證明它們將一起運作。是時候開始組成各單元並建構更大的整體了。

開始連結

在這個時候,我必須做出決定。如果我正在建構相對簡單的東西,我可能會選擇在整合模組時放棄測試驅動方法,但在這種情況下,我將繼續沿著 TDD 路徑進行,原因有兩個

  • 我想專注於模組之間整合的設計,而撰寫測試是一個很好的工具。
  • 在我可以使用原始驗收測試作為驗證之前,仍有幾個模組需要實作。如果我等到整合它們,如果我的一些基本假設有缺陷,我可能有很多事情要解開。

如果我的第一個驗收測試是一個巨石,而我的單元測試是鵝卵石,那麼這個第一個整合測試將是一個拳頭大小的石頭:一個龐大的測試,從控制器執行呼叫路徑到第一層網域函式,提供測試替身以取代該層之外的任何東西。至少它會這樣開始。我可能會在進行時繼續整合架構的後續層。如果測試失去效用或妨礙我,我可能也會決定放棄它。

在初始實作後,測試將驗證的內容不只於我是否正確地連接路由,而且很快就會涵蓋呼叫進入網域層,並驗證回應是否如預期般編碼。

test/restaurantRatings/controller.integration.spec.ts…

  describe("the controller top rated handler", () => {
  
    it("delegates to the domain top rated logic", async () => {
      const returnedRestaurants = [
        { id: "r1", name: "restaurant1" },
        { id: "r2", name: "restaurant2" },
      ];
  
      const topRated = () => Promise.resolve(returnedRestaurants);
  
      const app = express();
      ratingsSubdomain.init(
        app,
        productionFactories.replaceFactoriesForTest({
          topRatedCreate: () => topRated,
        }),
      );
  
      const response = await request(app).get(
        "/vancouverbc/restaurants/recommended",
      );
      expect(response.status).toEqual(200);
      expect(response.get("content-type")).toBeDefined();
      expect(response.get("content-type").toLowerCase()).toContain("json");
      const payload = response.body as RatedRestaurants;
      expect(payload.restaurants).toBeDefined();
      expect(payload.restaurants.length).toEqual(2);
      expect(payload.restaurants[0].id).toEqual("r1");
      expect(payload.restaurants[1].id).toEqual("r2");
    });
  });
  
  interface RatedRestaurants {
    restaurants: { id: string; name: string }[];
  }

這些測試可能會有點難看,因為它們非常依賴於 Web 框架。這導致我做出了第二個決定。我可以使用 Jest 或 Sinon.js 等框架,並使用模組存根或間諜程式,讓我在無法存取的相依性中取得掛鉤,例如 topRated 模組。我特別不想在我的 API 中公開這些內容,因此使用測試框架技巧可能是合理的。但在這種情況下,我決定提供一個更傳統的進入點:在 init() 函式中覆寫的工廠函式選用集合。這為我在開發過程中提供了所需的進入點。隨著進度,我可能會決定不再需要那個掛鉤,屆時我將擺脫它。

接下來,我撰寫組裝模組的程式碼。

src/restaurantRatings/index.ts…

  
  export const init = (
    express: Express,
    factories: Factories = productionFactories,
  ) => {
    // TODO: Wire in a stub that matches the dependencies signature for now.
    //  Replace this once we build our additional dependencies.
    const topRatedDependencies = {
      findRatingsByRestaurant: () => {
        throw "NYI";
      },
      calculateRatingForRestaurant: () => {
        throw "NYI";
      },
    };
    const getTopRestaurants = factories.topRatedCreate(topRatedDependencies);
    const handler = factories.handlerCreate({
      getTopRestaurants, // TODO: <-- This line does not compile right now. Why?
    });
    express.get("/:city/restaurants/recommended", handler);
  };
  
  interface Factories {
    topRatedCreate: typeof topRated.create;
    handlerCreate: typeof createTopRatedHandler;
    replaceFactoriesForTest: (replacements: Partial<Factories>) => Factories;
  }
  
  export const productionFactories: Factories = {
    handlerCreate: createTopRatedHandler,
    topRatedCreate: topRated.create,
    replaceFactoriesForTest: (replacements: Partial<Factories>): Factories => {
      return { ...productionFactories, ...replacements };
    },
  };
controller.ts topRated.ts

handler()

topRated()

index.ts

getTopRestaurants() findRatingsByRestaurant() calculateRatings ForRestaurants()

有時我有一個模組的相依性,但還沒有任何內容可以履行該合約。這完全沒問題。我可以在內嵌實作中定義一個實作,就像上面 topRatedHandlerDependencies 物件中擲回例外情況一樣。驗收測試將會失敗,但在這個階段,這正是我所預期的。

找出並修復問題

仔細的觀察者會注意到在建構 topRatedHandler 時有一個編譯錯誤,因為我在兩個定義之間發生衝突

  • controller.ts 理解的餐廳表示法
  • topRated.ts 中定義的餐廳和 getTopRestaurants 回傳的餐廳。

原因很簡單:我尚未在 topRated.ts 中的 Restaurant 類型中新增 name 欄位。這裡有一個權衡。如果我有一個代表餐廳的單一類型,而不是每個模組中有一個類型,我只需要新增一次 name,而且兩個模組都會在沒有其他變更的情況下編譯。儘管如此,我選擇將類型分開,即使它會產生額外的範本程式碼。透過維護兩個不同的類型,一個類型對應於應用程式的每一層,我不太可能不必要地將這些層結合在一起。不,這不太 DRY,但我常常願意冒一些重複的風險,以保持模組合約盡可能獨立。

src/restaurantRatings/topRated.ts…

  
    interface Restaurant {
      id: string;
      name: string,
    }
  
    const toRestaurant = (r: OverallRating) => ({
      id: r.restaurantId,
      // TODO: I put in a dummy value to
      //  start and make sure our contract is being met
      //  then we'll add more to the testing
      name: "",
    });

我極為天真的解決方案讓程式碼再次編譯,讓我可以繼續進行模組上的目前工作。我很快就會在我的測試中新增驗證,以確保 name 欄位會如預期般對應。現在測試通過了,我繼續進行下一步,也就是為餐廳對應提供更永久的解決方案。

接觸儲存庫層

現在,我的 getTopRestaurants 函式的結構大致就緒,需要一種方法來取得餐廳名稱,我將填寫 toRestaurant 函式以載入其餘的 Restaurant 資料。在過去,在採用這種高度函式驅動的開發風格之前,我可能會建立一個儲存庫物件介面或存根,其中有一個用於載入 Restaurant 物件的方法。現在,我的傾向是建立我需要的最低限度:一個用於載入物件的函式定義,而不會對實作做出任何假設。當我繫結到該函式時,稍後可以進行。

test/restaurantRatings/topRated.spec.ts…

  
      const restaurantsById = new Map<string, any>([
        ["restaurant1", { restaurantId: "restaurant1", name: "Restaurant 1" }],
        ["restaurant2", { restaurantId: "restaurant2", name: "Restaurant 2" }],
      ]);
  
      const getRestaurantByIdStub = (id: string) => { 
        return restaurantsById.get(id);
      };
  
      //SNIP...
    const dependencies = {
      getRestaurantById: getRestaurantByIdStub,  
      findRatingsByRestaurant: findRatingsByRestaurantStub,
      calculateRatingForRestaurant: calculateRatingForRestaurantStub,
    };

    const getTopRated = topRated.create(dependencies);
    const topRestaurants = await getTopRated("vancouverbc");
    expect(topRestaurants.length).toEqual(2);
    expect(topRestaurants[0].id).toEqual("restaurant1");
    expect(topRestaurants[0].name).toEqual("Restaurant 1"); 
    expect(topRestaurants[1].id).toEqual("restaurant2");
    expect(topRestaurants[1].name).toEqual("Restaurant 2");

在我的網域層級測試中,我已引入

  1. Restaurant 的存根尋找器
  2. 該尋找器的相依項中的輸入
  3. 驗證名稱是否與從 Restaurant 物件載入的內容相符。

與載入資料的其他函式一樣,getRestaurantById 會傳回一個包裝在 Promise 中的值。儘管我繼續玩這個小遊戲,假裝不知道自己將如何實作該函式,但我已知道 Restaurant 來自外部資料來源,因此我想要非同步載入它。這使得對應程式碼更為複雜。

src/restaurantRatings/topRated.ts…

  const getTopRestaurants = async (city: string): Promise<Restaurant[]> => {
    const {
      findRatingsByRestaurant,
      calculateRatingForRestaurant,
      getRestaurantById,
    } = dependencies;

    const toRestaurant = async (r: OverallRating) => { 
      const restaurant = await getRestaurantById(r.restaurantId);
      return {
        id: r.restaurantId,
        name: restaurant.name,
      };
    };

    const ratingsByRestaurant = await findRatingsByRestaurant(city);

    const overallRatings = calculateRatings(
      ratingsByRestaurant,
      calculateRatingForRestaurant,
    );

    return Promise.all(  
      sortByOverallRating(overallRatings).map(r => {
        return toRestaurant(r);
      }),
    );
  };
  1. 複雜性來自於 toRestaurant 是非同步的
  2. 我可以用 Promise.all() 輕鬆地在呼叫程式碼中處理它。

我不希望這些請求中的每個請求都遭到封鎖,否則我的 IO 綁定負載將會依序執行,導致整個使用者請求延遲,但我需要封鎖,直到所有查詢完成。幸運的是,Promise 函式庫提供了 Promise.all,可將 Promise 集合壓縮成包含集合的單一 Promise。

透過這個變更,查詢餐廳的請求會並行發出。由於同時請求的數量很少,這對於前 10 名清單來說沒問題。在任何規模的應用程式中,我可能會重新架構我的服務呼叫,以透過資料庫聯結載入 name 欄位,並消除額外的呼叫。如果沒有這個選項,例如,我正在查詢外部 API,我可能會手動批次處理它們,或使用由第三方函式庫提供的非同步池,例如 Tiny Async Pool 來管理並行性。

再次,我使用虛擬實作更新組裝模組,讓所有內容都編譯,然後開始撰寫履行我剩餘合約的程式碼。

src/restaurantRatings/index.ts…

  
  export const init = (
    express: Express,
    factories: Factories = productionFactories,
  ) => {
  
    const topRatedDependencies = {
      findRatingsByRestaurant: () => {
        throw "NYI";
      },
      calculateRatingForRestaurant: () => {
        throw "NYI";
      },
      getRestaurantById: () => {
        throw "NYI";
      },
    };
    const getTopRestaurants = factories.topRatedCreate(topRatedDependencies);
    const handler = factories.handlerCreate({
      getTopRestaurants,
    });
    express.get("/:city/restaurants/recommended", handler);
  };
controller.ts topRated.ts

handler()

topRated()

index.ts

getTopRestaurants() findRatingsByRestaurant() calculateRatings ForRestaurants() getRestaurantById()

最後一哩路:實作領域層依賴關係

在控制器和主要網域模組工作流程就位後,現在是時候實作相依性,也就是資料庫存取層和加權評分演算法。

這會產生下列高階函式和相依性集合

controller.ts topRated.ts ratingsAlgorithm.ts restaurantRepo.ts ratingsRepo.ts

handler()

topRated()

index.ts

calculateRatings
ForRestaurants()

groupedBy
Restaurant()

findById()

getTopRestaurants() findRatingsByRestaurant() calculateRatings ForRestaurants() getRestaurantById()

針對測試,我有下列存根安排

controller.ts topRated.ts

handler()

topRated()

calculateRatingFor
RestaurantStub()

findRatingsBy
RestaurantStub

getRestaurantBy
IdStub()

getTopRestaurants() findRatingsByRestaurant() calculateRatings ForRestaurants() getRestaurantById()

針對測試,所有元素都由測試程式碼建立,但我沒有在圖表中顯示,以避免雜亂。

實作這些模組的流程遵循相同的模式

  • 實作測試以驅動基本設計和 Dependencies 類型(如果需要的話)
  • 建立模組的基本邏輯流程,讓測試通過
  • 實作模組相依性
  • 重複。

我不會再說明整個流程,因為我已經示範過這個流程。模組從頭到尾運作的程式碼可在 儲存庫中取得。最終實作的某些面向需要額外的註解。

到目前為止,你可能會預期我的評分演算法可透過另一個實作為部分套用函式的工廠取得。這次我選擇撰寫純函式。

src/restaurantRatings/ratingsAlgorithm.ts…

  interface RestaurantRating {
    rating: Rating;
    ratedByUser: User;
  }
  
  interface User {
    id: string;
    isTrusted: boolean;
  }
  
  interface RatingsByRestaurant {
    restaurantId: string;
    ratings: RestaurantRating[];
  }
  
  export const calculateRatingForRestaurant = (
    ratings: RatingsByRestaurant,
  ): number => {
    const trustedMultiplier = (curr: RestaurantRating) =>
      curr.ratedByUser.isTrusted ? 4 : 1;
    return ratings.ratings.reduce((prev, curr) => {
      return prev + rating[curr.rating] * trustedMultiplier(curr);
    }, 0);
  };

我做出這個選擇,表示這應始終是一個簡單的無狀態計算。如果我想為更複雜的實作留下簡單的途徑,例如由每位使用者參數化的資料科學模型所支援,我會再次使用工廠模式。通常沒有正確或錯誤的答案。設計選擇提供了一個途徑,可以說,表示我預期軟體如何演進。我在我不認為應該變更的區域建立更嚴格的程式碼,同時在對方向沒有那麼有信心的區域留下更多彈性。

另一個我「留下途徑」的範例,是在 ratingsAlgorithm.ts 中定義另一個 RestaurantRating 類型的決定。此類型與在 topRated.ts 中定義的 RestaurantRating 完全相同。我可以在這裡採取另一條路徑

  • topRated.ts 匯出 RestaurantRating,並在 ratingsAlgorithm.ts 中直接參照它,或
  • RestaurantRating 匯出到一個共用模組。您通常會在稱為 types.ts 的模組中看到共用定義,儘管我比較喜歡更具脈絡性的名稱,例如 domain.ts,它提供了一些關於其中包含類型種類的提示。

在這種情況下,我並不確定這些類型是否完全相同。它們可能是具有不同欄位的相同網域實體的不同投影,而且我不想在模組邊界間共用它們,以降低更深入耦合的風險。儘管這看起來不太直觀,但我相信這是正確的選擇:在這個時間點,壓縮實體非常便宜且容易。如果它們開始分歧,我可能不應該合併它們,但一旦它們被繫結,將它們分開可能會非常棘手。

如果看起來像鴨子

我承諾解釋為什麼我經常選擇不匯出類型。我只有在確定這樣做不會產生意外耦合,限制程式碼演進能力時,才希望將類型提供給其他模組。幸運的是,Typescript 的結構化或「duck」打字使得在編譯時保證合約完整性的同時,很容易讓模組保持解耦,即使類型沒有共用。只要類型在呼叫方和被呼叫方中相容,程式碼就會編譯。

像 Java 或 C# 這樣的更嚴格語言會迫使您在流程的早期做出一些決定。例如,在實作評分演算法時,我將被迫採取不同的方法

  • 我可以萃取 RestaurantRating 類型,以讓包含演算法的模組和包含整體頂級評分工作流程的模組都能使用它。缺點是其他函式可以繫結到它,增加模組耦合。
  • 或者,我可以建立兩個不同的 RestaurantRating 類型,然後提供一個轉換函式,在兩個相同的類型之間進行轉換。這沒問題,但它會增加範本程式碼的數量,只是為了告訴編譯器您希望它已經知道的事情。
  • 我可以將演算法完全收斂到 topRated 模組,但那會讓它承擔比我預期的更多責任。

語言的僵化性表示使用這種方法可能會產生更昂貴的權衡。Martin Fowler 在他 2004 年關於依賴注入和服務定位器模式的文章中,討論了使用 角色介面 來減少 Java 中依賴項的耦合,儘管缺乏結構類型或一階函數。如果我在 Java 中工作,我絕對會考慮這種方法。

我打算將這個專案移植到其他幾個強型別語言,看看這個模式在其他情境中適用的程度如何。到目前為止,我已經移植到 KotlinGo,有跡象表明這個模式適用,但並非不需要做一些調整。我也相信我可能必須將它移植到每種語言中多次,才能更好地了解哪些調整能產生最佳結果。我在各自的儲存庫中記錄了我所做的選擇和對結果的看法,做進一步的說明。

總結

透過選擇以函數而非類別來履行依賴項合約,將模組之間的程式碼共用降到最低,並透過測試來推動設計,我可以建立一個由高度離散、可演進,但仍然是類型安全的模組組成的系統。如果你在你的下一個專案中也有類似的優先順序,請考慮採用我概述的方法的一些面向。不過,請注意,為你的專案選擇一個基礎方法很少像選擇「最佳實務」一樣簡單,需要考慮其他因素,例如你的技術堆疊的慣用語和你的團隊的技能。有許多方法可以組合一個系統,每種方法都有一組複雜的權衡。這使得軟體架構經常困難,但總是引人入勝。我不會有其他方法。


重大修訂

2023 年 7 月 3 日:新增範例專案的 Go 和 Kotlin 移植參考

2023 年 5 月 23 日:發布