Java SPI与Spring SPI全面解析(源码级别讲解)

Java SPI with Spring SPI

Java SPI

什么是SPI?

SPI全称(Service Provider Interface)即 服务提供者接口,它是JDK内置的一种服务提供发现机制,这样说可能比较抽象,下面我们来举个例子来类比一下:

Spring项目中,我们写service层的时候都会约定俗成的有一个接口来定义规范,然后通过spring中的依赖注入,可以使用@Autowired等方式注入这个接口实现类的实例对象,之后的调用操作都是对基于这个接口调用。

简单来说就是这样的:

image-20230419004512529

如图所示,接口、实现类都是由服务提供方提供,我们可以把controller看作是服务调用方,调用方只关心接口的调用就好了。

在上面这个例子里,service层和其中的方法我们都可以称之为API,而我们要讨论的SPI和它相比,有类似也有差异,看以下图:

image-20230419124544277

简单来说,就是定义一个接口规范,可以由不同的服务提供者实现,并且,调用方可以通过某种机制来发现服务提供方,并通过这个接口调用它的能力。

通过对比,我们可以看出它们虽然都有着接口这一层面,但还是有很大不同。

API中的接口只是给我们调用方提供一个功能列表,调用方直接调用就完事了,而SPI是定义一个接口规范,提供给服务方实现,然后调用方可以通过某种机制发现服务提供者。

说白了就是我给你接口规范,你只要按照我的规范去实现,我就能通过某种机制发现服务提供者,并且通过这个接口调用它的能力。

SPI有什么用?有什么好处?

简单来说,就是我们定义一个标准接口,让第三方平台去实现这个接口,调用者可以根据配置来动态加载不同的第三方库,实现动态扩展功能。例如: 实现我这个接口的厂商有A、B、C,那么我就可以根据配置来加载其中一个库。

好处就是解耦、可插拔、面向接口编程、动态类加载、扩展性强。

如果这么说还是有点抽象,那么就看下面的案例

定义接口

在这个案例里我们就以智能电风扇为例,一般智能电风扇的核心功能有: 开关、摆头、定时、调节档次。

假设我们有A、B、C三个不同型号的智能电风扇,但是我们的主要功能是一样的,设想一下,如果不同型号的智能电风扇各写各的接口,那么对接起来是不是很麻烦。

那么怎么解决呢?很简单,这个时候就用到了我们的SPI,我先定义一套接口规范,你按照我的接口规范实现,我就能通过某种服务发现机制找到你,使用这种方式的话,后续还有其他型号生产,只要按照这套规范实现,我都能找到。

废话不多说,上代码

新建一个项目,名为fan-spi-demo

image-20230421161445292

接下来新建一个接口,定义接口规范

package com.hmg.spi.service;

/**
 * @author hmg
 * @version 1.0
 * @date 2023-04-21 16:16
 * @description: fan core service
 */
public interface IFanCoreService {
    /**
     * 获取风扇类型
     * @return 类型
     */
    String getType();

    /**
     * 开关
     */
    void turnOnOff();

    /**
     * 调节风速
     */
    void adjustSpeed();

    /**
     * 摆头
     */
    void frontSway();

    /**
     * 定时
     */
    void timer();
}

这个接口定义好了,后面要给服务提供者实现,用maven把它打成jar包,方便给服务提供者引入

//使用这条命令打包
mvn clean install

打完包之后服务提供者就可以引入了

服务提供者实现

A模块

新建一个服务提供者A 模块,名为fanA-type-provider

image-20230421164105223

引入标准接口jar包

   <dependencies>
        <dependency>
            <groupId>com.hmg.spi</groupId>
            <artifactId>fan-spi-demo</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

创建服务实现类

package com.hmg.spi.service.impl;

import com.hmg.spi.service.IFanCoreService;

/**
 * @author hmg
 * @version 1.0
 * @date 2023-04-21 16:44
 * @description: A型号风扇实现类
 */
public class AaTypeFanServiceImpl implements IFanCoreService {
    @Override
    public String getType() {
        return "A";
    }

    @Override
    public void turnOnOff() {
        System.out.println("A型号风扇开关");
    }

