接口使用的最佳时机

1. 引言

接口在系统设计中,以及代码重构优化中,是一个不可或缺的工具,能够帮助我们写出可扩展,可维护性更强的程序。

在本文,我们将介绍什么是接口,在此基础上,通过一个例子来介绍接口的优点。但是接口也不是任何场景都可以随意使用的,我们会介绍接口使用的常见场景,同时也介绍了接口滥用可能带来的问题,以及一些接口滥用的特征,帮助我们及早发现接口滥用的情况。

2. 什么是接口

接口是一种工具,在识别出系统中变化部分时,帮助从系统模块中抽取出变化的部分,从而保证系统的稳定性,可维护性和可扩展性。接口充当了一种契约或规范,规定了类或模块应该提供的方法和行为,而不关心具体的实现细节。

接口通常用于面向对象编程语言中,如 JavaGo 等。在这些语言中,类可以实现一个或多个接口,并提供接口定义的方法的具体实现。通过使用接口,我们可以编写更灵活、可维护和可扩展的代码,同时将系统中的变化隔离开来。

接口的实现在不同的编程语言中可能会有所不同。以下简单展示接口在JavaGo 语言中的示例。在Go 语言中,接口是一组方法签名的集合。实现接口时,类不需要显式声明实现了哪个接口,只要一个类型实现了接口中的所有方法,就被视为实现了该接口。

// 定义一个接口
type Shape interface {
    Area() float64
    Perimeter() float64
}

// 实现接口的类型
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

Java 语言中,接口使用 interface 定义,同时包含所有的方法签名。类需要通过使用 implements 关键字来实现接口,并提供接口中定义的方法的具体实现。

// 定义一个接口
interface Shape {
    double area();
    double perimeter();
}

// 实现接口的类
class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }

    @Override
    public double perimeter() {
        return 2 * Math.PI * radius;
    }
}

上面示例展示了JavaGo语言中接口的定义方式以及接口的实现方式,虽然具体实现方式各不相同,但它们都遵循了相似的概念,接口用于定义规范和契约,实现类则提供方法的具体实现来满足接口的要求。

3. 接口的优点

在识别出系统变化的部分后,接口能够帮助我们将系统中变化的部分抽取出来,基于此能够降低了模块间的耦合度,能够提高代码的可维护性和代码的模块化程度,有助于创建更灵活、可扩展和易于维护的代码。下面我们通过一个简单的例子来进行说明,详细讨论这些好处。

3.1 初始需求

假设我们在构建一个商城系统,其中一个相对复杂且重要的模块为商品价格的计算,计算购物车中各种商品的总价格。价格计算过程相对复杂,包括了基础价格、折扣、运费的计算,然后每一块内容都会有比较复杂的业务逻辑。

基于此设计了OrderProcessor结构体,其中的CalculateTotalPrice 实现商品价格的计算,设计了ShippingCalculator 来计算运费,同时还设计DiscountCalculator 来计算商品的折扣信息,通过这几部分的交互配合,共同来完成商家价格的计算。

image.png

下面我们通过一段代码来展示上面的计算流程:

type OrderProcessor struct {
        discountCalculator DiscountCalculator
        taxCalculator      TaxCalculator
}

// 计算总价格
func (tpc OrderProcessor) CalculateTotalPrice(products []Product) float64 {
        total := 0.0
        for _, item := range cart {
                // 获取商品的基础价格
                basePrice := item.BasePrice
                // 获取适用于商品的折扣
                discount := tpc.discountCalculator.CalculateDiscount(item)
                // 计算运费
                shippingCost := tpc.shippingCalculator.CalculateShippingCost(item)
                // 计算商品的最终价格(基础价格 - 折扣 + 税费 + 运费)
                finalPrice := basePrice - discount + shippingCost
                total += finalPrice
        }
        return total
}

