Dubbo源码(一)SPI vs Spring

电子说

1.3w人已加入

描述

ExtensionLoader

1、私有构造

ExtensionLoader的构造方法传入一个Class,代表当前扩展点对应的SPI接口。

每个ExtensionLoader实例管理自己Class的扩展点,包括加载、获取等等。

ECHO

type:当前扩展点对应spi接口class;

objectFactory:扩展点工厂AdaptiveExtensionFactory,主要用于setter注入,后面再看。

ECHO

2、单例

ExtensionLoader提供静态方法,构造ExtensionLoader实例。

ECHO

单例往往要针对一个范围(scope)来说,比如Spring中所说的单例,往往是在一个BeanFactory中,而一个应用可以运行多个BeanFactory。又比如Class对象是单例,往往隐含的scope是同一ClassLoader。

ExtensionLoader在一个扩展点接口Class下只有一个实例,而每个扩展点实现实例在全局只有一个。

ECHO

3、成员变量

ExtensionLoader的成员变量可以分为几类

普通扩展点相关:

ECHO

active扩展点相关:

ECHO

adaptive扩展点相关:

ECHO

wrapper扩展点相关:

ECHO

4、加载扩展点Class

ExtensionLoader#getExtensionClasses:

当需要加载某个扩展点实现实例前,总会优先加载该扩展点所有实现Class,并缓存到cachedClasses中。

ECHO

ExtensionLoader#loadExtensionClasses:加载所有扩展点实现类

ECHO

ExtensionLoader#cacheDefaultExtensionName:加载SPI注解中的value属性,作为默认扩展点名称,默认扩展点只能存在一个。

ECHO

ExtensionLoader会扫描classpath三个路径下的扩展点配置文件:

  • META-INF/dubbo/internal:dubbo框架自己用的
  • META-INF/dubbo/:用户扩展用的
  • META-INF/services/:官方也没建议这样使用

ECHO

ExtensionLoader#loadDirectory:

1)类加载器:优先线程类加载器,其次ExtensionLoader自己的类加载器;

2)扫描扩展点配置文件;

3)加载扩展类;

ECHO

ExtensionLoader#loadResource:加载文件中每一行,key是扩展名,value是扩展实现类名。

ECHO

ExtensionLoader#loadClass:最终将每个扩展实现类Class按照不同的方式,缓存到ExtensionLoader实例中。

ECHO

普通扩展点

案例

对于一个扩展点MyExt:

@SPI
public interface MyExt {
    String echo(URL url, String s);
}

MyExtImplA实现MyExt:

public class MyExtImplA implements MyExt {
    @Override
    public String echo(URL url, String s) {
        return "ext1";
    }
}

可以配置多个扩展点实现META-INF/dubbo/x.y.z.MyExt:

A=x.y.z.impl.MyExtImplA
B=x.y.z.impl.MyExtImplB

使用ExtensionLoader#getExtention获取对应扩展点:

@Test
void testExtension() {
    ExtensionLoader

这种用法,对于用户来说,和beanFactory极为类似,当然实现并不同。

MyExt a = beanFactory.getBean("A", MyExt.class);

原理

ExtensionLoader#getExtention固然有单例缓存(cachedInstances),这个直接跳过。

ECHO

ExtensionLoader#createExtension:创建扩展点实现

0)getExtensionClasses:确保所有扩展点class被加载

1)通过无参构造,实例化扩展点instance

2)injectExtention:对扩展点instance执行setter注入,暂时忽略

3)包装类相关,暂时忽略

4)执行instance的初始化方法

ECHO

initExtension:初始化

ECHO

ExtensionLoader和Spring的创建bean流程相比,确实很像,比如:

