← 返回蜂巢洞察

深入探讨行为模式:访问者设计模式及其在复杂对象结构中的应用

几乎每一个正在发展的软件系统中都会出现这样一个问题,而大多数开发人员直到问题造成了实际的损害之后才意识到自己遇到了它。 你有一组对象:它们具有不同的类型、形状和数据结构。而在某个时刻,会有人要求你对这些对象执行某种操作,比如将它们导出为PDF格式、向它们发送通知、生成报告或计算相关费用。 你的第一反应可能是编写一个函数,根据对象的类型来决定执行哪些操作,例如使用if-else语句或switch结构。这样的代码逻辑是:如果这个对象是NewUser类型,就执行这个操作;如果是JointAccountUser类型,就执行另一个操作。这种方法确实有效,你将其实现后,大家都很满意。 然而,后来又出现了新

几乎每一个正在发展的软件系统中都会出现这样一个问题,而大多数开发人员直到问题造成了实际的损害之后才意识到自己遇到了它。

你有一组对象:它们具有不同的类型、形状和数据结构。而在某个时刻,会有人要求你对这些对象执行某种操作,比如将它们导出为PDF格式、向它们发送通知、生成报告或计算相关费用。

你的第一反应可能是编写一个函数,根据对象的类型来决定执行哪些操作,例如使用if-else语句或switch结构。这样的代码逻辑是:如果这个对象是NewUser类型,就执行这个操作;如果是JointAccountUser类型,就执行另一个操作。这种方法确实有效,你将其实现后,大家都很满意。

然而,后来又出现了新的操作需求。每次遇到新的需求时,你都会回到原来的代码结构中,添加新的分支。这样一来,函数的结构会变得越来越复杂,相关类也会越来越多,测试用例的数量也会急剧增加。最初简洁明了的设计方案,最终变成了一个“万能对象”,它似乎能够为所有类型的对象执行所有的操作。

访问者设计模式的存在,正是为了彻底打破这种恶性循环。

目录

什么是访问者设计模式?

访问者模式是一种行为设计模式,它允许你在不修改对象本身的情况下,为某一类对象定义新的操作。

这里的关键词是“行为”。行为设计模式关注的是对象之间如何进行通信以及责任是如何分配的。与创建型设计模式关注对象的创建方式不同,结构型设计模式关注对象之间的组合关系,而行为设计模式则关注对象之间的交互方式以及谁应该负责执行哪些操作。

访问者模式专门用于解决这样一个问题:当某个操作需要在多种类型的对象上执行不同的逻辑时,应该由哪个对象来负责这个操作呢?

传统的解决方法是在每个对象上都添加相应的操作方法,让每个类都拥有处理自己类型对象的操作功能。但这种方法一旦遇到多个操作需求,就会变得不可行,因为每次新增一个操作,就意味着需要修改所有的相关类。这种做法会导致整个代码结构变得极其复杂。

访问者模式恰恰改变了这种处理方式。它并没有将各种操作分散到各个对象中,而是将这些操作集中到一个被称为“访问者”的组件中。各个对象只需接受这个访问者,并让它完成自己的工作即可。每当需要添加新的操作时,就只需要创建一个新的访问者组件而已,现有的对象本身根本不需要发生任何变化。

这正是“开闭原则”所期望发挥的作用:允许扩展,但禁止修改。

访问者模式所要解决的问题

让我来具体说明一下,如果没有使用访问者模式,会是什么情况。

假设你有一个金融科技平台,平台上有多种类型的用户:现有客户、新客户、未成年账户持有者以及联名账户持有者。你的产品经理要求为你们的产品添加文档导出功能,而且每种类型的用户都应该能够将文档导出为PDF、Excel或CSV格式。

如果不使用访问者模式,那么实现这个功能的自然方式会是这样的:

class ExistingUser {
  final int id;
  final String firstName;
  final String lastName;
  final DateTime lastPaymentDate;
  final num accountBalance;

  String exportToPdf() {
    return '$firstName\n$lastName\n$lastPaymentDate\n$accountBalance';
  }

  String exportToExcel() {
    return '$firstName,$lastName,$lastPaymentDate,$accountBalance';
  }

