跳转到内容

Java 6:Instrumentation新功能

原文日期:2017年5月16日
原文地址:https://www.ibm.com/developerworks/cn/java/j-lo-jse61/index.html
转载备注:原标题为“Java SE 6新特性:Instrumentation新功能”,调整了少量格式与错别字。

利用Java代码,即java.lang.instrument做动态Instrumentation是Java SE 5的新特性,它把Java的instrument功能从本地代码中解放出来,使之可以用Java代码的方式解决问题。使用Instrumentation,开发者可以构建一个独立于应用程序的代理程序(Agent),用来监测和协助运行在JVM上的程序,甚至能够替换和修改某些类的定义。有了这样的功能,开发者就可以实现更为灵活的运行时虚拟机监控和Java类操作了,这样的特性实际上提供了一种虚拟机级别支持的AOP实现方式,使得开发者无需对JDK做任何升级和改动,就可以实现某些AOP的功能了。

在Java SE 6里面,instrument包被赋予了更强大的功能:启动后的instrument、本地代码(native code)instrument,以及动态改变classpath等等。这些改变,意味着Java具有了更强的动态控制、解释能力,它使得Java语言变得更加灵活多变。

在Java SE 6里面,最大的改变使运行时的Instrumentation成为可能。在Java SE 5中,Instrument要求在运行前利用命令行参数或者系统参数来设置代理类,在实际的运行之中,虚拟机在初始化之时(在绝大多数的Java类库被载入之前),instrumentation的设置已经启动,并在虚拟机中设置了回调函数,检测特定类的加载情况,并完成实际工作。但是在实际的很多的情况下,我们没有办法在虚拟机启动之时就为其设定代理,这样实际上限制了instrument的应用。而Java SE 6的新特性改变了这种情况,通过Java Tool API中的attach方式,我们可以很方便地在运行过程中动态地设置加载代理类,以达到instrumentation的目的。

另外,对native的Instrumentation也是Java SE 6的一个崭新的功能,这使以前无法完成的功能——对native接口的instrumentation可以在Java SE 6中,通过一个或者一系列的prefix添加而得以完成。

最后,Java SE 6里的Instrumentation也增加了动态添加classpath的功能。所有这些新的功能,都使得instrument包的功能更加丰富,从而使Java语言本身更加强大。

Instrumentation的基本功能和用法

“java.lang.instrument”包的具体实现,依赖于JVMTI。JVMTI(Java Virtual Machine Tool Interface)是一套由Java虚拟机提供的,为JVM相关的工具提供的本地编程接口集合。JVMTI是从Java SE 5开始引入,整合和取代了以前使用的Java Virtual Machine Profiler Interface (JVMPI)和the Java Virtual Machine Debug Interface (JVMDI),而在Java SE 6中,JVMPI和JVMDI已经消失了。JVMTI提供了一套“代理”程序机制,可以支持第三方工具程序以代理的方式连接和访问JVM,并利用JVMTI提供的丰富的编程接口,完成很多跟JVM相关的功能。事实上,java.lang.instrument包的实现,也就是基于这种机制的:在Instrumentation的实现当中,存在一个JVMTI的代理程序,通过调用JVMTI当中Java类相关的函数来完成Java类的动态操作。除开Instrumentation功能外,JVMTI还在虚拟机内存管理,线程控制,方法和变量操作等等方面提供了大量有价值的函数。关于JVMTI的详细信息,请参考Java SE 6文档(请参见相关主题)当中的介绍。

Instrumentation的最大作用,就是类定义动态改变和操作。在Java SE 5及其后续版本当中,开发者可以在一个普通Java程序(带有main函数的Java类)运行时,通过-javaagent参数指定一个特定的jar文件(包含Instrumentation代理)来启动Instrumentation的代理程序。

在Java SE 5当中,开发者可以让Instrumentation代理在main函数运行前执行。简要说来就是如下几个步骤:

1. 编写premain函数

编写一个Java类,包含如下两个方法当中的任何一个:

public static void premain(String agentArgs, Instrumentation inst); // [1]
public static void premain(String agentArgs); // [2]

其中,[1]的优先级比[2]高,将会被优先执行([1]和[2]同时存在时,[2]被忽略)。