    @Override
    public void adjustSpeed() {
        System.out.println("A型号风扇调节风速");
    }

    @Override
    public void frontSway() {
        System.out.println("A型风扇摆头");
    }

    @Override
    public void timer() {
        System.out.println("A型风扇定时");
    }
}

在项目的resources目录下创建META-INF/services目录,然后以接口全限定名创建文件,并在文件中写入实现类的全限定类名

image-20230421165631874

这样我们就完成了一个服务提供者的实现,用maven打成jar包,就可以提供给调用方使用了

mvn clean install
B模块

接下来创建服务提供者B模块,和A模块同理

  1. 新建B模块

image-20230421173056351

  1. 在pom.xml中引入标准接口jar包

    <dependencies>
            <dependency>
                <groupId>com.hmg.spi</groupId>
                <artifactId>fan-spi-demo</artifactId>
                <version>1.0-SNAPSHOT</version>
            </dependency>
        </dependencies>
    
  2. 创建接口实现类

    package com.hmg.spi.service.impl;
    
    import com.hmg.spi.service.IFanCoreService;
    
    /**
     * @author hmg
     * @version 1.0
     * @date 2023-04-21 17:36
     * @description:  B型号风扇实现类
     */
    public class BbTypeFanServiceImpl implements IFanCoreService {
        @Override
        public String getType() {
            return "B";
        }
    
        @Override
        public void turnOnOff() {
            System.out.println("B型号风扇开关");
        }
    
        @Override
        public void adjustSpeed() {
            System.out.println("B型号风扇调节风速");
        }
    
        @Override
        public void frontSway() {
            System.out.println("B型风扇摆头");
        }
    
        @Override
        public void timer() {
            System.out.println("B型风扇定时");
        }
    }
    
  3. 在resources目录下创建META-INF/services目录,新建一个普通文件,以接口全限定名作为文件名,文件内容就是实现类的全限定类名

    image-20230421174241033

  4. 使用maven打成jar包,提供给调用方使用

mvn clean install
C模块

C模块同理,一样的操作,这里就不再演示了。

服务发现

现在三个服务提供者都实现了接口,下一步就是最关键的服务发现了,不过这一步java中的spi发现机制已经帮我们实现好了。

  1. 新建项目fan-app

    image-20230421233728466

  2. 引入那三个服务提供者jar包

    <dependencies>
        <dependency>
        <groupId>com.hmg.spi</groupId>
        <artifactId>fanA-type-provider</artifactId>
        <version>1.0-SNAPSHOT</version>
        </dependency>
    
        <dependency>
        <groupId>com.hmg.spi</groupId>
        <artifactId>fanB-type-provider</artifactId>
        <version>1.0-SNAPSHOT</version>
        </dependency>
    
        <dependency>
        <groupId>com.hmg.spi</groupId>
        <artifactId>fanC-type-provider</artifactId>
        <version>1.0-SNAPSHOT</version>
        </dependency>
     </dependencies>
    
  3. 编写主程序代码

    按照之前的说法,虽然每个服务提供者都对接口做了实现,但是调用者无需关心具体实现类,我们要做的是通过接口来调用服务提供者实现的方法。

    下面就是关键的服务发现环节,我们编写一个方法,根据型号去调用智能电风扇的开关方法

    package com.hmg.spi;
    
    import com.hmg.spi.service.IFanCoreService;
    
    import java.util.ServiceLoader;
    
    /**
     * @author hmg
     * @version 1.0
     * @date 2023-04-21 23:37
     * @description: 调用者测试
     */
    public class Main {
        /**
         * A型号的风扇
         */
        private final static String A_TYPE = "A";
    
        public static void main(String[] args) {
            new Main().turnOn(A_TYPE);
        }
    
        private void turnOn(String type){
            //通过ServiceLoader类的load方法去发现服务提供者并加载
            ServiceLoader<IFanCoreService> fanCoreServices = ServiceLoader.load(IFanCoreService.class);
            fanCoreServices.forEach(fanCoreService -> {
                System.out.println("检测到的类名:" + fanCoreService.getClass().getSimpleName());
                //判断是否是A型号的风扇,是的话直接开启
                if (type.equals(fanCoreService.getType())) {
                    fanCoreService.turnOnOff();
                }
            });
        }
    }
    
  4. 测试结果

    image-20230422000649041

