diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc index 11816910ad7e..0c5dc050d42d 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc @@ -445,6 +445,13 @@ directly. For example: } ---- +The following `ThreadLocalAccessor` implementations are provided out of the box: + +* `LocaleContextThreadLocalAccessor` -- propagates `LocaleContext` via `LocaleContextHolder` +* `RequestAttributesThreadLocalAccessor` -- propagates `RequestAttributes` via `RequestContextHolder` + +The above are not registered automatically. You need to register them via `ContextRegistry.getInstance()` on startup. + For more details, see the https://micrometer.io/docs/contextPropagation[documentation] of the Micrometer Context Propagation library. diff --git a/spring-context/spring-context.gradle b/spring-context/spring-context.gradle index 0256d6bfdbfb..af48a0fa2070 100644 --- a/spring-context/spring-context.gradle +++ b/spring-context/spring-context.gradle @@ -13,6 +13,7 @@ dependencies { api(project(":spring-expression")) api("io.micrometer:micrometer-observation") optional(project(":spring-instrument")) + optional("io.micrometer:context-propagation") optional("io.projectreactor:reactor-core") optional("jakarta.annotation:jakarta.annotation-api") optional("jakarta.ejb:jakarta.ejb-api") diff --git a/spring-context/src/main/java/org/springframework/context/i18n/LocaleContextThreadLocalAccessor.java b/spring-context/src/main/java/org/springframework/context/i18n/LocaleContextThreadLocalAccessor.java new file mode 100644 index 000000000000..be1c6abda378 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/context/i18n/LocaleContextThreadLocalAccessor.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 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.context.i18n; + +import io.micrometer.context.ThreadLocalAccessor; + +/** + * Adapt {@link LocaleContextHolder} to the {@link ThreadLocalAccessor} contract to assist + * the Micrometer Context Propagation library with {@link LocaleContext} propagation. + * @author Tadaya Tsuyukubo + * @since 6.2 + */ +public class LocaleContextThreadLocalAccessor implements ThreadLocalAccessor { + + /** + * Key under which this accessor is registered in + * {@link io.micrometer.context.ContextRegistry}. + */ + public static final String KEY = LocaleContextThreadLocalAccessor.class.getName() + ".KEY"; + + @Override + public Object key() { + return KEY; + } + + @Override + public LocaleContext getValue() { + return LocaleContextHolder.getLocaleContext(); + } + + @Override + public void setValue(LocaleContext value) { + LocaleContextHolder.setLocaleContext(value); + } + + @Override + public void setValue() { + LocaleContextHolder.resetLocaleContext(); + } + +} diff --git a/spring-context/src/test/java/org/springframework/context/i18n/LocaleContextThreadLocalAccessorTests.java b/spring-context/src/test/java/org/springframework/context/i18n/LocaleContextThreadLocalAccessorTests.java new file mode 100644 index 000000000000..aaf43b5d096a --- /dev/null +++ b/spring-context/src/test/java/org/springframework/context/i18n/LocaleContextThreadLocalAccessorTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2024 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.context.i18n; + +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshotFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LocaleContextThreadLocalAccessor}. + * + * @author Tadaya Tsuyukubo + */ +class LocaleContextThreadLocalAccessorTests { + + private final ContextRegistry registry = new ContextRegistry() + .registerThreadLocalAccessor(new LocaleContextThreadLocalAccessor()); + + @AfterEach + void cleanUp() { + LocaleContextHolder.resetLocaleContext(); + } + + @ParameterizedTest + @MethodSource + void propagation(@Nullable LocaleContext previous, LocaleContext current) throws Exception { + LocaleContextHolder.setLocaleContext(current); + ContextSnapshot snapshot = ContextSnapshotFactory.builder() + .contextRegistry(this.registry) + .clearMissing(true) + .build() + .captureAll(); + + AtomicReference previousHolder = new AtomicReference<>(); + AtomicReference currentHolder = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + new Thread(() -> { + LocaleContextHolder.setLocaleContext(previous); + try (ContextSnapshot.Scope scope = snapshot.setThreadLocals()) { + currentHolder.set(LocaleContextHolder.getLocaleContext()); + } + previousHolder.set(LocaleContextHolder.getLocaleContext()); + latch.countDown(); + }).start(); + + latch.await(1, TimeUnit.SECONDS); + assertThat(previousHolder).hasValueSatisfying(value -> assertThat(value).isSameAs(previous)); + assertThat(currentHolder).hasValueSatisfying(value -> assertThat(value).isSameAs(current)); + } + + private static Stream propagation() { + LocaleContext previous = new SimpleLocaleContext(Locale.ENGLISH); + LocaleContext current = new SimpleLocaleContext(Locale.ENGLISH); + return Stream.of( + Arguments.of(null, current), + Arguments.of(previous, current) + ); + } +} diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index b1fffdb0c610..4e500e367c7c 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -20,7 +20,7 @@ dependencies { optional("com.google.protobuf:protobuf-java-util") optional("com.rometools:rome") optional("com.squareup.okhttp3:okhttp") - optional("io.reactivex.rxjava3:rxjava") + optional("io.micrometer:context-propagation") optional("io.netty:netty-buffer") optional("io.netty:netty-handler") optional("io.netty:netty-codec-http") @@ -31,6 +31,7 @@ dependencies { optional("io.netty:netty5-transport") optional("io.projectreactor.netty:reactor-netty-http") optional("io.projectreactor.netty:reactor-netty5-http") + optional("io.reactivex.rxjava3:rxjava") optional("io.undertow:undertow-core") optional("jakarta.el:jakarta.el-api") optional("jakarta.faces:jakarta.faces-api") diff --git a/spring-web/src/main/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessor.java b/spring-web/src/main/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessor.java new file mode 100644 index 000000000000..e2639f7b96f0 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessor.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2024 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.web.context.request; + +import io.micrometer.context.ThreadLocalAccessor; + +/** + * Adapt {@link RequestContextHolder} to the {@link ThreadLocalAccessor} contract to assist + * the Micrometer Context Propagation library with {@link RequestAttributes} propagation. + * @author Tadaya Tsuyukubo + * @since 6.2 + */ +public class RequestAttributesThreadLocalAccessor implements ThreadLocalAccessor { + + /** + * Key under which this accessor is registered in + * {@link io.micrometer.context.ContextRegistry}. + */ + public static final String KEY = RequestAttributesThreadLocalAccessor.class.getName() + ".KEY"; + + @Override + public Object key() { + return KEY; + } + + @Override + public RequestAttributes getValue() { + return RequestContextHolder.getRequestAttributes(); + } + + @Override + public void setValue(RequestAttributes value) { + RequestContextHolder.setRequestAttributes(value); + } + + @Override + public void setValue() { + RequestContextHolder.resetRequestAttributes(); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessorTests.java b/spring-web/src/test/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessorTests.java new file mode 100644 index 000000000000..0d36ae337b0b --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/context/request/RequestAttributesThreadLocalAccessorTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2024 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.web.context.request; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import io.micrometer.context.ContextRegistry; +import io.micrometer.context.ContextSnapshot; +import io.micrometer.context.ContextSnapshotFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RequestAttributesThreadLocalAccessor}. + * + * @author Tadaya Tsuyukubo + */ +class RequestAttributesThreadLocalAccessorTests { + + private final ContextRegistry registry = new ContextRegistry() + .registerThreadLocalAccessor(new RequestAttributesThreadLocalAccessor()); + + @AfterEach + void cleanUp() { + RequestContextHolder.resetRequestAttributes(); + } + + @ParameterizedTest + @MethodSource + void propagation(@Nullable RequestAttributes previous, RequestAttributes current) throws Exception { + RequestContextHolder.setRequestAttributes(current); + ContextSnapshot snapshot = ContextSnapshotFactory.builder() + .contextRegistry(this.registry) + .clearMissing(true) + .build() + .captureAll(); + + AtomicReference previousHolder = new AtomicReference<>(); + AtomicReference currentHolder = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + new Thread(() -> { + RequestContextHolder.setRequestAttributes(previous); + try (ContextSnapshot.Scope scope = snapshot.setThreadLocals()) { + currentHolder.set(RequestContextHolder.getRequestAttributes()); + } + previousHolder.set(RequestContextHolder.getRequestAttributes()); + latch.countDown(); + }).start(); + + latch.await(1, TimeUnit.SECONDS); + assertThat(previousHolder).hasValueSatisfying(value -> assertThat(value).isSameAs(previous)); + assertThat(currentHolder).hasValueSatisfying(value -> assertThat(value).isSameAs(current)); + } + + private static Stream propagation() { + RequestAttributes previous = mock(RequestAttributes.class); + RequestAttributes current = mock(RequestAttributes.class); + return Stream.of( + Arguments.of(null, current), + Arguments.of(previous, current) + ); + } + +}