[譯]Scala DSL教程: 實現一個web框架路由器

yzhi0788 8年前發布 | 31K 次閱讀 Scala Scala開發

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

譯者按:
Scala非常適合實現DSL( Domain-specific language )。我在使用Scala的過程中印象深刻的是 scalatestspray-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

翻譯完畢。

其它參考資料

  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

 

來自: http://colobu.com/2016/05/24/scala-dsl-tutorial-writing-web-framework-router/

 

 本文由用戶 yzhi0788 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!