[译]Scala DSL教程: 实现一个web框架路由器

原文: Scala DSL tutorial - writing a web framework router, 作者: Tymon Tobolski

译者按:
Scala非常适合实现DSL(Domain-specific language)。我在使用Scala的过程中印象深刻的是scalatestspray-routing,

比如scalatest的测试代码的编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import collection.mutable.Stack
import org.scalatest._
class ExampleSpec extends FlatSpec with Matchers {
"A Stack" should "pop values in last-in-first-out order" in {
val stack = new Stack[Int]
stack.push(1)
stack.push(2)
stack.pop() should be (2)
stack.pop() should be (1)
}
it should "throw NoSuchElementException if an empty stack is popped" in {
val emptyStack = new Stack[Int]
a [NoSuchElementException] should be thrownBy {
emptyStack.pop()
}
}
}

或者 akka-http的路由(route)的配置 (akka-http可以看作是spray 2.0的版本,因为作者现在在lightbend,也就是原先的typesafe公司开发akka-http):

1
2
3
4
5
6
7
8
9
10
11
12
val route =
get {
pathSingleSlash {
complete(HttpEntity(ContentTypes.`text/html(UTF-8)`,"<html><body>Hello world!</body></html>"))
} ~
path("ping") {
complete("PONG!")
} ~
path("crash") {
sys.error("BOOM!")
}
}

可以看到,使用Scala实现的DSL非常简洁,也符合人类便于阅读的方式。但是我们如何实现自己的DSL呢?文末有几篇参考文档,介绍了使用Scala实现DSL的技术,但是本文翻译的这篇文章,使用Scala实现了一个鸡蛋的web路由DSL,步骤详细,代码简单,所以我特意翻译了一下。以下内容(除了参考文档)是对原文的翻译。

目标

Play 2.0的发布给Java社区带来了新的创建web service的方式。尽管非常美好,但是有些组件缺不是我的菜,其中之一它的router定义,它使用定制的route文件,独立的编译器和难以捉摸的逻辑。作为一个Riby程序员,我开始想能否使用Scala实现一个简单的DSL.需求很简单:

  • 静态编译
  • 静态类型
  • 易于使用
  • 可扩展
  • 反向路由
  • 尽可能的类型推断
  • 不使用圆括号

设计

所以第一个问题是:什么是路由器(router)? 它可以表示为PartialFunction[Request, Handler],这就是Play框架中实现的方式。让我们花几秒钟先看看Play的原始的路由器。

在编译的过程中, conf/routes文件下的文件被解析并转换成target/src_managed文件夹下的.scala文件。有两个文件会被产生routing.scalareverse_routing.scalarouting.scala是一个巨大的PartialFunction,每一个路由使用一个case语句。 reverse_routing.scala对象结构。我真的不喜欢这种方式。

让我们开始探索 如何使用Scala创建一个有用的DSL

最终用户ui

我不知道DSL设计的最佳实践,我也从没读过一本关于这方面的书。我用我的方式来实现它。

实现的结果应该自然而直接。首先,描述你想要的,然后实现它。

开始的例子很简单,GET /foo可以路由到Application.foo()方法:

1
GET "/foo" Application.foo

这个DSL非常好,但不幸的是,不使用括号的话,无法用Scala按这种方式实现。

当然,你已经知道Scala可是使用infix notationsuffix notation去掉括号:

1
A.op(B)

可以写成

1
A op B

同样

1
A.op(B).opp(C)

可以写成

1
A op B opp C

但是这种写法仅仅适用于只有一个参数的方法, 如objectA method objectB。但是在我们上面的DSL例子中(GET "/foo" Application.foo),中间的不是是一个字符串,而不是一个方法名,所以我们不能使用infix notation。增加一些中间单词如何:

1
2
GET on "/foo" to Application.foo
GET.on("/foo").to(Application.foo) //等价于上面的写法