// 运费计算
type ShippingCalculator struct {}
func (sc ShippingCalculator) CalculateShippingCost(product Product) float64 {
     return 0.0
}

// 折扣计算
type DiscountCalculator struct {}
func (dc DiscountCalculator) CalculateDiscount(product Product) float64 {
      return 0.0 
}

如果这里需求没有发生变化,这个流程可以很好得运转下去。假设这里需要根据商品的类型来应用不同的折扣,之后要怎么支持呢,可以对变化的部分抽取出一个接口,也可以不抽取,都可以支持,我们比较一下没有使用接口和使用接口的两种实现方式的区别。

3.2 不抽象接口

首先是不使用接口的实现,这里我们直接在DiscountCalculator 中叠加逻辑,支持不同类型商品的折扣:

type DiscountCalculator struct{}

func (dc DiscountCalculator) CalculateDiscount(product Product) float64 {
        // 根据商品类型应用不同的折扣逻辑
        switch product.Type {
        case "TypeA":
                return dc.calculateTypeADiscount(product)
        case "TypeB":
                return dc.calculateTypeBDiscount(product)
        default:
                return dc.calculateDefaultDiscount(product)
        }
}

func (dc DiscountCalculator) calculateTypeADiscount(product Product) float64 {
        // 计算 TypeA 商品的折扣
        return product.BasePrice * 0.1 // 例如,假设 TypeA 商品有 10% 的折扣
}

func (dc DiscountCalculator) calculateTypeBDiscount(product Product) float64 {
        // 计算 TypeB 商品的折扣
        return product.BasePrice * 0.15 // 例如,假设 TypeB 商品有 15% 的折扣
}

func (dc DiscountCalculator) calculateDefaultDiscount(product Product) float64 {
        // 默认折扣逻辑,如果商品类型未匹配到其他情况
        return product.BasePrice // 默认不打折
}

在这里,我们计算商品折扣,直接使用DiscountCalculator 来实现,根据商品的类型应用不同的折扣逻辑。这里使用了 switch 语句来确定应该应用哪种折扣。这种实现方式虽然在一个类中处理了所有的逻辑,但它可能会导致 DiscountCalculator 类变得庞大且难以维护,特别是当折扣逻辑变得更加复杂或需要频繁更改时。

3.3 抽象接口

下面我们给出一个使用接口的实现,将不同的折扣逻辑封装到不同的实现中,以下是使用接口的示例实现:

type OrderProcessor struct {
        // 计算商品价格,直接依赖接口
        discountCalculator DiscountCalculatorInterface
        taxCalculator      TaxCalculator
        shippingCalculator ShippingCalculator
}

// 定义折扣计算器接口
type DiscountCalculatorInterface interface {
        CalculateDiscount(product Product) float64
}

// 定义一个具体的折扣计算器实现
type TypeADiscountCalculator struct{}

func (dc TypeADiscountCalculator) CalculateDiscount(product Product) float64 {
        // 计算 TypeA 商品的折扣
        return product.BasePrice * 0.1 // 例如,假设 TypeA 商品有 10% 的折扣
}

// 定义另一个具体的折扣计算器实现
type TypeBDiscountCalculator struct{}

func (dc TypeBDiscountCalculator) CalculateDiscount(product Product) float64 {
        // 计算 TypeB 商品的折扣
        return product.BasePrice * 0.15 // 例如,假设 TypeB 商品有 15% 的折扣
}

上述示例中,我们定义了一个 DiscountCalculatorInterface 接口以及两个不同的折扣计算器实现:TypeADiscountCalculatorTypeBDiscountCalculatorOrderProcessorWithInterface 结构体依赖于 DiscountCalculatorInterface 接口,这使得我们可以根据商品的类型轻松切换不同的折扣策略。

3.4 实现对比

下面我们通过比较上面两种实现,探讨在识别出系统的变化后,让系统依赖一个接口,相对于依赖一个具体类的优点。

