Creating a spring boot starter for event sourcing

This commit is contained in:
Kenny Bastani
2016-12-26 06:12:21 -05:00
parent e1dc702107
commit 6f022b8ee6
49 changed files with 1125 additions and 473 deletions

View File

@@ -17,7 +17,14 @@
<relativePath>../</relativePath>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<modules>
<module>spring-boot-starter-aws-lambda</module>
<module>spring-boot-starter-data-events</module>
</modules>
</project>

View File

@@ -0,0 +1,121 @@
# Spring Boot Starter Data Events
This starter project provides auto-configuration support classes for building event-driven Spring Data applications.
* Uses a familiar _Spring Data_ repository pattern for creating an `EventRepository<T, ID>`
* The `EventRepository` provides trait specific features for managing an event log that is attached to an existing domain entity
* Provides a set of event abstractions that can be extended to use any Spring Data repository (JPA, Mongo, Neo4j, Redis..)
* Provides an `EventService` bean that can be used to publish events to a _Spring Cloud Stream_ output channel
## Usage
In your Spring Boot project, add the starter project dependency to your class path. For Maven, add the following dependency to your `pom.xml`.
```xml
<dependencies>
<dependency>
<groupId>org.kbastani</groupId>
<artifactId>spring-boot-starter-data-events</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
...
</dependencies>
```
Next, configure your _Spring Cloud Stream_ output bindings. Add the following snippet to the `application.properties|yaml` file of your Spring Boot application. Replace the destination value with the name of your message channel for the event stream.
```yaml
spring:
cloud:
stream:
bindings:
output:
destination: payment
```
Next, you'll need to create a custom `Event` entity. The snippet below extends the provided `Event<T, E, ID>` interface. This example uses Spring Data JPA, but you can use any Spring Data project for implementing your event entities.
```java
@Entity
@EntityListeners(AuditingEntityListener.class)
public class PaymentEvent extends Event<Payment, PaymentEventType, Long> {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long eventId;
@Enumerated(EnumType.STRING)
private PaymentEventType type;
@OneToOne(cascade = CascadeType.MERGE, fetch = FetchType.LAZY)
@JsonIgnore
private Payment entity;
@CreatedDate
private Long createdAt;
@LastModifiedDate
private Long lastModified;
...
}
```
To start managing events you'll need to extend the `EventRepository<T, ID>` interface. The `PaymentEvent` is the JPA entity we defined in the last snippet.
```java
public interface PaymentEventRepository extends EventRepository<PaymentEvent, Long> {
}
```
That's it! You're ready to start sending domain events to the stream binding's output channel using the auto-configured `EventService`. The example snippet below shows how to create and append a new `PaymentEvent` to a `Payment` entity before publishing the event over AMQP to the configured event stream's output channel.
```java
@Service
public class PaymentService {
private final EventService<PaymentEvent, Long> eventService;
public PaymentController(EventService<PaymentEvent, Long> eventService) {
this.eventService = eventService;
}
public PaymentEvent appendCreateEvent(Payment payment) {
PaymentEvent paymentEvent = new PaymentEvent(PaymentEventType.PAYMENT_CREATED);
paymentEvent.setEntity(payment);
paymentEvent = eventService.save(event);
// Send the event to the Spring Cloud stream binding
eventService.sendAsync(paymentEvent);
}
...
}
```
A default `EventController` is also provided with the starter project. The `EventController` provides a basic REST API with hypermedia resource support for managing the `Event` log of a domain entity over HTTP. The following cURL snippet gets the `PaymentEvent` we created in the last example from the `EventController`.
```bash
curl -X GET "http://localhost:8082/v1/events/1"
```
Response:
```json
{
"eventId": 1,
"type": "PAYMENT_CREATED",
"createdAt": 1482749707006,
"lastModified": 1482749707006,
"_links": {
"self": {
"href": "http://localhost:8082/v1/events/1"
},
"payment": {
"href": "http://localhost:8082/v1/payments/1"
}
}
}
```
In the snippet above we can see the `EventController` responded with a `hal+json` formatted resource. Since the `PaymentEvent` has a reference to the `Payment` entity, we see a _payment_ link is available to fetch the related resource.

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>spring-boot-starter-data-events</artifactId>
<packaging>jar</packaging>
<parent>
<groupId>org.kbastani</groupId>
<artifactId>spring-boot-starters</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,69 @@
package demo.event;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.core.EvoInflectorRelProvider;
import sun.reflect.generics.reflectiveObjects.ParameterizedTypeImpl;
import java.io.Serializable;
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
/**
* Abstract implementation of the {@link Event} entity.
*
* @param <T> is the entity this {@link Event} applies to
* @param <E> is the type of event, typically an {@link Enum}
* @param <ID> is the unique identifier type used to persist the {@link Event}
* @author Kenny Bastani
* @see org.springframework.stereotype.Repository
* @see ResourceSupport
*/
public abstract class Event<T extends ResourceSupport, E, ID extends Serializable> extends ResourceSupport {
public Event() {
}
public abstract ID getEventId();
public abstract void setEventId(ID eventId);
public abstract E getType();
public abstract void setType(E type);
public abstract T getEntity();
public abstract void setEntity(T entity);
public abstract Long getCreatedAt();
public abstract void setCreatedAt(Long createdAt);
public abstract Long getLastModified();
public abstract void setLastModified(Long lastModified);
@Override
@SuppressWarnings("unchecked")
public List<Link> getLinks() {
List<Link> links = super.getLinks().stream().collect(Collectors.toList());
links.add(getId());
Class<T> clazz = (Class<T>) ((ParameterizedTypeImpl)
this.getClass().getGenericSuperclass()).getActualTypeArguments()[0];
links.add(getEntity().getId().withRel(new EvoInflectorRelProvider().getItemResourceRelFor(clazz)));
return links;
}
@Override
public String toString() {
return String.format("links: %s", getLinks().toString());
}
@Override
public Link getId() {
return linkTo(EventController.class).slash("events").slash(getEventId()).withSelfRel();
}
}