编译通过。 GET可以是一个代表HTTP method的对象, on是一个方法, /foo是这个方法的参数,然后to是另外一个方法,而Application.foo是一个Function0[Handler]。 我犯了一个错误,开始去实现它,然后我不得不扔掉了大段代码,因为实现并不能满足我前面定义的需求。

我来把坑挖的更深,来看看路径参数。怎么写一个路由来匹配 GET /foo/{id}然后调用Application.show(id)?,我的初始想法是:

1
GET on "foo" / * to Application.show

看起来很好,/作为路径分隔符,*作为参数,而Application.show作为Function1[Int, Handler]/作为方法实现,而*可以作为一个对象,因此上面的语句等价于:

1
GET.on("foo")./(*).to(Application.show) // 错误!

事实上, 由于Scala操作符优先级的问题,它实际等价于:

1
GET.on( "foo"./(*) ).to(Application.show)

好消息,路径可以组合在一起作为on的参数。

更多的例子:

1
2
3
GET on "foo" to Application.foo
PUT on "show" / * to Application.show
POST on "bar" / * / * / "blah" / * to Application.bar

最后一件事,反向路由(reverse routing)。Play框架默认的路由器有一个限制,一个路由一个action。如果已经定义了一个路由,为什么不把它赋值给val变量用来反向路由呢:

1
val foo = GET on "foo" to Application.foo

然后把路由放在一个对象中:

1
2
3
4
5
object routes {
val foo = GET on "foo" to Application.foo
val show = PUT on "show" / * to Application.show
val bar = POST on "bar" / * / * / "blah" / * to Application.bar
}

现在可以调用routes.foo() 或者 routes.show(5)可以得到路径。

本文的下一部分描述内部实现。现在你可以自己去实现它,或者参考我的实现 http://github.com/teamon/play-navigator, 但我强烈推荐你继续阅读实现部分。

实现

这里有两个难点:typearity。Scala中的Function可以有0到22个参数,代表[Function0]到Function22,后面我会介绍到。

我的实现play-navigator Route有几个参数:

  • HTTP method
  • path definition
  • handler function

用下面的例子描述各个部分:

1
val foo = GET on "foo" / * to Application.show

我们已经知道它等价于:

1
val foo = GET.on( "foo"./(*) ).to(Application.shows)

从左边开始,首先GET还没有实现,让我们实现它:

1
2
3
4
sealed trait Method
case object ANY extends Method
case object GET extends Method
case object POST extends Method

我定义了两个HTTP method和ANY对应所有的HTTP method。接下来应该实现on方法,但是我们还不知道它使用什么参数。让我们先看看"foo" / *

路径可以有多个变种:

1
"foo" / "bar" / "baz" "foo" / * / "blah" * / * / *

幸好路径的各个部分可以用有限的几个类型来表示,它可以是静态路径,也可能是占位符。如此说来,我们可以使用Scala直接实现:

1
2
3
sealed trait PathElem
case class Static(name: String) extends PathElem
case object * extends PathElem

case class包装了一个字符串,而*是一个case object。不幸的的是,因为每个部分都有关联,我不得不描述更多的数据结构。先前我说过Scala有23种不同类型的Function,它们有不同数量的参数。我想让类型系统比较 路径占位符的数量和函数参数的数量,如果不匹配就抛出错误。因此我定义了不同版本的RouteDefN,我将数量减少到3:

1
2
3
4
5
6
7
8
9
sealed trait RouteDef[Self] {
def withMethod(method: Method): Self
def method: Method
def elems: List[PathElem]
}
case class RouteDef0(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef0]
case class RouteDef1(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef1]
case class RouteDef2(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef2]

Self类型和withMethod稍候解释。注意RouteDefN并没有类型参数(我说过我想尽可能地在编译的时候检查)。事实是RouteDefN仅仅知道它的HTTP method和 path elements,并不会理会handler函数本身。

目前的挑战是如何将

1
GET on "foo" / * / "bar"

转换为

1
RouteDef1(GET, List(Static("foo"), *, Static("bar")))

靠隐式函数来救驾了。