1)Spring可以通过各种方式选择bean的一个构造方法创建一个bean(AbstractAutowireCapableBeanFactory#createBeanInstance),而ExtensionLoader只能通过无参构造创建扩展点;

2)Spring可以通过多种方式进行依赖注入(AbstractAutowireCapableBeanFactory#populateBean),比如Aware接口/setter/注解等,而ExtensionLoader只能支持setter注入;

3)Spring可以通过多种方式进行初始化(AbstractAutowireCapableBeanFactory#initializeBean),比如PostConstruct注解/InitializingBean/initMethod等,而ExtensionLoader只支持InitializingBean(LifeCycle)这种方式;

包装扩展点

案例

上面在ExtensionLoader#createExtension的第三步,可能会走包装扩展点逻辑。

假设有个扩展点MyExt2:

@SPI
public interface MyExt2 {
    String echo(URL url, String s);
}

有普通扩展点实现MyExt2ImplA:

public class MyExt2ImplA implements MyExt2 {
    @Override
    public String echo(URL url, String s) {
        return "A";
    }
}

除此以外,还有两个实现MyExt2的扩展点的MyExtWrapperA和MyExtWrapperB, 特点在于他有MyExt2的单参数构造方法

public class MyExtWrapperA implements MyExt2 {

    private final MyExt2 myExt2;

    public MyExtWrapperA(MyExt2 myExt2) {
        this.myExt2 = myExt2;
    }

    @Override
    public String echo(URL url, String s) {
        return "wrapA>>>" + myExt2.echo(url, s);
    }
}
public class MyExtWrapperB implements MyExt2 {

    private final MyExt2 myExt2;

    public MyExtWrapperB(MyExt2 myExt2) {
        this.myExt2 = myExt2;
    }

    @Override
    public String echo(URL url, String s) {
        return "wrapB>>>" + myExt2.echo(url, s);
    }
}

然后编写配置文件META-INF/x.y.z.myext2.MyExt2:

A=x.y.z.myext2.impl.MyExt2ImplA
wrapperA=x.y.z.myext2.impl.MyExtWrapperA
wrapperB=x.y.z.myext2.impl.MyExtWrapperB

测试验证,echo方法输出wrapB>>>wrapA>>>A。

@Test
void testWrapper() {
    ExtensionLoader

但是包装扩展点不能通过getExtension显示获取 ,比如:

// 包装类无法通过name直接获取
@Test
void testWrapper_IllegalStateException() {
    ExtensionLoader

原理

包装类之所以不暴露给用户直接获取,是因为包装类提供类似aop的用途,对于用户来说是透明的。

类加载阶段

在类加载阶段,isWrapperClass判断一个扩展类是否是包装类,如果是的话放入cachedWrapperClasses缓存。

对于包装类,不会放入普通扩展点的缓存map,所以无法通过getExtension显示获取。

ECHO

判断是否是包装类,取决于扩展点实现clazz是否有对应扩展点type的单参构造方法。

ECHO

实例化阶段

包装类实例化,是通过ExtensionLoader.getExtension("A")获取普通扩展点触发的,而返回的会是一个包装类。

如果一个扩展点存在包装类,客户端通过getExtension永远无法获取到原始扩展点实现

ECHO

包装类是硬编码实现的:

1)本质上包装的顺序是无序的,取决于扩展点配置文件的扫描顺序。(SpringAOP可以设置顺序)

2)包装类即使只关注扩展点的一个方法,也必须要实现扩展点的所有方法,扩展点新增方法如果没有默认实现,需要修改所有包装类。(SpringAOP如果用户只关心其中一个方法,也可以实现,因为是动态代理)

3)性能较好。(无反射)

自适应扩展点

对于一个扩展点type,最多只有一个自适应扩展点实现。

可以通过用户硬编码实现,也可以通过dubbo自动生成,优先取用户硬编码实现的自适应扩展点。

硬编码(Adaptive注解Class)

案例

假如有个水果扩展类,howMuch来统计交易上下文中该水果能卖多少钱。

@SPI
public interface Fruit {
    int howMuch(String context);
}

有苹果香蕉等实现,负责计算自己能卖多少钱。

public class Apple implements Fruit {
    @Override
    public int howMuch(String context) {
        return context.contains("apple") ? 1 : 0;
    }
}
public class Banana implements Fruit {
    @Override
    public int howMuch(String context) {
        return context.contains("banana") ? 2 : 0;
    }
}

这里引入一个AdaptiveFruit,在类上加了Adaptive注解,目的是统计上下文中所有水果能卖多少钱。

getSupportedExtensionInstances这个方法能加载所有扩展点,并依靠Prioritized接口实现排序,这个原理忽略,和Spring的Ordered差不多。

