写在翻译前

原文链接为:Spring Transaction Management: @Transactional In-Depth | MarcoBehler

阅读建议: 原文没有配置可运行项目代码,译者根据理解补充了一份。 为了帮助理解和调试,建议下载 poc/spring-transaction 示例项目,边动手边学习。 示例项目使用 Embedded H2 数据库,通过 spring schema.sql 初始化数据结构,通过访问 http://localhost:8080/h2-console 管理界面,可进行基础的 CURD 查询,方便读者进行数据校验。 当然有经验的读者,也可以把数据源切换为 mysql,毕竟其 GUI 工具更加齐全。

为什么要翻译此文?
理由有三:

  • 讲解透彻,作者专业功底扎实,读之直呼彩。
  • 内容深入浅出,从原理到上层实践娓娓道来,作者自下向上解构一个特性的思维值得学习。
  • 编排为启发式架构,全程由问题引出解答,由解答引出下一个问题,环环相扣。

⬇️⬇️⬇️ 话不在多,请看正文 ⬇️⬇️⬇️

你可以通过本文,对 @Transactional 注解在 Spring 事务管理中的运行机制,形成一个简明实用的理解。

唯一的阅读前提?你需要对数据库 ACID 原则有个大致了解,即数据库事务是什么,我们为什么需要它。此外,本文没有覆盖分布式事务和反应式事务(reactive transactions),尽管在 Spring 中下文提到的一些通用原则也是适用的。

简介

在本文中,你将学到 Spring 事务抽象框架的核心概念(黑人脸+问号?),同时会有很多示例代码帮助你理解

  • @Transactional(声明式事务管理)vs 编程式事务管理
  • 物理 vs 逻辑 事务
  • Spring @Transactional 与 JPA / Hibernate 集成
  • Spring @Transactional 和 Spring Boot 或 Spring MVC 集成
  • 回滚、代理、常见陷阱等

相对于 Spring 官方文档,本文不会让你迷失在 Spring 的上层概念里。
相反你会以不同寻常的路径来学习 Spring 事务管理。从底层开始,一层层向上。也就是说,你将从普通原始的 JDBC 事务 学起。

普通的 JDBC 事务是如何工作的

如果你对 JDBC 事务还没有透彻的了解,请不要想着忽略此章节。

如何 start, commit 或 rollback JDBC 事务

第一个重要的要点是:不管你使用的是 Spring @Transactional,Hibernate,JOOQ 或者其他数据库类。

最终,他们都做了同样的事来开启和关闭(或称为“管理”)数据库事务。纯 JDBC 事务管理代码如下:

  1. import java.sql.Connection;
  2. Connection connection = dataSource.getConnection(); // (1)
  3. try (connection) {
  4. connection.setAutoCommit(false); // (2)
  5. // execute some SQL statements...
  6. connection.commit(); // (3)
  7. } catch (SQLException e) {
  8. connection.rollback(); // (4)
  9. }
  1. 你需要先建立数据库链接来开启事务。尽管在大多数企业级应用中你会通过数据源配置来获取连接,但单独的 DriverManager.getConnection(url, user, password) 也可以工作得很好。
  2. 这是唯一的在 JAVA 中开启数据库事务的方法,尽管名字听起来对不上。setAutoCommit(true) 包装了在它事务内的所有 SQL 表达式,而 setAutoCommit(false) 则相反:你可以基于此来开关事务。
  3. 提交执行事务…
  4. 或者,假如发生了意外,则回滚我们的变更。

这 4 行高度简化的代码,就是 Spring @Transactional 为你在背后做的所有事情。在下一章节中,你将会学到他们是如何工作的。在此之前,我们还有一丁点知识点要补充。

(快速入门:根据配置,类似 HikariCP 的连接池可以自动切换 autocommit 模式。但这是个高级话题了,不扯远)

如何使用 JDBC 隔离级别和保存点(savepoints)

如果你已经使用过 Spring @Transactional 注解,你可能碰到过类似用法:

  1. @Transactional(propagation=TransactionDefinition.NESTED,
  2. isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED)

