Skip to content

Commit

Permalink
native image support for transcoding (#65)
Browse files Browse the repository at this point in the history
* native image support for transcoding

* fix tests
  • Loading branch information
DanielLiu1123 authored Oct 31, 2024
1 parent ae68760 commit 378b8de
Show file tree
Hide file tree
Showing 9 changed files with 275 additions and 14 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:

native-image-build:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
steps:
- name: Check out the repo
uses: actions/checkout@v4
Expand Down
14 changes: 13 additions & 1 deletion examples/transcoding/webflux/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'org.springframework.boot'
id "org.springframework.boot"
id "org.graalvm.buildtools.native"
}

dependencies {
Expand All @@ -15,3 +16,14 @@ dependencies {
}

apply from: "${rootDir}/gradle/protobuf.gradle"

// https://graalvm.github.io/native-build-tools/latest/gradle-plugin.html#configuration-options
graalvmNative {
testSupport = false
binaries {
main {
verbose = true
sharedLibrary = false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,36 @@
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import java.util.Map;
import java.util.Objects;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.client.WebClient;

/**
* @author Freeman
*/
@Slf4j
@SpringBootApplication
public class TranscodingWebFluxApp extends SimpleServiceImplBase {

public static void main(String[] args) {
SpringApplication.run(TranscodingWebFluxApp.class, args);
var ctx = SpringApplication.run(TranscodingWebFluxApp.class, args);

// We need to do `./gradlew nativeRun` in CI, it needs to be closed.
// When running the example locally, no need to close it.
// See
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
if (System.getenv("CI") != null) {
ctx.close();
}
}

@Override
Expand Down Expand Up @@ -47,4 +65,24 @@ public void serverStreamingRpc(SimpleRequest request, StreamObserver<SimpleRespo
}
r.onCompleted();
}

@Bean
ApplicationRunner runner(WebClient.Builder builder, ReactiveWebServerApplicationContext ctx) {
return args -> {
var client = builder.baseUrl(
"http://localhost:" + ctx.getWebServer().getPort())
.build();

var response = client.post()
.uri("/unary")
.bodyValue(Map.of("requestMessage", "World"))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
log.info("response: {}", response);

Assert.notNull(response, "response is null");
Assert.isTrue(Objects.equals(response.get("responseMessage"), "Hello World"), "response message not match");
};
}
}
19 changes: 17 additions & 2 deletions examples/transcoding/webmvc/build.gradle
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
plugins {
id 'org.springframework.boot'
id "org.springframework.boot"
id "org.graalvm.buildtools.native"
}

dependencies {
implementation("io.grpc:grpc-testing-proto")
implementation(project(":grpc-starters:grpc-server-boot-starter"))
implementation(project(":grpc-starters:grpc-server-boot-starter")){
exclude(group: 'io.grpc', module: "grpc-netty-shaded")
}
implementation(project(":grpc-starters:grpc-starter-transcoding"))
implementation("org.springframework.boot:spring-boot-starter-web")
runtimeOnly("io.grpc:grpc-netty")

testImplementation(project(":grpc-starters:grpc-starter-test"))
}

apply from: "${rootDir}/gradle/protobuf.gradle"

// https://graalvm.github.io/native-build-tools/latest/gradle-plugin.html#configuration-options
graalvmNative {
testSupport = false
binaries {
main {
verbose = true
sharedLibrary = false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,38 @@
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import java.util.Map;
import java.util.Objects;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.context.WebServerApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.util.Assert;
import org.springframework.web.client.RestClient;
import transcoding.mvc.Simpleservice;
import transcoding.mvc.Simpleservice.SimpleRequest;

/**
* @author Freeman
*/
@Slf4j
@SpringBootApplication
public class TranscodingMvcApp extends SimpleServiceImplBase {

public static void main(String[] args) {
SpringApplication.run(TranscodingMvcApp.class, args);
var ctx = SpringApplication.run(TranscodingMvcApp.class, args);

// We need to do `./gradlew nativeRun` in CI, it needs to be closed.
// When running the example locally, no need to close it.
// See
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
if (System.getenv("CI") != null) {
ctx.close();
}
}

@Override
Expand Down Expand Up @@ -47,4 +65,23 @@ public void serverStreamingRpc(SimpleRequest request, StreamObserver<Simpleservi
}
r.onCompleted();
}

@Bean
ApplicationRunner runner(RestClient.Builder builder, WebServerApplicationContext ctx) {
return args -> {
var client = builder.baseUrl(
"http://localhost:" + ctx.getWebServer().getPort())
.build();

var response = client.post()
.uri("/unary")
.body(Map.of("requestMessage", "World"))
.retrieve()
.body(new ParameterizedTypeReference<Map<String, Object>>() {});
log.info("response: {}", response);

Assert.notNull(response, "response is null");
Assert.isTrue(Objects.equals(response.get("responseMessage"), "Hello World"), "response message not match");
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@ public HttpHeaders toHttpHeaders(Metadata headers) {
}

private Set<String> getRemoveHeaders() {
Set<String> result = new LinkedHashSet<>(findPublicStaticFinalStringFieldNames(HttpHeaders.class));
Set<String> result = new LinkedHashSet<>(getHttpHeaders());

result.removeIf(HttpHeaders.AUTHORIZATION::equalsIgnoreCase); // keep authorization
return result;
}

private static Set<String> findPublicStaticFinalStringFieldNames(Class<?> clazz) {
return Arrays.stream(clazz.getDeclaredFields())
private static Set<String> getHttpHeaders() {
return Arrays.stream(HttpHeaders.class.getDeclaredFields())
.filter(f -> Modifier.isPublic(f.getModifiers())
&& Modifier.isStatic(f.getModifiers())
&& Modifier.isFinal(f.getModifiers())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,10 @@ public DefaultReactiveTranscoder grpcStarterDefaultReactiveTranscoder(
transcodingExceptionResolver);
}
}

// AOT support
@Bean
static GrpcTranscodingBeanFactoryInitializationAotProcessor grpcTranscodingBeanFactoryInitializationAotProcessor() {
return new GrpcTranscodingBeanFactoryInitializationAotProcessor();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package grpcstarter.extensions.transcoding;

import com.google.protobuf.DescriptorProtos;
import com.google.protobuf.Message;
import io.grpc.BindableService;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import lombok.SneakyThrows;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.ReflectionHints;
import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpHeaders;
import org.springframework.util.ClassUtils;

/**
* TIP: use 'processAot' task to debug the AOT processing.
*
* @author Freeman
*/
class GrpcTranscodingBeanFactoryInitializationAotProcessor
implements BeanFactoryInitializationAotProcessor, EnvironmentAware {

private Environment env;

@Override
public void setEnvironment(Environment environment) {
this.env = environment;
}

@Nullable
@Override
public BeanFactoryInitializationAotContribution processAheadOfTime(
@Nonnull ConfigurableListableBeanFactory beanFactory) {
return (generationContext, beanFactoryInitializationCode) -> {
var enabled = env.getProperty(GrpcTranscodingProperties.PREFIX + ".enabled", Boolean.class, true);
if (!enabled) {
return;
}

var reflection = generationContext.getRuntimeHints().reflection();

// See grpcstarter.extensions.transcoding.DefaultHeaderConverter.getHttpHeaders
reflection.registerType(HttpHeaders.class, builder -> builder.withMembers(MemberCategory.PUBLIC_FIELDS));

// This will increase the packaging size about 2MB.
// I don't know why this type is needed, don't want to spend much time to figure it out :)
registerReflectionForClassAndInnerClasses(reflection, DescriptorProtos.class);

// request + response messages
registerReflectionForMessages(reflection, getMessages(listGrpcServiceDefinition(beanFactory)));
};
}

private static void registerReflectionForClassAndInnerClasses(ReflectionHints reflection, Class<?> clz) {

reflection.registerType(clz, MemberCategory.INTROSPECT_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS);

for (var declaredClass : clz.getDeclaredClasses()) {
registerReflectionForClassAndInnerClasses(reflection, declaredClass);
}
}

private static LinkedHashSet<Class<?>> getMessages(Map<String, BeanDefinition> beanNameToBeanDefinition) {
var messages = new LinkedHashSet<Class<?>>();
for (var entry : beanNameToBeanDefinition.entrySet()) {
var beanDefinition = entry.getValue();
var clz = beanDefinition.getResolvableType().resolve();
if (clz == null) {
continue;
}

var methods = clz.getMethods();
for (var method : methods) {
Class<?> returnType = method.getReturnType();
if (returnType != void.class) { // grpc method should return void
continue;
}
if (method.getParameterCount() != 2) { // grpc method should have 2 parameters
continue;
}
Class<?> message1 = method.getParameterTypes()[0];
if (!Message.class.isAssignableFrom(message1)) { // the first parameter should be a Message
continue;
}
Type arg2 = method.getGenericParameterTypes()[1]; // the second parameter must be a StreamObserver
if (!(arg2 instanceof ParameterizedType pt)) {
continue;
}
var typeArgs = pt.getActualTypeArguments();
if (typeArgs.length != 1) {
continue;
}
if (!(typeArgs[0] instanceof Class<?> message2)) {
continue;
}
if (!Message.class.isAssignableFrom(message2)) { // the second parameter should be a Message
continue;
}

messages.add(message1);
messages.add(message2);
}
}
return messages;
}

@SneakyThrows
private static void registerReflectionForMessages(ReflectionHints reflection, Set<Class<?>> messages) {
for (var message : messages) {

// register the message and its builder
reflection.registerType(
message, MemberCategory.INTROSPECT_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS);

var builderClass = getBuilderClass(message);
if (builderClass != null) {
reflection.registerType(
builderClass, MemberCategory.INTROSPECT_PUBLIC_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS);
}
}
}

private static Class<?> getBuilderClass(Class<?> message) {
try {
return ClassUtils.forName(message.getName() + "$Builder", null);
} catch (ClassNotFoundException e) {
return null;
}
}

private static Map<String, BeanDefinition> listGrpcServiceDefinition(ConfigurableListableBeanFactory beanFactory) {
var beanDefinitions = new HashMap<String, BeanDefinition>();
for (String name : beanFactory.getBeanDefinitionNames()) {
BeanDefinition beanDefinition = beanFactory.getBeanDefinition(name);
Class<?> clz = beanDefinition.getResolvableType().resolve();
if (clz != null && BindableService.class.isAssignableFrom(clz)) {
beanDefinitions.put(name, beanDefinition);
}
}
return beanDefinitions;
}
}
10 changes: 5 additions & 5 deletions website/docs/40-configuration-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,16 +284,16 @@ This page was generated by [spring-configuration-property-documenter](https://gi

|Key|Type|Description|Default value|Deprecation|
|---|----|-----------|-------------|-----------|
| auto-mapping| java.lang.Boolean| Whether to route methods without the &#x60;google.api.http&#x60; option, default true. &lt;p&gt; Example: &lt;pre&gt;\{@code package bookstore; service Bookstore \{ rpc GetShelf(GetShelfRequest) returns (Shelf) \{} } message GetShelfRequest \{ int64 shelf &#x3D; 1; } message Shelf \{} }&lt;/pre&gt; &lt;p&gt; The client could &#x60;post&#x60; a json body &#x60;\{&quot;shelf&quot;: 1234}&#x60; with the path of &#x60;/bookstore.Bookstore/GetShelfRequest&#x60; to call &#x60;GetShelfRequest&#x60;.| true| |
| enabled| java.lang.Boolean| Whether to enable transcoding autoconfiguration, default \{@code true}.| true| |
| endpoint| java.lang.String| gRPC server endpoint, if not set, will use \{@code localhost:$\{grpc.server.port}}. &lt;p&gt; In most cases, do not need to set this property explicitly.| | |
| auto-mapping| java.lang.Boolean| | | |
| enabled| java.lang.Boolean| | | |
| endpoint| java.lang.String| | | |
### grpc.transcoding.print-options
**Class:** `grpcstarter.extensions.transcoding.GrpcTranscodingProperties$PrintOptions`

|Key|Type|Description|Default value|Deprecation|
|---|----|-----------|-------------|-----------|
| add-whitespace| java.lang.Boolean| Whether to add spaces, line breaks and indentation to make the JSON output easy to read. Defaults to false.| false| |
| always-print-enums-as-ints| java.lang.Boolean| Whether to always print enums as ints. By default they are rendered as strings. Defaults to false.| false| |
| add-whitespace| java.lang.Boolean| | | |
| always-print-enums-as-ints| java.lang.Boolean| | | |

## grpc-validation
### grpc.validation
Expand Down

0 comments on commit 378b8de

Please sign in to comment.