Scala中的Implicit詳解

Scala中的implicit關(guān)鍵字對于我們初學(xué)者像是一個謎一樣的存在砍艾,一邊驚訝于代碼的簡潔,
一邊像在迷宮里打轉(zhuǎn)一樣地去找隱式的代碼,因此我們團(tuán)隊結(jié)合目前的開發(fā)工作只搁,將implicit
作為一個專題進(jìn)行研究赤兴,了一些心得妖滔。

在研究的過程當(dāng)中,我們注重三方面:

  1. 為什么需要implicit?
  2. implicit 包含什么桶良,有什么內(nèi)在規(guī)則座舍?
  3. implicit 的應(yīng)用模式有哪些?

為什么需要Implicit?

Scala在面對編譯出現(xiàn)類型錯誤時陨帆,提供了一個由編譯器自我修復(fù)的機(jī)制曲秉,編譯器試圖去尋找
一個隱式implicit的轉(zhuǎn)換方法,轉(zhuǎn)換出正確的類型疲牵,完成編譯承二。這就是implicit 的意義。

我們正在做Guardian系統(tǒng)的升級纲爸,Guardian是公司內(nèi)部的核心系統(tǒng)亥鸠,提供統(tǒng)一權(quán)限管控、
操作審計、單點登錄等服務(wù)负蚊。系統(tǒng)已經(jīng)有4年多的歷史了神妹,已經(jīng)難以滿足目前的需要,比如:
當(dāng)時僅提供了RESTFul的服務(wù)接口家妆,而隨著性能需求的提高灾螃,有些服務(wù)使用Tcp消息完成遠(yuǎn)程
調(diào)用;另外揩徊,在RESTFull接口的協(xié)議方面腰鬼,我們也想做一些優(yōu)化。

而現(xiàn)狀是公司內(nèi)部系統(tǒng)已經(jīng)全部接入Guardian塑荒,要接入新版熄赡,不可能一次全部遷移,甚至
要花很長一段時間才能完成遷移工作齿税,因此新版接口必須同時支持新老兩個版本接口協(xié)議彼硫。

因此我們必須解決兩個問題:

  1. 兼容老版本協(xié)議, 以便能夠平滑升級
  2. 支持多種協(xié)議,以滿足不同業(yè)務(wù)系統(tǒng)的需求

我們希望對接口層提供一個穩(wěn)定的Service接口凌箕,以免業(yè)務(wù)的變動影響前端接口代碼拧篮,常規(guī)
的做法是我們在Service接口上定義多種版本的方法(重載),比如鑒權(quán)服務(wù):

trait AuthService {
    // 兼容老版本的鑒權(quán)業(yè)務(wù)方法
    def auth(p: V1HttpAuthParam): Future[V1HttpAuthResult]

    // 新版本的鑒權(quán)業(yè)務(wù)方法
    def auth(p: V2HttpAuthParam): Future[V2HttpAuthResult]

    // 新版本中支持的對Tcp消息鑒權(quán)的業(yè)務(wù)方法
    def auth(p: V2TcpMsg): Future[V2TcpMsg]
}

這種做法的問題在于一旦業(yè)務(wù)發(fā)生變化牵舱,出現(xiàn)了新的參數(shù)串绩,勢必要修改AuthService接口,
添加新的接口方法芜壁,接口不穩(wěn)定礁凡。

假如有一個通用的auth方法就好了:

trait AuthParam {}

trait StableAuthService{
    // 穩(wěn)定的鑒權(quán)接口
    def auth(p: AuthParam)
}

這樣,我們就可以按照下面的方式調(diào)用:

//在老版本的REST WS接口層:
val result = authService auth V1HttpAuthParam
response(result)

//在新版本的REST WS接口層:
val result = authService auth V2HttpAuthParam
response(result)

// .... 在更多的服務(wù)接口層慧妄,任意的傳入?yún)?shù)顷牌,獲得結(jié)果

很明顯,這樣的代碼編譯出錯塞淹。 因為在authService中沒有這樣的方法簽名窟蓝。

再舉個簡單的例子, 我們想在打印字符串時,添加一些分隔符饱普,下面是最自然的調(diào)用方式:

"hello,world" printWithSeperator "*"

很明顯运挫,這樣的代碼編譯出錯。 因為String 沒有這樣的方法费彼。

Scala在面對編譯出現(xiàn)類型錯誤時滑臊,提供了一個由編譯器自我修復(fù)的機(jī)制,編譯器試圖去尋找
一個隱式implicit的轉(zhuǎn)換方法箍铲,轉(zhuǎn)換出正確的類型雇卷,完成編譯。這就是implicit 的意義。

