Make feign.Request.Options as refreshable beans inside Feign clients (#526)

This commit is contained in:
Jasbir
2021-04-19 21:57:28 +05:30
committed by GitHub
parent 96268d16da
commit e47255e79d
10 changed files with 486 additions and 9 deletions

View File

@@ -7,6 +7,7 @@
|feign.client.decode-slash | `true` | Feign clients do not encode slash `/` characters by default. To change this behavior, set the `decodeSlash` to `false`.
|feign.client.default-config | `default` |
|feign.client.default-to-properties | `true` |
|feign.client.refresh-enabled | `false` | Enables options value refresh capability for Feign.
|feign.compression.request.enabled | `false` | Enables the request sent by Feign to be compressed.
|feign.compression.request.mime-types | `[text/xml, application/xml, application/json]` | The list of supported mime types.
|feign.compression.request.min-request-size | `2048` | The minimum threshold content size.

View File

@@ -717,6 +717,17 @@ You may consider enabling Jackson Modules for the support `org.springframework.d
feign.autoconfiguration.jackson.enabled=true
----
=== Spring `@RefreshScope` Support
If Feign client refresh is enabled, each feign client is created with `feign.Request.Options` as a refresh-scoped bean. This means properties such as `connectTimeout` and `readTimeout` can be refreshed against any Feign client instance through `POST /actuator/refresh`.
By default, refresh behavior in Feign clients is disabled. Use the following property to enable refresh behavior:
[source,java]
----
feign.client.refresh-enabled=true
----
TIP: DO NOT annotate the `@FeignClient` interface with the `@RefreshScope` annotation.
== Configuration properties
To see the list of all Spring Cloud OpenFeign related configuration properties please check link:appendix.html[the Appendix page].

View File

@@ -66,6 +66,7 @@ import org.springframework.util.StringUtils;
* @author Marcin Grzejszczak
* @author Jonatan Ivanov
* @author Sam Kruglov
* @author Jasbir Singh
*/
public class FeignClientFactoryBean
implements FactoryBean<Object>, InitializingBean, ApplicationContextAware, BeanFactoryAware {
@@ -105,6 +106,8 @@ public class FeignClientFactoryBean
private boolean followRedirects = new Request.Options().isFollowRedirects();
private boolean refreshableClient = false;
private final List<FeignBuilderCustomizer> additionalCustomizers = new ArrayList<>();
@Override
@@ -188,6 +191,10 @@ public class FeignClientFactoryBean
}
}
Request.Options options = getInheritedAwareOptional(context, Request.Options.class);
if (options == null) {
options = getOptionsByName(context, contextId);
}
if (options != null) {
builder.options(options);
readTimeoutMillis = options.readTimeoutMillis();
@@ -231,12 +238,15 @@ public class FeignClientFactoryBean
builder.logLevel(config.getLoggerLevel());
}
connectTimeoutMillis = config.getConnectTimeout() != null ? config.getConnectTimeout() : connectTimeoutMillis;
readTimeoutMillis = config.getReadTimeout() != null ? config.getReadTimeout() : readTimeoutMillis;
followRedirects = config.isFollowRedirects() != null ? config.isFollowRedirects() : followRedirects;
if (!refreshableClient) {
connectTimeoutMillis = config.getConnectTimeout() != null ? config.getConnectTimeout()
: connectTimeoutMillis;
readTimeoutMillis = config.getReadTimeout() != null ? config.getReadTimeout() : readTimeoutMillis;
followRedirects = config.isFollowRedirects() != null ? config.isFollowRedirects() : followRedirects;
builder.options(new Request.Options(connectTimeoutMillis, TimeUnit.MILLISECONDS, readTimeoutMillis,
TimeUnit.MILLISECONDS, followRedirects));
builder.options(new Request.Options(connectTimeoutMillis, TimeUnit.MILLISECONDS, readTimeoutMillis,
TimeUnit.MILLISECONDS, followRedirects));
}
if (config.getRetryer() != null) {
Retryer retryer = getOrInstantiate(config.getRetryer());
@@ -342,6 +352,20 @@ public class FeignClientFactoryBean
"No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-loadbalancer?");
}
/**
* Meant to get Options bean from context with bean name.
* @param context context of Feign client
* @param contextId name of feign client
* @return returns Options found in context
*/
protected Request.Options getOptionsByName(FeignContext context, String contextId) {
if (refreshableClient) {
return context.getInstance(contextId, Request.Options.class.getCanonicalName() + "-" + contextId,
Request.Options.class);
}
return null;
}
@Override
public Object getObject() {
return getTarget();
@@ -504,6 +528,10 @@ public class FeignClientFactoryBean
this.fallbackFactory = fallbackFactory;
}
public void setRefreshableClient(boolean refreshableClient) {
this.refreshableClient = refreshableClient;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -520,13 +548,14 @@ public class FeignClientFactoryBean
&& Objects.equals(path, that.path) && Objects.equals(type, that.type) && Objects.equals(url, that.url)
&& Objects.equals(connectTimeoutMillis, that.connectTimeoutMillis)
&& Objects.equals(readTimeoutMillis, that.readTimeoutMillis)
&& Objects.equals(followRedirects, that.followRedirects);
&& Objects.equals(followRedirects, that.followRedirects)
&& Objects.equals(refreshableClient, that.refreshableClient);
}
@Override
public int hashCode() {
return Objects.hash(applicationContext, beanFactory, decode404, inheritParentContext, fallback, fallbackFactory,
name, path, type, url, readTimeoutMillis, connectTimeoutMillis, followRedirects);
name, path, type, url, readTimeoutMillis, connectTimeoutMillis, followRedirects, refreshableClient);
}
@Override
@@ -538,8 +567,8 @@ public class FeignClientFactoryBean
.append(", ").append("beanFactory=").append(beanFactory).append(", ").append("fallback=")
.append(fallback).append(", ").append("fallbackFactory=").append(fallbackFactory).append("}")
.append("connectTimeoutMillis=").append(connectTimeoutMillis).append("}").append("readTimeoutMillis=")
.append(readTimeoutMillis).append("}").append("followRedirects=").append(followRedirects).append("}")
.toString();
.append(readTimeoutMillis).append("}").append("followRedirects=").append(followRedirects)
.append("refreshableClient=").append(refreshableClient).append("}").toString();
}
@Override

View File

@@ -29,6 +29,9 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import feign.Request;
import org.springframework.aop.scope.ScopedProxyUtils;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
import org.springframework.beans.factory.annotation.AnnotatedGenericBeanDefinition;
@@ -63,6 +66,7 @@ import org.springframework.util.StringUtils;
* @author Michal Domagala
* @author Marcin Grzejszczak
* @author Olga Maciaszek-Sharma
* @author Jasbir Singh
*/
class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {
@@ -213,6 +217,7 @@ class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar, ResourceLo
factoryBean.setName(name);
factoryBean.setContextId(contextId);
factoryBean.setType(clazz);
factoryBean.setRefreshableClient(isClientRefreshEnabled());
BeanDefinitionBuilder definition = BeanDefinitionBuilder.genericBeanDefinition(clazz, () -> {
factoryBean.setUrl(getUrl(beanFactory, attributes));
factoryBean.setPath(getPath(beanFactory, attributes));
@@ -249,6 +254,8 @@ class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar, ResourceLo
BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className, qualifiers);
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
registerOptionsBeanDefinition(registry, contextId);
}
private void validate(Map<String, Object> attributes) {
@@ -408,4 +415,28 @@ class FeignClientsRegistrar implements ImportBeanDefinitionRegistrar, ResourceLo
this.environment = environment;
}
/**
* This method is meant to create {@link Request.Options} beans definition with
* refreshScope.
* @param registry spring bean definition registry
* @param contextId name of feign client
*/
private void registerOptionsBeanDefinition(BeanDefinitionRegistry registry, String contextId) {
if (isClientRefreshEnabled()) {
String beanName = Request.Options.class.getCanonicalName() + "-" + contextId;
BeanDefinitionBuilder definitionBuilder = BeanDefinitionBuilder
.genericBeanDefinition(OptionsFactoryBean.class);
definitionBuilder.setScope("refresh");
definitionBuilder.addPropertyValue("contextId", contextId);
BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(definitionBuilder.getBeanDefinition(),
beanName);
definitionHolder = ScopedProxyUtils.createScopedProxy(definitionHolder, registry, true);
BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, registry);
}
}
private boolean isClientRefreshEnabled() {
return environment.getProperty("feign.client.refresh-enabled", Boolean.class, false);
}
}

View File

@@ -30,6 +30,7 @@ import org.springframework.lang.Nullable;
* @author Spencer Gibb
* @author Dave Syer
* @author Matt King
* @author Jasbir Singh
*/
public class FeignContext extends NamedContextFactory<FeignClientSpecification> {
@@ -52,4 +53,8 @@ public class FeignContext extends NamedContextFactory<FeignClientSpecification>
return getContext(name).getBeansOfType(type);
}
public <T> T getInstance(String contextName, String beanName, Class<T> type) {
return getContext(contextName).getBean(beanName, type);
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.openfeign;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import feign.Request;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
/**
* This factory bean is meant to create {@link Request.Options} instance as per the
* applicable configurations.
*
* @author Jasbir Singh
*/
public class OptionsFactoryBean implements FactoryBean<Request.Options>, ApplicationContextAware {
private ApplicationContext applicationContext;
private String contextId;
private Request.Options options;
@Override
public Class<?> getObjectType() {
return Request.Options.class;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public Request.Options getObject() throws Exception {
if (options != null) {
return options;
}
options = new Request.Options();
FeignClientProperties properties = applicationContext.getBean(FeignClientProperties.class);
options = createOptionsWithApplicableValues(properties.getConfig().get(properties.getDefaultConfig()), options);
options = createOptionsWithApplicableValues(properties.getConfig().get(contextId), options);
return options;
}
public void setContextId(String contextId) {
this.contextId = contextId;
}
private Request.Options createOptionsWithApplicableValues(
FeignClientProperties.FeignClientConfiguration clientConfiguration, Request.Options options) {
if (Objects.isNull(clientConfiguration)) {
return options;
}
int connectTimeoutMillis = Objects.nonNull(clientConfiguration.getConnectTimeout())
? clientConfiguration.getConnectTimeout() : options.connectTimeoutMillis();
int readTimeoutMillis = Objects.nonNull(clientConfiguration.getReadTimeout())
? clientConfiguration.getReadTimeout() : options.readTimeoutMillis();
boolean followRedirects = Objects.nonNull(clientConfiguration.isFollowRedirects())
? clientConfiguration.isFollowRedirects() : options.isFollowRedirects();
return new Request.Options(connectTimeoutMillis, TimeUnit.MILLISECONDS, readTimeoutMillis,
TimeUnit.MILLISECONDS, followRedirects);
}
}

View File

@@ -55,6 +55,12 @@
"type": "java.lang.Boolean",
"description": "Enables metrics capability for Feign.",
"defaultValue": "true"
},
{
"name": "feign.client.refresh-enabled",
"type": "java.lang.Boolean",
"description": "Enables options value refresh capability for Feign.",
"defaultValue": "false"
}
]
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.openfeign;
import java.util.concurrent.TimeUnit;
import feign.Request;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.context.scope.refresh.RefreshScope;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.context.support.GenericWebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Jasbir Singh
*/
@SpringBootTest
@TestPropertySource("classpath:feign-refreshable-properties.properties")
@DirtiesContext
public class FeignClientWithRefreshableOptionsTest {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private RefreshScope refreshScope;
@Autowired
private Application.RefreshableClient refreshableClient;
@Autowired
private Application.ReadTimeoutClient readTimeoutClient;
@Autowired
private Application.ConnectTimeoutClient connectTimeoutClient;
@Autowired
private Application.OverrideOptionsClient overrideOptionsClient;
@Autowired
private FeignClientProperties clientProperties;
@Test
public void overridedOptionsBeanShouldBePresentInsteadOfRefreshable() {
OptionsTestClient.OptionsResponseForTests options = overrideOptionsClient.override();
assertConnectionAndReadTimeout(options, 1, 1);
}
@Test
public void refreshScopeBeanDefinitionShouldBePresent() {
BeanDefinition beanDefinition = ((GenericWebApplicationContext) applicationContext)
.getBeanDefinition(Request.Options.class.getCanonicalName() + "-" + "refreshableClient");
BeanDefinition originBeanDefinition = beanDefinition.getOriginatingBeanDefinition();
assertThat(originBeanDefinition.getBeanClassName()).isEqualTo(OptionsFactoryBean.class.getCanonicalName());
assertThat(originBeanDefinition.getScope()).isEqualTo("refresh");
}
@Test
public void withConfigDefaultConnectTimeoutAndReadTimeout() {
OptionsTestClient.OptionsResponseForTests options = refreshableClient.refreshable();
assertConnectionAndReadTimeout(options, 5000, 5000);
}
@Test
public void readTimeoutShouldWorkWhenConnectTimeoutNotSet() {
OptionsTestClient.OptionsResponseForTests options = readTimeoutClient.readTimeout();
assertConnectionAndReadTimeout(options, 5000, 2000);
}
@Test
public void connectTimeoutShouldWorkWhenReadTimeoutNotSet() {
OptionsTestClient.OptionsResponseForTests options = connectTimeoutClient.connectTimeout();
assertConnectionAndReadTimeout(options, 2000, 5000);
}
@Test
public void connectTimeoutShouldNotChangeWithoutContextRefresh() {
OptionsTestClient.OptionsResponseForTests options = connectTimeoutClient.connectTimeout();
assertConnectionAndReadTimeout(options, 2000, 5000);
clientProperties.getConfig().get("connectTimeout").setConnectTimeout(5000);
options = connectTimeoutClient.connectTimeout();
assertConnectionAndReadTimeout(options, 2000, 5000);
}
@Test
public void connectTimeoutShouldChangeAfterContextRefresh() {
OptionsTestClient.OptionsResponseForTests options = connectTimeoutClient.connectTimeout();
assertConnectionAndReadTimeout(options, 2000, 5000);
clientProperties.getConfig().get("connectTimeout").setConnectTimeout(5000);
refreshScope.refreshAll();
options = connectTimeoutClient.connectTimeout();
assertConnectionAndReadTimeout(options, 5000, 5000);
}
private void assertConnectionAndReadTimeout(OptionsTestClient.OptionsResponseForTests options,
int expectedConnectTimeoutInMillis, int expectedReadTimeoutInMillis) {
assertThat(options.getConnectTimeout()).isEqualTo(expectedConnectTimeoutInMillis);
assertThat(options.getReadTimeout()).isEqualTo(expectedReadTimeoutInMillis);
}
@Configuration
@EnableAutoConfiguration
@EnableConfigurationProperties(FeignClientProperties.class)
@EnableFeignClients(clients = { Application.OverrideOptionsClient.class, Application.RefreshableClient.class,
Application.ReadTimeoutClient.class, Application.ConnectTimeoutClient.class })
protected static class Application {
@Bean
OptionsTestClient client() {
return new OptionsTestClient();
}
@FeignClient(name = "overrideOptionsClient", configuration = OverrideConfig.class)
protected interface OverrideOptionsClient {
@GetMapping("/override")
OptionsTestClient.OptionsResponseForTests override();
}
@FeignClient(name = "refreshableClient")
protected interface RefreshableClient {
@GetMapping("/refreshable")
OptionsTestClient.OptionsResponseForTests refreshable();
}
@FeignClient(name = "readTimeout")
protected interface ReadTimeoutClient {
@GetMapping("/readTimeout")
OptionsTestClient.OptionsResponseForTests readTimeout();
}
@FeignClient(name = "connectTimeout")
protected interface ConnectTimeoutClient {
@GetMapping("/connectTimeout")
OptionsTestClient.OptionsResponseForTests connectTimeout();
}
@Configuration
protected class OverrideConfig {
@Bean
public Request.Options options() {
return new Request.Options(1, TimeUnit.MILLISECONDS, 1, TimeUnit.MILLISECONDS, true);
}
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.openfeign;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import feign.Client;
import feign.Request;
import feign.Response;
/**
* @author Jasbir Singh
*/
public class OptionsTestClient implements Client {
private static ObjectMapper mapper;
static {
mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()).configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
@Override
public Response execute(Request request, Request.Options options) throws IOException {
return Response.builder().status(200).request(request).headers(headers()).body(prepareResponse(options))
.build();
}
private Map<String, Collection<String>> headers() {
Map<String, Collection<String>> headers = new LinkedHashMap<>();
headers.put("Content-Type", Collections.singletonList("application/json"));
return headers;
}
private byte[] prepareResponse(Request.Options options) {
try {
OptionsResponseForTests response = new OptionsResponseForTests(options.connectTimeoutMillis(),
TimeUnit.MILLISECONDS, options.readTimeoutMillis(), TimeUnit.MILLISECONDS);
return mapper.writeValueAsString(response).getBytes();
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
static class OptionsResponseForTests {
private long connectTimeout;
private TimeUnit connectTimeoutUnit;
private long readTimeout;
private TimeUnit readTimeoutUnit;
OptionsResponseForTests(long connectTimeout, TimeUnit connectTimeoutUnit, long readTimeout,
TimeUnit readTimeoutUnit) {
this.connectTimeout = connectTimeout;
this.connectTimeoutUnit = connectTimeoutUnit;
this.readTimeout = readTimeout;
this.readTimeoutUnit = readTimeoutUnit;
}
public long getConnectTimeout() {
return connectTimeout;
}
public TimeUnit getConnectTimeoutUnit() {
return connectTimeoutUnit;
}
public long getReadTimeout() {
return readTimeout;
}
public TimeUnit getReadTimeoutUnit() {
return readTimeoutUnit;
}
@Override
public String toString() {
return "OptionsResponseForTests{" + "connectTimeout=" + connectTimeout + ", connectTimeoutUnit="
+ connectTimeoutUnit + ", readTimeout=" + readTimeout + ", readTimeoutUnit=" + readTimeoutUnit
+ '}';
}
}
}

View File

@@ -0,0 +1,10 @@
# This configuration used by test class FeignClientWithRefreshableOptionsTest
logging.level.org.springframework.cloud.openfeign=debug
feign.client.default-to-properties=true
feign.client.default-config=default
feign.client.refresh-enabled=true
feign.client.config.default.connectTimeout=5000
feign.client.config.default.readTimeout=5000
feign.client.config.default.loggerLevel=full
feign.client.config.connectTimeout.connectTimeout=2000
feign.client.config.readTimeout.readTimeout=2000