  String exportToCsv() {
    return '"$firstName","$lastName","$lastPaymentDate","$accountBalance"';
  }
}

对于新客户、未成年账户持有者以及联名账户持有者,也需要分别编写类似的代码。就这样,仅仅为了实现文档导出功能,就有十二个方法分散在四个不同的类中。

后来产品经理又提出了新的需求:需要为用户提供电子邮件通知、短信通知以及推送通知功能。于是你又不得不再次修改这四个类,每个类都需要增加三个新的方法。这样一来,同样的四个类中又增加了十二个方法。

随后他们还要求实现费用计算功能以及进行客户身份验证状态检查。每当有新的功能需求出现时,这些操作都会应用到所有类型的用户身上。因此,相关类的数量不断增加,需要修改的地方也越来越多,测试工作也变得极其繁琐。

而这恰恰就是访问者模式被设计出来所要解决的问题。

核心组件

访问者模式由四个核心组件构成。在查看代码之前先了解这些组件的功能,会让你更容易理解整个模式的实现原理。

访问者接口

这是所有访问者组件都必须实现的契约。它规定了针对每种需要被访问的对象类型,都需要定义一个相应的方法。例如,如果一个访问者组件需要处理四种类型的用户,那么它就需要定义四个方法,分别对应这四种类型。

具体实现类

这些就是访问者接口的具体实现类。每个实现类都负责执行某一项具体的操作,并且知道如何处理所有类型的对象。例如, PdfHandler就是一个具体实现类,ExcelHandler也是一个具体实现类,SmsNotificationHandler同样如此。每一个实现类都有其特定的职责,并且能够为所有类型的用户完成相应的操作。

消费者接口(也称为元素或接受者接口)

这是层次结构中每个对象都必须实现的接口。该接口定义了一个名为`accept`的方法,该方法接收一个`Visitor`对象,并调用相应的访问方法来处理该对象。正是这种“双重调度机制”使得这一设计模式能够正常运作。

具体消费者对象

这些才是层次结构中的实际对象:`ExistingCustomers`、`NewCustomers`、`MinorCustomer`和`JointCustomer`。每一个这类对象都会通过调用与其自身类型相匹配的访问方法来实现`accept`接口。

可以这样理解:`Visitor`接口被所有需要执行特定操作的类所实现,比如`PdfHandler`、`ExcelHandler`和`CsvHandler`;这些类都知道如何处理这四种类型的用户对象。

而`Consumer`接口则被层次结构中的每一个对象所实现,包括`ExistingCustomers`、`NewCustomers`、`MinorCustomer`和`JointCustomer`;它们都知道如何接收`Visitor`对象,并将其转发到正确的处理方法中。

当你调用`existingCustomer.accept(pdfHandler)`时,`ExistingCustomers`会自动调用`pdfHandler.visitExistingCustomer(this)`,并将自身作为参数传递给该方法。正确的处理方法会自动被执行,整个过程中不需要进行类型检查、条件判断或switch语句操作。对象只需告诉访问者自己的身份,访问者就会知道该如何利用这些信息来完成任务。

现实世界案例一:文档导出

这是一个来自金融科技平台的真实场景:系统中存在四种类型的用户,它们具有不同的数据结构,但都需要将自身的信息导出为PDF、Excel和CSV三种格式。

步骤1:定义用户模型

class ExistingUser {
  final int id;
  final String firstName;
  final String lastName;
  final DateTime lastPaymentDate;
  final num accountBalance;

  const ExistingUser({
    required this.id,
    required this.firstName,
    required this.lastName,
    required this.lastPaymentDate,
    required this.accountBalance,
  });
}

class NewUser {
  final String firstName;
  final String lastName;

  const NewUser({
    required this.firstName,
    required this.lastName,
  });
}

class MinorAccountUser {
  final int age;
  final int guardianId;
  final String firstName;
  final String lastName;
  final String guardianName;

  const MinorAccountUser({
    required this.age,
    required this.guardianId,
    required this.firstName,
    required this.lastName,
    required this.guardianName,
  });
}

class JointAccountUser {
  final int jointAccountId;
  final List accountHoldersInfo;
  final num accountBalance;

