Merge remote-tracking branch 'origin/master' into 2.0.x

This commit is contained in:
Ryan Baxter
2017-07-12 11:14:27 -04:00
19 changed files with 413 additions and 55 deletions

View File

@@ -1656,7 +1656,37 @@ To not discard these well known security headers in case Spring Security is on t
If you are using `@EnableZuulProxy` with tha Spring Boot Actuator you
will enable (by default) an additional endpoint, available via HTTP as
`/routes`. A GET to this endpoint will return a list of the mapped
routes. A POST will force a refresh of the existing routes (e.g. in
routes:
.GET /routes
[source,json]
----
{
/stores/**: "http://localhost:8081"
}
----
Additional route details can be requested by adding the `?format=details` query
string to `/routes`. This will produce the following output:
.GET /routes?format=details
[source,json]
----
{
"/stores/**": {
"id": "stores",
"fullPath": "/stores/**",
"location": "http://localhost:8081",
"path": "/**",
"prefix": "/stores",
"retryable": false,
"customSensitiveHeaders": false,
"prefixStripped": true
}
}
----
A POST will force a refresh of the existing routes (e.g. in
case there have been changes in the service catalog). You can disable
this endpoint by setting `endpoints.routes.enabled` to `false`.

View File

@@ -18,7 +18,11 @@ package org.springframework.cloud.netflix.zuul;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.endpoint.AbstractEndpoint;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -34,6 +38,7 @@ import org.springframework.jmx.export.annotation.ManagedResource;
* @author Spencer Gibb
* @author Dave Syer
* @author Ryan Baxter
* @author Gregor Zurowski
*/
@ManagedResource(description = "Can be used to list the reverse proxy routes")
@ConfigurationProperties(prefix = "endpoints.routes")
@@ -59,4 +64,112 @@ public class RoutesEndpoint extends AbstractEndpoint<Map<String, String>> {
}
return map;
}
Map<String, RouteDetails> invokeRouteDetails() {
Map<String, RouteDetails> map = new LinkedHashMap<>();
for (Route route : this.routes.getRoutes()) {
map.put(route.getFullPath(), new RouteDetails(route));
}
return map;
}
/**
* Container for exposing Zuul {@link Route} details as JSON.
*/
@JsonPropertyOrder({ "id", "fullPath", "location" })
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public static class RouteDetails {
private String id;
private String fullPath;
private String path;
private String location;
private String prefix;
private Boolean retryable;
private Set<String> sensitiveHeaders;
private boolean customSensitiveHeaders;
private boolean prefixStripped;
public RouteDetails() {
}
RouteDetails(final Route route) {
this.id = route.getId();
this.fullPath = route.getFullPath();
this.path = route.getPath();
this.location = route.getLocation();
this.prefix = route.getPrefix();
this.retryable = route.getRetryable();
this.sensitiveHeaders = route.getSensitiveHeaders();
this.customSensitiveHeaders = route.isCustomSensitiveHeaders();
this.prefixStripped = route.isPrefixStripped();
}
public String getId() {
return id;
}
public String getFullPath() {
return fullPath;
}
public String getPath() {
return path;
}
public String getLocation() {
return location;
}
public String getPrefix() {
return prefix;
}
public Boolean getRetryable() {
return retryable;
}
public Set<String> getSensitiveHeaders() {
return sensitiveHeaders;
}
public boolean isCustomSensitiveHeaders() {
return customSensitiveHeaders;
}
public boolean isPrefixStripped() {
return prefixStripped;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RouteDetails that = (RouteDetails) o;
return Objects.equals(id, that.id) &&
Objects.equals(fullPath, that.fullPath) &&
Objects.equals(path, that.path) &&
Objects.equals(location, that.location) &&
Objects.equals(prefix, that.prefix) &&
Objects.equals(retryable, that.retryable) &&
Objects.equals(sensitiveHeaders, that.sensitiveHeaders) &&
customSensitiveHeaders == that.customSensitiveHeaders &&
prefixStripped == that.prefixStripped;
}
@Override
public int hashCode() {
return Objects.hash(id, fullPath, path, location, prefix, retryable,
sensitiveHeaders, customSensitiveHeaders, prefixStripped);
}
}
}

