Spring Boot 容器启动原理揭秘

不得不讲SpringBoot 使用起来太方便了,它的外表轻巧简单,在企业级的应用系统中非常流行,已经成为java开发者必备技能。而它采用的one-jar的方案已经深入人心,其实one-jar技术早在2004年就已经被提出,除此之外spring boot的强大的自动配置类也是非常的受用,总之用过的童靴都会很感觉一个字“爽”。但是 SpringBoot它内部实现却非常的复杂,它常常把爱研究源码的读者绕的晕头转向。

目录

1. spring-boot-maven-plugin是个什么鬼

2. maven-shade-plugin和spring-boot-maven-plugin有何区别

3. JarLauncher

4. Archive

5. LaunchedURLClassLoader

6. WarLauncher

7. SpringBootApplication


1. spring-boot-maven-plugin是个什么鬼

如果你不知道从哪里开始,就按作者的思路向下看吧!SpringBoot在于打包时它使用了one-jar (也有很多人叫 FatJar)技术,它就是将所有的依赖 jar 包一起放进了最终的 jar 包中的 BOOT-INF/lib 目录中,当前应用系统的 class 被统一放到了 BOOT-INF/classes 目录中。

简单的讲:它可以实现将所有的依赖 jar 包及class 文件塞进统一的 jar 包中。如果你还不了解,但有一个onejar-maven-plugin你可能听说过,是不是有一种旧瓶换新药的感觉。

这里提个问题:META-INF目录和org/springframework/boot/loader目录有什么特别之处呢?

<plugins>
    <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
</plugins>

springboot-exaple-1.0-SNAPSHOT.jar
├── BOOT-INF
│   ├── classes
│   │   └── com
│   └── lib
│       ├── classmate-1.3.4.jar
│       ├── jackson-datatype-jdk8-2.9.6.jar
│       ├── jackson-datatype-jsr310-2.9.6.jar
│       ├── snakeyaml-1.19.jar
│       ├── spring-aop-5.0.9.RELEASE.jar
│       ├── spring-beans-5.0.9.RELEASE.jar
│       ├── spring-boot-2.0.5.RELEASE.jar
│       ├── spring-boot-autoconfigure-2.0.5.RELEASE.jar
│       ├── spring-boot-starter-2.0.5.RELEASE.jar
│       ├── spring-boot-starter-json-2.0.5.RELEASE.jar
│       ├── spring-boot-starter-logging-2.0.5.RELEASE.jar
│       ├── spring-boot-starter-tomcat-2.0.5.RELEASE.jar
│       ├── spring-boot-starter-web-2.0.5.RELEASE.jar
│       ├── spring-context-5.0.9.RELEASE.jar
│       ├── spring-core-5.0.9.RELEASE.jar
│       ├── spring-expression-5.0.9.RELEASE.jar
│       ├── spring-jcl-5.0.9.RELEASE.jar
│       ├── spring-web-5.0.9.RELEASE.jar
│       ├── spring-webmvc-5.0.9.RELEASE.jar
│       ├── tomcat-embed-core-8.5.34.jar
│       ├── tomcat-embed-el-8.5.34.jar
│       ├── tomcat-embed-websocket-8.5.34.jar
│       └── validation-api-2.0.1.Final.jar
├── META-INF
│   ├── MANIFEST.MF
│   └── maven
│       └── org.springframework
└── org
    └── springframework
        └── boot
            └── loader
                └── Launcher.class
                └── JarLauncher.class

2. maven-shade-plugin和spring-boot-maven-plugin有何区别

META-INF 目录一定不陌生吧!基础知识提问:MANIFEST 文件有何用?

借助MANIFEST 文件可以直接使用 java -jar springboot-exaple-1.0-SNAPSHOT.jar 运行,而不用 java -classpath jar1:jar2:jar3...  mainClassName 这么复杂的语法格式运行。

如果你对maven-shade-plugin有一定的了解(不了解的可以去看dubbo中的运用),二者本身没有可比性,这里讲的区别主要指的是:两者的 MANIFEST 文件的差异性

// Generated by Maven Shade Plugin
Manifest-Version: 1.0
Implementation-Title: gs-spring-boot
Implementation-Version: 0.1.0
Built-By: qianwp
Implementation-Vendor-Id: org.springframework
Created-By: Apache Maven 3.5.4
Build-Jdk: 1.8.0_191
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-boot-starter-parent/gs-spring-boot
Main-Class: com.kxtx.Application

