This is another example of Xorcery using commands/aggregates, including JAX-RS integration, along with a simple forum application domain example (posts with comments). It now shows the full cycle of GET, submit form, parse/process command, turn into events, project into Neo4j, query updates from Neo4j, and render with updated data.
Setup
We will use the same dependencies from our first Greeter example, so we will only add those that are additional and necessary for this example.
<!-- Domain model and projection -->
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-domainevents-publisher</artifactId>
</dependency>
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-domainevents-neo4jprojection</artifactId>
</dependency>
These dependencies are necessary for the projection to neo4j.
<!-- REST API -->
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-jsonapi-server-neo4j</artifactId>
</dependency>
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-domainevents-jsonapi</artifactId>
</dependency>
We add these to the REST-API dependencies to support domain events and projection to neo4j.
<!-- Integration -->
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-dns-registration</artifactId>
</dependency>
In the integration dependencies we only add this for registration in the DNS server.
<!-- Logging -->
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-log4j</artifactId>
</dependency>
We added the logging dependency for Xorcery to be able to see the messages that Xorcery publishes in the log.
<!-- These features can be extracted out into their own service -->
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-jwt-server</artifactId>
</dependency>
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-eventstore</artifactId>
</dependency>
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-opensearch</artifactId>
</dependency>
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-certificates-ca</artifactId>
</dependency>
<dependency>
<groupId>com.exoreaction.xorcery</groupId>
<artifactId>xorcery-dns-server</artifactId>
</dependency>
Finally, we publish the dependencies that allow us to work with a JWT server, an event store, an open search, and a DNS server. What makes Xorcery so special is that it establishes an almost real setup for working with microservices.
In the src/main/docker folder, you will find a docker-compose.yml that starts precisely these dependent services.
version: "3.9"
services:
exmples-forum-eventstore:
image: eventstore/eventstore:23.6.0-buster-slim
environment:
- EVENTSTORE_CLUSTER_SIZE=1
- EVENTSTORE_RUN_PROJECTIONS=All
- EVENTSTORE_START_STANDARD_PROJECTIONS=true
- EVENTSTORE_EXT_TCP_PORT=1113
- EVENTSTORE_HTTP_PORT=2113
- EVENTSTORE_INSECURE=true
- EVENTSTORE_ENABLE_EXTERNAL_TCP=true
- EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true
- EVENTSTORE_MAX_APPEND_SIZE=8388608
deploy:
resources:
limits:
cpus: "2"
memory: 2G
reservations:
cpus: "1"
memory: 1G
ports:
- "1113:1113"
- "2113:2113"
volumes:
- type: volume
source: eventstore-volume-data
target: /var/lib/eventstore
- type: volume
source: eventstore-volume-logs
target: /var/log/eventstore
exmples-forum-opensearch:
image: opensearchproject/opensearch:2.11.0
environment:
- cluster.name=opensearch-cluster
- node.name=opensearch-node1
- bootstrap.memory_lock=true # along with the memlock settings below, disables swapping
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" # minimum and maximum Java heap size, recommend setting both to 50% of system RAM
- "DISABLE_INSTALL_DEMO_CONFIG=true" # disables execution of install_demo_configuration.sh bundled with security plugin, which installs demo certificates and security configurations to OpenSearch
- "DISABLE_SECURITY_PLUGIN=true" # disables security plugin entirely in OpenSearch by setting plugins.security.disabled: true in opensearch.yml
- "discovery.type=single-node" # disables bootstrap checks that are enabled when network.host is set to a non-loopback address
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536 # maximum number of open files for the OpenSearch user, set to at least 65536 on modern systems
hard: 65536
volumes:
- opensearch-data1:/usr/share/opensearch/data
ports:
- 9200:9200
- 9600:9600 # required for Performance Analyzer
exmples-forum-opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:2.11.0
ports:
- 5601:5601
expose:
- "5601"
environment:
- 'OPENSEARCH_HOSTS=["http://exmples-forum-opensearch:9200"]'
- "DISABLE_SECURITY_DASHBOARDS_PLUGIN=true" # disables security dashboards plugin in OpenSearch Dashboards
volumes:
eventstore-volume-data:
eventstore-volume-logs:
opensearch-data1:
networks:
bridge:
driver: bridge
Note: You have to have docker desktop installed and previously run this docker-compose and then run it in the Main class of the project.
Now we add the Xorcery configuration and the Neo4j framework of POST and COMMENTS.
In this project, we can see the configuration of logging, rest API resources, certificates, dns client configuration, and jwt server:
application.name: "forum"
instance.home: "{{ SYSTEM.jpackage_app-path ? jpackage.app | SYSTEM.user_dir}}"
jpackage.app: "{{ SYSTEM.jpackage_app-path }}/../../lib/app"
# So that we can generate a SSL certificate for the local hostname. Replace with whatever domain name you actually use
instance.domain: local
# Add local convenience names for your own computer into the SSL cert
certificates:
dnsNames:
- localhost
- "{{ instance.host }}"
ipAddresses:
- 127.0.0.1
- "{{ instance.ip }}"
# REST API resources
jersey.server.register:
- com.exoreaction.xorcery.examples.forum.resources.api.CommentResource
- com.exoreaction.xorcery.examples.forum.resources.api.ForumResource
- com.exoreaction.xorcery.examples.forum.resources.api.PostCommentsResource
- com.exoreaction.xorcery.examples.forum.resources.api.PostResource
- com.exoreaction.xorcery.examples.forum.resources.api.PostsResource
dns.client.search:
- xorcery.test
dns.client.hosts:
_certificates._sub._https._tcp : "https://127.0.0.1"
dns.client.nameServers:
- 127.0.0.1:8853
jetty:
server:
http:
port: 8080
ssl:
port: 8443
security:
jwt:
issuers:
server.xorcery.test:
keys:
- kid: "2d3f1d1f-4038-4c01-beb7-97b260462ada"
alg: "ES256"
publicKey: "secret:MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEd7L6zz97U1MMaj9MSN325SZ15htR26mec0/1A0vt1b8Yfcu0QuiN9E4ijSfMRiof+B57P/hkrb+OuRSYLL854Q=="
# These features can be extracted into separate services
jwt.server.keys:
- kid: "2d3f1d1f-4038-4c01-beb7-97b260462ada"
alg: "ES256"
publicKey: "secret:MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEd7L6zz97U1MMaj9MSN325SZ15htR26mec0/1A0vt1b8Yfcu0QuiN9E4ijSfMRiof+B57P/hkrb+OuRSYLL854Q=="
privateKey: "secret:MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCCSHC362NTeBZYTkYGXK3vfRvoqQum+Uo6DFUDzvX7MuA=="
dns.server.port: 8853
# Log configuration
log4j2.Configuration:
name: Xorcery Example Forum
status: warn
thresholdFilter:
level: trace
appenders:
Console:
name: STDOUT
target: SYSTEM_OUT
PatternLayout:
Pattern: "%d [%t] %-5level %marker %c{1.}: %msg%n%throwable"
# Log4jPublisher:
# name: Log4jPublisher
# PatternLayout:
# Pattern: "%d [%t] %-5level %marker %c{1.}: %msg%n%throwable"
Loggers:
logger:
- name: org.apache.logging.log4j
level: debug
additivity: false
AppenderRef:
ref: STDOUT
- name: com.exoreaction.xorcery.core.Xorcery
level: debug
- name: com.exoreaction.xorcery.service
level: debug
- name: com.exoreaction.xorcery.dns
level: trace
Root:
level: info
AppenderRef:
- ref: STDOUT
# - ref: Log4jPublisher
We verify that the setup starts without problems by executing the Main class.
public class Main {
public static void main(String[] args ) throws Exception
{
System.exit(new CommandLine(new com.exoreaction.xorcery.runner.Main()).execute(args));
}
}
If everything is fine this will be the result.
We are done with the setup.
Forum Service
We register the ForumService and inject the configuration and service resource objects.
@Service(name="forum")@RunLevel(20)public class ForumService {@Injectpublic ForumService(Configuration configuration, ServiceResourceObjects serviceResourceObjects) {serviceResourceObjects.add(new ServiceResourceObject.Builder(new InstanceConfiguration(configuration.getConfiguration("instance")), "forum").version("1.0.0").attribute("domain", "forum").api("forum", "api/forum").build());}}
Model
package com.exoreaction.xorcery.domainevents.helpers.model;public interface CommonModel {public static enum Entity {id,aggregateId,externalId,createdOn,lastUpdatedOn;private Entity() {}}public static enum Label {Entity,Aggregate;private Label() {}}}
ForumModel
package com.exoreaction.xorcery.examples.forum.model;
import com.exoreaction.xorcery.domainevents.helpers.model.CommonModel;
public interface ForumModel
extends CommonModel {
enum Label {
Post,
Comment
}
enum Relationship {
PostComments
}
enum Post {
title,
body,
is_comments_enabled
}
enum Comment {
body
}
}
Now we create the model for Post and Comments, they both inherit from EntityModel:
package com.exoreaction.xorcery.domainevents.helpers.model;
import com.exoreaction.xorcery.domainevents.helpers.model.CommonModel.Entity;
public interface EntityModel extends Model { // Model inherit from JsonElement
default String getId() {
return (String)this.getString(Entity.id).orElse((Object)null);
}
default String getAggregateId() {
return (String)this.getString(Entity.aggregateId).orElse((Object)null);
}
}
PostModel
public record PostModel(ObjectNode json)
implements EntityModel {
public String getTitle() {
return getString(ForumModel.Post.title).orElse("");
}
public String getBody() {
return getString(ForumModel.Post.body).orElse("");
}
}
CommentsModel
public record CommentModel(ObjectNode json)
implements EntityModel {
public String getBody() {
return getString(ForumModel.Comment.body).orElse("");
}
}
Finally, the records that use the neo4j client to obtain information from the posts or comments.
Posts
public record Posts(GraphDatabase db) {
private static final String POSTS = MessageFormat.format(
"MATCH ({0}:{0}) WITH {0}, {0} as {1}",
ForumModel.Label.Post, CommonModel.Label.Entity);
private final static BiConsumer<GraphQuery, StringBuilder> clauses = where()
.parameter(CommonModel.Entity.id, String.class, "Post.id=$entity_id");
public GraphQuery posts() {
return db.query(POSTS).where(clauses);
}
}
Comments
public record Comments(GraphDatabase db) {
private static final String COMMENTS = MessageFormat.format(
"MATCH ({0}:{0}) WITH {0}, {0} as {1}",
ForumModel.Label.Comment, CommonModel.Label.Entity);
private final static BiConsumer<GraphQuery, StringBuilder> clauses = where()
.parameter(CommonModel.Entity.id, String.class, "Comment.id=$entity_id");
private static final String POST_COMMENTS = MessageFormat.format(
"MATCH ({0}:{0})-[:{1}]->({2}:{2}) WITH {2}, {2} as {3}",
ForumModel.Label.Post, ForumModel.Relationship.PostComments, ForumModel.Label.Comment, CommonModel.Label.Entity);
private final static BiConsumer<GraphQuery, StringBuilder> byPostClauses = where()
.parameter(CommonModel.Entity.id, String.class, "Post.id=$entity_id");
public GraphQuery comments() {
return db.query(COMMENTS).where(clauses);
}
public GraphQuery commentsByPost(String postId)
{
return db.query(POST_COMMENTS).where(byPostClauses).parameter(CommonModel.Entity.id, postId);
}
}
Entities
PostEntity
public class PostEntity
extends Entity<PostEntity.PostSnapshot> {
@Create
public record CreatePost(String id, String title, String body)
implements Command {
}
@Update
public record UpdatePost(String id, String title, String body)
implements Command {
}
public static class PostSnapshot
implements EntitySnapshot {
public String title;
public String body;
}
public void handle(CreatePost command) {
add(event("createdpost")
.created("Post", command.id)
.attribute("title", command.title)
.attribute("body", command.body)
.build());
}
public void handle(UpdatePost command) {
add(event("updatedpost")
.updated("Post", command.id)
.attribute("title", command.title)
.attribute("body", command.body)
.build());
}
}
CommentEntity
public class CommentEntity
extends Entity<CommentEntity.CommentSnapshot> {
@Create
public record AddComment(String id, String body)
implements Command {
}
@Update
public record UpdateComment(String id, String body)
implements Command {
}
@Delete
public record RemoveComment(String id)
implements Command {
}
public static class CommentSnapshot
implements EntitySnapshot {
public String body;
}
public void handle(AddComment command) {
add(event("addedcomment")
.created("Comment", command.id)
.attribute("body", command.body)
.addedRelationship("PostComments", "Post", metadata.getAggregateId())
.build());
}
public void handle(UpdateComment command) {
if (snapshot.body.equals(command.body))
return;
add(event("updatedcomment")
.updated("Comment", command.id)
.attribute("body", command.body)
.build());
}
public void handle(RemoveComment command) {
add(event("removedcomment").deleted("Comment", command.id));
}
}
ForumApplication
@Service(name="forum")public class ForumApplication {private static final Logger logger = LogManager.getLogger(ForumApplication.class);private final DomainEventPublisher domainEventPublisher;private final Neo4jEntitySnapshotLoader snapshotLoader;private final Supplier<PostEntity> postEntitySupplier;private final Supplier<CommentEntity> commentEntitySupplier;@Injectpublic ForumApplication(DomainEventPublisher domainEventPublisher,GraphDatabase database,ServiceLocator serviceLocator) {this.domainEventPublisher = domainEventPublisher;this.snapshotLoader = new Neo4jEntitySnapshotLoader(database);postEntitySupplier = () -> serviceLocator.createAndInitialize(PostEntity.class);commentEntitySupplier = () -> serviceLocator.createAndInitialize(CommentEntity.class);}public PostsContext posts() {return new PostsContext(this, postEntitySupplier);}
public <T extends EntitySnapshot> CompletionStage<Metadata> handle(Entity<T> entity, DomainEventMetadata metadata, Command command) {try {DomainEventMetadata domainMetadata = new DomainEventMetadata.Builder(metadata.context()).domain("forum").commandName(command.getClass()).build();T snapshot;if (Command.isCreate(command.getClass())) {// Should failtry {snapshotLoader.load(domainMetadata, command.id(), entity);return CompletableFuture.failedStage(new BadRequestException("Entity already exists"));} catch (Exception e) {// Good!Class<?> snapshotClass = (Class<?>) ((ParameterizedType) entity.getClass().getGenericSuperclass()).getActualTypeArguments()[0];snapshot = (T)snapshotClass.getConstructor().newInstance();}} else {snapshot = snapshotLoader.load(domainMetadata, command.id(), entity);}List<DomainEvent> events = entity.handle(domainMetadata, snapshot, command);return domainEventPublisher.publish(new CommandEvents(metadata.context(), events));} catch (Throwable e) {return CompletableFuture.failedStage(e);}}}
REST API Resources
# REST API resourcesjersey.server.register:- com.exoreaction.xorcery.examples.forum.resources.api.CommentResource- com.exoreaction.xorcery.examples.forum.resources.api.ForumResource- com.exoreaction.xorcery.examples.forum.resources.api.PostCommentsResource- com.exoreaction.xorcery.examples.forum.resources.api.PostResource- com.exoreaction.xorcery.examples.forum.resources.api.PostsResource
0 comentarios:
Publicar un comentario