为什么您的代码需要抽象层?,


抽象是编写设计良好的软件最重要的方面之一。

了解这个基本概念将为您提供可遵循的系统和清晰的思维模型,以了解如何创建好的抽象。

好的抽象降低了复杂性,并允许开发人员更轻松地更改代码并减少错误。但是创建抽象并非易事。那么您究竟如何做到这一点,需要采取哪些步骤?

什么是抽象?

谈论代码中的抽象层之前,不妨简要地谈谈抽象是什么。

抽象可以定义为通过以下方式简化实体的过程:

1. 省略不重要的细节。

2. 暴露接口。

所有抽象在这方面都大同小异。

自动驾驶汽车是抽象的实际例子。在这种情况下,离合器是抽象的,驾驶员可以更轻松地换档。

抽象也有不足。比如说,虽然驾驶员可以更轻松地换档,但现在对汽车的控制也较少,因此为赛车驾驶员抽象离合器可能是坏主意。

作者John Ousterhout在《软件设计理念》一书中谈到了抽象可能出错的两种方式:

1. 包含不重要的细节:由于包含不重要的细节,抽象变得过于复杂,导致开发人员的认知负担加大。

2. 省略重要细节:Ousterhout将这种抽象称为“虚假抽象”,因为查看抽象的开发人员不会拥有他们需要的所有信息。

所以,好的抽象需要兼顾和权衡。

代码中的抽象

我们已知道了抽象,但它如何应用于代码?

所有代码可以归类为策略或细节。

  • 策略:这些是实体和业务逻辑。
  • 细节:这是策略的实现。细节执行策略。

假设您有一个 User 实体。用户有某个接口以及某个业务逻辑。这个User实体还有组,您被指派编写获取所有用户组的代码。

在这里,策略是用户本身,因为它是一个实体,但它也是getUserGroups函数,因为它是与该实体相关的业务逻辑。

它如何实现、使用哪个数据库、使用哪个ORM(对象关系映射)、使用哪些库、如何编写代码以及所有不同的实现,这些都是代码的细节部分。

在您的代码中,您希望在隐藏细节的同时暴露策略。策略和细节之间的这种分离让您可以切换和轻松重构实现。

如果您的策略和细节是耦合的,就很难重构,因为它们混合在一起,更改会从一个传播到另一个。

在设计良好的系统中,策略和细节之间的分离是关键。

那么这如何应用于抽象层呢?

抽象层

抽象层暴露了接口,并隐藏了它背后的实现细节。

抽象层的目的是创建抽象。层里面的方法和属性应该是暴露的接口,而这些方法里面的实现是细节层中的一切。

创建抽象层主要有三个好处:

1. 集中​​:通过在一层中创建抽象,与其相关的所有内容都是集中的,因此可以在一处进行任何更改。集中与“不要重复自己”(DRY)原则有关,这很容易被误解。

DRY不仅涉及代码的重复,还涉及知识的重复。有时,两个不同的实体可以复制相同的代码,因为这可以实现分离,允许这些实体将来分别演进。

2. 简化:通过创建抽象层,您可以暴露特定的功能并隐藏实现细节。现在代码可以直接与您的接口交互,避免处理不相关的实现细节。这提高了代码的可读性,减轻了阅读代码的开发人员的认知负担。为何?

因为策略不如细节复杂,所以与其交互更直接。

3. 测试:抽象层非常适合测试,因为您可以把细节换成另一组细节,这有助于隔离正在测试的区域,并正确创建测试替代(test doubles)。

测试代​​码时,开发人员需要测试特定的功能,同时为某些功能创建测试替代,以避免调用真正的数据库之类的对象。策略和细节纠缠在一起时,过度使用测试替代很常见,这使得覆盖率更低,测试的用处也大大降低。

为数据库实现对象创建抽象层时,开发人员可以替换该层,确保在测试其余功能时仅替换数据库响应。

创建抽象层的示例

假设您为组创建API编写代码:

  1. function createUserGroup(group, userId) { 
  2.         logger.info('Creating group for user ${userId}') 
  3.         db.startTransaction(); 
  4.         const isValidGroup = validateGroup(group); 
  5.         if (!isValidGroup) throw new Error('Invalid group'); 
  6.         db.addDoc('groups', group) 
  7.         dc.addDoc('quotas/groups', 1) 
  8.         . 
  9.         . 
  10.         . 
  11.     } 

可从上述例子看出,该函数逻辑与策略和细节混合在一起。它处理很多不同的功能,并不使用任何抽象层。

这是使用抽象层的代码:

  1. class GroupsService { 
  2.     GROUPS_COLLECTION = 'groups'; 
  3.     createGroup() { 
  4.         db.startTransaction(); 
  5.          
  6.         const isValid = this.validateGroup(); 
  7.         if (!isValid) throw new Error('Invalid group') 
  8.          
  9.         db.addDoc(GROUPS_COLLECTION, group) 
  10.         quotasService.setQuota('/groups', 1); 
  11.          
  12.         db.finishTransaction(); 
  13.     } 
  14.      
  15.     validateGroup() 
  16.      
  17.     deleteGroup(); 
  18. class QuotasService { 
  19.     setQuota(collection: string, value: any) { 
  20.         dc.addDoc(`quotas/${collection}`, value) 
  21.     } 
  22. function createUserGroup(group, userId) { 
  23.     logger.info(`Creating group for user ${userId}`) 
  24.     groupsService.createGroup(); 
  25.      
  26.     return { 
  27.         status: 200, 
  28.         message: 'Group created successfully' 
  29.     } 

第二个实现有诸多好处:

1. 更容易理解,因为实现细节是抽象的,您在阅读的是与策略交互的代码。

2. 一切都集中在一项服务中。想象一下与组有关的代码散布在整个应用程序中。所做的每一次更改都需要到处进行;至少可以说,这会有问题。

3. 代码更加封装。注意控制器createUserGroup现在不知道配额,只知道组创建,因为配额无关紧要。

4. 我们可以专注于测试实现,同时仅把细节层换成测试替代,使测试更容易。至于集成测试,我们可以替换QuotaService和GroupService,并测试该特定控制器所实现的实现。

可能的应用

抽象层可以通过许多不同的方式实现,其中最常见的用例是:

1. 通过分离策略和细节创建更精简的组件:如果变更和重构很容易,您的代码将通过时间的考验。分离策略和细节,同时仅用接口保持组件之间的交互提供了未来代码演变所需的基础设施。

2. 包装第三方库:您的代码中过时的第三方库阻止您升级其他依赖项是一场噩梦,如果该依赖项存在安全风险,尤为糟糕。

通过在一个中央抽象层中使用您自己的接口包装第三方库,变得将很容易,因为它们只需要在暴露接口的那一处进行。

3. 创建实用服务:实用服务是提高开发速度和重用通用代码段的关键方法。

比如说,如果您在开发处理大量不同时间和日期功能的特性,为什么不创建几个实用函数来帮助您、并将它们放在一处供进一步重用?

小结

创建抽象层通过提供三大好处来帮助显著改进代码:集中、简化和更好的测试。

请记住,抽象层和一般的抽象不是目的,而是实现目的的手段。抽象可能有缺点。一个常见的例子是某些抽象会影响性能。所以总是要先了解不足。

原文标题:Why Your Code Needs Abstraction Layers,作者:Yair Cohen

相关内容