// Generated by SpringBootLoader Plugin
Manifest-Version: 1.0
Implementation-Title: gs-spring-boot
Implementation-Version: 0.1.0
Built-By: qianwp
Implementation-Vendor-Id: org.springframework
Spring-Boot-Version: 2.0.5.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.kxtx.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.5.4
Build-Jdk: 1.8.0_191
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-boot-starter-parent/gs-spring-boot

but,spring-boot并没有指定classpath啊,它是怎么做的的呢?

对,这里要说道说道了,首先运行环境一般有两种:

  1. IDE中运行,你完全感知不到打包逻辑的,它会自动帮你下载Jar包、指定classpath(细心的会发现启动控制台有答案)
  2. 直接执行java -jar的话,这就是springboot自己的JarLauncher奥秘,它进行解包、用依赖包配置ClassLoader、用反射调用实际main函数

具体是如何做的的呢?

SpringBoot 将 jar 包中的 Main-Class 进行了替换,换成了 JarLauncher,还增加了一个 Start-Class 参数,这个参数对应的类才是真正的业务 main 方法入口。其实jar包在启动时实际上启动的是springboot自己的JarLauncher,通过这个JarLauncher去加载 lib 下的依赖,然后去启动 Start-Class 配置对应下的类。

Start-Class是怎么找到呢?

spring-boot-maven-plugin执行时会去找有@SpringBootApplication注解的类,如果找不到,那么就检测所有的类中有Main函数的,如果找到且只找到一个就皆大欢喜,否则就报错给你看。

public abstract class MainClassFinder {
		private String getMainClassName() {
			Set<MainClass> matchingMainClasses = new LinkedHashSet<MainClass>();
			if (this.annotationName != null) {
				for (MainClass mainClass : this.mainClasses) {
					if (mainClass.getAnnotationNames().contains(this.annotationName)) {
						matchingMainClasses.add(mainClass);
					}
				}
			}
			if (matchingMainClasses.isEmpty()) {
				matchingMainClasses.addAll(this.mainClasses);
			}
			if (matchingMainClasses.size() > 1) {
				throw new IllegalStateException(
						"Unable to find a single main class from the following candidates "
								+ matchingMainClasses);
			}
			return (matchingMainClasses.isEmpty() ? null
					: matchingMainClasses.iterator().next().getName());
		}

	}
}
public class Repackager {
	private static final String MAIN_CLASS_ATTRIBUTE = "Main-Class";
	private static final String START_CLASS_ATTRIBUTE = "Start-Class";
	private static final String BOOT_VERSION_ATTRIBUTE = "Spring-Boot-Version";
	private static final String BOOT_LIB_ATTRIBUTE = "Spring-Boot-Lib";
	private static final String BOOT_CLASSES_ATTRIBUTE = "Spring-Boot-Classes";
	private static final byte[] ZIP_FILE_HEADER = new byte[] { 'P', 'K', 3, 4 };
	private static final long FIND_WARNING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
	private static final String SPRING_BOOT_APPLICATION_CLASS_NAME = "org.springframework.boot.autoconfigure.SpringBootApplication";
	protected String findMainMethod(JarFile source) throws IOException {
		return MainClassFinder.findSingleMainClass(source,
				this.layout.getClassesLocation(), SPRING_BOOT_APPLICATION_CLASS_NAME);
	}
}

3. JarLauncher

Launcher for JAR based archives. This launcher assumes that dependency jars are included inside a /BOOT-INF/lib directory and that application classes are included inside a /BOOT-INF/classes directory.

Main-Class的启动类是JarLaucher(源于org/springframework/boot/loader目录) ,它创建了一个特殊的 ClassLoader,然后由这个 ClassLoader 来加载 MainClass 并运行。

这里提个问题:Archive(红色的部分)又是个什么鬼?

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-loader</artifactId>
    <version>2.0.5.RELEASE</version>
</dependency>

//源于org.springframework.boot.loader;
public class JarLauncher extends ExecutableArchiveLauncher {
	protected JarLauncher(Archive archive) {
		super(archive);
	}
	public static void main(String[] args) throws Exception {
		new JarLauncher().launch(args);
	}
}
public abstract class Launcher {
    protected void launch(String[] args) throws Exception {
        JarFile.registerUrlProtocolHandler();
        // 生成自定义ClassLoader
        ClassLoader classLoader = this.createClassLoader(this.getClassPathArchives());
        // 启动应用
        this.launch(args, this.getMainClass(), classLoader);
    }
    protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
        List<URL> urls = new ArrayList(archives.size());
        Iterator var3 = archives.iterator();