在这个premain函数中,开发者可以进行对类的各种操作。

agentArgs是premain函数得到的程序参数,随同-javaagent一起传入。与main函数不同的是,这个参数是一个字符串而不是一个字符串数组,如果程序参数有多个,程序将自行解析这个字符串。

Inst是一个java.lang.instrument.Instrumentation的实例,由JVM自动传入。java.lang.instrument.Instrumentation是instrument包中定义的一个接口,也是这个包的核心部分,集中了其中几乎所有的功能方法,例如类定义的转换和操作等等。

2. jar文件打包

将这个Java类打包成一个jar文件,并在其中的manifest属性当中加入”Premain-Class”来指定步骤1当中编写的那个带有premain的Java类。(可能还需要指定其他属性以开启更多功能。)

3. 运行

用如下方式运行带有Instrumentation的Java程序:

Terminal window
java -javaagent:jar 文件的位置 [=传入premain的参数]

对Java类文件的操作,可以理解为对一个byte数组的操作(将类文件的二进制字节流读入一个byte数组)。开发者可以在“ClassFileTransformer”的transform方法当中得到,操作并最终返回一个类的定义(一个byte数组)。这方面,Apache的BCEL开源项目提供了强有力的支持,读者可以在参考文章“Java 5特性:Instrumentation实践”中看到一个BCEL和Instrumentation结合的例子。具体的字节码操作并非本文的重点,所以,本文中所举的例子,只是采用简单的类文件替换的方式来演示Instrumentation的使用。

下面,我们通过简单的举例,来说明Instrumentation的基本使用方法。

首先,我们有一个简单的类,TransClass,可以通过一个静态方法返回一个整数1。

public class TransClass {
public int getNumber() {
return 1;
}
}

我们运行如下类,可以得到输出“1”。

public class TestMainInJar {
public static void main(String[] args) {
System.out.println(new TransClass().getNumber());
}
}

然后,我们将TransClass的getNumber方法改成如下:

public int getNumber() {
return 2;
}

再将这个返回2的Java文件编译成类文件,为了区别开原有的返回1的类,我们将返回2的这个类文件命名为TransClass2.class.2。

