Skip to content

Commit

Permalink
support spring cloud loadbalancer (#28)
Browse files Browse the repository at this point in the history
* support spring cloud loadbalancer

* update docs

* add SPRING_RETRY_PRESENT

* upgrade plugins version

* upgrade gradle 8.5

* update ci
  • Loading branch information
DanielLiu1123 authored Dec 2, 2023
1 parent 1ae3b45 commit 7843f44
Show file tree
Hide file tree
Showing 20 changed files with 487 additions and 52 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
branches:
- main
- 3.1.x
pull_request:
branches:
- main
- 3.1.x
jobs:
build:
runs-on: ubuntu-latest
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,44 @@ public interface PostApi {
>
> Consider using `@HttpExchange` instead of `@RequestMapping` if possible.
### Load Balancer Support

Support to work with `spring-cloud-starter-loadbalancer` to achieve client side load balancing.

Add dependency:

```groovy
implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer")
```

```yaml
http-exchange:
channels:
- base-url: user # service id
clients:
- com.example.user.api.*Api
```
Load balancer will be enabled automatically when `spring-cloud-starter-loadbalancer` is in the classpath.

Disable load balancer for all channels:

```yaml
http-exchange:
load-balancer-enabled: false # default is true
```

Disable load balancer for a specific channel:

```yaml
http-exchange:
channels:
- base-url: user
load-balancer-enabled: false
clients:
- com.example.user.api.*Api
```

### Dynamic Refresh Configuration

Support to dynamically refresh the configuration of clients, you can put the configuration in the configuration
Expand Down
9 changes: 3 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@ allprojects {
apply plugin: 'java-library'

repositories {
mavenLocal()
mavenCentral()
maven {
url "https://repo.spring.io/snapshot"
}
maven {
url "https://repo.spring.io/milestone"
}
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
}

java {
Expand Down
38 changes: 38 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,44 @@ public interface PostApi {
>
> Consider using `@HttpExchange` instead of `@RequestMapping` if possible.
### Load Balancer Support

Support to work with `spring-cloud-starter-loadbalancer` to achieve client side load balancing.

Add dependency:

```groovy
implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer")
```

```yaml
http-exchange:
channels:
- base-url: user # service id
clients:
- com.example.user.api.*Api
```
Load balancer will be enabled automatically when `spring-cloud-starter-loadbalancer` is in the classpath.

Disable load balancer for all channels:

```yaml
http-exchange:
load-balancer-enabled: false # default is true
```

Disable load balancer for a specific channel:

```yaml
http-exchange:
channels:
- base-url: user
load-balancer-enabled: false
clients:
- com.example.user.api.*Api
```

### Dynamic Refresh Configuration

Support to dynamically refresh the configuration of clients, you can put the configuration in the configuration
Expand Down
10 changes: 10 additions & 0 deletions examples/load-balancer/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation(project(":starters:httpexchange-spring-boot-starter"))
implementation("org.springframework.cloud:spring-cloud-starter-loadbalancer")

implementation("org.springframework:spring-webflux")
implementation("org.springframework.retry:spring-retry")

testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example;

import io.github.danielliu1123.httpexchange.EnableExchangeClients;
import java.util.List;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@EnableExchangeClients
@RestController
public class LoadBalancerApp implements UserApi {

public static void main(String[] args) {
new SpringApplicationBuilder(LoadBalancerApp.class)
.properties("server.port=0")
.run(args);
}

@Override
public UserApi.UserDTO getById(String id) {
return new UserApi.UserDTO(id, "Freeman", List.of("Coding", "Reading"));
}
}
14 changes: 14 additions & 0 deletions examples/load-balancer/src/main/java/com/example/UserApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example;

import java.util.List;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;

@HttpExchange("/user")
public interface UserApi {
record UserDTO(String id, String name, List<String> hobbies) {}

@GetExchange("/getById/{id}")
UserDTO getById(@PathVariable("id") String id);
}
18 changes: 18 additions & 0 deletions examples/load-balancer/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
spring:
application:
name: load-balancer
cloud:
discovery:
client:
simple:
instances:
user:
- host: localhost
port: ${server.port}
- host: localhost
port: ${random.int(50000,60000)} # Simulate an unavailable instance
http-exchange:
channels:
- base-url: user # service id
clients:
- com.example.*Api
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.example;

import static org.assertj.core.api.Assertions.assertThat;

import java.net.ServerSocket;
import lombok.SneakyThrows;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.builder.SpringApplicationBuilder;

/**
* Spring Cloud load balancer is based on Spring Retry or Reactor.
*
* <p> If using sync clients, the retry depends on Spring Retry.
* <p> If using async clients, the retry depends on Reactor.
*/
class LoadBalancerAppTests {

/**
* When using sync clients, the retry depends on Spring Retry.
*/
@ParameterizedTest
@ValueSource(strings = {"rest_client", "rest_template"})
void testLoadBalancer_whenRetryDependsOnSpringRetry_thenAllRequestOK(String clientType) {
int port = getRandomPort();

var ctx = new SpringApplicationBuilder(LoadBalancerApp.class)
.properties("server.port=" + port)
.properties("http-exchange.client-type=" + clientType)
.run();

UserApi userApi = ctx.getBean(UserApi.class);

int success = 0;
int failure = 0;
for (int i = 0; i < 4; i++) {
try {
userApi.getById("1");
success++;
} catch (Exception e) {
failure++;
}
}

assertThat(success).isEqualTo(4);
assertThat(failure).isZero();

ctx.close();
}

/**
* When using async clients, the retry depends on Reactor.
*/
@ParameterizedTest
@ValueSource(strings = {"web_client"})
void testLoadBalancer_whenRetryDependsOnReactor_thenAllRequestOK(String clientType) {
int port = getRandomPort();

var ctx = new SpringApplicationBuilder(LoadBalancerApp.class)
.properties("server.port=" + port)
.properties("http-exchange.client-type=" + clientType)
.properties("spring.cloud.loadbalancer.retry.enabled=true")
.run();

UserApi userApi = ctx.getBean(UserApi.class);

int success = 0;
int failure = 0;
for (int i = 0; i < 4; i++) {
try {
userApi.getById("1");
success++;
} catch (Exception e) {
failure++;
}
}

assertThat(success).isEqualTo(4);
assertThat(failure).isZero();

ctx.close();
}

@ParameterizedTest
@ValueSource(strings = {"rest_client", "rest_template", "web_client"})
void testLoadBalancer_whenDisableRetry_thenHalfOKHalfFailed(String clientType) {
int port = getRandomPort();

var ctx = new SpringApplicationBuilder(LoadBalancerApp.class)
.properties("server.port=" + port)
.properties("http-exchange.client-type=" + clientType)
.properties("spring.cloud.loadbalancer.retry.enabled=false")
.run();

UserApi userApi = ctx.getBean(UserApi.class);

int success = 0;
int failure = 0;
for (int i = 0; i < 4; i++) {
try {
userApi.getById("1");
success++;
} catch (Exception e) {
failure++;
}
}

assertThat(success).isEqualTo(2);
assertThat(failure).isEqualTo(2);

ctx.close();
}

@ParameterizedTest
@ValueSource(strings = {"rest_client", "rest_template", "web_client"})
void testLoadBalancer_whenDisabled(String clientType) {
int port = getRandomPort();

var ctx = new SpringApplicationBuilder(LoadBalancerApp.class)
.properties("server.port=" + port)
.properties("http-exchange.client-type=" + clientType)
.properties("spring.cloud.loadbalancer.enabled=false")
.run();

UserApi userApi = ctx.getBean(UserApi.class);

int success = 0;
int failure = 0;
for (int i = 0; i < 4; i++) {
try {
userApi.getById("1");
success++;
} catch (Exception e) {
failure++;
}
}

assertThat(success).isZero();
assertThat(failure).isEqualTo(4);

ctx.close();
}

@SneakyThrows
private static int getRandomPort() {
try (ServerSocket ss = new ServerSocket(0)) {
return ss.getLocalPort();
}
}
}
6 changes: 3 additions & 3 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ version=3.2.0-RC2-SNAPSHOT

springBootVersion=3.2.0
springDependencyManagementVersion=1.1.4
springCloudVersion=2022.0.4
spotlessVersion=6.22.0
spotbugsVersion=6.0.0-rc.2
springCloudVersion=2023.0.0-RC1
spotlessVersion=6.23.2
spotbugsVersion=6.0.0-rc.3

classpathReplacerVersion=2.1.2

Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
14 changes: 7 additions & 7 deletions gradlew
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,15 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
Expand Down Expand Up @@ -202,11 +202,11 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'

# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.

set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
Expand Down
Loading

0 comments on commit 7843f44

Please sign in to comment.