From d1141a830cf38a169c686a9822d181adeb27a890 Mon Sep 17 00:00:00 2001 From: Spencer Gibb Date: Mon, 8 Feb 2016 15:31:36 -0700 Subject: [PATCH] Watch consul config and refresh when there are changes. fixes gh-19 --- spring-cloud-consul-config/pom.xml | 5 + .../cloud/consul/config/ConfigWatch.java | 107 +++++++++++++++ .../config/ConsulConfigAutoConfiguration.java | 51 +++++++ .../consul/config/ConsulConfigProperties.java | 12 +- .../config/ConsulConfigRefreshEvent.java | 45 ++++++ .../config/ConsulConfigRefreshListener.java | 55 ++++++++ .../config/ConsulPropertySourceLocator.java | 19 ++- .../main/resources/META-INF/spring.factories | 4 + .../ConsulPropertySourceLocatorTests.java | 129 ++++++++++++++++++ 9 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConfigWatch.java create mode 100644 spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigAutoConfiguration.java create mode 100644 spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshEvent.java create mode 100644 spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshListener.java create mode 100644 spring-cloud-consul-config/src/test/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocatorTests.java diff --git a/spring-cloud-consul-config/pom.xml b/spring-cloud-consul-config/pom.xml index 8b678f04..2d402cf9 100644 --- a/spring-cloud-consul-config/pom.xml +++ b/spring-cloud-consul-config/pom.xml @@ -22,6 +22,11 @@ spring-boot-starter-web true + + org.springframework.boot + spring-boot-starter-actuator + true + org.springframework.retry spring-retry diff --git a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConfigWatch.java b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConfigWatch.java new file mode 100644 index 00000000..6d7c8cbc --- /dev/null +++ b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConfigWatch.java @@ -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 contexts; + private final ConsulClient consul; + private AtomicBoolean running = new AtomicBoolean(false); + private ApplicationEventPublisher publisher; + private HashMap consulIndexes = new HashMap<>(); + + public ConfigWatch(List 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> 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; + } +} diff --git a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigAutoConfiguration.java b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigAutoConfiguration.java new file mode 100644 index 00000000..8841ac13 --- /dev/null +++ b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigAutoConfiguration.java @@ -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); + } + } +} diff --git a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigProperties.java b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigProperties.java index c5c12baf..9e4d4e73 100644 --- a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigProperties.java +++ b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigProperties.java @@ -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. */ diff --git a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshEvent.java b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshEvent.java new file mode 100644 index 00000000..9aa661c9 --- /dev/null +++ b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshEvent.java @@ -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(); + } +} diff --git a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshListener.java b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshListener.java new file mode 100644 index 00000000..0ecfd554 --- /dev/null +++ b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulConfigRefreshListener.java @@ -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)); + } + } + } +} diff --git a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocator.java b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocator.java index 93570c55..3b386fdb 100644 --- a/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocator.java +++ b/spring-cloud-consul-config/src/main/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocator.java @@ -41,11 +41,17 @@ public class ConsulPropertySourceLocator implements PropertySourceLocator { private ConsulConfigProperties properties; + private List contexts = new ArrayList<>(); + public ConsulPropertySourceLocator(ConsulClient consul, ConsulConfigProperties properties) { this.consul = consul; this.properties = properties; } + public List getContexts() { + return contexts; + } + @Override @Retryable(interceptor = "consulRetryInterceptor") public PropertySource locate(Environment environment) { @@ -55,21 +61,20 @@ public class ConsulPropertySourceLocator implements PropertySourceLocator { List profiles = Arrays.asList(env.getActiveProfiles()); String prefix = this.properties.getPrefix(); - List 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); diff --git a/spring-cloud-consul-config/src/main/resources/META-INF/spring.factories b/spring-cloud-consul-config/src/main/resources/META-INF/spring.factories index ea9d49cd..d1699b34 100644 --- a/spring-cloud-consul-config/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-consul-config/src/main/resources/META-INF/spring.factories @@ -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 diff --git a/spring-cloud-consul-config/src/test/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocatorTests.java b/spring-cloud-consul-config/src/test/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocatorTests.java new file mode 100644 index 00000000..63048e5a --- /dev/null +++ b/spring-cloud-consul-config/src/test/java/org/springframework/cloud/consul/config/ConsulPropertySourceLocatorTests.java @@ -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)); + } +}