        while(var3.hasNext()) {
            Archive archive = (Archive)var3.next();
            urls.add(archive.getUrl());
        }
        return this.createClassLoader((URL[])urls.toArray(new URL[urls.size()]));
    }
    protected ClassLoader createClassLoader(URL[] urls) throws Exception {
        return new LaunchedURLClassLoader(urls, this.getClass().getClassLoader());
    }
    protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception {
        //设置为当前线程上下文类加载器
        Thread.currentThread().setContextClassLoader(classLoader);
        this.createMainMethodRunner(mainClass, args, classLoader).run();
    }
    protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
        return new MainMethodRunner(mainClass, args);
    }
}
public class MainMethodRunner {
    private final String mainClassName;
    private final String[] args;
    public MainMethodRunner(String mainClass, String[] args) {
        this.mainClassName = mainClass;
        this.args = args != null ? (String[])args.clone() : null;
    }
    public void run() throws Exception {
        Class<?> mainClass = Thread.currentThread().getContextClassLoader().loadClass(this.mainClassName);
        Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
        // 调用业务系统类(startClass)的main方法
        mainMethod.invoke((Object)null, this.args);
    }
}

4. Archive

Archive是spring boot中特有的对象,可以理解为:

  • 归档文件,通常为tar/zip等格式压缩包,jar为zip格式归档文件
  • 一个Archive可以是jar(JarFileArchive),可以是一个文件目录(ExplodedArchive),可以抽象为统一访问资源的逻辑层
public interface Archive extends Iterable<Archive.Entry> {
	// 获取该归档的url
	URL getUrl() throws MalformedURLException;
	// 获取jar!/META-INF/MANIFEST.MF或[ArchiveDir]/META-INF/MANIFEST.MF
	Manifest getManifest() throws IOException;
	// 获取jar!/BOOT-INF/lib/*.jar或[ArchiveDir]/BOOT-INF/lib/*.jar
	List<Archive> getNestedArchives(EntryFilter filter) throws IOException;
	interface Entry {
		boolean isDirectory();
		String getName();
	}
}
public class JarFileArchive implements Archive {
	@Override
	public URL getUrl() throws MalformedURLException {
		if (this.url != null) {
			return this.url;
		}
		return this.jarFile.getUrl();
	}
	@Override
	public Manifest getManifest() throws IOException {
		return this.jarFile.getManifest();
	}
	@Override
	public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
		List<Archive> nestedArchives = new ArrayList<>();
		for (Entry entry : this) {
			if (filter.matches(entry)) {
				nestedArchives.add(getNestedArchive(entry));
			}
		}
		return Collections.unmodifiableList(nestedArchives);
	}
}
public class ExplodedArchive implements Archive {}

springboot-exaple-1.0-SNAPSHOT.jar 既为一个JarFileArchive,springboot-exaple-1.0-SNAPSHOT.jar!/BOOT-INF/lib下的每一个jar包也是一个JarFileArchive。将springboot-exaple-1.0-SNAPSHOT.jar解压到目录springboot-exaple-1.0-SNAPSHOT后,则该目录就是一个ExplodedArchive

创建archive在ExecutableArchiveLauncher ,它是JarLauncher WarLauncher的父类。

public abstract class ExecutableArchiveLauncher extends Launcher {
	// 在自己所在的jar,并创建Archive
	public ExecutableArchiveLauncher() {
		try {
			this.archive = createArchive();
		}
		catch (Exception ex) {
			throw new IllegalStateException(ex);
		}
	}
	@Override
	protected String getMainClass() throws Exception {
		Manifest manifest = this.archive.getManifest();
		String mainClass = null;
		if (manifest != null) {
			mainClass = manifest.getMainAttributes().getValue("Start-Class");
		}
		if (mainClass == null) {
			throw new IllegalStateException(
					"No 'Start-Class' manifest entry specified in " + this);
		}
		return mainClass;
	}
	// 获取/BOOT-INF/lib下所有jar及/BOOT-INF/classes目录对应的archive
	@Override
	protected List<Archive> getClassPathArchives() throws Exception {
		List<Archive> archives = new ArrayList<>(
				this.archive.getNestedArchives(this::isNestedArchive));
		postProcessClassPathArchives(archives);
		return archives;
	}
}
public abstract class Launcher {
	protected final Archive createArchive() throws Exception {
		ProtectionDomain protectionDomain = getClass().getProtectionDomain();
		CodeSource codeSource = protectionDomain.getCodeSource();
		URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
		String path = (location != null) ? location.getSchemeSpecificPart() : null;
		if (path == null) {
			throw new IllegalStateException("Unable to determine code source archive");
		}
		File root = new File(path);
		if (!root.exists()) {
			throw new IllegalStateException(
					"Unable to determine code source archive from " + root);
		}
		return (root.isDirectory() ? new ExplodedArchive(root)
				: new JarFileArchive(root));
	}
}

