diff --git a/README.md b/README.md index 99b34a2..e0078e5 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,21 @@ Euonia is also available for **[.NET](https://github.com/NerosoftDev/Euonia)** graph TD subgraph "Euonia Java" direction TB - Domain --> Core + DDD --> Core + DDD --> UoW OSBA --> Core Pipeline --> Core Spring --> Core - Sample --> Domain + Spring --> UoW + Sample --> DDD Sample --> OSBA Sample --> Pipeline Sample --> Spring end style Core fill:#4A90D9,color:#fff - style Domain fill:#50B86C,color:#fff + style DDD fill:#50B86C,color:#fff + style UoW fill:#1F6FEB,color:#fff style OSBA fill:#E8833A,color:#fff style Pipeline fill:#E74C3C,color:#fff style Spring fill:#2ECC71,color:#fff @@ -44,7 +47,7 @@ graph TD | `com.euonia.annotation` | `@Required`, `@Validator`, `@Validation` — metadata for field validation | | `com.euonia.reflection` | `TypeHelper`, `GenericType`, `@DisplayName` | -### Domain (`euonia-domain`) +### DDD (`euonia-domain-driven-design`) > Domain-Driven Design abstractions: entities, aggregates, value objects, domain events, and auditing support. | Class | Purpose | @@ -57,6 +60,18 @@ graph TD | `EventAggregate` | Event metadata wrapper: id, eventId, typeName, originator, timestamp, sequence | | `@Audited` / `AuditRecord` / `AuditStore` | Change auditing support for domain entities | +### UoW (`euonia-unit-of-work`) +> Unit of Work abstraction for transaction boundaries, commit/rollback lifecycle, and consistent persistence orchestration. + +| Class / Interface | Purpose | +|-------------------|---------| +| `IUnitOfWork` | Unit-of-work contract with lifecycle methods (`saveChanges`, `commit`, `rollback`) | +| `IUnitOfWorkManager` | Creates/manages current unit-of-work scope | +| `UnitOfWork` | Default unit-of-work implementation | +| `UnitOfWorkBase` | Base class for shared transaction flow | +| `UnitOfWorkInterceptor` | Intercepts application flow to attach UoW boundaries | +| `IUnitOfWorkAccessor` | Access current active unit-of-work context | + ### Pipeline (`euonia-pipeline`) > Middleware pipeline framework inspired by ASP.NET Core pipeline pattern — chainable request/response processing with behaviors, delegates, and dependency injection integration. @@ -210,7 +225,7 @@ The `sample` module demonstrates **Euonia framework integration with Spring Boot com.euonia - domain + domain-driven-design 1.0.0 ``` diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..b400f46 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,294 @@ +# Euonia(Java) + +> *Eunoia* —— 源自希腊语 *εὔνοια*:美好的思维、善意、心态平和。 + +Euonia 是一个用于构建企业级 Java 应用的开发框架。它将**面向对象可扩展业务架构(OSBA)**与**领域驱动设计(DDD)**理念结合起来,为构建健壮、可维护的业务系统提供完整基础设施。该框架基于 **Java 17+**,并可与 **Spring Boot** 无缝集成。 + +Euonia 同时提供 **[.NET 版本](https://github.com/NerosoftDev/Euonia)**,本仓库为 **Java 版本**。 + +--- + +## 模块 + +```mermaid +graph TD + subgraph "Euonia Java" + direction TB + DDD --> Core + DDD --> UoW + OSBA --> Core + Pipeline --> Core + Spring --> Core + Spring --> UoW + Sample --> DDD + Sample --> OSBA + Sample --> Pipeline + Sample --> Spring + end + + style Core fill:#4A90D9,color:#fff + style DDD fill:#50B86C,color:#fff + style UoW fill:#1F6FEB,color:#fff + style OSBA fill:#E8833A,color:#fff + style Pipeline fill:#E74C3C,color:#fff + style Spring fill:#2ECC71,color:#fff + style Sample fill:#9B59B6,color:#fff +``` + +### Core(euonia-core) +> 基础核心库:提供基类、ID 生成、反射工具、元组、HTTP 异常、安全能力与验证注解。 + +| 包 | 说明 | +|---------|-------------| +| `com.euonia.core` | 统一 `ObjectId`(支持 Snowflake、UUID、ULID、Random)、`SnowflakeId`、`ULID`、`ShortUniqueId`、`Singleton`、`PriorityQueue`、`Pair` | +| `com.euonia.tuple` | 不可变强类型元组:`Solo`、`Duet`、`Trio`、`Quartet`、`Quintet`、`Sextet`、`Septet`、`Octet`、`Nonet`、`Decet` | +| `com.euonia.http` | HTTP 状态异常:`BadRequestException`(400)、`UnauthorizedAccessException`(401)、`ForbiddenException`(403)、`ResourceNotFoundException`(404)、`ConflictException`(409)等 | +| `com.euonia.security` | `UserPrincipal`、`UserClaimTypes`、`AuthenticationException`、`CredentialException`、`UnauthorizedAccessException` | +| `com.euonia.annotation` | `@Required`、`@Validator`、`@Validation` —— 字段校验元数据 | +| `com.euonia.reflection` | `TypeHelper`、`GenericType`、`@DisplayName` | + +### DDD(euonia-domain-driven-design) +> 领域驱动设计抽象:实体、聚合、值对象、领域事件与审计支持。 + +| 类 | 作用 | +|-------|---------| +| `Entity` / `EntityBase` | 领域实体的接口与抽象基类(含标识) | +| `Aggregate` / `AggregateBase` | 聚合根与领域事件管理(`raiseEvent`、`clearEvents`、`attachEvents`) | +| `ValueObject` | 不可变值对象,基于反射实现 `equals`、`hashCode`、`compareTo` | +| `DomainEvent` / `DomainEventBase` | 领域事件契约,支持聚合挂载与事件元数据 | +| `ApplicationEvent` / `ApplicationEventBase` | 应用层事件基类 | +| `EventAggregate` | 事件元数据封装:id、eventId、typeName、originator、timestamp、sequence | +| `@Audited` / `AuditRecord` / `AuditStore` | 领域对象变更审计支持 | + +### UoW(euonia-unit-of-work) +> Unit of Work 抽象:定义事务边界、提交/回滚生命周期与一致性的持久化编排。 + +| 类 / 接口 | 作用 | +|-------------------|---------| +| `IUnitOfWork` | UoW 契约,包含生命周期方法(`saveChanges`、`commit`、`rollback`) | +| `IUnitOfWorkManager` | 创建并管理当前 UoW 作用域 | +| `UnitOfWork` | 默认 UoW 实现 | +| `UnitOfWorkBase` | 共享事务流程的基类 | +| `UnitOfWorkInterceptor` | 拦截应用流程并附加 UoW 边界 | +| `IUnitOfWorkAccessor` | 访问当前激活的 UoW 上下文 | + +### Pipeline(euonia-pipeline) +> 受 ASP.NET Core 启发的中间件管道框架:支持可链式的请求/响应处理、行为、委托与依赖注入集成。 + +| 接口 / 类 | 说明 | +|-------------------|-------------| +| `Pipeline` | 管道构建器:通过 `use()` 链接组件、构建委托并异步执行 | +| `PipelineBase` | 抽象基类:组件注册、反向链构建、`@PipelineBehaviors` 注解支持 | +| `PipelineDelegate` | `FunctionalInterface`:`CompletionStage invoke(Object context)` | +| `PipelineBehavior` | 行为接口:`CompletionStage handleAsync(Object, PipelineDelegate)` | +| `PipelineFactory` / `DefaultPipelineFactory` | 创建 `Pipeline` 与 `RequestResponsePipeline` 的工厂 | +| `DefaultPipelineProvider` | 默认实现,通过 `ServiceResolver` 解析行为(反射或 DI) | +| `RequestResponsePipeline` | 强类型请求/响应管道,支持 `runAsync(TRequest)` | +| `RequestResponsePipelineBase` | 强类型管道抽象基类 | +| `RequestResponsePipelineBehavior` | 强类型行为:`handleAsync(TRequest, PipelineDelegate)` | +| `RequestResponsePipelineDelegate` | 强类型委托:`CompletionStage invoke(TRequest)` | +| `RequestPipelineDelegate` | 无返回的强类型委托:`CompletionStage invoke(TRequest)` | +| `@PipelineBehaviors` | 按上下文类型自动附加行为的注解 | + +**关键特性:** +- Fluent API:支持通过 `.use()` 以 lambda、类或 `@PipelineBehaviors` 自动发现方式拼装行为 +- 同时支持无返回管道(`Pipeline`)和请求/响应管道(`RequestResponsePipeline`) +- 基于委托的组合,采用反向链构建(最内层先执行) +- `ServiceResolver` 抽象支持独立运行与 Spring 集成 +- 全链路异步(`CompletionStage`) + +```java +// 创建一个管道 +Pipeline pipeline = new DefaultPipelineProvider(resolver) + .use((ctx, next) -> next.invoke(ctx).thenRun(() -> System.out.println("Log: done"))) + .use(LoggingBehavior.class); + +// 运行 +pipeline.runAsync(new MyContext()).toCompletableFuture().join(); +``` + +### Spring(euonia-spring) +> Spring 集成模块。通过 `ApplicationContext` 与 `ServiceResolver` 建立桥接,为 Pipeline 及其它 Euonia 组件提供无缝依赖注入。 + +| 类 | 说明 | +|-------|-------------| +| `ApplicationContextServiceResolver` | 基于 Spring `ApplicationContext` 的 `ServiceResolver` 实现,支持 `getBeanProvider`、`autowireBean` 与构造参数创建 | +| `ServiceResolverConfiguration` | Spring `@Configuration`,自动注册 `ServiceResolver` Bean | + +**关键特性:** +- 为 Pipeline 与其它 Euonia 组件提供 Spring DI 能力 +- 自动注入 Spring 管理的 Bean 到 Pipeline 委托/行为 +- 提供带自动装配的反射构建兜底能力 +- 极简接入:`@Import(ServiceResolverConfiguration.class)` 或组件扫描 + +### OSBA(euonia-osba) +> **面向对象可扩展业务架构**:提供富业务对象模型,支持规则校验、属性变更追踪、状态管理与反射驱动工厂。 + +#### 业务对象层级 + +``` +BusinessObject — 核心:规则、上下文、属性管理 + └── ObservableObject — 状态跟踪:NEW / CHANGED / DELETED + ├── EditableObject — 支持异步规则校验与保存 + ├── ReadOnlyObject — 带权限控制的只读对象 + └── ExecutableObject — 模板化执行对象 +``` + +#### 核心概念 + +| 概念 | 说明 | +|---------|-------------| +| **BusinessContext** | 服务定位与对象工厂上下文;负责注入上下文与初始化规则 | +| **PropertyInfo** | 强类型属性元数据:名称、类型、友好名、默认值、字段引用 | +| **FieldDataManager** | 实例级反射字段值管理 | +| **Rule System** | 异步规则校验,基于 `RuleManager`(类型级单例)与 `Rules`(实例级执行器) | +| **ObjectEditState** | 生命周期状态机:`NONE → NEW → CHANGED → DELETED` | +| **ObjectFactory** | 反射驱动 CRUD 工厂:`@FactoryCreate`、`@FactoryFetch`、`@FactoryInsert`、`@FactoryUpdate`、`@FactoryDelete`、`@FactoryExecute` | + +#### 规则系统 + +```java +protected void addRules() { + getRules().addRule(new LambdaRule<>(age, (a, ctx) -> a != null && a >= 18, "Must be 18+")); +} +``` + +| 类 | 说明 | +|-------|-------------| +| `Rule` | 接口:`getName()`、`getProperty()`、`getPriority()`、`executeAsync(RuleContext)` | +| `LambdaRule` | 基于 Lambda:`(value, context) → boolean` | +| `RegularRule` | 基于方法执行 | +| `RequiredRule` | 非空属性校验 | +| `BrokenRule` / `BrokenRuleCollection` | 校验结果集合,含严重级别(ERROR/WARNING/INFO) | +| `RuleCheckException` | 校验失败时抛出 | + +--- + +## 示例应用 + +`sample` 模块演示了 **Euonia 与 Spring Boot 4.0 的集成**: + +| 组件 | 说明 | +|-----------|-------------| +| **`User` 聚合** | 基于 `EditableObject`,使用 `@FactoryCreate`、自定义规则(`UserNameRule`、`LambdaRule`)与 Snowflake ID | +| **`OsbaConfiguration`** | 将 `BusinessObjectFactory` 绑定到 Spring `ApplicationContext` | +| **`UserController`** | REST API:`POST /api/user`、`GET /api/user/{id}`,通过 `ObjectFactory` 创建/查询聚合 | + +### 技术栈 + +| 类别 | 技术 | +|----------|-----------| +| **语言** | Java 17+(sample 使用 Java 25) | +| **框架** | Spring Boot 4.0(Spring MVC、Spring Data JPA、Spring Framework 7.0) | +| **数据库** | MySQL、H2(内存模式) | +| **API 文档** | SpringDoc OpenAPI 3.0 | +| **构建** | Maven | +| **ID 生成** | Snowflake、UUID、ULID | +| **Pipeline** | 自定义中间件管道(责任链 / middleware 模式) | +| **DI 集成** | 基于 `ServiceResolver` 的 Spring `ApplicationContext` 集成 | + +--- + +## 快速开始 + +### Maven 依赖 + +```xml + + + com.euonia + core + 1.0.0 + + + + + com.euonia + pipeline + 1.0.0 + + + + + com.euonia + spring + 1.0.0 + + + + + com.euonia + osba + 1.0.0 + + + + + com.euonia + domain-driven-design + 1.0.0 + +``` + +```java +// 定义业务对象 +@Component @Scope("prototype") +public class Order extends EditableObject { + private final PropertyInfo productName = registerProperty(String.class, "productName"); + + @FactoryCreate + protected void create(String productName) { + super.create(); + setProductName(productName); + setId(ObjectId.snowflake().getValue(Long.class)); + } + + @Override + protected void addRules() { + getRules().addRule(new RequiredRule(productName)); + } +} + +// 使用工厂 +@Autowired +private ObjectFactory factory; + +var order = factory.create(Order.class, "Widget"); +order.save(false); +``` + +--- + +## 构建 + +```bash +# 构建全部模块 +mvn clean install + +# 运行示例应用 +cd sample +mvn spring-boot:run +``` + +--- + +## 项目链接 + +- **GitHub**: [github.com/NerosoftDev/euonia-java](https://github.com/NerosoftDev/euonia-java) +- **.NET 版本**: [github.com/NerosoftDev/Euonia](https://github.com/NerosoftDev/Euonia) + +--- + +## 赞助 + +donate + +--- + +[![JetBrains](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://www.jetbrains.com/) + +感谢 [JetBrains](https://www.jetbrains.com/) 通过其 [开源免费许可证计划](https://www.jetbrains.com/community/opensource) 提供 [全家桶产品支持](https://www.jetbrains.com/products.html)。 + +--- + +![Alt](https://repobeats.axiom.co/api/embed/5dc93c910fbd2dc550495a9325f7bcd0235a6082.svg "Repobeats analytics image") diff --git a/core/pom.xml b/core/pom.xml index 98c7155..0dd0b15 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -18,7 +18,7 @@ org.junit.jupiter junit-jupiter - 5.12.2 + ${junit.jupiter.version} test @@ -28,7 +28,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.3 + 3.5.6 diff --git a/domain/pom.xml b/ddd/pom.xml similarity index 56% rename from domain/pom.xml rename to ddd/pom.xml index c4dfd3e..916e26b 100644 --- a/domain/pom.xml +++ b/ddd/pom.xml @@ -9,8 +9,8 @@ ${revision} - domain - euonia domain module + domain-driven-design + euonia domain-driven-design module @@ -18,6 +18,19 @@ core ${revision} + + + com.euonia + unit-of-work + ${revision} + + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + diff --git a/domain/src/main/java/com/euonia/domain/Aggregate.java b/ddd/src/main/java/com/euonia/domain/Aggregate.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/Aggregate.java rename to ddd/src/main/java/com/euonia/domain/Aggregate.java diff --git a/domain/src/main/java/com/euonia/domain/AggregateBase.java b/ddd/src/main/java/com/euonia/domain/AggregateBase.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/AggregateBase.java rename to ddd/src/main/java/com/euonia/domain/AggregateBase.java diff --git a/domain/src/main/java/com/euonia/domain/Entity.java b/ddd/src/main/java/com/euonia/domain/Entity.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/Entity.java rename to ddd/src/main/java/com/euonia/domain/Entity.java diff --git a/domain/src/main/java/com/euonia/domain/EntityBase.java b/ddd/src/main/java/com/euonia/domain/EntityBase.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/EntityBase.java rename to ddd/src/main/java/com/euonia/domain/EntityBase.java diff --git a/domain/src/main/java/com/euonia/domain/ValueObject.java b/ddd/src/main/java/com/euonia/domain/ValueObject.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/ValueObject.java rename to ddd/src/main/java/com/euonia/domain/ValueObject.java diff --git a/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java b/ddd/src/main/java/com/euonia/domain/auditing/AuditRecord.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java rename to ddd/src/main/java/com/euonia/domain/auditing/AuditRecord.java diff --git a/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java b/ddd/src/main/java/com/euonia/domain/auditing/AuditStore.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/auditing/AuditStore.java rename to ddd/src/main/java/com/euonia/domain/auditing/AuditStore.java diff --git a/domain/src/main/java/com/euonia/domain/auditing/Audited.java b/ddd/src/main/java/com/euonia/domain/auditing/Audited.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/auditing/Audited.java rename to ddd/src/main/java/com/euonia/domain/auditing/Audited.java diff --git a/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java b/ddd/src/main/java/com/euonia/domain/event/ApplicationEvent.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java rename to ddd/src/main/java/com/euonia/domain/event/ApplicationEvent.java diff --git a/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java b/ddd/src/main/java/com/euonia/domain/event/ApplicationEventBase.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java rename to ddd/src/main/java/com/euonia/domain/event/ApplicationEventBase.java diff --git a/domain/src/main/java/com/euonia/domain/event/DomainEvent.java b/ddd/src/main/java/com/euonia/domain/event/DomainEvent.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/DomainEvent.java rename to ddd/src/main/java/com/euonia/domain/event/DomainEvent.java diff --git a/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java b/ddd/src/main/java/com/euonia/domain/event/DomainEventBase.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/DomainEventBase.java rename to ddd/src/main/java/com/euonia/domain/event/DomainEventBase.java diff --git a/domain/src/main/java/com/euonia/domain/event/Event.java b/ddd/src/main/java/com/euonia/domain/event/Event.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/Event.java rename to ddd/src/main/java/com/euonia/domain/event/Event.java diff --git a/domain/src/main/java/com/euonia/domain/event/EventAggregate.java b/ddd/src/main/java/com/euonia/domain/event/EventAggregate.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/EventAggregate.java rename to ddd/src/main/java/com/euonia/domain/event/EventAggregate.java diff --git a/domain/src/main/java/com/euonia/domain/event/EventBase.java b/ddd/src/main/java/com/euonia/domain/event/EventBase.java similarity index 100% rename from domain/src/main/java/com/euonia/domain/event/EventBase.java rename to ddd/src/main/java/com/euonia/domain/event/EventBase.java diff --git a/osba/pom.xml b/osba/pom.xml index 71401e2..21e7e18 100644 --- a/osba/pom.xml +++ b/osba/pom.xml @@ -21,6 +21,13 @@ ${revision} compile + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + diff --git a/pipeline/pom.xml b/pipeline/pom.xml index f71708c..c76c2e4 100644 --- a/pipeline/pom.xml +++ b/pipeline/pom.xml @@ -22,7 +22,7 @@ org.junit.jupiter junit-jupiter - 5.12.2 + ${junit.jupiter.version} test diff --git a/pom.xml b/pom.xml index e7df6ed..512e996 100644 --- a/pom.xml +++ b/pom.xml @@ -10,8 +10,9 @@ core osba - domain + ddd pipeline + uow spring @@ -41,6 +42,10 @@ UTF-8 17 17 + 6.1.0 + 3.5.6 + 7.0.6 + 1.9.25.1 diff --git a/spring/pom.xml b/spring/pom.xml index 89f8caa..0489775 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -19,16 +19,34 @@ ${revision} + + com.euonia + unit-of-work + ${revision} + + org.springframework spring-context - 7.0.6 + ${spring.version} + + + + org.springframework + spring-aop + ${spring.version} + + + + org.aspectj + aspectjweaver + ${aspectj.version} org.junit.jupiter junit-jupiter - 5.12.2 + ${junit.jupiter.version} test @@ -38,7 +56,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.5.3 + ${maven.surefire.plugin.version} diff --git a/spring/src/main/java/com/euonia/uow/UnitOfWorkAspect.java b/spring/src/main/java/com/euonia/uow/UnitOfWorkAspect.java new file mode 100644 index 0000000..3e8f2b9 --- /dev/null +++ b/spring/src/main/java/com/euonia/uow/UnitOfWorkAspect.java @@ -0,0 +1,72 @@ +package com.euonia.uow; + +import java.lang.reflect.Method; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; + +/** + * Spring AOP aspect that intercepts methods annotated with + * {@link com.euonia.uow.annotation.UnitOfWork @UnitOfWork} and + * wraps them in a unit of work. + * + *

How it works

+ *
    + *
  1. Before the method executes, a new {@link UnitOfWork} is begun + * via {@link UnitOfWorkManager}.
  2. + *
  3. The method proceeds normally.
  4. + *
  5. On success, the unit of work is completed (save → handlers → listeners).
  6. + *
  7. On failure, the unit of work is rolled back and the exception is + * propagated. Failed listeners are fired via {@link UnitOfWork#close()}.
  8. + *
  9. The unit of work is always disposed in a {@code finally} block.
  10. + *
+ * + *

Pointcut

+ *

Intercepts any Spring-managed bean method whose class or method is + * annotated with {@code @UnitOfWork}. Methods annotated with + * {@code @UnitOfWork(disabled = true)} are skipped.

+ * + * @see UnitOfWorkManager + * @see com.euonia.uow.annotation.UnitOfWork + */ +@Aspect +public class UnitOfWorkAspect { + + private final UnitOfWorkManager unitOfWorkManager; + + /** + * Creates an aspect that uses the given manager for unit-of-work lifecycle. + * + * @param unitOfWorkManager the unit-of-work manager + */ + public UnitOfWorkAspect(UnitOfWorkManager unitOfWorkManager) { + this.unitOfWorkManager = unitOfWorkManager; + } + + /** + * Around advice that wraps annotated methods in a unit of work. + * + * @param pjp the join point + * @return the method's return value + * @throws Throwable if the method throws + */ + @Around("@within(com.euonia.uow.annotation.UnitOfWork) || @annotation(com.euonia.uow.annotation.UnitOfWork)") + public Object aroundUnitOfWork(ProceedingJoinPoint pjp) throws Throwable { + Method method = ((MethodSignature) pjp.getSignature()).getMethod(); + if (!UnitOfWorkHelper.isUnitOfWorkMethod(method)) { + return pjp.proceed(); + } + + try (UnitOfWork uow = unitOfWorkManager.begin(new UnitOfWorkOptions(true), false)) { + Object result = pjp.proceed(); + uow.completeAsync().toCompletableFuture().join(); + return result; + } catch (Throwable t) { + // close() (via try-with-resources) detects !completed + // and fires failed listeners automatically + throw t; + } + } +} diff --git a/spring/src/main/java/com/euonia/uow/UnitOfWorkAutoConfiguration.java b/spring/src/main/java/com/euonia/uow/UnitOfWorkAutoConfiguration.java new file mode 100644 index 0000000..2cb8c3b --- /dev/null +++ b/spring/src/main/java/com/euonia/uow/UnitOfWorkAutoConfiguration.java @@ -0,0 +1,72 @@ +package com.euonia.uow; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +/** + * Spring auto-configuration for the Unit of Work module. + * + *

Registers the following beans: + *

    + *
  • {@link UnitOfWorkAccessor} — thread-local holder for the + * ambient unit of work
  • + *
  • {@link UnitOfWorkManager} — entry point for creating and + * managing units of work
  • + *
  • {@link UnitOfWorkAspect} — AOP aspect that wraps + * {@code @UnitOfWork}-annotated methods
  • + *
+ * + *

Enable AspectJ auto-proxy so the {@link UnitOfWorkAspect} can + * intercept annotated Spring beans.

+ * + *

Usage

+ *

For Spring Boot, this configuration is auto-detected via + * {@code spring.factories}. For plain Spring, import it manually:

+ *
{@code
+ * @Configuration
+ * @Import(UnitOfWorkAutoConfiguration.class)
+ * public class AppConfig { }
+ * }
+ * + * @see UnitOfWorkAspect + * @see UnitOfWorkManager + */ +@Configuration +@EnableAspectJAutoProxy +public class UnitOfWorkAutoConfiguration { + + /** + * Creates the thread-local accessor for tracking the ambient + * unit of work. + * + * @return a new {@link UnitOfWorkAccessor} + */ + @Bean + public UnitOfWorkAccessor unitOfWorkAccessor() { + return new UnitOfWorkAccessor(); + } + + /** + * Creates the unit-of-work manager. + * + * @param accessor the thread-local accessor + * @return a new {@link UnitOfWorkManager} + */ + @Bean + public UnitOfWorkManager unitOfWorkManager(UnitOfWorkAccessor accessor) { + return new UnitOfWorkManager(accessor, new UnitOfWorkOptions()); + } + + /** + * Creates the AOP aspect that wraps {@code @UnitOfWork}-annotated + * methods in a unit of work. + * + * @param manager the unit-of-work manager + * @return a new {@link UnitOfWorkAspect} + */ + @Bean + public UnitOfWorkAspect unitOfWorkAspect(UnitOfWorkManager manager) { + return new UnitOfWorkAspect(manager); + } +} diff --git a/spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f144323 --- /dev/null +++ b/spring/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.euonia.uow.UnitOfWorkAutoConfiguration +com.euonia.reflection.ServiceResolverConfiguration diff --git a/uow/README.md b/uow/README.md new file mode 100644 index 0000000..ffd6b0a --- /dev/null +++ b/uow/README.md @@ -0,0 +1,312 @@ +# Unit of Work Module + +A lightweight, async unit-of-work abstraction for coordinating transactional resources (database, message broker, etc.) in a single atomic operation. Inspired by the .NET `Euonia.Uow` module. + +--- + +## Architecture + +``` +UnitOfWorkManager + │ + ├── begin(options, requiresNew) + │ │ + │ ▼ + │ ┌─────────────────────┐ + │ │ UnitOfWork │ + │ │ (implements │ + │ │ AutoCloseable) │ + │ └────────┬────────────┘ + │ │ + │ ├── contexts: Map + │ │ ├── "db" → JdbcTransactionContext + │ │ ├── "mq" → MessageQueueContext + │ │ └── "cache" → CacheContext + │ │ + │ ├── listeners + │ │ ├── completedListeners + │ │ ├── failedListeners + │ │ └── disposedListeners + │ │ + │ └── handlers + │ └── completedHandlers (async pre-completion) + │ + └── getCurrent() → UnitOfWorkAccessor (ThreadLocal) +``` + +## Core Concepts + +| Class / Interface | Description | +|-------------------|-------------| +| `UnitOfWork` | Coordinates contexts, listeners, and lifecycle (save → complete → dispose) | +| `UnitOfWorkManager` | Entry point — creates units, manages ambient scope via `ThreadLocal` | +| `UnitOfWorkContext` | Interface for transactional resources (save, commit, rollback, close) | +| `ChildUnitOfWork` | Delegates to parent when nesting without `requiresNew` | +| `UnitOfWorkAccessor` | `ThreadLocal` holder for the current ambient unit of work | +| `UnitOfWorkOptions` | Transactional flag, isolation level, timeout | +| `UnitOfWorkEnabled` | Marker interface for automatic interception | +| `@UnitOfWork` | Annotation for declarative unit-of-work boundaries | +| `UnitOfWorkHelper` | Static utilities for introspecting annotations | + +## Lifecycle + +``` +initialize(options) + │ + ▼ + [add contexts & business logic] + │ + ├── completeAsync() + │ │ + │ ├── saveChangesAsync() ← flush all contexts + │ ├── invokeCompletedHandlers() + │ └── notifyCompleted() ← fire completed listeners + │ + └── close() (AutoCloseable / try-with-resources) + │ + ├── close all contexts + ├── notifyFailed() if !completed + └── notifyDisposed() +``` + +## Quick Start + +### Programmatic API + +```java +UnitOfWorkManager manager = new UnitOfWorkManager(); + +try (UnitOfWork uow = manager.begin(new UnitOfWorkOptions(true), false)) { + uow.addContext("db", new JdbcTransactionContext(connection)); + + uow.addCompletedListener(event -> + log.info("Unit of work {} completed", event.getUnitOfWork().getId())); + + uow.addFailedListener(event -> + log.error("Unit of work failed", event.getException())); + + // ... business logic ... + + uow.completeAsync().toCompletableFuture().join(); +} +``` + +### Annotation-driven (with AOP) + +Add the `@UnitOfWork` annotation to your service classes or methods: + +```java +import com.euonia.uow.annotation.UnitOfWork; + +@UnitOfWork +public class OrderService implements UnitOfWorkEnabled { + + public void placeOrder(Order order) { + // Automatically wrapped in a unit of work + } + + @UnitOfWork(disabled = true) + public List findOrders() { + // Read-only — no unit of work + } +} +``` + +### Spring Boot Integration + +Add the `spring` module dependency: + +```xml + + com.euonia + spring + ${euonia.version} + +``` + +The auto-configuration (`UnitOfWorkAutoConfiguration`) registers: +- `UnitOfWorkAccessor` — thread-local holder +- `UnitOfWorkManager` — entry point for creating units of work +- `UnitOfWorkAspect` — AOP aspect wrapping `@UnitOfWork`-annotated methods + +**How the aspect works:** + +```mermaid +sequenceDiagram + participant Caller + participant Aspect as UnitOfWorkAspect + participant Manager as UnitOfWorkManager + participant UOW as UnitOfWork + participant Service + + Caller->>Aspect: call @UnitOfWork method + Aspect->>Manager: begin(options, false) + Manager->>UOW: create & initialize + Aspect->>Service: proceed() + alt success + Service-->>Aspect: return result + Aspect->>UOW: completeAsync() + UOW->>UOW: saveChanges → handlers → listeners + else exception + Service--xAspect: throws + Aspect->>UOW: close() → failed listeners + end + Aspect->>UOW: close() → disposed listeners + Aspect-->>Caller: return result / throw +``` + +**Service example:** + +```java +@Service +@UnitOfWork +public class OrderService { + + private final JdbcTemplate jdbc; + private final RabbitTemplate rabbit; + + public OrderService(JdbcTemplate jdbc, RabbitTemplate rabbit) { + this.jdbc = jdbc; + this.rabbit = rabbit; + } + + public void placeOrder(Order order) { + // DB insert and MQ publish happen in the same unit of work + jdbc.update("INSERT INTO orders ..."); + rabbit.convertAndSend("order.exchange", "placed", order); + // On success: both are committed + // On failure: both are rolled back + } +} +``` + +**Registering transactional contexts:** + +Use lifecycle listeners to register your contexts automatically: + +```java +@Configuration +public class UowContextConfig { + + @Bean + public UnitOfWorkManager unitOfWorkManager( + UnitOfWorkAccessor accessor, + DataSource dataSource, + ConnectionFactory connectionFactory) { + + UnitOfWorkManager manager = new UnitOfWorkManager(accessor, new UnitOfWorkOptions(true)); + + // Register a global listener to add contexts on creation + // (fired via UnitOfWork.addDisposedListener approach, or + // subclass UnitOfWorkManager to override begin()) + return manager; + } +} +``` + +For programmatic context registration per unit of work: + +```java +@Autowired +private UnitOfWorkAccessor accessor; + +public void doSomething() { + UnitOfWork uow = accessor.getCurrentUnitOfWork(); + uow.getOrAddContext("db", () -> new JdbcTransactionContext(dataSource.getConnection())); + // ... all DB operations share this context ... +} +``` + +**Nested units of work:** + +```java +@Service +public class OrderFacade { + + @Autowired + private UnitOfWorkManager manager; + + @UnitOfWork + public void checkout(Order order) { + // Outer unit of work begins automatically + paymentService.charge(order); // participates in outer UOW + inventoryService.reserve(order); // participates in outer UOW + } +} + +@Service +@UnitOfWork +public class PaymentService { + // Methods automatically join the ambient unit of work + // unless .begin(..., true) is used for a new transaction +} +``` + +### Custom Transactional Context + +```java +public class JdbcTransactionContext implements UnitOfWorkContext { + private final Connection connection; + + public JdbcTransactionContext(Connection connection) { + this.connection = connection; + } + + @Override + public CompletionStage saveChangesAsync() { + return CompletableFuture.runAsync(() -> { + // Flush pending statements + }); + } + + @Override + public CompletionStage commitAsync() { + return CompletableFuture.runAsync(() -> connection.commit()); + } + + @Override + public CompletionStage rollbackAsync() { + return CompletableFuture.runAsync(() -> connection.rollback()); + } + + @Override + public void close() { + try { connection.close(); } catch (SQLException ignored) { } + } +} +``` + +## Events + +| Event Class | When Fired | +|-------------|------------| +| `UnitOfWorkEvent` | On successful completion and on disposal | +| `UnitOfWorkFailure` | On failure (exception or explicit rollback) | + +## Isolation Levels + +| Level | JDBC Constant | +|-------|---------------| +| `UNSPECIFIED` | `TRANSACTION_NONE` | +| `READ_UNCOMMITTED` | `TRANSACTION_READ_UNCOMMITTED` | +| `READ_COMMITTED` | `TRANSACTION_READ_COMMITTED` | +| `REPEATABLE_READ` | `TRANSACTION_REPEATABLE_READ` | +| `SERIALIZABLE` | `TRANSACTION_SERIALIZABLE` | + +## Maven + +```xml + + + com.euonia + unit-of-work + ${euonia.version} + + + + + com.euonia + spring + ${euonia.version} + +``` diff --git a/uow/README.zh.md b/uow/README.zh.md new file mode 100644 index 0000000..e7954b2 --- /dev/null +++ b/uow/README.zh.md @@ -0,0 +1,312 @@ +# Unit of Work 模块 + +轻量级异步工作单元抽象,用于在单次原子操作中协调事务资源(数据库、消息代理等)。灵感来自 .NET `Euonia.Uow` 模块。 + +--- + +## 架构 + +``` +UnitOfWorkManager + │ + ├── begin(options, requiresNew) + │ │ + │ ▼ + │ ┌─────────────────────┐ + │ │ UnitOfWork │ + │ │ (实现 │ + │ │ AutoCloseable) │ + │ └────────┬────────────┘ + │ │ + │ ├── contexts: Map + │ │ ├── "db" → JdbcTransactionContext + │ │ ├── "mq" → MessageQueueContext + │ │ └── "cache" → CacheContext + │ │ + │ ├── listeners + │ │ ├── completedListeners + │ │ ├── failedListeners + │ │ └── disposedListeners + │ │ + │ └── handlers + │ └── completedHandlers(异步前置完成回调) + │ + └── getCurrent() → UnitOfWorkAccessor (ThreadLocal) +``` + +## 核心概念 + +| 类 / 接口 | 说明 | +|-----------|------| +| `UnitOfWork` | 协调上下文、监听器和生命周期(保存 → 完成 → 释放) | +| `UnitOfWorkManager` | 入口 — 创建工作单元,通过 `ThreadLocal` 管理环境作用域 | +| `UnitOfWorkContext` | 事务资源接口(保存、提交、回滚、关闭) | +| `ChildUnitOfWork` | 嵌套时无需 `requiresNew`,代理到父级 | +| `UnitOfWorkAccessor` | `ThreadLocal` 持有当前环境工作单元 | +| `UnitOfWorkOptions` | 事务标志、隔离级别、超时 | +| `UnitOfWorkEnabled` | 标记接口,用于自动拦截 | +| `@UnitOfWork` | 注解,用于声明式工作单元边界 | +| `UnitOfWorkHelper` | 静态工具类,用于检查注解 | + +## 生命周期 + +``` +initialize(options) + │ + ▼ + [添加上下文 & 业务逻辑] + │ + ├── completeAsync() + │ │ + │ ├── saveChangesAsync() ← 刷新所有上下文 + │ ├── invokeCompletedHandlers() + │ └── notifyCompleted() ← 触发完成监听器 + │ + └── close() (AutoCloseable / try-with-resources) + │ + ├── 关闭所有上下文 + ├── notifyFailed() 如果未完成 + └── notifyDisposed() +``` + +## 快速开始 + +### 编程式 API + +```java +UnitOfWorkManager manager = new UnitOfWorkManager(); + +try (UnitOfWork uow = manager.begin(new UnitOfWorkOptions(true), false)) { + uow.addContext("db", new JdbcTransactionContext(connection)); + + uow.addCompletedListener(event -> + log.info("工作单元 {} 已完成", event.getUnitOfWork().getId())); + + uow.addFailedListener(event -> + log.error("工作单元执行失败", event.getException())); + + // ... 业务逻辑 ... + + uow.completeAsync().toCompletableFuture().join(); +} +``` + +### 注解驱动(配合 AOP) + +在服务类或方法上添加 `@UnitOfWork` 注解: + +```java +import com.euonia.uow.annotation.UnitOfWork; + +@UnitOfWork +public class OrderService implements UnitOfWorkEnabled { + + public void placeOrder(Order order) { + // 自动包装在工作单元中 + } + + @UnitOfWork(disabled = true) + public List findOrders() { + // 只读 — 无需工作单元 + } +} +``` + +### Spring Boot 集成 + +添加 `spring` 模块依赖: + +```xml + + com.euonia + spring + ${euonia.version} + +``` + +自动配置(`UnitOfWorkAutoConfiguration`)会注册以下 Bean: +- `UnitOfWorkAccessor` — 线程局部持有者 +- `UnitOfWorkManager` — 创建工作单元的入口 +- `UnitOfWorkAspect` — AOP 切面,拦截 `@UnitOfWork` 注解的方法 + +**切面工作原理:** + +```mermaid +sequenceDiagram + participant Caller as 调用方 + participant Aspect as UnitOfWorkAspect + participant Manager as UnitOfWorkManager + participant UOW as UnitOfWork + participant Service as 业务服务 + + Caller->>Aspect: 调用 @UnitOfWork 方法 + Aspect->>Manager: begin(options, false) + Manager->>UOW: 创建并初始化 + Aspect->>Service: proceed() + alt 成功 + Service-->>Aspect: 返回结果 + Aspect->>UOW: completeAsync() + UOW->>UOW: saveChanges → handlers → listeners + else 异常 + Service--xAspect: 抛出异常 + Aspect->>UOW: close() → 失败监听器 + end + Aspect->>UOW: close() → 释放监听器 + Aspect-->>Caller: 返回结果 / 抛出异常 +``` + +**服务示例:** + +```java +@Service +@UnitOfWork +public class OrderService { + + private final JdbcTemplate jdbc; + private final RabbitTemplate rabbit; + + public OrderService(JdbcTemplate jdbc, RabbitTemplate rabbit) { + this.jdbc = jdbc; + this.rabbit = rabbit; + } + + public void placeOrder(Order order) { + // 数据库写入和消息发布在同一工作单元中 + jdbc.update("INSERT INTO orders ..."); + rabbit.convertAndSend("order.exchange", "placed", order); + // 成功:两者一起提交 + // 失败:两者一起回滚 + } +} +``` + +**注册事务上下文:** + +通过生命周期监听器自动注册上下文: + +```java +@Configuration +public class UowContextConfig { + + @Bean + public UnitOfWorkManager unitOfWorkManager( + UnitOfWorkAccessor accessor, + DataSource dataSource, + ConnectionFactory connectionFactory) { + + UnitOfWorkManager manager = new UnitOfWorkManager(accessor, new UnitOfWorkOptions(true)); + + // 注册全局监听器,在创建时添加上下文 + // (可通过 UnitOfWork.addDisposedListener 方式, + // 或继承 UnitOfWorkManager 覆写 begin() 方法) + return manager; + } +} +``` + +在每个工作单元中编程式注册上下文: + +```java +@Autowired +private UnitOfWorkAccessor accessor; + +public void doSomething() { + UnitOfWork uow = accessor.getCurrentUnitOfWork(); + uow.getOrAddContext("db", () -> new JdbcTransactionContext(dataSource.getConnection())); + // ... 所有数据库操作共享此上下文 ... +} +``` + +**嵌套工作单元:** + +```java +@Service +public class OrderFacade { + + @Autowired + private UnitOfWorkManager manager; + + @UnitOfWork + public void checkout(Order order) { + // 外层工作单元自动开始 + paymentService.charge(order); // 参与外层 UOW + inventoryService.reserve(order); // 参与外层 UOW + } +} + +@Service +@UnitOfWork +public class PaymentService { + // 方法自动加入环境工作单元 + // 除非使用 .begin(..., true) 开启新事务 +} +``` + +### 自定义事务上下文 + +```java +public class JdbcTransactionContext implements UnitOfWorkContext { + private final Connection connection; + + public JdbcTransactionContext(Connection connection) { + this.connection = connection; + } + + @Override + public CompletionStage saveChangesAsync() { + return CompletableFuture.runAsync(() -> { + // 刷新待执行语句 + }); + } + + @Override + public CompletionStage commitAsync() { + return CompletableFuture.runAsync(() -> connection.commit()); + } + + @Override + public CompletionStage rollbackAsync() { + return CompletableFuture.runAsync(() -> connection.rollback()); + } + + @Override + public void close() { + try { connection.close(); } catch (SQLException ignored) { } + } +} +``` + +## 事件 + +| 事件类 | 触发时机 | +|--------|----------| +| `UnitOfWorkEvent` | 成功完成时和释放时 | +| `UnitOfWorkFailure` | 失败时(异常或显式回滚) | + +## 隔离级别 + +| 级别 | JDBC 常量 | +|------|----------| +| `UNSPECIFIED` | `TRANSACTION_NONE` | +| `READ_UNCOMMITTED` | `TRANSACTION_READ_UNCOMMITTED` | +| `READ_COMMITTED` | `TRANSACTION_READ_COMMITTED` | +| `REPEATABLE_READ` | `TRANSACTION_REPEATABLE_READ` | +| `SERIALIZABLE` | `TRANSACTION_SERIALIZABLE` | + +## Maven + +```xml + + + com.euonia + unit-of-work + ${euonia.version} + + + + + com.euonia + spring + ${euonia.version} + +``` diff --git a/uow/pom.xml b/uow/pom.xml new file mode 100644 index 0000000..178f1f1 --- /dev/null +++ b/uow/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + com.euonia + parent + ${revision} + + + unit-of-work + euonia uow module + Unit of work module for the Euonia framework + + + + com.euonia + core + ${revision} + + + + org.junit.jupiter + junit-jupiter + 5.12.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + + diff --git a/uow/src/main/java/com/euonia/uow/ChildUnitOfWork.java b/uow/src/main/java/com/euonia/uow/ChildUnitOfWork.java new file mode 100644 index 0000000..8421b5e --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/ChildUnitOfWork.java @@ -0,0 +1,146 @@ +package com.euonia.uow; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * A unit of work that delegates all operations to a parent unit of work. + * + *

When {@link UnitOfWorkManager#begin} is called while a unit of work + * is already active on the current thread (and {@code requiresNew} is + * {@code false}), a {@code ChildUnitOfWork} is returned instead of a new + * top-level unit. All lifecycle methods — save, commit, rollback, listener + * registration — are forwarded to the parent.

+ * + *

The {@link #completeAsync()} method is a no-op on a child, since + * only the outermost unit of work should trigger completion.

+ * + * @see UnitOfWork + * @see UnitOfWorkManager + */ +public class ChildUnitOfWork extends UnitOfWork { + private final UnitOfWork parent; + + public ChildUnitOfWork(UnitOfWork parent) { + super(parent == null ? null : parent.getOptions()); + this.parent = Objects.requireNonNull(parent, "parent"); + } + + @Override + public Map getItems() { + return parent.getItems(); + } + + @Override + public Map getContexts() { + return parent.getContexts(); + } + + @Override + public UnitOfWorkOptions getOptions() { + return parent.getOptions(); + } + + @Override + public UnitOfWork getOuter() { + return parent.getOuter(); + } + + @Override + public boolean isReserved() { + return parent.isReserved(); + } + + @Override + public String getReservationName() { + return parent.getReservationName(); + } + + @Override + public boolean isDisposed() { + return parent.isDisposed(); + } + + @Override + public boolean isCompleted() { + return parent.isCompleted(); + } + + @Override + public Throwable getFailure() { + return parent.getFailure(); + } + + @Override + public void addCompletedListener(Consumer listener) { + parent.addCompletedListener(listener); + } + + @Override + public void addFailedListener(Consumer listener) { + parent.addFailedListener(listener); + } + + @Override + public void addDisposedListener(Consumer listener) { + parent.addDisposedListener(listener); + } + + @Override + public void onCompleted(Supplier> handler) { + parent.onCompleted(handler); + } + + @Override + public void initialize(UnitOfWorkOptions options) { + parent.initialize(options); + } + + @Override + public void reserve(String reservationName) { + parent.reserve(reservationName); + } + + @Override + public CompletionStage saveChangesAsync() { + return parent.saveChangesAsync(); + } + + @Override + public CompletionStage rollbackAsync() { + return parent.rollbackAsync(); + } + + @Override + public CompletionStage completeAsync() { + return CompletableFuture.completedFuture(null); + } + + @Override + public UnitOfWorkContext findContext(String key) { + return parent.findContext(key); + } + + @Override + public void addContext(String key, UnitOfWorkContext context) { + parent.addContext(key, context); + } + + @Override + public UnitOfWorkContext getOrAddContext(String key, Supplier factory) { + return parent.getOrAddContext(key, factory); + } + + @Override + public void setOuter(UnitOfWork outer) { + parent.setOuter(outer); + } + + @Override + public void close() { + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWork.java b/uow/src/main/java/com/euonia/uow/UnitOfWork.java new file mode 100644 index 0000000..31114c8 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWork.java @@ -0,0 +1,460 @@ +package com.euonia.uow; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Coordinates transactional resources within a single atomic unit. + * + *

Lifecycle

+ *
    + *
  1. {@link #initialize(UnitOfWorkOptions)} — called by the manager
  2. + *
  3. Register contexts via {@link #addContext} or {@link #getOrAddContext}
  4. + *
  5. {@link #completeAsync()} — save changes and commit
  6. + *
  7. {@link #close()} — release resources (implements {@link AutoCloseable})
  8. + *
+ * + *

Usage

+ *
{@code
+ * try (UnitOfWork uow = new UnitOfWork()) {
+ *     uow.initialize(new UnitOfWorkOptions(true));
+ *     uow.addContext("db", new JdbcTransactionContext(connection));
+ *     // ... business logic ...
+ *     uow.completeAsync().toCompletableFuture().join();
+ * } // automatically disposes
+ * }
+ * + *

Listeners

+ *

Register callbacks for success, failure, and disposal: + *

    + *
  • {@link #addCompletedListener(Consumer)}
  • + *
  • {@link #addFailedListener(Consumer)}
  • + *
  • {@link #addDisposedListener(Consumer)}
  • + *
  • {@link #onCompleted(Supplier)} — async handler before completion event
  • + *
+ * + * @see UnitOfWorkManager + * @see UnitOfWorkContext + * @see ChildUnitOfWork + */ +public class UnitOfWork implements AutoCloseable { + private final String id = UUID.randomUUID().toString(); + private final Map items = new ConcurrentHashMap<>(); + private final Map contexts = new ConcurrentHashMap<>(); + private final List>> completedHandlers = new CopyOnWriteArrayList<>(); + private final List> completedListeners = new CopyOnWriteArrayList<>(); + private final List> failedListeners = new CopyOnWriteArrayList<>(); + private final List> disposedListeners = new CopyOnWriteArrayList<>(); + + private final UnitOfWorkOptions defaultOptions; + + private volatile boolean completing; + private volatile boolean completed; + private volatile boolean disposed; + private volatile boolean rolledBack; + private volatile Throwable failure; + + private UnitOfWorkOptions options; + private UnitOfWork outer; + private boolean reserved; + private String reservationName; + + /** Creates a unit of work with default (non-transactional) options. */ + public UnitOfWork() { + this(new UnitOfWorkOptions()); + } + + /** + * Creates a unit of work with the given default options. + * + * @param defaultOptions the fallback options; if {@code null}, non-transactional defaults are used + */ + public UnitOfWork(UnitOfWorkOptions defaultOptions) { + this.defaultOptions = defaultOptions == null ? new UnitOfWorkOptions() : defaultOptions; + } + + /** @return the unique identifier for this unit of work */ + public String getId() { + return id; + } + + /** + * Returns a mutable map for storing arbitrary data scoped to this + * unit of work. + * + * @return the items map + */ + public Map getItems() { + return items; + } + + /** + * Returns an unmodifiable view of the registered transactional contexts. + * + * @return the contexts map + */ + public Map getContexts() { + return Collections.unmodifiableMap(contexts); + } + + /** @return the active options, or {@code null} before initialization */ + public UnitOfWorkOptions getOptions() { + return options; + } + + /** @return the outer (parent) unit of work, or {@code null} if this is the outermost */ + public UnitOfWork getOuter() { + return outer; + } + + /** @return whether this unit of work has been reserved */ + public boolean isReserved() { + return reserved; + } + + /** @return the reservation name, or {@code null} if not reserved */ + public String getReservationName() { + return reservationName; + } + + /** @return whether {@link #close()} has been called */ + public boolean isDisposed() { + return disposed; + } + + /** @return whether {@link #completeAsync()} completed successfully */ + public boolean isCompleted() { + return completed; + } + + /** + * Returns the failure that caused this unit of work to fail, + * or {@code null} if it succeeded. + * + * @return the failure, or {@code null} + */ + public Throwable getFailure() { + return failure; + } + + /** + * Registers a listener to be notified when the unit of work + * completes successfully. + * + * @param listener the callback + */ + public void addCompletedListener(Consumer listener) { + completedListeners.add(Objects.requireNonNull(listener, "listener")); + } + + /** + * Registers a listener to be notified when the unit of work fails. + * + * @param listener the callback + */ + public void addFailedListener(Consumer listener) { + failedListeners.add(Objects.requireNonNull(listener, "listener")); + } + + /** + * Registers a listener to be notified when the unit of work is disposed. + * + * @param listener the callback + */ + public void addDisposedListener(Consumer listener) { + disposedListeners.add(Objects.requireNonNull(listener, "listener")); + } + + /** + * Registers an async handler that runs before the completed event is fired. + * Handlers are chained sequentially. + * + * @param handler the async handler + */ + public void onCompleted(Supplier> handler) { + completedHandlers.add(Objects.requireNonNull(handler, "handler")); + } + + /** + * Initializes the unit of work with the given options (called once by the manager). + * Merges the supplied options with this unit's defaults. + * + * @param options the options for this unit of work + * @throws IllegalArgumentException if {@code options} is {@code null} + * @throws IllegalStateException if already initialized + */ + public void initialize(UnitOfWorkOptions options) { + if (options == null) { + throw new IllegalArgumentException("options"); + } + + if (this.options != null) { + throw new IllegalStateException("This unit of work is already initialized before!"); + } + + this.options = defaultOptions.normalize(options); + this.reserved = false; + } + + /** + * Reserves this unit of work for a specific purpose. + * + * @param reservationName a descriptive name + */ + public void reserve(String reservationName) { + if (reservationName == null || reservationName.isBlank()) { + throw new IllegalArgumentException("reservationName"); + } + + this.reservationName = reservationName; + this.reserved = true; + } + + /** + * Saves pending changes across all registered contexts without committing. + * + * @return a stage that completes when all contexts have saved + */ + public CompletionStage saveChangesAsync() { + if (rolledBack) { + return CompletableFuture.completedFuture(null); + } + + List> stages = new ArrayList<>(); + for (UnitOfWorkContext context : contexts.values()) { + stages.add(context.saveChangesAsync()); + } + + return CompletableFuture.allOf(stages.stream() + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new)); + } + + /** + * Rolls back all registered contexts. + * + * @return a stage that completes when all contexts have rolled back + */ + public CompletionStage rollbackAsync() { + if (rolledBack) { + return CompletableFuture.completedFuture(null); + } + + rolledBack = true; + List> stages = new ArrayList<>(); + for (UnitOfWorkContext context : contexts.values()) { + stages.add(context.rollbackAsync()); + } + + return CompletableFuture.allOf(stages.stream() + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new)); + } + + /** + * Completes the unit of work: saves changes, fires completion handlers, + * then notifies listeners. May only be called once. + * + * @return a stage that completes when the unit of work is done + * @throws IllegalStateException if completion has already been requested + */ + public CompletionStage completeAsync() { + if (rolledBack) { + return CompletableFuture.completedFuture(null); + } + + if (completed || completing) { + throw new IllegalStateException("Completion has already been requested for this unit of work."); + } + + completing = true; + CompletableFuture result = new CompletableFuture<>(); + saveChangesAsync().whenComplete((ignored, throwable) -> { + try { + if (throwable != null) { + failure = unwrap(throwable); + result.completeExceptionally(failure); + return; + } + + completed = true; + invokeCompletedHandlers().whenComplete((unused, handlerFailure) -> { + if (handlerFailure != null) { + failure = unwrap(handlerFailure); + result.completeExceptionally(failure); + return; + } + + notifyCompleted(); + result.complete(null); + }); + } finally { + completing = false; + } + }); + + return result; + } + + /** + * Sets the outer (parent) unit of work for nesting support. + * + * @param outer the outer unit of work + */ + public void setOuter(UnitOfWork outer) { + this.outer = outer; + } + + /** + * Finds a registered context by key. + * + * @param key the context key + * @return the context, or {@code null} if not found + */ + public UnitOfWorkContext findContext(String key) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("key"); + } + + return contexts.get(key); + } + + /** + * Registers a context under the given key. Fails if a context is + * already registered with that key. + * + * @param key the context key + * @param context the context to register + * @throws IllegalStateException if a context with this key already exists + */ + public void addContext(String key, UnitOfWorkContext context) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("key"); + } + + if (context == null) { + throw new IllegalArgumentException("context"); + } + + UnitOfWorkContext previous = contexts.putIfAbsent(key, context); + if (previous != null) { + throw new IllegalStateException("This unit of work already contains a context with the key: " + key); + } + } + + /** + * Gets an existing context or creates a new one using the supplied factory. + * + * @param key the context key + * @param factory the factory to create the context if absent + * @return the existing or newly created context + */ + public UnitOfWorkContext getOrAddContext(String key, Supplier factory) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("key"); + } + + if (factory == null) { + throw new IllegalArgumentException("factory"); + } + + return contexts.computeIfAbsent(key, ignored -> factory.get()); + } + + /** + * Disposes the unit of work. Closes all contexts, fires failure + * listeners if the unit did not complete successfully, and fires + * disposal listeners. Idempotent — subsequent calls are no-ops. + */ + @Override + public void close() { + if (disposed) { + return; + } + + disposed = true; + + for (UnitOfWorkContext context : contexts.values()) { + context.close(); + } + + if (!completed || failure != null) { + notifyFailed(); + } + + notifyDisposed(); + } + + /** + * Notifies all registered completed listeners with the given event. + * + * @param event the completion event + */ + protected void notifyCompleted(UnitOfWorkEvent event) { + for (Consumer listener : completedListeners) { + listener.accept(event); + } + } + + /** + * Notifies all registered failure listeners with the given event. + * + * @param event the failure event + */ + protected void notifyFailed(UnitOfWorkFailure event) { + for (Consumer listener : failedListeners) { + listener.accept(event); + } + } + + /** + * Notifies all registered disposal listeners with the given event. + * + * @param event the disposal event + */ + protected void notifyDisposed(UnitOfWorkEvent event) { + for (Consumer listener : disposedListeners) { + listener.accept(event); + } + } + + private CompletionStage invokeCompletedHandlers() { + CompletableFuture chain = CompletableFuture.completedFuture(null); + for (Supplier> handler : completedHandlers) { + chain = chain.thenCompose(ignored -> handler.get()); + } + + return chain; + } + + private void notifyCompleted() { + notifyCompleted(new UnitOfWorkEvent(this)); + } + + private void notifyFailed() { + notifyFailed(new UnitOfWorkFailure(this, failure, rolledBack)); + } + + private void notifyDisposed() { + notifyDisposed(new UnitOfWorkEvent(this)); + } + + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof java.util.concurrent.CompletionException completionException + && completionException.getCause() != null) { + return completionException.getCause(); + } + + return throwable; + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkAccessor.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkAccessor.java new file mode 100644 index 0000000..ddc0ca1 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkAccessor.java @@ -0,0 +1,40 @@ +package com.euonia.uow; + +/** + * Thread-local holder for the current ambient {@link UnitOfWork}. + * + *

Each thread has its own independent unit of work, making this safe + * for use in thread-per-request environments such as servlet containers.

+ * + *
{@code
+ * UnitOfWork current = accessor.getCurrentUnitOfWork();
+ * }
+ */ +public class UnitOfWorkAccessor { + private final ThreadLocal current = new ThreadLocal<>(); + + /** + * Returns the unit of work associated with the current thread, + * or {@code null} if none is active. + * + * @return the current unit of work, or {@code null} + */ + public UnitOfWork getCurrentUnitOfWork() { + return current.get(); + } + + /** + * Sets the unit of work for the current thread. Passing {@code null} + * removes the association. + * + * @param unitOfWork the unit of work to associate, or {@code null} to clear + */ + public void setCurrentUnitOfWork(UnitOfWork unitOfWork) { + if (unitOfWork == null) { + current.remove(); + return; + } + + current.set(unitOfWork); + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkContext.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkContext.java new file mode 100644 index 0000000..607415c --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkContext.java @@ -0,0 +1,49 @@ +package com.euonia.uow; + +import java.util.concurrent.CompletionStage; + +/** + * Abstraction for a transactional resource (database, message broker, etc.) + * that participates in a unit of work. + * + *

Implementations register themselves with a {@link UnitOfWork} via + * {@link UnitOfWork#addContext} and receive lifecycle callbacks:

+ *
    + *
  1. {@link #saveChangesAsync()} — flush pending changes
  2. + *
  3. {@link #commitAsync()} — commit the transaction
  4. + *
  5. {@link #rollbackAsync()} — roll back the transaction
  6. + *
  7. {@link #close()} — release resources
  8. + *
+ * + * @see UnitOfWork + */ +public interface UnitOfWorkContext { + /** + * Persists pending changes to the underlying resource without + * committing the transaction. + * + * @return a stage that completes when changes are saved + */ + CompletionStage saveChangesAsync(); + + /** + * Commits the transaction. + * + * @return a stage that completes when the commit is done + */ + CompletionStage commitAsync(); + + /** + * Rolls back the transaction. + * + * @return a stage that completes when the rollback is done + */ + CompletionStage rollbackAsync(); + + /** + * Releases any resources held by this context. + * The default implementation does nothing. + */ + default void close() { + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkEnabled.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkEnabled.java new file mode 100644 index 0000000..1d7eed8 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkEnabled.java @@ -0,0 +1,13 @@ +package com.euonia.uow; + +/** + * Marker interface indicating that a type requires a unit of work. + * + *

Implement this interface on any service or handler class whose + * public methods should be automatically intercepted with a unit of work + * when used with a unit-of-work-aware proxy or AOP aspect.

+ * + * @see UnitOfWork + */ +public interface UnitOfWorkEnabled { +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkEvent.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkEvent.java new file mode 100644 index 0000000..6f3ff41 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkEvent.java @@ -0,0 +1,31 @@ +package com.euonia.uow; + +/** + * Event raised when a unit of work completes or is disposed. + * + *

Listeners registered via {@link UnitOfWork#addCompletedListener} + * and {@link UnitOfWork#addDisposedListener} receive instances of this class.

+ * + * @see UnitOfWorkFailure + */ +public class UnitOfWorkEvent { + private final UnitOfWork unitOfWork; + + /** + * Creates a new event for the given unit of work. + * + * @param unitOfWork the unit of work that triggered the event + */ + public UnitOfWorkEvent(UnitOfWork unitOfWork) { + this.unitOfWork = unitOfWork; + } + + /** + * Returns the unit of work that triggered this event. + * + * @return the associated unit of work + */ + public UnitOfWork getUnitOfWork() { + return unitOfWork; + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkFailure.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkFailure.java new file mode 100644 index 0000000..a643b78 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkFailure.java @@ -0,0 +1,47 @@ +package com.euonia.uow; + +/** + * Event raised when a unit of work fails — either due to an exception + * or an explicit rollback. + * + *

Listeners registered via {@link UnitOfWork#addFailedListener} + * receive instances of this class.

+ * + * @see UnitOfWorkEvent + */ +public class UnitOfWorkFailure extends UnitOfWorkEvent { + private final Throwable exception; + private final boolean rollback; + + /** + * Creates a new failure event. + * + * @param unitOfWork the unit of work that failed + * @param exception the exception that caused the failure, may be {@code null} + * @param rollback whether a rollback was triggered + */ + public UnitOfWorkFailure(UnitOfWork unitOfWork, Throwable exception, boolean rollback) { + super(unitOfWork); + this.exception = exception; + this.rollback = rollback; + } + + /** + * Returns the exception that caused the failure, or {@code null} + * if the failure was triggered by an explicit rollback. + * + * @return the exception, or {@code null} + */ + public Throwable getException() { + return exception; + } + + /** + * Returns whether a rollback was performed. + * + * @return {@code true} if the unit of work was rolled back + */ + public boolean isRollback() { + return rollback; + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkHelper.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkHelper.java new file mode 100644 index 0000000..a343a25 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkHelper.java @@ -0,0 +1,89 @@ +package com.euonia.uow; + +import java.lang.reflect.Method; + +import com.euonia.uow.annotation.UnitOfWork; + +/** + * Static utility methods for introspecting unit-of-work annotations + * and marker interfaces on types and methods. + * + *

Used primarily by AOP interceptors and proxies to determine + * whether a given class or method should be wrapped in a unit of work.

+ */ +public final class UnitOfWorkHelper { + private UnitOfWorkHelper() { + } + + /** + * Returns {@code true} if the type has a unit-of-work annotation + * (class or method level) or implements {@link UnitOfWorkEnabled}. + * + * @param implementationType the type to inspect + * @return {@code true} if the type requires a unit of work + */ + public static boolean isUnitOfWorkType(Class implementationType) { + if (implementationType == null) { + throw new IllegalArgumentException("implementationType"); + } + + if (hasUnitOfWorkAnnotation(implementationType) || anyMethodHasUnitOfWorkAnnotation(implementationType)) { + return true; + } + + return UnitOfWorkEnabled.class.isAssignableFrom(implementationType); + } + + /** + * Returns {@code true} if the method has an active (non-disabled) + * {@link UnitOfWork} annotation. + * + * @param method the method to inspect + * @return {@code true} if the method is annotated with {@code @UnitOfWork} + */ + public static boolean isUnitOfWorkMethod(Method method) { + return getUnitOfWorkAnnotation(method) != null; + } + + /** + * Retrieves the active {@link UnitOfWork} annotation from a method + * or its declaring class, returning {@code null} if the annotation + * is absent or {@link UnitOfWork#disabled() disabled}. + * + * @param method the method to inspect + * @return the annotation, or {@code null} + */ + public static UnitOfWork getUnitOfWorkAnnotation(Method method) { + if (method == null) { + throw new IllegalArgumentException("method"); + } + + UnitOfWork annotation = method.getAnnotation(UnitOfWork.class); + if (annotation != null) { + return annotation.disabled() ? null : annotation; + } + + Class declaringClass = method.getDeclaringClass(); + annotation = declaringClass.getAnnotation(UnitOfWork.class); + if (annotation != null) { + return annotation.disabled() ? null : annotation; + } + + return null; + } + + private static boolean anyMethodHasUnitOfWorkAnnotation(Class implementationType) { + for (Method method : implementationType.getDeclaredMethods()) { + if (method.isAnnotationPresent(UnitOfWork.class)) { + return true; + } + } + + return false; + } + + private static boolean hasUnitOfWorkAnnotation(Class implementationType) { + UnitOfWork annotation = implementationType.getAnnotation(UnitOfWork.class); + return annotation != null && !annotation.disabled(); + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkIsolationLevel.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkIsolationLevel.java new file mode 100644 index 0000000..0586b79 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkIsolationLevel.java @@ -0,0 +1,52 @@ +package com.euonia.uow; + +import java.sql.Connection; + +/** + * Transaction isolation levels, compatible with JDBC isolation constants. + * + *
    + *
  • {@link #UNSPECIFIED} — use the underlying store's default
  • + *
  • {@link #READ_UNCOMMITTED} — dirty reads allowed
  • + *
  • {@link #READ_COMMITTED} — no dirty reads (most common)
  • + *
  • {@link #REPEATABLE_READ} — consistent reads within a transaction
  • + *
  • {@link #SERIALIZABLE} — strictest isolation
  • + *
+ * + * @see java.sql.Connection#TRANSACTION_NONE + * @see java.sql.Connection#TRANSACTION_READ_UNCOMMITTED + * @see java.sql.Connection#TRANSACTION_READ_COMMITTED + * @see java.sql.Connection#TRANSACTION_REPEATABLE_READ + * @see java.sql.Connection#TRANSACTION_SERIALIZABLE + */ +public enum UnitOfWorkIsolationLevel { + /** Use the underlying store's default isolation level. */ + UNSPECIFIED(Connection.TRANSACTION_NONE), + /** Chaos isolation — maps to {@link Connection#TRANSACTION_NONE}. */ + CHAOS(Connection.TRANSACTION_NONE), + /** Dirty reads, non-repeatable reads, and phantom reads may occur. */ + READ_UNCOMMITTED(Connection.TRANSACTION_READ_UNCOMMITTED), + /** Prevents dirty reads; non-repeatable and phantom reads may occur. */ + READ_COMMITTED(Connection.TRANSACTION_READ_COMMITTED), + /** Prevents dirty and non-repeatable reads; phantom reads may occur. */ + REPEATABLE_READ(Connection.TRANSACTION_REPEATABLE_READ), + /** Snapshot isolation — maps to {@link Connection#TRANSACTION_REPEATABLE_READ}. */ + SNAPSHOT(Connection.TRANSACTION_REPEATABLE_READ), + /** Prevents dirty reads, non-repeatable reads, and phantom reads. */ + SERIALIZABLE(Connection.TRANSACTION_SERIALIZABLE); + + private final int jdbcIsolationLevel; + + UnitOfWorkIsolationLevel(int jdbcIsolationLevel) { + this.jdbcIsolationLevel = jdbcIsolationLevel; + } + + /** + * Converts this isolation level to the corresponding JDBC constant. + * + * @return a {@link Connection} transaction isolation constant + */ + public int toJdbcIsolationLevel() { + return jdbcIsolationLevel; + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkManager.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkManager.java new file mode 100644 index 0000000..d8d3f21 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkManager.java @@ -0,0 +1,95 @@ +package com.euonia.uow; + +import java.util.Objects; + +/** + * Entry point for creating and managing units of work. + * + *

Each manager holds a default set of {@link UnitOfWorkOptions} and + * a {@link UnitOfWorkAccessor} for tracking the ambient unit of work on + * the current thread.

+ * + *
{@code
+ * UnitOfWorkManager manager = new UnitOfWorkManager();
+ * try (UnitOfWork uow = manager.begin(new UnitOfWorkOptions(true), false)) {
+ *     uow.addContext("db", new JdbcTransactionContext(connection));
+ *     // ... business logic ...
+ *     uow.completeAsync().toCompletableFuture().join();
+ * }
+ * }
+ * + * @see UnitOfWork + * @see ChildUnitOfWork + */ +public class UnitOfWorkManager { + private final UnitOfWorkAccessor accessor; + private final UnitOfWorkOptions defaultOptions; + + /** Creates a manager with default options and a new accessor. */ + public UnitOfWorkManager() { + this(new UnitOfWorkAccessor(), new UnitOfWorkOptions()); + } + + /** + * Creates a manager with the given accessor and default options. + * + * @param accessor the accessor for tracking the current unit of work + * @param defaultOptions the default options to apply to new units of work + */ + public UnitOfWorkManager(UnitOfWorkAccessor accessor, UnitOfWorkOptions defaultOptions) { + this.accessor = Objects.requireNonNull(accessor, "accessor"); + this.defaultOptions = defaultOptions == null ? new UnitOfWorkOptions() : defaultOptions; + } + + /** + * Returns the unit of work associated with the current thread, + * or {@code null} if none is active. + * + * @return the current unit of work, or {@code null} + */ + public UnitOfWork getCurrent() { + return accessor.getCurrentUnitOfWork(); + } + + /** + * Begins a new unit of work with the given options. + * + *

If a unit of work is already active on the current thread and + * {@code requiresNew} is {@code false}, a {@link ChildUnitOfWork} + * that delegates to the existing unit is returned. Otherwise a new + * top-level unit is created and set as the ambient unit of work.

+ * + * @param options the options for the unit of work + * @param requiresNew if {@code true}, always creates a new top-level unit + * @return the unit of work (must be closed via {@link UnitOfWork#close()}) + */ + public UnitOfWork begin(UnitOfWorkOptions options, boolean requiresNew) { + if (options == null) { + throw new IllegalArgumentException("options"); + } + + UnitOfWork current = getCurrent(); + if (current != null && !requiresNew) { + return new ChildUnitOfWork(current); + } + + UnitOfWork unitOfWork = new UnitOfWork(defaultOptions); + unitOfWork.initialize(options); + unitOfWork.setOuter(current); + accessor.setCurrentUnitOfWork(unitOfWork); + unitOfWork.addDisposedListener(event -> accessor.setCurrentUnitOfWork(current)); + return unitOfWork; + } + + /** + * Convenience method for beginning a unit of work with a + * transactional flag. + * + * @param transactional whether the unit of work is transactional + * @param requiresNew if {@code true}, always creates a new top-level unit + * @return the unit of work + */ + public UnitOfWork begin(boolean transactional, boolean requiresNew) { + return begin(new UnitOfWorkOptions(transactional), requiresNew); + } +} diff --git a/uow/src/main/java/com/euonia/uow/UnitOfWorkOptions.java b/uow/src/main/java/com/euonia/uow/UnitOfWorkOptions.java new file mode 100644 index 0000000..a743898 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/UnitOfWorkOptions.java @@ -0,0 +1,106 @@ +package com.euonia.uow; + +import java.time.Duration; + +/** + * Configuration options for a unit of work. + * + *

Options control transactional behavior, isolation level, and timeout. + * Use {@link #normalize(UnitOfWorkOptions)} to merge with a set of defaults.

+ * + * @see UnitOfWork + * @see UnitOfWorkManager + */ +public class UnitOfWorkOptions { + private boolean transactional; + private UnitOfWorkIsolationLevel isolationLevel; + private Duration timeout; + + /** Creates default, non-transactional options. */ + public UnitOfWorkOptions() { + } + + /** + * Creates options with the given transactional flag. + * + * @param transactional whether the unit of work is transactional + */ + public UnitOfWorkOptions(boolean transactional) { + this.transactional = transactional; + } + + /** + * Creates fully-specified options. + * + * @param transactional whether the unit of work is transactional + * @param isolationLevel the transaction isolation level, or {@code null} + * @param timeout the transaction timeout, or {@code null} + */ + public UnitOfWorkOptions(boolean transactional, UnitOfWorkIsolationLevel isolationLevel, Duration timeout) { + this.transactional = transactional; + this.isolationLevel = isolationLevel; + this.timeout = timeout; + } + + /** + * Returns whether the unit of work should be transactional. + * + * @return {@code true} if transactional + */ + public boolean isTransactional() { + return transactional; + } + + public void setTransactional(boolean transactional) { + this.transactional = transactional; + } + + /** + * Returns the transaction isolation level, or {@code null} if not set. + * + * @return the isolation level, or {@code null} + */ + public UnitOfWorkIsolationLevel getIsolationLevel() { + return isolationLevel; + } + + public void setIsolationLevel(UnitOfWorkIsolationLevel isolationLevel) { + this.isolationLevel = isolationLevel; + } + + /** + * Returns the transaction timeout, or {@code null} if not set. + * + * @return the timeout, or {@code null} + */ + public Duration getTimeout() { + return timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + /** + * Merges the given options with this instance, using this instance's + * values as defaults for any {@code null} fields. + * + * @param options the options to normalize (must not be {@code null}) + * @return the normalized options object (the same instance) + */ + public UnitOfWorkOptions normalize(UnitOfWorkOptions options) { + if (options == null) { + throw new IllegalArgumentException("options"); + } + + if (options.isolationLevel == null) { + options.isolationLevel = isolationLevel; + } + + if (options.timeout == null) { + options.timeout = timeout; + } + + return options; + } +} diff --git a/uow/src/main/java/com/euonia/uow/annotation/UnitOfWork.java b/uow/src/main/java/com/euonia/uow/annotation/UnitOfWork.java new file mode 100644 index 0000000..4944801 --- /dev/null +++ b/uow/src/main/java/com/euonia/uow/annotation/UnitOfWork.java @@ -0,0 +1,34 @@ +package com.euonia.uow.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a type or method as requiring a unit of work. + * + *

When applied to a class, all methods are wrapped in a unit of work. + * When applied to a method, only that specific method is wrapped. + * + *

{@code
+ * @UnitOfWork
+ * public class OrderService { ... }
+ *
+ * @UnitOfWork(disabled = true)
+ * public void readOnlyQuery() { ... }
+ * }
+ * + * @see com.euonia.uow.UnitOfWork + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface UnitOfWork { + /** + * When {@code true}, the unit of work is suppressed for the annotated + * element even if a parent scope declares it. + * + * @return whether the unit of work is disabled + */ + boolean disabled() default false; +} diff --git a/uow/src/test/java/com/euonia/uow/UnitOfWorkManagerTest.java b/uow/src/test/java/com/euonia/uow/UnitOfWorkManagerTest.java new file mode 100644 index 0000000..ce58f3d --- /dev/null +++ b/uow/src/test/java/com/euonia/uow/UnitOfWorkManagerTest.java @@ -0,0 +1,34 @@ +package com.euonia.uow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import org.junit.jupiter.api.Test; + +class UnitOfWorkManagerTest { + @Test + void beginShouldSetAndRestoreCurrentUnitOfWork() { + UnitOfWorkAccessor accessor = new UnitOfWorkAccessor(); + UnitOfWorkManager manager = new UnitOfWorkManager(accessor, new UnitOfWorkOptions()); + + try (UnitOfWork unitOfWork = manager.begin(new UnitOfWorkOptions(true), true)) { + assertSame(unitOfWork, manager.getCurrent()); + } + + assertSame(null, manager.getCurrent()); + } + + @Test + void beginWithoutRequiresNewShouldReturnChildWrapper() { + UnitOfWorkAccessor accessor = new UnitOfWorkAccessor(); + UnitOfWorkManager manager = new UnitOfWorkManager(accessor, new UnitOfWorkOptions()); + + try (UnitOfWork parent = manager.begin(new UnitOfWorkOptions(true), true)) { + UnitOfWork child = manager.begin(new UnitOfWorkOptions(false), false); + + assertInstanceOf(ChildUnitOfWork.class, child); + assertEquals(parent.getItems(), child.getItems()); + assertEquals(parent.getContexts(), child.getContexts()); + } + } +} diff --git a/uow/src/test/java/com/euonia/uow/UnitOfWorkOptionsTest.java b/uow/src/test/java/com/euonia/uow/UnitOfWorkOptionsTest.java new file mode 100644 index 0000000..59ad832 --- /dev/null +++ b/uow/src/test/java/com/euonia/uow/UnitOfWorkOptionsTest.java @@ -0,0 +1,21 @@ +package com.euonia.uow; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +class UnitOfWorkOptionsTest { + @Test + void normalizeShouldFillMissingValues() { + UnitOfWorkOptions defaults = new UnitOfWorkOptions(true, UnitOfWorkIsolationLevel.READ_COMMITTED, Duration.ofSeconds(30)); + UnitOfWorkOptions options = new UnitOfWorkOptions(false, null, null); + + UnitOfWorkOptions normalized = defaults.normalize(options); + + assertEquals(false, normalized.isTransactional()); + assertEquals(UnitOfWorkIsolationLevel.READ_COMMITTED, normalized.getIsolationLevel()); + assertEquals(Duration.ofSeconds(30), normalized.getTimeout()); + } +} \ No newline at end of file diff --git a/uow/src/test/java/com/euonia/uow/UnitOfWorkTest.java b/uow/src/test/java/com/euonia/uow/UnitOfWorkTest.java new file mode 100644 index 0000000..352a20d --- /dev/null +++ b/uow/src/test/java/com/euonia/uow/UnitOfWorkTest.java @@ -0,0 +1,113 @@ +package com.euonia.uow; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + +class UnitOfWorkTest { + @Test + void addAndFindContextShouldWork() { + try (UnitOfWork unitOfWork = new UnitOfWork(new UnitOfWorkOptions(false, UnitOfWorkIsolationLevel.READ_COMMITTED, Duration.ofSeconds(1)))) { + unitOfWork.initialize(new UnitOfWorkOptions(true)); + + TestContext context = new TestContext(); + unitOfWork.addContext("primary", context); + + assertEquals(context, unitOfWork.findContext("primary")); + assertEquals(context, unitOfWork.getOrAddContext("primary", TestContext::new)); + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> { + unitOfWork.addContext("primary", new TestContext()); + }); + assertEquals("This unit of work already contains a context with the key: primary", exception.getMessage()); + } + } + + @Test + void completeShouldInvokeHandlersAndListeners() { + try (UnitOfWork unitOfWork = new UnitOfWork()) { + unitOfWork.initialize(new UnitOfWorkOptions(true)); + + AtomicBoolean completed = new AtomicBoolean(false); + AtomicBoolean handlerCalled = new AtomicBoolean(false); + unitOfWork.addCompletedListener(event -> completed.set(event.getUnitOfWork() == unitOfWork)); + unitOfWork.onCompleted(() -> { + handlerCalled.set(true); + return CompletableFuture.completedFuture(null); + }); + + unitOfWork.completeAsync().toCompletableFuture().join(); + + assertTrue(unitOfWork.isCompleted()); + assertTrue(completed.get()); + assertTrue(handlerCalled.get()); + } + } + + @Test + void rollbackShouldPreventCompletion() { + try (UnitOfWork unitOfWork = new UnitOfWork()) { + unitOfWork.initialize(new UnitOfWorkOptions(true)); + + unitOfWork.rollbackAsync().toCompletableFuture().join(); + unitOfWork.completeAsync().toCompletableFuture().join(); + + assertFalse(unitOfWork.isCompleted()); + } + } + + @Test + void closeShouldEmitFailureWhenNotCompleted() { + try (UnitOfWork unitOfWork = new UnitOfWork()) { + unitOfWork.initialize(new UnitOfWorkOptions(true)); + + AtomicBoolean failed = new AtomicBoolean(false); + AtomicBoolean disposed = new AtomicBoolean(false); + unitOfWork.addFailedListener(event -> failed.set(event.getUnitOfWork() == unitOfWork)); + unitOfWork.addDisposedListener(event -> disposed.set(event.getUnitOfWork() == unitOfWork)); + + unitOfWork.close(); + + assertTrue(unitOfWork.isDisposed()); + assertTrue(failed.get()); + assertTrue(disposed.get()); + } + } + + @Test + void completeShouldFailWhenCalledTwice() { + try (UnitOfWork unitOfWork = new UnitOfWork()) { + unitOfWork.initialize(new UnitOfWorkOptions(true)); + + unitOfWork.completeAsync().toCompletableFuture().join(); + + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> { + unitOfWork.completeAsync().toCompletableFuture().join(); + }); + assertEquals("Completion has already been requested for this unit of work.", exception.getMessage()); + } + } + + private static final class TestContext implements UnitOfWorkContext { + @Override + public CompletionStage saveChangesAsync() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage commitAsync() { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage rollbackAsync() { + return CompletableFuture.completedFuture(null); + } + } +}