grpc-java icon indicating copy to clipboard operation
grpc-java copied to clipboard

servlet: Implement gRPC server as a Servlet

Open dapengzhang0 opened this issue 7 years ago • 26 comments

The goal is as described in #1621 to add the ability to run a gRPC server as a Servlet on any web container with the Servlet 4.0 support and HTTP/2 enabled.

cc @vlsinitsyn @cs224 @jnehlmeier @hofmanndavid

cc @meltsufin for high level design

API

This PR provides the following API:

An adapter

/**
 * An adapter that transforms {@link HttpServletRequest} into gRPC request and lets a gRPC server
 * process it, and transforms the gRPC response into {@link HttpServletResponse}. An adapter can be
 * instantiated by {@link ServletServerBuilder#buildServletAdapter()}.
 *
 * <p>In a servlet, calling {@link #doPost(HttpServletRequest, HttpServletResponse)} inside {@link
 * javax.servlet.http.HttpServlet#doPost(HttpServletRequest, HttpServletResponse)} makes the servlet
 * backed by the gRPC server associated with the adapter. The servlet must support Asynchronous
 * Processing and must be deployed to a container that supports servlet 4.0 and enables HTTP/2.
 */
public final class ServletAdapter {
   /**
   * Call this method inside {@link javax.servlet.http.HttpServlet#doGet(HttpServletRequest,
   * HttpServletResponse)} to serve gRPC POST request.
   */
  public doPost(HttpServletRequest, HttpServletResponse);

and a grpc server builder

/** Builder to build a gRPC server that can run as a servlet. */
public final class io.grpc.servlet.ServletServerBuilder extends ServerBuilder

  /**
   * <p>Users should not call this method directly. Instead users should call
   * {@link #buildServletAdapter()} which internally will call {@code build()} and {@code start()}
   * appropriately.
   * @throws IllegalStateException if this method is called by users directly
   */
  @Override
  public Server build();

  /** Creates a {@link ServletAdapter}.*/
  public ServletAdapter buildServletAdapter();

and a servlet impl

public class GrpcServlet extends HttpServlet {
  public GrpcServlet(List<BindableService> grpcServices);
}

A ServletAdapter instance will be backing one or more gRPC services with a ServletServerBuilder

ServletAdapter servletAdapter = 
    new ServletServerBuilder().addService(new HelloServiceImpl()).buildServletAdapter();

A servlet powering the gRPC services will be as simple as either

@WebServlet(asyncSupported = true)
public class HelloServlet extends HttpServlet {

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
    servletAdapter.doPost(req, resp);  
  }
}

or

@WebServlet(asyncSupported = true)
public class HelloServlet extends GrpcServlet {
}

Alternative API

Hello World example

See examples/example-servlet/src/main/java/io/grpc/servlet/examples/helloworld/

Implementation

Aside from trivial API pluming, what the impl actually does is (in doPost() method)

  • start an AsyncContext
  • set WriteListener and ReadListener for the ServletOutputStream and ServletInputStream
  • create and pass a new instance of io.grpc.servlet.ServletServerStream to ServerTransportListener.streamCreated(serverStream, method, headers)
  • This ServletServerStream calls stream.transportState().inboundDataReceived() in the ReadListener.onDataAvailable() callback
  • The ServletServerStream holds a WritableBufferAllocator for the outbound data that will write to the ServletOutputStream, and uses an atomic ref of ServletServerStream.WriteState to coordinate with the WriteListener.onWritePossible() callback.
  • The ServletServerStream.TransportState.runOnTransportThread() method uses a SerializingExecutor(directExecutor())

Test result

We tested it with a servlet backed by the InteropTest gRPC service TestServiceImp, and an ordinary gRPC InteropTest client, for the test cases EMPTY_UNARY, LARGE_UNARY, CLIENT_STREAMING, SERVER_STREAMING, and PING_PONG. The following are the test results of some of the web container vendors

Jetty 10.0-SNAPSHOT

All tests passed! Jetty looks fully compliant with the Servlet 4.0 spec and HTTP/2 protocol. :100: @sbordet


GlassFish 5.0 - Web Profile

All tests passed with minor workaround. An issue of GlassFish is filed, cc @mattgill98 for help:


Tomcat 9.0.10

Non-blocking servlet I/O (ReadListener/WriteListener) practically can not work in full-duplex case. Only EMPTY_UNARY test passed. (Update: Filed multiple bugs to Tomcat: cc @markt-asf for help)

(Update: Tomcat 9.0.x trunk

All tests passed! :100:)


Undertow 2.0.11.Final/WildFly-14.0.0.Beta1