首先我们需要将String转换成RouteDef0:

1
implicit def stringToRouteDef0(name: String) = RouteDef0(ANY, Static(name) :: Nil)

任意一个字符串都转换成一个RouteDef0,拥有ANY method,下一步,同样的技巧应用与*类型:

1
implicit def asterixToRoutePath1(ast: *.type) = RouteDef1(ANY, ast :: Nil)

之所以是RouteDef1是因为已经有一个参数占位符。我们需要实现/方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
case class RouteDef0(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef0] {
def /(static: Static) = RouteDef0(method, elems :+ static)
def /(p: PathElem) = RouteDef1(method, elems :+ p)
}
case class RouteDef1(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef1]{
def /(static: Static) = RouteDef1(method, elems :+ static)
def /(p: PathElem) = RouteDef2(method, elems :+ p)
}
case class RouteDef2(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef2]{
def /(static: Static) = RouteDef2(method, elems :+ static)
}

/方法的逻辑很简单。如果它得到Static参数,那么它返回的类型还是相同的类型。如果得到*参数,它返回一个更"高"的路由。RouteDef2并不允许传递*参数,所以我们没有定义RouteDef3。我们还需要实现一个字符串到Static的隐式转换。

1
implicit def stringToStatic(name: String) = Static(name)

现在我们定义的DSL可以处理:

1
GET on someRouteDef

现在是on方法如何实现?

让我们返回Method定义,它的on方法需要类型参数R,它会调用routeDef的withMethod方法。

1
2
3
sealed trait Method {
def on[R](routeDef: RouteDef[R]): R = routeDef.withMethod(this)
}

还记得RouteDef特质的withMethod方法的实现么?

1
2
3
sealed trait RouteDef[Self] {
def withMethod(method: Method): Self
}

现在RouteDefN可以写做:

1
2
3
4
5
6
7
8
9
10
11
case class RouteDef0(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef0] {
def withMethod(method: Method) = RouteDef0(method, elems)
}
case class RouteDef1(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef1]{
def withMethod(method: Method) = RouteDef1(method, elems)
}
case class RouteDef2(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef2]{
def withMethod(method: Method) = RouteDef2(method, elems)
}

这样on方法就是返回正确的类型。

最后就是和handler拼装起来:

1
someRouteDef to Application.show

我说过我想让编译器检查路径参数中的参数数量是否和handler需要的参数数量一致。现在隆重转为疯狂的类RouteN出场。

1
2
3
4
5
6
7
sealed trait Route[RD] {
def routeDef: RouteDef[RD]
}
case class Route0(routeDef: RouteDef0, f0: () ⇒ Out) extends Route[RouteDef0]
case class Route1[A: PathParam : Manifest](routeDef: RouteDef1, f1: (A) ⇒ Out) extends Route[RouteDef1]
case class Route2[A: PathParam : Manifest, B: PathParam : Manifest](routeDef: RouteDef2, f2: (A, B) ⇒ Out) extends Route[RouteDef2]

呜呼哀哉, 类型、更多的类型、更多坨的类型,保持胃口继续看。Route0需要RouteDef0() ⇒ Out参数。 Route1 需要RouteDef1function (A) ⇒ Out,A为类型参数:

1
[A: PathParam : Manifest]

是下面代码的简写:

1
[A](implicit pp: PathParam[A], mf: Manifest[A])

PathParam[A]Manifest[A]稍后解释。

你也可能已经推断出Route2使用RouteDef2function (A,B) ⇒ Out做参数, A 和 B 都是类型参数。

返回到RouteDef,增加to方法:

1
2
3
4
5
6
7
8
9
10
11
case class RouteDef0(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef0] {
def to(f0: () ⇒ Out) = Route0(this, f0)
}
case class RouteDef1(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef1]{
def to[A: PathParam : Manifest](f1: (A) ⇒ Out) = Route1(this, f1)
}
case class RouteDef2(method: Method, elems: List[PathElem]) extends RouteDef[RouteDef2]{
def to[A: PathParam : Manifest, B: PathParam : Manifest](f2: (A, B) ⇒ Out) = Route2(this, f2)
}

