47、Scala 设计模式与架构实践

Scala 设计模式与架构实践

在软件开发中,设计模式是解决常见问题的通用方案,能提高代码的可维护性和复用性。Scala 作为一门融合了面向对象和函数式编程特性的语言,对各种设计模式有独特的实现方式。同时,设计契约和特定的架构风格也能帮助我们构建更健壮、高效的应用程序。

设计模式概述

设计模式记录了反复出现且广泛有用的思想,成为开发者交流的重要词汇。下面将介绍常见的设计模式及其在 Scala 中的应用。

1. 创建型模式

创建型模式主要用于对象的创建过程,以下是几种常见的创建型模式及其在 Scala 中的实现方式。
- 抽象工厂(Abstract Factory) :用于从类型族中构造实例,而无需显式指定类型。Scala 对象中的 apply 方法可用于此目的,它根据方法参数实例化适当类型的实例。 Monad.flatMap 传递的函数和 Applicative 定义的 apply 方法也对构造进行了抽象。
- 建造者(Builder) :将复杂对象的构造与其表示分离,以便同一过程可用于不同表示。Scala 中经典的例子是 collection.generic.CanBuildFrom ,它允许像 map 这样的组合方法构建与输入集合类型相同的新集合。
- 工厂方法(Factory Method) :定义一个方法,由子类重写(或实现)以决定实例化的类型和方式。 CanBuildFrom.apply 是用于构造可构建实例的构建器的抽象方法,子类和特定实例提供具体细节。 Applicative.apply 也提供了类似的抽象。
- 原型(Prototype) :从原型实例开始,通过可选修改复制它来构造新实例。Scala 的 case class 的 copy 方法是很好的例子,用户可以在指定更改参数的同时克隆实例。
- 单例(Singleton) :确保一个类型只有一个实例,并且该类型的所有用户都可以访问该实例。Scala 通过对象将此模式实现为语言的一等特性。

2. 结构型模式

结构型模式主要关注如何将类或对象组合成更大的结构,以下是几种常见的结构型模式及其在 Scala 中的应用。
| 模式名称 | 描述 | Scala 实现示例 |
| ---- | ---- | ---- |
| 适配器(Adapter) | 创建客户端期望的接口,包装另一个抽象,使后者可被客户端使用 | 如在实现观察者模式时,使用匿名函数作为适配器,减少依赖 |
| 桥接(Bridge) | 将抽象与其实现解耦,使它们可以独立变化 | 类型类是一个有趣的例子,不仅将抽象从可能需要它的类型中移除,还可以单独定义给定类型的类型类抽象的实现 |
| 组合(Composite) | 实例的树结构,代表部分 - 整体层次结构,对单个实例或组合进行统一处理 | 函数式代码倾向于使用通用结构(如树),提供统一访问和操作树的组合器 |
| 装饰器(Decorator) | 动态地为对象附加额外职责 | 类型类在编译时实现此功能,而 Dynamic 特征可用于运行时灵活性。 Monads Applicatives 也分别用于“装饰”值或计算 |
| 外观(Facade) | 为子系统中的一组接口提供统一接口,使子系统更易于使用 | 包对象支持此模式,它们可以只暴露应该公开的类型和行为 |
| 享元(Flyweight) | 使用共享来高效支持大量细粒度对象 | 函数式编程中对不可变性的强调使此模式易于实现,如持久数据结构 Vector |
| 代理(Proxy) | 提供另一个实例的代理,以控制对它的访问 | 包对象在粗粒度级别支持此目标,不可变实例不受客户端损坏的风险,因此控制需求降低 |

3. 行为型模式