5. LaunchedURLClassLoader

新的问题来了,当 JVM 遇到一个不认识的类,BOOT-INF/lib 目录里又有那么多 jar 包,它是如何知道去哪个 jar 包里加载呢?

java中定义了URL的概念,对应的URLConnection,可以灵活地获取多种URL协议(http、 file、 ftp、 jar )下的资源,具体的可以看我之前分享的URL拓展协议

每个jar都会对应一个url,如:jar:file:/data/springboot-exaple-1.0-SNAPSHOT/BOOT-INF/lib/spring-aop-5.0.4.RELEASE.jar!/

jar中的资源对应的url,并以'!/'分割,如:jar:file:/data/springboot-exaple-1.0-SNAPSHOT/BOOT-INF/lib/spring-aop-5.0.4.RELEASE.jar!/org/springframework/aop/SpringProxy.class

关于jar中的资源对应的url,原始的 java.util.jar.JarFile 只支持一个'!/',而SpringBoot扩展了此协议,使其支持多个'!/',以实现jar in jar的资源。自定义URL的类格式为[pkgs].[protocol].Handler,具体实现参考JarFile.registerUrlProtocolHandler()

spring如何读取SpringProxy.class呢?

会循环处理'!/'分隔符,从最上层出发,先构造springboot-exaple-1.0-SNAPSHOT.jarJarFile,再构造spring-aop-5.0.4.RELEASE.jarJarFile,最后构造指向SpringProxy.class的JarURLConnection,通过JarURLConnection的getInputStream方法获取SpringProxy.class内容。

ClassLoader 会在本地缓存包名和 jar包路径的映射关系。

public class LaunchedURLClassLoader extends URLClassLoader {
    //入口
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        Handler.setUseFastConnectionExceptions(true);
        Class var3;
        try {
            try {
                this.definePackageIfNecessary(name);
            } catch (IllegalArgumentException var7) {
                if (this.getPackage(name) == null) {
                    throw new AssertionError("Package " + name + " has already been defined but it could not be found");
                }
            }

            var3 = super.loadClass(name, resolve);
        } finally {
            Handler.setUseFastConnectionExceptions(false);
        }

        return var3;
    }
    private void definePackageIfNecessary(String className) {
        int lastDot = className.lastIndexOf(46);
        if (lastDot >= 0) {
            String packageName = className.substring(0, lastDot);
            if (this.getPackage(packageName) == null) {
                try {
                    this.definePackage(className, packageName);
                } catch (IllegalArgumentException var5) {
                    if (this.getPackage(packageName) == null) {
                        throw new AssertionError("Package " + packageName + " has already been defined but it could not be found");
                    }
                }
            }
        }

    }
    private void definePackage(String className, String packageName) {
        try {
            AccessController.doPrivileged(() -> {
                String packageEntryName = packageName.replace('.', '/') + "/";
                String classEntryName = className.replace('.', '/') + ".class";
                URL[] var5 = this.getURLs();
                int var6 = var5.length;

                for(int var7 = 0; var7 < var6; ++var7) {
                    URL url = var5[var7];

                    try {
                        URLConnection connection = url.openConnection();
                        if (connection instanceof JarURLConnection) {
                            JarFile jarFile = ((JarURLConnection)connection).getJarFile();
                            if (jarFile.getEntry(classEntryName) != null && jarFile.getEntry(packageEntryName) != null && jarFile.getManifest() != null) {
                                this.definePackage(packageName, jarFile.getManifest(), url);
                                return null;
                            }
                        }
                    } catch (IOException var11) {
                    }
                }

                return null;
            }, AccessController.getContext());
        } catch (PrivilegedActionException var4) {
        }

    }

    public void clearCache() {
        URL[] var1 = this.getURLs();
        int var2 = var1.length;

        for(int var3 = 0; var3 < var2; ++var3) {
            URL url = var1[var3];

            try {
                URLConnection connection = url.openConnection();
                if (connection instanceof JarURLConnection) {
                    this.clearCache(connection);
                }
            } catch (IOException var6) {
            }
        }

    }
}
// java.util.jar.JarFile的拓展体,可以访问内部任何目录项或jar文件
public class JarFile extends java.util.jar.JarFile {
    private static final String MANIFEST_NAME = "META-INF/MANIFEST.MF";
    private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs";
    private static final String HANDLERS_PACKAGE = "org.springframework.boot.loader";
    private static final AsciiBytes META_INF = new AsciiBytes("META-INF/");
    // 注册一个'java.protocol.handler.pkgs'属性以便URLStreamHandler可以处理jar urls
    public static void registerUrlProtocolHandler() {
        String handlers = System.getProperty(PROTOCOL_HANDLER, "");
        System.setProperty(PROTOCOL_HANDLER, ("".equals(handlers) ? HANDLERS_PACKAGE
				: handlers + "|" + HANDLERS_PACKAGE));
        resetCachedUrlHandlers();
    }
}
//jar-url处理器
public class Handler extends URLStreamHandler {
    private static final String JAR_PROTOCOL = "jar:";
    private static final String FILE_PROTOCOL = "file:";
    private static final String SEPARATOR = "!/";//在处理如下URL时,会循环处理'!/'分隔符
    private static final String CURRENT_DIR = "/./";
    private static final Pattern CURRENT_DIR_PATTERN = Pattern.compile("/./");
    private static final String PARENT_DIR = "/../";
    private static final String[] FALLBACK_HANDLERS = new String[]{"sun.net.www.protocol.jar.Handler"};
    private static final Method OPEN_CONNECTION_METHOD;
    private static SoftReference<Map<File, JarFile>> rootFileCache;
    private final JarFile jarFile;
    private URLStreamHandler fallbackHandler;
}
//jar-url 读取文件
final class JarURLConnection extends java.net.JarURLConnection {
    private static ThreadLocal<Boolean> useFastExceptions = new ThreadLocal();
    private static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException("Jar file or entry not found");
    private static final IllegalStateException NOT_FOUND_CONNECTION_EXCEPTION;
    private static final String SEPARATOR = "!/";
    private static final URL EMPTY_JAR_URL;
    private static final JarURLConnection.JarEntryName EMPTY_JAR_ENTRY_NAME;
    private static final String READ_ACTION = "read";
}

