How to fully intercept gRPC java uniary calls on the client and server?
I’m migrating a distributed systems codebase from SOAP (JAX-WS) to gRPC-java.
We use this codebase to teach remote calling, fault tolerance, and secure implementation.
On the JAX-WS architecture, there is an interceptor class called a SOAP handler that intercepts SOAP messages. You can configure handlers on both the client and the server.
As a reference, here is the full sequence of JAX-WS remote calls:
- Client – Creates a port (stub) and calls remote methods
- stub – Converts Java objects into SOAP messages (XML).
- ClientHandler – Intercepts outgoing SOAP messages and can read/write to them
- Network – SOAP request message transmitted
- ServerHandler – Intercepts incoming SOAP messages and can read/write
- Tie – Converts SOAP messages into Java objects
- Server – Execute method, response
- ServerHandler – Intercepts outgoing SOAP responses and can read/write
- Network – The SOAP response message was transmitted
- Client – Creates a port (stub) and calls remote methods
- stub – Converts Java objects into SOAP messages (XML).
- ClientHandler – Intercepts incoming SOAP messages
- Client – Receives the response
With this approach, we can create handlers to log SOAP messages and add security, such as digital signatures or encryption.
I’m trying to use gRPC on Java (v1.17.2) with similar functionality.
My gRPC code is based on this google tutorial, a simple hello world with a unary method.
Based on these examples , I wrote 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 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 is my (finally) question:
- How does the server interceptor view messages before and after method execution?
- How does a server interceptor modify messages?
- How do client-side interceptors modify messages?
The ultimate goal is to be able to write a CipherClientHandler and a CipherServerHandler to encrypt message bytes on the network. I know TLS is the right implementation in practice, but I’d like students to do custom implementations.
Thanks for any pointers in the right direction!
Solution
- For “method execution
“, I assume that you are referring to the preceding “server – execution method, response”. The exact time when the server method is called is not part of the interception API and should not be relied upon. For today’s asynchronous server handlers, the server’s methods are called when
serverListener.halfClose()
is called. But again, you should not rely on this. It’s unclear why this is necessary.The server interceptor receives the
ReqT
message of the request and theRespT message
of the response. To modify messages, simply modify them before callingsuper
.Client-side interceptors can do the same thing as server-side interceptors; Modify the message before delivery.
Note that when I say “modify message”, it is usually implemented as “copy the message and make the appropriate modifications”.
However, if you want to encrypt/decrypt messages, it is not easy to encrypt/decrypt from the API because you are completely changing their type. You will get a ReqT
and you will convert it to bytes. To do this, you must modify MethodDescriptor
.
On the client side, this can be done in start()
and provide your own Marshaller
to MethodDescriptor.Builder.
You have access to the application’s original MethodDescriptor
, so you can use it 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);
}
};
}
The server side is usually similar, but a bit complicated because you need to rebuild the ServerServiceDefinition
, which can’t be done like a normal interceptor. But there happens to be a utility that executes the boilerplate :
ssd = ServerInterceptors.useMarshalledMessages(ssd, ENCRYPTING_MARSHALLER);