@Adaptive
public class AdaptiveFruit implements Fruit {
    private final Set

测试方法如下,用户购买苹果和香蕉,共花费3元。

核心api是ExtensionLoader#getAdaptiveExtension获取自适应扩展点实现。

@Test
void testAdaptiveFruit() {
    ExtensionLoader

原理

在类加载阶段,被Adaptive注解修饰的扩展点Class会被缓存到cachedAdaptiveClass。

注意,Adaptive注解类也不会作为普通扩展点暴露给用户,即不能通过ExtensionLoader.getExtension通过扩展名直接获取。

ECHO

ExtensionLoader#getAdaptiveExtension获取自适应扩展点。

实例化阶段,无参构造反射创建Adaptive扩展点,并执行setter注入。

ECHO

dubbo优先选取用户实现的Adaptive扩展点实现,否则会动态生成Adaptive扩展点。

ECHO

动态生成(Adaptive注解Method)

案例

假设现在有个秒杀水果扩展点SecKillFruit。

相较于刚才的Fruit扩展点, 区别在于入参改为了URL,且方法加了Adaptive注解

@SPI
public interface SecKillFruit {
    @Adaptive
    int howMuch(URL context);
}

苹果秒杀0元,香蕉秒杀1元。

public class SecKillApple implements SecKillFruit {
    @Override
    public int howMuch(URL context) {
        return 0;
    }
}
public class SecKillBanana implements SecKillFruit {
    @Override
    public int howMuch(URL context) {
        return 1;
    }
}

扩展点配置文件META-INF/x.y.z.myext4.SecKillFruit:

apple=x.y.z.myext4.impl.SecKillApple
banana=x.y.z.myext4.impl.SecKillBanana

假设场景,每次只能秒杀一种水果,需要根据上下文不同,决定秒杀的是哪种水果,计算不同的价钱。

有下面的测试案例,关键点在于URL里增加了sec.kill.fruit=扩展点名,零编码实现根据URL走不同策略。

sec.kill.fruit是SecKillFruit驼峰解析为小写后用点分割得到。

@Test
void testAdaptiveFruit2() {
    ExtensionLoader

也可以通过指定Adaptive注解的value,让获取扩展点名字的逻辑更加清晰。

比如取URL中的fruitType作为获取扩展名的方式。

@SPI
public interface SecKillFruit {
    @Adaptive("fruitType")
    int howMuch(URL context);
}

原理

由于Dubbo内部就是用URL做全局上下文来用,你可以理解为字符串无所不能。

所以为了减少重复代码,很多策略都通过动态生成自适应扩展来实现。

ExtensionLoader#createAdaptiveExtensionClass:如果没有用户Adaptive注解实现扩展点,走这里动态生成。

ECHO

关键点在于AdaptiveClassCodeGenerator#generate如何生成java代码。

扩展点接口必须有Adaptive注解方法,否则getAdaptiveExtension会异常。

ECHO

关键在于generateMethodContent如何实现adaptive方法逻辑。

对于没有Adaptive注解的方法,直接抛出异常。

对于Adaptive注解的方法,分为四步:

1)获取URL:优先从参数列表里直接找URL,降级从一个有URL的getter方法的Class里获取URL,否则异常;

2)决定扩展名:优先从Adaptive注解value属性获取,否则取扩展点类名去驼峰加点;

3)获取扩展点:调用ExtensionLoader.getExtension;

4)委派给目标扩展实现:调用目标扩展的目标方法,传入原始参数列表;

ECHO

比如针对SecKillFruit,最终生成的代码如下。

对于Dubbo来说,虽然扩展点不同,但是都用URL上下文,就可以少写重复代码。