我们会在后文更加详细的介绍 Spring 嵌套事务和隔离级别,在这重复提及,是因为这些参数最终可提炼成如下 JDBC 代码:

  1. import java.sql.Connection;
  2. // isolation=TransactionDefinition.ISOLATION_READ_UNCOMMITTED
  3. connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED); // (1)
  4. // propagation=TransactionDefinition.NESTED
  5. Savepoint savePoint = connection.setSavepoint(); // (2)
  6. ...
  7. connection.rollback(savePoint);
  1. 这里展示了 Spring 是如何在数据库连接上设置隔离级别的。是不是完全不像造火箭(Rocket Science)那样复杂?
  2. Spring 中的嵌套事务等价于 JDBC 中的保存点。如果你不知道什么是保存点,可以看下这个教程「译者注:参见配套项目 PlainOldJDBCSample 实现」。注意保存点特性支持依赖于你的 JDBC 驱动/数据库。

Spring 或 Spring Boot 的事务是如何工作的

既然现在你对 JDBC 事务有了基础的理解,让我们再去探究下纯粹的 Spring 核心 事务。这里所讲都可 1:1 适用于 Spring Boot 和 Sring MVC,但又做了一些补充。

到底什么是 Spring 事务管理或事务抽象框架(更加困惑的命名)?

记住,事务管理可简单理解为:Spring 如何 start, commit 或 rollback JDBC 事务?是不是听着和前文讲得很相似?

抓住重点:基于 JDBC 你只有一个方法(setAutocommit(false))来开启事务管理,Spring 提供了许多不同,但更方便的封装来做相同的事情。

如何使用 Spring 编程式事务管理?

最初,但现在很少使用方式,是在 Spring 通过编程定义事务:通过 TransactionTemplate 或者直接使用 PlatformTransactionManager。代码示例如下「译者注:参见配套项目 BookingServcie 实现」:

  1. @Service
  2. public class UserService {
  3. @Autowired
  4. private TransactionTemplate template;
  5. public Long registerUser(User user) {
  6. Long id = template.execute(status -> {
  7. // execute some SQL that e.g.
  8. // inserts the user into the db and returns the autogenerated id
  9. return id;
  10. });
  11. }
  12. }

JDBC 示例 比较:

  • 你不再需要手动开关数据库连接(try-finally),取而代之的是 Transaction Callbacks
  • 你也不再需要手动捕获 SQLExceptions,Spring 将这些异常转换成了运行时异常。
  • 还有,你的代码能更好的集成进 Spring 生态。TransactionTemplate 内部会使用到 TransactionManager,后者会使用到某个数据源(data source)。这些都是需要你事先在 Spring context 配置里定义的 Beans,定义完后你就不用操心了。

尽管这是一个不小的改进,但编程式事务管理并不是 Spring 事务框架主要关注的。相反,声明式事务管理才是重头戏。让我们一探究竟

如何使用 Spring 的 XML 声明式事务管理?

在过去,使用 XML 进行配置是 Spring 项目的标配,你可以在 XML 文件中直接配置事务。但现在,除了一些历史遗留,企业项目,你很难在日常开发中遇到此类用法,取而代之是更加简明的 @Transactional 注解。

