ASM——运行时/编译时动态修改class源码

简述

最近在看阿里的ARouter的源码,从git上clone下来之后,run起来发现项目运行的效果和源码有明显区别。打个比方,源码是这样

boolean b = true;
System.out.println(b);

但是当你跑起来之后去发现打印出来的false,打开编译好的class文件却发现编译出来的class的代码和源码不一样。经过翻看ARouter的工程源码,发现其实ARouter是利用了Gradle的 Transform API和ASM共同完成的编译时修改源码的功能。

Transform API的功能是让你在java文件编译成class文件之后对这些class文件进行读写,发生在编译时,是Android的gradle打包插件自带的功能,这里不详细展开。本片文章主要是讲解ASM的基本使用方法。有机会会出一个Transform + ASM插件教程。

ASM简介

ASM 是一个 Java 字节码操控框架。它能够以二进制形式修改已有类或者动态生成类。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。许多AOP框架以及动态修改字节码的库的底层都是由ASM实现的,例如Spring AOP,cglib等等

一句话概括:ASM可以动态的修改创建class文件,达到动态修改java代码的效果。

ASM 核心API

public abstract class ClassVisitor {
  // 实现的ASM的API版本。该字段的值必须为如下几个之一:Opcodes.ASM4,ASM5,ASM6,ASM7
  protected final int api;
  // 该类的方法可以委托给子类
  protected ClassVisitor cv;
  // 构造器 
  public ClassVisitor(final int api) {
    this(api, null);
  }
  // 构造器
  public ClassVisitor(final int api, final ClassVisitor classVisitor) {
    if (api != Opcodes.ASM6 && api != Opcodes.ASM5 && api != Opcodes.ASM4 && api != Opcodes.ASM7) {
      throw new IllegalArgumentException();
    }
    this.api = api;
    this.cv = classVisitor;
  }
  /**
  * 访问类头部信息
  *
  * @param version
  *            类版本
  * @param access
  *            类访问标识符public等
  * @param name
  *            类名称
  * @param signature
  *            类签名(非泛型为NUll)
  * @param superName
  *            类的父类
  * @param interfaces
  *            类实现的接口
  */
  public void visit(
      final int version,final int access,
      final String name, final String signature,
      final String superName,final String[] interfaces) {
    if (cv != null) {
      cv.visit(version, access, name, signature, superName, interfaces);
    }
  }
  /**
  * 访问类的源文件.
  *
  * @param source
  *            源文件名称
  * @param debug
  *            附加的验证信息,可以为空
  */
  public void visitSource(final String source, final String debug) {
    if (cv != null) {
      cv.visitSource(source, debug);
    }
  }

  /**
  * 访问与类对应的模块. ASM6之后才有的API
  *
  * @param name
  *            模块名称
  * @param access
  *            模式 ACC_MANDATED 等
  * @param version
  *            版本号
  */
  public ModuleVisitor visitModule(final String name, final int access, final String version) {
    if (api < Opcodes.ASM6) {
      throw new UnsupportedOperationException("This feature requires ASM6");
    }
    if (cv != null) {
      return cv.visitModule(name, access, version);
    }
    return null;
  }