  const JointAccountUser({
    required this.jointAccountId,
    required this.accountHoldersInfo,
    required this.accountBalance,
  });
}

我们这里定义了四种用户模型,每种模型只包含自身所需的数据,不包含任何导出逻辑或通知机制,也不涉及任何业务操作。这些仅仅是纯粹的数据结构而已。

正是应该这样。模型的作用就是存储数据,而访问者的职责则是对这些数据进行处理。

步骤2:定义访问者与消费者接口

abstract class UserVisitor {
  T visitExistingCustomer(ExistingUser user);
  T visitNewCustomer(NewUser user);
  T visitMinorCustomer(MinorAccountUser user);
  T visitJointCustomer(JointAccountUser user);
}

abstract class UserConsumer {
  T accept(UserVisitor visitor);
}

UserVisitor是一个泛型类。类型参数T表示访问者方法返回的值类型:文档导出功能对应的访问者会返回一个字符串,费用计算功能对应的访问者可能会返回一个双精度浮点数,验证功能对应的访问者则可能返回一个布尔值。任何类型的返回值都可以使用这种模式。

UserConsumer类定义了accept方法。层次结构中的每一个对象都必须实现这个方法。正是accept方法使得双 dispatch机制能够正常工作——对象接收访问者对象后,会立即调用相应的方法,并将自己作为参数传递给该方法。

步骤3:实现具体的消费者类

class ExistingCustomers implements UserConsumer {
  final ExistingUser user;
  ExistingCustomers({required this.user});

  @override
  T accept(UserVisitor visitor) {
    return visitor.visitExistingCustomer(user);
  }
}

class NewCustomers implements UserConsumer {
  final NewUser user;
  NewCustomers({required this.user});

  @override
  T accept(UserVisitor visitor) {
    return visitor.visitNewCustomer(user);
  }
}

class MinorCustomer implements UserConsumer {
  final MinorAccountUser user;
  MinorCustomer({required this.user});

  @override
  T accept(UserVisitor visitor) {
    return visitor.visitMinorCustomer(user);
  }
}

class JointCustomer implements UserConsumer {
  final JointAccountUser user;
  JointCustomer({required this.user});

  @override
  T accept(UserVisitor visitor) {
    return visitor.visitJointCustomer(user);
  }
}

每个消费者类都负责处理某种类型的用户数据,并通过调用相应的访问者方法来完成自己的功能。具体来说,这些消费者类会判断自己所处理的用户类型是什么,然后通过调用正确的访问者方法来执行相应的操作。

需要注意的是,这些类中没有任何一个与PDF、Excel、CSV、电子邮件、短信或任何具体的数据处理操作有关联。它们完全独立于将来可能对这些数据进行的各种操作。

步骤4:实现具体的访问者类

class PdfHandler implements UserVisitor {
  @override
  String visitExistingCustomer(ExistingUser user) {
    return '${user.firstName} ${user.lastName}'
        '\n余额: ${user.accountBalance}'
        '\n最后一次付款日期: ${user.lastPaymentDate}`;
  }

  @override
  String visitNewCustomer(NewUser user) {
    return '${user.firstName} ${user.lastName)';
  }

  @override
  String visitMinorCustomer(MinorAccountUser user) {
    return '${user.firstName} ${user.lastName}'
        '\n年龄: ${user.age}'
        '\n监护人: ${user.guardianName} (ID: ${user.guardianId})';
  }

  @override
  String visitJointCustomer(JointAccountUser user) {
    final holders = user.accountHoldersInfo.join(', ');
    return '联合账户编号: ${user.jointAccountId}'
        '\n账户持有人: $holders'
        '\n余额: ${user.accountBalance}`;
  }
}

class ExcelHandler implements UserVisitor {
  @override
  String visitExistingCustomer(ExistingUser user) {
    return '${user.firstName}\t${user.lastName}'
        '\t${user.accountBalance}\t${user.lastPaymentDate)';
  }

  @override
  String visitNewCustomer(NewUser user) {
    return '${user.firstName}\t${user.lastName}`;
  }

  @override
  String visitMinorCustomer(MinorAccountUser user) {
    return '${user.firstName}\t${user.lastName}'
        '\t${user.age}\t${user.guardianName}\t${user.guardianId)';
  }