public class SecKillFruit$Adaptive implements x.y.z.myext4.SecKillFruit {
    // Adaptive注解方法,通过ContextHolder.getUrl获取URL
    public int howMuch2(x.y.z.myext4.ContextHolder arg0) {
        if (arg0 == null)
            throw new IllegalArgumentException("...");
        if (arg0.getUrl() == null)
            throw new IllegalArgumentException("...");
        org.apache.dubbo.common.URL url = arg0.getUrl();
        String extName = url.getParameter("fruitType");
        if (extName == null)
            throw new IllegalStateException("...");
        x.y.z.myext4.SecKillFruit extension = (x.y.z.myext4.SecKillFruit)
                                               ExtensionLoader
                                               .getExtensionLoader(x.y.z.myext4.SecKillFruit.class)
                                               .getExtension(extName);
        return extension.howMuch2(arg0);
    }
    // Adaptive注解方法,直接从参数列表中获取URL
    public int howMuch(org.apache.dubbo.common.URL arg0) {
        if (arg0 == null) throw new IllegalArgumentException("url == null");
        org.apache.dubbo.common.URL url = arg0;
        String extName = url.getParameter("fruitType");
        if (extName == null)
            throw new IllegalStateException("...");
        x.y.z.myext4.SecKillFruit extension = (x.y.z.myext4.SecKillFruit)
                                               ExtensionLoader
                                               .getExtensionLoader(x.y.z.myext4.SecKillFruit.class)
                                               .getExtension(extName);
        return extension.howMuch(arg0);
    }

    // 没有Adaptive注解的方法
    public int howMuch() {
        throw new UnsupportedOperationException("...");
    }
}

Spring+jdk动态代理实现

上面原理分析不太好理解,这个事情也可以用Spring+jdk动态代理来实现。

其实这个需求和feign的FeignClient、mybatis的Mapper都比较像,写完接口就相当于写完实现。

针对同一个扩展点type设计一个 AdaptiveFactoryBean

public class AdaptiveFactoryBean implements FactoryBean {
    private final Class? type; /* 扩展点 */
    private final String defaultExtName; /* 默认扩展名 */
    private final Map

核心逻辑在InvocationHandler#invoke代理逻辑中,和AdaptiveClassCodeGenerator#generateMethodContent一样。

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if (!cachedMethod2Adaptive.containsKey(method)) {
        throw new UnsupportedOperationException();
    }
    // 1. 获取URL
    int urlIdx = cachedMethod2URLIndex.get(method);
    URL url = (URL) args[urlIdx];
    // 2. 从url里获取扩展点名
    Adaptive adaptive = cachedMethod2Adaptive.get(method);
    String extName = null;
    for (String key : adaptive.value()) {
        extName = url.getParameter(key);
        if (extName != null) {
            break;
        }
    }
    if (extName == null) {
        extName = defaultExtName;
    }
    if (extName == null) {
        throw new IllegalStateException();
    }
    // 3. 获取扩展点
    Object extension = 
        ExtensionLoader.getExtensionLoader(type).getExtension(extName);
    // 4. 委派给扩展点
    return method.invoke(extension, args);
}

为了注入所有包含Adaptive注解方法的扩展点AdaptiveFactoryBean,提供一个批量注册BeanDefinition的 AdaptiveBeanPostProcessor ,实现比较粗糙,主要为了说明问题。

public class AdaptiveBeanPostProcessor implements BeanDefinitionRegistryPostProcessor, EnvironmentAware {
    private final String packageToScan;
    private Environment environment;
    public AdaptiveBeanPostProcessor(String packageToScan) {
        this.packageToScan = packageToScan;
    }
    @Override
    public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
        ClassPathScanningCandidateComponentProvider scanner =
                new ClassPathScanningCandidateComponentProvider(false, this.environment) {
                    @Override
                    protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
                        // 有Adaptive注解方法
                        return beanDefinition.getMetadata()
                                .hasAnnotatedMethods("org.apache.dubbo.common.extension.Adaptive");
                    }
                };
        scanner.addIncludeFilter(new AnnotationTypeFilter(SPI.class));
        Set

测试验证:

@Configuration
public class AdaptiveFactoryBeanTest {

    @Bean
    public AdaptiveBeanPostProcessor adaptiveBeanPostProcessor() {
        return new AdaptiveBeanPostProcessor("x.y.z.myext4");
    }

    @Test
    void test() {
        AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
        applicationContext.register(AdaptiveFactoryBeanTest.class);
        applicationContext.refresh();
        SecKillFruit secKillFruit = applicationContext.getBean("x.y.z.myext4.SecKillFruit$Adaptive_Spring", SecKillFruit.class);
        URL url = new URL("myProtocol", "1.2.3.4", 1010, "path");
        // 0元秒杀苹果
        url = url.addParameters("fruitType", "apple");
        int money = secKillFruit.howMuch(url);
        assertEquals(0, money);
        // 1元秒杀香蕉
        url = url.addParameters("fruitType", "banana");
        money = secKillFruit.howMuch(url);
        assertEquals(1, money);
        // 无URL方法异常
        assertThrows(UnsupportedOperationException.class, secKillFruit::howMuch);
    }
}