Implicit包含什么关划,有什么內(nèi)在規(guī)則小染?

Scala 中的implicit包含兩個方面:

  1. 隱式參數(shù)(implicit parameters)
  2. 隱式轉(zhuǎn)換(implicit conversion)

隱式參數(shù)(implicit parameters)

隱式參數(shù)同樣是編譯器在找不到函數(shù)需要某種類型的參數(shù)時的一種修復(fù)機(jī)制,我們可以采用顯式的柯里化式
的隱式參數(shù)申明贮折,也可以進(jìn)一步省略裤翩,采用implicitly方法來獲取所需要的隱式變量。

隱式參數(shù)相對比較簡單调榄,Scala中的函數(shù)申明提供了隱式參數(shù)的語法踊赠,在函數(shù)的最后的柯里化參數(shù)
列表中可以添加隱式implicit關(guān)鍵字進(jìn)行標(biāo)記, 標(biāo)記為implicit的參數(shù)在調(diào)用中可以省略每庆,
Scala編譯器會從當(dāng)前作用域中尋找一個相同類型的隱式變量筐带,作為調(diào)用參數(shù)。

在Scala的并發(fā)庫中就大量使用了隱式參數(shù)缤灵,比如Future:

// Future 需要一個隱式的ExecutionContext
// 引入一個默認(rèn)的隱式ExecutionContext, 否則編譯不通過
import scala.concurrent.ExecutionContext.Implicits.default
Future {
    sleep(1000)
    println("I'm in future")
}

對于一些常量類的伦籍,可共用的一些對象,我們可以用隱式參數(shù)來簡化我們的代碼腮出,比如帖鸦,我們的應(yīng)用
一般都需要一個配置對象:

object SomeApp extends App {
  //這是我們的全局配置類
  class Setting(config: Config) {
    def host: String = config.getString("app.host")
  }
  // 申明一個隱式的配置對象
  implicit val setting = new Setting(ConfigFactory.load)

  // 申明隱式參數(shù)
  def startServer()(implicit setting: Setting): Unit = {
    val host = setting.host
    println(s"server listening on $host")
  }

  // 無需傳入隱式參數(shù)
  startServer()
}

甚至,Scala為了更進(jìn)一步減少隱式參數(shù)的申明代碼,我們都可以不需要再函數(shù)參數(shù)上顯示的申明,在scala.Predef包中迅箩,提供了一個implicitly的函數(shù),幫助我們找到當(dāng)前上下文中所需要類型的
隱式變量:

@inline def implicitly[T](implicit e: T) = e    // for summoning implicit values from the nether world

因此上面的startServer函數(shù)我們可以簡化為:

  // 省略隱式參數(shù)申明
  def startServer(): Unit = {
    val host = implicitly[Setting].host
    println(s"server listening on $host")
  }

需要注意的是立倍,進(jìn)一步簡化之后,代碼的可讀性有所損失侣滩,調(diào)用方并不知道startServer需要一個隱式的
配置對象,要么加強(qiáng)文檔說明变擒,要么選用顯式的申明君珠,這種權(quán)衡需要團(tuán)隊達(dá)成一致。

隱式轉(zhuǎn)換(implicit conversion)

回顧一下前面說到的小例子娇斑,讓字符串能夠帶分隔符打硬咛怼:

"hello,world" printWithSeperator "*"

此時,Scala編譯器嘗試從當(dāng)前的表達(dá)式作用域范圍中尋找能夠?qū)?code>String轉(zhuǎn)換成一個具有printWithSeperator
函數(shù)的對象毫缆。

為此唯竹,我們提供一個PrintOpstrait,有一個printWithSeperator函數(shù):

trait PrintOps {
  val value: String
  def printWithSepeator(sep: String): Unit = {
    println(value.split("").mkString(sep))
  }
}

此時苦丁,編譯仍然不通過浸颓,因為Scala編譯器并沒有找到一個可以將String轉(zhuǎn)換為PrintOps的方法!那我們申明一個:

def stringToPrintOps(str: String): PrintOps = new PrintOps {
  override val value: String = str
}

OK, 我們可以顯示地調(diào)用stringToPrintOps了:

stringToPrintOps("hello,world") printWithSepeator "*"

離我們的最終目標(biāo)只有一步之遙了,只需要將stringToPrintOps方法標(biāo)記為implicit即可产上,除了為String
添加stringToPrintOps的能力棵磷,還可以為其他類型添加,完整代碼如下:

