楼层: 首页/ 软件技术/ Spring 核心框架/ 小项目:银行转账系统
9

小项目:银行转账系统

Mini Project: Bank Transfer

把前面学的 IoC、AOP、事务、配置类串起来,写一个能跑的最小银行转账系统。重点看三件事:① 用构造器注入组装 Bean;② 用 @Transactional 保证转账原子性;③ 用 AOP 给所有转账方法打日志。这里用内存 Map 模拟数据库,方便你直接跑。

第一步:账户 DAO(内存版)

package com.example.bank; import org.springframework.stereotype.Repository; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Repository public class AccountDao { // 内存账户表:key = 账户 id,value = 余额 private final Map<Long, Double> balance = new ConcurrentHashMap<>(); public void createAccount(long id, double init) { balance.put(id, init); } // 扣款:余额不足直接抛异常,触发事务回滚 public void deduct(long id, double amount) { double left = balance.get(id); if (left < amount) { throw new IllegalStateException("余额不足,账户#" + id); } balance.put(id, left - amount); } public void credit(long id, double amount) { balance.merge(id, amount, Double::sum); } public double balanceOf(long id) { return balance.getOrDefault(id, 0.0); } }

第二步:转账服务(事务核心)

package com.example.bank; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class TransferService { private final AccountDao accountDao; // 构造器注入 public TransferService(AccountDao accountDao) { this.accountDao = accountDao; } // 整个 transfer 方法在一个事务里 // 任意一步失败,前面已经做的扣款会被回滚 @Transactional(rollbackFor = Exception.class) public void transfer(long from, long to, double amount) { System.out.printf(" 尝试转账:#%d -> #%d,金额 %.2f%n", from, to, amount); accountDao.deduct(from, amount); // 第一步:扣款 accountDao.credit(to, amount); // 第二步:入账 System.out.printf(" 转账成功:#%d 余额=%.2f,#%d 余额=%.2f%n", from, accountDao.balanceOf(from), to, accountDao.balanceOf(to)); } }

第三步:配置类 + 启动

package com.example.bank; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.transaction.annotation.EnableTransactionManagement; @Configuration @ComponentScan("com.example.bank") @EnableTransactionManagement // 打开声明式事务开关 public class BankApp { public static void main(String[] args) { var ctx = new AnnotationConfigApplicationContext(BankApp.class); AccountDao dao = ctx.getBean(AccountDao.class); TransferService svc = ctx.getBean(TransferService.class); // 开户:A 有 1000,B 有 100 dao.createAccount(1, 1000); dao.createAccount(2, 100); // 正常转账:A 给 B 转 200 System.out.println("--- 第一笔:正常转账 200 ---"); svc.transfer(1, 2, 200); // 异常转账:A 给 B 转 9999,余额不足 → 事务回滚 System.out.println("--- 第二笔:余额不足,应该回滚 ---"); try { svc.transfer(1, 2, 9999); } catch (Exception e) { System.out.println(" 转账失败:" + e.getMessage()); } System.out.printf("最终余额:#1=%.2f,#2=%.2f(第二笔失败后应该没变)%n", dao.balanceOf(1), dao.balanceOf(2)); ctx.close(); } }
运行结果: --- 第一笔:正常转账 200 --- 尝试转账:#1 -> #2,金额 200.00 转账成功:#1 余额=800.00,#2 余额=300.00 --- 第二笔:余额不足,应该回滚 --- 尝试转账:#1 -> #2,金额 9999.00 转账失败:余额不足,账户#1 最终余额:#1=800.00,#2=300.00(第二笔失败后应该没变)

把这个项目再往前推一步

上面的例子用内存 Map 模拟数据库,跑通了 IoC、AOP、事务三件套。真实项目里你可以把 AccountDao 换成 JdbcTemplate 或 Spring Data JPA,业务代码一行都不用改——这就是面向接口编程的好处。下面是几个可以自己加的扩展点。

扩展点怎么改
换成真数据库引入 spring-jdbc 和 HikariCP,AccountDao 实现里改用 JdbcTemplate 执行 SQL。Service 和 Controller 不动。
加 AOP 审计写一个 TransferAspect,@Around 拦截 transfer 方法,记录谁在什么时候转了多少钱。
加事件通知转账成功后 publishEvent(new TransferEvent(from, to, amount)),邮件监听者发通知。
加并发控制高并发下同一账户被多个线程同时改余额,要加悲观锁(SELECT ... FOR UPDATE)或乐观锁(@Version)。
加单元测试@SpringJUnitConfig 启动容器,注入 TransferService,写三个用例:正常转账、余额不足、大额转账。

用 JdbcTemplate 重写 AccountDao(生产写法)

@Repository public class JdbcAccountDao implements AccountDao { private final JdbcTemplate jdbc; public JdbcAccountDao(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Override public void deduct(long id, double amount) { // 乐观锁:余额必须大于等于金额才扣成功,否则影响行数为 0 int updated = jdbc.update( "UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ?", amount, id, amount); if (updated == 0) { throw new IllegalStateException("余额不足,账户#" + id); } } @Override public void credit(long id, double amount) { jdbc.update("UPDATE account SET balance = balance + ? WHERE id = ?", amount, id); } @Override public double balanceOf(long id) { return jdbc.queryForObject( "SELECT balance FROM account WHERE id = ?", Double.class, id); } }
注意:Service 代码一行没改,只是换了 DAO 的实现。 这就是依赖注入的价值——面向接口编程,实现可替换。

扩展完整项目:用户管理系统(XML 配置 vs 注解配置对比)

前面都是片段,这里给一个完整的三层结构项目:Dao → Service → Controller。同一个业务,分别用 XML 和注解两种方式装配,对比着看你就明白——注解不是魔法,它只是把 XML 里的 <bean> 标签换成了代码里的注解。

业务模型:User + 三层接口(先写接口,面向接口编程)

// User.java:普通 POJO public class User { private long id; private String name; public User() {} public User(long id, String name) { this.id = id; this.name = name; } // getter / setter 省略 } // Dao 接口 public interface UserDao { User findById(long id); void save(User u); } // Service 接口 public interface UserService { User findUser(long id); void register(User u); }

实现类:Dao 用内存 Map 模拟,Service 调 Dao

// UserDaoImpl:内存版 Dao public class UserDaoImpl implements UserDao { private final Map<Long, User> db = new HashMap<>(); public User findById(long id) { return db.get(id); } public void save(User u) { db.put(u.getId(), u); } } // UserServiceImpl:依赖 UserDao(构造器注入) public class UserServiceImpl implements UserService { private final UserDao userDao; public UserServiceImpl(UserDao userDao) { this.userDao = userDao; } public User findUser(long id) { return userDao.findById(id); } public void register(User u) { if (u.getName() == null || u.getName().isBlank()) throw new IllegalArgumentException("用户名不能为空"); userDao.save(u); } }

方式 A:用 XML 把它们装配起来(老项目写法)

<!-- applicationContext.xml --> <beans xmlns="http://www.springframework.org/schema/beans"> <!-- ① 注册 Dao,id=userDao --> <bean id="userDao" class="com.example.dao.UserDaoImpl"/> <!-- ② 注册 Service,构造器引用上面的 userDao --> <bean id="userService" class="com.example.service.UserServiceImpl"> <constructor-arg ref="userDao"/> </bean> </beans>

方式 B:用注解 + Java 配置类装配(新项目写法,推荐)

// ① 实现类上贴注解,Spring 扫描后自动注册成 Bean @Repository public class UserDaoImpl implements UserDao { ... } @Service public class UserServiceImpl implements UserService { private final UserDao userDao; // 单构造器,@Autowired 可省略;Spring 自动把 UserDao 注入进来 public UserServiceImpl(UserDao userDao) { this.userDao = userDao; } // ... } // ② 一个配置类替代整个 XML @Configuration @ComponentScan("com.example") public class AppConfig { }

Controller 层:模拟一个"控制器"调用 Service

@Controller public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } // 模拟 HTTP 请求:GET /user?id=1 public String getUser(long id) { User u = userService.findUser(id); return u == null ? "404 用户不存在" : u.getName(); } } // 启动验证 public class Main { public static void main(String[] args) { var ctx = new AnnotationConfigApplicationContext(AppConfig.class); UserController c = ctx.getBean(UserController.class); // 先注册一个用户 ctx.getBean(UserService.class).register(new User(1, "李四")); System.out.println(c.getUser(1)); System.out.println(c.getUser(99)); } }
李四 404 用户不存在 # 对比小结:XML 里 <bean id="userDao" .../> ≈ 注解里 @Repository # XML 里 <constructor-arg ref="userDao"/> ≈ 注解里构造器参数自动注入 # 两种方式最终都在容器里得到同一个 UserController,装配效果一模一样。

注意最后一行——第二笔转账虽然先调用了 deduct(1, 9999),但因为余额不足抛了异常,事务把已经扣的钱退回去了,最终余额停留在第一笔成功后的状态。这就是 @Transactional 的威力。

本章面试题 · 第 9 章(综合项目)

1.(架构题)为什么 Dao、Service、Controller 三层都要先写接口、再写实现?直接写实现类不行吗?

查看答案

答案:面向接口编程是为了解耦和可替换。Service 依赖 UserDao 接口,将来换数据库(内存→MySQL→Mongo)只要换实现类,Service 一行不用改。测试时也能塞一个 Mock 实现。解析:这正是 IoC 的价值——依赖抽象,不依赖具体。

2.(对比题)XML 配置和注解配置,Spring 更推荐哪种?老项目为什么还在用 XML?

查看答案

答案:新项目推荐 Java 配置类(@Configuration + @Bean)+ 注解,类型安全、重构友好。老项目用 XML 是历史包袱——Spring 1.x/2.x 年代还没有注解,几十年的存量代码改不动。解析:Spring Boot 之后基本没人新写 XML 了。

3.(实践题)UserServiceImpl 里想再调一个 @Transactional 的方法 this.register(),为什么事务不生效?

查看答案

答案:this 调用绕过代理。容器里的 UserService 是 AOP 代理对象,但 this 指向原始对象。解析:把 register 挪到另一个 Service,或自注入后用 self.register() 调用。又是那个"同类内部调用"的老坑。