Watch consul config and refresh when there are changes.

fixes gh-19
This commit is contained in:
Spencer Gibb
2016-02-08 15:31:36 -07:00
parent 3912743d77
commit d1141a830c
9 changed files with 419 additions and 8 deletions

View File

@@ -22,6 +22,11 @@
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2013-2016 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
*
* http://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.consul.config;
import java.io.Closeable;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.PostConstruct;
import com.ecwid.consul.v1.ConsulClient;
import com.ecwid.consul.v1.QueryParams;
import com.ecwid.consul.v1.Response;
import com.ecwid.consul.v1.kv.model.GetValue;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.scheduling.annotation.Scheduled;
import lombok.Data;
import lombok.extern.apachecommons.CommonsLog;
/**
* @author Spencer Gibb
*/
@CommonsLog
public class ConfigWatch implements Closeable, ApplicationEventPublisherAware {
private final List<String> contexts;
private final ConsulClient consul;
private AtomicBoolean running = new AtomicBoolean(false);
private ApplicationEventPublisher publisher;
private HashMap<String, Long> consulIndexes = new HashMap<>();
public ConfigWatch(List<String> contexts, ConsulClient consul) {
this.contexts = contexts;
this.consul = consul;
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
@PostConstruct
public void start() {
this.running.compareAndSet(false, true);
}
@Scheduled(fixedDelayString = "${spring.cloud.consul.config.watch.delay:10}")
public void watchConfigKeyValues() {
if (this.running.get()) {
for (String context : this.contexts) {
if (!context.endsWith("/")) {
context = context + "/";
}
try {
Long currentIndex = this.consulIndexes.get(context);
if (currentIndex == null) {
currentIndex = -1L;
}
Response<List<GetValue>> response = this.consul.getKVValues(context, new QueryParams(2, currentIndex));
Long newIndex = response.getConsulIndex();
if (newIndex != null && !newIndex.equals(currentIndex)) {
// don't publish the same index again, don't publish the first time (-1) so index can be primed
if (!this.consulIndexes.containsValue(newIndex) && !currentIndex.equals(-1L)) {
this.publisher.publishEvent(new ConsulConfigRefreshEvent(this, new RefreshEventData(context, currentIndex, newIndex)));
}
this.consulIndexes.put(context, newIndex);
}
} catch (Exception e) {
log.error("Error initializing listener for context " + context, e);
}
}
}
}
@Override
public void close() {
this.running.compareAndSet(true, false);
}
@Data
static class RefreshEventData {
private final String context;
private final Long prevIndex;
private final Long newIndex;
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2013-2016 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
*
* http://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.consul.config;
import com.ecwid.consul.v1.ConsulClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.endpoint.RefreshEndpoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author Spencer Gibb
*/
@Configuration
public class ConsulConfigAutoConfiguration {
@Configuration
@ConditionalOnClass(RefreshEndpoint.class)
protected static class ConsulRefreshConfiguration {
@Bean
@ConditionalOnBean(RefreshEndpoint.class)
public ConsulConfigRefreshListener zookeeperConfigRefreshListener(
RefreshEndpoint refreshEndpoint) {
return new ConsulConfigRefreshListener(refreshEndpoint);
}
@Bean
@ConditionalOnProperty(name = "spring.cloud.consul.config.watch.enabled", matchIfMissing = true)
public ConfigWatch configWatch(ConsulPropertySourceLocator locator,
ConsulClient consul) {
return new ConfigWatch(locator.getContexts(), consul);
}
}
}

View File

@@ -52,6 +52,16 @@ public class ConsulConfigProperties {
private String aclToken;
private Watch watch = new Watch();
@Data
public class Watch {
private int waitTime = 2;
private boolean enabled = true;
private int delay = 10;
}
/**
* There are many ways in which we can specify configuration in consul i.e.,
*
@@ -76,7 +86,7 @@ public class ConsulConfigProperties {
*
* @author srikalyan.swayampakula
*/
public static enum Format {
public enum Format {
/**
* Indicates that the configuration specified in consul is of type native key values.
*/

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2013-2016 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
*
* http://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.consul.config;
import org.springframework.context.ApplicationEvent;
/**
* @author Spencer Gibb
*/
public class ConsulConfigRefreshEvent extends ApplicationEvent {
private Object event;
/**
* Create a new ApplicationEvent.
*
* @param source the object on which the event initially occurred (never {@code null})
*/
public ConsulConfigRefreshEvent(Object source, Object event) {
super(source);
this.event = event;
}
public Object getEvent() {
return event;
}
public String getEventDesc() {
return event.toString();
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2013-2016 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
*
* http://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.consul.config;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cloud.endpoint.RefreshEndpoint;
import org.springframework.context.event.EventListener;
import lombok.extern.apachecommons.CommonsLog;
/**
* @author Spencer Gibb
*/
@CommonsLog
public class ConsulConfigRefreshListener {
private RefreshEndpoint refresh;
private AtomicBoolean ready = new AtomicBoolean(false);
public ConsulConfigRefreshListener(RefreshEndpoint refresh) {
this.refresh = refresh;
}
@EventListener
public void handle(ApplicationReadyEvent event) {
this.ready.compareAndSet(false, true);
}
@EventListener
public void handle(ConsulConfigRefreshEvent event) {
if (this.ready.get()) { // don't handle events before app is ready
log.info("Event received " + event.getEventDesc());
if (this.refresh != null) {
String[] keys = this.refresh.refresh();
log.info("Refresh keys changed: " + Arrays.asList(keys));
}
}
}
}

View File

@@ -41,11 +41,17 @@ public class ConsulPropertySourceLocator implements PropertySourceLocator {
private ConsulConfigProperties properties;
private List<String> contexts = new ArrayList<>();
public ConsulPropertySourceLocator(ConsulClient consul, ConsulConfigProperties properties) {
this.consul = consul;
this.properties = properties;
}
public List<String> getContexts() {
return contexts;
}
@Override
@Retryable(interceptor = "consulRetryInterceptor")
public PropertySource<?> locate(Environment environment) {
@@ -55,21 +61,20 @@ public class ConsulPropertySourceLocator implements PropertySourceLocator {
List<String> profiles = Arrays.asList(env.getActiveProfiles());
String prefix = this.properties.getPrefix();
List<String> contexts = new ArrayList<>();
String defaultContext = prefix + "/" + this.properties.getDefaultContext();
contexts.add(defaultContext + "/");
addProfiles(contexts, defaultContext, profiles);
this.contexts.add(defaultContext + "/");
addProfiles(this.contexts, defaultContext, profiles);
String baseContext = prefix + "/" + appName;
contexts.add(baseContext + "/");
addProfiles(contexts, baseContext, profiles);
this.contexts.add(baseContext + "/");
addProfiles(this.contexts, baseContext, profiles);
CompositePropertySource composite = new CompositePropertySource("consul");
Collections.reverse(contexts);
Collections.reverse(this.contexts);
for (String propertySourceContext : contexts) {
for (String propertySourceContext : this.contexts) {
ConsulPropertySource propertySource = create(propertySourceContext);
propertySource.init();
composite.addPropertySource(propertySource);

View File

@@ -1,3 +1,7 @@
# Auto Configuration
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.cloud.consul.config.ConsulConfigAutoConfiguration
# Bootstrap Configuration
org.springframework.cloud.bootstrap.BootstrapConfiguration=\
org.springframework.cloud.consul.config.ConsulConfigBootstrapConfiguration

View File

@@ -0,0 +1,129 @@
/*
* Copyright 2013-2016 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
*
* http://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.consul.config;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import com.ecwid.consul.v1.ConsulClient;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.consul.ConsulProperties;
import org.springframework.cloud.context.scope.refresh.RefreshScope;
import org.springframework.cloud.endpoint.RefreshEndpoint;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.ConfigurableEnvironment;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
/**
* @author Spencer Gibb
*/
public class ConsulPropertySourceLocatorTests {
public static final String PREFIX = "_propertySourceLocatorTests_config__";
public static final String ROOT = PREFIX + UUID.randomUUID();
private ConfigurableApplicationContext context;
public static final String KEY = ROOT + "/application/testProp";
@Configuration
@EnableAutoConfiguration
static class Config {
@Bean
public RefreshEndpoint refreshEndpoint(ConfigurableApplicationContext context,
RefreshScope scope) {
RefreshEndpoint endpoint = new TestRefreshEndpoint(context, scope);
return endpoint;
}
}
static class TestRefreshEndpoint extends RefreshEndpoint {
private CountDownLatch successLatch = new CountDownLatch(1);
private CountDownLatch toManyLatch = new CountDownLatch(1);
private AtomicInteger count = new AtomicInteger();
public TestRefreshEndpoint( ConfigurableApplicationContext context, RefreshScope scope) {
super(context, scope);
}
@Override
public synchronized String[] refresh() {
String[] keys = super.refresh();
if (this.count.incrementAndGet() == 1) {
this.successLatch.countDown();
} else {
this.toManyLatch.countDown();
}
return keys;
}
}
private ConfigurableEnvironment environment;
private ConsulClient client;
private ConsulProperties properties;
@Before
public void setup() {
this.properties = new ConsulProperties();
this.client = new ConsulClient(properties.getHost(), properties.getPort());
this.client.deleteKVValues(PREFIX);
this.client.setKVValue(KEY, "testPropVal");
this.context = new SpringApplicationBuilder(Config.class)
.web(false)
.run("--spring.spring.application.name=testConsulPropertySourceLocator",
"--spring.cloud.consul.config.prefix="+ROOT,
"spring.cloud.consul.config.watch.delay=1");
this.client = context.getBean(ConsulClient.class);
this.properties = context.getBean(ConsulProperties.class);
this.environment = context.getEnvironment();
}
@After
public void teardown() {
this.client.deleteKVValues(PREFIX);
}
@Test
public void propertyLoadedAndUpdated() throws Exception {
String testProp = this.environment.getProperty("testProp");
assertThat("testProp was wrong", testProp, is(equalTo("testPropVal")));
this.client.setKVValue(KEY, "testPropValUpdate");
TestRefreshEndpoint endpoint = this.context.getBean(TestRefreshEndpoint.class);
boolean receivedEvent = endpoint.successLatch.await(5, TimeUnit.SECONDS);
assertThat("listener didn't receive event", receivedEvent, is(true));
testProp = this.environment.getProperty("testProp");
assertThat("testProp was wrong after update", testProp, is(equalTo("testPropValUpdate")));
boolean receivedExtraEvent = endpoint.toManyLatch.await(500, TimeUnit.MILLISECONDS);
assertThat("refresh called to many times", receivedExtraEvent, is(false));
}
}