ServiceLoader原理

ServiceLoader.load()
  1. ServiceLoader.load()方法

    load()方法帮我们干了什么事呢?他其实就干了一件事,把接口.class和多线程上下文保存到LazyIterator(懒加载迭代器),我怎么知道的?当然是看源码,请看以下图:

    image-20230422134946615

​ 有读者可能会有疑问,线程的上下文类加载器是用来干嘛的?先别急,后面会说到,继续往下走,看源码:

新创建一个ServiceLoader对象,把服务和类加载器都传进去

image-20230422135409724

image-20230422141422168

image-20230422140618171

对于providers和lookupIterator属性,有些读者可能在这里会有疑问,这两个是什么?看图:

image-20230422141722457

从上面创建新的懒加载迭代器点进来看实现,对传进来的参数进行赋值

image-20230422143446258

到目前为止,load()方法就结束了,所以load方法干了什么事应该都有了大致了解。

接下来就是整个ServiceLoader的核心了, 当我们遍历ServiceLoader.load()方法得到的结果后,发现它会调用iterator()方法,为什么?当然是因为ServiceLoader实现了Iterable这个接口,细心的同学应该早就发现了,而整个服务发现的核心就在iterator()这个方法里,接下来让我们一探究竟。

iterator()
hasNext()

image-20230422153625775

lookupIterator.hasNext()

里面的acc是访问控制上下文,ServiceLoader创建的时候用的,前面判断的时候为空,所以这里直接看hasNextService()就好了

image-20230422153946854

hasNextService()

image-20230422155945822

路径前缀,所以这也就是为什么要在resources下创建META-INF/services目录了

image-20230422210540821

parse()

image-20230422161930565

parseLine()

image-20230422162341592

next()

image-20230422200114370

lookupIterator.next()

image-20230422200457152

nextService()

image-20230422203709368

至此,ServiceLoader的整个执行流程源码我们就看完了,在迭代器的迭代过程中,会完成所有实现类的实例化,其实归根结底,还是基于 java 反射去实现的。

小总结

