Kotlin与Groovy的CallSafe处理比较

为什么需要CallSafe?

  我们平时在使用Java开发的时候,遇到过最多的异常就是 NullPointException (NPE),想处理这个这个异常很简单,只需要在变量、方法返回值等使用前对其进行判空处理之后再使用即可,但是我们又不想书写再所有地方都进行先判空再使用的逻辑代码,因为这样不仅让代码看起来乱糟糟还影响阅读,所以我们迫切的希望有一种”系统统一处理NPE“的功能,这些逻辑代码不侵入我们自己的项目代码。

带着这个想法我们继续探讨:
①系统帮我们处理NPE;
②这些代码不要出现在项目代码中
仔细想来要实现这个功能,无非只有两个阶段时能做到:
1.编译阶段,编译阶段将NPE处理的代码插入到业务代码中,最终生成带有NPE处理的.class 文件;
2.运行阶段,我们希望在JVM在执行代码时,遇到NPE不是抛出的NPE异常而中止,而是按照我们预定义的逻辑继续执行。
两种处理方式各有优劣,不过目前看来第一种方式明显好于第二种,因为第一种方式虽然可能会导致JVM多执行一些额外的逻辑,但是生成的 .class 在任何的标准JVM上都能正确的执行,第二种方式再执行上可能会更高效点,但是需要干预 JVM 的执行,这在某些情况下是不可行的(比如Android程序,你无法干预所有客户端的执行)。

Kotlin的CallSafe处理

  扯的有点远,现在正式开始说这个Kotlin和Groovy处理NPE的方式,在kotlin中,如果我们不想自己做NPE检查,又不想程序因抛出NPE而终止运行,我们可以使用 ‘可选型’类型来处理有可能发生NPE的情况,举例:

 private fun foo(str:String?):String?{
        return str?.toLowerCase()?.substring(1)
    }

  这段代码在方法调用时候使用了‘?.’ 来替代我们之前使用的 ‘.’,它的作用是:如果方法的被调用者为null了,则终止操作,想必这个很简单,大家都知道作用,但是它是如何实现的呢?我们使用'javap -p -v' 命令来查看这段代码最终生成的class中是怎么处理的:

Constant pool:
...省略部分常量...
   #14 = Utf8               kotlin/TypeCastException
   #15 = Class              #14           // kotlin/TypeCastException
   #16 = Utf8               null cannot be cast to non-null type java.lang.String
   #17 = String             #16           // null cannot be cast to non-null type java.lang.String
   #18 = Utf8               <init>
   #19 = Utf8               (Ljava/lang/String;)V
   #20 = NameAndType        #18:#19       // "<init>":(Ljava/lang/String;)V
   #21 = Methodref          #15.#20       // kotlin/TypeCastException."<init>":(Ljava/lang/String;)V
   #22 = Utf8               java/lang/String
   #23 = Class              #22           // java/lang/String
   #24 = Utf8               toLowerCase
   #25 = Utf8               ()Ljava/lang/String;
   #26 = NameAndType        #24:#25       // toLowerCase:()Ljava/lang/String;
   #27 = Methodref          #23.#26       // java/lang/String.toLowerCase:()Ljava/lang/String;
   #28 = Utf8               (this as java.lang.String).toLowerCase()
   #29 = String             #28           // (this as java.lang.String).toLowerCase()
   #30 = Utf8               kotlin/jvm/internal/Intrinsics
   #31 = Class              #30           // kotlin/jvm/internal/Intrinsics
   #32 = Utf8               checkExpressionValueIsNotNull
   #33 = Utf8               (Ljava/lang/Object;Ljava/lang/String;)V
   #34 = NameAndType        #32:#33       // checkExpressionValueIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
   #35 = Methodref          #31.#34       // kotlin/jvm/internal/Intrinsics.checkExpressionValueIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
   #36 = Utf8               substring
   #37 = Utf8               (I)Ljava/lang/String;
   #38 = NameAndType        #36:#37       // substring:(I)Ljava/lang/String;
   #39 = Methodref          #23.#38       // java/lang/String.substring:(I)Ljava/lang/String;
   #40 = Utf8               (this as java.lang.String).substring(startIndex)
   #41 = String             #40           // (this as java.lang.String).substring(startIndex)
