Skip to content

Commit

Permalink
Add SmallRye Stork service discovery
Browse files Browse the repository at this point in the history
Fixes #40
  • Loading branch information
edeandrea committed May 6, 2022
1 parent 4115ac4 commit 67d43dd
Show file tree
Hide file tree
Showing 17 changed files with 356 additions and 47 deletions.
50 changes: 50 additions & 0 deletions .github/workflows/simple-build-test.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Basic build and test

env:
MANDREL_VERSION: "22.0.0.2-Final"

on:
push:
paths-ignore:
Expand Down Expand Up @@ -61,3 +64,50 @@ jobs:
- name: "build-test-jvm-${{ matrix.project }}-java-${{ matrix.java }}"
working-directory: ${{ matrix.project }}
run: ./mvnw -B clean verify -Dquarkus.http.host=0.0.0.0 -Dmaven.compiler.release=${{ matrix.java }}

native-build-test:
runs-on: ubuntu-latest
if: github.repository == 'quarkusio/quarkus-super-heroes'
strategy:
fail-fast: false
matrix:
java:
- '11'
- '17'
project:
- event-statistics
- rest-fights
- rest-heroes
- rest-villans
name: "native-build-test-${{ matrix.project }}-java-${{ matrix.java }}"
steps:
- uses: actions/checkout@v3

- name: Cache and restore Mandrel distro
id: check-mandrel-cache
uses: actions/cache@v3
with:
path: mandrel-${{ env.MANDREL_VERSION }}-${{ matrix.java }}.tar.gz
key: mandrel-distro-${{ env.MANDREL_VERSION }}-${{ matrix.java }}

- name: Download Mandrel
if: steps.check-mandrel-cache.outputs.cache-hit != 'true'
run: |
download_url="https://github.com/graalvm/mandrel/releases/download/mandrel-${MANDREL_VERSION}/mandrel-java${{ matrix.java }}-linux-amd64-${MANDREL_VERSION}.tar.gz"
wget -q -O mandrel-${{ env.MANDREL_VERSION }}-${{ matrix.java }}.tar.gz $download_url
- name: Setup Maven+OpenJDK Distro
uses: actions/setup-java@v2
with:
distribution: 'jdkfile'
jdkFile: mandrel-${{ env.MANDREL_VERSION }}-${{ matrix.java }}.tar.gz
java-version: ${{ matrix.java }}
architecture: x64
cache: maven