我们不会在本文中深入讲解 XML 配置,但是如果你有兴趣的话,你可以通过这个示例作为深入研究的起点(示例直接摘录自 official Spring documentation

  1. <!-- the transactional advice (what 'happens'; see the <aop:advisor/> bean below) -->
  2. <tx:advice id="txAdvice" transaction-manager="txManager">
  3. <!-- the transactional semantics... -->
  4. <tx:attributes>
  5. <!-- all methods starting with 'get' are read-only -->
  6. <tx:method name="get*" read-only="true"/>
  7. <!-- other methods use the default transaction settings (see below) -->
  8. <tx:method name="*"/>
  9. </tx:attributes>
  10. </tx:advice>

你通过上面的 XML 定义了 AOP advice(面向切面编程),你可以通过如下配置应用到 UserService bean

<aop:config>
  <aop:pointcut id="userServiceOperation" expression="execution(* x.y.service.UserService.*(..))"/>
  <aop:advisor advice-ref="txAdvice" pointcut-ref="userServiceOperation"/>
</aop:config>

<bean id="userService" class="x.y.service.UserService"/>

你的 UserService bean 看起像这样:

public class UserService {

    public Long registerUser(User user) {
        // execute some SQL that e.g.
        // inserts the user into the db and retrieves the autogenerated id
        return id;
    }
}

从 Java 代码的角度来看,这种声明式事务实现比编程式简单很多。但是,为了配置 pointcutadvisor 也衍生了很多复杂冗余的 XML。

如何使用 Spring 的 @Transactional 注解(声明式事务管理)

让我们看下时下的 Spring 事务管理通常怎么用:

public class UserService {

    @Transactional
    public Long registerUser(User user) {
        // execute some SQL that e.g.
        // inserts the user into the db and retrieves the autogenerated id
        // userDao.save(user);
        return id;
    }
}

这是怎么做到的?没有了冗余的 XML 配置和额外的编码。相反,你只需要做两件事:

  • 确定你的 Spring 配置添加了 @EnableTransactionManagement 标注(Spring Boot 会自动为你开启)
  • 确定你在 Spring 配置中指定了一个事务管理器(这需要你自己做)「译者注:引入 spring-boot-starter-data-jdbc 可以自动帮你配置事务管理器」
  • 之后,聪明的 Spring 能够为你处理事务了,这一切对于你来说是透明的:任何 Bean 上标注了 @Transactional 的公共方法,会在一个数据库事务中执行(注意:这里有一些坑)。

所以,为了让 @Transactional 工作,你需要:

@Configuration
@EnableTransactionManagement
public class MySpringConfig {

    @Bean
    public PlatformTransactionManager txManager() {
        return yourTxManager; // more on that later
    }

}

现在,我说 Spring 透明的为你处理事务,到底在指什么?
在有了 JDBC 事务示例 的知识储备后,@Transactional 标注的 UserService 可以翻译简化成:

public class UserService {

    public Long registerUser(User user) {
        Connection connection = dataSource.getConnection(); // (1)
        try (connection) {
            connection.setAutoCommit(false); // (1)

            // execute some SQL that e.g.
            // inserts the user into the db and retrieves the autogenerated id
            // userDao.save(user); <(2)

            connection.commit(); // (1)
        } catch (SQLException e) {
            connection.rollback(); // (1)
        }
    }
}
  1. 这些都是标准的数据库连接开闭操作。Spring 事务管理为你自动做了这一切,你无需显式地编码。
  2. 这是你的业务代码,通过一个 DAO 保存用户等

这个示例看起来像魔术,让我们继续探究下 Spring 是如何为你自动插入这些连接代码的。

CGLIB & JDK 代理 - 在 @Transactional 之下

Spring 不能真的像我上面做的那样,去重写你的 Java 类来插入连接代码(除非你使用字节码增强等高级技术,在这我们暂时忽略它)

你的 registerUser() 方法还是只是调用了 userDao.save(user),这是无法实时改变的。

但是 Spring 有它的优势。在核心层,它有一个 IoC 容器。它实例化一个 UserService 单例并可自动注入到任何需要 UserService 的 Bean 中。

不管何时你在一个 Bean 上使用 @Transactional,Spring 使用了个小伎俩。它不是直接实例化一个 UserService 原始对象,而不是一个 UserService 的事务代理对象。

借助 Cglib library 的能力,它可以使用子类继承代理(proxy-through-subclassing)的方式来实现。当然还有其他方式可以构造代理对象(例如 Dynamic JDK proxies 「译者注:这要求代理对象有相应接口类」),这里暂不做展开。

让我们通过一张图来了解代理的具体操作:
image.png
正如你在图中所看到的,这代理做了一件事:

  • 开闭数据库连接和事务
  • 然后代理到你所写的原始 UserService 对象
  • 最后其他 Beans,像你的 UserRestController 永远不知道他们在和代理对象交互,而不是原始对象。

Quick Exam
看看下面的源代码,并告诉我 Spring 会自动构造哪种类型的 UserService,假设它已标记为 @Transactional 或具有 @Transactional 方法。

@Configuration
@EnableTransactionManagement
public static class MyAppConfig {

    @Bean
    public UserService userService() {  // (1)
        return new UserService();
    }
}
  1. 正确答案:Spring 会在这里构造一个 UserService 类的动态 CGLib 代理,可以为你开闭数据库事务。你或者其他任何 Bean 都不会注意到这不是原始 UserService 对象,而是一个 UserService 的代理对象。

你需要什么样的事务管理器(例如:PlatformTransactionManager)?

现在仅剩一个重要的知识点还没讲到,尽管我们在前文中已经多次提到。

你的 UserService 可以动态生成代理类,并且代理可以帮你管理事务。但是并不是代理类本身去处理事务状态(open,commit,close),而是委托给了事务管理器(transaction manager)。

Spring 提供了 PlatformTransactionManager / TransactionManager 接口定义,默认也提供了些实用的实现。其中一个是数据源事务管理器(datasource transaction manager)。

它与你到目前为止管理事务的操作完全相同,但是首先让我们看一下所需的Spring配置:

// 「译者注」:Spring Boot 也有自动配置见:
// org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
@Bean
public DataSource dataSource() {
    return new MysqlDataSource(); // (1)
}

// 「译者注」:Spring Boot 也有自动配置见:
// org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
@Bean
public PlatformTransactionManager txManager() {
    return new DataSourceTransactionManager(dataSource()); // (2)
}
  1. 你在这里创建了指定数据库或者连接池的数据源。示例中使用的 Mysql。
  2. 在这里你,创建了事务管理器,它需要一个数据源作为入参来管理事务。

简述之。所有事务管理器都具有“ doBegin”(用于启动事务)或“ doCommit”之类的方法,类似如下从 Spring 源码直接截取并简化的代码:

public class DataSourceTransactionManager implements PlatformTransactionManager {

    @Override
    protected void doBegin(Object transaction, TransactionDefinition definition) {
        Connection newCon = obtainDataSource().getConnection();
        // ...
        con.setAutoCommit(false);
        // yes, that's it!
    }

    @Override
    protected void doCommit(DefaultTransactionStatus status) {
        // ...
        Connection connection = status.getTransaction().getConnectionHolder().getConnection();
        try {
            con.commit();
        } catch (SQLException ex) {
            throw new TransactionSystemException("Could not commit JDBC transaction", ex);
        }
    }
}

因此,数据源事务管理器在管理事务时将使用与 JDBC 几乎完全相同的代码。

记住了这一点,我们基于上述结论拓展流程图:
image.png
总结如下:

  1. 如果 Spring 在 Bean 上检测到 @Transactional 注解,它将创建该 Bean 的动态代理类。
  2. 代理类可以访问事务管理器,并要求其打开和关闭事务/连接。
  3. 事务管理器本身也就是简单执行你之前手动做的事:管理一个实用、传统的 JDBC 连接。

物理和逻辑事务之间的有什么区别?

想象以下两个事务类。

@Service
public class UserService {

    @Autowired
    private InvoiceService invoiceService;

    @Transactional
    public void invoice() {
        invoiceService.createPdf();
        // send invoice as email, etc.
    }
}

@Service
public class InvoiceService {

    @Transactional
    public void createPdf() {
        // ...
    }
}

UserService 有一个 invoice() 事务方法。它调用了另外一个 InvoiceService 类上的 createPdf() 事务方法。

现在就数据库事务而言,这里只有 1 个数据库事务。(记住:getConnection(),setAutocommit(false),commit() )。Spring 称之为物理事务,可能一下子不解其意。

然而从 Spring 看来,这里有 2 个逻辑事务存在:第一个在 UserService,另外一个在 InvoiceService。Spring 足够智能知道让两个 @Transactional 标记的方法,在底层使用同一个物理数据库事务。

我们做了如下变更后,呈现会有什么不同?

@Service
public class InvoiceService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createPdf() {
        // ...
    }
}

