我們知道,SpringBoot僅憑一個Jar包就能將我們構建的整個工程跑起來,如果你也想知道這個能跑起來的jar內部結構是如何構建出來的,請耐心讀完本篇,本篇內容可能有點多,但包你有收穫。如果讀完沒有收穫,請拉到文章最後,我再告訴你一個絕招。
分析Springboot重構Jar包源碼前我們先按平常方式創建一個springboot項目,通過IDEA或springboot提供的網站(https://start.spring.io/)很容易就可以創建出一個springboot web工程。注意:創建時要把Web Starter選擇上,不然後面不會啟動Tomcat容器。創建好的項目目錄如下:
pom.xml文件如下:
<code>
<project>
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>
/<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>/<code>
SourcecodeAnalysisSpringbootApplication.java類如下:
<code>package com.sourcecode.analysis.springboot;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SourcecodeAnalysisSpringbootApplication {
public static void main(String[] args) {
SpringApplication.run(SourcecodeAnalysisSpringbootApplication.class, args);
}
}/<code>
application.properties文件默認是空的。
當springboot項目創建好之後,我們可以直接運行SourcecodeAnalysisSpringbootApplication.java類的main方法即可啟動web工程,啟動成功控制檯如下:(默認端口8080)
除此之外我們也可以通過執行maven package方式將項目打成jar包,通過java -jar 命令來運行,運行效果如下:
springboot項目創建過程就是這麼多,接下來就可以在這個腳手架基礎上去填我們的業務代碼了。在springboot之前要實現這個過程可是要配置一大堆xml的,但是現在這些事情springboot通過starter的方式幫我們做了。由於我們本章主要內容是講解springboot重構jar包的源碼,關於springboot starter如何幫我們簡化配置的等到後面有時間再寫一篇來介紹。
當通過java -jar命令啟動jar包時,首先會先從jar包中META-INF/MANIFEST.MF文件中找到Main-Class的值作為主類來運行jar包,這是java基礎知識。所以說要了解springboot是如何啟動的,我們首先需要將springboot打出來的jar包解壓出來,找到META-INF/MANIFEST.MF文件並打開,我們可以看到大概如下內容:
<code>Manifest-Version: 1.0
Implementation-Title: sourcecode-analysis-springboot
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: com.sourcecode.analysis.springboot.SourcecodeAnalysisSpri
ngbootApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.2.2.RELEASE
Created-By: Maven Archiver 3.4.0
Main-Class: org.springframework.boot.loader.JarLauncher/<code>
可以看到Main-Class的值為: org.springframework.boot.loader.JarLauncher。通過java -jar運行時執行的主方法便是 org.springframework.boot.loader.JarLauncher類的main方法。而我們通過IDEA手工運行的主類被配置為key為Start-Class的值中,這個類是怎麼寫到META-INF/MANIFEST.MF文件中的呢?因為我們前面是通過maven package命令打包出來的,所以要解開這個問題我們要回到maven打包階段去思考,想到這裡此時我們應該看下pom.xml的插件配置,可以看到如下核心配置:
<code><build>
<plugins>
<plugin>
<groupid>org.springframework.boot/<groupid>
<artifactid>spring-boot-maven-plugin/<artifactid>
/<plugin>
/<plugins>
/<build>/<code>
從名字感官上可以看出這應該是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源碼中找到一個註解@Mojo為repackage的RepackageMojo類文件:
這個類頂層繼承自maven插件抽象父類AbstractMojo,且被定義為打包階段執行,這個類正是spring-boot-maven-plugin插件中執行的repackage,我們找到插件默認執行的execute方法源碼如下:
<code>@Override
public 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();
}/<code>
我們繼續跟入repackage()方法,源碼如下:
<code>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());
}/<artifact>/<code>
這個方法核心邏輯如下:
1、實例化maven-jar-plugin插件構建的原始jar包(後面統稱這個jar為原始jar)文件對象
2、實例化啟動腳本LaunchScript對象,由於沒有配置參數,所以這裡返回null
3、實例化Libraries依賴信息對象,這裡原理是讀取pom.xml依賴後轉為Set<artifact>集合/<artifact>
4、實例化重構jar包核心工作對象Repackager
5、調用Repackager對象的repackager重構jar方法,將實例化好的參數帶入
6、最後重構完成後更新原來Artifact File對象
我們繼續跟入Repackager對象的repackage方法,源碼如下:
<code>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);
}
}
}/<code>
這個方法核心邏輯如下:
對傳入的參數做必要性判斷
根據jar包後綴判斷初始化layout對象,這裡初始化的是JarLayout,JarLayout主要佈局信息為:BOOT-INF/lib為lib目錄,BOOT-INF/classes為class類文件目錄,需要寫入springboot自定義的classloader使jar可正常執行
將maven-jar-plugin插件構建的原始jar包備份後綴為original文件中,並將原始jar包刪除,這麼做目的是準備好從備份original文件到原始jar包的重構環境
進入Repackager對象的repackage重載方法,將準備好的參數帶入
我們繼續跟入Repackager對象的repackage重載方法,源碼如下:
<code>private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript)
throws IOException {
// 遍歷前面傳入的artifacts
//並封裝到內部Map<string> 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> libraryEntryNames
// 將所有lib寫入到原始jar中
writeableLibraries.write(writer);
}
}/<string>/<string>/<code>
這個方法從頂層直觀告訴我們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信息預寫對象源碼如下:
<code>// Repackager類的一個內部類
private final class WritableLibraries implements UnpackHandler {
private final Map<string> 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> 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)));
}
}
}/<string>/<string>/<string>/<code>
2、將原始jar File對象包裝成JarWrite對象源碼如下:
寫入META-INF/MANIFE文件內容源碼如下:
<code>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");
}/<code>
buildManifest源碼如下:
<code>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();
}
}/<code>
writeLoaderClasses源碼如下:
<code>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();
}/<code>
連環調用源碼如下:
<code>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));
}
}
}
}/<code>
從備份original文件複製class文件源碼如下:
連帶調用源碼如下:
<code>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);
}
}
}
}/<jarentry>/<code>
寫入lib資源源碼如下:
這個方法前面實例化lib預寫對象時已經看過了,這裡再放出來一遍:
//可以看到寫入是通過外部傳入的JarWriter來執行的
//這個外部JarWriter實際就是原始jar的JarWriter對象
<code>private void write(JarWriter writer) throws IOException {
for (Entry<string> entry : this.libraryEntryNames.entrySet()) {
writer.writeNestedLibrary(entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1),
entry.getValue());
}
}/<string>/<code>
整個springboot重構jar包源碼分析到此就算結束了,你get到了嗎?
如果你讀完還一頭霧水沒啥收穫,這裡給出的絕招是:請集中精力,再讀一遍!
閱讀更多 laizhiy 的文章