上一篇我们简单的过了一下SPI的基本概念,以及简单使用了下Dubbo提供的一些SPI。
那么这篇我们将真正进入Dubbo的源码分析环节,也就是说通过分析Dubbo的SPI源码一步一步的进入Dubbo的内心世界。
因为SPI源代码很长,碍于篇幅原因可能会拆分成多份。
这一篇的内容如下:
- dubbo源码分析环境搭建
- ExtensionLoader.getExtesionLoader()源码分析
0x01 Dubbo源码分析环境搭建
- 事先说明,这里用来分析的Dubbo版本是2.7.2,工具用的是
IDEA
- 首先我们从github上下载源码包。地址是dubbo.我们下载2.7.2版本。
- 解压zip包,然后打开IDEA,选择
File->New->Project from existing Sources...
- 选择你解压的路径。然后点击ok,然后在弹出的对话框中选中
import project from external model
和maven
- 选择Finish。搞定
0x02 从哪入手分析SPI?
- 环境我们搞好了,可以入手分析了,那么从哪里开始切入呢?还记得我们上一篇里面的demo么?我们加载扩展点的时候,第一行代码就是写它——
ExtensionLoader.getExtensionLoader(xxxx.class);
,那我们从getExtensionLoader()
方法开始切入。 ExtensionLoader.getExtensionLoader(xxxx.class)
- 首先我们不看源码,从字面意思上看,它是获取一个类的
ExtensionLoader
.返回的是一个ExtensionLoader
,然后我们就可以通过这个ExtensionLoader
根据key来获取相应的实现类。那么come on。看看他内部吧。 - 代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
//校验我们跳过,可以从字面以上理解
if (type == null) {
throw new IllegalArgumentException("Extension type == null");
}
if (!type.isInterface()) {
throw new IllegalArgumentException("Extension type (" + type + ") is not an interface!");
}
if (!withExtensionAnnotation(type)) {
throw new IllegalArgumentException("Extension type (" + type +
") is not an extension, because it is NOT annotated with @" + SPI.class.getSimpleName() + "!");
}
//这里是首先从EXTENSION_LOADERS中获取ExtensionLoader。
//联系上下文来看,这个EXTENSION_LOADERS一定是个缓存。因为如果每次调用都去load一次文件,效率不高。
//定义为:ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS = new ConcurrentHashMap<>();
//先从缓存中get。缓存中没有再去new一个。
ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
if (loader == null) {
//如果缓存中没有,就new一个。
EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type));
loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
}
return loader;
}
//ExtensionLoader的构造方法
private ExtensionLoader(Class<?> type) {
this.type = type;
//这里我们可以看到是获取的AdaptiveExtension,这里我们后面回过头说。
objectFactory = (type == ExtensionFactory.class ? null : ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension());
} - 从上面的代码来说,这个
getExtensionLoader
方法无法就是做了如下几件事:- 校验Class
- 从缓存(concurrentHashMap)中get
- 如果缓存中没有,就new一个出来。
- 下面我们看看
getExtension(String)
方法吧。
- 首先我们不看源码,从字面意思上看,它是获取一个类的
ExtensionLoader.getExtension()
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53public T getExtension(String name) {
//name的判断
if (StringUtils.isEmpty(name)) {
throw new IllegalArgumentException("Extension name == null");
}
if ("true".equals(name)) {
return getDefaultExtension();
}
//获取一个Holder实例,Holder类的代码和getOrCreateHolder方法体见下方。
Holder<Object> holder = getOrCreateHolder(name);
//获取实例。
Object instance = holder.get();
//如果实例为空就调用createExtension创建一个。
if (instance == null) {
synchronized (holder) {
//锁住后再get,判断一次,防止重复创建
instance = holder.get();
if (instance == null) {
//创建实例
instance = createExtension(name);
holder.set(instance);
}
}
}
return (T) instance;
}
/**
* Holder类代码
*/
public class Holder<T> {
private volatile T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
//getOrCreateHolder()方法代码。从下面看,和ExtensionLoader处理一样,先从缓存拿,拿不到就new一个。
private Holder<Object> getOrCreateHolder(String name) {
Holder<Object> holder = cachedInstances.get(name);
if (holder == null) {
cachedInstances.putIfAbsent(name, new Holder<>());
holder = cachedInstances.get(name);
}
return holder;
}- 从上面的代码来看,
getExtension()
做了如下几个事情:- key的合法性判断
- 获取Holder,会先从缓存中获取,缓存中取不到就new一个Holder。
- 从Holder中拿对应的对象,如果没有就调用
createExtension()
创建个。
- 从上面的代码来看,
- 很明显,
createExtension()
方法才是我们的重头戏。在使用的时候,我们应该会有一个疑问,我们配置的这个key-value文件是在什么时候读取的呢?它又是如何被加载的?里面还会有其他的功能吗?这一切的疑问,都将在createExtension()
方法中为你解答。- 这部分代码,我打算先把里面的代码贴出来,然后逐步讲解。首先我们来总览一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28private T createExtension(String name) {
//(1)
Class<?> clazz = getExtensionClasses().get(name);
if (clazz == null) {
throw findException(name);
}
try {
//(2)
T instance = (T) EXTENSION_INSTANCES.get(clazz);
if (instance == null) {
EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance());
instance = (T) EXTENSION_INSTANCES.get(clazz);
}
//(3)
injectExtension(instance);
//(4)
Set<Class<?>> wrapperClasses = cachedWrapperClasses;
if (CollectionUtils.isNotEmpty(wrapperClasses)) {
for (Class<?> wrapperClass : wrapperClasses) {
instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
}
}
return instance;
} catch (Throwable t) {
throw new IllegalStateException("Extension instance (name: " + name + ", class: " +type + ") couldn't be instantiated: " + t.getMessage(), t);
}
} - 如上所见,我会把代码分为4步进行说明。下面开始来看看标为
(1)
的部分。摘出来的代码如下:1
2
3
4Class<?> clazz = getExtensionClasses().get(name);
if (clazz == null) {
throw findException(name);
}- 从上面第一行代码来看,他是先调用getExtensionClasses()方法,获取一个东西(可能是个Map,我们暂不关心),然后直接通过传入的name就可以get到一个我们想要的Class。那么这时候我们就可以猜想一下:文件解析的操作是不是会在
getExtensionClasses()
方法中完成呢?然后构建出一个Map,这样就可以让我们愉快的使用name直接get到一个Class了。代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15private Map<String, Class<?>> getExtensionClasses() {
//cachedClasses一看就是一个缓存。先从缓存中get。缓存中get不到再解析配置。
Map<String, Class<?>> classes = cachedClasses.get();
if (classes == null) {
synchronized (cachedClasses) {
classes = cachedClasses.get();
if (classes == null) {
//解析配置
classes = loadExtensionClasses();
cachedClasses.set(classes);
}
}
}
return classes;
} - 上面代码逻辑很简单,解析配置会在loadExtensionClasses()中体现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21private Map<String, Class<?>> loadExtensionClasses() {
//提取并缓存默认扩展名(如果存在)。也就是缓存@SPI的value属性
cacheDefaultExtensionName();
Map<String, Class<?>> extensionClasses = new HashMap<>();
//加载DUBBO_INTERNAL_DIRECTORY下面的文件,
//type就是当前要加载的类,也就是我们ExtensionLoader.getExtensionLoader()方法所传递的类的全限定名
loadDirectory(extensionClasses, DUBBO_INTERNAL_DIRECTORY, type.getName());
loadDirectory(extensionClasses, DUBBO_INTERNAL_DIRECTORY, type.getName().replace("org.apache", "com.alibaba"));
//加载DUBBO_DIRECTORY下面的文件
loadDirectory(extensionClasses, DUBBO_DIRECTORY, type.getName());
loadDirectory(extensionClasses, DUBBO_DIRECTORY, type.getName().replace("org.apache", "com.alibaba"));
//加载SERVICES_DIRECTORY下面的文件
loadDirectory(extensionClasses, SERVICES_DIRECTORY, type.getName());
loadDirectory(extensionClasses, SERVICES_DIRECTORY, type.getName().replace("org.apache", "com.alibaba"));
return extensionClasses;
} - 通过上面的代码,我们发现其实就加载了3个目录下面的文件,因为最开始的时候阿里还没有把dubbo捐apache。所以它的包名都是com.alibaba。所以才会有repace(“org.apache”,“com.alibaba”)的代码。那么是哪三个目录呢?
- DUBBO_INTERNAL_DIRECTORY:
META-INF/dubbo/internal/
- DUBBO_DIRECTORY:
META-INF/dubbo/
- SERVICES_DIRECTORY:
META-INF/services/
- DUBBO_INTERNAL_DIRECTORY:
- 接下来我们往里面分析
loadDirectory()
方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25private void loadDirectory(Map<String, Class<?>> extensionClasses, String dir, String type) {
//从传参可以看出,这里拼起来就类似`META-INF/dubbo/internal/top.zyzling.IHelloService`
String fileName = dir + type;
try {
Enumeration<java.net.URL> urls;
//获取当前的ClassLoader,下面加载类到内存时肯定会用到
ClassLoader classLoader = findClassLoader();
if (classLoader != null) {
//获取我们的文件
urls = classLoader.getResources(fileName);
} else {
urls = ClassLoader.getSystemResources(fileName);
}
if (urls != null) {
while (urls.hasMoreElements()) {
java.net.URL resourceURL = urls.nextElement();
//解析文件 & 加载类到内存
loadResource(extensionClasses, classLoader, resourceURL);
}
}
} catch (Throwable t) {
logger.error("Exception occurred when loading extension class (interface: " +
type + ", description file: " + fileName + ").", t);
}
}- 从上面的代码我们可以很清晰的知道,为什么我们的文件要以接口的全限定名为名,并且需要扔到特定的目录下。下面我们一起来看看
loadResource()
方法
- 从上面的代码我们可以很清晰的知道,为什么我们的文件要以接口的全限定名为名,并且需要扔到特定的目录下。下面我们一起来看看
loadResource()
方法代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader, java.net.URL resourceURL) {
try {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
String line;
//读取文件
while ((line = reader.readLine()) != null) {
final int ci = line.indexOf('#');
if (ci >= 0) {
line = line.substring(0, ci);
}
line = line.trim();
if (line.length() > 0) {
try {
String name = null;
int i = line.indexOf('=');
if (i > 0) {
// =号前面部分,也就是我们的key
name = line.substring(0, i).trim();
// =号后面部分,也就是我们的实现类的全限定名
line = line.substring(i + 1).trim();
}
if (line.length() > 0) {
//调用loadClass把实现类加载到内存中,解析这个类,并缓存取来
//注意,在调这个方法的时候就已经通过Class.forName()把类加载到了内存
loadClass(extensionClasses, resourceURL, Class.forName(line, true, classLoader), name);
}
} catch (Throwable t) {
IllegalStateException e = new IllegalStateException("Failed to load extension class (interface: " + type + ", class line: " + line + ") in " + resourceURL + ", cause: " + t.getMessage(), t);
exceptions.put(line, e);
}
}
}
}
} catch (Throwable t) {
logger.error("Exception occurred when loading extension class (interface: " +
type + ", class file: " + resourceURL + ") in " + resourceURL, t);
}
}- 上面的代码就是循环读取文件,利用
=
切割key和对应的实现类,最后调用loadClass()
方法进行加载。
- 上面的代码就是循环读取文件,利用
loadClass()
方法代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59private void loadClass(Map<String, Class<?>> extensionClasses, java.net.URL resourceURL, Class<?> clazz, String name) throws NoSuchMethodException {
//判断该实现类的接口是不是我们getExtensionLoader()所传递的接口类。
//也就是加载的实现类必须是要实现getExtensionLoader()所传递的接口类,否则报错。
if (!type.isAssignableFrom(clazz)) {
throw new IllegalStateException("Error occurred when loading extension class (interface: " + type + ", class line: " + clazz.getName() + "), class "+ clazz.getName() + " is not subtype of interface.");
}
//判断在类上是否有加@Adaptive注解。这里后面讲到@Adaptive注解时再回过头来看一下。
if (clazz.isAnnotationPresent(Adaptive.class)) {
//todo something。等讲到@Adaptive注解时在回过头来看
cacheAdaptiveClass(clazz);
} else if (isWrapperClass(clazz)) {
//如果是Warpper类,则缓存起来。怎样才是Warpper类呢?代码在下方
//那么这里有什么用呢?等后面我们说到的时候再回过头来看
cacheWrapperClass(clazz);
} else {
//到这里,说明上面的条件都不满足,
clazz.getConstructor();
//如果我们没有定义key。就使用实现类的simpleName小写
if (StringUtils.isEmpty(name)) {
name = findAnnotationName(clazz);
if (name.length() == 0) {
throw new IllegalStateException("No such extension name for the class " + clazz.getName() + " in the config " + resourceURL);
}
}
//使用","切割name,也就是我们的key。如果配了多个,就需要缓存多个。
String[] names = NAME_SEPARATOR.split(name);
if (ArrayUtils.isNotEmpty(names)) {
//缓存激活扩展点,等讲到激活扩展点时再回过头看
cacheActivateClass(clazz, names[0]);
for (String n : names) {
//缓存key
cacheName(clazz, n);
//缓存类,会先从Map中get,没有才会缓存,有就会报错。实现在下方
saveInExtensionClass(extensionClasses, clazz, name);
}
}
}
}
//判断是否是Wrapper类。
//判断方法就是看该实现类是否有一个参数为:getExtensionLoader()方法传递的接口类的构造方法。
private boolean isWrapperClass(Class<?> clazz) {
try {
clazz.getConstructor(type);
return true;
} catch (NoSuchMethodException e) {
return false;
}
}
private void saveInExtensionClass(Map<String, Class<?>> extensionClasses, Class<?> clazz, String name) {
//先从Map中获取,如果没有就put,有就抛异常
Class<?> c = extensionClasses.get(name);
if (c == null) {
extensionClasses.put(name, clazz);
} else if (c != clazz) {
throw new IllegalStateException("Duplicate extension " + type.getName() + " name " + name + " on " + c.getName() + " and " + clazz.getName());
}
}- 到这里我们整个
getExtensionClasses()
方法,算是从外到内全部分析完毕了。简单总结下吧,总共做了如下事情:
- 首先从缓存中获取对应的Map,如果有就直接返回,没有的话就调用loadExtensionClasses
加载.
- 那么加载做了什么呢?首先会找到我们特定的目录,寻找下面以getExtensionLoader()
方法的参数接口的全限定名为名的文件。
- 然后就是解析文件了,通过=
分割,把key和对应的实现类的全限定名取出来。
- 使用Class.forName()加载实现类到内存。
- 分析实现类,并缓存到Map中。
- 到这里我们整个
- 从上面第一行代码来看,他是先调用getExtensionClasses()方法,获取一个东西(可能是个Map,我们暂不关心),然后直接通过传入的name就可以get到一个我们想要的Class。那么这时候我们就可以猜想一下:文件解析的操作是不是会在
- 到这里终于把
(1)
中的Class<?> clazz = getExtensionClasses().get(name);
给搞完了。下面我们继续看(2)
1
2
3
4
5
6
7//....以上代码省略
T instance = (T) EXTENSION_INSTANCES.get(clazz);
if (instance == null) {
EXTENSION_INSTANCES.putIfAbsent(clazz, clazz.newInstance());
instance = (T) EXTENSION_INSTANCES.get(clazz);
}
//....以下代码省略 - 上面的代码逻辑比较清晰和简单,就是先会根据我们
getExtensionClass().getName(String)
返回的Class对象去缓存中取实例。如果实例没有,就调用Class.newInstance()
创建一个实例到缓存。 - 继续,我们看
(3)
,代码如下:1
2
3//....以上代码省略
injectExtension(instance);
//....以下代码省略- 从方法名上看,
inject
这个单词的中文翻译就是注入,看全方法名,意识是注入扩展。那么方法里的内容肯定是和注入相关了。dubbo的扩展点为什么需要注入?设想下,假设我有一个SPI扩展实现类,我里面的属性包含其他的SPI扩展的实现类,这时候是不是需要对其进行初始化?也就是说要注入。说起注入,我们就会想到Spring依赖注入,Spring提供了构造方法注入,set方法注入,属性注入。那么Dubbo是怎么实现的呢?一探究竟吧。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37private T injectExtension(T instance) {
try {
if (objectFactory != null) {
//获取实例的所有方法,并遍历
for (Method method : instance.getClass().getMethods()) {
//如果方法是public setXXX
if (isSetter(method)) {
//判断是否有@DisableInject注解。如果有该注解,就跳过注入
if (method.getAnnotation(DisableInject.class) != null) {
continue;
}
//拿到第一个参数的类型,如果是基本数据类型或者String、Date、Number、Boolean等就跳过
Class<?> pt = method.getParameterTypes()[0];
if (ReflectUtils.isPrimitives(pt)) {
continue;
}
try {
//获取set后面的参数名。比如setVersion。那么property = version
String property = getSetterProperty(method);
//找到对应的扩展实现类,实现注入。
Object object = objectFactory.getExtension(pt, property);
if (object != null) {
//调用set方法注入
method.invoke(instance, object);
}
} catch (Exception e) {
logger.error("Failed to inject via method " + method.getName()
+ " of interface " + type.getName() + ": " + e.getMessage(), e);
}
}
}
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return instance;
}- 从上面代码看,dubbo SPI实现类的注入方式就明晓了,原来它是通过
set
方法进行注入。
- 从方法名上看,
- 接下来就是
createExtension()
方法的最后一部分了。代码如下:1
2
3
4
5
6Set<Class<?>> wrapperClasses = cachedWrapperClasses;
if (CollectionUtils.isNotEmpty(wrapperClasses)) {
for (Class<?> wrapperClass : wrapperClasses) {
instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
}
} - 首先,这里是先获取一个cacheWrapperClasses,是一个Set,那么这个值是从哪里来的呢?还记得我们分析
(1)
部分时,最后面说的那几个后面碰到的时候才说的地方么?emm。来往上面找找。在loadClass()
方法中,有这样的一个判断:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25if(...){.....}
else if (isWrapperClass(clazz)) {
//如果是Warpper类,则缓存起来。怎样才是Warpper类呢?代码在下方
//那么这里有什么用呢?等后面我们说到的时候再回过头来看
cacheWrapperClass(clazz);
}
//判断是否是Wrapper类。
//判断方法就是看该实现类是否有一个参数为:getExtensionLoader()方法传递的接口类的构造方法。
private boolean isWrapperClass(Class<?> clazz) {
try {
//这里的type是我们ExtensionLoader.getExtensionLoader()传递的接口类
clazz.getConstructor(type);
return true;
} catch (NoSuchMethodException e) {
return false;
}
}
//缓存WarpperClass
private void cacheWrapperClass(Class<?> clazz) {
if (cachedWrapperClasses == null) {
cachedWrapperClasses = new ConcurrentHashSet<>();
}
cachedWrapperClasses.add(clazz);
}- 这时候我我们来好好说说这段代码干了些啥事。首先我们要明确,WarpperClass的字面意思包装类,也就是把一个类包装起来,增强实现类的功能,这就是我们的装饰器模式。于是判断是否是WarpperClass的那段代码就好理解了。只要我这个实现类有一个构造方法的参数是接口类,就认为他是一个WarpperClass。因为我们包装的话,肯定是这个接口下的所有实现类都能包装。然后就是缓存啦。缓存到`cachedWrapperClasses`中,它是一个`ConcurrentHashSet`。
- 回到现在
(3)
部分的代码。现在wrapperClass
变量的值是什么,怎么来的我们都知道了。那么下面肯定是会对现在的实现类进行包装。如果有多个情况,会一个套一个的包装。这就类似于我们操作IO流,你经常会看到如下的代码:这就是装饰器模式。一个套一个。1
InputStream inputStream = new BufferedInputStream(new FileInputStream("xxxx"))
- 那么下面我们来看看他是如何包装的吧。
1
2
3
4
5
6
7
8
9if (CollectionUtils.isNotEmpty(wrapperClasses)) {
//遍历所有的WarpperClass
for (Class<?> wrapperClass : wrapperClasses) {
//这里有两步:
//第一步是获取用接口类作为参数的构造函数,然后创建一个对象。
//第二步是根据Set依赖注入
instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
}
}
- 回到现在
- 这部分代码,我打算先把里面的代码贴出来,然后逐步讲解。首先我们来总览一下
- 上面就是
ExtensionLoader.getExtensionLoader()
方法的全部内容了。这块内容很重要,是Dubbo最核心的源码之一。后面我们在分析服务注册、导出以及消费端RPC调用时会经常看到他们的身影。
0x03 总结
这里我们总结一下ExtensionLoader.getExtensionLoader()
搞了写什么东东。
- 从缓存中获取
Holder
,如果没有就创建。然后调用get()
获取实例。如果获取不到,则往下走。 - 从缓存中获取该接口所有的扩展类。如果获取不到,则往下走。
- 加载指定目录下面的以该接口全限定名命名的文件,通过
=
分割,得出我们的name(key)和实现类的全限定名。 - 通过
Class.forName()
方法把实现类加载到内存,然后判断该实现类的一些特性,做一些操作,比如我们已经说过的cacheWrapperClass
。我们的实现类就是在这时候保存到缓存中去的。 - 这时候我们接口对应的文件中的类就装载到了内存。准确的说,除了一些特性的类,其他的普通的实现类都放到了我们第2步里面的缓存中。这时候再通过name去缓存中获取实现类,肯定是可以找到的(如果在对应的文件中配置了)。
- 通过Class从缓存中获取实例,如果获取不到就调用
Class.newInstance()
方法创建一个实例,并弄到缓存中。 - 如果我们实现类中有用到其他的SPI作为属性,这里就需要通过Setter方法进行依赖注入,把该SPI的实现类注入进来。这样我们在实现类中调用其他的SPI就不会有空指针出现了。
- 获取该接口的所有
Warpper
类,一个套一个的对该实现类进行包装。目的就是增强其功能。 - 经过上面一系列操作后,返回该实例,并且缓存起来。这样只要进程不停,下次就可以直接从缓存中get,而不用走这么多路。
好了,上面就是该篇的全部内容。才疏学浅,如有错误之处,还望不吝指教。谢谢观看~
评论