更改事务传播模式为 requires_new 告诉 Spring:createPDF() 需要在它自己的、独立于其他任何已经在的事务里执行。会想下本文中之前的原始 Java 版本,你看到过有一种方式可以将事务一分为二的吗?我是没看到的。

这意味着你的底层代码会打开 2(物理)连接/事务 到数据库(再提:getConnection() x2,setAutocommit(false) x2,commit() x2 )。Spring 依旧能够机智的把 2 个 逻辑事务( invoice()/createPdf() )映射到两个不同的物理数据库事务上。

因此,总结如下:

  • 物理事务:实际上等价于 JDBC 事务
  • 逻辑事务:Spring 中被 @Transactional 标记的方法(可能存在嵌套)

接下来,我们将深入了解下事务传播模式一些细节。

@Transactional 传播级别(Propagation Levels)有什么用?

当你去查阅 Spring 源码,你会发现有多种传播级别可以挂载到 @Transactional 方法上。

// 「译者注:这是 Spring 中的默认传播方式」
@Transactional(propagation = Propagation.REQUIRED)

// or

@Transactional(propagation = Propagation.REQUIRES_NEW)
// etc

完整列表如下:

  • REQUIRED
  • SUPPORTS
  • MANDATORY
  • REQUIRES_NEW
  • NOT_SUPPORTED
  • NEVER
  • NESTED