接下来,我们建立一个Transformer类:

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;
class Transformer implements ClassFileTransformer {
public static final String classNumberReturns2 = "TransClass.class.2";
public static byte[] getBytesFromFile(String fileName) {
try {
// precondition
File file = new File(fileName);
InputStream is = new FileInputStream(file);
long length = file.length();
byte[] bytes = new byte[(int) length];
// Read in the bytes
int offset = 0;
int numRead = 0;
while (offset < bytes.length
&& (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
offset += numRead;
}
if (offset < bytes.length) {
throw new IOException("Could not completely read file "
+ file.getName());
}
is.close();
return bytes;
} catch (Exception e) {
System.out.println("error occurs in _ClassTransformer!"
+ e.getClass().getName());
return null;
}
}
public byte[] transform(ClassLoader l, String className, Class<?> c,
ProtectionDomain pd, byte[] b) throws IllegalClassFormatException {
if (!className.equals("TransClass")) {
return null;
}
return getBytesFromFile(classNumberReturns2);
}
}

这个类实现了ClassFileTransformer接口。其中,getBytesFromFile方法根据文件名读入二进制字符流,而ClassFileTransformer当中规定的transform方法则完成了类定义的替换转换。

最后,我们建立一个Premain类,写入Instrumentation的代理方法premain:

public class Premain {
public static void premain(String agentArgs, Instrumentation inst)
throws ClassNotFoundException, UnmodifiableClassException {
inst.addTransformer(new Transformer());
}
}

可以看出,addTransformer方法并没有指明要转换哪个类。转换发生在premain函数执行之后,main函数执行之前,这时每装载一个类,transform方法就会执行一次,看看是否需要转换,所以,在transform(Transformer类中)方法中,程序用className.equals(“TransClass”)来判断当前的类是否需要转换。

代码完成后,我们将他们打包为TestInstrument1.jar。返回1的那个TransClass的类文件保留在jar包中,而返回2的那个TransClass.class.2则放到jar的外面。在manifest里面加入如下属性来指定premain所在的类:

Manifest-Version: 1.0
Premain-Class: Premain

在运行这个程序的时候,如果我们用普通方式运行这个jar中的main函数,可以得到输出“1”。如果用下列方式运行:

Terminal window
java -javaagent:TestInstrument1.jar -cp TestInstrument1.jar TestMainInJar

则会得到输出“2”。

当然,程序运行的main函数不一定要放在premain所在的这个jar文件里面,这里只是为了例子程序打包的方便而放在一起的。

除开用addTransformer的方式,Instrumentation当中还有另外一个方法“redefineClasses”来实现premain当中指定的转换。用法类似,如下:

public class Premain {
public static void premain(String agentArgs, Instrumentation inst)
throws ClassNotFoundException, UnmodifiableClassException {
ClassDefinition def = new ClassDefinition(TransClass.class, Transformer
.getBytesFromFile(Transformer.classNumberReturns2));
inst.redefineClasses(new ClassDefinition[] { def });
System.out.println("success");
}
}

redefineClasses的功能比较强大,可以批量转换很多类。

虚拟机启动后的动态instrument

在Java SE 5当中,开发者只能在premain当中施展想象力,所作的Instrumentation也仅限与main函数执行前,这样的方式存在一定的局限性。

在Java SE 5的基础上,Java SE 6针对这种状况做出了改进,开发者可以在main函数开始执行以后,再启动自己的Instrumentation程序。

在Java SE 6的Instrumentation当中,有一个跟premain“并驾齐驱”的“agentmain”方法,可以在main函数开始运行之后再运行。

跟premain函数一样,开发者可以编写一个含有“agentmain”函数的Java类:

public static void agentmain(String agentArgs, Instrumentation inst); // [1]
public static void agentmain(String agentArgs); // [2]

同样,[1]的优先级比[2]高,将会被优先执行。

跟premain函数一样,开发者可以在agentmain中进行对类的各种操作。其中的agentArgs和Inst的用法跟premain相同。

与“Premain-Class”类似,开发者必须在manifest文件里面设置“Agent-Class”来指定包含agentmain函数的类。

可是,跟premain不同的是,agentmain需要在main函数开始运行后才启动,这样的时机应该如何确定呢,这样的功能又如何实现呢?

在Java SE 6文档当中,开发者也许无法在java.lang.instrument包相关的文档部分看到明确的介绍,更加无法看到具体的应用agnetmain的例子。不过,在Java SE 6的新特性里面,有一个不太起眼的地方,揭示了agentmain的用法。这就是Java SE 6当中提供的Attach API。

Attach API不是Java的标准API,而是Sun公司提供的一套扩展API,用来向目标JVM“附着”(Attach)代理工具程序的。有了它,开发者可以方便的监控一个JVM,运行一个外加的代理程序。

Attach API很简单,只有2个主要的类,都在com.sun.tools.attach包里面:VirtualMachine代表一个Java虚拟机,也就是程序需要监控的目标虚拟机,提供了JVM枚举,Attach动作和Detach动作(Attach动作的相反行为,从 JVM上面解除一个代理)等等 ; VirtualMachineDescriptor则是一个描述虚拟机的容器类,配合VirtualMachine类完成各种功能。

为了简单起见,我们举例简化如下:依然用类文件替换的方式,将一个返回1的函数替换成返回2的函数,Attach API写在一个线程里面,用睡眠等待的方式,每隔半秒时间检查一次所有的Java虚拟机,当发现有新的虚拟机出现的时候,就调用attach函数,随后再按照Attach API文档里面所说的方式装载Jar文件。等到5秒钟的时候,attach程序自动结束。而在main函数里面,程序每隔半秒钟输出一次返回值(显示出返回值从1变成2)。

TransClass类和Transformer类的代码不变,参看上一节介绍。含有main函数的TestMainInJar代码为:

public class TestMainInJar {
public static void main(String[] args) throws InterruptedException {
System.out.println(new TransClass().getNumber());
int count = 0;
while (true) {
Thread.sleep(500);
count++;
int number = new TransClass().getNumber();
System.out.println(number);
if (3 == number || count >= 10) {
break;
}
}
}
}

含有agentmain的AgentMain类的代码为:

import java.lang.instrument.ClassDefinition;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class AgentMain {
public static void agentmain(String agentArgs, Instrumentation inst)
throws ClassNotFoundException, UnmodifiableClassException,
InterruptedException {
inst.addTransformer(new Transformer(), true);
inst.retransformClasses(TransClass.class);
System.out.println("Agent Main Done");
}
}

其中,retransformClasses是Java SE 6里面的新方法,它跟redefineClasses一样,可以批量转换类定义,多用于agentmain场合。

Jar文件跟Premain那个例子里面的Jar文件差不多,也是把main和agentmain的类、TransClass、Transformer等类放在一起,打包为“TestInstrument1.jar”,而Jar文件当中的Manifest文件为:

Manifest-Version: 1.0
Agent-Class: AgentMain

另外,为了运行Attach API,我们可以再写一个控制程序来模拟监控过程(代码片段):

import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
...
// 一个运行Attach API的线程子类
static class AttachThread extends Thread {
private final List<VirtualMachineDescriptor> listBefore;
private final String jar;
AttachThread(String attachJar, List<VirtualMachineDescriptor> vms) {
listBefore = vms; // 记录程序启动时的VM集合
jar = attachJar;
}
public void run() {
VirtualMachine vm = null;
List<VirtualMachineDescriptor> listAfter = null;
try {
int count = 0;
while (true) {
listAfter = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : listAfter) {
if (!listBefore.contains(vmd)) {
// 如果VM有增加,我们就认为是被监控的VM启动了
// 这时,我们开始监控这个VM
vm = VirtualMachine.attach(vmd);
break;
}
}
Thread.sleep(500);
count++;
if (null != vm || count >= 10) {
break;
}
}
vm.loadAgent(jar);
vm.detach();
} catch (Exception e) {
ignore
}
}
}
...
public static void main(String[] args) throws InterruptedException {
new AttachThread("TestInstrument1.jar", VirtualMachine.list()).start();
}

