Post

Spring Transaction Self Call

Spring Transaction Self Call

Spring @Transactional 自我呼叫筆記

一開始為什麼會這樣寫

一開始這樣寫,其實很自然,原因通常有兩個:

  1. 想提供比較短、比較好叫的 method
  2. 想把完整版參數集中在同一支 method,其他版本只是轉呼叫

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class RecordApprovalService {

	@Transactional
	public int bulkAgree(
			String personSn,
			String year,
			String unit,
			String status,
			String actingUnitSn,
			String actingDegree,
			boolean agree
	) {
		// 真正的批次簽核邏輯
		return 1;
	}

	public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
		return bulkAgree(personSn, year, unit, status, null, null, agree);
	}
}

或是查詢 service 內部直接呼叫自己另一支 public method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class RecordWorkspaceQueryService {

	@Transactional(readOnly = true)
	public List<TsAdminSignRow> getRecordsForAdmin(String personSn, String uyear) {
		return repository.getRecordsForAdmin(personSn, uyear);
	}

	@Transactional(readOnly = true)
	public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
		return getRecordsForAdmin(personSn, uyear).stream()
				.map(TsAdminSignRow::getUnitn)
				.toList();
	}
}

從 Java 語法來看,這些寫法都完全合法,也很常見;問題是 @Transactional 不是只看 Java 語法,而是要看 Spring 在執行時怎麼代理這個 bean。

為什麼會被報錯

Spring 的 @Transactional 一般是靠 proxy 生效。

也就是說,只有「從 bean 外面,經過 Spring proxy 呼叫進來」時,transaction advice 才會套上去。

正常會生效的情況

1
recordApprovalService.bulkAgree(...);

這種是別的 bean 呼叫 RecordApprovalService,會經過 Spring proxy,所以 @Transactional 會生效。

不會重新套用 transaction advice 的情況

1
2
3
public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
	return bulkAgree(personSn, year, unit, status, null, null, agree);
}

這裡是 同一個物件內部 直接呼叫另一支 method,本質上等於:

1
this.bulkAgree(...);

這不會經過 Spring proxy,所以:

  • 不會重新進入 transaction advice
  • 內層 method 上的 @Transactional 不會在這次呼叫中額外發揮作用

IDEA 的警告不是說程式一定壞掉,而是提醒:

你以為這裡會套用 @Transactional,但實際上不會。

為什麼這件事麻煩

如果只是單純 overload 轉呼叫,程式有時候表面上還是能跑,但會有幾個問題:

  1. 可讀性會誤導
    看到 method 上有 @Transactional,很容易以為每次呼叫都一定會經過 transaction proxy。

  2. 重構時容易失真
    一開始也許沒差,但之後若有人調整 propagation、readOnly 或 rollback 規則,就可能以為有生效,其實沒有。

  3. IDEA 會一直警告
    這不是單純「編輯器太囉唆」,而是真的在提醒設計有模糊地帶。

這次專案裡實際踩到的情況

當時拆完 service 後,雖然 RecordService 已經不見了,但以下這類寫法還在:

寫入 service 的簡化 overload

1
2
3
4
5
6
7
public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
	return bulkAgree(personSn, year, unit, status, null, null, agree);
}

public AdminRecordView updateRecordDecision(String personSn, String uyear, Long tsSn, boolean agree) {
	return updateRecordDecision(personSn, uyear, tsSn, null, null, agree);
}

查詢 service 的簡化 overload

1
2
3
4
5
6
7
public DashboardSummary getDashboardSummary(String personSn, String uyear) {
	return getDashboardSummary(personSn, uyear, null, null);
}

public List<String> getSignableUnits(String personSn, String uyear) {
	return getSignableUnits(personSn, uyear, null, null);
}

查詢 service 內部再呼叫自己帶 transaction 的 method

1
2
3
4
5
6
7
@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
	List<TsAdminSignRow> actionableRecords = getRecordsForAdmin(personSn, uyear).stream()
			.filter(...)
			.toList();
	return dashboardQueryService.resolveActionableUnitNames(actionableRecords);
}

