使用 Spring 管理事务

本指南介绍如何以非侵入式事务包裹数据库操作。

你将构建什么

你将构建一个简单的 JDBC 应用,不必编写专门的 JDBC 事务代码,就能让数据库操作具备事务性。

所需条件

如何完成本指南

与大多数 Spring 入门指南一样,你可以从零开始逐步完成,也可以跳过已经熟悉的基础设置。不论哪种方式,最终都会得到可以工作的代码。

如果从零开始,请继续阅读“从 Spring Initializr 开始”。

如果要跳过基础设置,请执行以下步骤:

完成后,可以与 gs-managing-transactions/complete 中的代码比较。

从 Spring Initializr 开始

你可以使用预先初始化的项目,点击 Generate 下载 ZIP 文件。这个项目已经配置为适合本教程示例的形式。

如果手动初始化项目:

  • 访问 https://start.spring.io。该服务会引入应用所需的全部依赖,并完成大部分设置。
  • 选择 Gradle 或 Maven,以及希望使用的语言。
  • 在 Artifact 字段中输入 managing-transactions。
  • 点击 Dependencies,选择 Spring Data JDBC 与 H2 Database。
  • 点击 Generate。
  • 下载生成的 ZIP 文件,其中包含根据所选选项配置的应用项目。

如果 IDE 集成了 Spring Initializr,可以直接在 IDE 中完成这一过程。

也可以在 GitHub 上 fork 项目,再用 IDE 或其他编辑器打开。

创建预约服务

首先使用 BookingService 类创建一个基于 JDBC 的服务,按姓名把人预约到系统中。以下分别保留 Java 与 Kotlin 实现。

Java

package com.example.managingtransactions;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class BookingService {

	private final static Logger logger = LoggerFactory.getLogger(BookingService.class);

	private final JdbcTemplate jdbcTemplate;

	public BookingService(JdbcTemplate jdbcTemplate) {
		this.jdbcTemplate = jdbcTemplate;
	}

	@Transactional
	public void book(String... persons) {
		for (String person : persons) {
			logger.info("Booking {} in a seat...", person);
			jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", person);
		}
	}

	public List<String> findAllBookings() {
		return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
				(rs, rowNum) -> rs.getString("FIRST_NAME"));
	}

}

Kotlin

package com.example.managingtransactions

import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

private val logger = LoggerFactory.getLogger(BookingService::class.java)

@Component
class BookingService(private val jdbcTemplate: JdbcTemplate) {

    @Transactional
    fun book(vararg persons: String?) {
        persons.forEach { person ->
            logger.info("Booking {} in a seat...", person)
            jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", person)
        }
    }

    fun findAllBookings(): List<String> =
        jdbcTemplate.query("select FIRST_NAME from BOOKINGS") { rs, _ ->
            rs.getString("FIRST_NAME")
        }
}

代码自动注入 JdbcTemplate。这个方便的模板类完成后续代码所需的全部数据库交互。

book 方法可以同时预约多人。它遍历人员列表,使用 JdbcTemplate 将每个人插入 BOOKINGS 表。方法标注了 @Transactional;在本例的数据库写入失败场景中,整个操作会回滚到之前状态,并重新抛出原异常。也就是说,只要其中一人添加失败,这次调用中的任何人都不会添加到 BOOKINGS。

findAllBookings 方法负责查询数据库,把每一行转换为 String,再将全部行组合为 List。

构建应用

Spring Initializr 提供应用类。本例无需修改它。以下分别保留 Java 与 Kotlin 应用类。

Java

package com.example.managingtransactions;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ManagingTransactionsApplication {

	public static void main(String[] args) {
		SpringApplication.run(ManagingTransactionsApplication.class, args);
	}

}

Kotlin

package com.example.managingtransactions

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class ManagingTransactionsApplication

fun main(args: Array<String>) {
    runApplication<ManagingTransactionsApplication>(*args)
}

@SpringBootApplication 是一个便利注解,会同时加入以下能力:

  • @Configuration:把该类标记为应用上下文的 bean 定义来源。
  • @EnableAutoConfiguration:告诉 Spring Boot 根据类路径、其他 bean 和属性设置添加 bean。例如,类路径包含 spring-webmvc 时,它会将应用标记为 Web 应用,并启用设置 DispatcherServlet 等关键行为。
  • @ComponentScan:让 Spring 在 com/example 包中寻找其他组件、配置和服务,从而发现控制器。

main() 使用 Spring Boot 的 SpringApplication.run() 启动应用。这里没有一行 XML,也没有 web.xml 文件。这个应用完全使用 Java,无需处理底层设施配置。

应用实际上不需要额外配置。Spring Boot 在类路径检测到 spring-jdbc 与 h2 后,会自动创建 DataSource 和 JdbcTemplate。基础设施具备而没有专门配置时,还会创建 DataSourceTransactionManager。它负责拦截带 @Transactional 的方法,例如 BookingService 的 book 方法。BookingService 则通过类路径扫描发现。