这里思考一个问题:war是一个怎样的处理流程呢?

6. WarLauncher

首先将内嵌容器相关依赖设为provided,再重写SpringBootServletInitializer的configure方法。

@SpringBootApplication
public class WebApp extends SpringBootServletInitializer {
    public static void main(String[] args) {
        SpringApplication.run(WebApp.class, args);
    }
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(WebApp.class);
    }
}

它构建处理的结构大概是这样的:

springboot-exaple-1.0-SNAPSHOT.war
├── META-INF
│   └── MANIFEST.MF
├── WEB-INF
│   ├── classes
│   │   └── 应用程序
│   └── lib
│       └── 第三方依赖jar
│   └── lib-provided
│       └── 与内嵌容器相关的第三方依赖jar
└── org
    └── springframework
        └── boot
            └── loader
                └── springboot启动程序

MANIFEST.MF内容为:

Manifest-Version: 1.0
Start-Class: com.kxtx.Application
Main-Class: org.springframework.boot.loader.WarLauncher

WarLauncher实现,其实与JarLauncher并无太大差别。差别仅在于:JarLauncher在构建LauncherURLClassLoader时,会搜索BOOT-INF/classes目录及BOOT-INF/lib目录下jar,WarLauncher在构建LauncherURLClassLoader时,则会搜索WEB-INFO/classes目录及WEB-INFO/libWEB-INFO/lib-provided两个目录下的jar。

因此构建出的war便支持两种启动方式:

  • 直接运行./springboot-exaple-1.0-SNAPSHOT.war start
  • 部署到Tomcat容器中

spring boot提供的除了JarLauncher、WarLauncher 之外,还提供更为轻量的PropretiesLauncher

7. SpringBootApplication

我们知道SpringBoot 深度依赖注解来完成配置的自动装配工作,它发明了几十个注解,你需要仔细阅读文档才能知道它是用来干嘛的。@SpringBootApplication是一个复合注解,包括@ComponentScan,和@SpringBootConfiguration@EnableAutoConfiguration

@ComponentScan的功能其实就是自动扫描并加载符合条件的组件(比如@Component和@Repository等)或者bean定义,最终将这些bean定义加载到IoC容器中。可以通过basePackages等属性来细粒度的定制自动扫描的范围,因为默认不指定basePackages,这也就是为什么SpringBoot的启动类最好是放在root package下的原因

@SpringBootApplication(scanBasePackages = {"com.example"})
public class WebApp {
    public static void main(String[] args) {
        SpringApplication.run(WebApp.class, args);
    }
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration    // springboot的大神器之一,其借助@import的帮助
@ComponentScan(excludeFilters = { // 扫描路径设置
		@Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
}
// 继承了Configuration,表示当前是注解类
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
public @interface SpringBootConfiguration {
}

总结,EnableAutoConfiguration的强大神奇之处,三言两语无法道尽,在下一篇中工作原理介绍

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值