2
votes

I am migrating a distributed systems codebase from SOAP (JAX-WS) to gRPC-java. We use this codebase to teach remote calls, fault tolerance, security implementations.

On the JAX-WS architecture, there is an interceptor class (called a SOAP handler) that can intercept SOAP messages. You can configure handlers on the client and on the server.

For reference, this is a full sequence for a remote call on JAX-WS:

  • Client - create port (stub) and invoke remote method
  • Stub - convert Java objects to SOAP message (XML)
  • ClientHandler - intercepts outgoing SOAP message and can read/write on it
  • Network - SOAP request message transmitted
  • ServerHandler - intercepts incoming SOAP message, can read/write
  • Tie - convert SOAP message to Java objects
  • Server - execute method, respond
  • ServerHandler - intercepts outgoing SOAP response, can read/write
  • Network - SOAP response message transmitted
  • Client - create port (stub) and invoke remote method
  • Stub - convert Java objects to SOAP message (XML)
  • ClientHandler - intercepts incoming SOAP message
  • Client - receives response

With this approach, we can create handlers to log SOAP messages, and to add security, like digital signature or encryption.

I am trying to have similar capabilities with gRPC on Java (v1.17.2).

I based my gRPC code in this google tutorial, a simple hello world with a unary method.

Based on these examples, I have written a ClientInterceptor:

package example.grpc.client;
import java.util.Set;
import io.grpc.*;

public class HelloClientInterceptor implements ClientInterceptor {

@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> methodDescriptor,
        CallOptions callOptions, Channel channel) {
    return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
            channel.newCall(methodDescriptor, callOptions)) {

        @Override
        public void sendMessage(ReqT message) {
            System.out.printf("Sending method '%s' message '%s'%n", methodDescriptor.getFullMethodName(),
                    message.toString());
            super.sendMessage(message);
        }

        @Override
        public void start(Listener<RespT> responseListener, Metadata headers) {
            System.out.println(HelloClientInterceptor.class.getSimpleName());

            ClientCall.Listener<RespT> listener = new ForwardingClientCallListener<RespT>() {
                @Override
                protected Listener<RespT> delegate() {
                    return responseListener;
                }

                @Override
                public void onMessage(RespT message) {
                    System.out.printf("Received message '%s'%n", message.toString());
                    super.onMessage(message);
                }
            };

            super.start(listener, headers);
        }
    };
}

}

I have created a ServerInterceptor:

package example.grpc.server;

import java.util.Set;

import io.grpc.*;

public class HelloServerInterceptor implements ServerInterceptor {

@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata,
        ServerCallHandler<ReqT, RespT> serverCallHandler) {
    // print class name
    System.out.println(HelloServerInterceptor.class.getSimpleName());

    return Contexts.interceptCall(ctx, serverCall, metadata, serverCallHandler);
}

}

Here are (finally) my questions:

  1. How can the server interceptor see the message before and after the method execution?
  2. How can the server interceptor modify the message?
  3. How can the client interceptor modify the message?

The end-goal is to be able to write a CipherClientHandler and a CipherServerHandler that would encrypt the message bytes on the wire. I know that TLS is the right way to do it in practice, but I want students to do a custom implementation.

Thanks for any pointers in the right direction!

1

1 Answers

2
votes
  1. By "method execution", I'm assuming you're meaning "Server - execute method, respond" from earlier. The exact time the server method is called is not part of the interception API and should not be depended on. With the async server handlers today, it happens that the server's method is called when serverListener.halfClose() is called. But again, that should not be depended on. It's unclear why this is necessary.

  2. The server interceptor receives the ReqT message for the request and the RespT message for the response. To modify the messages, simply modify those messages before calling super.

  3. The client interceptor can do the same as the server interceptor; modify the message before passing it along.

Note that when I say "modify the message", it would generally be implemented as "make a copy of the message with the appropriate modifications."

But if you are going to want to encrypt/decrypt the messages, that doesn't flow as easily from the API because you are completely changing their type. You are given a ReqT and you will turn that into bytes. To do this you must modify the MethodDescriptors.

On client-side this can be done within start() and providing your own Marshallers to MethodDescriptor.Builder. You have access to the application's original MethodDescriptor, so you can use that to serialize to bytes.

Marshaller ENCRYPTING_MARSHALLER = new Marshaller<InputStream>() {
  @Override
  public InputStream parse(InputStream stream) {
    return decrypt(stream);
  }

  @Override
  public InputStream stream(InputStream stream) {
    return encrypt(stream);
  }
};

public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
        MethodDescriptor<ReqT, RespT> methodDescriptor,
        CallOptions callOptions, Channel channel) {
  ClientCall<InputStream, InputStream> call = channel.newCall(
      methodDescriptor.toBuilder(
        ENCRYPTING_MARSHALLER, ENCRYPTING_MARSHALLER),
      callOptions);
  // Can't use Forwarding* because the generics would break.
  // Note that all of this is basically boilerplate; the marshaller is
  // doing the work.
  return new ClientCall<ReqT, RespT>() {
    @Override
    public void halfClose() {
      call.halfClose();
    }
    // ... ditto for _all_ the other methods on ClientCall

    @Override
    public void sendMessage(ReqT message) {
      call.sendMessage(methodDescriptor.streamRequest(message));
    }

    @Override
    public void start(Listener<RespT> listener, Metadata headers) {
      call.start(new Listener<InputStream>() {
        @Override
        public void onHalfClose() {
          listener.onHalfClose();
        }
        // ... ditto for _all_ the other methods on Listener

        @Override
        public void onMessage(InputStream message) {
          listener.onMessage(methodDescriptor.parseResponse(message));
        }
      }, headers);
    }
  };
}

Server-side would normally be similar but a bit more complicated, since you would need to rebuild the ServerServiceDefinition which can't be done as a normal interceptor. But there happens to be a utility that does the boilerplate:

ssd = ServerInterceptors.useMarshalledMessages(ssd, ENCRYPTING_MARSHALLER);