练习:
在原始 Java 实现那节,我展示了 JDBC 能够对事务进行的所有操作。花几分钟思考下,每个 Spring 传播模式在 数据库或 JDBC 连接层面到底做了什么。

然后再看下下面的解答。

Answers:

  • Required (default):我的方法需要事务支持,使用现有的或为我新建一个 → getConnection(),setAutocommit(false),commit()。
  • Supports:我不在乎是否有事务打开,我都能正常工作 → 不在 JDBC 层面做任何事情
  • Mandatory:我不打算为自己打开一个事务,但如果没有人为我打开事务,我会大呼小叫 「译者注:我需要事务,但是个伸手党,需要别人为我打开,不然我会抛错」→ 不在 JDBC 层面做任何事情
  • Require_new:我需要一个独占的事务 → getConnection(),setAutocommit(false),commit()。
  • Not_Supported:我不喜欢事务,我甚至会挂起当前的运行事务 → 不在 JDBC 层面做任何事情
  • Never:如果有人为我开启了事务,我会大呼小叫 → 不在 JDBC 层面做任何事情
  • Nested:听起来有些复杂,但其实我们在谈保存点!→ connection.setSavepoint()

如你所见,大多数传播模式并没有在数据库或 JDBC 层面做什么事情。更多的是通过 Spring 来组织你的代码,告诉 Spring 如何/什么时候/哪里需要事务处理。

看下这个示例:

public class UserService {

     @Transactional(propagation = Propagation.MANDATORY)
     public void myMethod() {
        // execute some sql
     }

}

在示例中,任何时候你调用 UserServicemyMethod() 方法,Spring 期望这里有一个打开的事务。它不会为自己开启,相反,在没有已开启事务的情况下调用方法,Spring 会抛出异常。请记住这 “逻辑事务处理”的补充知识点。

@Transactional 上隔离级别(Isolation Levels)代表什么?

这是个抖机灵的问题,但当你如下配置的时候,到底发生了什么:

@Transactional(isolation = Isolation.REPEATABLE_READ)

哈,这可以简单地等价于:

connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

然而数据库事务隔离级别,是一个复杂的主题,你需要自己花些时间去掌握。Pstgres 的官方文档中的 isolation levels 章节,是个不错的入门文档。

再提一嘴,当你在一个事务中切换隔离级别的时候,你必须事先确认底层 JDBC 驱动/数据库是否支持你需要的特性。

最容易踩的 @Transactional 的坑

这里有一个 Spring 新手经常踩的坑,看下如下代码:

@Service
public class UserService {

