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() 调用。又是那个"同类内部调用"的老坑。