...省略部分常量...
private final java.lang.String foo(java.lang.String);
    descriptor: (Ljava/lang/String;)Ljava/lang/String;
    flags: ACC_PRIVATE, ACC_FINAL
    Code:
      stack=4, locals=4, args_size=2
         0: aload_1
         1: dup
         2: ifnull        65
         5: astore_2
         6: aload_2
         7: dup
         8: ifnonnull     21
        11: new           #15                 // class kotlin/TypeCastException
        14: dup
        15: ldc           #17                 // String null cannot be cast to non-null type java.lang.String
        17: invokespecial #21                 // Method kotlin/TypeCastException."<init>":(Ljava/lang/String;)V
        20: athrow
        21: invokevirtual #27                 // Method java/lang/String.toLowerCase:()Ljava/lang/String;
        24: dup
        25: ldc           #29                 // String (this as java.lang.String).toLowerCase()
        27: invokestatic  #35                 // Method kotlin/jvm/internal/Intrinsics.checkExpressionValueIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
        30: dup
        31: ifnull        65
        34: astore_2
        35: iconst_1
        36: istore_3
        37: aload_2
        38: dup
        39: ifnonnull     52
        42: new           #15                 // class kotlin/TypeCastException
        45: dup
        46: ldc           #17                 // String null cannot be cast to non-null type java.lang.String
        48: invokespecial #21                 // Method kotlin/TypeCastException."<init>":(Ljava/lang/String;)V
        51: athrow
        52: iload_3
        53: invokevirtual #39                 // Method java/lang/String.substring:(I)Ljava/lang/String;
        56: dup
        57: ldc           #41                 // String (this as java.lang.String).substring(startIndex)
        59: invokestatic  #35                 // Method kotlin/jvm/internal/Intrinsics.checkExpressionValueIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
        62: goto          67
        65: pop
        66: aconst_null
        67: areturn
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      68     0  this   Ldemo/KotlinMain;
            0      68     1   str   Ljava/lang/String;
      LineNumberTable:
        line 15: 0
        line 15: 35

下面来逐行解释每个指令的意思:

     0:aload_1              //将 LocalVariableTable 中的第2个引用类型变量(也就是str)压入栈顶
     1: dup                 //复制栈顶数据并将复制值压入栈顶
     2: ifnull          65  //如果(栈顶数据)为null,则跳转至 65
     5: astore_2            //将栈顶引用型数值存入第三个本地变量
     6: aload_2             //将第三个引用类型本地变量推送至栈顶
     7: dup                 //复制栈顶数值并将复制值压入栈顶
     8: ifnonnull       21  //如果不为空则跳转至21(为空则继续向下执行)
     11: new           #15  //创建 TypeCastException的实例,并将其引用压入栈顶
     14: dup                //复制栈顶数值并将复制值压入栈顶
     15: ldc           #17  //将 ”null cannot be cast to non-null type java.lang.String“压入栈顶
     17: invokespecial #21  //调用 TypeCastException 的 <init>方法 (初始化)
     20: athrow             //将栈顶的异常抛出
     21: invokevirtual #27  //调用实例方法 String#toLowerCase()
     24: dup                //复制栈顶数值并将复制值压入栈顶(上一步方法调用后的返回值)
     25: ldc           #29  //将”(this as java.lang.String).toLowerCase()“压入栈顶
     27: invokestatic  #35  //调用 kotlin.jvm.internal.Intrinsics.checkExpressionValueIsNotNull(Object,String)方法 (参数的匹配:以当前栈顶的元素作为方法的最后一个参数,距栈顶深度为2的元素作为倒数第二个参数... 以此类推)
     30: dup                //复制栈顶数值并将复制值压入栈顶(也就是上一步方法调用返回值)
     31: ifnull        65   //如果(栈顶数据)为null,则跳转至 65
     34: astore_2           //将栈顶引用型数值存入第三个本地变量
     35: iconst_1           //将int型1推送至栈顶
     36: istore_3           //将栈顶int型数值存入第四个本地变量(也就是1)
     37: aload_2            //将第三个引用类型本地变量推送至栈顶(也就是 .toLowerCase()的返回值)
     38: dup                //复制栈顶数值并将复制值压入栈顶
     39: ifnonnull     52   //不为null时跳转至 52 (实际上在判断.toLowerCase()的返回值是否为null)
     42: new           #15  //创建 TypeCastException的实例,并将其引用压入栈顶
     45: dup                //复制栈顶数据并将复制值压入栈顶
     46: ldc           #17  //”null cannot be cast to non-null type java.lang.String“压入栈顶
     48: invokespecial #21  //调用 TypeCastException 的 <init>方法 (初始化)
     51: athrow             //将栈顶的异常抛出
     52: iload_3            //将第四个int型本地变量推送至栈顶(也就是1)
     53: invokevirtual #39  //调用 .substring(1)
     56: dup                //复制栈顶数值并将复制值压入栈顶
     57: ldc           #41  //将”(this as java.lang.String).substring(startIndex)“压入栈顶
     59: invokestatic  #35  //调用kotlin.jvm.internal.Intrinsics.checkExpressionValueIsNotNull()
     62: goto          67   //跳转至 67
     65: pop                //将栈顶数值弹出(数值不能是long或double类型的)
     66: aconst_null        //将null推送至栈顶
     67: areturn            //从当前方法返回对象引用