View File

@@ -18,28 +18,38 @@
package org.springframework.cloud.netflix.zuul;
import org.springframework.boot.actuate.endpoint.mvc.ActuatorMediaTypes;
import org.springframework.boot.actuate.endpoint.mvc.EndpointMvcAdapter;
import org.springframework.cloud.netflix.zuul.filters.Route;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.http.MediaType;
import org.springframework.jmx.export.annotation.ManagedOperation;
import org.springframework.jmx.export.annotation.ManagedResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* Endpoint used to reset the reverse proxy routes
* @author Ryan Baxter
* @author Gregor Zurowski
*/
@ManagedResource(description = "Can be used to reset the reverse proxy routes")
public class RoutesMvcEndpoint extends EndpointMvcAdapter implements ApplicationEventPublisherAware {
static final String FORMAT_DETAILS = "details";
private final RoutesEndpoint endpoint;
private RouteLocator routes;
private ApplicationEventPublisher publisher;
public RoutesMvcEndpoint(RoutesEndpoint endpoint, RouteLocator routes) {
super(endpoint);
this.endpoint = endpoint;
this.routes = routes;
}
@@ -55,4 +65,18 @@ public class RoutesMvcEndpoint extends EndpointMvcAdapter implements Application
this.publisher.publishEvent(new RoutesRefreshedEvent(this.routes));
return super.invoke();
}
}
/**
* Expose Zuul {@link Route} information with details.
*/
@GetMapping(params = "format", produces = { ActuatorMediaTypes.APPLICATION_ACTUATOR_V1_JSON_VALUE,
MediaType.APPLICATION_JSON_VALUE })
@ResponseBody
public Object invokeRouteDetails(@RequestParam String format) {
if (FORMAT_DETAILS.equalsIgnoreCase(format)) {
return endpoint.invokeRouteDetails();
} else {
return super.invoke();
}
}
}

View File

@@ -27,17 +27,24 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.bind.annotation.RestController;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
/**
* @author Ryan Baxter
* @author Gregor Zurowski
*/
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = RANDOM_PORT,
@@ -64,6 +71,22 @@ public class RoutesEndpointIntegrationTests {
assertTrue(refreshListener.wasCalled());
}
@Test
public void getRouteDetailsTest() {
ResponseEntity<Map<String, RoutesEndpoint.RouteDetails>> responseEntity = restTemplate.exchange(
"/admin/routes?format=details", HttpMethod.GET, null, new ParameterizedTypeReference<Map<String, RoutesEndpoint.RouteDetails>>() {
});
assertThat(responseEntity.getStatusCode(), is(HttpStatus.OK));
RoutesEndpoint.RouteDetails details = responseEntity.getBody().get("/sslservice/**");
assertThat(details.getPath(), is("/**"));
assertThat(details.getFullPath(), is("/sslservice/**"));
assertThat(details.getLocation(), is("https://localhost:8443"));
assertThat(details.getPrefix(), is("/sslservice"));
assertTrue(details.isPrefixStripped());
}
@Configuration
@EnableAutoConfiguration
@RestController

View File

