Java探针(javaagent)

JDK1.5开始引入了Agent机制(即启动java程序时添加“-javaagent”参数,Java Agent机制允许用户在JVM加载class文件的时候先加载自己编写的Agent文件,通过修改JVM传入的字节码来实现注入自定义的代码。采用这种方式时,必须在容器启动时添加jvm参数,所以需要重启Web容器。
JDK1.6新增了attach方式,可以对运行中的java进程附加agent,提供了动态修改运行中已经被加载的类的途径。一般通过VirtualMachine的attach(pid)方法获得VirtualMachine实例,随后可调用loadagent方法将JavaAgent的jar包加载到目标JVM中。

什么是java agent?

在JVM中运行中,类是通过classLoader加载.class文件进行生成的。在类加载加载.class文件生成对应的类对象之前时,我们可以通过修改.class文件内容(就是字节码修改技术),达到修改类的目的。JDK提供了对字节码进行操作的一系列api,而使用这些api开发出的程序就可以称之为java agent。

java agent能做什么?

不修改目标应用达到代码增强的目的,就好像spring的aop一样,但是java agent是直接修改字节码,而不是通过创建代理类。例如skywalking就是使用java agent技术,为目标应用代码植入监控代码,监控代码进行数据统计上报的。这种方式实现了解耦,通用的功能。

javaagent作用

  • 可以在加载java文件之前进行拦截,修改字节码。

  • 可以在运行期间修改已经加载的类的字节码。
    这种用法有很多的限制。

  • javaagent结合javassist功能更强大:可以创建类、方法、变量等。
    这实际上提供了一种虚拟机级别的 AOP 实现方式。通过以上方法就能实现对一些框架或是技术的采集点进行字节码修改,完成这些功能:对应用进行监控,对执行指定方法或是接口时额外添加操作(打印日志、打印方法执行时间、采集方法的入参和结果等)。

    很多APM监控系统就是基于此实现的,例如:Arthas、SkyWalking

javaagent使用方式

  • 方式1:在一个普通 Java 程序(带有 main 函数的 Java 类)运行时,通过 -javaagent 参数指定一个特定的 jar 文件(包含 Instrumentation 代理)来启动 Instrumentation 的代理程序。
    -javaagent 这个参数的个数是不限的,如果指定了多个,则会按指定的先后执行,执行完各个 agent 后,才会执行主程序的 main 方法。例如:
java -javaagent:D:\workspace\javaagent.jar=hello1 
-javaagent:D:\workspace\javaagent.jar=hello2 -jar D:\workspace\myTest.jar

注: hello1是参数

  • 方式2:在一个普通 Java 程序(带有 main 函数的 Java 类)运行时,通过 Java Tool API 中的 attach 方式指定进程id和特定jar包地址,启动 Instrumentation 的代理程序。

javaagent其他功能

  • 获取所有已经被加载过的类
  • 获取所有已经被初始化过了的类(执行过了clinit方法,是上面的一个子集)
  • 获取某个对象的大小
  • 将某个jar加入到bootstrapclasspath里作为高优先级被bootstrapClassloader加载
  • 将某个jar加入到classpath里供AppClassload去加载
  • 设置某些native方法的前缀,主要在查找native方法的时候做规则匹配

静态agent与动态agent

Agent分为如下两种:

  • 静态Instrument:在main加载之前运行的Agent
  • 动态Instrument:在main运行之后运行的Agent(JDK1.6以后提供)。

静态Instrument(启动时)加载Instrument过程

  • 创建并初始化 JPLISAgent;
  • 监听VMInit事件,在JVM初始化完成之后做下面的事情:
  • 创建InstrumentationImpl对象;
  • 监听ClassFileLoadHook事件;
  • 调用InstrumentationImpl的loadClassAndCallPremain方法,在这个方法里会去调用javaagent中MANIFEST.MF里指定的Premain-Class类的premain方法 ;
  • 解析javaagent中MANIFEST.MF文件的参数,并根据这些参数来设置JPLISAgent里的一些内容。

动态Instrument运行时加载Instrument过程

通过JVM的attach机制来请求目标JVM加载对应的agent,过程大致如下:

  • 创建并初始化JPLISAgent;
  • 解析 javaagent 里 MANIFEST.MF 里的参数;
  • 创建 InstrumentationImpl 对象;
  • 监听 ClassFileLoadHook 事件;
  • 调用 InstrumentationImpl 的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class类的agentmain方法。

示例1: 简单例子

agent程序

1.提供premain方法

package com.example.a;
 
import java.lang.instrument.Instrumentation;
 
public class DemoAgent {
    /**
     * 该方法在main方法之前运行,与main方法运行在同一个JVM中
     */
    public static void premain(String arg, Instrumentation instrumentation) {
        System.out.println("agent的premain(String arg, Instrumentation instrumentation)方法");
    }
 
    /**
     * 若不存在 premain(String agentArgs, Instrumentation inst),
     * 则会执行 premain(String agentArgs)
     */
    public static void premain(String arg) {
        System.out.println("agent的premain(String arg)方法");
    }
}

2.提供META-INF/MANIFEST.MF

在src/main/java的同级目录下新建META-INF文件夹,在里边新建MANIFEST.MF文件(注意最后一行必须是空行)

Manifest-Version: 1.0
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Premain-Class: com.example.a.DemoAgent

  • Premain-Class :包含 premain 方法的类(类的全路径名)
  • Agent-Class :包含 agentmain 方法的类(类的全路径名)
  • Boot-Class-Path :设置引导类加载器搜索的路径列表。查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径。按列出的顺序搜索路径。列表中的路径由一个或多个空格分开。路径使用分层 URI 的路径组件语法。如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径。相对路径根据代理 JAR 文件的绝对路径解析。忽略格式不正确的路径和不存在的路径。如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径。(可选)
  • Can-Redefine-Classes :true表示能重定义此代理所需的类,默认值为 false(可选)
  • Can-Retransform-Classes :true 表示能重转换此代理所需的类,默认值为 false (可选)
  • Can-Set-Native-Method-Prefix: true表示能设置此代理所需的本机方法前缀,默认值为 false(可选)

3.将其打包为jar包

步骤1:打包的配置入口

File=> Project Structure=> Project Settings=> Artifacts=> + => JAR=> From modules with dependencies..


image.png

步骤2:打包的配置


image.png

步骤3:打包

Build=> Build Artifacts...=> Build

此时会生成out目录,并生成jar包:


image.png

也可使用maven配置META-INF/MANIFEST.MF

使用maven,打包方便,而且不用手写META-INF/MANIFEST.MF,用插件即可:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.5.1</version>
            <!-- 指定maven编译的jdk版本。若不指定,maven3默认用jdk 1.5 maven2默认用jdk1.3 -->
            <configuration>
                <source>8</source>
                <target>8</target>
            </configuration>
        </plugin>
 
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>3.2.0</version>
            <configuration>
                <archive>
                    <!--自动添加META-INF/MANIFEST.MF -->
                    <manifest>
                        <addClasspath>true</addClasspath>
                    </manifest>
                    <manifestEntries>
                        <Menifest-Version>1.0</Menifest-Version>
                        <Premain-Class>com.example.a.DemoAgent</Premain-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>

maven的项目结构为:


image.png

应用程序
项目结构


image.png

1.提供main程序

package com.example.a;
public class Demo {
    public static void main(String[] args) {
        System.out.println("应用的main方法");
    }
}

测试
java -javaagent:D:\tmp\demo_javaagent.jar -jar demo_java.jar

结果:


image.png

示例2:统计方法的执行时间

需求:写一个agent,统计应用的某个方法的执行时间。(本处要统计的方法是:TimeTest#test方法)

agent程序

agent代码

package com.example.a;
 
import java.lang.instrument.Instrumentation;
 
public class DemoAgent {
    /**
     * 该方法在main方法之前运行,与main方法运行在同一个JVM中
     */
    public static void premain(String arg, Instrumentation instrumentation) {
        System.out.println("agent的premain(String arg, Instrumentation instrumentation)方法");
 
        instrumentation.addTransformer(new MyTransformer());
    }
 
    /**
     * 若不存在 premain(String agentArgs, Instrumentation inst),
     * 则会执行 premain(String agentArgs)
     */
    public static void premain(String arg) {
        System.out.println("agent的premain(String arg)方法");
    }
}

Transformer代码

package com.example.a;
 
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
 
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
 
public class MyTransformer implements ClassFileTransformer {
    private final String injectedClass = "com.example.a.TimeTest";
    private final String injectedMethod = "test";
 
    @Override
    public byte[] transform(ClassLoader loader,
                            String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {
 
        String realClassName = className.replace("/", ".");
 
        if (realClassName.equals(injectedClass)) {
            CtClass ctClass;
            try {
                // 使用全称,取得字节码类<使用javassist>
                ClassPool classPool = ClassPool.getDefault();
                ctClass = classPool.get(realClassName);
 
                // 得到方法实例
                CtMethod ctMethod = ctClass.getDeclaredMethod(injectedMethod);
                // 添加变量
                ctMethod.addLocalVariable("time", CtClass.longType);
                ctMethod.insertBefore("System.out.println(\"------------ Before --------\");");
                ctMethod.insertBefore("time = System.currentTimeMillis();");
 
                ctMethod.insertAfter("System.out.println(\"Elapsed Time(ms): \" + (System.currentTimeMillis() - time));");
                ctMethod.insertAfter("System.out.println(\"------------- After --------\");");
 
                return ctClass.toBytecode();
            } catch (Throwable e) { //这里要用Throwable,不要用Exception
                System.out.println(e.getMessage());
                e.printStackTrace();
            }
        }
 
        // 返回原类字节码
        return classfileBuffer;
    }
}

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
 
  <groupId>com.example</groupId>
  <artifactId>demo_javaagent</artifactId>
  <version>1.0-SNAPSHOT</version>
 
  <dependencies>
    <dependency>
      <groupId>org.javassist</groupId>
      <artifactId>javassist</artifactId>
      <version>3.28.0-GA</version>
    </dependency>
  </dependencies>
 
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.5.1</version>
        <!-- 指定maven编译的jdk版本。若不指定,maven3默认用jdk 1.5 maven2默认用jdk1.3 -->
        <configuration>
          <source>8</source>
          <target>8</target>
        </configuration>
      </plugin>
 
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        <configuration>
          <archive>
            <!--自动添加META-INF/MANIFEST.MF -->
            <manifest>
              <addClasspath>true</addClasspath>
            </manifest>
            <manifestEntries>
              <Menifest-Version>1.0</Menifest-Version>
              <Premain-Class>com.example.a.DemoAgent</Premain-Class>
              <Can-Redefine-Classes>true</Can-Redefine-Classes>
              <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>
 
</project>

应用程序
main类

package com.example.a;
 
public class Demo {
    public static void main(String[] args) {
        System.out.println("应用的main方法");
        new TimeTest().test();
    }
}

测试类

package com.example.a;
 
public class TimeTest {
    public void test() {
        System.out.println("开始执行TimeTest#test");
        System.out.println("sleep开始");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("sleep结束");
    }
}

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <groupId>com.example</groupId>
    <artifactId>demo_maven</artifactId>
    <version>1.0-SNAPSHOT</version>
 
    <dependencies>
        <dependency>
            <groupId>org.javassist</groupId>
            <artifactId>javassist</artifactId>
            <version>3.28.0-GA</version>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.5.1</version>
                <!-- 指定maven编译的jdk版本。若不指定,maven3默认用jdk 1.5 maven2默认用jdk1.3 -->
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
 
            <plugin>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>com.example.a.Demo</mainClass>
                        </manifest>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <appendAssemblyId>false</appendAssemblyId>
 
                </configuration>
 
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>assembly</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

测试
java -javaagent:D:\tmp\demo_javaagent-1.0-SNAPSHOT.jar -jar demo_maven-1.0-SNAPSHOT.jar

结果:


image.png

Agentmain(attach)

在 Java SE 6 的 Instrumentation 当中,提供了一个新的代理操作方法:agentmain,可以在 main 函数开始运行之后再运行。
跟premain函数一样, 开发者可以编写一个含有agentmain函数的 Java 类:

//采用attach机制,被代理的目标程序VM有可能很早之前已经启动,当然其所有类已经被加载完成,
//这个时候需要借助Instrumentation#retransformClasses(Class<?>... classes)
//让对应的类可以重新转换,从而激活重新转换的类执行ClassFileTransformer列表中的回调
public static void agentmain (String agentArgs, Instrumentation inst)

public static void agentmain (String agentArgs)

agentMain 主要用于对java程序的监控,调用java进程,将自己编写的agentMain 注入目标完成对程序的监控,修改。

创建agentmain

public class TestMainAgent {
    public static void agentmain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("loadagent after main run.args=" + agentArgs);

        Class<?>[] classes = instrumentation.getAllLoadedClasses();

        for (Class<?> cls : classes)
        {
            System.out.println(cls.getName());
        }

        System.out.println("agent run completely.");
    }

    static class DefineTransformer implements ClassFileTransformer {

        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
            System.out.println("premain load Class:" + className);
            return classfileBuffer;
        }
    }
}

添加maven插件打包

 <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <archive>
                        <!--自动添加META-INF/MANIFEST.MF -->
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Agent-Class>com.tttiger.TestMainAgent</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

测试agentMain插桩到其他类
另外启用了一个jvm进程,找到需要attach的jvm进程,让它加载agentMain,那么agentMain就会被加载到对方jvm执行。arthas就是使用这种方式attach进jvm进程,开启一个socket然后进行目标jvm的监控。

    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException, InterruptedException {
        //获取当前系统中所有 运行中的 虚拟机
        System.out.println("running JVM start ");
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        for (VirtualMachineDescriptor vmd : list) {
            //如果虚拟机的名称为 xxx 则 该虚拟机为目标虚拟机,获取该虚拟机的 pid
            //然后加载 agent.jar 发送给该虚拟机
            System.out.println(vmd.displayName());
            if (vmd.displayName().endsWith("com.tttiger.TestJVM")) {
                System.out.println(vmd.id());
                VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
                virtualMachine.loadAgent("e:/test-agentMain-1.0-SNAPSHOT.jar");
                virtualMachine.detach();
                System.out.println("attach");
            }
        }
        Thread.sleep(10000L);
    }

VirtualMachine 字面意义表示一个Java 虚拟机,也就是程序需要监控的目标虚拟机,提供了获取系统信息(比如获取内存dump、线程dump,类信息统计(比如已加载的类以及实例个数等), loadAgent,Attach 和 Detach (Attach 动作的相反行为,从 JVM 上面解除一个代理)等方法,可以实现的功能可以说非常之强大 。该类允许我们通过给attach方法传入一个jvm的pid(进程id),远程连接到jvm上 。

代理类注入操作只是它众多功能中的一个,通过loadAgent方法向jvm注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以 在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。

VirtualMachineDescriptor 则是一个描述虚拟机的容器类,配合 VirtualMachine 类完成各种功能

通过VirtualMachine类的attach(pid)方法,便可以attach到一个运行中的java进程上,之后便可以通过loadAgent(agentJarPath)来将agent的jar包注入到对应的进程,然后对应的进程会调用agentmain方法。


image.png

Instrumentation的局限性
大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性:

  1. premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。
  2. 类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
  • 2.1 新类和老类的父类必须相同;
  • 2.2 新类和老类实现的接口数也要相同,并且是相同的接口;
  • 2.3 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;
  • 2.4 新类和老类新增或删除的方法必须是private static/final修饰的;
  • 2.5 可以修改方法体。

除了上面的方式,如果想要重新定义一个类,可以考虑基于类加载器隔离的方式:创建一个新的自定义类加载器去通过新的字节码去定义一个全新的类,不过也存在只能通过反射调用该全新类的局限性。

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

推荐阅读更多精彩内容