object StringOpsTest extends App {
  // 定義打印操作Trait
  trait PrintOps {
    val value: String
    def printWithSeperator(sep: String): Unit = {
      println(value.split("").mkString(sep))
    }
  }

  // 定義針對String的隱式轉(zhuǎn)換方法
  implicit def stringToPrintOps(str: String): PrintOps = new PrintOps {
    override val value: String = str
  }

  // 定義針對Int的隱式轉(zhuǎn)換方法
  implicit def intToPrintOps(i: Int): PrintOps = new PrintOps {
    override val value: String = i.toString
  }

  // String 和 Int 都擁有 printWithSeperator 函數(shù)
  "hello,world" printWithSeperator "*"
  1234 printWithSeperator "*"
}

隱式轉(zhuǎn)換的規(guī)則 -- 如何尋找隱式轉(zhuǎn)換方法

Scala編譯器是按照怎樣的套路來尋找一個可以應(yīng)用的隱式轉(zhuǎn)換方法呢晋涣? 在Martin Odersky的Programming in Scala, First Edition中總結(jié)了以下幾條原則:

  1. 標(biāo)記規(guī)則:只會去尋找?guī)в?code>implicit標(biāo)記的方法仪媒,這點很好理解,在上面的代碼也有演示谢鹊,如果不申明為implicit
    只能手工去調(diào)用算吩。
  2. 作用域范圍規(guī)則:
    1. 只會在當(dāng)前表達(dá)式的作用范圍之內(nèi)查找,而且只會查找單一標(biāo)識符的函數(shù)佃扼,上述代碼中赌莺,
      如果stringToPrintOps方法封裝在其他對象(加入叫Test)中,雖然Test對象也在作用域范圍之內(nèi)松嘶,但編譯器不會嘗試使用Test.stringToPrintOps進(jìn)行轉(zhuǎn)換艘狭,這就是單一標(biāo)識符的概念。
    2. 單一標(biāo)識符有一個例外翠订,如果stringToPrintOps方法在PrintOps的伴生對象中申明也是有效的巢音,Scala
      編譯器也會在源類型或目標(biāo)類型的伴生對象內(nèi)查找隱式轉(zhuǎn)換方法,本規(guī)則只會在轉(zhuǎn)型有效尽超。而一般的慣例官撼,會將隱式轉(zhuǎn)換方法封裝在伴生對象中
    3. 當(dāng)前作用域上下文的隱式轉(zhuǎn)換方法優(yōu)先級高于伴生對象內(nèi)的隱式方法
  3. 不能有歧義原則:在相同優(yōu)先級的位置只能有一個隱式的轉(zhuǎn)型方法似谁,否則Scala編譯器無法選擇適當(dāng)?shù)倪M(jìn)行轉(zhuǎn)型傲绣,編譯出錯。
  4. 只應(yīng)用轉(zhuǎn)型方法一次原則:Scala編譯器不會進(jìn)行多次隱式方法的調(diào)用巩踏,比如需要C類型參數(shù)秃诵,而實際類型為A,作用域內(nèi)
    存在A => B,B => C的隱式方法塞琼,Scala編譯器不會嘗試先調(diào)用A => B ,再調(diào)用B => C菠净。
  5. 顯示方法優(yōu)先原則:如果方法被重載,可以接受多種類型彪杉,而作用域中存在轉(zhuǎn)型為另一個可接受的參數(shù)類型的隱式方法毅往,則不會
    被調(diào)用,Scala編譯器優(yōu)先選擇無需轉(zhuǎn)型的顯式方法派近,例如:
    def m(a: A): Unit = ???
    def m(b: B): Unit = ???
    
    val b: B = new B
    
    //存在一個隱式的轉(zhuǎn)換方法 B => A
    implicit def b2a(b: B): A = ???
    
    m(b) //隱式方法不會被調(diào)用攀唯,優(yōu)先使用顯式的 m(b: B): Unit
    

Implicit的應(yīng)用模式有哪些?

隱式轉(zhuǎn)換的核心在于將錯誤的類型通過查找隱式方法渴丸,轉(zhuǎn)換為正確的類型侯嘀×砹瑁基于Scala編譯器的這種隱式轉(zhuǎn)換機(jī)制,通常有兩種應(yīng)用
模式:Magnet PatternMethod Injection残拐。

Magnet Pattern

Magnet Pattern模式暫且翻譯為磁鐵模式, 解決的是方法參數(shù)類型的不匹配問題途茫,能夠優(yōu)雅地解決本文開頭所提出的問題,
用一個通用的Service方法簽名來屏蔽不同版本溪食、不同類型服務(wù)的差異囊卜。