行为型模式主要关注对象之间的交互和职责分配,以下是几种常见的行为型模式及其在 Scala 中的应用。
- 责任链(Chain of Responsibility) :避免发送者和接收者的耦合,允许一系列潜在接收者尝试处理请求,直到第一个成功为止。这正是模式匹配的工作方式,在 Akka 的 receive 块中描述更为贴切。
- 命令(Command) :将服务请求具体化,使请求可以排队,支持撤销、重播等。Akka 就是这样工作的,虽然目前不支持撤销和重播,但原则上可以实现。 Monad 常用于扩展此问题,以可预测的顺序编排“命令”步骤。
- 解释器(Interpreter) :定义一种语言和解释该语言中表达式的方式。DSL(领域特定语言)的概念在相关书籍之后出现,之前讨论过几种实现方法。
- 迭代器(Iterator) :允许遍历集合而不暴露实现细节,几乎所有与函数式容器的工作都是这样完成的。
- 中介者(Mediator) :通过使用中介者实现实例之间的交互,避免实例直接交互,使交互可以独立发展。 ExecutionContext 可被视为中介者的例子,Akka 演员之间的消息也由运行时系统中介。
- 备忘录(Momento) :捕获实例的状态,以便可以存储并在以后恢复该状态。纯函数使记忆化更容易,装饰器可用于添加记忆化功能。
- 观察者(Observer) :在主题和其状态的观察者之间建立一对多的依赖关系,当状态发生变化时通知观察者。
- 状态(State) :允许实例在其状态变化时改变其行为。当值不可变时,构造新实例来表示新状态。
- 策略(Strategy) :将相关算法族具体化,使它们可以互换使用。高阶函数使这变得容易,例如在调用 map 时,实际用于转换每个元素的“算法”由调用者选择。
- 模板方法(Template Method) :将算法的骨架定义为最终方法,调用其他可在子类中重写的方法以自定义行为。这是一种比较好的模式,比重写具体方法更有原则性和安全性。也可以将模板方法作为高阶函数,传入函数进行定制。
- 访问者(Visitor) :在实例中插入协议,使其他代码可以访问类型不支持的操作的内部。这是一个不太好的模式,因为它破坏了公共接口并使实现复杂化。可以通过定义 unapply unapplySeq 方法来定义低开销协议,模式匹配使用此功能提取值并实现新功能,类型类也是为现有类型添加新行为的方式。

以下是创建型模式的 mermaid 流程图:

graph LR
    A[抽象工厂] --> B[根据参数实例化]
    C[建造者] --> D[分离构造与表示]
    E[工厂方法] --> F[子类决定实例化]
    G[原型] --> H[复制原型实例]
    I[单例] --> J[确保唯一实例]
设计契约(Design by Contract)

设计契约(DbC)的思想是在模块之间定义契约,通过规定输入约束(前置条件)、结果约束(后置条件)和不变条件,确保程序的正确性。虽然 Scala 没有显式支持设计契约,但 Predef 中的 assert assume require 方法可用于此目的。

以下是一个使用设计契约的示例代码:

// src/main/scala/progscala2/appdesign/dbc/BankAccount.sc
case class Money(val amount: Double) {
  require(amount >= 0.0, s"Negative amount $amount not allowed")
  def +  (m: Money): Money = Money(amount + m.amount)
  def -  (m: Money): Money = Money(amount - m.amount)
  def >= (m: Money): Boolean = amount >= m.amount
}

case class BankAccount(balance: Money) {
  def debit(amount: Money) = {
    assert(balance >= amount,
      s"Overdrafts are not permitted, balance = $balance, debit = $amount")
    new BankAccount(balance - amount)
  }
  def credit(amount: Money) = {
    new BankAccount(balance + amount)
  }
}

可以使用以下脚本测试:

import scala.util.Try
Seq(-10, 0, 10) foreach (i => println(f"$i%3d: ${Try(Money(i))}"))
val ba1 = BankAccount(Money(10.0))
val ba2 = ba1.credit(Money(5.0))
val ba3 = ba2.debit(Money(8.5))
val ba4 = Try(ba3.debit(Money(10.0)))
println(s"""
  |Initial state: $ba1
  |After credit of $$5.0: $ba2
  |After debit of $$8.5: $ba3
  |After debit of $$10.0: $ba4""".stripMargin)

assert assume require 方法都有两个重载版本, assert assume 方法行为相同,失败时抛出 AssertionError ,编译时可通过 -Xelide-below ASSERTION 选项移除。 require 方法用于测试方法参数,失败时抛出 IllegalArgumentException ,其代码生成不受 -Xelide-below 选项影响。

帕台农神庙架构(The Parthenon Architecture)

在软件开发中,面向对象编程中的通用语言概念旨在促进团队成员之间的有效沟通,但在实现许多现实世界的领域概念时存在问题。功能性代码则具有简洁、精确的特点。帕台农神庙架构结合了四个层次,试图在获得通用语言好处的同时避免其缺点。