  @override
  String visitJointCustomer(JointAccountUser user) {
    final holders = user.accountHoldersInfo.join('\t');
    return '${user.jointAccountId}\t$holders\t${user.accountBalance}`;
  }
}

class CsvHandler implements UserVisitor {
  @override
  String visitExistingCustomer(ExistingUser user) {
    return '"${user.firstName}","${user.lastName}"'
        ',"${user.accountBalance}","${user.lastPaymentDate}"';
  }

  @override
  String visitNewCustomer(NewUser user) {
    return '"${user.firstName}","${user.lastName}"';
  }

  @override
  String visitMinorCustomer(MinorAccountUser user) {
    return '"${user.firstName}","${user.lastName}"'
        ',"${user.age}","${user.guardianName}","${user.guardianId}"';
  }

  @override
  String visitJointCustomer(JointAccountUser user) {
    final holders = user.accountHoldersInfo.map((h) => '"$h"').join(',');
    return '"${user.jointAccountId}",$holders,"${user.accountBalance}"';
  }
}
每个处理类都实现了“访问者接口”,并且清楚地知道如何根据特定的文档格式来处理不同类型的用户数据。PdfHandler使用换行符和标签来进行格式化;ExcelHandler则使用制表符;CsvHandler则会将数值用引号括起来,并用逗号进行分隔。 每种文档类型的格式化逻辑都仅存在于一个类中。如果PDF格式发生了变化,只需要修改PdfHandler即可;同样地,如果CSV格式发生变化,也只需修改CsvHandler即可。用户模型本身永远不会发生任何改变。

步骤5:实际应用