Not able to test. Simply can not make the servlet container send out trailers for plain servlet. cc @ctomc @stuartwdouglas for help (Update: Identified as undertow bug

)

(Update: Undertow 2.0.12.Final

All tests passed! :100:)

dapengzhang0 avatar Aug 04 '18 00:08 dapengzhang0

Trailers should be supported. Is there an easy way for me to test this so I can see what is wrong?

stuartwdouglas avatar Aug 07 '18 03:08 stuartwdouglas

@stuartwdouglas The StackOverflow question has provided a simple servlet, I couldn't even make that work. I might have missed some configuration. If you can successfully run that servlet and verify that client side receives the trailers, just let me know the steps I might have missed. Really appreciate it. On my machine, I can verify Glassfish, Tomcat and Jetty all send trailers, but Undertow/Wildfly does not.

dapengzhang0 avatar Aug 07 '18 04:08 dapengzhang0

This should be fixed in Undertow 2.0.12.Final, which will be released shortly.

stuartwdouglas avatar Aug 07 '18 05:08 stuartwdouglas

Thanks a lot @stuartwdouglas , I will test on it once it's released. Right now it is not straight forward to run the InteropTest for this PR, I'm trying to make the steps easier.

dapengzhang0 avatar Aug 07 '18 15:08 dapengzhang0

@dapengzhang0 can you try Jetty 9.4.11? It should have similar support for 4.0, trailers, etc. that Jetty 10 has.

sbordet avatar Aug 08 '18 10:08 sbordet

@sbordet I tried Jetty 9.4.11, it did not work

java.lang.NoSuchMethodError: javax.servlet.http.HttpServletResponse.setTrailerFields

It seems it does not support servlet 4.0. Actually its release note didn't claim it supports servlet 4.0. And it's pom

<dependencyManagement>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
</dependency>
...

dapengzhang0 avatar Aug 08 '18 17:08 dapengzhang0

@dapengzhang0 thanks for trying. Yes, with 9.4.x you need to downcast to Jetty specific classes to get the trailer methods. Never mind 9.4.x, and happy that Jetty 10.0.x works well for this case.

sbordet avatar Aug 09 '18 16:08 sbordet

There's a lot here, and much of it has obvious things that need to be cleaned up before a real review. What did you want me to look at for the initial review?

@ejona86 aside from impl details, for the initial review, is there anything fundamentally in a wrong approach, or anything important is missing (such as runOnTransportThread() I had missed), or anything I must fix before adding new stuff?

dapengzhang0 avatar Aug 13 '18 20:08 dapengzhang0

Added InteropTest and TransportTest with embedded Undertow servlet container. Rebased. @ejona86 PTAL

dapengzhang0 avatar Nov 02 '18 17:11 dapengzhang0

It seems like this is moving closer to being included. I will keep monitoring it.

ehallander9591 avatar Jun 26 '19 16:06 ehallander9591

@ehallander9591 Thank you for following the progress. I'll still working on it. Will send out updated PR in a week or so.

dapengzhang0 avatar Jun 26 '19 16:06 dapengzhang0

@creamsoup Would you like to be a second reviewer, on AbstractStream part at least?

dapengzhang0 avatar Jul 16 '19 18:07 dapengzhang0

@dapengzhang0, sure.

creamsoup avatar Jul 16 '19 18:07 creamsoup