是不是用spring+动态代理来说明,更加容易理解了。

依赖注入

无论是对于普通扩展点/包装扩展点/自适应扩展点,所有的扩展点实例都会经过依赖注入。

案例

InjectExt是个扩展点,有实现InjectExtImplA,InjectExtImplA有一个Inner的setter方法。

public class InjectExtImplA implements InjectExt {
    private Inner inner;
    public void setInner(Inner inner) {
        this.inner = inner;
    }
    @Override
    public Inner getInner() {
        return inner;
    }
}

Inner是个扩展点,且能生成自适应扩展实现。

@SPI
public interface Inner {
    @Adaptive
    String echo(URL url);
}

Inner有InnerA实现。

public class InnerA implements Inner {
    @Override
    public String echo(URL url) {
        return "A";
    }
}

测试方法,InjectExtImplA 被自动注入了Inner的自适应实现

@Test
void testInject() {
    ExtensionLoader

原理

ExtensionLoader#injectExtension依赖注入,循环每个setter方法,找到入参Class和属性名。

通过ExtensionFactory搜索依赖,整个注入过程的异常都被捕获。

ECHO

ExtensionFactory也是SPI接口。

这里走硬编码实现的 AdaptiveExtensionFactory ,循环每个ExtensionFactory扩展点,通过type和name找扩展点实现。

ECHO

ExtensionFactory扩展点有两个实现。

原生的 SpiExtensionFactory没有利用setter的属性name ,直接获取type对应的自适应扩展点。

这也是为什么案例中,被注入的扩展点用了Adaptive。

ECHO

Spring相关的SpringExtensionFactory支持从多个ioc容器中,通过getBean(setter属性名,扩展点)获取bean。

ECHO

激活扩展点

背景

ExtensionLoader#getExtension可以获取单个扩展点实现。

ExtensionLoader#getSupportedExtensionInstances可以获取所有扩展点实现。

现在 需要根据条件,获取一类扩展点实现,这就是所谓的激活扩展点

以Spring为例,如何利用Qualifier做到这点。

假设现在有个用户接口,根据用户类型和用户等级有不同实现。

public interface User {
}

利用Qualifier注解,Category代表用户类型,Level代表用户等级。

@Qualifier
public @interface Category {
    String value();
}
@Qualifier
public @interface Level {
    int value();
}

针对User有四种实现,包括vip1级用户、vip2级用户、普通用户、普通2级用户。

@Component
@Category("vip")
@Level(1)
public static class VipUser implements User {
}
@Component
@Category("vip")
@Level(2)
public static class GoldenVipUser implements User {
}
@Component
@Category("normal")
public static class UserImpl implements User {
}
@Component
@Category("normal")
@Level(2)
public static class UserImpl2 implements User {
}

通过Qualifier,可以按照需求注入不同类型等级用户集合,做业务逻辑。

@Configuration
@ComponentScan
public class QualifierTest {
    @Autowired
    @Category("vip")
    private List

案例

和上面Spring的案例一样,替换成ExtensionLoader实现,看起来语义差不多。

用户等级作为分组,在URL参数上获取用户等级。

@Activate(group = {"vip"}, value = {"level:1"})
public class VipUser implements User {
}
@Activate(group = {"vip"}, value = {"level:2"}, order = 1000)
public class GoldenVipUser implements User {
}
@Activate(group = {"normal"}, order = 10)
public class UserImpl implements User {
}
@Activate(group = {"normal"}, value = {"level:2"}, order = 500)
public class UserImpl2 implements User {
}

测试方法如下,发现与Spring的Qualifier有相同也有不同。

比如通过group=vip和url不包含level去查询:

1)UserImpl和UserImpl2查不到,因为group不满足;

2)VipUser和GoldenVipUser查不到,因为url必须有level,且分别为1和2;

又比如通过group=null和level=2去查询:

