Browse Source

Merge pull request #699 from stephaneLeroy/master

* pull699:
  Add Zuul Proxy regex serviceId to route mapping
pull/6/head
Spencer Gibb 9 years ago
parent
commit
4720b78db5
  1. 20
      docs/src/main/asciidoc/spring-cloud-netflix.adoc
  2. 34
      spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java
  3. 16
      spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocator.java
  4. 18
      spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ServiceRouteMapper.java
  5. 13
      spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleServiceRouteMapper.java
  6. 20
      spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java
  7. 70
      spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapper.java
  8. 45
      spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocatorTests.java
  9. 127
      spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperIntegrationTests.java
  10. 48
      spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperTests.java

20
docs/src/main/asciidoc/spring-cloud-netflix.adoc

@ -1093,6 +1093,26 @@ users: @@ -1093,6 +1093,26 @@ users:
listOfServers: example.com,google.com
----
You can provide convention between serviceId and routes using regexmapper.
It uses regular expression named group to extract variables from serviceId and inject them
into a route pattern.
.application.yml
[source,yaml]
----
zuul:
regexMapper:
enabled: true
servicePattern: "(?<name>^.+)-(?<version>v.+$)"
routePattern: "${version}/${name}"
----
This means that a serviceId "myusers-v1" will be mapped to route "/v1/myusers/**".
Any regular expression is accepted but all named group must be present in both servicePattern and routePattern.
If servicePattern do not match a serviceId, the default behavior is used. In exemple above,
a serviceId "myusers" will be mapped to route "/myusers/**" (no version detected)
These feature is disable by default and is only applied to discovered services.
To add a prefix to all mappings, set `zuul.prefix` to a value, such as
`/api`. The proxy prefix is stripped from the request before the
request is forwarded by default (switch this behaviour off with

34
spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulProxyConfiguration.java

@ -21,6 +21,7 @@ import org.springframework.boot.actuate.endpoint.Endpoint; @@ -21,6 +21,7 @@ import org.springframework.boot.actuate.endpoint.Endpoint;
import org.springframework.boot.actuate.trace.TraceRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.cloud.client.actuator.HasFeatures;
import org.springframework.cloud.client.discovery.DiscoveryClient;
@ -32,8 +33,11 @@ import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEven @@ -32,8 +33,11 @@ import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEven
import org.springframework.cloud.netflix.ribbon.SpringClientFactory;
import org.springframework.cloud.netflix.zuul.filters.ProxyRequestHelper;
import org.springframework.cloud.netflix.zuul.filters.ProxyRouteLocator;
import org.springframework.cloud.netflix.zuul.filters.ServiceRouteMapper;
import org.springframework.cloud.netflix.zuul.filters.SimpleServiceRouteMapper;
import org.springframework.cloud.netflix.zuul.filters.ZuulProperties;
import org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter;
import org.springframework.cloud.netflix.zuul.filters.regex.RegExServiceRouteMapper;
import org.springframework.cloud.netflix.zuul.filters.route.RestClientRibbonCommandFactory;
import org.springframework.cloud.netflix.zuul.filters.route.RibbonCommandFactory;
import org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter;
@ -66,6 +70,9 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { @@ -66,6 +70,9 @@ public class ZuulProxyConfiguration extends ZuulConfiguration {
@Autowired
private ServerProperties server;
@Autowired
private ServiceRouteMapper serviceRouteMapper;
@Override
public HasFeatures zuulFeature() {
return HasFeatures.namedFeature("Zuul (Discovery)", ZuulProxyConfiguration.class);
@ -75,7 +82,7 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { @@ -75,7 +82,7 @@ public class ZuulProxyConfiguration extends ZuulConfiguration {
@Override
public ProxyRouteLocator routeLocator() {
return new ProxyRouteLocator(this.server.getServletPrefix(), this.discovery,
this.zuulProperties);
this.zuulProperties, serviceRouteMapper);
}
@Bean
@ -86,8 +93,8 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { @@ -86,8 +93,8 @@ public class ZuulProxyConfiguration extends ZuulConfiguration {
// pre filters
@Bean
public PreDecorationFilter preDecorationFilter() {
return new PreDecorationFilter(routeLocator(),
public PreDecorationFilter preDecorationFilter(ProxyRouteLocator routeLocator) {
return new PreDecorationFilter(routeLocator,
this.zuulProperties.isAddProxyHeaders());
}
@ -117,6 +124,27 @@ public class ZuulProxyConfiguration extends ZuulConfiguration { @@ -117,6 +124,27 @@ public class ZuulProxyConfiguration extends ZuulConfiguration {
return new ZuulRefreshListener();
}
@Configuration
@ConditionalOnProperty(name = "zuul.regexMapper.enabled", matchIfMissing = false)
protected static class RegexServiceRouteMapperConfiguration {
@Bean
public ServiceRouteMapper serviceRouteMapper(ZuulProperties props) {
return new RegExServiceRouteMapper(props.getRegexMapper().getServicePattern(),
props.getRegexMapper().getRoutePattern());
}
}
@Configuration
@ConditionalOnMissingBean(ServiceRouteMapper.class)
protected static class SimpleServiceRouteMapperConfiguration {
@Bean
public ServiceRouteMapper serviceRouteMapper() {
return new SimpleServiceRouteMapper();
}
}
@Configuration
@ConditionalOnClass(Endpoint.class)
protected static class RoutesEndpointConfiguration {

16
spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocator.java

@ -55,6 +55,8 @@ public class ProxyRouteLocator implements RouteLocator { @@ -55,6 +55,8 @@ public class ProxyRouteLocator implements RouteLocator {
private String servletPath;
private ServiceRouteMapper serviceRouteMapper;
public ProxyRouteLocator(String servletPath, DiscoveryClient discovery,
ZuulProperties properties) {
if (StringUtils.hasText(servletPath)) { // a servletPath is passed explicitly
@ -75,11 +77,17 @@ public class ProxyRouteLocator implements RouteLocator { @@ -75,11 +77,17 @@ public class ProxyRouteLocator implements RouteLocator {
}
}
}
this.serviceRouteMapper = new SimpleServiceRouteMapper();
this.discovery = discovery;
this.properties = properties;
}
public ProxyRouteLocator(String servletPath, DiscoveryClient discovery,
ZuulProperties properties, ServiceRouteMapper serviceRouteMapper) {
this(servletPath, discovery, properties);
this.serviceRouteMapper = serviceRouteMapper;
}
public void addRoute(String path, String location) {
this.staticRoutes.put(path, new ZuulRoute(path, location));
resetRoutes();
@ -196,7 +204,7 @@ public class ProxyRouteLocator implements RouteLocator { @@ -196,7 +204,7 @@ public class ProxyRouteLocator implements RouteLocator {
for (String serviceId : services) {
// Ignore specifically ignored services and those that were manually
// configured
String key = "/" + serviceId + "/**";
String key = "/" + mapRouteToService(serviceId) + "/**";
if (staticServices.containsKey(serviceId)
&& staticServices.get(serviceId).getUrl() == null) {
// Explicitly configured with no URL, cannot be ignored
@ -238,6 +246,10 @@ public class ProxyRouteLocator implements RouteLocator { @@ -238,6 +246,10 @@ public class ProxyRouteLocator implements RouteLocator {
return values;
}
protected String mapRouteToService(String serviceId) {
return this.serviceRouteMapper.apply(serviceId);
}
protected void addConfiguredRoutes(Map<String, ZuulRoute> routes) {
Map<String, ZuulRoute> routeEntries = this.properties.getRoutes();
for (ZuulRoute entry : routeEntries.values()) {

18
spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ServiceRouteMapper.java

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
package org.springframework.cloud.netflix.zuul.filters;
/**
* @author Stéphane LEROY
*
* Provide a way to apply convention between routes and discovered services name.
*
*/
public interface ServiceRouteMapper {
/**
* Take a service Id (its discovered name) and return a route path.
*
* @param serviceId service discovered name
* @return route path
*/
String apply(String serviceId);
}

13
spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/SimpleServiceRouteMapper.java

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
package org.springframework.cloud.netflix.zuul.filters;
/**
* @author Stéphane Leroy
*
* A simple passthru service route mapper.
*/
public class SimpleServiceRouteMapper implements ServiceRouteMapper {
@Override
public String apply(String serviceId) {
return serviceId;
}
}

20
spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java

@ -21,16 +21,15 @@ import java.util.LinkedHashMap; @@ -21,16 +21,15 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import javax.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
/**
* @author Spencer Gibb
* @author Dave Syer
@ -57,6 +56,8 @@ public class ZuulProperties { @@ -57,6 +56,8 @@ public class ZuulProperties {
private boolean ignoreLocalService = true;
private RegexMapper regexMapper = new RegexMapper();
@PostConstruct
public void init() {
for (Entry<String, ZuulRoute> entry : this.routes.entrySet()) {
@ -73,6 +74,17 @@ public class ZuulProperties { @@ -73,6 +74,17 @@ public class ZuulProperties {
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class RegexMapper {
private boolean enabled = false;
private String servicePattern = "(?<name>.*)-(?<version>v.*$)";
private String routePattern = "${version}/${name}";
}
@Data
@AllArgsConstructor
@NoArgsConstructor

70
spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapper.java

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
package org.springframework.cloud.netflix.zuul.filters.regex;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.cloud.netflix.zuul.filters.ServiceRouteMapper;
import org.springframework.util.StringUtils;
/**
* @author Stéphane Leroy
*
* This service route mapper use Java 7 RegEx named group feature to rewrite a discovered
* service Id into a route.
*
* Ex : If we want to map service Id [rest-service-v1] to /v1/rest-service/** route
* service pattern : "(?<name>.*)-(?<version>v.*$)" route pattern : "${version}/${name}"
*
* /!\ This implementation use Matcher.replaceFirst so only one match will be replace.
*/
public class RegExServiceRouteMapper implements ServiceRouteMapper {
/**
* A RegExp Pattern that extract needed information from a service ID. Ex :
* "(?<name>.*)-(?<version>v.*$)"
*/
private Pattern servicePattern;
/**
* A RegExp that refer to named groups define in servicePattern. Ex :
* "${version}/${name}"
*/
private String routePattern;
public RegExServiceRouteMapper(String servicePattern, String routePattern) {
this.servicePattern = Pattern.compile(servicePattern);
this.routePattern = routePattern;
}
/**
* Use servicePattern to extract groups and routePattern to construct the route.
*
* If there is no matches, the serviceId is returned.
*
* @param serviceId service discovered name
* @return route path
*/
@Override
public String apply(String serviceId) {
Matcher matcher = servicePattern.matcher(serviceId);
String route = matcher.replaceFirst(routePattern);
route = cleanRoute(route);
return (StringUtils.hasText(route) ? route : serviceId);
}
/**
* Route with regex and replace can be a bit messy when used with conditional named
* group. We clean here first and trailing '/' and remove multiple consecutive '/'
* @param route
* @return
*/
private String cleanRoute(final String route) {
String routeToClean = route.replaceAll("/{2,}", "/");
if (routeToClean.startsWith("/")) {
routeToClean = routeToClean.substring(1);
}
if (routeToClean.endsWith("/")) {
routeToClean = routeToClean.substring(0, routeToClean.length() - 1);
}
return routeToClean;
}
}

45
spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/ProxyRouteLocatorTests.java

@ -16,14 +16,6 @@ @@ -16,14 +16,6 @@
package org.springframework.cloud.netflix.zuul.filters;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.BDDMockito.given;
import static org.mockito.MockitoAnnotations.initMocks;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@ -35,8 +27,17 @@ import org.springframework.cloud.client.DefaultServiceInstance; @@ -35,8 +27,17 @@ import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.netflix.zuul.filters.ProxyRouteLocator.ProxyRouteSpec;
import org.springframework.cloud.netflix.zuul.filters.ZuulProperties.ZuulRoute;
import org.springframework.cloud.netflix.zuul.filters.regex.RegExServiceRouteMapper;
import org.springframework.core.env.ConfigurableEnvironment;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.BDDMockito.given;
import static org.mockito.MockitoAnnotations.initMocks;
/**
* @author Spencer Gibb
* @author Dave Syer
@ -518,6 +519,34 @@ public class ProxyRouteLocatorTests { @@ -518,6 +519,34 @@ public class ProxyRouteLocatorTests {
assertMapping(routesMap, MYSERVICE);
}
@Test
public void testRegExServiceRouteMapperNoServiceIdMatches() {
given(this.discovery.getServices()).willReturn(Collections.singletonList(MYSERVICE));
RegExServiceRouteMapper regExServiceRouteMapper = new RegExServiceRouteMapper(properties.getRegexMapper().getServicePattern(),
properties.getRegexMapper().getRoutePattern());
ProxyRouteLocator routeLocator = new ProxyRouteLocator("/", this.discovery,
this.properties, regExServiceRouteMapper);
Map<String, String> routesMap = routeLocator.getRoutes();
assertNotNull("routesMap was null", routesMap);
assertFalse("routesMap was empty", routesMap.isEmpty());
assertMapping(routesMap, MYSERVICE);
}
@Test
public void testRegExServiceRouteMapperServiceIdMatches() {
given(this.discovery.getServices()).willReturn(Collections.singletonList("rest-service-v1"));
RegExServiceRouteMapper regExServiceRouteMapper = new RegExServiceRouteMapper(properties.getRegexMapper().getServicePattern(),
properties.getRegexMapper().getRoutePattern());
ProxyRouteLocator routeLocator = new ProxyRouteLocator("/", this.discovery,
this.properties, regExServiceRouteMapper);
Map<String, String> routesMap = routeLocator.getRoutes();
assertNotNull("routesMap was null", routesMap);
assertFalse("routesMap was empty", routesMap.isEmpty());
assertMapping(routesMap, "rest-service-v1", "v1/rest-service");
}
protected void assertMapping(Map<String, String> routesMap, String serviceId) {
assertMapping(routesMap, serviceId, serviceId);

127
spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperIntegrationTests.java

@ -0,0 +1,127 @@ @@ -0,0 +1,127 @@
package org.springframework.cloud.netflix.zuul.filters.regex;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.TestRestTemplate;
import org.springframework.boot.test.WebIntegrationTest;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.netflix.ribbon.RibbonClient;
import org.springframework.cloud.netflix.ribbon.StaticServerList;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.cloud.netflix.zuul.RoutesEndpoint;
import org.springframework.cloud.netflix.zuul.filters.ProxyRouteLocator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import com.netflix.loadbalancer.Server;
import com.netflix.loadbalancer.ServerList;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.cloud.netflix.zuul.filters.regex.RegExServiceRouteMapperIntegrationTests.SERVICE_ID;
/**
* @author Stéphane Leroy
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = SampleCustomZuulProxyApplication.class)
@WebIntegrationTest(value = { "spring.application.name=regex-test-application",
"spring.jmx.enabled=true" }, randomPort = true)
@TestPropertySource(properties = { "eureka.client.enabled=false",
"zuul.regexMapper.enabled=true",
"zuul.regexMapper.servicePattern=(?<domain>^.+)-(?<name>.+)-(?<version>v.+$)",
"zuul.regexMapper.routePattern=${version}/${domain}/${name}" })
@DirtiesContext
public class RegExServiceRouteMapperIntegrationTests {
protected static final String SERVICE_ID = "domain-service-v1";
@Value("${local.server.port}")
private int port;
@Autowired
private ProxyRouteLocator routes;
@Autowired
private RoutesEndpoint endpoint;
@Test
public void getRegexMappedService() {
endpoint.reset();
ResponseEntity<String> result = new TestRestTemplate().exchange(
"http://localhost:" + this.port + "/v1/domain/service/get/1",
HttpMethod.GET, new HttpEntity<>((Void) null), String.class);
assertEquals(HttpStatus.OK, result.getStatusCode());
assertEquals("Get 1", result.getBody());
}
@Test
public void getStaticRoute() {
this.routes.addRoute("/self/**", "http://localhost:" + this.port);
endpoint.reset();
ResponseEntity<String> result = new TestRestTemplate().exchange(
"http://localhost:" + this.port + "/self/get/1", HttpMethod.GET,
new HttpEntity<>((Void) null), String.class);
assertEquals(HttpStatus.OK, result.getStatusCode());
assertEquals("Get 1", result.getBody());
}
}
@Configuration
@EnableAutoConfiguration
@RestController
@EnableZuulProxy
@RibbonClient(value = SERVICE_ID, configuration = SimpleRibbonClientConfiguration.class)
class SampleCustomZuulProxyApplication {
@Bean
public DiscoveryClient discoveryClient() {
DiscoveryClient discoveryClient = mock(DiscoveryClient.class);
List<String> services = new ArrayList<>();
services.add(SERVICE_ID);
when(discoveryClient.getServices()).thenReturn(services);
return discoveryClient;
}
@RequestMapping(value = "/get/{id}", method = RequestMethod.GET)
public String get(@PathVariable String id) {
return "Get " + id;
}
public static void main(String[] args) {
SpringApplication.run(SampleCustomZuulProxyApplication.class, args);
}
}
@Configuration
class SimpleRibbonClientConfiguration {
@Value("${local.server.port}")
private int port = 0;
@Bean
public ServerList<Server> ribbonServerList() {
return new StaticServerList<>(new Server("localhost", this.port));
}
}

48
spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/filters/regex/RegExServiceRouteMapperTests.java

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
package org.springframework.cloud.netflix.zuul.filters.regex;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* @author Stéphane Leroy
*/
public class RegExServiceRouteMapperTests {
/**
* Service pattern that follow convention {domain}-{name}-{version}. The name is
* optional
*/
public static final String SERVICE_PATTERN = "(?<domain>^\\w+)(-(?<name>\\w+)-|-)(?<version>v\\d+$)";
public static final String ROUTE_PATTERN = "${version}/${domain}/${name}";
@Test
public void test_return_mapped_route_if_serviceid_matches() {
RegExServiceRouteMapper toTest = new RegExServiceRouteMapper(SERVICE_PATTERN,
ROUTE_PATTERN);
assertEquals("service version convention", "v1/rest/service",
toTest.apply("rest-service-v1"));
}
@Test
public void test_return_serviceid_if_no_matches() {
RegExServiceRouteMapper toTest = new RegExServiceRouteMapper(SERVICE_PATTERN,
ROUTE_PATTERN);
// No version here
assertEquals("No matches for this service id", "rest-service",
toTest.apply("rest-service"));
}
@Test
public void test_route_should_be_cleaned_before_returned() {
// Messy patterns
RegExServiceRouteMapper toTest = new RegExServiceRouteMapper(SERVICE_PATTERN
+ "(?<nevermatch>.)?", "/${version}/${nevermatch}/${domain}/${name}/");
assertEquals("No matches for this service id", "v1/domain/service",
toTest.apply("domain-service-v1"));
assertEquals("No matches for this service id", "v1/domain",
toTest.apply("domain-v1"));
}
}
Loading…
Cancel
Save