SpringBoot重构Jar包源码分析

咱们知道,SpringBoot仅凭一个Jar包就能将咱们构建的整个工程跑起来,若是你也想知道这个能跑起来的jar内部结构是如何构建出来的,请耐心读完本篇,本篇内容可能有点多,但包你有收获。若是读完没有收获,请拉到文章最后,我再告诉你一个绝招。php


分析Springboot重构Jar包源码前咱们先按日常方式建立一个springboot项目,经过IDEAspringboot提供的网站(https://start.spring.io/)很容易就能够建立出一个springboot web工程。注意:建立时要把Web Starter选择上,否则后面不会启动Tomcat容器。建立好的项目目录通常大体以下:java

 

pom.xml文件以下:git

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.2.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.sourcecode.analysis</groupId> <artifactId>sourcecode-analysis-springboot</artifactId> <version>0.0.1-SNAPSHOT</version> <name>sourcecode-analysis-springboot</name> <description>Demo project for Spring Boot</description>
<properties> <java.version>1.8</java.version> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
</project>

SourcecodeAnalysisSpringbootApplication.java类以下:github

package com.sourcecode.analysis.springboot;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class SourcecodeAnalysisSpringbootApplication {
public static void main(String[] args) { SpringApplication.run(SourcecodeAnalysisSpringbootApplication.class, args); }
}

application.properties文件默认是空的。web


springboot项目建立好以后,咱们能够直接运行SourcecodeAnalysisSpringbootApplication.java类的main方法便可启动web工程,启动成功控制台以下:(默认端口8080spring

 

 

除此以外咱们也能够经过执行maven package方式将项目打成jar包,经过java -jar 命令来运行,运行效果以下:typescript

 

 

springboot项目建立过程就是这么多,接下来就能够在这个脚手架基础上去填咱们的业务代码了。在springboot以前要实现这个过程但是要配置一大堆xml的,可是如今这些事情springboot经过starter的方式帮咱们作了。因为咱们本章主要内容是讲解springboot重构jar包的源码,关于springboot starter如何帮咱们简化配置的等到后面有时间再写一篇来介绍。apache

 

当经过java -jar命令启动jar包时,首先会先从jar包中META-INF/MANIFEST.MF文件中找到Main-Class的值做为主类来运行jar包,这是java基础知识。因此说要了解springboot是如何启动的,咱们首先须要将springboot打出来的jar包解压出来,找到META-INF/MANIFEST.MF文件并打开,咱们能够看到大概以下内容:编程

Manifest-Version: 1.0Implementation-Title: sourcecode-analysis-springbootImplementation-Version: 0.0.1-SNAPSHOTStart-Class: com.sourcecode.analysis.springboot.SourcecodeAnalysisSpri ngbootApplicationSpring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/Build-Jdk-Spec: 1.8Spring-Boot-Version: 2.2.2.RELEASECreated-By: Maven Archiver 3.4.0Main-Class: org.springframework.boot.loader.JarLauncher

 

能够看到Main-Class的值为: org.springframework.boot.loader.JarLauncher经过java -jar运行时执行的主方法即是 org.springframework.boot.loader.JarLauncher类的main方法。而咱们经过IDEA手工运行的主类被配置为keyStart-Class的值中,这个类是怎么写到META-INF/MANIFEST.MF文件中的呢?由于咱们前面是经过maven package命令打包出来的,因此要解开这个问题咱们要回到maven打包阶段去思考,想到这里此时咱们应该看下pom.xml的插件配置,能够看到以下核心配置:springboot

 <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <executable>true</executable> </configuration> </plugin> </plugins> </build>

 

从名字感官上能够看出这应该是springboot打包插件,咱们能够大胆猜想springboot经过本身实现的maven package阶段插件对maven-jar-plugin插件建立的jar动了手脚。为了找到具体线索,查看maven package命令日志,能够看到打包阶段执行完maven-jar-plugin插件后接下来果真是执行了spring-boot-maven-plugin插件中的repackage进行jar重构。

 


为了深刻了解springboot是如何对maven-jar-plugin插件原始jar包动手脚的,咱们把springboot源码下载下来准备进行源码分析,springboot github 源码clone地址:https://github.com/spring-projects/spring-boot.git克隆下来源码目录结构以下:

 


紧接着咱们顺藤摸瓜,经过全局搜索repackag关键字的方式从springboot源码中找到一个注解@MojorepackageRepackageMojo类文件:

 

 

这个类顶层继承自maven插件抽象父类AbstractMojo,且被定义为打包阶段执行,这个类正是spring-boot-maven-plugin插件中执行的repackage,咱们找到插件默认执行的execute方法源码以下:

@Overridepublic void execute() throws MojoExecutionException, MojoFailureException { if (this.project.getPackaging().equals("pom")) { getLog().debug("repackage goal could not be applied to pom project."); return; } if (this.skip) { getLog().debug("skipping repackaging as per configuration."); return; }//从新打包 repackage();}


咱们继续跟入repackage()方法,源码以下:

private void repackage() throws MojoExecutionException { // 获取maven-jar-plugin插件构建的jar包 Artifact对象 Artifact source = getSourceArtifact(); // 获取maven-jar-plugin插件构建的jar包 File对象 File target = getTargetFile(); // 实例化从新打包对象,整个打包工做基本由这个对象完成//将maven-jar-plugin插件构建的jar包 File对象传入给source属性 Repackager repackager = getRepackager(source.getFile()); // 过滤掉spring-boot-devtools依赖以及系统本地的依赖 // this.project.getArtifacts()返回//pom.xml当前阶段范围的全部依赖信息对象集合,包含传递过来的 Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters())); // 建立lib信息对象,这里纯粹传参实例化没有逻辑 Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack, getLog()); try { // 获取sh应用管理脚本(start/stop/restart),可配置 // 这里默认返回null,惟有配置插件时配置了executable //或embeddedLaunchScript时才会实例化 LaunchScript launchScript = getLaunchScript(); // 进入核心重构jar方法 repackager.repackage(target, libraries, launchScript); } catch (IOException ex) { throw new MojoExecutionException(ex.getMessage(), ex); } //将重构好的File对象set到Artifact对象中,更新jar包文件 updateArtifact(source, target, repackager.getBackupFile());}

这个方法核心逻辑以下:


一、实例化maven-jar-plugin插件构建的原始jar包(后面统称这个jar为原始jar)文件对象

二、实例化启动脚本LaunchScript对象,因为没有配置参数,因此这里返回null

三、实例化Libraries依赖信息对象,这里原理是读取pom.xml依赖后转为Set<Artifact>集合

四、实例化重构jar包核心工做对象Repackager

五、调用Repackager对象的repackager重构jar方法,将实例化好的参数带入

六、最后重构完成后更新原来Artifact File对象

 

咱们继续跟入Repackager对象的repackage方法,源码以下:

 

public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException {
//参数校验 if (destination == null || destination.isDirectory()) { throw new IllegalArgumentException("Invalid destination"); } if (libraries == null) { throw new IllegalArgumentException("Libraries must not be null"); }
//实例化layout属性//layout做用是定义重构jar包内部目录名称//以及定义是否须要写入classloader使jar文件可执行,默认true if (this.layout == null) { //这里默认根据文件后缀判断实例化哪一个布局对象,//分别有Jar War Expanded(zip)等,默认实例化JarLayout对象 this.layout = getLayoutFactory().getLayout(this.source); }
// maven-jar-plugin构建的原始jar包File对象 destination = destination.getAbsoluteFile(); File workingSource = this.source; if (alreadyRepackaged() && this.source.equals(destination)) { return; } if (this.source.equals(destination)) {workingSource = getBackupFile(); workingSource.delete();// 将原始jar包备份(重命名)为原始jar名称.original文件 renameFile(this.source, workingSource); }//删除原始jar文件 destination.delete(); try { //JarFile是jdk自带的类,可从中获取jar对象信息 try (JarFile jarFileSource = new JarFile(workingSource)) { //将备份jar做为源,将删除后的原始jar做新的目的地,//传入lib对象、启动脚本等参数进入repackage对象的重载方法 repackage(jarFileSource, destination, libraries, launchScript); } } finally {
if (!this.backupSource && !this.source.equals(workingSource)) { //目前版本源码中,这里永远不会执行 由于backupSource写死为true deleteFile(workingSource); } }}


这个方法核心逻辑以下:


一、对传入的参数作必要性判断

二、根据原始jar包后缀判断初始化layout对象,这里初始化的是JarLayoutJarLayout主要布局信息为:BOOT-INF/liblib目录,BOOT-INF/classesclass类文件目录,须要写入springboot自定义的classloader使jar可正常执行

三、maven-jar-plugin插件构建的原始jar包备份后缀为original文件中,并将原始jar包删除,这么作目的是准备好从备份original文件到原始jar包的重构环境

四、进入Repackager对象的repackage重载方法,将准备好的参数带入


咱们继续跟入Repackager对象的repackage重载方法,源码以下:

private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript) throws IOException {
// 遍历前面传入的artifacts//并封装到内部Map<String, Library> libraryEntryNames中 // 过程当中将BOOT-INF/lib/目录拼接上去,//例如put(“BOOT-INF/lib/aaa.jar”, Library) // 同时会对判断是否存在重复依赖,存在会报错Duplicate library aaa.jar WritableLibraries writeableLibraries = new WritableLibraries(libraries);
// JarWriter是springboot封装了apache的JarArchiveOutputStream类 // 底层就是ziparchive版本的OutputStream输出流,支持往jar写东西的对象 // 这里简单理解成new FileOutputStream(new 前面被删除的原始jar文件对象), // 意思是准备往前面被删除的原始jar文件写入东西(删除后文件是干净的) try (JarWriter writer = new JarWriter(destination, launchScript)) {
// 写入META-INF/MANIFEST.MF文件内容 // 咱们看到的META-INF/MANIFEST.MF文件内容所有都是这个方法写入的 writer.writeManifest(buildManifest(sourceJar));
// 根据layout配置写入SpringBoot实现的ClassLoader // 写入classloader缘由是重构后的jar包的lib目录为BOOT-INF/lib, // 外部的classloader并不知道重构后的lib应该到哪里加载,// 因此springboot插件须要实现一个classloader并设置到线程上下文// 给后续加载类时使用,这里写入的classloader是springboot// 项目的其中一个module,打成jar包后解压写入进去的 writeLoaderClasses(writer); if (this.layout instanceof RepackagingLayout) { //将备份的original中class文件和其余资源文件所有写入到原始jar的//BOOT-INF/classes/目录下,写入过程进行了SHA-1签名 writer.writeEntries(sourceJar, new RenamingEntryTransformer(((RepackagingLayout) this.layout).getRepackagedClassesLocation()), writeableLibraries); } else { writer.writeEntries(sourceJar, writeableLibraries); } // 最后遍历前面WritableLibraries对象内部// Map<String, Library> libraryEntryNames // 将全部lib写入到原始jar中 writeableLibraries.write(writer); }}


这个方法从顶层直观告诉咱们springboot对原始jar包动手脚的整个前后顺序。核心逻辑以下:


一、实例化lib信息预写对象,主要是遍历libratis对象的artifacts属性,封装到map

二、将原始jar File对象包装成JarWrite对象,准备往干净的(由于前面被删过)原始jar文件写入新的东西

三、往原始jar写入META-INF/MANIFEST.MF文件内容

四、往原始jar写入classloader

五、从备份的original文件复制class类文件以及其余资源文件写入到原始jar BOOT-INF/classes目录下

六、将从pom.xml解析到的artifacts lib依赖包文件流写入到原始jar BOOT-INF/lib目录下


为了更加深度解析,咱们分别进入每一个写入方法大体过一下处理逻辑源码,下面逻辑都写在每一行代码注释中,请用心看注释描述。另外配套前面顶层源码的截图信息。

一、实例化lib信息预写对象源码以下:

顶层源码截图:

 

 

// Repackager类的一个内部类private final class WritableLibraries implements UnpackHandler {
private final Map<String, Library> libraryEntryNames = new LinkedHashMap<>();
//外部new调用的参数构造器 private WritableLibraries(Libraries libraries) throws IOException { // libraries实现类为ArtifactsLibraries,前面分析代码过程new出来的 // doWithLibraries方法代码下面有截图,逻辑很简单就是遍历artifact,而后 // 判断去重后调用这里的箭头函数,最后工做就是保存到map中 libraries.doWithLibraries((library) -> { if (isZip(library.getFile())) { // 这里layout代码以下,写死ruturn BOOT-INF/lib/ // public String getLibraryDestination(String libraryName, LibraryScope scope) { // return "BOOT-INF/lib/"; // } String libraryDestination = Repackager.this.layout.getLibraryDestination(library.getName(), library.getScope()); if (libraryDestination != null) {
// 放到map中,key=BOOT-INF/lib/ + jar名称, value=library信息对象 // 这里提一下,library里面有个File file属性,这个属性才是jar文件对象 Library existing = this.libraryEntryNames.putIfAbsent(libraryDestination + library.getName(), library); if (existing != null) { throw new IllegalStateException("Duplicate library " + library.getName()); } } } }); }
// 判断是否容许打开文件,主要后面sha1Hash签名时判断用 @Override public boolean requiresUnpack(String name) { Library library = this.libraryEntryNames.get(name); return library != null && library.isUnpackRequired(); }
// 文件加密方法 @Override public String sha1Hash(String name) throws IOException { Library library = this.libraryEntryNames.get(name); if (library == null) { throw new IllegalArgumentException("No library found for entry name '" + name + "'"); } return FileUtils.sha1Hash(library.getFile()); }
//能够看到写入是经过外部传入的JarWriter来执行的 //这个外部JarWriter实际就是原始jar的JarWriter对象 private void write(JarWriter writer) throws IOException { for (Entry<String, Library> entry : this.libraryEntryNames.entrySet()) { writer.writeNestedLibrary(entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1), entry.getValue()); } }
}

ArtifactsLibraries对象的doWithLibraries方法源码以下:

public void doWithLibraries(LibraryCallback callback) throws IOException { Set<String> duplicates = getDuplicates(this.artifacts); for (Artifact artifact : this.artifacts) { LibraryScope scope = SCOPES.get(artifact.getScope()); if (scope != null && artifact.getFile() != null) { String name = getFileName(artifact); // 处理重复jar名称 if (duplicates.contains(name)) { this.log.debug("Duplicate found: " + name); name = artifact.getGroupId() + "-" + name; this.log.debug("Renamed to: " + name); } // 回调传入的函数,artifact.getFile()是重点, // 它是真正jar的File 对象,能够读出来写入到其它地方 callback.library(new Library(name, artifact.getFile(), scope, isUnpackRequired(artifact))); } }}



2、将原始jar File对象包装成JarWrite对象源码以下:

顶层源码截图:

 

public JarWriter(File file, LaunchScript launchScript) throws FileNotFoundException, IOException { FileOutputStream fileOutputStream = new FileOutputStream(file); if (launchScript != null) { // 若是存在脚本,直接写入原始jar文件 fileOutputStream.write(launchScript.toByteArray()); // 修改下文件执行权限 setExecutableFilePermission(file); } this.jarOutput = new JarArchiveOutputStream(fileOutputStream); this.jarOutput.setEncoding("UTF-8");}


三、写入META-INF/MANIFE文件内容源码以下:

顶层源码截图:

 


 

buildManifest源码以下:

private Manifest buildManifest(JarFile source) throws IOException { // 备份original文件的Manifest文件 Manifest manifest = source.getManifest(); if (manifest == null) { manifest = new Manifest(); manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); } // 从新实例化一个新的Manifest文件 manifest = new Manifest(manifest); String startClass = this.mainClass; if (startClass == null) { startClass = manifest.getMainAttributes().getValue(MAIN_CLASS_ATTRIBUTE); } if (startClass == null) { //这里根据规则去找Application启动类,也就是Start-Class的值 //IDEA咱们手工run的那个主类 //若是查找超过必定时间会发出警告,建议让你手工配置,查找比较耗时 startClass = findMainMethodWithTimeoutWarning(source); } //layout。getLauncherClassName方法以下: // public String getLauncherClassName() { // return "org.springframework.boot.loader.JarLauncher"; // } String launcherClassName = this.layout.getLauncherClassName(); if (launcherClassName != null) { // 写入 Main-Class manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, launcherClassName); if (startClass == null) { throw new IllegalStateException("Unable to find main class"); } // 写入 Start-Class manifest.getMainAttributes().putValue(START_CLASS_ATTRIBUTE, startClass); } else if (startClass != null) { manifest.getMainAttributes().putValue(MAIN_CLASS_ATTRIBUTE, startClass); } String bootVersion = getClass().getPackage().getImplementationVersion(); manifest.getMainAttributes().putValue(BOOT_VERSION_ATTRIBUTE, bootVersion); // 写入Spring-Boot-Classes = BOOT-INF/classes/ manifest.getMainAttributes().putValue(BOOT_CLASSES_ATTRIBUTE, (this.layout instanceof RepackagingLayout) ? ((RepackagingLayout) this.layout).getRepackagedClassesLocation() : this.layout.getClassesLocation()); String lib = this.layout.getLibraryDestination("", LibraryScope.COMPILE); if (StringUtils.hasLength(lib)) { // 写入Spring-Boot-Lib = BOOT-INF/lib/ manifest.getMainAttributes().putValue(BOOT_LIB_ATTRIBUTE, lib); } return manifest;}


writeManifest关键源码以下:

public void writeManifest(Manifest manifest) throws IOException { JarArchiveEntry entry = new JarArchiveEntry("META-INF/MANIFEST.MF"); writeEntry(entry, manifest::write);}

writeEntry有多个重载方法,最终都走的是下面的重载方法:

private void writeEntry(JarArchiveEntry entry, EntryWriter entryWriter, UnpackHandler unpackHandler) throws IOException {
String parent = entry.getName(); if (parent.endsWith("/")) { parent = parent.substring(0, parent.length() - 1); entry.setUnixMode(UnixStat.DIR_FLAG | UnixStat.DEFAULT_DIR_PERM); } else { entry.setUnixMode(UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM); } if (parent.lastIndexOf('/') != -1) { parent = parent.substring(0, parent.lastIndexOf('/') + 1); if (!parent.isEmpty()) { writeEntry(new JarArchiveEntry(parent), null, unpackHandler); } }
if (this.writtenEntries.add(entry.getName())) { // 这里判断是否容许打开文件,若是容许则往里面写入sha1Hash签名 entryWriter = addUnpackCommentIfNecessary(entry, entryWriter, unpackHandler); //前面实例化的原始jar的JarArchiveOutputStream对象 //这里表明写入entry到输出流内存中 this.jarOutput.putArchiveEntry(entry); if (entryWriter != null) { //将输出流写到entryWriter对象 entryWriter.write(this.jarOutput); } //关闭资源 this.jarOutput.closeArchiveEntry(); }}


四、写入classloader源码以下:

顶层源码截图:

 

 

writeLoaderClasses源码以下:

private void writeLoaderClasses(JarWriter writer) throws IOException { // 自定义布局时才走这里,通常不多人这么闲本身实现calssloader的 if (this.layout instanceof CustomLoaderLayout) { ((CustomLoaderLayout) this.layout).writeLoadedClasses(writer); } // layout.isExecutable固定返回true,全部进入这里 else if (this.layout.isExecutable()) { writer.writeLoaderClasses(); }}


连环调用源码以下:

private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar";
public void writeLoaderClasses() throws IOException { writeLoaderClasses(NESTED_LOADER_JAR);}
public void writeLoaderClasses(String loaderJarResourceName) throws IOException { // getClass().getClassLoader()返回的是JDK的sun.misc.Launcher$AppClassLoader类加载器 // 获取META-INF/loader/spring-boot-loader.jar文件 URL loaderJar = getClass().getClassLoader().getResource(loaderJarResourceName); try (JarInputStream inputStream = new JarInputStream(new BufferedInputStream(loaderJar.openStream()))) { JarEntry entry; while ((entry = inputStream.getNextJarEntry()) != null) { if (entry.getName().endsWith(".class")) { // 遍历spring-boot-loader.jar文件 //把全部的.class文件写入到原始jar包中 writeEntry(new JarArchiveEntry(entry), new InputStreamEntryWriter(inputStream)); } } }}


五、从备份original文件复制class文件源码以下:

顶层源码截图:

 

void writeEntries(JarFile jarFile, EntryTransformer entryTransformer, UnpackHandler unpackHandler) throws IOException { Enumeration<JarEntry> entries = jarFile.entries(); //遍历整个备份的original文件 while (entries.hasMoreElements()) { JarArchiveEntry entry = new JarArchiveEntry(entries.nextElement()); setUpEntry(jarFile, entry); try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) { EntryWriter entryWriter = new InputStreamEntryWriter(inputStream); JarArchiveEntry transformedEntry = entryTransformer.transform(entry); if (transformedEntry != null) { //将全部class类文件以及其余资源文件写入到原始jar中 //这里走的是前面分析的带签名的writeEntry重装方法 writeEntry(transformedEntry, entryWriter, unpackHandler); } } }}
void writeEntries(JarFile jarFile, EntryTransformer entryTransformer, UnpackHandler unpackHandler) throws IOException { Enumeration<JarEntry> entries = jarFile.entries(); //遍历整个备份的original文件 while (entries.hasMoreElements()) { JarArchiveEntry entry = new JarArchiveEntry(entries.nextElement()); setUpEntry(jarFile, entry); try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) { EntryWriter entryWriter = new InputStreamEntryWriter(inputStream); JarArchiveEntry transformedEntry = entryTransformer.transform(entry); if (transformedEntry != null) { //将全部class类文件以及其余资源文件写入到原始jar中 //这里走的是前面分析的带签名的writeEntry重装方法 writeEntry(transformedEntry, entryWriter, unpackHandler); } } }}


六、写入lib资源源码以下:

顶层源码截图:

 

//能够看到写入是经过外部传入的JarWriter来执行的//这个外部JarWriter实际就是原始jar的JarWriter对象private void write(JarWriter writer) throws IOException { for (Entry<String, Library> entry : this.libraryEntryNames.entrySet()) { writer.writeNestedLibrary(entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1), entry.getValue()); }}


整个springboot重构jar包源码分析到此就算结束了,你get到了吗?

 

若是你读完还一头雾水没啥收获,这里给出的绝招是:请集中精力,再读一遍!




长按扫码关注《Java软件编程之家》微信公众号



本文分享自微信公众号 - Java软件编程之家(gh_b3a87885f8f5)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索