深入探讨 Java Spring 框架事务注释


最近,我必须使用六边形架构模式 在 Java 中实现一个新的 CRUD 服务。六边形架构模式是一种强调系统中关注点分离和组件独立性的软件模式。遵循此模式的服务由以下部分组成:

  • 核心模块:这是应用程序的业务逻辑所在的位置。它包含系统的基本功能。
  • 端口:这些接口定义了核心模块如何与外部世界交互。它们代表应用程序的输入和输出点并隐藏其实现细节。
  • 适配器:这些是端口的实现。它们充当外部世界和核心模块之间的桥梁。


在这种架构模式中,系统的所有智能都在核心模块中定义,并且与外部系统的所有交互都是使用接口进行的。这些接口向核心模块隐藏了外部系统的详细信息。预计在端口级别定义的行为是非常基本的,而所有协调都是在核心模块级别完成的。在 CRUD 服务中,这意味着如果某个操作需要在数据库中进行多次更改,则所有这些协调都应在核心模块中完成。

通常,当系统中的单个操作需要在数据库中进行多次更改时,我们使用数据库事务 来确保数据一致性。数据库事务是关系数据库系统最重要的功能之一。它们提供了一种将一个或多个数据库语句分组为单个不可分割的工作单元的方法。通过这样做,他们可以保证所有语句要么成功执行,要么没有执行,从而防止部分更新并保持数据完整性。

一个问题浮出水面
将六边形架构和数据库事务的概念结合起来,我心中产生了一个问题:如果六边形架构模式规定所有的业务逻辑都应该在核心模块完成,而核心模块不知道与外部系统交互的实现细节如何在不影响关注点分离相关六边形架构主要思想的情况下保证数据一致性?

Spring 框架来救援
Spring 框架是一种广泛使用的开源框架,用于构建基于 Java 的应用程序,它提供了强大且灵活的事务管理系统。该框架提供了一个注释@Transactional,确保在用它注释的方法中执行的所有数据库操作都在单个数据库事务的上下文中执行。Spring 在方法开始之前自动启动事务,并在方法完成后提交或回滚事务,具体取决于方法的结果(成功或失败)。

在六边形架构的上下文中,在核心模块定义数据库事务是有意义的,因为该模块将负责聚合单个服务操作所需的所有数据库更改。但是,如果我们在核心模块定义此注释并且核心模块不知道如何在较低级别完成操作,那么这将如何工作?

这个挥之不去的疑问一直困扰着我,所以我决定深入研究这个 Spring 注解的实现细节,以充分理解它的行为。

Spring 注解@Transactional的幕后花絮
为了探索上面讨论的概念,我们将使用我创建的演示项目,该项目可以在GitHub上找到。在这个演示项目中,我们有一个 API 模块,允许我们创建帐户并将其与城市关联。

  • API 模块 -AccountsApi调用核心模块 -SpringAccountService来执行此操作,遵循六边形架构的原则,
  • 而六边形架构又调用适当的端口 - AccountRepositoryPort- 使用 JPA 存储库实现。
  • 此服务操作在数据库级别执行两项更改:一是创建帐户,二是将其与指定城市关联。

注释@Transactional用于保证帐户的创建及其与城市的关联之间的数据一致性。这是一个非常简单和虚拟的项目,只是为了让我们探索所需的概念。

当我们调用带有 @Transactional 注解的 SpringAccountService.createAccount() 方法时,奇迹就开始了:

  • 此时,Spring 不会直接调用 SpringAccountService 对象,而是会调用 Spring 创建的代理对象。
  • Spring 之所以能做到这一点,是因为它在创建 Bean 的过程中会检查是否有任何方面与给定的类相关联,如果有,它就会将真实对象封装在代理对象中。
  • 然后,它就可以为代理对象添加额外的行为,这些行为可以在真实对象方法调用之前或之后执行。
  • 在 @Transactional 的例子中,Spring 添加了额外的行为来处理事务管理。

Spring 有两种创建代理对象的策略:

  • JDK 动态代理:如果目标对象至少实现了一个接口,Spring 就会使用 JDK 动态代理。这些代理实现了与目标对象相同的接口,并拦截方法调用以应用相关方面。
  • CGLIB:如果目标对象没有实现任何接口,Spring 就会使用 CGLIB。CGLIB 会在运行时生成目标类的子类,该子类会覆盖方法以应用各方面。

步骤:

  1. SpringAccountService.createAccount()方法处, Spring 创建的一个代理对象
  2. 代理对象中调用TransactionInterceptor AOP拦截器类。该类负责创建一个数据库事务,所有数据库操作都将在该事务中执行。
  3. 这是在TransactionInterceptor 的invokeWithinTransaction()方法中完成的。在此方法中,我们接收回调作为参数,其中包含应在数据库事务中执行的代码,该代码指向真实SpringAccountService对象
  4. 在此方法中,Spring 创建一个数据库事务(如果尚未创建)并将事务信息添加到执行操作的线程的ThreadLocal实例中。
  5. 这将允许在此线程内执行的后续方法使用先前打开的事务。存储了有关交易的大量信息,但与我们的讨论更相关的是EntityManager。该事务将具有关联的 EntityManager,并且只要使用相同的 EntityManager 完成数据库操作,它们就会位于同一事务内
  6. 在此之后,真正的 SpringAccountService.createAccount() 会被调用,这反过来又会调用 AccountRepositoryPort,而 AccountRepositoryPort 是利用 Spring Data JPA 资源库实现的。
  7. 默认情况下,Spring Data JPA 资源库中的方法具有与之相关的事务性。这意味着数据库操作将在之前启动的事务中执行。
  8. JPA 资源库将访问 ThreadLocal 实例以获取事务信息,然后访问相应的 EntityManager。
  9. JPA 资源库所做的所有操作都将通过该实体管理器完成,以确保它们是在先前启动的事务上下文中完成的。这样JPA 资源库久能使用现有当前的事务。
  10. 在 SpringAccountService.createAccount() 方法结束时,当所有数据库更改都成功完成后,将使用相同的 EntityManager 提交事务,并清理 ThreadLocal 实例中的资源。

问题终于有了答案!
当使用 @Transactional 注解声明核心模块的方法时,一旦该方法被调用,数据库事务就会启动,并通过 ThreadLocal 实例将事务详细信息添加到执行该方法的线程中。

然后,Spring Data JPA 资源库将访问 ThreadLocal 实例中的事务信息,并在该事务范围内进行数据库更改。

这意味着核心模块不需要知道哪些适配器将被执行,只要它们是在启动事务的同一线程中执行即可。

当然,如果适配器的实现不是基于 Spring,它们就不会知道正在进行的事务,数据完整性也就得不到保证。

不过,只要我们使用 Spring 与外部数据库系统交互,这种保证就会一直有效。

我们可以在不影响服务层或其事务行为的情况下,轻松地将 JPA 存储库更换为不同的实现。

利用 AOP 方法,我们可以在不污染业务逻辑的情况下获得事务保证。Spring 真是一个充满魔力的世界:D