  public void visitNestHost(final String nestHost) {
    if (api < Opcodes.ASM7) {
      throw new UnsupportedOperationException("This feature requires ASM7");
    }
    if (cv != null) {
      cv.visitNestHost(nestHost);
    }
  }
  /**
  * 这个其实并不是访问外部类的回调,而是访问方法体中含有匿名内部类的方法
  *
  * @param owner 为创建匿名类的类,当然其也是一个enclosing class类型的类
  * @param name 创建匿名类的方法。
  * @param desc 创建匿名类的方法描述信息。
  * @return 返回一个注解值访问器
  */
  public void visitOuterClass(final String owner, final String name, final String descriptor) {
    if (cv != null) {
      cv.visitOuterClass(owner, name, descriptor);
    }
  }
  /**
  * 访问类的注解
  *
  * @param desc
  *            注解类的类描述
  * @param visible
  *            runtime时期注解是否可以被访问
  * @return 返回一个注解值访问器
  */
  public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) {
    if (cv != null) {
      return cv.visitAnnotation(descriptor, visible);
    }
    return null;
  }
  /**
  * 访问标注在类型上的注解
  *
  * @param typeRef
  * @param typePath
  * @param desc
  * @param visible
  * @return
  */
  public AnnotationVisitor visitTypeAnnotation(
      final int typeRef, final TypePath typePath, final String descriptor, final boolean visible) {
    if (api < Opcodes.ASM5) {
      throw new UnsupportedOperationException("This feature requires ASM5");
    }
    if (cv != null) {
      return cv.visitTypeAnnotation(typeRef, typePath, descriptor, visible);
    }
    return null;
  }
  /**
  * 访问一个类的属性
  *
  * @param attribute
  *            类的属性
  */
  public void visitAttribute(final Attribute attribute) {
    if (cv != null) {
      cv.visitAttribute(attribute);
    }
  }

  public void visitNestMember(final String nestMember) {
    if (api < Opcodes.ASM7) {
      throw new UnsupportedOperationException("This feature requires ASM7");
    }
    if (cv != null) {
      cv.visitNestMember(nestMember);
    }
  }
  /**
  * 访问内部类信息
  * @param name
  * @param outerName
  * @param innerName
  * @param access
  */
  public void visitInnerClass(
      final String name, final String outerName, final String innerName, final int access) {
    if (cv != null) {
      cv.visitInnerClass(name, outerName, innerName, access);
    }
  }
  /**
  * 访问类的字段
  * @param access
  * @param name
  * @param desc
  * @param signature
  * @param value
  * @return
  */
  public FieldVisitor visitField(
      final int access,
      final String name,
      final String descriptor,
      final String signature,
      final Object value) {
    if (cv != null) {
      return cv.visitField(access, name, descriptor, signature, value);
    }
    return null;
  }
  /**
  * 访问类的方法
  * @param access
  * @param name
  * @param desc
  * @param signature
  * @param exceptions
  * @return
  */
  public MethodVisitor visitMethod(
      final int access,
      final String name,
      final String descriptor,
      final String signature,
      final String[] exceptions) {
    if (cv != null) {
      return cv.visitMethod(access, name, descriptor, signature, exceptions);
    }
    return null;
  }

 /**
  * 访问类结束
  */
  public void visitEnd() {
    if (cv != null) {
      cv.visitEnd();
    }
  }
}