@@ -34,6 +34,7 @@ import static org.junit.Assert.assertTrue;
/**
* @author Ryan Baxter
* @author Gregor Zurowski
*/
public class RoutesEndpointTests {
@@ -51,7 +52,7 @@ public class RoutesEndpointTests {
public List<Route> getRoutes() {
List<Route> routes = new ArrayList<>();
routes.add(new Route("foo", "foopath", "foolocation", null, true, Collections.EMPTY_SET));
routes.add(new Route("bar", "barpath", "barlocation", null, true, Collections.EMPTY_SET));
routes.add(new Route("bar", "barpath", "barlocation", "/bar-prefix", true, Collections.EMPTY_SET));
return routes;
}
@@ -72,6 +73,16 @@ public class RoutesEndpointTests {
assertEquals(result , endpoint.invoke());
}
@Test
public void testInvokeRouteDetails() {
RoutesEndpoint endpoint = new RoutesEndpoint(locator);
Map<String, RoutesEndpoint.RouteDetails> results = new HashMap<>();
for (Route route : locator.getRoutes()) {
results.put(route.getFullPath(), new RoutesEndpoint.RouteDetails(route));
}
assertEquals(results, endpoint.invokeRouteDetails());
}
@Test
public void testId() {
RoutesEndpoint endpoint = new RoutesEndpoint(locator);

View File

@@ -42,6 +42,7 @@ import static org.mockito.Mockito.verify;
/**
* @author Ryan Baxter
* @author Gregor Zurowski
*/
@SpringBootTest
@RunWith(MockitoJUnitRunner.class)
@@ -63,7 +64,7 @@ public class RoutesMvcEndpointTests {
public List<Route> getRoutes() {
List<Route> routes = new ArrayList<>();
routes.add(new Route("foo", "foopath", "foolocation", null, true, Collections.EMPTY_SET));
routes.add(new Route("bar", "barpath", "barlocation", null, true, Collections.EMPTY_SET));
routes.add(new Route("bar", "barpath", "barlocation", "bar-prefix", true, Collections.EMPTY_SET));
return routes;
}
@@ -88,4 +89,15 @@ public class RoutesMvcEndpointTests {
verify(publisher, times(1)).publishEvent(isA(RoutesRefreshedEvent.class));
}
@Test
public void routeDetails() throws Exception {
RoutesMvcEndpoint mvcEndpoint = new RoutesMvcEndpoint(endpoint, locator);
Map<String, RoutesEndpoint.RouteDetails> results = new HashMap<>();
for (Route route : locator.getRoutes()) {
results.put(route.getFullPath(), new RoutesEndpoint.RouteDetails(route));
}
assertEquals(results, mvcEndpoint.invokeRouteDetails(RoutesMvcEndpoint.FORMAT_DETAILS));
verify(endpoint, times(1)).invokeRouteDetails();
}
}

View File

@@ -56,13 +56,6 @@
<groupId>org.webjars</groupId>
<artifactId>d3js</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Only needed at compile time -->
<scope>compile</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>

View File

@@ -26,8 +26,8 @@ import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.apachecommons.CommonsLog;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
@@ -103,9 +103,10 @@ public class HystrixDashboardConfiguration {
* not yet support CORS (https://bugs.webkit.org/show_bug.cgi?id=61862) so that a UI
* can request a stream from a different server.
*/
@CommonsLog
public static class ProxyStreamServlet extends HttpServlet {
private static final Log log = LogFactory.getLog(ProxyStreamServlet.class);
private static final long serialVersionUID = 1L;
private static final String CONNECTION_CLOSE_VALUE = "close";

View File

@@ -89,13 +89,6 @@
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Only needed at compile time -->
<scope>compile</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>

View File

@@ -17,18 +17,17 @@
package org.springframework.cloud.netflix.sidecar;
import java.net.URI;
import java.util.Objects;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Spencer Gibb
* @author Gregor Zurowski
*/
@Data
@ConfigurationProperties("sidecar")
public class SidecarProperties {
@@ -44,4 +43,72 @@ public class SidecarProperties {
private String ipAddress;
public URI getHealthUri() {
return healthUri;
}
public void setHealthUri(URI healthUri) {
this.healthUri = healthUri;
}
public URI getHomePageUri() {
return homePageUri;
}
public void setHomePageUri(URI homePageUri) {
this.homePageUri = homePageUri;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getHostname() {
return hostname;
}
public void setHostname(String hostname) {
this.hostname = hostname;
}
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SidecarProperties that = (SidecarProperties) o;
return Objects.equals(healthUri, that.healthUri) &&
Objects.equals(homePageUri, that.homePageUri) &&
port == that.port &&
Objects.equals(hostname, that.hostname) &&
Objects.equals(ipAddress, that.ipAddress);
}
@Override
public int hashCode() {
return Objects.hash(healthUri, homePageUri, port, hostname, ipAddress);
}
@Override
public String toString() {
return new StringBuilder("SidecarProperties{")
.append("healthUri=").append(healthUri).append(", ")
.append("homePageUri=").append(homePageUri).append(", ")
.append("port=").append(port).append(", ")
.append("hostname='").append(hostname).append("', ")
.append("ipAddress='").append(ipAddress).append("'}")
.toString();
}
}

View File

@@ -78,13 +78,6 @@
<groupId>io.reactivex</groupId>
<artifactId>rxjava</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Only needed at compile time -->
<scope>compile</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>

View File

@@ -20,6 +20,8 @@ import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.messaging.handler.annotation.Payload;
@@ -28,16 +30,16 @@ import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.apachecommons.CommonsLog;
import rx.subjects.PublishSubject;
/**
* @author Spencer Gibb
*/
@CommonsLog
@Component // needed for ServiceActivator to be picked up
public class HystrixStreamAggregator {
private static final Log log = LogFactory.getLog(HystrixStreamAggregator.class);
private ObjectMapper objectMapper;
private PublishSubject<Map<String, Object>> subject;

View File

@@ -21,6 +21,8 @@ import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.actuator.HasFeatures;
@@ -39,7 +41,6 @@ import io.netty.buffer.ByteBuf;
import io.reactivex.netty.RxNetty;
import io.reactivex.netty.protocol.http.server.HttpServer;
import io.reactivex.netty.protocol.text.sse.ServerSentEvent;
import lombok.extern.apachecommons.CommonsLog;
import rx.Observable;
import rx.subjects.PublishSubject;
@@ -47,10 +48,11 @@ import rx.subjects.PublishSubject;
* @author Spencer Gibb
*/
@Configuration
@CommonsLog
@EnableConfigurationProperties(TurbineStreamProperties.class)
public class TurbineStreamConfiguration implements SmartLifecycle {
private static final Log log = LogFactory.getLog(TurbineStreamConfiguration.class);
private AtomicBoolean running = new AtomicBoolean(false);
@Autowired

View File

@@ -21,13 +21,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.netflix.hystrix.HystrixConstants;
import org.springframework.http.MediaType;
import lombok.Data;
import java.util.Objects;
/**
* @author Dave Syer
* @author Gregor Zurowski
*/
@ConfigurationProperties("turbine.stream")
@Data
public class TurbineStreamProperties {
@Value("${server.port:8989}")
@@ -36,4 +36,53 @@ public class TurbineStreamProperties {
private String destination = HystrixConstants.HYSTRIX_STREAM_DESTINATION;
private String contentType = MediaType.APPLICATION_JSON_VALUE;
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getDestination() {
return destination;
}
public void setDestination(String destination) {
this.destination = destination;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TurbineStreamProperties that = (TurbineStreamProperties) o;
return port == that.port &&
Objects.equals(destination, that.destination) &&
Objects.equals(contentType, that.contentType);
}
@Override
public int hashCode() {
return Objects.hash(port, destination, contentType);
}
@Override
public String toString() {
return new StringBuilder("TurbineStreamProperties{")
.append("port=").append(port).append(", ")
.append("destination='").append(destination).append("', ")
.append("contentType='").append(contentType).append("'}")
.toString();
}
}

View File

@@ -89,13 +89,6 @@
<groupId>com.netflix.turbine</groupId>
<artifactId>turbine-core</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Only needed at compile time -->
<scope>compile</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>

View File

@@ -21,6 +21,8 @@ import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.expression.Expression;
@@ -30,8 +32,6 @@ import org.springframework.expression.spel.support.StandardEvaluationContext;
import com.netflix.turbine.discovery.Instance;
import com.netflix.turbine.discovery.InstanceDiscovery;
import lombok.extern.apachecommons.CommonsLog;
/**
* Class that encapsulates an {@link InstanceDiscovery}
* implementation that uses Spring Cloud Commons (see https://github.com/spring-cloud/spring-cloud-commons)
@@ -45,9 +45,10 @@ import lombok.extern.apachecommons.CommonsLog;
*
* @author Spencer Gibb
*/
@CommonsLog
public class CommonsInstanceDiscovery implements InstanceDiscovery {
private static final Log log = LogFactory.getLog(CommonsInstanceDiscovery.class);
private static final String DEFAULT_CLUSTER_NAME_EXPRESSION = "serviceId";
protected static final String PORT_KEY = "port";
protected static final String SECURE_PORT_KEY = "securePort";

View File

@@ -27,8 +27,8 @@ import com.netflix.appinfo.InstanceInfo.InstanceStatus;
import com.netflix.discovery.EurekaClient;
import com.netflix.discovery.shared.Application;
import com.netflix.turbine.discovery.Instance;
import lombok.extern.apachecommons.CommonsLog;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* Class that encapsulates an {@link com.netflix.turbine.discovery.InstanceDiscovery}
@@ -43,9 +43,10 @@ import lombok.extern.apachecommons.CommonsLog;
*
* @author Spencer Gibb
*/
@CommonsLog
public class EurekaInstanceDiscovery extends CommonsInstanceDiscovery {
private static final Log log = LogFactory.getLog(EurekaInstanceDiscovery.class);
private static final String EUREKA_DEFAULT_CLUSTER_NAME_EXPRESSION = "appName";
private static final String ASG_KEY = "asg";

View File

@@ -31,16 +31,18 @@ import com.netflix.turbine.monitor.cluster.AggregateClusterMonitor;
import com.netflix.turbine.monitor.cluster.ClusterMonitor;
import com.netflix.turbine.monitor.cluster.ClusterMonitorFactory;
import lombok.extern.apachecommons.CommonsLog;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import static com.netflix.turbine.monitor.cluster.AggregateClusterMonitor.AggregatorClusterMonitorConsole;
/**
* @author Spencer Gibb
*/
@CommonsLog
public class SpringAggregatorFactory implements ClusterMonitorFactory<AggDataFromCluster> {
private static final Log log = LogFactory.getLog(SpringAggregatorFactory.class);
private static final DynamicStringProperty aggClusters = DynamicPropertyFactory
.getInstance().getStringProperty("turbine.aggregator.clusterConfig", null);

View File

@@ -18,16 +18,15 @@ package org.springframework.cloud.netflix.turbine;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import lombok.Data;
/**
* @author Spencer Gibb
* @author Gregor Zurowski
*/
@Data
@ConfigurationProperties("turbine")
public class TurbineProperties {
@@ -48,4 +47,53 @@ public class TurbineProperties {
}
return null;
}
public String getClusterNameExpression() {
return clusterNameExpression;
}
public void setClusterNameExpression(String clusterNameExpression) {
this.clusterNameExpression = clusterNameExpression;
}
public String getAppConfig() {
return appConfig;
}
public void setAppConfig(String appConfig) {
this.appConfig = appConfig;
}
public boolean isCombineHostPort() {
return combineHostPort;
}
public void setCombineHostPort(boolean combineHostPort) {
this.combineHostPort = combineHostPort;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TurbineProperties that = (TurbineProperties) o;
return Objects.equals(clusterNameExpression, that.clusterNameExpression) &&
Objects.equals(appConfig, that.appConfig) &&
Objects.equals(combineHostPort, that.combineHostPort);
}
@Override
public int hashCode() {
return Objects.hash(clusterNameExpression, appConfig, combineHostPort);
}
@Override
public String toString() {
return new StringBuilder("TurbineProperties{")
.append("clusterNameExpression='").append(clusterNameExpression).append("', ")
.append("appConfig='").append(appConfig).append("', ")
.append("combineHostPort=").append(combineHostPort).append("}")
.toString();
}
}