    @Transactional
    public void invoice() {
        createPdf();
        // send invoice as email, etc.
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createPdf() {
        // ...
    }
}

你有一个 UserService 类,事务方法 invoice 内部调用了事务方法 createPdf()

所以,当有人调用 invoice() 的时候,最终有个多少个物理事务被打开?

答案不是 2 个,而是 1 个,为什么呢?

让我们回到本文中代理那章节。Spring 为你创建了 UserService 代理类,但代理类内部的方法调用,是无法被代理的。也就是说,没有新的事务为你生成。

看下示例图:
image.png
这里有些技巧(例如:self-injection 「译者注:参见示例项目 InnerCallSercie」),可以帮助你绕过该限制。但主要收获是:始终牢记代理事务的边界。

如何在 Spring Boot 或 Spring MVC 中使用 @Transactional

我们目前只是讨论了纯粹的核心 Spring 上的用法。那在 Spring Boot 或 Spring MVC 中会有什么使用差异吗?

答案是:没有。

无论使用何种框架(或更确切地说:Spring 生态系统中的所有框架),您都将始终使用 @Transactional 注解,配合事务管理器,以及 @EnableTransactionManagement 注解。 没有其他用法了。

但是,与 Spring Boot 的唯一区别是,通过 JDBC 自动配置,它会自动设置 @EnableTransactionManagement 注解,并为你创建 PlatformTransactionManager

Spring 是如何处理回滚的(以及默认的回滚策略)

关于 Spring 回滚的部分,将会在下一次文章修订中补充。

「译者注:Spring Boot 内回滚是通过 @Transactional 注解上 rollback 系列配置实现的,读者可查阅源码注释了解使用方式,注释还是写得很完备的,本质上也是根据配置条件,确定何时调用 commit,何时调用 rollback」

Spring 和 JPA / Hibernate 事务管理是如何一起工作的

目标:同步 Spring @Transactional 和 Hibernate / JPA

在这个节点上,你期望 Spring 可以和其他数据库框架,类似 Hibernate(一个流行的 JPA 实现)或 Jooq 等整合。

让我来看一个纯粹的 Hibernate 示例(注意:直接使用 Hibernate 还是通过 JPA 使用 Hibernate 都没关系)。

用 Hibernate 将 UserService 重写如下:

public class UserService {

    @Autowired
    private SessionFactory sessionFactory; // (1)

    public void registerUser(User user) {

        Session session = sessionFactory.openSession(); // (2)

        // lets open up a transaction. remember setAutocommit(false)!
        session.beginTransaction();

        // save == insert our objects
        session.save(user);

        // and commit it
        session.getTransaction().commit();

        // close the session == our jdbc connection
        session.close();
    }
}
  1. 这是纯粹、原始的 Hibernate 会话工厂(SessionFactory),所有 Hibernate 查询的入口
  2. 通过 Hibernate 的 API 手动管理会话(读取:数据库连接)和事务

然而上述代码有一个大问题:

  • Hibernate 无法识别 Spring @Transactional 注解
  • Spring @Transactional 也不知道 Hibernate 的事务封装概念

但最终我们还是可以将 Spring 和 Hibernate 无缝整合,也就是说他们其实可以理解对象的事务概念。

代码如下:

@Service
public class UserService {

    @Autowired
    private SessionFactory sessionFactory; // (1)

    @Transactional
    public void registerUser(User user) {
        sessionFactory.getCurrentSession().save(user); // (2)
    }

}
  1. 与上文相同的 SessionFactory
  2. 不需要手动进行状态管理。相反,getCurrentSession()@Transactional 是同步进行的。

这是怎么做到的?

使用 HibernateTransactionManager

有一个非常简单的解决此集成问题的方法:

相比在 Spring 配置里使用 DataSourcePlatformTransactionManager,你可以替换成 HibernateTransactionManager(如果使用了原生 Hibernate)或 JpaTransactionManager(如果通过 JPA 使用了 Hibernate)

这个定制化的 HibernateTransactionManager 会确保:

  • 通过 Hibernate(即SessionFactory)管理事务。
  • 足够智能允许 Spring 在非 Hibernate 中使用相同的事务注解,即 @Transactional

与往常一样,一图胜千言(不过请注意,代理和真实服务之间的流程在这被高度抽象和简化了)。
image.png
上诉是对 SPring 和 Hibernate 整合方式的简单概括。

在了解其他集成方式或打算进行深入了解之前,看一眼 Spring 提供的所有 PlatformTransactionManager 实现,会大有裨益。

最后

到目前为止,您应该对 Spring 框架是如何处理事务,以及如何应用于其他 Spring 类库(例如 Spring Boot 或 Spring WebMVC)有了一个很好的了解。 最大的收获应是:最终使用哪种框架都无关紧要,这一切可以映射到 JDBC 的基础概念上。

正确理解它们(记住:getConnection(),setAutocommit(false),commit() ),在以后碰到复杂的企业级项目的使用,你能更容易抓住本质。

谢谢阅读。

鸣谢

感谢 Andreas Eisele 对本指南早期版本的反馈。 感谢 Ben Horsfield 提供了急需的 Javascirpt 代码来提升本指南的阅读体验。