[email protected] (@marius)
[translated by hongjiang(@hongjiang), tongqing(@tongqing)] .TOC English 日本語 Русский ## 序言 [Scala][Scala]是Twitter使用的主要应用编程语言之一。很多我们的基础架构都是用scala写的,[我们也有一些大的库](https://github.com/twitter/)支持我们使用。虽然非常有效, Scala也是一门大的语言,经验教会我们在实践中要非常小心。 它有什么陷阱?哪些特性我们应该拥抱,哪些应该避开?我们什么时候采用“纯函数式风格”,什么时候应该避免?换句话说:哪些是我们发现的,可以高效地使用这门语言的地方?本指南试图把我们的经验提炼成短文,提供一系列最佳实践。我们使用scala主要创建一些大容量分布式系统服务——我们的建议也偏向于此——但这里的大多建议也应该自然的适用其他系统。这不是定律,但不当的使用应该被调整。 Scala提供很多工具使表达式可以很简洁。敲的少读的就少,读的少就能更快的读,因此简洁增强了代码的清晰。然而简洁也是一把钝器(blunt tool)也可能起到相反的效果:在考虑正确性之后,也要为读者着想。 首先,用Scala编程,你不是在写Java,Haskell或Python;Scala程序不像这其中的任何一种。为了高效的使用语言,你必须用其术语表达你的问题。 强制把Java程序转成Scala程序是无用的,因为大多数情况下它会不如原来的。 这不是对Scala的一篇介绍,我们假定读者熟悉这门语言。这儿有些学习Scala的资源: * [Scala School](https://twitter.github.com/scala_school/) * [Learning Scala](https://www.scala-lang.org/node/1305) * [Learning Scala in Small Bites](https://matt.might.net/articles/learning-scala-in-small-bites/) 这是一篇“活的”文档,我们会更新它,以反映我们当前的最佳实践,但核心的思想不太可能会变: 永远重视可读性;写泛化的代码但不要牺牲清晰度; 利用简单的语言特性的威力,但避免晦涩难懂(尤其是类型系统)。最重要的,总要意识到你所做的取舍。一门成熟的(sophisticated)语言需要复杂的实现,复杂性又产生了复杂性:之于推理,之于语义,之于特性之间的交互,以及与你合作者之间的理解。因此复杂性是为成熟所交的税——你必须确保效用超过它的成本。 玩的愉快。 ## 格式化 代码格式化的规范并不重要,只要它们实用。它的定义形式没有先天的好与坏,几乎每个人都有自己的偏好。然而,对于一贯地采用同一格式化规则的总会增加可读性。已经熟悉某种特定风格的读者不必非要去掌握另一套当地习惯,或译解另一个角落里的语言语法。 这对Scala来说也特别重要,因为它的语法高度的重叠。一个例子是方法调用:方法调用可以用"."后边跟圆括号,或不使用".",后边用空格加不带圆括号(针对空元或一元方法)方式调用。此外,不同风格的方法调用揭露了它们在语法上不同的分歧(ambiguities)。当然一致的应用慎重的选择一组格式化规则,对人和机器来说都会消除大量的歧义。 我们依着[Scala style guide](https://docs.scala-lang.org/style/) 增加了以下规则: ### 空格 用两个空格缩进。避免每行长度超过100列。在两个方法、类、对象定义之间使用一个空白行。 ### 命名
- 对作用域较短的变量使用短名字:
-
i
s,j
s 和k
s等可出现在循环中。 - 对作用域较长的变量使用长名字:
- 外部APIs应该用长的,不需加以说明便可理解的名字。例如:
Future.collect
而非Future.all
- 使用通用的缩写,避开隐秘难懂的缩写:
- 例如每个人都知道
ok
,err
,defn
等缩写的意思,而sfri
是不常用的。 - 不要在不同用途时重用同样的名字:
- 使用
val
(注:Scala中的不可变类型) - 避免用
`
声明保留字变量: - 用
typ
替代`type`
- 用主动语态(active)来命名有副作用的操作:
user.activate()
而非user.setActive()
- 对有返回值的方法使用具有描述性的名字:
src.isDefined
而非src.defined
- getters不采用前缀
get
: - 用get是多余的:
site.count
而非site.getCount
- 不必重复已经被package或object封装过的名字:
- 使用:
而非:
object User { def get(id: Int): Option[User] }
相比object User { def getUser(id: Int): Option[User] }
get
方法getUser
方法中的User是多余的,并不能提供额外的信息。
- 对引入行按字母顺序排序:
- 这样既方便了视觉上的检查,也简化了自动操作。
- 当从一个包中引入多个名字时,用花括号:
import com.twitter.concurrent.{Broker, Offer}
- 当引入超过6个名字时使用通配符:
- e.g.:
import com.twitter.concurrent._
不要轻率的使用: 一些包导入了太多的名字 - 当引入集合的时候,通过用import scala.collections.immutable(不可变集合)或scala.collections.mutable(可变集合)来限定名称
- 可变和不可变集合有相同的名字。限定名称让读者很明确知道使用的是哪个变量(e.g. "
immutable.Map
") - (译注,通常也会默认immutable,而在使用mutable时显式引入)
- 不要使用来自其它包的相对引用:
- 避免而应该用清晰的:
import com.twitter import concurrent
(译注,实际上上面的import不能编译通过,第二个import应该为:import twitter.concurrent 即import一个包实际是定义了这个包的别名。)import com.twitter.concurrent
- 将import放在文件的顶部:
- 读者可以在一个地方参考所有的引用。
让我们重新审视我们所说的组合:将简单的组件合成一个更复杂的。函数组合的一个权威的例子:给定函数 f 和 g,组合函数 (g∘f)(x) = g(f(x)) ——结果先对 x使用f函数,然后在使用g函数——用Scala来写:
val f = (i: Int) => i.toString
val g = (s: String) => s+s+s
val h = g compose f // : Int => String
scala> h(123)
res0: java.lang.String = 123123123
Future[Future[Int]]
) 完成的future (Future[Future[Int]]
).如果外部future失败,内部flattened future也将失败。
Future (类似List) 也定义了flatMap;Future[A] 定义方法flatMap的签名
flatMap[B](f: A => Future[B]): Future[B]
.LP 如同组合 map 和 flatten,我们可以这样实现:
def flatMap[B](f: A => Future[B]): Future[B] = {
val mapped: Future[Future[B]] = this map f
val flattened: Future[B] = mapped.flatten
flattened
}
这是一种有威力的组合!使用flatMap我们可以定义一个 Future 作为两个Future序列的结果。第二个future 的计算基于第一个的结果。想象我们需要2次RPC调用来验证一个用户身份,我们可以用下面的方式组合操作:
def getUser(id: Int): Future[User]
def authenticate(user: User): Future[Boolean]
def isIdAuthed(id: Int): Future[Boolean] =
getUser(id) flatMap { user => authenticate(user) }
.LP 这种组合类型的一个额外的好处是错误处理是内置的:如果getUser(..)或authenticate(..)失败,future 从 isAuthred(..)返回时将会失败。这里我们没有额外的错误处理的代码。
#### 风格
Future回调方法(respond, onSuccess, onFailure, ensure) 返回一个新的Future,并链接到调用者。这个Future被保证只有在它调用者完成后才完成,使用模式如下:
acquireResource()
future onSuccess { value =>
computeSomething(value)
} ensure {
freeResource()
}
.LP freeResource() 被保证只有在 computeSomething之后才执行,这样就模拟了try-finally 模式。
使用 onSuccess替代 foreach —— 它与 onFailure 方法对称,命名的意图更明确,并且也允许 chaining。
永远避免直接创建Promise实例: 几乎每一个任务都可以通过使用预定义的组合子完成。这些组合子确保错误和取消是可传播的, 通常鼓励的数据流风格的编程,不再需要同步和volatility声明。
用尾递归风格编写的代码不再导致堆栈空间泄漏,并使得以数据流风格高效的实现循环成为可能:
case class Node(parent: Option[Node], ...)
def getNode(id: Int): Future[Node] = ...
def getHierarchy(id: Int, nodes: List[Node] = Nil): Future[Node] =
getNode(id) flatMap {
case n@Node(Some(parent), ..) => getHierarchy(parent, n :: nodes)
case n => Future.value((n :: nodes).reverse)
}
Future定义很多有用的方法: 使用 Future.value() 和 Future.exception() 来创建未满意(pre-satisfied) 的future。Future.collect(), Future.join() 和 Future.select() 提供了组合子将多个future合成一个(例如:scatter-gather操作的gather部分)。
#### Cancellation
Future实现了一种弱形式的取消。调用Future#cancel 不会直接终止运算,而是发送某个级别的可被任何处理查询的触发信号,最终满足这个future。Cancellation信号流向相反的方向:一个由消费者设置的cancellation信号,会传播到它的生产者。生产者使用 Promise的onCancellation来监听信号并执行相应的动作。
这意味这cancellation语意上依赖生产者,没有默认的实现。cancellation只是一个提示。
#### Local
Util的[Local](https://github.com/twitter/util/blob/master/util-core/src/main/scala/com/twitter/util/Local.scala#L40)提供了一个位于特定的future派发树(dispatch tree)的引用单元(cell)。设定一个local的值,使这个值可以用于被同一个线程的Future 延迟的任何计算。有一些类似于thread locals(注:Java中的线程机制),不同的是它们的范围不是一个Java线程,而是一个 future 线程树。在
trait User {
def name: String
def incrCost(points: Int)
}
val user = new Local[User]
...
user() = currentUser
rpc() ensure {
user().incrCost(10)
}
.LP 在 ensure块中的 user() 将在回调被添加的时候引用 user local的值。
就thread locals来说,我们的Locals非常的方便,但要尽量避免使用:除非确信通过显式传递数据时问题不能被充分的解决,哪怕解决起来有些繁重。
Locals有效的被核心库使用在非常常见的问题上——线程通过RPC跟踪,传播监视器,为future的回调创建stack traces——任何其他解决方法都使得用户负担过度。Locals在几乎任何其他情况下都不适合。
### Offer/Broker
并发系统由于需要协调访问数据和资源而变得复杂。[Actor](http://www.scala-lang.org/api/current/scala/actors/Actor.html)提出一种简化的策略:每一个actor是一个顺序的进程(process),保持自己的状态和资源,数据通过消息的方式与其它actor共享。 共享数据需要actor之间通信。
Offer/Broker 建立于Actor之上,以这三种重要的方式表现:1,通信通道(Brokers)是first class——即发送消息需要通过Brokers,而非直接到actor。2, Offer/Broker 是一种同步机制:通信会话是同步的。 这意味我们可以用 Broker作为协调机制:当进程a发送一条信息给进程b;a和b都要对系统状态达成一致。3, 最后,通信可以选择性地执行:一个进程可以提出几个不同的通信,其中的一个将被获取。
为了以一种通用的方式支持选择性通信(以及其他组合),我们需要将通信的描述和执行解耦。这正是Offer做的——它是一个持久数据用于描述一次通信;为了执行这个通信(offer执行),我们通过它的sync()方法同步
trait Offer[T] {
def sync(): Future[T]
}
.LP 返回 Future[T] 当通信被获取的时候生成交换值。
Broker通过offer协调值的交换——它是通信的通道:
trait Broker[T] {
def send(msg: T): Offer[Unit]
val recv: Offer[T]
}
.LP 所以,当创建两个offer
val b: Broker[Int]
val sendOf = b.send(1)
val recvOf = b.recv
.LP sendOf和recvOf都同步
// In process 1:
sendOf.sync()
// In process 2:
recvOf.sync()
.LP 两个offer都获取并且值1被交换。
通过将多个offer和Offer.choose绑定来执行可选择通信。
def choose[T](ofs: Offer[T]*): Offer[T]
.LP 上面的代码生成一个新的offer,当同步时获取一个特定的ofs——第一个可用的。当多个都立即可用时,随机获取一个。
Offer对象有些一次性的Offers用于与来自Broker的Offer构建。
Offer.timeout(duration): Offer[Unit]
.LP offer在给定时间后激活。Offer.never将用于不会有效,Offer.const(value)在给定值后立即有效。这些操作由选择性通信来组合是非常有用的。例如,在一个send操作中使用超时:
Offer.choose(
Offer.timeout(10.seconds),
broker.send("my value")
).sync()
人们可能会比较 Offer/Broker 与[SynchronousQueue](http://docs.oracle.com/javase/6/docs/api/java/util/concurrent/SynchronousQueue.html),他们有细微但非常重要的区别。Offer可以被组合,而queue不能。例如,考虑一组queues,描述为 Brokers:
val q0 = new Broker[Int]
val q1 = new Broker[Int]
val q2 = new Broker[Int]
.LP 现在让我们为读取创建一个合并的queue
val anyq: Offer[Int] = Offer.choose(q0.recv, q1.recv, q2.recv)
.LP anyq是一个将从第一个可用的queue中读取的offer。注意 anyq 仍是同步的——我们仍然拥有底层队列的语义。这类组合是不可能用queue实现的。
#### 例子:一个简单的连接池
连接池在网络应用中很常见,并且它们的实现常常需要技巧——例如,在从池中获取一个连接的时候,通常需要超时机制,因为不同的客户端有不同的延迟需求。池的简单原则:维护一个连接队列,满足那些进入的等待者。使用传统的同步原语,这通常需要两个队列(queues):一个用于等待者(当没有连接可用时),一个用于连接(当没有等待者时)。
使用 Offer/Brokers ,可以表达得非常自然:
class Pool(conns: Seq[Conn]) {
private[this] val waiters = new Broker[Conn]
private[this] val returnConn = new Broker[Conn]
val get: Offer[Conn] = waiters.recv
def put(c: Conn) { returnConn ! c }
private[this] def loop(connq: Queue[Conn]) {
Offer.choose(
if (connq.isEmpty) Offer.never else {
val (head, rest) = connq.dequeue
waiters.send(head) { _ => loop(rest) }
},
returnConn.recv { c => loop(connq enqueue c) }
).sync()
}
loop(Queue.empty ++ conns)
}
loop总是提供一个归还的连接,但只有queue非空的时候才会send。 使用持久化队列(persistent queue)更进一步简化逻辑。与连接池的接口也是通过Offer实现,所以调用者如果愿意设置timeout,他们可以通过利用组合子(combinators)来做:
val conn: Future[Option[Conn]] = Offer.choose(
pool.get { conn => Some(conn) },
Offer.timeout(1.second) { _ => None }
).sync()
实现timeout不需要额外的记账(bookkeeping);这是因为Offer的语义:如果Offer.timeout被选择,不会再有offer从池中获得——连接池和它的调用者在各自waiter的broker上不必同时同意接受和发送。
#### 埃拉托色尼筛子(Sieve of Eratosthenes 译注:一种用于筛选素数的算法)
把并发程序构造为一组顺序的同步通信进程,通常很有用——有时程序被大大地简化了。Offer和Broker提供了一组工具来让它简单并一致。确实,它们的应用超越了我们可能认为是经典并发性问题——并发编程(有Offer/Broker的辅助)是一种有用的构建工具,正如子例程(subroutines),类,和模块都是——来自CSP(译注:Communicating sequential processes的缩写,即通信顺序进程)的重要思想。
这里有一个[埃拉托色尼筛子](http://ja.wikipedia.org/wiki/%E3%82%A8%E3%83%A9%E3%83%88%E3%82%B9%E3%83%86%E3%83%8D%E3%82%B9%E3%81%AE%E7%AF%A9)可以构造为一个针对一个整数流(stream of integers)的连续的应用过滤器 。首先,我们需要一个整数的源(source of integers):
def integers(from: Int): Offer[Int] = {
val b = new Broker[Int]
def gen(n: Int): Unit = b.send(n).sync() ensure gen(n + 1)
gen(from)
b.recv
}
.LP integers(n) 方法简单地提供了从n开始的所有连续的整数。然后我们需要一个过滤器:
def filter(in: Offer[Int], prime: Int): Offer[Int] = {
val b = new Broker[Int]
def loop() {
in.sync() onSuccess { i =>
if (i % prime != 0)
b.send(i).sync() ensure loop()
else
loop()
}
}
loop()
b.recv
}
.LP filter(in, p) 方法返回的offer删除了in中的所有质数(prime)的倍数。最终我们定义了我们的筛子(sieve):
def sieve = {
val b = new Broker[Int]
def loop(of: Offer[Int]) {
for (prime <- of.sync(); _ <- b.send(prime).sync())
loop(filter(of, prime))
}
loop(integers(2))
b.recv
}
.LP loop() 工作很简单:从of中读取下一个质数,然后对of应用过滤器排除这个质数。loop不断的递归,持续的质数被过滤,于是我们得到了筛选结果。我们现在打印前10000个质数:
val primes = sieve
0 until 10000 foreach { _ =>
println(primes.sync()())
}
除了构造简单,组件正交,这种做法也给你一种流式筛子(streaming sieve):你不需要事先计算出你感兴趣的质数集合,从而进一步提高了模块化。
## 致谢
本课程由Twitter公司Scala社区贡献——我希望我是个忠实的记录者。
Blake Matheny, Nick Kallen, Steve Gury, 和Raghavendra Prabhu提供了很多有用的指导和许多优秀的建议。
[Scala]: https://www.scala-lang.org/
[Finagle]: https://github.com/twitter/finagle
[Util]: https://github.com/twitter/util