1. 架构层次
  • 通用语言的 DSL :用于声明用例,用户界面设计也属于此层,因为它也是一种沟通工具和语言。
  • DSL 库 :DSL 的实现,包括为一些领域概念实现的类型、用户界面等。
  • 用例逻辑 :实现每个用例的功能性代码,尽可能保持专注和简洁,主要依赖标准库类型和最少的领域导向类型。
  • 核心库 :Scala 标准库、Akka、Play、日志和数据库访问的 API 等,以及从用例实现中提取的任何可重用代码。
2. 架构特点

这种架构看起来像古典希腊神庙,核心库是神庙基础,用例实现是柱子,领域支持库(包括 DSL 实现和 UI)是柱顶过梁,用户编写的 DSL 代码是三角墙。虽然用例代码看起来拒绝重用,但实际上,简单的就地数据流逻辑易于理解、测试和演进,而且功能性代码通常较小,琐碎的重复不值得去除。

以下是帕台农神庙架构的 mermaid 流程图:

graph LR
    A[核心库] --> B[用例逻辑]
    C[DSL 库] --> B
    D[通用语言的 DSL] --> B
3. 示例代码

以下是一个使用工资外部 DSL 的示例,展示了如何实现两个用例:每个员工的工资单报告和工资周期的总计报告。

// src/main/scala/progscala2/appdesign/parthenon/PayrollUseCases.scala
package progscala2.appdesign.parthenon
import progscala2.dsls.payroll.parsercomb.dsl.PayrollParser
import progscala2.dsls.payroll.common._

object PayrollParthenon {
  val dsl = """biweekly {
      federal tax          %f  percent,
      state tax            %f  percent,
      insurance premiums   %f  dollars,
      retirement savings   %f  percent
    }"""

  private def readData(inputFileName: String): Seq[(String, Money, String)] =
    for {
      line <- scala.io.Source.fromFile(inputFileName).getLines.toVector
      if line.matches("\\s*#.*") == false  // skip comments
    } yield toRule(line)

  private def toRule(line: String): (String, Money, String) = {
    val Array(name, salary, fedTax, stateTax, insurance, retirement) =
      line.split("""\s*,\s*""")
    val ruleString = dsl.format(
      fedTax.toDouble, stateTax.toDouble,
      insurance.toDouble, retirement.toDouble)
    (name, Money(salary.toDouble), ruleString)
  }

  private val parser = new PayrollParser
  private def toDeduction(rule: String) =
    parser.parseAll(parser.biweekly, rule).get

  private type EmployeeData = (String, Money, Deductions)

  private def processRules(inputFileName: String): Seq[EmployeeData] = {
    val data = readData(inputFileName)
    for {
      (name, salary, rule) <- data
      deductions      = toDeduction(rule)
    } yield (name, salary, toDeduction(rule))
  }

  def biweeklyPayrollPerEmployeeReportUseCase(data: Seq[EmployeeData]): Unit ={
    val fmt  = "%-10s %6.2f  %5.2f  %5.2f\n"
    val head = "%-10s %-7s  %-5s    %s\n"
    println("\nBiweekly Payroll:")
    printf(head, "Name", "Gross", "Net", "Deductions")
    printf(head, "----", "-----", "---", "----------")
    for {
      (name, salary, deductions) <- data
      gross = deductions.gross(salary.amount)
      net   = deductions.net(salary.amount)
    } printf(fmt, name, gross, net, gross - net)
  }

  def biweeklyPayrollTotalsReportUseCase(data: Seq[EmployeeData]): Unit = {
    val (gross, net) = (data foldLeft (0.0, 0.0)) {
      case ((gross, net), (name, salary, deductions)) =>
      val g = deductions.gross(salary.amount)
      val n = deductions.net(salary.amount)
      (gross + g, net + n)
    }
    printf("\nBiweekly Totals: Gross %7.2f, Net %6.2f, Deductions: %6.2f\n",
      gross, net, gross - net)
  }

  def main(args: Array[String]) = {
    val inputFileName =
      if (args.length > 0) args(0) else "misc/parthenon-payroll.txt"
    val data = processRules(inputFileName)
    biweeklyPayrollTotalsReportUseCase(data)
    biweeklyPayrollPerEmployeeReportUseCase(data)
  }
}

这个示例通过读取逗号分隔的员工数据,使用 DSL 创建规则字符串,解析字符串创建所需的数据结构,最后实现两个用例。虽然使用中间字符串在实际应用中可能不合理,但它允许我们重用之前的 DSL 并说明架构的要点。