1)UserImpl没有设置Activate注解value,代表对url没有约束,且查询条件group=null,代表匹配所有group,所以可以查到;

2)VipUser对url有约束,必须level=1,所以查不到;

3)GoldenVipUser和UserImpl2,都满足level=2,且查询条件group=null,代表匹配所有group,所以都能查到;

@Test
void testActivate() {
    ExtensionLoader

原理

类加载阶段,激活扩展点在普通扩展点分支逻辑中。

所以 激活扩展点只是筛选普通扩展点的方式 ,属于普通扩展点的子集。

ECHO

ExtensionLoader#getActivateExtension获取激活扩展点的入参包含三部分:

1)查询URL;2)查询扩展点名称集合;3)查询分组

其中1和3用于Activate匹配,2用于直接从getExtension获取扩展点加在Activate匹配的扩展点之后。

ECHO

重点看isMatchGroup和isActive两个方法。

isMatchGroup :如果查询条件不包含group,则匹配,如果查询条件包含group,注解中必须有group与其匹配。

ECHO

isActive :匹配url

1)Activate没有value约束,匹配

2)url匹配成功条件:如果注解value配置为k:v模式,要求url参数kv完全匹配;如果注解value配置为k模式,只需要url包含kv参数即可。其中k还支持后缀匹配。

比如@Activate(value = {"level"})只需要url中有level=xxx即可,

而@Activate(value = {"level:2"})需要url中level=2。

ECHO

总结

本文分析了Dubbo2.7.6的SPI实现。

ExtensionLoader相较于java的spi能按需获取扩展点,还有很多高级特性,与Spring的ioc和aop非常相似。

看似ExtensionLoader的功能都能通过Spring实现,但是Dubbo不想依赖Spring,所以造了套轮子。

题外话:非常夸张的是,Dubbo一个RPC框架,竟然有27w行代码,而同样是RPC框架的sofa-rpc5.9.0只有14w行。

除了很多com.alibaba的老包兼容代码,轮子是真的多,早期版本连json库都是自己实现的,现在是换成fastjson了。

普通扩展点

ExtensionLoader#getExtension(name),普通扩展点通过扩展名获取。

@SPI
public interface MyExt {
    String echo(URL url, String s);
}

创建普通扩展点分为四个步骤

1)无参构造

2)依赖注入

3)包装

4)初始化

包装扩展点

如果扩展点实现包含该扩展点的单参构造方法,被认为是包装扩展点。

public class WrapperExt implements Ext {
    public WrapperExt(Ext ext) {
    }
}

包装扩展点无法通过扩展名显示获取,而是在用户获取普通扩展点时,自动包装普通扩展点,返回给用户,整个过程是透明的。

自适应扩展点

ExtensionLoader#getAdaptiveExtension获取自适应扩展点。

每个扩展点最多只有一个自适应扩展点。

自适应扩展点分为两类:硬编码、动态生成。

硬编码自适应扩展点,在扩展点实现class上标注Adaptive注解,优先级高于动态生成自适应扩展点。

@Adaptive
public class AdaptiveFruit implements Fruit {

}

动态生成自适应扩展点。

出现的背景是,dubbo中有许多依赖URL上下文选择不同扩展点策略的场景,如果通过硬编码实现,会有很多重复代码。

动态生成自适应扩展点,针对@Adaptive注解方法且方法参数有URL的扩展点,采用javassist字节码技术,动态生成策略实现。

@SPI
public interface SecKillFruit {
    @Adaptive("fruitType")
    int howMuch(URL context);
}

激活扩展点

激活扩展点属于普通扩展点的子集。

激活扩展点利用Activate注解,根据条件匹配一类扩展点实现

@Activate(group = {"vip"}, value = {"level:2"}, order = 1000)
public class GoldenVipUser implements User {
}

ExtensionLoader#getActivateExtension:通过group和URL查询一类扩展点实现。

@Test
void testActivate() {
    ExtensionLoader

依赖注入

无论是普通/包装/自适应扩展点,在暴露给用户使用前,都会进行setter依赖注入。

依赖注入对象可来源于两部分:

1)SpiExtensionFactory根据type获取自适应扩展点

2)SpringExtensionFactory根据setter属性+type从ioc容器获取扩展点

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分