通过对上面 .class中foo方法的分析,可以将以上代码还原为Java为:

private final String foo2(String str) {
      String var10000;
      if (str != null) {
         if (str == null) {
            throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
         }

         var10000 = str.toLowerCase();
         Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).toLowerCase()");
         if (var10000 != null) {
            String var2 = var10000;
            byte var3 = 1;
            if (var2 == null) {
               throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
            }

            var10000 = var2.substring(var3);
            Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).substring(startIndex)");
            return var10000;
         }
      }

      var10000 = null;
      return var10000;
   }

附:所用到的kotlin.jvm.internal中的其他方法

    public static void checkExpressionValueIsNotNull(Object value, String expression) {
        if (value == null) {
            throw sanitizeStackTrace(new IllegalStateException(expression + " must not be null"));
        }
    }

    private static <T extends Throwable> T sanitizeStackTrace(T throwable) {
        return sanitizeStackTrace(throwable, Intrinsics.class.getName());
    }

    static <T extends Throwable> T sanitizeStackTrace(T throwable, String classNameToDrop) {
        StackTraceElement[] stackTrace = throwable.getStackTrace();
        int size = stackTrace.length;

        int lastIntrinsic = -1;
        for (int i = 0; i < size; i++) {
            if (classNameToDrop.equals(stackTrace[i].getClassName())) {
                lastIntrinsic = i;
            }
        }

        List<StackTraceElement> list = Arrays.asList(stackTrace).subList(lastIntrinsic + 1, size);
        throwable.setStackTrace(list.toArray(new StackTraceElement[list.size()]));
        return throwable;
    }

将这个反编译的.java源码和我们之前写的 .java源码对比,不难发现:
①:访问修饰符增加了 final;
②:对"可选型"参数在使用前进行了判空;
③:对”不可为null“的情况使用了‘Intrinsics.checkExpressionValueIsNotNull()’去检查,并在抛出异常时消除掉该条语句的堆栈信息。
  看到这里我们基本上就可以得出结论了,Kotlin的safeCall并不是使用了”可选型“而是使用”判空“来保证不会触发NPE而终止程序。
  但是,如果你仔细看了上面的代码,不难发现,在调用 .substring()前的判空操作”滞后“了,因为在调用此方法之前先调用了 Intrinsics.checkExpressionValueIsNotNull(),这个方法最终仍会抛出异常。猜想: ‘.toLowerCase()’这个方法的定义是在 ‘java.lang.String'中,它的返回值是”String“ 而不是”String?“(事实上Java中也没有String?这样的方式),验证这个猜想很简单,我们只需要使用Kotlin的类方法扩展功能,为String扩写一个方法,该方法返回 "String?",然后在查看编译出的”.class“中是否对我们新定义的方法在调用前有判空操作即可:
新增扩展方法:

    public inline fun String.tonull(flag: Boolean): String? = if (flag) {null} else{"abc"}
    //这里如果直接返回 null ,在掉用这个方法之后再继续使用返回值调用其他的方法会在编译阶段被优化掉