运行时,可以首先运行上面这个启动新线程的main函数,然后,在5秒钟内(仅仅简单模拟JVM的监控过程)运行如下命令启动测试Jar文件:

Terminal window
java -javaagent:TestInstrument2.jar -cp TestInstrument2.jar TestMainInJar

如果时间掌握得不太差的话,程序首先会在屏幕上打出1,这是改动前的类的输出,然后会打出一些2,这个表示agentmain已经被Attach API成功附着到JVM上,代理程序生效了,当然,还可以看到“Agent Main Done”字样的输出。

以上例子仅仅只是简单示例,简单说明这个特性而已。真实的例子往往比较复杂,而且可能运行在分布式环境的多个JVM之中。

本地方法的Instrumentation

在1.5版本的instumentation里,并没有对Java本地方法(Native Method)的处理方式,而且在Java标准的JVMTI之下,并没有办法改变method signature,这就使替换本地方法非常地困难。一个比较直接而简单的想法是,在启动时替换本地代码所在的动态链接库——但是这样,本质上是一种静态的替换,而不是动态的Instrumentation。而且,这样可能需要编译较大数量的动态链接库——比如,我们有三个本地函数,假设每一个都需要一个替换,而在不同的应用之下,可能需要不同的组合,那么如果我们把三个函数都编译在同一个动态链接库之中,最多我们需要8个不同的动态链接库来满足需要。当然,我们也可以独立地编译之,那样也需要6个动态链接库——无论如何,这种繁琐的方式是不可接受的。

在Java SE 6中,新的Native Instrumentation提出了一个新的native code的解析方式,作为原有的native method的解析方式的一个补充,来很好地解决了一些问题。这就是在新版本的java.lang.instrument包里,我们拥有了对native代码的instrument方式——设置prefix。

假设我们有了一个native函数,名字叫nativeMethod,在运行中过程中,我们需要将它指向另外一个函数(需要注意的是,在当前标准的JVMTI之下,除了native函数名,其他的signature需要一致)。比如我们的Java代码是:

package nativeTester;
class nativePrefixTester{
...
native int nativeMethod(int input);
...
}

那么我们已经实现的本地代码是:

jint Java_nativeTester_nativeMethod(jclass thiz, jobject thisObj, jint input);