首先是对于系统的可扩展性,假设现在需要支持新的类型的折扣,如果引入了接口,只需实现新的折扣计算器并满足相同的接口要求,就可以完成预期的功能。如果我们还是依赖一个具体的类,此时要么在DiscountCalculator 中通过if...else 叠加业务逻辑,相对于接口的引入,代码的可扩展性相比接口的使用就大大降低了。

对于系统的可测试性,如果是定义了接口,我们不需要验证其他DiscountCalculator 的实现,只需要验证当前新增的处理器即可。如果是依赖一个具体的类,此时如果进行测试,就需要对所有分支进行覆盖,很容易疏漏。其次,我们也可以轻松模拟不同的折扣计算器实现,验证 OrderProcessor 的行为。

还有代码可读性和可维护性,接口提供了一种清晰的契约,我们可以将DiscountCalculator当作一个小的模块,OrderProcessor通过接口与该模块进行交互,这使得代码更易于理解和维护,因为接口充当了文档,明确了每个模块的预期行为。

最后,通过接口的定义,OrderProcessor将不再依赖具体的类,而是依赖一个抽象层,降低了系统的耦合度,不再需要关注折扣的计算,让折扣的计算变得更加灵活。

通过以上的讨论,我们认为如果识别出了系统的变化后,该模块可能存在多个不同方向的变化,应该尽量抽取出一个接口,这样能够提高系统的可扩展性,可测试性,代码的可读性以及可维护性都有一定程度的提高。

4. 何时使用接口

接口可以给我们带来一系列的优点,如松耦合,隔绝变化,提高代码的可扩展性等,但是滥用接口的话,反而会引入不必要的复杂性,并增加代码的理解和维护成本。

有一个核心的准则,尽量支持依赖具体的类,而不是抽取接口,不要为了使用接口而创造不必要的抽象,这可能会使代码变得混乱和难以理解。

如果真的使用接口,应该确定其在系统设计中起到促进松耦合和可维护性的作用,而不是增加复杂性。要在合适的场景下使用接口,并考虑接口设计的清晰性和可维护性。下面基于此,我们讨论一些接口可能适用的场景。

4.1 系统中存在变化部分

系统中存在变化的部分是使用接口的最核心场景之一 使用接口可以将这些变化部分从系统的其他部分隔离开来,使系统更具灵活性和可维护性。这种设计允许我们将变化的部分抽取为一个单独的模块,在变化时,只需要对该模块进行修改,而不必修改整个系统。接口充当了变化部分的契约,使不同的实现可以轻松地替换或添加,从而适应新的需求或变化的情况。

比如系统需要向用户发送邮件,可能不同的运营商提供了不同的API,然后我们系统中需要支持多个不同的运营商,在不同场景下使用不同运营商的接口。

此时我们通过定义接口,系统通过与该接口进行交互即可,而不需要关心底层的实现细节。如果将来要添加新的邮件服务提供商,只需创建一个新的类并实现接口即可,而不需要修改现有的代码。

这种方式使系统的变化部分与其余部分隔离开来,提高了系统的可维护性和可扩展性。此外,通过使用接口,我们可以创建模拟邮件发送器来验证系统的行为,更容易进行单元测试。

4.2 类库的可配置性

类库对外扩展和提供可配置性也是接口使用的重要场景之一。当开发一个类库或框架时,为了让用户能够轻松地扩展和自定义其行为,可以通过接口提供一组可配置的扩展点。这些扩展点允许用户提供自己的实现,以适应其特定需求。

举例来说,一个日志库可以定义一个接口 Logger,并允许用户提供他们自己的 Logger 实现。用户可以选择使用默认的日志记录实现,也可以创建一个自定义的实现,以将日志信息发送到不同的地方(例如文件、数据库、远程服务器等)。这种可配置性使用户能够根据其项目的要求自由选择和调整库的行为。