大概的核心流程就是先通过ServiceLoader.load方法清空缓存,并且创建懒加载迭代器,把class对象和类加载器先装到懒加载迭代器,做好真正加载的工作,然后遍历load得到的结果,就来到了iterator()方法,进来就先获取缓存,然后来到hasNext()方法(这个方法主要用于判断缓存中是否有值,如果有直接返回true,没有的话就使用lookupIterator调用hasNext方法,hasNext方法里又调用hasNextService方法获取实现类全限定类名,放到nextName中,然后返回true),判断是否有下一个值完了之后,就到了next()方法,进来也是先判断缓存里是否有值,有的话直接返回,没有就使用lookupIterator调用next方法,next方法中调用nextService()方法(这个方法主要是通过Class.forName加载Class对象,然后通过Class对象的newIntstance()方法实例化实现类对象,最后再放到缓存里(全限定类名作为key,实例化后的实现类对象作为value,大概就是这样了,如果到这还是看不太懂的话,那就多debug看几遍就会了。

应用场景

SPI应用场景有很多,比如: Spring、Common-Logging、JDBC、Dubbo、可插拔架构、框架开发等等

总结

Java中的SPI机制整体上来说还是很不错的,通过接口灵活的将服务调用者与服务提供者分离,提供给第三方实现扩展时还是很方便的,不过它也有缺点,比如不能按需加载,只能一次性全部加载进来,如果加载到某些不需要的实现类,那就会造成资源浪费,还有每一个标准接口都需要创建一个新的文件来存放具体实现类,这是非常不方便的,最后ServiceLoader多线程使用是不安全的,不过整体上来说还是很不错的,提供了一种非常不错的思想。

Spring SPI

简介

看了上面的Java SPI,相信大家对SPI都有大概的认识了,其实Spring SPI也是基于JavaSPI思想来做的,从而实现了自己的SPI。

这里还是使用JavaSPI里的案例(智能电风扇)

定义接口模块

  1. 创建spring项目名为fan-spi-spring

    image-20230423001041981

    这里我还是使用聚合项目,我就不过多阐述这个了

  2. 引入spring依赖

    <dependencies>
            <dependency>
                <groupId>org.springframework</groupId>
                <artifactId>spring-core</artifactId>
                <version>5.3.26</version>
            </dependency>
        </dependencies>
    
  3. 定义接口

    package com.hmg.spi.fanspi;
    
    /**
     * @author hmg
     * @version 1.0
     * @date 2023-04-23 0:28
     * @description: fan core spi service
     */
    public interface IFanCoreSpiService {
        /**
         * 获取风扇类型
         * @return 类型
         */
        String getType();
    
        /**
         * 开关
         */
        void turnOnOff();
    
        /**
         * 调节风速
         */
        void adjustSpeed();
    
        /**
         * 摆头
         */
        void frontSway();
    
        /**
         * 定时
         */
        void timer();
    }
    
  4. 使用maven打包

    mvn clean install
    

服务提供者实现

A模块
  1. 新建项目,名为fanA-type-provider-spring

    image-20230423004756797

  2. 引入fan-spi-spring项目的依赖

    <dependencies>
            <dependency>
                <groupId>com.hmg.spi</groupId>
                <artifactId>fan-spi-spring</artifactId>
                <version>1.0-SNAPSHOT</version>
            </dependency>
        </dependencies>
    
  3. 创建接口实现类

    package com.hmg.spi.service.impl;
    
    import com.hmg.spi.fanspi.IFanCoreSpiService;
    
    /**
     * @author hmg
     * @version 1.0
     * @date 2023-04-23 0:50
     * @description: A型号风扇spi实现类
     */
    public class AaTypeFanCoreSpiServiceImpl implements IFanCoreSpiService {
        @Override
        public String getType() {
            return "A";
        }
    
        @Override
        public void turnOnOff() {
            System.out.println("A型号风扇开关");
        }
    
        @Override
        public void adjustSpeed() {
            System.out.println("A型号风扇调节风速");
        }
    
        @Override
        public void frontSway() {
            System.out.println("A型风扇摆头");
        }
    
        @Override
        public void timer() {
            System.out.println("A型风扇定时");
        }
    }
    
  4. 在resources目录下创建META-INF目录,并创建spring.factories文件,在文件里写入接口全限定名=实现类全限定类名,多个实现类用逗号隔开,例如:接口全限定名=实现类全限定类名, 实现类全限定类名

    image-20230423192437398

  5. 使用maven打包,提供给调用者使用

mvn clean install
B模块
  1. 新建项目,名为fanB-type-provider-spring

    image-20230423010035417

  2. 引入fan-spi-spring项目的依赖

        <dependencies>
            <dependency>
                <groupId>com.hmg.spi</groupId>
                <artifactId>fan-spi-spring</artifactId>
                <version>1.0-SNAPSHOT</version>
            </dependency>
        </dependencies>
    
  3. 创建接口实现类

    package com.hmg.spi.service.impl;
    
    import com.hmg.spi.fanspi.IFanCoreSpiService;
    
    /**
     * @author hmg
     * @version 1.0
     * @date 2023-04-23 1:02
     * @description: B型号风扇spi实现类
     */
    public class BbTypeFanCoreSpiServiceImpl implements IFanCoreSpiService {
        @Override
        public String getType() {
            return "B";
        }
    
        @Override
        public void turnOnOff() {
            System.out.println("B型号风扇开关");
        }
    
        @Override
        public void adjustSpeed() {
            System.out.println("B型号风扇调节风速");
        }
    
        @Override
        public void frontSway() {
            System.out.println("B型风扇摆头");
        }
    
        @Override
        public void timer() {
            System.out.println("B型风扇定时");
        }
    }
    
  4. 在resources目录下创建META-INF目录,并创建spring.factories文件,在文件里写入接口全限定名=实现类全限定类名,多个实现类用逗号隔开,例如:接口全限定名=实现类全限定类名, 实现类全限定类名

    image-20230423192618051

  5. 使用maven打包,提供给调用者使用

    mvn clean install
    
C模块

C模块同理,一样的操作,这里就不再演示了。

服务发现

  1. 新建项目fan-app-spring

    image-20230423011245829

  2. 引入三个服务提供者模块的依赖

        <dependencies>
            <dependency>
                <groupId>com.hmg.spi</groupId>
                <artifactId>fanA-type-provider-spring</artifactId>
                <version>1.0-SNAPSHOT</version>
            </dependency>
    
            <dependency>
                <groupId>com.hmg.spi</groupId>
                <artifactId>fanB-type-provider-spring</artifactId>
                <version>1.0-SNAPSHOT</version>
            </dependency>
    
            <dependency>
                <groupId>com.hmg.spi</groupId>
                <artifactId>fanC-type-provider-spring</artifactId>
                <version>1.0-SNAPSHOT</version>
            </dependency>
        </dependencies>
    
  3. 编写调用者代码

    package com.hmg.spi;
    
    import com.hmg.spi.fanspi.IFanCoreSpiService;
    import org.springframework.core.io.support.SpringFactoriesLoader;
    
    import java.util.List;
    
    /**
     * @author hmg
     * @version 1.0
     * @date 2023-04-23 1:12
     * @description: 调用者测试
     */
    public class Main {
        public static void main(String[] args) {
            new Main().turnOn("B");
        }
    
        private void turnOn(String type){
            //Spring SPI使用SpringFactoriesLoader去发现并加载实现类
            List<IFanCoreSpiService> fanCoreSpiServices =
                    SpringFactoriesLoader.loadFactories(IFanCoreSpiService.class, Main.class.getClassLoader());
    
            fanCoreSpiServices.forEach(iFanCoreSpiService -> {
                System.out.println("检测到的实现类有:" + iFanCoreSpiService.getClass().getSimpleName());
                if (type.equals(iFanCoreSpiService.getType())) {
                    iFanCoreSpiService.turnOnOff();
                }
            });
        }
    }
    
  4. 测试

    image-20230423192857763

SpringFactoriesLoader原理

因为ServiceLoader基于图片形式讲解,感觉比较麻烦,所以SpringFactoriesLoader我还是贴代码讲解吧,在代码里加注释。

loadFactories(Class factoryType, @Nullable ClassLoader classLoader)
	public static <T> List<T> loadFactories(Class<T> factoryType, @Nullable ClassLoader classLoader) {
        //断言,factoryType为空直接报错factoryType' must not be null
		Assert.notNull(factoryType, "'factoryType' must not be null");
		ClassLoader classLoaderToUse = classLoader;
        //判断是否有类加载器,没有的话就使用SpringFactoriesLoader的
		if (classLoaderToUse == null) {
			classLoaderToUse = SpringFactoriesLoader.class.getClassLoader();
		}
        //加载实现类全限定名,我们进去看下loadFactoryNames()实现,看它怎么加载的实现类全限定名
		List<String> factoryImplementationNames = loadFactoryNames(factoryType, classLoaderToUse);
		if (logger.isTraceEnabled()) {
			logger.trace("Loaded [" + factoryType.getName() + "] names: " + factoryImplementationNames);
		}
		List<T> result = new ArrayList<>(factoryImplementationNames.size());
		for (String factoryImplementationName : factoryImplementationNames) {
            /**
            * 重点关注instantiateFactory(factoryImplementationName, factoryType, classLoaderToUse),这里面主要是通过反射实例化对象
            * 接下来我们探究一下它的源码
            *
            **/
			result.add(instantiateFactory(factoryImplementationName, factoryType, classLoaderToUse));
		}
        
        //对结果进行排序
		AnnotationAwareOrderComparator.sort(result);
		return result;
	}
loadFactoryNames
	public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
		ClassLoader classLoaderToUse = classLoader;
        //判断是否有类加载器,没有的话就使用SpringFactoriesLoader的
		if (classLoaderToUse == null) {
			classLoaderToUse = SpringFactoriesLoader.class.getClassLoader();
		}
        //获取接口全限定名
		String factoryTypeName = factoryType.getName();
        //主要看loadSpringFactories(classLoaderToUse)方法,这里面是找到spring.factories的源码
		return loadSpringFactories(classLoaderToUse).getOrDefault(factoryTypeName, Collections.emptyList());
	}
loadSpringFactories
	private static Map<String, List<String>> loadSpringFactories(ClassLoader classLoader) {
        //先从缓存里面取,如果有数据直接返回,则继续往下执行(注意:key是classLoader)
		Map<String, List<String>> result = cache.get(classLoader);
		if (result != null) {
			return result;
		}

		result = new HashMap<>();
		try {
            //根据路径获取所有资源,FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories"
			Enumeration<URL> urls = classLoader.getResources(FACTORIES_RESOURCE_LOCATION);
			while (urls.hasMoreElements()) {
				URL url = urls.nextElement();
				UrlResource resource = new UrlResource(url);
                
                //加载配置文件拿到实现类全限定名,为什么可以用Properties加载,因为配置是K V的
				Properties properties = PropertiesLoaderUtils.loadProperties(resource);
				for (Map.Entry<?, ?> entry : properties.entrySet()) {
					String factoryTypeName = ((String) entry.getKey()).trim();
                    //以逗号分隔的实现类全限定类名转成字符串数组
					String[] factoryImplementationNames =
							StringUtils.commaDelimitedListToStringArray((String) entry.getValue());
					for (String factoryImplementationName : factoryImplementationNames) {
						//重新计算key,不存在则添加,存在则直接返回
						result.computeIfAbsent(factoryTypeName, key -> new ArrayList<>())
								.add(factoryImplementationName.trim());
					}
				}
			}

			//给结果去重
			result.replaceAll((factoryType, implementations) -> implementations.stream().distinct()
					.collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList)));
            
            //把结果添加到缓存里,classLoader作为key, 结果集作为value
			cache.put(classLoader, result);
		}
		catch (IOException ex) {
			throw new IllegalArgumentException("Unable to load factories from location [" +
					FACTORIES_RESOURCE_LOCATION + "]", ex);
		}
        //返回结果
		return result;
	}