- name: "build-test-native-${{ matrix.project }}-java-${{ matrix.java }}"
working-directory: ${{ matrix.project }}
run: |
./mvnw -B clean verify -Pnative \
-Dquarkus.http.host=0.0.0.0 \
-Dmaven.compiler.release=${{ matrix.java }}
28 changes: 26 additions & 2 deletions rest-fights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
- [Retries](#retries)
- [Hero Client](#hero-client)
- [Villain Client](#villain-client)
- [Service Discovery and Load Balancing](#service-discovery-and-client-load-balancing)
- [Service Discovery](#service-discovery)
- [Client-side Load Balancing](#client-side-load-balancing)
- [Testing](#testing)
- [Running the Application](#running-the-application)
- [Running Locally via Docker Compose](#running-locally-via-docker-compose)
Expand Down Expand Up @@ -66,6 +69,27 @@ The [`VillainClient`](src/main/java/io/quarkus/sample/superheroes/fight/client/V
- The downstream [Villain service](../rest-villains) returns a `404` if no random [`Villain`](src/main/java/io/quarkus/sample/superheroes/fight/client/Villain.java) is found. `VillainClient` handles this case and simulates the service returning nothing.
- In the event the downstream [Villain service](../rest-heroes) returns an error, `VillainClient` adds 3 retries with a 200ms delay between each retry.

## Service Discovery and Client Load Balancing
The fight service implements service discovery and client-side load balancing when making downstream calls to the [`rest-heroes`](../rest-heroes) and [`rest-villains`](../rest-villains) services. The service discovery is implemented in Quarkus using [SmallRye Stork](https://quarkus.io/blog/smallrye-stork-intro).

Stork [integrates directly with the Quarkus REST Client Reactive](http://smallrye.io/smallrye-stork/1.1.0/quarkus). This means that there is no additional code needed in the [`HeroRestClient`](src/main/java/io/quarkus/sample/superheroes/fight/client/HeroRestClient.java) in order to take advantage of Stork's service discovery and client-side load balancing.

> You could disable Stork completely for the `HeroRestClient` by setting `quarkus.rest-client.hero-client.url` to any non-Stork URL (i.e. something that doesn't start with `stork://`).
The [`VillainClient`](src/main/java/io/quarkus/sample/superheroes/fight/client/VillainClient.java), on the other hand, is implemented by directly using the [JAX-RS client API](https://docs.oracle.com/javaee/7/tutorial/jaxrs-client001.htm) with the [RESTEasy Reactive client](https://quarkus.io/guides/resteasy-reactive#resteasy-reactive-client). Therefore, the [Stork API](http://smallrye.io/smallrye-stork/1.1.0/concepts) is used directly in order to get the same functionality available in the [`HeroRestClient`](src/main/java/io/quarkus/sample/superheroes/fight/client/HeroRestClient.java).

> Similarly. you could disable Stork completely for the `VillainClient` by setting `fight.villain.client-base-url` to any non-Stork URL (i.e. something that doesn't start with `stork://`).
### Service Discovery
In local development mode, as well as when running via Docker Compose, SmallRye Stork is configured using [static list discovery](https://github.com/smallrye/smallrye-stork/blob/main/docs/service-discovery/static-list.md). In this mode, the downstream URLs are statically defined in an address list. In [`application.properties`](src/main/resources/application.properties), see the `quarkus.stork.hero-service.service-discovery.address-list` and `quarkus.stork.villain-service.service-discovery.address-list` properties.

When [running in Kubernetes](https://quarkus.io/blog/stork-kubernetes-discovery), Stork is configured to use the [Kubernetes Service Discovery](http://smallrye.io/smallrye-stork/1.1.0/kubernetes). In this mode, Stork will read the Kubernetes `Service`s for the [`rest-heroes`](../rest-heroes) and [`rest-villains`](../rest-villains) services to obtain the instance information. Additionally, the instance information has been configured to refresh every minute. See the `rest-fights-config` ConfigMap in [the Kubernetes deployment descriptors](deploy/k8s). Look for the `quarkus.stork.*` properties within the various `ConfigMap`s.

All of the other Stork service discovery mechanisms ([Consul](http://smallrye.io/smallrye-stork/1.1.0/consul) and [Eureka](http://smallrye.io/smallrye-stork/1.1.0/eureka)) can be used simply by updating the configuration appropriately according to the Stork documentation.

### Client-Side Load Balancing
In all cases, the default load balancing algorithm used is [round robin](http://smallrye.io/smallrye-stork/1.1.0/round-robin). All of the other load balancing algorithms ([random](http://smallrye.io/smallrye-stork/1.1.0/random), [least requests](http://smallrye.io/smallrye-stork/1.1.0/least-requests), [least response time](http://smallrye.io/smallrye-stork/1.1.0/response-time), and [power of two choices](http://smallrye.io/smallrye-stork/1.1.0/power-of-two-choices)) are available on the application's classpath, so feel free to play around with them by updating the configuration appropriately according to the Stork documentation.

## Testing
This application has a full suite of tests, including an [integration test suite](src/test/java/io/quarkus/sample/superheroes/fight/rest/FightResourceIT.java).
- The test suite uses [Wiremock](http://wiremock.org/) for [mocking http calls](https://quarkus.io/guides/rest-client-reactive#using-a-mock-http-server-for-tests) (see [`HeroesVillainsWiremockServerResource`](src/test/java/io/quarkus/sample/superheroes/fight/HeroesVillainsWiremockServerResource.java)) to the downstream [Hero](../rest-heroes) and [Villain](../rest-villains) services.
Expand All @@ -92,8 +116,8 @@ By default, the application is configured with the following:
| Database password | `QUARKUS_MONGODB_CREDENTIALS_PASSWORD` | `quarkus.mongodb.credentials.password` | `superfight` |
| Kafka Bootstrap servers | `KAFKA_BOOTSTRAP_SERVERS` | `kafka.bootstrap.servers` | `PLAINTEXT://localhost:9092` |
| Apicurio Schema Registry | `MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_APICURIO_REGISTRY_URL` | `mp.messaging.connector.smallrye-kafka.apicurio.registry.url` | `http://localhost:8086/apis/registry/v2` |
| Heroes Service URL | `quarkus.rest-client.hero-client.url` | `quarkus.rest-client.hero-client.url` | `http://localhost:8083` |
| Villains Service URL | `fight.villain.client-base-url` | `fight.villain.client-base-url` | `http://localhost:8084` |
| Heroes Service URL | `QUARKUS_REST_CLIENT_HERO_CLIENT_URL` | `quarkus.rest-client.hero-client.url` | `stork://hero-service` |
| Villains Service URL | `FIGHT_VILLAIN_CLIENT_BASE_URL` | `fight.villain.client-base-url` | `stork://villain-service` |

## Running Locally via Docker Compose
Pre-built images for this application can be found at [`quay.io/quarkus-super-heroes/rest-fights`](https://quay.io/repository/quarkus-super-heroes/rest-fights?tab=tags).
Expand Down
40 changes: 40 additions & 0 deletions rest-fights/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,46 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase-mongodb</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-discovery-static-list</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-discovery-kubernetes</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-discovery-eureka</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-discovery-consul</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-load-balancer-random</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-load-balancer-least-requests</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-load-balancer-least-response-time</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-load-balancer-power-of-two-choices</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
Expand Down
4 changes: 2 additions & 2 deletions rest-fights/src/main/docker-compose/java11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
QUARKUS_LIQUIBASE_MONGODB_MIGRATE_AT_START: false
QUARKUS_MONGODB_CREDENTIALS_USERNAME: superfight
QUARKUS_MONGODB_CREDENTIALS_PASSWORD: superfight
QUARKUS_REST_CLIENT_HERO_CLIENT_URL: http://rest-heroes:8083
FIGHT_VILLAIN_CLIENT_BASE_URL: http://rest-villains:8084
QUARKUS_STORK_HERO_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-heroes:8083
QUARKUS_STORK_VILLAIN_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-villains:8084
MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_APICURIO_REGISTRY_URL: http://apicurio:8086/apis/registry/v2
restart: on-failure
networks:
Expand Down
4 changes: 2 additions & 2 deletions rest-fights/src/main/docker-compose/java17.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
QUARKUS_LIQUIBASE_MONGODB_MIGRATE_AT_START: false
QUARKUS_MONGODB_CREDENTIALS_USERNAME: superfight
QUARKUS_MONGODB_CREDENTIALS_PASSWORD: superfight
QUARKUS_REST_CLIENT_HERO_CLIENT_URL: http://rest-heroes:8083
FIGHT_VILLAIN_CLIENT_BASE_URL: http://rest-villains:8084
QUARKUS_STORK_HERO_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-heroes:8083
QUARKUS_STORK_VILLAIN_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-villains:8084
MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_APICURIO_REGISTRY_URL: http://apicurio:8086/apis/registry/v2
restart: on-failure
networks:
Expand Down
4 changes: 2 additions & 2 deletions rest-fights/src/main/docker-compose/native-java11.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
QUARKUS_LIQUIBASE_MONGODB_MIGRATE_AT_START: false
QUARKUS_MONGODB_CREDENTIALS_USERNAME: superfight
QUARKUS_MONGODB_CREDENTIALS_PASSWORD: superfight
QUARKUS_REST_CLIENT_HERO_CLIENT_URL: http://rest-heroes:8083
FIGHT_VILLAIN_CLIENT_BASE_URL: http://rest-villains:8084
QUARKUS_STORK_HERO_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-heroes:8083
QUARKUS_STORK_VILLAIN_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-villains:8084
MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_APICURIO_REGISTRY_URL: http://apicurio:8086/apis/registry/v2
restart: on-failure
networks:
Expand Down
4 changes: 2 additions & 2 deletions rest-fights/src/main/docker-compose/native-java17.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
QUARKUS_LIQUIBASE_MONGODB_MIGRATE_AT_START: false
QUARKUS_MONGODB_CREDENTIALS_USERNAME: superfight
QUARKUS_MONGODB_CREDENTIALS_PASSWORD: superfight
QUARKUS_REST_CLIENT_HERO_CLIENT_URL: http://rest-heroes:8083
FIGHT_VILLAIN_CLIENT_BASE_URL: http://rest-villains:8084
QUARKUS_STORK_HERO_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-heroes:8083
QUARKUS_STORK_VILLAIN_SERVICE_SERVICE_DISCOVERY_ADDRESS_LIST: rest-villains:8084
MP_MESSAGING_CONNECTOR_SMALLRYE_KAFKA_APICURIO_REGISTRY_URL: http://apicurio:8086/apis/registry/v2
restart: on-failure
networks:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
import java.util.concurrent.CompletionStage;

import javax.enterprise.context.ApplicationScoped;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MediaType;

import org.eclipse.microprofile.faulttolerance.CircuitBreaker;
import org.jboss.resteasy.reactive.client.impl.UniInvoker;

import io.quarkus.logging.Log;
import io.quarkus.sample.superheroes.fight.config.FightConfig;

import io.smallrye.faulttolerance.api.CircuitBreakerName;
import io.smallrye.mutiny.Uni;
import io.smallrye.stork.Stork;
import io.smallrye.stork.api.ServiceInstance;

/**
* Bean to be used for interacting with the Villain service.
Expand All @@ -25,12 +29,20 @@
*/
@ApplicationScoped
public class VillainClient {
private final WebTarget villainClient;
private static final String STORK_PREFIX = "stork://";
private static final String VILLAINS_API_PATH = "/api/villains";

private final WebTargetProvider webTargetProvider;

public VillainClient(FightConfig fightConfig) {
this.villainClient = ClientBuilder.newClient()
.target(fightConfig.villain().clientBaseUrl())
.path("api/villains/");
var villainClientBaseUrl = fightConfig.villain().clientBaseUrl();

if (villainClientBaseUrl.startsWith(STORK_PREFIX)) {
this.webTargetProvider = new StorkWebTargetProvider(villainClientBaseUrl.replace(STORK_PREFIX, ""));
}
else {
this.webTargetProvider = new DefaultWebTargetProvider(villainClientBaseUrl);
}
}

/**
Expand All @@ -39,16 +51,18 @@ public VillainClient(FightConfig fightConfig) {
*/
@CircuitBreaker(requestVolumeThreshold = 8, failureRatio = 0.5, delay = 2, delayUnit = ChronoUnit.SECONDS)
@CircuitBreakerName("findRandomVillain")
CompletionStage<Villain> getRandomVillain() {
// Want the 404 handling to be part of the circuit breaker
// This means that the 404 responses aren't considered errors by the circuit breaker
return this.villainClient.path("random")
.request(MediaType.APPLICATION_JSON_TYPE)
.rx(UniInvoker.class)
.get(Villain.class)
.onFailure(Is404Exception.IS_404).recoverWithNull()
.subscribeAsCompletionStage();
}
CompletionStage<Villain> getRandomVillain() {
// Want the 404 handling to be part of the circuit breaker
// This means that the 404 responses aren't considered errors by the circuit breaker
return this.webTargetProvider.getWebTarget("/random")
.flatMap(webTarget ->
webTarget.request(MediaType.APPLICATION_JSON_TYPE)
.rx(UniInvoker.class)
.get(Villain.class)
.onFailure(Is404Exception.IS_404).recoverWithNull()
)
.subscribeAsCompletionStage();
}

/**
* Finds a random {@link Villain}. The retry logic is applied to the result of the {@link CircuitBreaker}, meaning that retries that return failures could trigger the breaker to open.
Expand All @@ -66,9 +80,69 @@ public Uni<Villain> findRandomVillain() {
* @return A "hello" from Villains
*/
public Uni<String> helloVillains() {
return this.villainClient.path("hello")
.request(MediaType.TEXT_PLAIN_TYPE)
.rx(UniInvoker.class)
.get(String.class);
return this.webTargetProvider.getWebTarget("/hello")
.flatMap(webTarget ->
webTarget.request(MediaType.TEXT_PLAIN_TYPE)
.rx(UniInvoker.class)
.get(String.class)
);
}

private static abstract class WebTargetProvider {
protected abstract Uni<WebTarget> getWebTarget(String path);
}

private static class DefaultWebTargetProvider extends WebTargetProvider {
private final WebTarget webTarget;

private DefaultWebTargetProvider(String baseUrl) {
Log.debugf("Creating Default provider for baseUrl = %s", baseUrl);
this.webTarget = ClientBuilder.newClient()
.target(baseUrl)
.path(VILLAINS_API_PATH);
}

@Override
protected Uni<WebTarget> getWebTarget(String path) {
return Uni.createFrom().item(this.webTarget.path(path));
}
}

private static class StorkWebTargetProvider extends WebTargetProvider {
private final Client villainClient = ClientBuilder.newClient();
private final String storkServiceName;

private StorkWebTargetProvider(String storkServiceName) {
Log.debugf("Creating Stork provider for service name = %s", storkServiceName);
this.storkServiceName = storkServiceName;
}

private Uni<ServiceInstance> getServiceInstance() {
return Stork.getInstance()
.getService(this.storkServiceName)
.selectInstanceAndRecordStart(true);
}

private WebTarget createWebTarget(ServiceInstance serviceInstance, String path) {
var url = String.format(
"%s://%s:%d",
serviceInstance.isSecure() ? "https" : "http",
serviceInstance.getHost(),
serviceInstance.getPort()
);

Log.debugf("Targeting Stork client for service with URL = %s", url);

return this.villainClient.target(url)
.path(VILLAINS_API_PATH)
.path(path);
}

@Override
protected Uni<WebTarget> getWebTarget(String path) {
return getServiceInstance()
.onItem().ifNotNull().transform(serviceInstance -> createWebTarget(serviceInstance, path))
.onItem().ifNull().failWith(() -> new IllegalArgumentException(String.format("Can't determine a downstream service for service name '%s'. Is one configured?")));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public Uni<Response> getFight(@Parameter(name = "id", required = true) @PathPara
return Response.ok(f).build();
})
.onItem().ifNull().continueWith(() -> {
Log.debugf("No fight found with id %d", id);
Log.debugf("No fight found with id %s", id);
return Response.status(Status.NOT_FOUND).build();
});
}
Expand Down
Loading

0 comments on commit 67d43dd

Please sign in to comment.