View File

@@ -0,0 +1,35 @@
package demo.event;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* This class auto-configures a {@link EventServiceImpl} bean.
*
* @author Kenny Bastani
*/
@Configuration
@ConditionalOnClass({ EventRepository.class, Source.class, RestTemplate.class })
@EnableConfigurationProperties(EventProperties.class)
public class EventAutoConfig {
private EventRepository eventRepository;
private Source source;
private RestTemplate restTemplate;
public EventAutoConfig(EventRepository eventRepository, Source source, RestTemplate restTemplate) {
this.eventRepository = eventRepository;
this.source = source;
this.restTemplate = restTemplate;
}
@SuppressWarnings("unchecked")
@Bean
public EventService eventService() {
return new EventServiceImpl(eventRepository, source, restTemplate);
}
}

View File

@@ -0,0 +1,45 @@
package demo.event;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.Serializable;
import java.util.Optional;
/**
* The default controller for managing {@link Event} entities.
*
* @author Kenny Bastani
*/
@RestController
@RequestMapping("/v1")
public class EventController<T extends Event, ID extends Serializable> {
private final EventService<T, Long> eventService;
public EventController(EventService<T, Long> eventService) {
this.eventService = eventService;
}
@PostMapping(path = "/events/{id}")
public ResponseEntity createEvent(@RequestBody T event, @PathVariable Long id) {
return Optional.ofNullable(eventService.save(id, event))
.map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
.orElseThrow(() -> new RuntimeException("Event creation failed"));
}
@PutMapping(path = "/events/{id}")
public ResponseEntity updateEvent(@RequestBody T event, @PathVariable Long id) {
return Optional.ofNullable(eventService.save(id, event))
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
.orElseThrow(() -> new RuntimeException("Event update failed"));
}
@GetMapping(path = "/events/{id}")
public ResponseEntity getEvent(@PathVariable Long id) {
return Optional.ofNullable(eventService.findOne(id))
.map(e -> new ResponseEntity<>(e, HttpStatus.OK))
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
}
}

View File

@@ -0,0 +1,25 @@
package demo.event;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "event")
public class EventProperties {
@NestedConfigurationProperty
private Props props;
public Props getProps() {
return props;
}
public void setProps(Props props) {
this.props = props;
}
public static class Props {
// TODO: Implement
}
}

View File

@@ -0,0 +1,21 @@
package demo.event;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.PagingAndSortingRepository;
import org.springframework.data.repository.query.Param;
import java.io.Serializable;
/**
* Extension of {@link PagingAndSortingRepository} to provide additional support for persisting event logs to entities.
*
* @author Kenny Bastani
* @see Event
* @see EventService
*/
@NoRepositoryBean
public interface EventRepository<E extends Event, ID extends Serializable> extends PagingAndSortingRepository<E, ID> {
Page<E> findEventsByEntityId(@Param("entityId") ID entityId, Pageable pageable);
}

View File