改变foo()为:

    private fun foo(str:String?,flag:Boolean):String?{
        return str?.toLowerCase()?.tonull(flag)?.substring(1)?.tonull(false)
    }
将以上代码编译为".class"再反编译回Java:

private final String foo2(String str, boolean flag) {
  String var10000;
  if (str != null) {
     if (str == null) {
        throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
     }

     var10000 = str.toLowerCase();
     Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).toLowerCase()");
     if (var10000 != null) {
        var10000 = flag ? null : "abc";
        if ((flag ? null : "abc") != null) {
           String var3 = var10000;
           byte var4 = 1;
           if (var3 == null) {
              throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
           }

           var10000 = var3.substring(var4);
           Intrinsics.checkExpressionValueIsNotNull(var10000, "(this as java.lang.String).substring(startIndex)");
           if (var10000 != null) {
              boolean flag$iv = false;
              var10000 = "abc";
              return var10000;
           }
        }
     }
  }
  var10000 = null;
  return var10000;

}

  可以看到确实是这样,对于返回值为 ”String“类型的会强制检查其值是不是为 null ,如果为null则直接抛出异常,终止执行。对于返回值为 ”String?“的类型则不会检查值,但在使用该值之前会做判空操作。

Groovy的CallSafe处理

  那在Groovy中也是通过 非空判断来避免NPE的吗?我们仍然以上面 foo()为例,将其改写为Groovy代码:

    private String foo(String str) {
        return str?.toLowerCase().substring(1)?.toUpperCase()
    }

  还是先将其编译为 ".class"文件,然后在将其反编译回Java,(不反编译回Java也行,不过直接使用javap命令查看到的代码不是很直观):

    private String foo(String str) {
        CallSite[] var2 = $getCallSiteArray();
        return (String)ShortTypeHandling.castToString(var2[10].callSafe(var2[11].call(var2[12].callSafe(str), 1)));
    }

  对照我们自己编写foo()源码后发现,我们使用的 ”?."变成了”.callSafe“ ,而 ”.“变成了 ”call“,具体是怎么转换的还需要进一步分析:
首先,这个我们自己并没有定义’$getCallSiteArray()‘这个方法,而且直接使用 IDEA 自带的反编译工具竟然开不到这个方法,那就只能去”.class“中找这个方法的定义了:

     private static org.codehaus.groovy.runtime.callsite.CallSite[] $getCallSiteArray();
    descriptor: ()[Lorg/codehaus/groovy/runtime/callsite/CallSite;
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=3, locals=1, args_size=0
         0: getstatic     #233                // Field $callSiteArray:Ljava/lang/ref/SoftReference;
         3: ifnull        20
         6: getstatic     #233                // Field $callSiteArray:Ljava/lang/ref/SoftReference;
         9: invokevirtual #239                // Method java/lang/ref/SoftReference.get:()Ljava/lang/Object;
        12: checkcast     #228                // class org/codehaus/groovy/runtime/callsite/CallSiteArray
        15: dup
        16: astore_0
        17: ifnonnull     35
        20: invokestatic  #241                // Method $createCallSiteArray:()Lorg/codehaus/groovy/runtime/callsite/CallSiteArray;
        23: astore_0
        24: new           #235                // class java/lang/ref/SoftReference
        27: dup
        28: aload_0
        29: invokespecial #244                // Method java/lang/ref/SoftReference."<init>":(Ljava/lang/Object;)V
        32: putstatic     #233                // Field $callSiteArray:Ljava/lang/ref/SoftReference;
        35: aload_0
        36: getfield      #247                // Field org/codehaus/groovy/runtime/callsite/CallSiteArray.array:[Lorg/codehaus/groovy/runtime/callsite/CallSite;
        39: areturn
      StackMapTable: number_of_entries = 2
        frame_type = 20 /* same */
        frame_type = 252 /* append */
          offset_delta = 14
          locals = [ class org/codehaus/groovy/runtime/callsite/CallSiteArray ]

  可以看到不仅生成了 “$getCallSiteArray()”这方法,还有其他的方法,以及静态字段,由于代码片段太多,这里就不全贴出来,只把这些生成的方法和字段反编译后的 Java代码贴出来:

    private static SoftReference<CallSiteArray> $callSiteArray

    private static void $createCallSiteArray_1(String[] param) {
        param[0] = "substring"
        param[1] = "toUpperCase"
        param[2] = "trim"
        param[3] = "iiclass"
        param[4] = "<\$constructor\$>"
        param[5] = "substring"
        param[6] = "trim"
        param[7] = "str"
        param[8] = "substring"
        param[9] = "toLowerCase"
        param[10] = "substring"
        param[11] = "toLowerCase"
        param[12] = "startsWith"
        param[13] = "substring"
        param[14] = "toUpperCase"
        return
    }

    private static CallSiteArray $createCallSiteArray() {
        String[] v0 = new String[15]
        return new CallSiteArray(GroovyMain.class, $createCallSiteArray_1(v0))
    }

    private static CallSite[] $getCallSiteArray() {
        if ($callSiteArray != null) {
            CallSiteArray ca = $callSiteArray.get()
            if (ca == null) {
                $callSiteArray = new SoftReference($createCallSiteArray())
            }
        } else {
            $callSiteArray = new SoftReference($createCallSiteArray())
        }
        return $callSiteArray.get().array
    }

  可以看到它将每个方法的调用都转换为了一个 org.codehaus.groovy.runtime.callsite.CallSite实例,那么Groovy是如何将一个方法转换为CallSite,并且是如何进行方法调用的呢?
  由于所涉及到代码过多,就不一一贴出了,下面只给出 foo() 在调用时候的堆栈信息(基于IDEA的堆栈显示,并添加了方法声明):

12:java.lang.reflect.Constructor# public T newInstance(Object ... initargs)
11:org.codehaus.groovy.reflection.CachedMethod# public CallSite createPojoMetaMethodSite(CallSite site, MetaClassImpl metaClass, Class[] params)
10:org.codehaus.groovy.runtime.callsite.PojoMetaMethodSite# public static CallSite createCachedMethodSite(CallSite site, MetaClassImpl metaClass, CachedMethod metaMethod, Class[] params, Object[] args)
9:org.codehaus.groovy.runtime.callsite.PojoMetaMethodSite# public static CallSite createPojoMetaMethodSite(CallSite site, MetaClassImpl metaClass, MetaMethod metaMethod, Class[] params, Object receiver, Object[] args)
8:groovy.lang.MetaClassImpl# public CallSite createPojoCallSite(CallSite site, Object receiver, Object[] args)
7:org.codehaus.groovy.runtime.callsite.CallSiteArray# private static CallSite createPojoSite(CallSite callSite, Object receiver, Object[] args)
6:org.codehaus.groovy.runtime.callsite.CallSiteArray# private static CallSite createCallSite(CallSite callSite, Object receiver, Object[] args)
5:org.codehaus.groovy.runtime.callsite.CallSiteArray# public static Object defaultCall(CallSite callSite, Object receiver, Object[] args) throws Throwable 
4:org.codehaus.groovy.runtime.callsite.AbstractCallSite# public Object call(Object receiver, Object[] args) throws Throwable
3:org.codehaus.groovy.runtime.callsite.AbstractCallSite# public Object call(Object receiver) throws Throwable 
2:org.codehaus.groovy.runtime.callsite.AbstractCallSite# public final Object callSafe(Object receiver) throws Throwable 
1:public String foo(String str)

  从以上信息可以看出,CallSite的生成最终是使用java中的反射方式 Constructor#newInstance(),而在CachedMethod中缓存了原始的方法 Method,最终方法的调用即是使用 Method#invoke()

总结

  Kotlin 与 Groovy 在使用"?."处理NPE的原理是一致的,都是在使用前进行判断是否为 null ,不为null才会继续执行调用;只是在具体实现细节上 Kotlin仅仅只是对其判空、调用、再判空、再调用... ,实现方式简单,但执行效率相对也高。Groovy将方法调用(属性访问)转换为 CallSite ,在使用前仍然是先判空,但是整个调用链很长,方法执行效率会相应降低,但是其相对更加灵活。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容