instantiateFactory

这个方法的实现就很少了,比较简单

	private static <T> T instantiateFactory(String factoryImplementationName, Class<T> factoryType, ClassLoader classLoader) {
		try {
            //通过ClassUtils.forName()加载Class对象
			Class<?> factoryImplementationClass = ClassUtils.forName(factoryImplementationName, classLoader);
            //判断实现类是不是实现了标准接口
			if (!factoryType.isAssignableFrom(factoryImplementationClass)) {
				throw new IllegalArgumentException(
						"Class [" + factoryImplementationName + "] is not assignable to factory type [" + factoryType.getName() + "]");
			}
            
            //先获得构造器,然后再实例化对象
			return (T) ReflectionUtils.accessibleConstructor(factoryImplementationClass).newInstance();
		}
		catch (Throwable ex) {
			throw new IllegalArgumentException(
				"Unable to instantiate factory class [" + factoryImplementationName + "] for factory type [" + factoryType.getName() + "]",
				ex);
		}
	}

到这里SpringFactoriesLoader源码解读也就结束了,整体上还是比较简单的,阅读完源码后来个小总结

小总结

大概说一下核心流程,就是我们在进入loadFactories方法时,第一个核心点就是loadFactoryNames方法里调用的loadSpringFactories方法,它所做的事就是加载实现类全限定名,第二个核心点就是instantiateFactory方法了,它所做的事就是通过反射实例化对象了。

总结

阅读完SpringFactoriesLoader源码发现比JavaSPI的ServiceLoader简洁了不少,不过他们的整体流程相似度还是很高的,还有就是spring spi的所有配置是放到一个文件里的,省去了写一大堆文件的麻烦,而java spi是一个标准接口一个文件,这样的话就比较麻烦了。

JavaSPI 与 SpringSPI有什么区别?

它们最大的区别就是配置文件了,JavaSPI 是一个接口一个配置文件,而Spring SPI是集中在一个配置文件里,也就是spring.factories,还有一个就是java spi是在遍历的时候才真正加载实现类,而spring spi是在loadFactories的时候就加载了。

  • 23
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

后端开发萌新

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值