现在我们需要在调用这个函数时,使之指向另外一个函数。那么按照J2SE的做法,我们可以按它的命名方式,加上一个prefix作为新的函数名。比如,我们以”another_“作为prefix,那么我们新的函数是:

jint Java_nativeTester_another_nativePrefixTester(jclass thiz, jobject thisObj,
jint input);

然后将之编入动态链接库之中。

现在我们已经有了新的本地函数,接下来就是做instrument的设置。正如以上所说的,我们可以使用premain方式,在虚拟机启动之时就载入premain完成instrument代理设置。也可以使用agentmain方式,去attach虚拟机来启动代理。而设置native函数的也是相当简单的:

premain() { // 或者也可以在agentmain里
...
if (!isNativeMethodPrefixSupported()){
return; // 如果无法设置,则返回
}
setNativeMethodPrefix(transformer,"another_"); // 设置native函数的prefix,注意这个下划线必须由用户自己规定
...
}

在这里要注意两个问题。一是不是在任何的情况下都是可以设置native函数的prefix的。首先,我们要注意到agent包之中的Manifest所设定的特性:

Can-Set-Native-Method-Prefix

要注意,这一个参数都可以影响是否可以设置native prefix,而且,在默认的设置之中,这个参数是false的,我们需要将之设置成true(顺便说一句,对Manifest之中的属性来说都是大小写无关的,当然,如果给一个不是“true”的值,就会被当作false值处理)。

当然,我们还需要确认虚拟机本身是否支持setNativePrefix。在Java API里,Instrumentation类提供了一个函数isNativePrefix,通过这个函数我们可以知道该功能是否可以实行。

二是我们可以为每一个ClassTransformer加上它自己的nativeprefix;同时,每一个ClassTransformer都可以为同一个class做transform,因此对于一个Class来说,一个native函数可能有不同的prefix,因此对这个函数来说,它可能也有好几种解析方式。

在Java SE 6当中,Native prefix的解释方式如下:对于某一个package内的一个class当中的一个native method来说,首先,假设我们对这个函数的transformer设置了native的prefix“another”,它将这个函数接口解释成:

由Java的函数接口:

native void method()

和上述prefix”another”,去寻找本地代码中的函数:

void Java_package_class_another_method(jclass theClass, jobject thiz);
// 请注意 prefix 在函数名中出现的位置!

一旦可以找到,那么调用这个函数,整个解析过程就结束了;如果没有找到,那么虚拟机将会做进一步的解析工作。我们将利用Java native接口最基本的解析方式 , 去找本地代码中的函数:

void Java_package_class_method(jclass theClass, jobject thiz);

如果找到,则执行之。否则,因为没有任何一个合适的解析方式,于是宣告这个过程失败。

那么如果有多个transformer,同时每一个都有自己的prefix,又该如何解析呢?事实上,虚拟机是按transformer被加入到的Instrumentation之中的次序去解析的(还记得我们最基本的addTransformer方法吗?)。

假设我们有三个transformer要被加入进来,他们的次序和相对应的prefix分别为:transformer1和“prefix1_”,transformer2和“prefix2_”,transformer3和“prefix3_”。那么,虚拟机会首先做的就是将接口解析为:

native void prefix1_prefix2_prefix3_native_method()

然后去找它相对应的native代码。

但是如果第二个transformer(transformer2)没有设定prefix,那么很简单,我们得到的解析是:

native void prefix1_prefix3_native_method()

这个方式简单而自然。

当然,对于多个prefix的情况,我们还要注意一些复杂的情况。比如,假设我们有一个native函数接口是:

native void native_method()

然后我们为它设置了两个prefix,比如”wrapped_“和”wrapped2_“,那么,我们得到的是什么呢?是这个吗:

void Java_package_class_wrapped_wrapped2_method(jclass theClass, jobject thiz);
// 这个函数名正确吗?

答案是否定的,因为事实上,对Java中native函数的接口到native中的映射,有一系列的规定,因此可能有一些特殊的字符要被代入。而实际中,这个函数的正确的函数名是:

void Java_package_class_wrapped_1wrapped2_1method(jclass theClass, jobject thiz);
// 只有这个函数名会被找到