编译器会检查参数的匹配问题,RouteDefNto方法只会允许正确的Handler作为参数。

我们可以为RouteN增加def apply来来检查参数的数量和正确的类型。

1
2
3
4
5
6
7
case class Route1[A: PathParam : Manifest](routeDef: RouteDef1, f2: (A) ⇒ Out) extends Route[RouteDef1] {
def apply(a: A) = PathMatcher1(routeDef.elems)(a)
}
case class Route2[A: PathParam : Manifest, B: PathParam : Manifest](routeDef: RouteDef2, f2: (A, B) ⇒ Out) extends Route[RouteDef2] {
def apply(a: A, b: B) = PathMatcher2(routeDef.elems)(a, b)
}

所以如果我们定义了一个路由:

1
val foo = GET on "foo" / * to Application.show

这里foo是一个类型为Route1[Int](RouteDef1(GET, Static("foo") :: * :: Nil), Application.show)的对象,同时foo还是(Int) ⇒ String类型。

关于PathMatcherN用来匹配request uri到正确的路由。因为在本文中我只想介绍DSL相关的实现,所以我不想多介绍它。你可以把它看成一个解析和构造url的函数。

现在只剩下一件事。既然所有的路由都是类型安全的,那么我们需要一个类型安全的方式匹配路径和action。一种方式是硬编码,比较傻。既然我们已经有了类型敏感的路由,Scala拥有强大的类型系统,为什么不让工作好上加好呢?

我们需要做什么?

  • 解析路径(字符串)为我们的类型
  • 转换路径参数为字符串 (for 反向路由)

如何实现呢?

1
2
3
4
trait PathParam[T]{
def apply(t: T): String
def unapply(s: String): Option[T]
}

apply将类型T转换成字符串。而unapply将字符串转换成T

下面是两个将路径参数转换成相应类型的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
implicit val StringPathParam: PathParam[String] = new PathParam[String] {
def apply(s: String) = s
def unapply(s: String) = Some(s)
}
implicit val BooleanPathParam: PathParam[Boolean] = new PathParam[Boolean] {
def apply(b: Boolean) = b.toString
def unapply(s: String) = s.toLowerCase match {
case "1" | "true" | "yes" ⇒ Some(true)
case "0" | "false" | "no" ⇒ Some(false)
case _ ⇒ None
}
}

因此可以定制类型作为action (handler)的参数。

上文中一个秘密就是RouteN中的PathParam[A],Route类只关心PathParam,所以使用其它类型创建route是不允许的,编译器出错。

Manifest[A]是Scala编译器提供的一个特殊的类,为类型提供运行时的类型信息。

再提供一个java.util.UUID的路径参数:

1
2
3
4
5
6
7
8
implicit val UUIDPathParam: PathParam[UUID] = new PathParam[UUID] {
def apply(uuid: UUID) = uuid.toString
def unapply(s: String) = try {
Some(UUID.fromString(s))
} catch {
case _ ⇒ None
}
}

现在,让我们检查一下我们的需求:

  • 静态编译 √
  • 静态类型 √
  • 易于使用 √
  • 可扩展 √
  • 反向路由 √
  • 尽可能的类型推断 √
  • 不使用圆括号 √

所有需求都实现。

如果你发现文中有遗漏的地方,或者错误,可以和作者联系 twitter (@iteamon), teamon on #scala @ irc.freenode.net

你也可以看完整的项目实现: play-navigator

翻译完毕。

其它参考资料

  1. DSLs - A powerful Scala feature
  2. Creating Domain Specific Languages with Scala - Part 1
  3. My First DSL
  4. DSLs in Action
  5. Writing DSLs using Scala. Part 1 — Underlying Concepts
  6. Writing DSLs using Scala. Part II - A simple matcher DSL
  7. Domain-Specific Languages in Scala
  8. scala-sql-dsl