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 开发者。
超级会员免费看
2554

被折叠的 条评论
为什么被折叠?



