[譯]Scala DSL教程: 實現一個web框架路由器
原文: Scala DSL tutorial - writing a web framework router , 作者: Tymon Tobolski
譯者按:
Scala非常適合實現DSL( Domain-specific language )。我在使用Scala的過程中印象深刻的是 scalatest 和 spray-routing ,
比如scalatest的測試代碼的編寫:
importcollection.mutable.Stack
importorg.scalatest._
classExampleSpecextendsFlatSpecwithMatchers{
"A Stack"should"pop values in last-in-first-out order"in {
valstack =newStack[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 {
valemptyStack =newStack[Int]
a [NoSuchElementException] should be thrownBy {
emptyStack.pop()
}
}
}
或者 akka-http 的路由(route)的配置 (akka-http可以看作是spray 2.0的版本,因為作者現在在lightbend,也就是原先的typesafe公司開發akka-http):
valroute =
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.scala 和 reverse_routing.scala 。 routing.scala 是一個巨大的 PartialFunction ,每一個路由使用一個case語句。 reverse_routing.scala 對象結構。我真的不喜歡這種方式。
讓我們開始探索 如何使用Scala創建一個有用的DSL 。
最終用戶ui
我不知道DSL設計的最佳實踐,我也從沒讀過一本關于這方面的書。我用我的方式來實現它。
實現的結果應該自然而直接。首先,描述你想要的,然后實現它。
開始的例子很簡單, GET /foo 可以路由到 Application.foo() 方法:
GET "/foo"Application.foo
這個DSL非常好,但不幸的是,不使用括號的話,無法用Scala按這種方式實現。
當然,你已經知道Scala可是使用 infix notation 和 suffix notation 去掉括號:
A.op(B)
可以寫成
A op B
同樣
A.op(B).opp(C)
可以寫成
A op B opp C
但是這種寫法僅僅適用于只有一個參數的方法, 如 objectA method objectB 。但是在我們上面的DSL例子中(GET "/foo" Application.foo),中間的不是是一個字符串,而不是一個方法名,所以我們不能使用 infix notation 。增加一些中間單詞如何:
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) ?,我的初始想法是:
GET on "foo"/ * to Application.show
看起來很好, / 作為路徑分隔符, * 作為參數,而 Application.show 作為 Function1[Int, Handler] 。 / 作為方法實現,而 * 可以作為一個對象,因此上面的語句等價于:
GET.on("foo")./(*).to(Application.show)// 錯誤!
事實上, 由于 Scala操作符優先級的問題 ,它實際等價于:
GET.on( "foo"./(*) ).to(Application.show)
好消息,路徑可以組合在一起作為 on 的參數。
更多的例子:
GET on "foo"to Application.foo
PUT on "show"/ * to Application.show
POST on "bar"/ * / * /"blah"/ * to Application.bar
最后一件事,反向路由(reverse routing)。Play框架默認的路由器有一個限制,一個路由一個action。如果已經定義了一個路由,為什么不把它賦值給val變量用來反向路由呢:
valfoo = GET on"foo"to Application.foo
然后把路由放在一個對象中:
objectroutes{
valfoo = GET on"foo"to Application.foo
valshow = PUT on"show"/ * to Application.show
valbar = POST on"bar"/ * / * /"blah"/ * to Application.bar
}
現在可以調用 routes.foo() 或者 routes.show(5) 可以得到路徑。
本文的下一部分描述內部實現。現在你可以自己去實現它,或者參考我的實現 http://github.com/teamon/play-navigator , 但我強烈推薦你繼續閱讀實現部分。
實現
這里有兩個難點: type 和 arity 。Scala中的 Function 可以有0到22個參數,代表[Function0]到 Function22 ,后面我會介紹到。
我的實現 play-navigator Route有幾個參數:
- HTTP method
- path definition
- handler function
用下面的例子描述各個部分:
valfoo = GET on"foo"/ * to Application.show
我們已經知道它等價于:
valfoo = GET.on("foo"./(*) ).to(Application.shows)
從左邊開始,首先 GET 還沒有實現,讓我們實現它:
sealedtraitMethod
caseobjectANYextendsMethod
caseobjectGETextendsMethod
caseobjectPOSTextendsMethod
我定義了兩個HTTP method和 ANY 對應所有的HTTP method。接下來應該實現 on 方法,但是我們還不知道它使用什么參數。讓我們先看看 "foo" / * 。
路徑可以有多個變種:
"foo"/"bar"/"baz""foo"/ * /"blah"* / * / *
幸好路徑的各個部分可以用有限的幾個類型來表示,它可以是靜態路徑,也可能是占位符。如此說來,我們可以使用Scala直接實現:
sealedtraitPathElem
caseclassStatic(name: String)extendsPathElem
caseobject*extendsPathElem
case class 包裝了一個字符串,而 * 是一個case object。不幸的的是,因為每個部分都有關聯,我不得不描述更多的數據結構。先前我說過Scala有23種不同類型的 Function ,它們有不同數量的參數。我想讓類型系統比較 路徑占位符的數量和函數參數的數量,如果不匹配就拋出錯誤。因此我定義了不同版本的 RouteDefN ,我將數量減少到3:
sealedtraitRouteDef[Self]{
defwithMethod(method: Method): Self
defmethod: Method
defelems: List[PathElem]
}
caseclassRouteDef0(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef0]
caseclassRouteDef1(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef1]
caseclassRouteDef2(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef2]
Self 類型和 withMethod 稍候解釋。注意 RouteDefN 并沒有類型參數(我說過我想盡可能地在編譯的時候檢查)。事實是 RouteDefN 僅僅知道它的HTTP method和 path elements,并不會理會handler函數本身。
目前的挑戰是如何將
GET on "foo"/ * /"bar"
轉換為
RouteDef1(GET, List(Static("foo"), *, Static("bar")))
靠隱式函數來救駕了。
首先我們需要將 String 轉換成 RouteDef0 :
implicit defstringToRouteDef0(name: String) = RouteDef0(ANY, Static(name) :: Nil)
任意一個字符串都轉換成一個 RouteDef0 ,擁有 ANY method,下一步,同樣的技巧應用與 * 類型:
implicit defasterixToRoutePath1(ast: *.type) = RouteDef1(ANY, ast :: Nil)
之所以是 RouteDef1 是因為已經有一個參數占位符。我們需要實現 / 方法:
caseclassRouteDef0(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef0]{
def/(static: Static) = RouteDef0(method, elems :+ static)
def/(p: PathElem) = RouteDef1(method, elems :+ p)
}
caseclassRouteDef1(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef1]{
def/(static: Static) = RouteDef1(method, elems :+ static)
def/(p: PathElem) = RouteDef2(method, elems :+ p)
}
caseclassRouteDef2(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef2]{
def/(static: Static) = RouteDef2(method, elems :+ static)
}
/ 方法的邏輯很簡單。如果它得到Static參數,那么它返回的類型還是相同的類型。如果得到 * 參數,它返回一個更"高"的路由。 RouteDef2 并不允許傳遞 * 參數,所以我們沒有定義 RouteDef3 。我們還需要實現一個字符串到 Static 的隱式轉換。
implicit defstringToStatic(name: String) = Static(name)
現在我們定義的DSL可以處理:
GET on someRouteDef
現在是 on 方法如何實現?
讓我們返回 Method 定義,它的 on 方法需要類型參數 R ,它會調用routeDef的withMethod方法。
sealedtraitMethod{
defon[R](routeDef: RouteDef[R]): R = routeDef.withMethod(this)
}
還記得 RouteDef 特質的 withMethod 方法的實現么?
sealedtraitRouteDef[Self]{
defwithMethod(method: Method): Self
}
現在 RouteDefN 可以寫做:
caseclassRouteDef0(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef0]{
defwithMethod(method: Method) = RouteDef0(method, elems)
}
caseclassRouteDef1(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef1]{
defwithMethod(method: Method) = RouteDef1(method, elems)
}
caseclassRouteDef2(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef2]{
defwithMethod(method: Method) = RouteDef2(method, elems)
}
這樣 on 方法就是返回正確的類型。
最后就是和handler拼裝起來:
someRouteDef to Application.show
我說過我想讓編譯器檢查路徑參數中的參數數量是否和handler需要的參數數量一致。現在隆重轉為瘋狂的類 RouteN 出場。
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 需要 RouteDef1 和 function (A) ? Out ,A為類型參數:
[A: PathParam : Manifest]
是下面代碼的簡寫:
[A](implicit pp: PathParam[A], mf: Manifest[A])
PathParam[A] 和 Manifest[A] 稍后解釋。
你也可能已經推斷出 Route2 使用 RouteDef2 和 function (A,B) ? Out 做參數, A 和 B 都是類型參數。
返回到 RouteDef ,增加 to 方法:
caseclassRouteDef0(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef0]{
defto(f0: () ? Out) = Route0(this, f0)
}
caseclassRouteDef1(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef1]{
defto[A: PathParam : Manifest](f1: (A) ? Out) = Route1(this, f1)
}
caseclassRouteDef2(method: Method, elems: List[PathElem])extendsRouteDef[RouteDef2]{
defto[A: PathParam : Manifest, B: PathParam : Manifest](f2: (A, B) ? Out) = Route2(this, f2)
}
編譯器會檢查參數的匹配問題, RouteDefN 的 to 方法只會允許正確的Handler作為參數。
我們可以為 RouteN 增加 def apply 來來檢查參數的數量和正確的類型。
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)
}
所以如果我們定義了一個路由:
valfoo = 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 反向路由)
如何實現呢?
traitPathParam[T]{
defapply(t: T): String
defunapply(s: String): Option[T]
}
apply 將類型T轉換成字符串。而 unapply 將字符串轉換成 T 。
下面是兩個將路徑參數轉換成相應類型的例子。
implicit valStringPathParam: PathParam[String] =newPathParam[String] {
defapply(s: String) = s
defunapply(s: String) = Some(s)
}
implicit valBooleanPathParam: PathParam[Boolean] =newPathParam[Boolean] {
defapply(b: Boolean) = b.toString
defunapply(s: String) = s.toLowerCasematch{
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的路徑參數:
implicit valUUIDPathParam: PathParam[UUID] =newPathParam[UUID] {
defapply(uuid: UUID) = uuid.toString
defunapply(s: String) =try{
Some(UUID.fromString(s))
} catch{
case_ ? None
}
}
現在,讓我們檢查一下我們的需求:
- 靜態編譯 √
- 靜態類型 √
- 易于使用 √
- 可擴展 √
- 反向路由 √
- 盡可能的類型推斷 √
- 不使用圓括號 √
所有需求都實現。
如果你發現文中有遺漏的地方,或者錯誤,可以和作者聯系 推ter (@iteamon) , teamon on #scala @ irc.freenode.net 。
你也可以看完整的項目實現: play-navigator
翻譯完畢。
其它參考資料
- DSLs - A powerful Scala feature
- Creating Domain Specific Languages with Scala - Part 1
- My First DSL
- DSLs in Action
- Writing DSLs using Scala. Part 1 — Underlying Concepts
- Writing DSLs using Scala. Part II - A simple matcher DSL
- Domain-Specific Languages in Scala
- scala-sql-dsl
來自: http://colobu.com/2016/05/24/scala-dsl-tutorial-writing-web-framework-router/