综上所述,设计模式、设计契约和特定的架构风格在 Scala 开发中都有重要的应用,它们可以帮助我们构建更符合需求、易于维护和扩展的软件系统。在实际开发中,我们可以根据具体情况选择合适的模式和架构,以提高开发效率和代码质量。

Scala 设计模式与架构实践

设计模式的实际应用考量

在实际的 Scala 开发中,合理运用设计模式能够显著提升代码的质量和可维护性,但同时也需要注意避免模式的滥用。例如,某些情况下,过度使用设计模式可能会使代码变得复杂,增加理解和维护的难度。

以访问者模式为例,虽然它可以插入协议让其他代码访问类型内部,但这种模式破坏了公共接口并使实现复杂化。在实际应用中,如果没有充分的理由,应尽量避免使用。而像策略模式,由于高阶函数的存在,在 Scala 中实现起来非常方便,我们可以根据具体的业务需求灵活选择合适的算法,提高代码的灵活性和可扩展性。

设计契约的优势与局限性

设计契约通过前置条件、后置条件和不变条件来确保程序的正确性,在 Scala 中可以使用 assert assume require 方法来实现。这种方式有助于在开发过程中快速发现问题,提高代码的健壮性。

然而,设计契约也存在一定的局限性。例如,在生产环境中,为了避免额外的开销和可能的崩溃,通常会选择不进行契约检查。但这样一来,如果在生产环境中出现契约违反的情况,可能会导致难以调试的问题。因此,在使用设计契约时,需要权衡利弊,根据具体的应用场景进行合理的配置。

以下是设计契约中不同方法的对比表格:
| 方法 | 用途 | 失败抛出异常 | 编译选项影响 |
| ---- | ---- | ---- | ---- |
| assert | 用于一般的断言检查 | AssertionError | 可通过 -Xelide-below ASSERTION 移除 |
| assume | 用于一般的断言检查 | AssertionError | 可通过 -Xelide-below ASSERTION 移除 |
| require | 用于测试方法参数 | IllegalArgumentException | 不受 -Xelide-below 选项影响 |

帕台农神庙架构的深入分析

帕台农神庙架构结合了四个层次,旨在平衡通用语言和功能性代码的优势。这种架构在处理复杂的业务逻辑时具有独特的优势,但也面临一些挑战。

从优势方面来看,每个用例的代码简洁且专注,主要依赖标准库类型,减少了对领域导向类型的依赖。这使得代码易于理解、测试和演进,同时也降低了不同用例之间的耦合度。例如,在工资单报告的示例中,用例逻辑部分的代码非常简洁,大部分代码是一个垂直切片,清晰地展示了业务流程。

然而,这种架构也存在一些挑战。例如,用例代码看起来拒绝重用,可能会导致一定程度的代码重复。但由于功能性代码通常较小,琐碎的重复不值得去除。此外,该架构的实现需要对各个层次有清晰的理解和设计,否则可能会导致架构混乱。

以下是帕台农神庙架构在实际应用中的操作步骤:
1. 定义 DSL :使用通用语言的 DSL 声明用例,同时设计用户界面。
2. 实现 DSL 库 :包括为领域概念实现类型、用户界面等。
3. 编写用例逻辑 :使用功能性代码实现每个用例,尽量保持简洁,依赖标准库类型。
4. 集成核心库 :将 Scala 标准库、Akka、Play 等核心库以及可重用代码集成到系统中。

总结与建议

在 Scala 开发中,设计模式、设计契约和帕台农神庙架构都是非常有用的工具。我们可以根据具体的业务需求和项目特点,合理选择和运用这些工具。

对于设计模式,要避免滥用,根据实际情况选择最合适的模式来解决问题。在使用设计契约时,要权衡其优势和局限性,合理配置契约检查的范围。而对于帕台农神庙架构,要充分理解其各个层次的职责和作用,确保架构的清晰和稳定。

以下是 mermaid 格式的总结流程图:

graph LR
    A[设计模式] --> B[合理选择避免滥用]
    C[设计契约] --> D[权衡利弊合理配置]
    E[帕台农神庙架构] --> F[理解层次确保稳定]
    B --> G[提升代码质量]
    D --> G
    F --> G

通过对这些技术的深入理解和应用,我们可以构建出更符合需求、易于维护和扩展的 Scala 软件系统。在未来的开发中,不断学习和实践这些技术,将有助于我们成为更优秀的 Scala 开发者。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值