ClassVisitor 的调用必须是遵循下面的调用顺序的:

 visit visitSource? visitOuterClass? ( visitAnnotation | visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd

围绕着ClassVisitor ,还有两个核心类: 后续的例子代码中可以看到,我们必须先调用visit方法,这就因为class是字节流的二进制文件,而我们解析和生成也是要遵循一定的顺序。ClassVisitor定义了我们需要操作的所有接口,并且ClassVisitor也可以接收一个ClassVisitor实例来构造,有点类似于一个事件的filter,可以套很多层的filter来一层层处理逻辑。

1、ClassReader 将class解析成byte 数组,然后会通过accept方法去按顺序调用绑定对象(继承了ClassVisitor的实例)的方法。可以视为一个事件的生产者。

2、ClassWriter 是ClassVisitor 的子类。直接可以通过toByteArray()方法以返回的byte数组形式构建编译后的class。可以视为一个事件的消费者。

但是需要注意的是虽然ClassReader和ClassWriter 看起来像是对称类例如InputStream和OutputStream但其实类结构上并无关联,ClassWriter 继承于ClassVisitor,而ClassReader 直接继承于Object,只是提供解析class,并依次调用ClassVisitor对象。也就是说ClassReader的api和ClassWriter 的api基本没有相关性。

另外补充一下,ASM中常见参数desc直译是描述,但是作用其实时限定方法的输入参数和返回参数类型,比如"()V"是无输入无输出,"(I)Ljava/lang/String;"是输入int,返回String。

无中生有 ——利用ASM动态创建一个类

由于是凭空创建,所以只需要ClassWriter 即可。

先上目标代码,我们的目的是创造一个下面的类

public class Student{
    public int age = 11;

    public int getAge() {
        return age;
    }
}

一个特别简单的javabean类。

简单说一下创建流程:

  1. 创建一个类需要先调用visit创建类的头部信息。
  2. 分别调用visitMethod或visitField生成需要的创建的方法或者字段。
  3. 调用visitEnd结束类的创建
  4. 调用ClassWriter 的toByteArray将动态生成的class转为byte[]数组,可以用ClassLoader动态载入,或者写出成.class文件
    完整代码:
public byte[] createNewClass() {
        //创建ClassWriter ,构造参数的含义是是否自动计算栈帧,操作数栈及局部变量表的大小
        //0:完全手动计算 即手动调用visitFrame和visitMaxs完全生效
        //ClassWriter.COMPUTE_MAXS=1:需要自己计算栈帧大小,但本地变量与操作数已自动计算好,当然也可以调用visitMaxs方法,只不过不起作用,参数会被忽略;
        //ClassWriter.COMPUTE_FRAMES=2:栈帧本地变量和操作数栈都自动计算,不需要调用visitFrame和visitMaxs方法,即使调用也会被忽略。
        //这些选项非常方便,但会有一定的开销,使用COMPUTE_MAXS会慢10%,使用COMPUTE_FRAMES会慢2倍。
        ClassWriter cw = new ClassWriter(0);
        //创建类头部信息:jdk版本,修饰符,类全名,签名信息,父类,接口集
        cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "asm/Student", null, "java/lang/Object", null);
        //创建字段age:修饰符,变量名,类型,签名信息,初始值(不一定会起作用后面会说明)
        cw.visitField(Opcodes.ACC_PUBLIC , "age", "I", null, new Integer(11))
                .visitEnd();
        //创建方法:修饰符,方法名,类型,描述(输入输出类型),签名信息,抛出异常集合
        // 方法的逻辑全部使用jvm指令来书写的比较晦涩,门槛较高,后面会介绍简单的方法
        MethodVisitor mv = cw.visitMethod(ACC_PUBLIC, "getAge", "()I", null, null);
        // 创建方法第一步
        mv.visitCode();
        // 将索引为 #0 的本地变量列表加到操作数栈下。#0 索引的本地变量列表永远是 this ,当前类实例的引用。
        mv.visitVarInsn(ALOAD, 0);
        // 获取变量的值,
        mv.visitFieldInsn(GETFIELD, "asm/Student", "age", "I");
        // 返回age
        mv.visitInsn(IRETURN);
        // 设置操作数栈和本地变量表的大小
        mv.visitMaxs(1, 1);
        //结束方法生成
        mv.visitEnd();
        //结束类生成
        cw.visitEnd();
        //返回class的byte[]数组
        return cw.toByteArray();
    }

通过以上代码可以看出其实类以及字段的创建还是比较简单的,难点在于方法的创建上。如果对于jvm指令集不熟悉基本抓瞎。这里介绍一个方法,先手写目标类,即Student的java文件,然后用javac编译成class文件(或者用IDE编译),找到编译好的class文件,用javap -c Student打开class文件。输出如下:

public class asm.Student {
  public int age;

  public asm.Student();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: bipush        10
       7: putfield      #2                  // Field age:I
      10: return

  public int getAge();
    Code:
       0: aload_0
       1: getfield      #2                  // Field age:I
       4: ireturn
}

可以看到jvm编译时帮助Student补全了构造方法Student(),着重看getAge的指令代码

       0: aload_0
       1: getfield      #2                  // Field age:I
       4: ireturn

一共三条正好和生成方法的代码对应上

        mv.visitVarInsn(ALOAD, 0);
        mv.visitFieldInsn(GETFIELD, "asm/ASMDemo", "age", "I");
        mv.visitInsn(IRETURN);

当没有思路时,可以用这参考这种办法。
好了现在已经生成了新的class的byte[],剩下的就是加载,验证了。
加载的代码:

/**
  *用来加载byte[],由于defineClass不是public修饰的所以只能这样写。
  */
public class MyClassLoader extends ClassLoader {
  public Class getClassByBytes(byte[] bytes) {
        return defineClass(null, bytes, 0, bytes.length);
    }