本指南还展示了 Spring Boot 启动时初始化数据库模式的能力。以下文件位于 src/main/resources/schema.sql,用于定义数据库模式:

drop table BOOKINGS if exists;
create table BOOKINGS(ID serial, FIRST_NAME varchar(5) NOT NULL);

还有一个 CommandLineRunner,它注入 BookingService,展示不同事务使用场景。以下分别保留 Java 与 Kotlin 实现:

Java

package com.example.managingtransactions;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;

@Component
class AppRunner implements CommandLineRunner {

	private final static Logger logger = LoggerFactory.getLogger(AppRunner.class);

	private final BookingService bookingService;

	public AppRunner(BookingService bookingService) {
		this.bookingService = bookingService;
	}

	@Override
	public void run(String... args) throws Exception {
		bookingService.book("Alice", "Bob", "Carol");
		Assert.isTrue(bookingService.findAllBookings().size() == 3,
				"First booking should work with no problem");
		logger.info("Alice, Bob and Carol have been booked");

		try {
			bookingService.book("Chris", "Samuel");
		} catch (RuntimeException e) {
			logger.info("v--- The following exception is expect because 'Samuel' is too " +
					"big for the DB ---v");
			logger.error(e.getMessage());
		}

		logCurrentBookings();
		logger.info("You shouldn't see Chris or Samuel. Samuel violated DB constraints, " +
				"and Chris was rolled back in the same TX");
		Assert.isTrue(bookingService.findAllBookings().size() == 3,
				"'Samuel' should have triggered a rollback");

		try {
			bookingService.book("Buddy", null);
		} catch (RuntimeException e) {
			logger.info("v--- The following exception is expect because null is not " +
					"valid for the DB ---v");
			logger.error(e.getMessage());
		}

		logCurrentBookings();
		logger.info("You shouldn't see Buddy or null. null violated DB constraints, and " +
				"Buddy was rolled back in the same TX");
		Assert.isTrue(bookingService.findAllBookings().size() == 3,
				"'null' should have triggered a rollback");
	}

	private void logCurrentBookings() {
		for (String person : bookingService.findAllBookings()) {
            logger.info("So far, {} is booked.", person);
		}
	}
}

Kotlin

package com.example.managingtransactions

import org.slf4j.LoggerFactory
import org.springframework.boot.CommandLineRunner
import org.springframework.stereotype.Component
import org.springframework.util.Assert

private val logger = LoggerFactory.getLogger(AppRunner::class.java)

@Component
class AppRunner(private val bookingService: BookingService) : CommandLineRunner {

    override fun run(vararg args: String) {
        bookingService.book("Alice", "Bob", "Carol")
        Assert.isTrue(bookingService.findAllBookings().size == 3,
            "First booking should work with no problem")
        logger.info("Alice, Bob and Carol have been booked")

        try {
            bookingService.book("Chris", "Samuel")
        } catch (e: RuntimeException) {
            logger.info("v--- The following exception is expect because 'Samuel' is too big for the DB ---v")
            logger.error(e.message)
        }

        logCurrentBookings()
        logger.info("You shouldn't see Chris or Samuel. Samuel violated DB constraints, and Chris was rolled back in the same TX")
        Assert.isTrue(bookingService.findAllBookings().size == 3,
            "'Samuel' should have triggered a rollback")

        try {
            bookingService.book("Buddy", null)
        } catch (e: RuntimeException) {
            logger.info("v--- The following exception is expect because null is not valid for the DB ---v")
            logger.error(e.message)
        }

        logCurrentBookings()
        logger.info("You shouldn't see Buddy or null. null violated DB constraints, and Buddy was rolled back in the same TX")
        Assert.isTrue(bookingService.findAllBookings().size == 3,
            "'null' should have triggered a rollback")
    }

    private fun logCurrentBookings() {
        bookingService.findAllBookings().forEach { person ->
            logger.info("So far, {} is booked.", person)
        }
    }
}

可以通过 Gradle 或 Maven 从命令行运行应用,也可以构建一个包含所有依赖、类和资源的可执行 JAR 再运行。可执行 JAR 便于在整个开发生命周期中跨环境交付、版本管理和部署服务。

使用 Gradle 时,运行 ./gradlew bootRun 即可启动。也可以通过 ./gradlew build 构建 JAR,再按下方命令运行:

java -jar build/libs/gs-managing-transactions-0.0.1-SNAPSHOT.jar

使用 Maven 时,运行 ./mvnw spring-boot:run 即可启动。也可以通过 ./mvnw clean package 构建 JAR,再按下方命令运行:

java -jar target/gs-managing-transactions-0.0.1-SNAPSHOT.jar

JAR 文件名:上面命令中的文件名对应本指南源码仓库。若使用 Spring Initializr 生成项目,应按项目的 Artifact 和版本号,使用构建目录内实际生成的 JAR 文件名。

原文给出了以下示例输出:

