From 71be166375cb3bc65a6b1fb465a09328dfe9f6db Mon Sep 17 00:00:00 2001 From: Alpha <2748109647@qq.com> Date: Sun, 25 Oct 2026 22:10:17 +0800 Subject: [PATCH] feat(spring): auto-configure gRPC actor hosting Signed-off-by: Alpha <2748109647@qq.com> --- .../actors/DaprActorAutoConfiguration.java | 50 ++++++++++++ .../DaprActorEventSubscriptionLifecycle.java | 67 +++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../ActorEventSubscriptionTestFactory.java | 33 ++++++++ .../DaprActorAutoConfigurationTest.java | 81 +++++++++++++++++++ .../actors/DaprActorProperties.java | 53 ++++++++++++ .../java/io/dapr/examples/actors/README.md | 21 +++++ .../dapr/it/actors/SpringBootActorGrpcIT.java | 58 +++++++++++++ .../app/SpringBootGrpcActorService.java | 54 +++++++++++++ 9 files changed, 418 insertions(+) create mode 100644 dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfiguration.java create mode 100644 dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorEventSubscriptionLifecycle.java create mode 100644 dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/actors/runtime/ActorEventSubscriptionTestFactory.java create mode 100644 dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfigurationTest.java create mode 100644 dapr-spring/dapr-spring-boot-properties/src/main/java/io/dapr/spring/boot/properties/actors/DaprActorProperties.java create mode 100644 sdk-tests/src/test/java/io/dapr/it/actors/SpringBootActorGrpcIT.java create mode 100644 sdk-tests/src/test/java/io/dapr/it/actors/app/SpringBootGrpcActorService.java diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfiguration.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfiguration.java new file mode 100644 index 0000000000..56d5af69fd --- /dev/null +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfiguration.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.spring.boot.autoconfigure.actors; + +import io.dapr.actors.runtime.ActorEventSubscription; +import io.dapr.actors.runtime.ActorRuntime; +import io.dapr.spring.boot.autoconfigure.client.DaprClientAutoConfiguration; +import io.dapr.spring.boot.properties.actors.DaprActorProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.context.properties.EnableConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for Dapr actor hosting. + */ +@AutoConfiguration(after = DaprClientAutoConfiguration.class) +@ConditionalOnClass(ActorRuntime.class) +@EnableConfigurationProperties(DaprActorProperties.class) +public class DaprActorAutoConfiguration { + + /** + * Starts the gRPC actor event stream after all application runners have registered their actor types. + * + * @param actorRuntime Dapr actor runtime. + * @param applicationContext Spring application context. + * @return The lifecycle that owns the event subscription. + */ + @Bean + @ConditionalOnProperty(prefix = "dapr.actors", name = "transport", havingValue = "grpc") + @ConditionalOnMissingBean(ActorEventSubscription.class) + DaprActorEventSubscriptionLifecycle daprActorEventSubscriptionLifecycle( + ActorRuntime actorRuntime, ConfigurableApplicationContext applicationContext) { + return new DaprActorEventSubscriptionLifecycle(actorRuntime, applicationContext); + } +} diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorEventSubscriptionLifecycle.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorEventSubscriptionLifecycle.java new file mode 100644 index 0000000000..5cb2fb4b65 --- /dev/null +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorEventSubscriptionLifecycle.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.spring.boot.autoconfigure.actors; + +import io.dapr.actors.runtime.ActorEventSubscription; +import io.dapr.actors.runtime.ActorRuntime; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * Owns the gRPC actor event subscription for the lifetime of a Spring Boot application. + */ +final class DaprActorEventSubscriptionLifecycle + implements ApplicationListener, DisposableBean { + + static final String BEAN_NAME = "daprActorEventSubscription"; + + private final ActorRuntime actorRuntime; + + private final ConfigurableApplicationContext applicationContext; + + private ActorEventSubscription subscription; + + DaprActorEventSubscriptionLifecycle( + ActorRuntime actorRuntime, ConfigurableApplicationContext applicationContext) { + this.actorRuntime = actorRuntime; + this.applicationContext = applicationContext; + } + + @Override + public synchronized void onApplicationEvent(ApplicationReadyEvent event) { + if (event.getApplicationContext() != applicationContext || subscription != null) { + return; + } + + ActorEventSubscription started = actorRuntime.subscribeActorEvents(); + try { + // Register only after ApplicationReadyEvent so actor types registered by application runners are included. + applicationContext.getBeanFactory().registerSingleton(BEAN_NAME, started); + subscription = started; + } catch (RuntimeException e) { + started.close(); + throw e; + } + } + + @Override + public synchronized void destroy() { + if (subscription != null) { + subscription.close(); + subscription = null; + } + } +} diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index cb5f990d18..0462779486 100644 --- a/dapr-spring/dapr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ io.dapr.spring.boot.autoconfigure.client.DaprJackson3AutoConfiguration io.dapr.spring.boot.autoconfigure.client.DaprClientAutoConfiguration +io.dapr.spring.boot.autoconfigure.actors.DaprActorAutoConfiguration diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/actors/runtime/ActorEventSubscriptionTestFactory.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/actors/runtime/ActorEventSubscriptionTestFactory.java new file mode 100644 index 0000000000..7041018014 --- /dev/null +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/actors/runtime/ActorEventSubscriptionTestFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.actors.runtime; + +import java.time.Duration; + +/** Test helper that creates an event subscription without opening a Dapr connection. */ +public final class ActorEventSubscriptionTestFactory { + + private ActorEventSubscriptionTestFactory() { + } + + /** + * Creates a subscription that can be used to verify lifecycle management without starting its stream. + * + * @param actorRuntime Actor runtime used when the subscription is closed. + * @return An unstarted event subscription. + */ + public static ActorEventSubscription newUnstartedSubscription(ActorRuntime actorRuntime) { + return new ActorEventSubscription(actorRuntime, null, null, Duration.ZERO); + } +} diff --git a/dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfigurationTest.java b/dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfigurationTest.java new file mode 100644 index 0000000000..20643e3e95 --- /dev/null +++ b/dapr-spring/dapr-spring-boot-autoconfigure/src/test/java/io/dapr/spring/boot/autoconfigure/actors/DaprActorAutoConfigurationTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.spring.boot.autoconfigure.actors; + +import io.dapr.actors.runtime.ActorEventSubscription; +import io.dapr.actors.runtime.ActorEventSubscriptionTestFactory; +import io.dapr.actors.runtime.ActorRuntime; +import io.dapr.actors.runtime.ActorRuntimeConfig; +import io.dapr.spring.boot.properties.actors.DaprActorProperties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; + +import java.time.Duration; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DaprActorAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DaprActorAutoConfiguration.class)); + + @Test + void doesNotStartSubscriptionForDefaultHttpTransport() { + ActorRuntime actorRuntime = mock(ActorRuntime.class); + + contextRunner.withBean(ActorRuntime.class, () -> actorRuntime).run(context -> { + assertThat(context.getBean(DaprActorProperties.class).getTransport()) + .isEqualTo(DaprActorProperties.Transport.HTTP); + assertThat(context).doesNotHaveBean(DaprActorEventSubscriptionLifecycle.class); + assertThat(context).doesNotHaveBean(ActorEventSubscription.class); + }); + + verify(actorRuntime, never()).subscribeActorEvents(); + } + + @Test + void startsAndExposesSubscriptionWhenApplicationIsReady() { + ActorRuntime actorRuntime = mock(ActorRuntime.class); + ActorRuntimeConfig actorRuntimeConfig = mock(ActorRuntimeConfig.class); + when(actorRuntime.getConfig()).thenReturn(actorRuntimeConfig); + ActorEventSubscription subscription = ActorEventSubscriptionTestFactory.newUnstartedSubscription(actorRuntime); + when(actorRuntime.subscribeActorEvents()).thenReturn(subscription); + + contextRunner.withPropertyValues("dapr.actors.transport=grpc") + .withBean(ActorRuntime.class, () -> actorRuntime) + .run(context -> { + assertThat(context).hasSingleBean(DaprActorEventSubscriptionLifecycle.class); + assertThat(context).doesNotHaveBean(ActorEventSubscription.class); + assertThat(context.getBean(DaprActorProperties.class).getTransport()) + .isEqualTo(DaprActorProperties.Transport.GRPC); + + ConfigurableApplicationContext applicationContext = context.getSourceApplicationContext(); + context.publishEvent(new ApplicationReadyEvent( + new SpringApplication(), new String[0], applicationContext, Duration.ZERO)); + + assertThat(context).hasSingleBean(ActorEventSubscription.class); + assertThat(context.getBean(ActorEventSubscription.class)).isSameAs(subscription); + }); + + verify(actorRuntime).subscribeActorEvents(); + assertThat(subscription.isRunning()).isFalse(); + } +} diff --git a/dapr-spring/dapr-spring-boot-properties/src/main/java/io/dapr/spring/boot/properties/actors/DaprActorProperties.java b/dapr-spring/dapr-spring-boot-properties/src/main/java/io/dapr/spring/boot/properties/actors/DaprActorProperties.java new file mode 100644 index 0000000000..c50ff3893d --- /dev/null +++ b/dapr-spring/dapr-spring-boot-properties/src/main/java/io/dapr/spring/boot/properties/actors/DaprActorProperties.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.spring.boot.properties.actors; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for hosting Dapr actors from a Spring Boot application. + */ +@ConfigurationProperties(prefix = "dapr.actors") +public class DaprActorProperties { + + private Transport transport = Transport.HTTP; + + /** + * Returns the transport used by the application to host actors. + * + * @return The actor hosting transport. Defaults to HTTP. + */ + public Transport getTransport() { + return transport; + } + + /** + * Sets the transport used by the application to host actors. + * + * @param transport The actor hosting transport. + */ + public void setTransport(Transport transport) { + this.transport = transport; + } + + /** + * Supported transports for hosting actors. + */ + public enum Transport { + /** Host callbacks through the existing HTTP app channel. */ + HTTP, + /** Host callbacks through the app-initiated gRPC actor event stream. */ + GRPC + } +} diff --git a/examples/src/main/java/io/dapr/examples/actors/README.md b/examples/src/main/java/io/dapr/examples/actors/README.md index 4ed9e78c51..a76bceb0ce 100644 --- a/examples/src/main/java/io/dapr/examples/actors/README.md +++ b/examples/src/main/java/io/dapr/examples/actors/README.md @@ -196,6 +196,27 @@ dapr run --resources-path ./components/actors --app-id demoactorservice -- java The stream reconnects if Dapr restarts. For an actor service that is idle for long periods, enable gRPC keepalive with `-Ddapr.grpc.enable.keep.alive=true`. +### Hosting actors over gRPC with Spring Boot + +The Dapr Spring Boot starter can start the actor event subscription after application startup, once actor types registered by application beans and runners are available. Set the transport property: + +```properties +dapr.actors.transport=grpc +``` + +Register actor types before the application is ready, for example from an `ApplicationRunner`: + +```java +@Bean +ApplicationRunner registerActors(ActorRuntime actorRuntime) { + return args -> actorRuntime.registerActor(DemoActorImpl.class); +} +``` + +When the application is ready, the starter exposes the running `ActorEventSubscription` as a bean and closes it with the application context. A health indicator can look it up through `ObjectProvider` and check `isRunning()`. + +The default transport remains HTTP. gRPC hosting requires Dapr to run without an HTTP app channel: omit `--app-port`, or configure `--app-protocol grpc` when an app port is present. If the Spring Boot application does not serve HTTP itself, set `spring.main.web-application-type=none` so it does not start an unrelated web server. + ### Running the Actor client The actor client is a simple java class with a main method that uses the Dapr Actor capabilities in order to create the actors and execute the different methods based on the Actor pattern. diff --git a/sdk-tests/src/test/java/io/dapr/it/actors/SpringBootActorGrpcIT.java b/sdk-tests/src/test/java/io/dapr/it/actors/SpringBootActorGrpcIT.java new file mode 100644 index 0000000000..630c4a3ed2 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/actors/SpringBootActorGrpcIT.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.it.actors; + +import io.dapr.actors.ActorId; +import io.dapr.actors.client.ActorClient; +import io.dapr.actors.client.ActorProxy; +import io.dapr.actors.client.ActorProxyBuilder; +import io.dapr.it.actors.app.SpringBootGrpcActorService; +import io.dapr.it.containers.BaseContainerIT; +import io.dapr.testcontainers.DaprContainer; +import io.dapr.testcontainers.DaprProtocol; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Verifies Spring Boot can host actors over the gRPC stream without an app port. */ +public class SpringBootActorGrpcIT extends BaseContainerIT { + + private static DaprContainer dapr; + + private static ActorClient actorClient; + + @BeforeAll + public static void start() throws Exception { + var pair = startActorHostAndAttach( + "springboot-actor-grpc-it", + SpringBootGrpcActorService.class, + () -> daprBuilder("springboot-actor-grpc-it") + .withAppProtocol(DaprProtocol.GRPC) + .withComponent(redisStateStore(STATE_STORE_NAME))); + dapr = pair.dapr(); + actorClient = newActorClient(dapr); + waitForActorsReady(dapr); + } + + @Test + public void invokesActorHostedBySpringBootOverGrpcWithoutAppPort() { + ActorProxy proxy = new ActorProxyBuilder<>("MyActorTest", ActorProxy.class, actorClient) + .build(new ActorId(UUID.randomUUID().toString())); + + assertEquals("olleh", proxy.invokeMethod("say", "hello", String.class).block()); + } +} diff --git a/sdk-tests/src/test/java/io/dapr/it/actors/app/SpringBootGrpcActorService.java b/sdk-tests/src/test/java/io/dapr/it/actors/app/SpringBootGrpcActorService.java new file mode 100644 index 0000000000..0a6f3297e8 --- /dev/null +++ b/sdk-tests/src/test/java/io/dapr/it/actors/app/SpringBootGrpcActorService.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 The Dapr 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 io.dapr.it.actors.app; + +import io.dapr.actors.runtime.ActorEventSubscription; +import io.dapr.actors.runtime.ActorRuntime; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; + +import java.time.Duration; +import java.util.Map; + +/** Spring Boot actor host used to verify the gRPC actor transport auto-configuration. */ +@SpringBootApplication +public class SpringBootGrpcActorService { + + public static void main(String[] args) throws Exception { + SpringApplication application = new SpringApplication(SpringBootGrpcActorService.class); + application.setDefaultProperties(Map.of( + "dapr.actors.transport", "grpc", + "spring.main.web-application-type", "none", + "spring.main.banner-mode", "off")); + + try (ConfigurableApplicationContext context = application.run(args)) { + context.getBean(ActorEventSubscription.class).awaitTermination(); + } + } + + @Bean + ApplicationRunner registerActors(ActorRuntime actorRuntime) { + return new ApplicationRunner() { + @Override + public void run(ApplicationArguments args) { + actorRuntime.getConfig().setActorIdleTimeout(Duration.ofSeconds(5)); + actorRuntime.registerActor(MyActorStringImpl.class); + } + }; + } +}