@@ -0,0 +1,72 @@
package demo.event;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.ResourceSupport;
import java.io.Serializable;
/**
* Service interface for managing {@link Event} entities.
*
* @author Kenny Bastani
* @see Event
* @see Events
* @see EventServiceImpl
*/
public interface EventService<T extends Event, ID extends Serializable> {
/**
* Raises a synchronous domain event. An {@link Event} will be applied to an entity through a chain of HTTP
* requests/responses.
*
* @param event
* @param links
* @return the applied {@link Event}
*/
<E extends ResourceSupport, S extends T> S send(S event, Link... links);
/**
* Raises an asynchronous domain event. An {@link Event} will be applied to an entity through a chain of AMQP
* messages.
*
* @param event
* @param links
* @return a flag indicating if the {@link Event} message was sent successfully
*/
<S extends T> Boolean sendAsync(S event, Link... links);
/**
* Saves a given event entity. Use the returned instance for further operations as the save operation might have
* changed the entity instance completely.
*
* @param event
* @return the saved event entity
*/
<S extends T> S save(S event);
/**
* Saves a given event entity. Use the returned instance for further operations as the save operation might have
* changed the entity instance completely. The {@link ID} parameter is the unique {@link Event} identifier.
*
* @param id
* @param event
* @return the saved event entity
*/
<S extends T> S save(ID id, S event);
/**
* Retrieves an {@link Event} entity by its id.
*
* @param id
* @return the {@link Event} entity with the given id or {@literal null} if none found
*/
<EID extends ID> T findOne(EID id);
/**
* Retrieves an entity's {@link Event}s by its id.
*
* @param entityId
* @return a {@link Events} containing a collection of {@link Event}s
*/
<E extends Events> E find(ID entityId);
}

View File

@@ -0,0 +1,80 @@
package demo.event;
import org.apache.log4j.Logger;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.data.domain.PageRequest;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.http.RequestEntity;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.web.client.RestTemplate;
import java.io.Serializable;
import java.net.URI;
/**
* Event service implementation of {@link EventService} for managing {@link Event} entities.
*
* @author Kenny Bastani
* @see Event
* @see Events
* @see EventService
*/
@SuppressWarnings("unchecked")
class EventServiceImpl<T extends Event, ID extends Serializable> implements EventService<T, ID> {
private static final Logger log = Logger.getLogger(EventServiceImpl.class);
private static final String EVENT_PROCESSOR_URL = "http://localhost:8083/v1/events";
private final EventRepository<T, ID> eventRepository;
private final Source eventStream;
private final RestTemplate restTemplate;
EventServiceImpl(EventRepository<T, ID> eventRepository, Source eventStream, RestTemplate restTemplate) {
this.eventRepository = eventRepository;
this.eventStream = eventStream;
this.restTemplate = restTemplate;
}
public <E extends ResourceSupport, S extends T> S send(S event, Link... links) {
// Assemble request to the event stream processor
RequestEntity<Resource<T>> requestEntity = RequestEntity.post(URI.create(EVENT_PROCESSOR_URL))
.contentType(MediaTypes.HAL_JSON).body(new Resource<T>(event), Resource.class);
try {
// Send the event to the event stream processor
E entity = (E) restTemplate.exchange(requestEntity, event.getEntity().getClass()).getBody();
// Set the applied entity reference to the event
event.setEntity(entity);
} catch (Exception ex) {
log.error(ex);
}
return event;
}
public <S extends T> Boolean sendAsync(S event, Link... links) {
return eventStream.output().send(MessageBuilder.withPayload(event).build());
}
public <S extends T> S save(S event) {
event = eventRepository.save(event);
return event;
}
public <S extends T> S save(ID id, S event) {
event.setEventId(id);
return save(event);
}
public <S extends ID> T findOne(S id) {
return eventRepository.findOne(id);
}
public <E extends Events> E find(ID entityId) {
return (E) new Events(entityId, eventRepository.findEventsByEntityId(entityId,
new PageRequest(0, Integer.MAX_VALUE)).getContent());
}
}

View File

@@ -0,0 +1,33 @@
package demo.event;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import java.io.Serializable;
import java.util.List;
/**
* General helper to easily create a wrapper for a collection of {@link Event} entities.
*
* @author Kenny Bastani
*/
public class Events<T extends ResourceSupport, E, ID extends Serializable> extends Resources<Event<T, E, ID>> {
private ID entityId;
public Events(ID entityId, List<Event<T, E, ID>> content) {
this(content);
this.entityId = entityId;
}
public Events(Iterable<Event<T, E, ID>> content, Link... links) {
super(content, links);
}
@JsonIgnore
public ID getEntityId() {
return entityId;
}
}

View File

@@ -0,0 +1,9 @@
{
"groups": [
{
"name": "event",
"type": "demo.event.EventProperties",
"sourceType": "demo.event.EventProperties"
}
]
}

View File

@@ -0,0 +1 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=demo.event.EventAutoConfig

View File

@@ -0,0 +1,39 @@
package demo.event;
import org.junit.After;
import org.junit.Test;
import org.springframework.boot.test.util.EnvironmentTestUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import static junit.framework.TestCase.assertNotNull;
public class ConfigurationTest {
private AnnotationConfigApplicationContext context;
@After
public void tearDown() {
if (this.context != null) {
this.context.close();
}
}
@Test
public void contextLoads() {
load(EmptyConfiguration.class);
assertNotNull(context);
}
@Configuration
static class EmptyConfiguration {
}
private void load(Class<?> config, String... environment) {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
EnvironmentTestUtils.addEnvironment(applicationContext, environment);
applicationContext.register(config);
applicationContext.refresh();
this.context = applicationContext;
}
}