@creamsoup

  • WriteListener#onWritePossible just calls the doWrite (similar to SynchronizedContext#drain).

  • any writes in Sink (header, data frame, trailers etc) need to be queued (SynchronizedContext#executeLater). after queue the write, it must call doWrite immediately. Those two steps are similar to SynchronizedContext#execute.

  • doWrite checks Reentrancy to make sure there is only one write just like SynchronizedContext#drain. the later doWrite returns immediately.

This is a great idea. However, on a second thought of the above approach, I found that it may cause the container to be in an illegal state in the following scenario:

(1) After some doWrite(), outputStream.isReady() returns false. An onWritePossible() callback will be called by the container later. (2) Then the sink tries to write some data.There is a race between Sink's doWirte and onWritePossible(). Suppose Sink's write wins the race. The sink will check outputStream.isReady() before the already scheduled/promised onWritePossible() is called. This violate the contract, the behavior is undefined, and may cause problem for some containers.

To solve this problem, we may still need something like writeState.stillWirtePossible, which adds complexity.

Also, we can not completely reuse SynchronizedContext or SerializingExecutor because we can not drain() all the buffered things. Instead, we need drainWhileReady(). This also adds complexity, and hard to prove the implementation's correctness.

dapengzhang0 avatar Aug 06 '19 16:08 dapengzhang0

I remember vaguely looking at the write flow before. I agree it is complicated. Since the writes are async, I think we should just use a lock for the write path, including writing to the async output stream. It seems that would greatly simplify the code.

ejona86 avatar Aug 07 '19 16:08 ejona86

@ejona86 Current write path implementation is so difficult to see the correctness. Will test out using a lock for the write path. Will update the PR if it works for all the three major containers. If any of containers has an issue, I will update the PR once the issue is fixed on the container side (Must guarantee the impl is well tested on three containers).

dapengzhang0 avatar Aug 07 '19 20:08 dapengzhang0

Inspired by @creamsoup , I found a simple and clean solution, reusing SerializingExecutor or SynchronizationContext, without lock, without other AtomicReference:

Executor wirteExecutor = new SerializingExecutor(directExecutor());// SyncCtx also works
boolean isWriteReady;
Queue< WritableBuffer> wirteQueue = new ArrayDeque();

void onWritePossible() {
  writeExecutor.execute(
      () -> {
        assert !isWriteReady;
        isWriteReady = outputStream.isReady(); 
        // the above is the ONLY place isWriteReady could flip from false to true

        while (isWriteReady && !writQueue.isEmpty()) {
          WritableBuffer buffer = writeQueue.poll();   
          outputStream.wirte(writeQueue.poll().readableBytes());
          isWriteReady = outputStream.isReady();
          // if the above returns false, another onWritePossible() will be called
          // by the container *later*,
        }
      });
}

// The difference from @creamsoup's approach is that writeFrame here never calls drain()
void writeFrame(WritableBuffer buffer) {
  writeExecutor.execute(
    () -> {
      if (isWriteReady) {
        assert writeQueue.isEmpty();
        outputStream.wirte(buffer.readableBytes());
        isWriteReady = outputStream.isReady();
        // if the above returns false, onWritePossible() will be called
        // by the container *later*
      } else {
        writeQueue.offer(buffer);
      }
    })
}

Update: This approach also has a subtle bug.

dapengzhang0 avatar Aug 08 '19 09:08 dapengzhang0

@dapengzhang0, that looks very concise and easy to follow. does it work on all 3 servlets?

creamsoup avatar Aug 08 '19 23:08 creamsoup

Hi all, is there a plan to get this reviewed and/or merged ? I'm considering forking grpc locally to build one supporting servlet unless this PR makes it through. Thanks

hypnoce avatar Feb 27 '21 13:02 hypnoce

Is this still maintained?

lprimak avatar May 13 '21 06:05 lprimak

Is this still maintained?

@dapengzhang0 are you planning to drive this to completion?

sanjaypujare avatar May 13 '21 16:05 sanjaypujare

Trying to use this in Payara, and getting all sorts of intermittent errors. Doesn't look like this works correctly currently Looks to me like this is abandoned. What a shame.

lprimak avatar May 13 '21 16:05 lprimak

Trying to use this in Payara, and getting all sorts of intermittent errors.

@lprimak, I tested GlassFish 5.0/Grizzly, it didn't pass all the interop tests like Jetty/UnderTow/Tomcat. Seems there are some HTTP/2 & aysnc-servlet issues in Grizzly. Payara is also based on Grizzly kernel so there could be the same problems. I didn't have time to identify the Grizzly bugs. Sorry for being inactive for a long time.

dapengzhang0 avatar May 13 '21 16:05 dapengzhang0

@dapengzhang0 Thanks, but are you maintaining this branch? Will it ever be merged? It's not compatible with the latest gRPC core even.

lprimak avatar May 13 '21 17:05 lprimak

I tested GlassFish 5.0/Grizzly, it didn't pass all the interop tests like Jetty/UnderTow/Tomcat. Seems there are some HTTP/2 & aysnc-servlet

Looks like these issues have been fixed in Payara in 2018 @dapengzhang0

lprimak avatar May 16 '21 17:05 lprimak

Can you at least update this PR to fix https://github.com/grpc/grpc-java/pull/4738#pullrequestreview-617677307 and have it working on the lastes gRPC-Java please? Thank you!

lprimak avatar May 16 '21 17:05 lprimak

@dapengzhang0 is this worked on? Looking forward to this! <3

rufreakde avatar Dec 21 '22 13:12 rufreakde

A newer version of this work is #8596, although there is no update on it for a long time too. Close this PR.

dapengzhang0 avatar Dec 21 '22 17:12 dapengzhang0

https://github.com/grpc/grpc-java/pull/8596 is now merged in https://github.com/grpc/grpc-java/releases/tag/v1.53.0

hutchig avatar Mar 17 '23 16:03 hutchig