2019-09-19 14:05:25.111  INFO 51911 --- [           main] c.e.m.ManagingTransactionsApplication    : Starting ManagingTransactionsApplication on Jays-MBP with PID 51911 (/Users/j/projects/guides/gs-managing-transactions/complete/target/classes started by j in /Users/j/projects/guides/gs-managing-transactions/complete)
2019-09-19 14:05:25.114  INFO 51911 --- [           main] c.e.m.ManagingTransactionsApplication    : No active profile set, falling back to default profiles: default
2019-09-19 14:05:25.421  INFO 51911 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data repositories in DEFAULT mode.
2019-09-19 14:05:25.438  INFO 51911 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 13ms. Found 0 repository interfaces.
2019-09-19 14:05:25.678  INFO 51911 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2019-09-19 14:05:25.833  INFO 51911 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2019-09-19 14:05:26.158  INFO 51911 --- [           main] c.e.m.ManagingTransactionsApplication    : Started ManagingTransactionsApplication in 1.303 seconds (JVM running for 3.544)
2019-09-19 14:05:26.170  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking Alice in a seat...
2019-09-19 14:05:26.181  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking Bob in a seat...
2019-09-19 14:05:26.181  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking Carol in a seat...
2019-09-19 14:05:26.195  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : Alice, Bob and Carol have been booked
2019-09-19 14:05:26.196  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking Chris in a seat...
2019-09-19 14:05:26.196  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking Samuel in a seat...
2019-09-19 14:05:26.271  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : v--- The following exception is expect because 'Samuel' is too big for the DB ---v
2019-09-19 14:05:26.271 ERROR 51911 --- [           main] c.e.managingtransactions.AppRunner       : PreparedStatementCallback; SQL [insert into BOOKINGS(FIRST_NAME) values (?)]; Value too long for column """FIRST_NAME"" VARCHAR(5) NOT NULL": "'Samuel' (6)"; SQL statement:
insert into BOOKINGS(FIRST_NAME) values (?) [22001-199]; nested exception is org.h2.jdbc.JdbcSQLDataException: Value too long for column """FIRST_NAME"" VARCHAR(5) NOT NULL": "'Samuel' (6)"; SQL statement:
insert into BOOKINGS(FIRST_NAME) values (?) [22001-199]
2019-09-19 14:05:26.271  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : So far, Alice is booked.
2019-09-19 14:05:26.271  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : So far, Bob is booked.
2019-09-19 14:05:26.271  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : So far, Carol is booked.
2019-09-19 14:05:26.271  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : You shouldn't see Chris or Samuel. Samuel violated DB constraints, and Chris was rolled back in the same TX
2019-09-19 14:05:26.272  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking Buddy in a seat...
2019-09-19 14:05:26.272  INFO 51911 --- [           main] c.e.managingtransactions.BookingService  : Booking null in a seat...
2019-09-19 14:05:26.273  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : v--- The following exception is expect because null is not valid for the DB ---v
2019-09-19 14:05:26.273 ERROR 51911 --- [           main] c.e.managingtransactions.AppRunner       : PreparedStatementCallback; SQL [insert into BOOKINGS(FIRST_NAME) values (?)]; NULL not allowed for column "FIRST_NAME"; SQL statement:
insert into BOOKINGS(FIRST_NAME) values (?) [23502-199]; nested exception is org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: NULL not allowed for column "FIRST_NAME"; SQL statement:
insert into BOOKINGS(FIRST_NAME) values (?) [23502-199]
2019-09-19 14:05:26.273  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : So far, Alice is booked.
2019-09-19 14:05:26.273  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : So far, Bob is booked.
2019-09-19 14:05:26.273  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : So far, Carol is booked.
2019-09-19 14:05:26.273  INFO 51911 --- [           main] c.e.managingtransactions.AppRunner       : You shouldn't see Buddy or null. null violated DB constraints, and Buddy was rolled back in the same TX

BOOKINGS 表的 first_name 列有两个约束:

  • 姓名不能超过五个字符。
  • 姓名不能为 null。

首先插入 Alice、Bob 和 Carol。应用断言表中新增了三人。如果这一步失败,应用就会提前退出。

接下来预约 Chris 和 Samuel。Samuel 的姓名被故意设置得过长,触发插入错误。事务行为要求 Chris 和 Samuel,也就是本次事务中的所有值,一起回滚。因此表中仍然只有三人,断言验证了这一点。

最后预约 Buddy 和 null。示例输出表明,null 同样触发回滚,预约列表仍然是原来的三人。

小结

你已经使用 Spring 构建了一个由非侵入式事务包裹的简单 JDBC 应用。

另请参阅

以下指南也可能有所帮助:

如果想编写新指南或为已有指南贡献内容,请查看贡献指南。

所有指南的代码采用 ASLv2(Apache License 2.0)许可证;文字采用知识共享署名—禁止演绎 3.0(CC BY-ND 3.0)许可证。


原文:Spring — Managing Transactions,Spring 团队。中文译文。代码与原文字的许可分别见上方;示例日志来自 Spring 原文。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容