This is a student project for the University of Applied Sciences Upper Austria.
Microservices for Books, Users, Orders, Rating, Analytics. Users and Books can be administered. User order and rate books. Orders and ratings can be analysed.
The client communicates with the respective services synchronously via an API gateway. The services themselves communicate asynchronously with events via spring cloud stream and a rabbitMQ broker. An eureka discovery service is used for service discovery. A configuration service is used to store the configurations in one repository and to be able to provide different configuration profiles for each service (e.g. develop, staging).
Clients should be able to:
- add users and addresses for users
- view users and addresses
- add, view and delete books
- add, view and cancel orders for books from users
- add, view and delete ratings for books from users
- view orders by rating, average order user, orders per day
A Eureka discovery service is implemented.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
@EnableEurekaServer
@SpringBootApplication
public class DiscoveryServiceApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServiceApplication.class, args);
}
}
server.port=8761
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
Each client must have an @EnableDiscoveryClient
Annotation, an spring.application.name
property
and the following dependency:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
Since all the files should be contained in this git repository the configuration service was configured to look for properties files on the classpath. Therefore it has to be started with the native spring boot profile. Alternatively a separate VCS repository can be used. The properties must have the name -.properties (e.g. sve.project.bookservice-develop.properties) to be distributed when services request properties for profiles. The configuration service is also a discovery client, so other services can find it. Therefore the discovery service cannot use the configuration service.
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
@EnableDiscoveryClient
@EnableConfigServer
@SpringBootApplication
public class ConfigurationServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigurationServerApplication.class, args);
}
}
server.port=8888
#spring.cloud.config.server.git.uri=https://some-git.com
spring.cloud.config.server.native.search-locations=classpath:/config/
Services can use the following properties to request configurations
spring.cloud.config.profile=develop
spring.cloud.config.uri=SVE.PROJECT.CONFIGURATIONSERVICE
spring.cloud.config.allow-override=true
spring.cloud.config.override-none=true
They also need the following dependency
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
An API gateway is used to centralise all incoming graphql requests. Also Hystrix is used to map error status codes to 503 (See Problems/GraphQL and Hystrix).
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@EnableHystrix
@EnableDiscoveryClient
@SpringBootApplication
public class GatewayServiceApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayServiceApplication.class, args);
}
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
//account
.route("account-graphql", r -> r
.path("/account/graphql")
.filters(f -> f
.rewritePath("^/account", "")
.hystrix(c -> c
.setName("fallbackCmd")
.setFallbackUri("forward:/hystrixfallback")))
.uri("lb://SVE.PROJECT.ACCOUNTSERVICE/graphql"))
//[...]
.build();
}
}
#gateway
spring.cloud.gateway.discovery.locator.enabled=true
spring.cloud.gateway.discovery.locator.lowerCaseServiceId=true
Spring cloud stream is used for event based asynchronous messaging between services. A RabbitMQ broker is used to handle the messaging.
A service must therefore have the following properties to connect to the rabbitMQ broker.
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
To send or receive messages with spring cloud stream, a service must declare bindings for a channel, where destination maps to the rabbitMQ exchange and group maps to one or more queues.
spring.cloud.stream.bindings.bookChannel.destination=book
spring.cloud.stream.bindings.bookChannel.group=bookOrderQueue, bookRatingQueue
spring.cloud.stream.bindings.bookChannel.content-type=application/json
To send messages an interface has been defined to use custom or multiple channels. This interface can be injected containing a message channel that provides a send method;
public interface EventSource {
@Output("userChannel")
MessageChannel userEvent();
//[...] other channels
}
@EnableBinding(EventSource.class)
public class Sender {
private final EventSource eventSource;
public Sender(EventSource eventSource) {
this.eventSource = eventSource;
}
public UserEvent sendCreateUser(User user) {
UserEvent userEvent = new UserEvent(user, UserEvent.Action.CREATE);
eventSource.userEvent().send(MessageBuilder.withPayload(userEvent).build());
return userEvent;
}
//[...] other events
}
To receive messages a similar interface can be defined.
public interface EventSink {
@Input("bookChannel")
SubscribableChannel bookEvent();
@Input("userChannel")
SubscribableChannel userEvent();
}
@EnableBinding(EventSink.class)
public class Receiver {
private final BookService bookService;
private final UserService userService;
public Receiver(BookService bookService, UserService userService) {
this.bookService = bookService;
this.userService = userService;
}
@StreamListener(value = "bookChannel")
public void receiveBook(BookEvent event) {
Book book = event.toBook();
bookService.saveBook(book);
}
//[...] other channels
}
GraphQL is used to provide a way to access and manipulate data from the outside. Each service must therefore provide a graphQL schema containing types, queries and mutations.
type Address{
id:ID!
country:String
zip:String
town:String
street:String
house:String
}
type User{
id:ID!
name: String
firstName: String
lastName: String
addresses: [Address]
}
type Query{
allUsers: [User]
allAddresses: [Address]
user(id:ID!): User
address(id:ID!): Address
hystrixDebug:Boolean
}
input AddressInput{
country:String
zip:String
town:String
street:String
house:String
}
type Mutation{
addUser(name: String, firstName: String, lastName:String) : User
addAddressToUser(userId:ID, address:AddressInput): Address
}
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-spring-boot-starter</artifactId>
<version>5.0.2</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphiql-spring-boot-starter</artifactId>
<version>5.0.2</version>
</dependency>
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-tools</artifactId>
<version>5.0.0</version>
</dependency>
Resolvers must be implemented according to the schema.
@Component
public class Mutation implements GraphQLMutationResolver {
//[...]
public User addUser(String name, String firstName, String lastName){
return userService.saveUser(name, firstName, lastName);
}
public Address addAddressToUser(Long userId, AddressInput address){
return addressService.addAddressToUser(userId, new Address(address));
}
}
To map Exceptions to GraphQL Errors, these exceptions must implement the GraphQLError interface. The stacktrace is ignored to hide server specific information from clients.
public class NotFoundException extends RuntimeException implements GraphQLError {
public NotFoundException(String message) {
super(message);
}
public NotFoundException(Long id, String type) {
super(type + " with ID:'" + id.toString() + "' not found.");
}
@Override
public List<SourceLocation> getLocations() {
return null;
}
@Override
public ErrorType getErrorType() {
return ErrorType.ValidationError;
}
@JsonIgnore
@Override
public StackTraceElement[] getStackTrace() {
return super.getStackTrace();
}
}
Account service handles user information (user, address). Book service handles book information (book, publisher, author). Ordering service handles orders of books by users (orderEntry, book, user). Rating service handles ratings of books by users (rating, book, user). These services are of course barebone and incomplete for business appliance (especially account and ordering). They can however be easily extended and are meant to show the architecture and the interaction of the services. They each are very similar in structure, therefore they are summarized here. Each of these services is a discovery client and a configuration client. They also declare streaming channels. Where account service and book service only have outgoing channels, whereas ordering service and rating service also have incoming channels. They all use hibernate and jpa to access their separate maria database and have a graphQL interface to access this data.
#data base
spring.datasource.url=jdbc:mysql://localhost:3308/accounts_db
spring.datasource.username=user
spring.datasource.password=pw
#Hibernate Configuration
hibernate.dialect=org.hibernate.dialect.H2Dialect
hibernate.hbm2ddl.auto=update
hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy
hibernate.show_sql=false
hibernate.format_sql=true
hibernate.enable_lazy_load_no_trans=true
This service is very similar to books-, ordering-, account- and rating-service. It however uses spring scheduling to calculate analytics results in a fixed interval and to save them to the database. This is necessary, because such calculations can require a large amount of time. Also a mongo database is used to store data.
@Scheduled(fixedRateString = "${analytics.update.rate}")
public void calculateAnalytics(){
AnalyticsResult result = new AnalyticsResult();
result.setAvgOrderPerUser(analyticsService.avgOrderPerUser());
result.setOrdersByRatings(analyticsService.orderByRating());
result.setOrdersPerDays(analyticsService.ordersPerDay());
result.setTimestamp(dateFormat.format(new Date()));
analyticsResultService.saveAnalyticsResult(result);
log.info("result calculated");
log.info(result.toString());
}
GraphQL maps each exception that is thrown in the service to an graphQL error. This error is contained in the graphQL response message. If an exception occurs on the service when a specific query or mutation is requested a Response with 200 OK status and error messages in the body is returned. I have found no way to configure hystrix on the API Gateway to check if the body contains an error message or to change the status of the response. Therefore each exception thrown on the service that does not implement GraphQLError is mapped to a "internal server error" - error message and a 200 OK status. Exceptions that implement GraphQLError are mapped to their specific message and also a 200 OK status. For this reason hystrix does not work with GraphQL.
To show this I implemented a hystrixDebug graphql-query, that throws a RuntimeException
, in the account service.
- Request:
GET localhost:8080/account/graphql?query={hystrixDebug}
- Response:
200 OK
{
"data": {
"hystrixDebug": null
},
"errors": [
{
"message": "Internal Server Error(s) while executing query",
"path": null,
"extensions": null
}
]
}
To show that hystrix usually works i have also implemented a TestController with binding /test/hystrix
on the gateway service.
This one also throws a RuntimeException
.
The gateway maps /hystrix
to /test/hystrix
. The fallback returns a 503 Service Unavailable.
- Request:
GET localhost:8080/hystrix
- Response:
503 Service Unavailable
service unavailable (fallback)
GET http://localhost:8080/account/graphql?query={allUsers{id}}
200 OK
{
"data": {
"allUsers": [
{
"id": "1"
},
{
"id": "2"
},
{
"id": "6"
},
{
"id": "7"
},
{
"id": "11"
},
{
"id": "12"
},
{
"id": "16"
},
{
"id": "17"
},
{
"id": "18"
},
{
"id": "19"
},
{
"id": "20"
},
{
"id": "21"
},
{
"id": "22"
},
{
"id": "23"
},
{
"id": "24"
},
{
"id": "25"
},
{
"id": "26"
}
]
}
}
GET http://localhost:8080/account/graphql?query={allAddresses{id}}
{
"data": {
"allAddresses": [
{
"id": "3"
},
{
"id": "4"
},
{
"id": "5"
},
{
"id": "8"
},
{
"id": "9"
},
{
"id": "10"
},
{
"id": "13"
},
{
"id": "14"
},
{
"id": "15"
}
]
}
}
user does not exist:
GET http://localhost:8080/account/graphql?query={user(id:4){id, name, firstName, lastName}}
{
"data": {
"user": null
},
"errors": [
{
"message": "Exception while fetching data (/user) : User with ID:'4' not found.",
"path": [
"user"
],
"exception": {
"cause": null,
"locations": null,
"errorType": "ValidationError",
"message": "User with ID:'4' not found.",
"path": null,
"extensions": null,
"localizedMessage": "User with ID:'4' not found.",
"suppressed": []
},
"locations": [
{
"line": 1,
"column": 2
}
],
"extensions": null,
"errorType": "DataFetchingException"
}
]
}
GET http://localhost:8080/account/graphql?query={user(id:2){id, name, firstName, lastName}}
{
"data": {
"user": {
"id": "2",
"name": "userName2",
"firstName": "Karl",
"lastName": "Karlsberger"
}
}
}
GET http://localhost:8080/account/graphql?query={address(id:3){id, country, zip, town, street, house}}
{
"data": {
"address": {
"id": "3",
"country": "austria",
"zip": "4664",
"town": "SteyermĂĽhl",
"street": "HauptstraĂźe",
"house": "1"
}
}
}
POST http://localhost:8080/account/graphql/
mutation {
addUser(name:"Hans123", firsName:"Hans", lastName:"Hansson"){
id
}
}
{
"data": {
"addUser": {
"id": "27"
}
}
}
Rating service:
received: User[id=27, name='Hans123', deleted=false]
Ordering service:
received: User[id=27, name='Hans123', deleted=false]
POST http://localhost:8080/account/graphql/
mutation {
addAddressToUser(userId:27, address:{country:"austria", zip:"4232", town:"Hagenberg", street:"softwarepark", house:"1"}){
id
}
}
{
"data": {
"addAddressToUser": {
"id": "28"
}
}
}
GET localhost:8080/analytics/graphql?query={orderByRating{rating, orders}}
{
"data": {
"orderByRating": [
{
"rating": 1,
"orders": 9
},
{
"rating": 3,
"orders": 9
},
{
"rating": 0,
"orders": 0
}
]
}
}
GET localhost:8080/analytics/graphql?query={avgOrderPerUser}
{
"data": {
"avgOrderPerUser": 6
}
}
GET localhost:8080/analytics/graphql?query={ordersPerDay{orders, day}}
{
"data": {
"ordersPerDay": [
{
"orders": 1,
"day": "2019-04-03"
},
{
"orders": 1,
"day": "2019-04-04"
},
{
"orders": 14,
"day": "2019-05-04"
}
]
}
}
GET localhost:8080/book/graphql?query={allBooks{id}}
{
"data": {
"allBooks": [
{
"id": "5"
},
{
"id": "6"
},
{
"id": "7"
},
{
"id": "8"
},
{
"id": "9"
},
{
"id": "10"
},
{
"id": "11"
},
{
"id": "12"
}
]
}
}
GET localhost:8080/book/graphql?query={book(id:6){name, author{firstName, lastName}, publisher{name}}}
{
"data": {
"book": {
"name": "n2",
"author": {
"firstName": "Fritz",
"lastName": "Fritzberger"
},
"publisher": {
"name": "Springer Verlag"
}
}
}
}
publisher does not exist
POST http://localhost:8080/book/graphql/
mutation {
saveBook(name:"Spring Framework for dummies", authorId:1, publisherId:2){id}
}
{
"data": {
"saveBook": null
},
"errors": [
{
"message": "Exception while fetching data (/saveBook) : Publisher with ID:'2' not found.",
"path": [
"saveBook"
],
"exception": {
"cause": null,
"locations": null,
"errorType": "ValidationError",
"message": "Publisher with ID:'2' not found.",
"path": null,
"extensions": null,
"localizedMessage": "Publisher with ID:'2' not found.",
"suppressed": []
},
"locations": [
{
"line": 2,
"column": 3
}
],
"extensions": null,
"errorType": "DataFetchingException"
}
]
}
POST http://localhost:8080/book/graphql/
mutation {
saveBook(name:"Spring Framework for dummies", authorId:2, publisherId:3){id}
}
{
"data": {
"saveBook": {
"id": "13"
}
}
}
Rating Serivce
received: Book[id=13, name='Spring Framework for dummies', deleted=false]
Ordering Service
received: Book[id=13, name='Spring Framework for dummies', deleted=false]
POST http://localhost:8080/book/graphql/
mutation {
deleteBook(id:13){id}
}
{
"data": {
"deleteBook": {
"id": "13"
}
}
}
Rating Serivce
received: Book[id=13, name='Spring Framework for dummies', deleted=true]
Ordering Service
received: Book[id=13, name='Spring Framework for dummies', deleted=true]
GET localhost:8080/order/graphql?query={allOrders{id}}
{
"data": {
"allOrders": [
{
"id": "1"
},
{
"id": "2"
},
{
"id": "3"
},
{
"id": "4"
},
{
"id": "5"
},
{
"id": "6"
},
{
"id": "7"
},
{
"id": "8"
},
{
"id": "9"
},
{
"id": "10"
},
{
"id": "11"
},
{
"id": "12"
},
{
"id": "13"
},
{
"id": "14"
},
{
"id": "15"
},
{
"id": "16"
},
{
"id": "17"
}
]
}
}
GET localhost:8080/order/graphql?query={orderEntry(id:1){book{id}, user{id}, date}}
{
"data": {
"orderEntry": {
"book": {
"id": "5"
},
"user": {
"id": "11"
},
"date": "2019-04-03"
}
}
}
book does not exist
POST http://localhost:8080/order/graphql/
mutation {
addOrder(date:"2019-06-26", userId:1, bookId:1){id}
}
{
"data": {
"addOrder": null
},
"errors": [
{
"message": "Exception while fetching data (/addOrder) : Book with ID:'1' not found.",
"path": [
"addOrder"
],
"exception": {
"cause": null,
"locations": null,
"errorType": "ValidationError",
"message": "Book with ID:'1' not found.",
"path": null,
"extensions": null,
"localizedMessage": "Book with ID:'1' not found.",
"suppressed": []
},
"locations": [
{
"line": 2,
"column": 3
}
],
"extensions": null,
"errorType": "DataFetchingException"
}
]
}
POST http://localhost:8080/order/graphql/
mutation {
addOrder(date:"2019-06-26", userId:11, bookId:5){id}
}
{
"data": {
"addOrder": {
"id": "18"
}
}
}
Analytics service:
received: OrderEvent[id=18, book=5, user=11, date='2019-06-26', action=CREATE]
POST http://localhost:8080/order/graphql/
mutation {
cancleOrder(id:18){id}
}
{
"data": {
"cancleOrder": {
"id": "18"
}
}
}
Analytics service:
received: OrderEvent[id=18, book=5, user=11, date='2019-06-26', action=DELETE]
GET localhost:8080/rating/graphql?query={allRatings{id}}
{
"data": {
"allRatings": [
{
"id": "1"
},
{
"id": "2"
},
{
"id": "3"
},
{
"id": "4"
},
{
"id": "5"
},
{
"id": "6"
},
{
"id": "7"
},
{
"id": "8"
},
{
"id": "9"
},
{
"id": "10"
},
{
"id": "11"
},
{
"id": "12"
}
]
}
}
GET localhost:8080/rating/graphql?query={rating(id:1){id, book{name}, user{name}}}
{
"data": {
"rating": {
"id": "1",
"book": {
"name": "n3"
},
"user": {
"name": "userName1"
}
}
}
}
book does not exist
POST http://localhost:8080/rating/graphql/
mutation {
addRating(value:2, userId:1, bookId:1){id}
}
{
"data": {
"addRating": null
},
"errors": [
{
"message": "Exception while fetching data (/addRating) : Book with ID:'1' not found.",
"path": [
"addRating"
],
"exception": {
"cause": null,
"locations": null,
"errorType": "ValidationError",
"message": "Book with ID:'1' not found.",
"path": null,
"extensions": null,
"localizedMessage": "Book with ID:'1' not found.",
"suppressed": []
},
"locations": [
{
"line": 2,
"column": 3
}
],
"extensions": null,
"errorType": "DataFetchingException"
}
]
}
POST http://localhost:8080/rating/graphql/
mutation {
addRating(value:2, userId:11, bookId:5){id}
}
{
"data": {
"addRating": {
"id": "13"
}
}
}
Analytics service:
received: RatingEvent[book=5, user=11, value=2, action=CREATE]
POST http://localhost:8080/rating/graphql/
mutation {
removeRating(id:13){id}
}
{
"data": {
"removeRating": {
"id": "13"
}
}
}
Analytics service:
received: RatingEvent[book=5, user=11, value=2, action=DELETE]
on startup of client on client
2019-06-26 18:02:32.873 INFO 8028 --- [ main] c.c.c.ConfigServicePropertySourceLocator : Fetching config from server at : http://localhost:8888
2019-06-26 18:02:34.572 INFO 8028 --- [ main] c.c.c.ConfigServicePropertySourceLocator : Located environment: name=sve.project.analyticsservice, profiles=[develop], label=null, version=null, state=null
2019-06-26 18:02:34.572 INFO 8028 --- [ main] b.c.PropertySourceBootstrapConfiguration : Located property source: CompositePropertySource {name='configService', propertySources=[MapPropertySource {name='classpath:/config/sve.project.analyticsservice-develop.properties'}]}
service turned off
GET localhost:8080/analytics/graphql?query={orderByRating{rating, orders}}
503 Service Unavailable
service unavailable (fallback)