依赖组合
基于对传统基于框架的依赖注入的挫败感,我采用了一种利用偏应用将上下文注入模块的组合策略。当与测试驱动开发作为设计过程相结合,并专注于函数而不是类时,模块可以保持清晰、干净,并且在很大程度上免受意外耦合的影响。
2023 年 5 月 23 日
起源故事
这一切开始于几年前,我团队中的一名成员问道:“我们应该采用什么模式来进行 依赖注入 (DI)?”该团队的技术栈是 Node.js 上的 Typescript,我并不十分熟悉,所以我鼓励他们自己解决。后来我失望地得知,该团队实际上决定不决定,留下了一堆用于连接模块的模式。一些开发人员使用工厂方法,另一些开发人员在根模块中使用手动依赖注入,还有一些开发人员在类构造函数中使用对象。
结果并不理想:各种面向对象和函数式模式以不同的方式组合在一起,每种模式都需要完全不同的测试方法。一些模块是单元可测试的,另一些模块缺乏测试入口点,因此简单的逻辑需要复杂的 HTTP 感知脚手架来执行基本功能。最重要的是,代码库中一个部分的更改有时会导致无关区域的契约中断。一些模块跨命名空间相互依赖;另一些模块则完全是扁平的模块集合,没有区分子域。
事后看来,我继续思考最初的决定:我们应该选择哪种 DI 模式。最终我得出一个结论:这是一个错误的问题。
依赖注入是一种手段,而不是目的
回顾过去,我应该引导团队问一个不同的问题:我们希望代码库具备哪些特性,我们应该使用哪些方法来实现这些特性?我希望我当时主张以下内容
- 离散模块,即使以牺牲一些重复类型为代价,也要尽量减少偶然耦合
- 将业务逻辑与管理传输的代码(如 HTTP 处理程序或 GraphQL 解析器)分开
- 不依赖于传输或具有复杂脚手架的业务逻辑测试
- 在类型中添加新字段时不会中断的测试
- 很少有类型暴露在模块之外,更少有类型暴露在它们所在的目录之外。
在过去的几年里,我确定了一种方法,这种方法可以引导采用它的开发人员朝着这些特性发展。我来自 测试驱动开发 (TDD) 背景,所以我自然地从那里开始。TDD 鼓励增量式开发,但我希望更进一步,所以我采用了极简主义的“函数优先”方法来进行模块组合。与其继续描述这个过程,不如我演示一下。下面是一个基于相对简单的架构构建的示例 Web 服务,其中控制器模块调用领域逻辑,领域逻辑又调用持久层中的存储库函数。
问题描述
想象一个类似于这样的用户故事
作为 RateMyMeal 的注册用户,也是一位不知道有什么可供选择的潜在餐厅顾客,我希望根据其他顾客的评分获得我所在地区推荐餐厅的排名列表。
验收标准
- 餐厅列表按推荐度从高到低排序。
- 评分过程包括以下潜在评分级别
- 优秀 (2)
- 高于平均水平 (1)
- 平均水平 (0)
- 低于平均水平 (-1)
- 糟糕 (-2)。
- 总体评分是所有个人评分的总和。
- 被认为是“可信”的用户,其评分乘以 4 倍。
- 用户必须指定一个城市来限制返回餐厅的范围。
构建解决方案
我被分配的任务是使用 Typescript、Node.js 和 PostgreSQL 构建一个 REST 服务。我首先构建一个非常粗略的集成作为 行走骨架,它定义了我想要解决的问题的边界。此测试尽可能多地使用底层基础设施。如果我使用任何存根,则是针对第三方云提供商或其他无法在本地运行的服务。即使那样,我也使用服务器存根,因此我可以使用真正的 SDK 或网络客户端。这成为我手头任务的验收测试,让我保持专注。我只会涵盖一个“快乐路径”,它会执行基本功能,因为构建健壮的测试将非常耗时。我会找到更经济的方式来测试边缘情况。为了本文的需要,我假设我有一个骨架数据库结构,如果需要,我可以修改它。
测试通常具有 given/when/then
结构:一组给定条件、一个参与操作和一个已验证的结果。我更喜欢从 when/then
开始,然后回到 given
,这有助于我专注于我想要解决的问题。
“当我调用我的推荐端点时,那么我希望得到一个 OK 响应和一个包含根据我们的评分算法排名的顶级餐厅的有效负载”。在代码中,这可能是
test/e2e.integration.spec.ts…
describe("the restaurants endpoint", () => { it("ranks by the recommendation heuristic", async () => { const response = await axios.get<ResponsePayload>( ➀ "http://localhost: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 }[]; };
有一些细节值得一提
Axios
是我选择的 HTTP 客户端库。Axiosget
函数接受一个类型参数 (ResponsePayload
),它定义了响应数据的预期结构。编译器将确保所有对response.data
的使用都符合该类型,但是,此检查只能在编译时发生,因此无法保证 HTTP 响应主体实际上包含该结构。我的断言需要做到这一点。- 与其检查返回餐厅的全部内容,不如只检查它们的 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 等反应式平台的经验教会我,除了最简单的函数之外,所有函数都应该定义异步契约。任何最终与 IO 相关的操作,例如数据库调用或文件读取,都应该是异步的,同步实现很容易包装在 Promise 中,如果需要的话。相反,选择同步契约,然后发现它需要是异步的,这是一个更难解决的问题,我们将在后面看到。
我故意推迟了为用户和餐厅创建显式类型,因为我还没有确定它们是什么样子。使用 Typescript 的结构化类型,我可以继续推迟创建该定义,并且仍然可以在我的模块 API 开始固化时获得类型安全的优势。正如我们将在后面看到的那样,这是保持模块解耦的关键手段。
此时,我有一个测试外壳,缺少测试依赖项。下一步是通过首先构建存根函数来填充这些依赖项,以使测试能够编译,然后实现这些辅助函数。这是一项非凡的工作量,但它也是高度上下文相关的,超出了本文的范围。需要说明的是,它通常包括
- 启动依赖服务,例如数据库。我通常使用 testcontainers 来运行 dockerized 服务,但这些服务也可以是网络模拟或内存中组件,无论你更喜欢哪种方式。
- 填充
create...
函数以预先构建测试所需的实体。在本例中,这些是 SQLINSERT
。 - 启动服务本身,此时是一个简单的存根。我们将更深入地研究服务初始化,因为它与组合的讨论相关。
如果你对测试依赖项的初始化方式感兴趣,可以在 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({});
});
});
我已经开始做一些设计工作,这将导致我承诺的高度解耦的模块。大部分代码都是相当典型的测试脚手架,但如果你仔细观察突出显示的函数调用,你可能会觉得它很不寻常。
这个小细节是迈向 偏应用 或函数返回具有上下文的函数的第一步。在接下来的段落中,我将演示它如何成为组合方法的基础。
接下来,我构建了被测单元的存根,这次是控制器,并运行它以确保我的测试按预期运行
src/restaurantRatings/controller.ts…
export const createTopRatedHandler = () => { return async (request: Request, response: Response) => {}; };
我的测试期望一个 200,但我没有收到对 status
的任何调用,因此测试失败。对我的存根进行一个小调整,它就通过了
src/restaurantRatings/controller.ts…
export const createTopRatedHandler = () => { return async (request: Request, response: Response) => { response.status(200).contentType("application/json").send({}); }; };
我提交并继续完善对预期有效负载的测试。我还没有完全确定如何处理此应用程序的数据访问或算法部分,但我清楚地知道我想委托,将此模块留给仅在 HTTP 协议和域之间进行转换。我还知道我想要从委托者那里得到什么。具体来说,我希望它加载最受欢迎的餐厅,无论它们是什么以及来自哪里,因此我创建了一个“依赖项”存根,其中包含一个函数来返回最受欢迎的餐厅。这成为我工厂函数中的一个参数。
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
函数的实现方式知之甚少,我该如何存根它?我知道足以设计我隐式地在依赖项存根中创建的契约的基本客户端视图:一个简单的未绑定函数,异步返回一组餐厅。此契约可能由一个简单的静态函数、对象实例上的方法或存根来实现,如上面的测试所示。此模块不知道、不关心,也不必关心。它只暴露了完成其工作所需的最小内容,仅此而已。
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
模块目前很脆弱,因此我需要完善我的测试以涵盖替代代码路径和边缘情况,但这超出了本文的范围。如果您有兴趣查看更全面的 测试 和 生成的控制器模块,两者都在 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;
我已经在这个领域引入了许多新概念,因此我将逐一介绍它们。
- 我需要一个“查找器”,它返回每家餐厅的一组评分。我将从存根它开始。
- 验收标准提供了将驱动整体评分的算法,但我选择暂时忽略它,并说,以某种方式,这组评分将提供整体餐厅评分作为数值。
- 为了使此模块正常运行,它将依赖于两个新概念:查找餐厅的评分,以及给定该评分集,生成整体评分。我创建了另一个“依赖项”接口,其中包含两个存根函数,它们具有朴素的、可预测的存根实现,以让我继续前进。
RatingsByRestaurant
表示特定餐厅的评分集合。RestaurantRating
是一个这样的单一评分。我在测试中定义它们是为了表明我的契约的意图。这些类型可能在某个时候消失,或者我可能会将它们提升到生产代码中。现在,它很好地提醒了我前进的方向。在像 Typescript 这样的结构化类型语言中,类型非常便宜,因此这样做的成本非常低。- 我还需要
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;
- 同样,我使用部分应用的函数工厂模式,传入依赖项并返回一个函数。测试当然会失败,但看到它以我预期的失败方式失败,增强了我对它健全性的信心。
- 当我开始实现被测模块时,我识别出一些应该提升到生产代码的域对象。特别是,我将直接依赖项移到被测模块中。任何不是直接依赖项的东西,我都将其保留在测试代码中的位置。
- 我还做了一个预期动作:我将
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 ..
这样做之后,我有了
- 填充了我在单元测试中建模的 Dependencies 类型
- 引入了
OverallRating
类型来捕获域概念。这可能是一个餐厅 ID 和数字的元组,但正如我之前所说,类型很便宜,我相信额外的清晰度很容易证明最小成本是合理的。 - 从测试中提取了几个现在是我的
topRated
模块的直接依赖项的类型 - 完成了工厂返回的主要函数的简单逻辑。
主要生产代码函数之间的依赖关系如下所示
当包含测试提供的存根时,它看起来像这样
随着此实现的完成(暂时),我有一个针对我的主要域函数和一个针对我的控制器的通过测试。它们完全解耦。事实上,它们是如此解耦,以至于我觉得需要向自己证明它们可以一起工作。是时候开始组合这些单元并构建一个更大的整体了。
开始连接
此时,我需要做出决定。如果我正在构建一些相对简单的東西,我可能会选择在集成模块时放弃测试驱动的方法,但在这种情况下,我将继续走 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 }; }, };
有时我有一个模块的依赖项定义,但还没有任何东西来满足该契约。这完全没问题。我可以在内联中定义一个实现,该实现抛出异常,如上面的 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
对象的Repository
对象接口或存根。现在,我的倾向是构建我需要的最小值:一个用于加载对象的函数定义,而不对实现做出任何假设。这可以在我绑定到该函数时再进行。
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");
在我的域级测试中,我引入了
- 一个用于
Restaurant
的存根查找器 - 在我的依赖项中为该查找器添加一个条目
- 验证名称是否与从
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); }), ); };
- 复杂性来自于
toRestaurant
是异步的 - 我可以在调用代码中使用
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); };
最后一公里:实现领域层依赖
我的控制器和主域模块工作流程到位后,是时候实现依赖项了,即数据库访问层和加权评分算法。
这导致了以下一组高级函数和依赖项
为了测试,我安排了以下存根
为了测试,所有元素都是由测试代码创建的,但由于混乱,我没有在图中显示它们。
实现这些模块的过程遵循相同的模式
- 实现一个测试来驱动基本设计,如果需要,创建一个
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 的结构化或“鸭子”类型使保持模块解耦变得非常容易,同时还能保证在编译时契约完整,即使类型未共享。只要类型在调用者和被调用者中兼容,代码就会编译。
像 Java 或 C# 这样的更严格的语言迫使您在流程的早期做出一些决定。例如,在实现评分算法时,我将被迫采取不同的方法
- 我可以提取
RestaurantRating
类型,使其可用于包含算法的模块和包含总体最高评分工作流程的模块。缺点是其他函数可以绑定到它,增加模块耦合。 - 或者,我可以创建两个不同的
RestaurantRating
类型,然后提供一个适配器函数来在这两个相同的类型之间进行转换。这可以,但会增加模板代码的数量,仅仅是为了告诉编译器它已经知道的内容。 - 我可以将算法完全合并到
topRated
模块中,但这会赋予它比我想要的更多的职责。
语言的严格性可能意味着使用这种方法会带来更昂贵的权衡。在 2004 年关于依赖注入和服务定位器模式的文章中,Martin Fowler 谈到了使用角色接口来减少 Java 中依赖项的耦合,尽管缺乏结构化类型或一阶函数。如果我在 Java 中工作,我肯定会考虑这种方法。
我打算将这个项目移植到其他几种强类型语言中,看看这种模式在其他环境中的适用程度。到目前为止,我已经将其移植到Kotlin和Go,有迹象表明这种模式适用,但并非没有需要一些调整。我还相信,我可能需要多次将其移植到每种语言中,才能更好地了解哪些调整会产生最佳结果。我在各个存储库中记录了更多关于我做出的选择和我对结果的理解。
总结
通过选择使用函数而不是类来满足依赖项契约,最大限度地减少模块之间的代码共享,并通过测试驱动设计,我可以创建一个由高度离散、可演化但仍然类型安全的模块组成的系统。如果您在下一个项目中也有类似的优先级,请考虑采用我概述的方法的一些方面。但是请注意,为您的项目选择一个基础方法很少像选择“最佳实践”那样简单,它需要考虑其他因素,例如您的技术堆栈的习惯用法和团队的技能。有很多方法可以将系统组合在一起,每种方法都有一套复杂的权衡。这使得软件架构通常很困难,但始终引人入胜。我不会以任何其他方式。
重大修订
2023 年 7 月 3 日:添加对示例项目的 Go 和 Kotlin 移植版本的引用
2023 年 5 月 23 日:发布