通过提供接口和可配置性,类库或框架可以更具通用性和灵活性,使用户能够根据其特定的用例和需求来定制和扩展库的功能,从而提高了库的可用性和适用性。这种模块化的设计方式有助于减少代码的重复,促进了代码的复用,同时也提供了更好的可扩展性和可维护性。

4.3 模块间的交互

系统划分不同模块并使用接口来进行交互也是一个重要的场景。当将系统划分为不同的模块或组件时,使用接口定义模块之间的契约和互动方式是一种良好的实践。每个模块可以实现所需的接口,并与其他模块进行交互,这使得模块之间的界限更加清晰,易于理解和维护。

使用接口可以降低模块之间的耦合度。这意味着每个模块不需要关心其他模块的具体实现细节,只需要遵循接口定义的契约。这种模块化的设计方式有助于将复杂的系统拆分为更小、更易管理的部分,并降低了系统开发和维护的复杂性。

4.4 单元测试的使用

在需要解除一个庞大的外部系统的依赖时。有时候我们并不是需要多个选择,而是某个外部依赖过重,我们测试或其他场景可能会选择 mock 一个外部依赖,以便降低测试系统的依赖。

比如依赖多个外部rpc,单元测试时需要屏蔽外部的依赖,此时就比较有必要使用接口,通过框架生成一个mock的实现,从而解除对外部的依赖。

5. 潜在的误用和滥用

5.1 接口滥用带来的问题

虽然接口在合适的场景中非常有用,但滥用接口可能会导致代码变得复杂、难以理解和难以维护。引入过多的接口可能会增加系统的复杂性,使代码难以理解。每个接口都需要额外的抽象和实现,这可能不是必要的。其次使用接口有时会引入额外的性能开销,因为运行时需要进行接口解析。在性能敏感的应用中,这可能是一个问题。

最重要的一个问题,接口的目标是提供一种通用的抽象,给系统提供可配置项,但有时候过度一般化可能会导致不必要的复杂性。在某些情况下,直接使用具体的类可能更加简单和清晰。

我们应该在确保接口是必要的情况下使用它们,以避免不必要的复杂性和耦合。接口的设计应该基于真正的需求和系统架构,而不是仅仅为了使用接口而使用接口。

5.2 如何识别接口是否滥用

对于识别接口是否滥用,可以通过下面几个方面来检查,如果满足了下面的某一个条件,此时大概率就出现了接口滥用的情况。

是否过早的抽象,在引入该接口时,系统中是否足够的不同实现来正当地支持这些接口。如果没有的话,此时大概率过早接口的引入,增加了复杂性,而不带来真正的好处。

是否所有类之间引入接口,无论是否有必要,在这种情况下,接口的数量可能会急剧增加,导致代码难以理解和维护,可能还是存在一定滥用的情况。

如果接口经常发生变化,那么实现这些接口的类可能需要频繁地进行修改,这会增加维护的难度,此时要么接口是不必要的,要么接口的设计是不合理的,需要重新设计。

总的来说, 我们需要确保真正需要接口时才引入它们。应该谨慎考虑每个接口的设计,确保它们具有明确的用途(如隔绝变化,模块间交互的契约,方便单元测试),并且不引入不必要的复杂性。根据实际需求和系统架构来合理地使用接口,而不是为了使用接口而使用接口。

6. 总结

在本文,我们介绍了什么是接口,接口是一种契约,一种协议,用于模块间的交互。

在此基础上,通过一个例子来介绍接口的优点,了解到接口可以提高代码的可扩展性,可维护性,以及降低系统之间的耦合度。

但是接口也不是任何场景都可以随意使用的,我们会介绍接口使用的常见场景,包括隔绝系统的变化部分,以及一些类库设计时对外提供配置项的场景。

最后我们还介绍了接口滥用可能带来的问题,以及一些比较明显的特征,帮助我们更早识别出系统设计的坏味道。

基于此,完成了对接口的完整介绍,希望对你有所帮助。