很有趣不是吗?因此如果我们要做类似的工作,一个很好的建议是首先在Java中写一个带prefix的native接口,用javah工具生成一个c的header-file,看看它实际解析得到的函数名是什么,这样我们就可以避免一些不必要的麻烦。

另外一个事实是,与我们的想像不同,对于两个或者两个以上的prefix,虚拟机并不做更多的解析;它不会试图去掉某一个prefix,再来组装函数接口。它做且仅作两次解析。

总之,新的native的prefix-instrumentation的方式,改变了以前Java中native代码无法动态改变的缺点。在当前,利用JNI来写native代码也是Java应用中非常重要的一个环节,因此它的动态化意味着整个Java都可以动态改变了——现在我们的代码可以利用加上prefix来动态改变native函数的指向,正如上面所说的,如果找不到,虚拟机还会去尝试做标准的解析,这让我们拥有了动态地替换native代码的方式,我们可以将许多带不同prefix的函数编译在一个动态链接库之中,而通过instrument包的功能,让native函数和Java函数一样动态改变、动态替换。

当然,现在的native的instrumentation还有一些限制条件,比如,不同的transformer会有自己的native prefix,就是说,每一个transformer会负责他所替换的所有类而不是特定类的prefix——因此这个粒度可能不够精确。

BootClassPath/SystemClassPath的动态增补

我们知道,通过设置系统参数或者通过虚拟机启动参数,我们可以设置一个虚拟机运行时的boot class加载路径(-Xbootclasspath)和system class(-cp)加载路径。当然,我们在运行之后无法替换它。然而,我们也许有时候要需要把某些jar加载到bootclasspath之中,而我们无法应用上述两个方法;或者我们需要在虚拟机启动之后来加载某些jar进入bootclasspath。在Java SE 6之中,我们可以做到这一点了。

实现这几点很简单,首先,我们依然需要确认虚拟机已经支持这个功能,然后在premain/agantmain之中加上需要的classpath。我们可以在我们的Transformer里使用appendToBootstrapClassLoaderSearch/appendToSystemClassLoaderSearch来完成这个任务。

同时我们可以注意到,在agent的manifest里加入Boot-Class-Path其实一样可以在动态地载入agent的同时加入自己的boot class路径,当然,在Java code中它可以更加动态方便和智能地完成——我们可以很方便地加入判断和选择成分。

在这里我们也需要注意几点。首先,我们加入到classpath的jar文件中不应当带有任何和系统的instrumentation有关的系统同名类,不然,一切都陷入不可预料之中——这不是一个工程师想要得到的结果,不是吗?

其次,我们要注意到虚拟机的ClassLoader的工作方式,它会记载解析结果。比如,我们曾经要求读入某个类someclass,但是失败了,ClassLoader会记得这一点。即使我们在后面动态地加入了某一个jar,含有这个类,ClassLoader依然会认为我们无法解析这个类,与上次出错的相同的错误会被报告。

再次我们知道在Java语言中有一个系统参数“java.class.path”,这个property里面记录了我们当前的classpath,但是,我们使用这两个函数,虽然真正地改变了实际的classpath,却不会对这个property本身产生任何影响。

在公开的JavaDoc中我们可以发现一个很有意思的事情,Sun的设计师们告诉我们,这个功能事实上依赖于ClassLoader的appendtoClassPathForInstrumentation方法——这是一个非公开的函数,因此我们不建议直接(使用反射等方式)使用它,事实上,instrument包里的这两个函数已经可以很好的解决我们的问题了。

结语

从以上的介绍我们可以得出结论,在Java SE 6里面,instrumentation包新增的功能——虚拟机启动后的动态instrument、本地代码(native code)instrumentation,以及动态添加classpath等等,使得Java具有了更强的动态控制、解释能力,从而让Java语言变得更加灵活多变。

这些能力,从某种意义上开始改变Java语言本身。在过去很长的一段时间内,动态脚本语言的大量涌现和快速发展,对整个软件业和网络业提高生产率起到了非常重要的作用。在这种背景之下,Java也正在慢慢地作出改变。而Instrument的新功能和Script平台(本系列的后面一篇中将介绍到这一点)的出现,则大大强化了语言的动态化和与动态语言融合,它是Java的发展的值得考量的新趋势。

相关主题