  public static void main(String[] args) throws Exception {
        MyClassLoader myClassLoader = new MyClassLoader();
        Class classByBytes = myClassLoader.getClassByBytes(create());
        Object o = classByBytes.newInstance();
        Field field = classByBytes.getField("age");
        Object o1 = field.get(o);
        Method method = classByBytes.getMethod("getAge");
        Object o2 = method.invoke(o);
        System.out.println("Field age:  " + o1 );
        System.out.println("Method method :  " + o2);
    }
}

点击运行,然后你就会发现——华丽丽的报错了

Exception in thread "main" java.lang.InstantiationException: asm.CreateTest
    at java.lang.Class.newInstance(Class.java:427)
    at asm.ASMTest.main(ASMTest.java:20)
Caused by: java.lang.NoSuchMethodException: asm.CreateTest.<init>()
    at java.lang.Class.getConstructor0(Class.java:3082)
    at java.lang.Class.newInstance(Class.java:412)
    ... 1 more

asm.CreateTest.<init>()这个方法没有找到,熟悉jvm的可能会知道其实<init>就是构造函数,构造函数在jvm中会被重新命名成<init>。但是我们手写的java文件时也没有写构造函数,为什么就可以呢?翻到上面贴出的用javac编译出的Student文件,可以看到编译时编译器自动帮我们加好了构造函数。然后再把咱们自己生成的class文件的byte[]通过输出流写成class文件,在通过javap -c 查看:

 public class asm.Student{
  public int zero;

  public int getZero();
    Code:
       0: aload_0
       1: getfield      #11                 // Field zero:I
       4: ireturn
}

果然利用ASM生成的class里的确没有构造方法。ASM还是要比编译器懒一些的,哈。既然没有,咱们加上就行了。
先参考一下上面由java编译成的class文件,其实构造函数的代码:

  public asm.Student();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: bipush        10
       7: putfield      #2                  // Field age:I
      10: return

简单翻译一下这6条指令:

  1. this变量入栈
  2. 执行父类的<init>方法
  3. this再次入栈
  4. byte变量10入栈
  5. 给对象字段age赋值
  6. 方法结束

如此可以看到其实构造函数最核心的指令就会调用父类的<init>方法(暂时不考虑字段赋值的事情)。现在基本能够确定,我们手写的构造函数必须包含这三条指令

aload_0
invokespecial
return

然后和上面生成getAge方法类似的生成一个<init>方法即可,代码如下:

        mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
        mv.visitCode();
        // aload_0
        mv.visitVarInsn(ALOAD, 0);
        // 获取变量的值,
        mv.visitMethodInsn(INVOKESPECIAL,"java/lang/Object", "<init>", "()V", false);
        // 结束
        mv.visitInsn(IRETURN);
        // 设置操作数栈和本地变量表的大小
        mv.visitMaxs(1, 1);
        //结束方法生成
        mv.visitEnd();

然后再次运行,发现已经可以正常运行,输出如下

Field age:  0
Method method :  0

说好的11呢???哈,其实通过查看java文件编译后的class就能发现全局变量的默认值赋值其实是在构造函数中进行的,也就是说我们通过ASM创建字段时设置的默认值没起效果,WTF!再次修改<init>方法(类似getAge,不在添加详细注释)

mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
mv.visitVarInsn(ALOAD, 0);
mv.visitIntInsn(BIPUSH, 10);
mv.visitFieldInsn(PUTFIELD, "asm/Student", "age", "I");
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();

再次运行

Field age:  10
Method method :  10

哈,完美运行。那么可能有同学会问了,那么设置字段默认值卵用没有,为什么还有这个参数呢,其实也并不是一点用没有,当生成的字段时static时,就会起作用。这里边又会涉及到类的静态变量加载时机,<cinit>函数等等,这里就不展开细讲了,否则篇幅该hold不住了。总结起来一句话:ASM只是工具,掌握jvm知识才是硬道理。

偷梁换柱——ASM修改已有的class

其实除了动态生成class,还有一大部分需求是修改class,这里简单介绍下最复杂的修改class的Method。其他的修改照葫芦画瓢就可以。
先上目标效果,首先原始类还用咱们的Student:

public class Student{
    public int age = 11;

    public int getAge() {
        return age;
    }
}

目标是在getAge里边插入一句打印语句,即:

    public class Student{
        public int age = 11;

        public int getAge() {
            System.out.println("getAge");
            return age;
        }
    }

思路如下:

  1. 首先自定义一个ClassVisitor,重写visitMethod,这样就可以收到每个方法的回调
  2. 判断方法名称是不是getAge
  3. 如果是返回一个自定义的MethodVisitor
  4. 自定义的MethodVisitor重写visitCode(访问方法的第一个步骤)
  5. 添加相应的逻辑
  6. 通过重写visitMaxs修改操作数栈和局部变量表的大小(添加了逻辑可能会导致操作数栈和局部变量表的最大值增大)

代码如下

public class MyMethodVisitor extends MethodVisitor {
    public MyMethodVisitor(MethodVisitor mv) {
        super(ASM5, mv);
    }


    @Override
    public void visitCode() {
        mv.visitCode();
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("getAge");
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }

    @Override
    public void visitMaxs(int maxStack, int maxLocals) {
        super.visitMaxs(maxStack+1, maxLocals);
    }
}

public class MyClassVisitor extends ClassVisitor {
    public MyClassVisitor(ClassVisitor cv) {
        super(ASM5, cv);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions);
        if(name.equals("getAge")){
            return new MyMethodVisitor(methodVisitor);
        }else {
            return methodVisitor;
        }
    }
}

//修改测试代码
public static void main(String[] args) throws Exception {
        ClassReader classReader = new ClassReader(createNewClass());
        ClassWriter classWriter = new ClassWriter(classReader, 0);
        ClassVisitor cv = new MyClassVisitor(classWriter);
        classReader.accept(cv,0);
        MyClassLoader myClassLoader = new MyClassLoader();
        Class classByBytes = myClassLoader.getClassByBytes(classWriter.toByteArray());
        Object o = classByBytes.newInstance();
        Field field = classByBytes.getField("age");
        Object o1 = field.get(o);
        Method method = classByBytes.getMethod("getAge");
        Object o2 = method.invoke(o);
        System.out.println("Field age:  " + o1);
        System.out.println("Method method :  " + o2);
    }

运行:

getAge
Field age:  10
Method method :  10

注入的逻辑完美运行!修改方法逻辑不仅仅可以在方法开始插入逻辑,包括方法结束时,甚至方法体中间都可以,可以利用这种思路很方便的写出一个AOP框架。

ASMifier

ASM由于是基于jvm指令集的所以比较晦涩。官方可能是考虑到大家都是比较菜的,提供了很多的工具类,这里只介绍一种我认为最有用的:ASMifier。ASMifier最大的功能就是将一个java文件翻译成ASM生成此文件的代码。

ASMifier.main(new String[]{"asm.Student"});

运行后,就可以在控制台看见如何利用ASM生成Student类了,省了很大力气。工具类很多就不一 一介绍了,推荐一个博客有兴趣可以去看看:

https://blog.csdn.net/ljz2016/article/details/81363828

总结

ASM相对于一些其他的操作字节码的框架偏底层了一些,只提供了一些低级api,要想熟练使用还是需要比较高的jvm知识的。但是作为其他操作字节码的框架的底层实现,还是非常有必要了解一下的。真实项目中如果对性能要求不是特别高的话,结合项目需求完全可以用其他高级库代替ASM,例如cglib javassist。
突然想起来前两年做过的一个需求:拿到一个类序列化之后的文件,然后在本地没有这个类的情况下反序列化它。
当时觉得这个需求真是扯淡,现在想想做反序列化时报出ClassNotFound这个错误之前,其实已经可以获取类的包名,类名,签名,以及字段详情了。其实完全可以重写反序列化方法,然后获取到类的信息后动态生成class文件,然后再加载到内存中,之后再做正常的反序列化操作。两年前的需求现在想出了解决方案,哈!

代码地址:

https://github.com/fengao1004/ASM.git

参考:

https://blog.csdn.net/lijingyao8206/article/category/3276863

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

推荐阅读更多精彩内容