磁鐵模式的核心在于,將函數(shù)的調(diào)用參數(shù)和返回結(jié)果封裝為一個磁鐵參數(shù)错沃,這樣方法的簽名就統(tǒng)一為一個了栅组,不需要函數(shù)重載;再
定義不同參數(shù)到磁鐵參數(shù)的隱式轉(zhuǎn)換函數(shù)枢析,利用Scala的隱式轉(zhuǎn)換機(jī)制玉掸,達(dá)到類似于函數(shù)重載的效果。

磁鐵模式廣泛運用于Spray Http 框架醒叁,該框架已經(jīng)遷移到Akka Http中司浪。

下面,我們一步步來實現(xiàn)一個磁鐵模式把沼,來解決本文開頭提出的問題啊易。

  1. 定義Magnet參數(shù)和使用Magnet參數(shù)的通用鑒權(quán)服務(wù)方法

    // Auth Magnet參數(shù)
    trait AuthMagnet {
      type Result
      def apply(): Result
    }
    
    // Auth Service 方法
    trait AuthService {
      def auth(am: AuthMagnet): am.Result = am()
    }
    
  2. 實現(xiàn)不同版本的AuthService

    //v1 auth service
    trait V1AuthService extends AuthService
    //v2 auth service
    trait V2AuthService extends AuthService
    
  3. 實現(xiàn)不同版本AuthService的伴生對象,添加適當(dāng)?shù)碾[式轉(zhuǎn)換方法

    //V1 版本的服務(wù)實現(xiàn)
    object V1AuthService {
        case class V1AuthRequest()
        case class V1AuthResponse()
    
        implicit def toAuthMagnet(p: V1AuthRequest): AuthMagnet {type Result = V1AuthResponse} = new AuthMagnet {
        override def apply(): Result = {
            // v1 版本的auth 業(yè)務(wù)委托到magnet的apply中實現(xiàn)
            println("這是V1 Auth Service")
            V1AuthResponse()
        }
        override type Result = V1AuthResponse
        }
    }
    
    //V2 版本的服務(wù)實現(xiàn)
    object V2AuthService {
        case class V2AuthRequest()
        case class V2AuthResponse()
    
        implicit def toAuthMagnet(p: V2AuthRequest): AuthMagnet {type Result = V2AuthResponse} = new AuthMagnet {
        override def apply(): Result = {
            // v2 版本的auth 業(yè)務(wù)委托到magnet的apply中實現(xiàn)
            println("這是V2 Auth Service")
            V2AuthResponse()
        }
        override type Result = V2AuthResponse
        }
    }
    
  4. 編寫兩個版本的資源接口(demo)

    trait V1Resource extends V1AuthService {
        def serv(): Unit = {
        val p = V1AuthRequest()
        val response = auth(p)
        println(s"v1 resource response: $response")
        }
    }
    
    trait V2Resource extends V2AuthService {
        def serv(): Unit = {
        val p = V2AuthRequest()
        val response = auth(p)
        println(s"v2 resource response: $response")
        }
    }
    
    
    val res1 = new V1Resource {}
    val res2 = new V2Resource {}
    
    res1.serv()
    res2.serv()
    

    控制臺輸出結(jié)果為:

    這是V1 Auth Service
    v1 resource response: V1AuthResponse()
    這是V2 Auth Service
    v2 resource response: V2AuthResponse()
    

Method Injection

Method Injection 暫且翻譯為方法注入饮睬,意思是給一個類型添加沒有定義的方法租谈,實際上也是通過隱式轉(zhuǎn)換來實現(xiàn)的,
這種技術(shù)在Scalaz中廣泛使用捆愁,Scalaz為我們提供了和Haskell類似的函數(shù)式編程庫割去。

本文中的關(guān)于printWithSeperator方法的例子其實就是Method Injection的應(yīng)用,從表面上看昼丑,即是給String
Int類型添加了printWithSeperator方法呻逆。

Magnet Pattern不同的是轉(zhuǎn)型所針對的對象,Magnet Pattern是針對方法參數(shù)進(jìn)行轉(zhuǎn)型,
Method Injection是針對調(diào)用對象進(jìn)行轉(zhuǎn)型矾克。

舉個簡單的例子页慷,Scala中的集合都是一個Functor,都可以進(jìn)行map操作胁附,但是Java的集合框架卻沒有,
如果需要對java.util.ArrayList等進(jìn)行map操作則需要先轉(zhuǎn)換為Scala對應(yīng)的類型滓彰,非常麻煩控妻,借助Method Injection,我們可以提供這樣的輔助工具揭绑,讓Java的集合框架也成為一種Functor弓候,具備map能力:

  1. 首先定義一個Functor
    trait Functor[F[_]] {
        def map[A, B](fa: F[A])(f: A ? B): F[B]
    }
    
  2. 再定義一個FunctorOps
    final class FunctorOps[F[_], A](l: F[A])(implicit functor: Functor[F]) {
        def map[A, B](f: A ? B): F[B] = functor.map(l)(f)
    }
    
  3. 在FunctorOps的伴生對象中定義針對java.util.List[E]的隱式Funcotr實例和針對java.util.List[E]到
    FunctorOps的隱式轉(zhuǎn)換方法
    object FunctorOps {
      // 針對List[E]的functor
      implicit val jlistFunctor: Functor[JList] = new Functor[JList] {
        override def map[A, B](fa: JList[A])(f: (A) => B): JList[B] = {
          val fb = new JLinkList[B]()
          val it = fa.iterator()
          while(it.hasNext) fb.add(f(it.next))
          fb
        }
      }
    
      // 將List[E]轉(zhuǎn)換為FunctorOps的隱式轉(zhuǎn)換方法
      implicit def jlistToFunctorOps[E](jl: JList[E]): FunctorOps[JList, E] = new FunctorOps[JList, E](jl)
    }
    
  4. 愉快滴使用map啦
    val jlist = new util.ArrayList[Int]()
    jlist.add(1)
    jlist.add(2)
    jlist.add(3)
    jlist.add(4)
    
    import FunctorOps._
    val jlist2 = jlist map (_ * 3)
    println(jlist2)
    // [3, 6, 9, 12]
    

總結(jié)

Implicit 是Scala語言中處理編譯類型錯誤的一種修復(fù)機(jī)制郎哭,利用該機(jī)制,我們可以編寫出任意參數(shù)和返回值的多態(tài)方法(這種多
態(tài)也被稱為Ad-hoc polymorphism -- 任意多態(tài))菇存,實現(xiàn)任意多態(tài)夸研,我們通常使用Magnet Pattern磁鐵模式;同時還可以
給其他類庫的類型添加方法來對其他類庫進(jìn)行擴(kuò)展依鸥,通常將這種技術(shù)稱之為Method Injection亥至。

參考資料

  1. 《Programming in Scala》中關(guān)于隱式轉(zhuǎn)換和隱式參數(shù)章節(jié): http://www.artima.com/pins1ed/implicit-conversions-and-parameters.html
  2. 《The Magnet Pattern》http://spray.io/blog/2012-12-13-the-magnet-pattern/
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市贱迟,隨后出現(xiàn)的幾起案子姐扮,更是在濱河造成了極大的恐慌,老刑警劉巖衣吠,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件茶敏,死亡現(xiàn)場離奇詭異,居然都是意外死亡缚俏,警方通過查閱死者的電腦和手機(jī)惊搏,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來忧换,“玉大人恬惯,你說我怎么就攤上這事“福” “怎么了宿崭?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長才写。 經(jīng)常有香客問我葡兑,道長,這世上最難降的妖魔是什么赞草? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任讹堤,我火速辦了婚禮,結(jié)果婚禮上厨疙,老公的妹妹穿的比我還像新娘洲守。我一直安慰自己,他們只是感情好沾凄,可當(dāng)我...
    茶點故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布梗醇。 她就那樣靜靜地躺著,像睡著了一般撒蟀。 火紅的嫁衣襯著肌膚如雪叙谨。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天保屯,我揣著相機(jī)與錄音手负,去河邊找鬼涤垫。 笑死,一個胖子當(dāng)著我的面吹牛竟终,可吹牛的內(nèi)容都是我干的蝠猬。 我是一名探鬼主播,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼统捶,長吁一口氣:“原來是場噩夢啊……” “哼榆芦!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起瘾境,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤歧杏,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后迷守,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體犬绒,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年兑凿,在試婚紗的時候發(fā)現(xiàn)自己被綠了凯力。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡礼华,死狀恐怖咐鹤,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情圣絮,我是刑警寧澤祈惶,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站扮匠,受9級特大地震影響捧请,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜棒搜,卻給世界環(huán)境...
    茶點故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一疹蛉、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧力麸,春花似錦可款、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至埃叭,卻和暖如春翠拣,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背游盲。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工误墓, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人益缎。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓谜慌,卻偏偏與公主長得像,于是被迫代替她去往敵國和親莺奔。 傳聞我的和親對象是個殘疾皇子欣范,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,916評論 2 344

推薦閱讀更多精彩內(nèi)容