這些都會被 IDEA 視為 self-invocation 風險。

如何解決

這次採用的原則很直接:

  1. 不保留會在同 class 內轉呼叫 @Transactional method 的簡化 overload
  2. 呼叫端直接傳完整參數
  3. 同一個 service 內若只是要重用查詢,直接呼叫 repository 或抽 private helper,不呼叫自己標註 @Transactional 的 public method

調整前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class RecordApprovalService {

	@Transactional
	public int bulkAgree(
			String personSn,
			String year,
			String unit,
			String status,
			String actingUnitSn,
			String actingDegree,
			boolean agree
	) {
		// ...
		return 1;
	}

	public int bulkAgree(String personSn, String year, String unit, String status, boolean agree) {
		return bulkAgree(personSn, year, unit, status, null, null, agree);
	}
}

調整後

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class RecordApprovalService {

	@Transactional
	public int bulkAgree(
			String personSn,
			String year,
			String unit,
			String status,
			String actingUnitSn,
			String actingDegree,
			boolean agree
	) {
		// ...
		return 1;
	}
}

呼叫端改成明確傳完整參數:

1
2
3
4
5
6
7
8
9
int updated = recordApprovalService.bulkAgree(
		personSn,
		year,
		unit,
		status,
		actingUnitSn,
		actingDegree,
		body.getAgree()
);

查詢 service 的調整前

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional(readOnly = true)
public List<TsAdminSignRow> getRecordsForAdmin(String personSn, String uyear) {
	return repository.getRecordsForAdmin(personSn, uyear);
}

@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
	return getRecordsForAdmin(personSn, uyear).stream()
			.filter(...)
			.map(TsAdminSignRow::getUnitn)
			.toList();
}

查詢 service 的調整後

1
2
3
4
5
6
7
@Transactional(readOnly = true)
public List<String> getSignableUnits(String personSn, String uyear, String actingUnitSn, String actingDegree) {
	return repository.getRecordsForAdmin(personSn, uyear).stream()
			.filter(...)
			.map(TsAdminSignRow::getUnitn)
			.toList();
}

這樣就沒有同 class 內 public @Transactional method 互相呼叫的問題。

為什麼這種解法比較乾淨

這種解法的好處是:

  • @Transactional 只標在真正的邏輯入口
  • 呼叫關係一眼就看得懂
  • 不需要為了「少打幾個 null」保留會誤導人的 method
  • IDEA 警告會跟著消失,而不是只是在壓警告

更重要的是,這不是在迎合工具,而是在把 transaction 邊界講清楚。

如果真的想保留簡化呼叫方式,怎麼辦

如果真的很想保留簡化 API,也不是完全不行,但要注意:

  1. 不要讓簡化 method 再去呼叫同 class 上帶 @Transactional 的 public method
  2. 可以把真正邏輯抽成 private method,由唯一一支 public @Transactional method 進入
  3. 或者乾脆把不同責任拆到不同 bean,讓呼叫跨 bean 發生

不過這次在 TsNoticeApproval 的情境下,最單純的做法仍然是:

  • 不保留這些簡化 overload
  • 呼叫端直接傳完整參數

因為這個系統本來就已經有 actingUnitSnactingDegree 這種工作台身分語意,硬做簡化版,反而容易把語意藏起來。

這次得到的結論

這次不是「拆了 service 就自然不會有 transaction 問題」,而是:

拆完 service 後,還要把同 class 內的 transaction 自我呼叫一起清掉,設計才算真的完成。

實務上可以記成這一句:

@Transactional 要標在真正對外的進入點;同一個 bean 內不要再靠 public method 彼此轉呼叫。

只要看到這兩種寫法,就要提高警覺:

1
return this.someTransactionalMethod(...);

1
return someTransactionalMethod(...);

如果那支 method 是同一個 class 的 public @Transactional method,就很可能又會被 IDEA 警告,而且那個警告通常是合理的。

This post is licensed under CC BY 4.0 by the author.