void existingUserLogic() { final customer = ExistingCustomers( user: ExistingUser( id: 10, firstName: 'Oluwaseyi', lastName: 'Fatunmole', lastPaymentDate: DateTime.now(), accountBalance: 7373773.39, ), ); final pdf = customer.accept(PdfHandler()); final excel = customer.accept(ExcelHandler()); final csv = customer.accept(CsvHandler()); print('PDF:\n'$pdf\n'); print('Excel:\n'$excel\n); print('CSV:\n'$csv\n); } void newUserLogic() { final customer = NewCustomers( user: NewUser( firstName: 'Oluwaseyi', lastName: 'Fatunmole', ), ); customer.accept(PdfHandler()); customer.accept(ExcelHandler()); customer.accept(CsvHandler()); } void minorUserLogic() { final customer = MinorCustomer( user: MinorAccountUser( age: 15, guardianId: 82882, firstName: 'Oluwaseyi', lastName: 'Fatunmole', guardianName: 'Inioluwa', ), ); customer.accept(PdfHandler()); customer.accept(ExcelHandler()); customer.accept(CsvHandler()); } void jointUserLogic() { final customer = JointCustomer( user: JointAccountUser( jointAccountId: 92, accountHoldersInfo: [ 'Oluwaseyi', 'Aderonke', 'Inioluwa', 'Tiwaloluwa', ], accountBalance: 9200020202.22, ), ); customer.accept(PdfHandler()); customer.accept(ExcelHandler()); customer.accept(CsvHandler()); } 同一个客户对象,只需通过相同的调用方法,就可以使用任何合适的处理类来进行数据格式化。类型判别是通过`accept`方法自动完成的;在调用代码中没有任何类型检查语句,也没有任何`if-else`或`switch`结构。只需要编写`customer.accept(handler)`这样的代码,相应的方法就会被自动执行。 现在想象一下,如果需要添加XML导出功能会怎么样。你只需创建一个新的类`XmlHandler`,实现相应的处理方法即可,而无需修改现有的`ExistingUser`、`NewUser`、`MinorAccountUser`、`JointAccountUser`类或任何现有的处理类。这样的系统确实具有很好的扩展性,同时也非常便于维护。

实际应用案例二:通知系统

在这里,我们仍然使用相同的四种用户类型和相同的处理模式,但所执行的操作却完全不同。 如果你的平台需要向用户发送关于账户状态变化的通知,那么并不是所有类型的用户都应该以相同的方式接收这些通知。现有用户会收到电子邮件和推送通知;新用户由于尚未完成个人资料的设置,因此仅会收到电子邮件。使用小号的用户,其通知会发送到他们的监护人的手机号码上;而联合账户的使用者,则会通过所有可用的渠道收到通知,因为这个账户是由多人共同使用的。

如果没有“访问者模式”,这种逻辑会分散到所有四个用户模型中,或者凝聚成一个充满类型检查的庞大函数。而使用“访问者模式”后,这些逻辑被集中到了三个专门的类中。

通知访问者接口

abstract class NotificationVisitor {
  void visitExistingCustomer(ExistingUser user);
  void visitNewCustomer(NewUser user);
  void visitMinorCustomer(MinorAccountUser user);
  void visitJointCustomer(JointAccountUser user);
}

这些访问者方法返回的是`void`,因为通知本质上只是副作用——它们用于发送消息,并不返回任何值。

具体的通知访问者实现

class EmailNotificationHandler implements NotificationVisitor {
  @override
  void visitExistingCustomer(ExistingUser user) {
    print('正在给现有客户发送电子邮件:${user.firstName}`);
    // 使用用户的完整账户信息发送邮件
  }

  @override
  void visitNewCustomer(NewUser user) {
    print('正在给新客户发送欢迎邮件:${user.firstName}`);
    // 发送包含入门指南的欢迎邮件
  }

  @override
  void visitMinorCustomer(MinorAccountUser user) {
    print('正在给未成年用户的监护人发送电子邮件:${user.guardianName}`);
    // 邮件会发送给监护人的注册号码
  }

  @override
  void visitJointCustomer(JointAccountUser user) {
    for (final holder in user.accountHoldersInfo) {
      print('正在给联合账户的持有者发送电子邮件:$holder');
      // 所有账户持有者都会收到通知
    }
  }
}

class SmsNotificationHandler implements NotificationVisitor {
  @override
  void visitExistingCustomer(ExistingUser user) {
    print('正在给现有客户发送短信:${user.firstName}`);
  }

  @override
  void visitNewCustomer(NewUser user) {
    // 新用户尚未完成短信验证,跳过此步骤
    print('新客户${user.firstName}尚未符合短信通知条件');
  }

  @override
  void visitMinorCustomer(MinorAccountUser user) {
    print('正在给未成年用户的监护人${user.guardianName}发送短信:${user.firstName}`);
    // 邮件会发送到监护人的注册号码
  }

  @override
  void visitJointCustomer(JointAccountUser user) {
    for (final holder in user.accountHoldersInfo) {
      print('正在给联合账户的持有者发送短信:$holder');
    }
  }
}

class PushNotificationHandler implements NotificationVisitor {
  @override
  void visitExistingCustomer(ExistingUser user) {
    print('正在给现有客户发送推送通知:${user.firstName}`);
  }

  @override
  void visitNewCustomer(NewUser user) {
    print('正在给新客户发送推送通知:${user.firstName}`);
  }

  @override
  void visitMinorCustomer(MinorAccountUser user) {
    // 未成年用户尚未安装相关应用,因此通知会发送给监护人
    print('正在给未成年用户的监护人${user.guardianName}发送推送通知:${user.firstName}`);
  }

  @override
  void visitJointCustomer(JointAccountUser user) {
    for (final holder in user.accountHoldersInfo) {
      print('正在给联合账户的持有者发送推送通知:$holder');
    }
  }
}
每个处理程序都了解针对不同用户类型的特定规则。SmsNotificationHandler知道新注册的用户尚未完成短信验证;PushNotificationHandler明白未成年账户的通知会发送给监护人;而EmailNotificationHandler则清楚联合账户的持有者都需要分别收到通知。 这种业务逻辑在每个通知渠道中都只存在于一个地方。当规则发生变化时(而规则总是在变化的),你只需要更新其中一个类即可。

使用通知处理类

void notifyExistingUser() {
  final customer = ExistingCustomers(
    user: ExistingUser(
      id: 10,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      lastPaymentDate: DateTime.now(),
      accountBalance: 7373773.39,
    ),
  );

  customer.accept(EmailNotificationHandler());
  customer.accept(SmsNotificationHandler());
  customer.accept(PushNotificationHandler());
}

void notifyMinorUser() {
  final customer = MinorCustomer(
    user: MinorAccountUser(
      age: 15,
      guardianId: 82882,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      guardianName: 'Inioluwa',
    ),
  );

  // 三个通知渠道都会被触发,且每个渠道都遵循针对未成年用户的特定规则
  customer.accept(EmailNotificationHandler());
  customer.accept(SmsNotificationHandler());
  customer.accept(PushNotificationHandler());
}

void notifyJointUser() {
  final customer = JointCustomer(
    user: JointAccountUser(
      jointAccountId: 92,
      accountHoldersInfo: [
        'Oluwaseyi',
        'Aderonke',
        'Inioluwa',
        'Tiwaloluwa',
      ],
      accountBalance: 9200020202.22,
    ),
  );

  customer.accept(EmailNotificationHandler());
  customer.accept(SmsNotificationHandler());
  customer.accept(PushNotificationHandler());
}
无论用户类型或通知渠道如何,调用代码都是完全相同的。整个通知处理过程是自动完成的,而这些规则都存储在相应的处理类中。 当WhatsApp通知成为必要功能时(而这肯定会发生),你只需要创建一个包含四个处理方法的WhatsAppNotificationHandler类即可,其他部分都不需要做任何修改。

现实世界案例三:费用计算

同样,这里仍然有四种类型的用户和相同的处理模式,但具体的操作流程却完全不同。 你的平台需要计算每月的维护费用,但不同类型的用户适用不同的收费规则: 现有客户需根据其账户余额支付固定的月费;新注册的客户在前三个月内可免交费用;未成年账户持有者因为账户功能受限,所以需要支付较低的费率;而联合账户的所有持有者则需要平摊这笔费用。 如果没有使用这些处理类,这些逻辑要么会被写成一个结构复杂的方法,其中包含多个分支语句,要么就会直接混杂在用户模型中。但使用了这些处理类之后,所有相关逻辑都被集中存储在一个专门的类里了。

费用计算接口

abstract class FeeVisitor {
  double visitExistingCustomer(ExistingUser user);
  double visitNewCustomer(NewUser user);
  double visitMinorCustomer(MinorAccountUser user);
  double visitJointCustomer(JointAccountUser user);
}

这个接口返回一个双精度数值,因为费用计算的结果本身就是数字形式。

具体的费用计算实现

class MonthlyFeeCalculator implements FeeVisitor {
  @override
  double visitExistingCustomer(ExistingUser user) {
    // 账户余额的0.5%,最低为500,最高为5000
    final fee = user.accountBalance * 0.005;
    return fee.clamp(500, 5000).toDouble();
  }

  @override
  double visitNewCustomer(NewUser user) {
    // 新客户在前三个月内免收费用
    return 0.0;
  }

  @override
  double visitMinorCustomer(MinorAccountUser user) {
    // 小账户的费用为固定金额
    return 150.0;
  }

  @override
  double visitJointCustomer(JointAccountUser user) {
    // 费用由所有账户持有人平分
    const standardFee = 2000.0;
    return standardFee / user.accountHoldersInfo.length;
  }
}

每种用户类型的费用规则都存储在这个类中。当现有客户的费用标准发生变化时,只需要修改这个类中的某个方法即可;小账户的费用调整也是如此。所有的用户模型都不会发生任何变化,其他相关的计算接口也不需要做任何修改。

使用费用计算接口

void calculateFees() {
  final existingCustomer = ExistingCustomers(
    user: ExistingUser(
      id: 10,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      lastPaymentDate: DateTime.now(),
      accountBalance: 7373773.39,
    ),
  );

  final newCustomer = NewCustomers(
    user: NewUser(
      firstName: 'Aderonke',
      lastName: 'Fatunmole',
    ),
  );

  final minorCustomer = MinorAccountUser(
    user: MinorAccountUser(
      age: 15,
      guardianId: 82882,
      firstName: 'Inioluwa',
      lastName: 'Fatunmole',
      guardianName: 'Oluwaseyi',
    ),
  );

  final jointCustomer = JointAccountUser(
    user: JointAccountUser(
      jointAccountId: 92,
      accountHoldersInfo: [
        'Oluwaseyi',
        'Aderonke',
        'Inioluwa',
        'Tiwaloluwa',
      ],
      accountBalance: 9200020202.22,
    ),
  );

  final calculator = MonthlyFeeCalculator();

  final existingFee = existingCustomer.accept(calculator);
  final newFee = newCustomer.accept(calculator);
  final minorFee = minorCustomer.accept(calculator);
  final jointFee = jointCustomer.accept(calculator);

  print('现有客户的费用:NGN $existingFee');
  print('新客户的费用:NGN $newFee');
  print('小账户的费用:NGN $minorFee';
  print('联合账户中每位持有人的费用:NGN $jointFee');
}

输出结果如下:

现有客户费用:5000奈拉  
新客户费用:0奈拉  
小账户费用:150奈拉  
联名账户每位持有者的费用:500奈拉

当需要为新的客户群体设计PremiumFeeCalculator时,只需创建一个实现FeeVisitor的新类即可。用户模型保持不变,MonthlyFeeCalculator也依然照旧,所有四个消费者的接受方法也都没有任何变化。

结合三种操作的力量

正是在这样的系统中,访问者模式才真正发挥了它的作用。你有相同的四种用户类型,可以在同一个调用链中对它们中的任意一种应用任意组合的访问者操作。

void processUser(UserConsumer customer) {
  final pdf = customer.accept(PdfHandler());
  final csv = customer.accept(CsvHandler());

  customer.accept(EmailNotificationHandler());
  customer.accept(PushNotificationHandler());

  final fee = customer.accept(MonthlyFeeCalculator());

  print('费用:$fee奈拉');
  print('文档已生成,通知也已发送');
}

一个函数,任意类型的用户,任意组合的操作。消费者并不关心自己会收到哪些访问者操作;访问者也不关心是哪些消费者调用了它们。它们通过接口进行交互,而这个接口能确保所有操作都能正确执行。

我们有三种完全不同的操作(文档导出、通知发送和费用计算),但它们都是通过对同一个对象应用相同的调用模式来完成的。这些操作彼此之间互不知情,也不会修改用户模型。每种操作都存在于自己独立的类中,且每个类都有其独特的存在目的。

何时使用访问者模式

当你拥有一组稳定的对象类型以及针对这些类型的不断增多的操作时,就可以使用访问者模式。

这种模式在对象层次结构不会频繁发生变化的情况下效果最为显著。它更适合用于添加新的操作,而不是新增对象类型。因为如果添加新的用户类型,就需要更新所有的访问者类;而如果对象类型本身在不断变化,那么使用访问者模式反而会增加工作量。

当你需要对一组对象执行多个无关的操作,同时又不想让这些逻辑污染到它们的原始类结构时,访问者模式也非常有效。例如文档导出、通知处理、费用计算和客户身份验证等操作,都属于这类情况。每种操作都应该由专门的访问者类来负责,而不应该分散在用户模型中。 当你希望数据与行为之间有清晰的划分时,访问者模式也是个不错的选择。用户模型用于存储数据,而访问者类则用于定义具体的操作逻辑。这样的设计使得代码更易于理解、测试和独立维护。

何时不应使用它

当对象层次结构经常发生变化时,应避免使用访问者模式。因为每次添加新的对象类型,都需要更新所有的访问者类。而在一个新用户类型不断出现的系统中,这种维护工作会迅速变得十分繁琐。

当只有寥寥一两个操作需要处理时,这种设计模式也并无帮助。对于简单的情况而言,创建访问者接口、消费者接口以及多个类的开销,其实并不足以带来相应的收益。

此外,当某些操作与对象的内部状态之间存在紧密的关联,且将这些操作放在一起处理确实更为合理时,也应避免使用这种设计模式。有些功能本质上就应该属于对象本身。

结论

访问者设计模式能够解决这样一个问题:大多数开发者往往直到把事情搞得一团糟之后,才会意识到这个问题的存在。当你面对由多种类型对象组成的系统时,如果这些对象需要执行一系列操作,而缺乏合理的结构设计,这些操作就会分散到模型、工具类以及那些令人望而却步的复杂switch语句中。

访问者模式将这些操作集中到同一个专门的类中,这样代码结构就能保持整洁,各种操作也能被有效地隔离。每当需要添加新的操作时,只需创建一个新的类即可,现有的代码无需做任何修改。

在上面的金融科技示例中,我们遇到了三种完全不同的需求:文档导出、通知发送以及费用计算。这些功能都是由专门的类来处理的,而这些类彼此之间没有任何关联。用户模型并不了解PDF文件、电子邮件或费用的相关信息;PdfHandler类与SMS通知无关;MonthlyFeeCalculator类也与推送通知无关。每个类的存在都有其明确的理由,其代码也需要根据具体需求进行相应的修改。

这就是正确应用访问者模式在实际开发中应该呈现的样子:代码结构清晰、功能专一且具备良好的扩展性。

祝编程愉快!

相关文章

技术实践

为什么二维结构中的囚禁离子量子计算机比一维结构的量子计算机更容易实现大规模扩展?

我仍然记得第一次在量子模拟器上运行贝尔态电路的情景。 那段代码只有几行,但它的效果实在令人惊叹——两个量子比特发生了纠缠,模拟器也给出了几乎完美的结果。后来,我把同样的电路应用到了真正的硬件设备上。 然而那种神奇的效果有所减弱。 输出结果仍然可以识别,但原本理想的50/50分布现在出现了误差,额外的错误也让电路的行为不再像我在本地测试时看到的理想状态那样。 那一刻让我明白了一个重要的道理:量子计算的未来不仅仅取决于更优秀的算法,还离不开更好的架构设计。 多年来,许多囚禁离子量子计算机都是基于 一维线性离子链 构建的。这类系统在门操作精度方面取得了行业内的领先水平,因此非常适合用于早期的量子计算

阅读全文
技术实践

如何利用“Outbox模式”来解决Node.js中的双写问题

想象一下,你正在构建一个电子商务平台,在这个平台上下订单时需要同时触发多个操作:必须通知仓库准备发货,电子邮件服务需要发送确认邮件,同时欺诈检测系统也需要审核这笔交易。 订单处理模块负责完成结账流程,将订单信息保存到数据库中,然后向消息队列发布一条 order.created 事件,这样下游的所有系统就可以各自独立地对这条事件作出响应。 这种设计非常常见且合理,但它存在一个可靠性问题——在生产环境中出现问题之前,这个问题往往不容易被察觉。 当顾客下订单并且支付成功后,应用程序需要执行两步操作:将订单信息保存到数据库中,以及向消息队列发布事件。这两项操作分别针对两个不同的系统进行,目前没有办法让

阅读全文
技术实践

如何构建Kubernetes操作器:开发人员手册

Kubernetes自带了一些控制器,用于管理一组内置资源,例如部署对象、服务、节点等等。 而“操作符”则将这种机制扩展到那些Kubernetes本身不识别的资源类型,使你能够以与管理系统中其他资源相同的方式来管理这些自定义的、通常是外部系统。 本指南分为四个部分:首先介绍什么是操作符,然后讲解操作符的内部结构,接着指导如何从零开始构建一个操作符,最后说明如何将其准备好投入生产环境使用。 目录 第1部分:简介 什么是操作符? 操作符、控制器与CRD的区别 为什么不直接使用Helm Chart、CronJob或脚本呢? 第2部分:操作符的内部结构 自定义资源 监控变化 管理器 协调循环 第3部分

阅读全文
技术实践

使用TRAE IDE进行多智能体编码与部署

现代软件工程正在发展成为一个由人工智能积极参与规划、编码和部署的生态系统。为了帮助弥合初始概念与最终上市产品之间的差距,我们刚刚在 freeCodeCamp.org 的YouTube频道上发布了一门由开发者兼课程创建者Ania Kubow制作的课程。 该教程以TRAE IDE为核心内容,这是一种专为人工智能设计的开发环境,具备双操作模式。在IDE模式下,它像一个标准的编辑器一样使用,同时提供智能代码补全和重构辅助功能;而在单独模式下,开发者可以用简单的英语描述开发目标,然后将整个实现过程委托给系统来处理,同时还能监督最终结果。 通过将一个简单的句子转化为一份完整的产品需求文档和结构化的任务列表

阅读全文