Add the protocol-comparison module
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import com.ankurm.protocols.grpc.QuoteRequest;
|
||||
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
import reactor.core.publisher.BaseSubscriber;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The measurement that decides the article.
|
||||
*
|
||||
* <p>Each protocol serves an unbounded stream. Each client takes a hundred messages and then does
|
||||
* nothing for a while. The question is: <strong>how many did the server produce?</strong>
|
||||
*
|
||||
* <p>The sweep over quiet periods is what makes the answer conclusive. A number that stays flat
|
||||
* as the quiet period grows means something bounded the producer. A number that grows in
|
||||
* proportion to the wait means <em>nothing did</em> — the producer is running flat out and
|
||||
* the only limit is the clock.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"spring.rsocket.server.port=7003", "spring.grpc.server.port=9093"})
|
||||
class BackPressureTest {
|
||||
|
||||
private static final int WANTED = 100;
|
||||
private static final long[] QUIET_MS = {500, 1_000, 2_000};
|
||||
|
||||
@LocalServerPort
|
||||
int httpPort;
|
||||
|
||||
@Autowired
|
||||
RSocketStrategies strategies;
|
||||
|
||||
@Test
|
||||
void howManyDidTheServerProduce() throws Exception {
|
||||
System.out.println("=== unbounded stream; client takes " + WANTED + ", then goes quiet ===");
|
||||
System.out.printf("%-14s %10s %10s %10s%n", "protocol", "quiet 0.5s", "quiet 1s", "quiet 2s");
|
||||
|
||||
long[] rs = new long[QUIET_MS.length];
|
||||
long[] gr = new long[QUIET_MS.length];
|
||||
long[] ws = new long[QUIET_MS.length];
|
||||
for (int i = 0; i < QUIET_MS.length; i++) {
|
||||
rs[i] = rsocket(QUIET_MS[i]);
|
||||
gr[i] = grpc(QUIET_MS[i]);
|
||||
ws[i] = websocket(QUIET_MS[i]);
|
||||
}
|
||||
row("RSocket", rs);
|
||||
row("gRPC", gr);
|
||||
row("WebSocket", ws);
|
||||
|
||||
System.out.println();
|
||||
System.out.println("RSocket is flat: the server produced exactly what request(n) asked for.");
|
||||
System.out.printf("gRPC grows %.1fx between 0.5 s and 2 s; WebSocket grows %.1fx.%n",
|
||||
gr[2] / (double) gr[0], ws[2] / (double) ws[0]);
|
||||
System.out.println("Growth that tracks the wait, rather than settling at a buffer size,");
|
||||
System.out.println("means nothing bounded the producer. (Growth is faster than linear");
|
||||
System.out.println("because the loop is still being JIT-compiled during the first run.)");
|
||||
|
||||
// RSocket delivered exactly the demand, at every quiet period.
|
||||
assertThat(rs).containsOnly(WANTED);
|
||||
// The other two grew with the wait rather than settling at a buffer size.
|
||||
assertThat(gr[2]).isGreaterThan(gr[0] * 2);
|
||||
assertThat(ws[2]).isGreaterThan(ws[0] * 2);
|
||||
}
|
||||
|
||||
private void row(String label, long[] v) {
|
||||
System.out.printf("%-14s %,10d %,10d %,10d%n", label, v[0], v[1], v[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* RSocket: {@code request(n)} is a frame on the wire. The server is told the number, produces
|
||||
* that many, and stops. Nothing blocks and nothing buffers — the producer is simply not
|
||||
* called again.
|
||||
*/
|
||||
private long rsocket(long quietMs) throws Exception {
|
||||
RSocketRequester requester = RSocketRequester.builder()
|
||||
.rsocketStrategies(strategies).tcp("localhost", 7003);
|
||||
try {
|
||||
AtomicLong received = new AtomicLong();
|
||||
BaseSubscriber<Quote> subscriber = new BaseSubscriber<>() {
|
||||
@Override
|
||||
protected void hookOnSubscribe(Subscription subscription) {
|
||||
subscription.request(WANTED); // and never again
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnNext(Quote value) {
|
||||
received.incrementAndGet();
|
||||
}
|
||||
};
|
||||
requester.route("quotes.unbounded").data("AAPL")
|
||||
.retrieveFlux(Quote.class).subscribe(subscriber);
|
||||
Thread.sleep(quietMs);
|
||||
long produced = requester.route("produced").retrieveMono(Long.class).block();
|
||||
subscriber.dispose();
|
||||
assertThat(received.get()).isEqualTo(WANTED);
|
||||
return produced;
|
||||
}
|
||||
finally {
|
||||
requester.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* gRPC: there is no application-level demand signal. The blocking stub's {@code Iterator}
|
||||
* requests one message per {@code next()}, and the handler writes with
|
||||
* {@code StreamObserver.onNext}, which never blocks.
|
||||
*/
|
||||
private long grpc(long quietMs) throws Exception {
|
||||
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9093)
|
||||
.usePlaintext().build();
|
||||
try {
|
||||
var stub = QuoteServiceGrpc.newBlockingStub(channel);
|
||||
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
|
||||
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamUnbounded(req);
|
||||
long received = 0;
|
||||
while (received < WANTED && it.hasNext()) {
|
||||
it.next();
|
||||
received++;
|
||||
}
|
||||
Thread.sleep(quietMs);
|
||||
return stub.produced(req).getCount();
|
||||
}
|
||||
finally {
|
||||
channel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw WebSocket: the frame protocol has no notion of demand at all. */
|
||||
private long websocket(long quietMs) throws Exception {
|
||||
try (WsClient client = new WsClient(httpPort, WANTED)) {
|
||||
client.send("UNBOUNDED AAPL");
|
||||
long received = 0;
|
||||
while (received < WANTED && client.take(5_000) != null) {
|
||||
received++;
|
||||
}
|
||||
Thread.sleep(quietMs);
|
||||
// Ask on a second connection: the first one's server-side thread is busy writing.
|
||||
try (WsClient asker = new WsClient(httpPort, 8)) {
|
||||
asker.send("PRODUCED");
|
||||
return Long.parseLong(asker.take(5_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Latency bookkeeping, kept in one place so the three protocols are measured identically. */
|
||||
final class Bench {
|
||||
|
||||
private final long[] nanos;
|
||||
private int i;
|
||||
|
||||
Bench(int n) {
|
||||
this.nanos = new long[n];
|
||||
}
|
||||
|
||||
void record(long ns) {
|
||||
nanos[i++] = ns;
|
||||
}
|
||||
|
||||
/** p50, p99 and mean in microseconds, plus calls per second derived from the mean. */
|
||||
String summary(String label) {
|
||||
long[] sorted = Arrays.copyOf(nanos, i);
|
||||
Arrays.sort(sorted);
|
||||
double p50 = sorted[(int) (sorted.length * 0.50)] / 1000.0;
|
||||
double p99 = sorted[(int) (sorted.length * 0.99)] / 1000.0;
|
||||
double mean = Arrays.stream(sorted).average().orElse(0) / 1000.0;
|
||||
return String.format("%-12s n=%-6d p50=%8.1f us p99=%9.1f us mean=%8.1f us ~%,.0f calls/s",
|
||||
label, sorted.length, p50, p99, mean, 1_000_000.0 / mean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import java.util.Iterator;
|
||||
|
||||
import com.ankurm.protocols.grpc.QuoteRequest;
|
||||
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* The documented gRPC answer to over-production, tested rather than assumed.
|
||||
*
|
||||
* <p>Every guide to gRPC flow control says the same thing: {@code StreamObserver.onNext} does not
|
||||
* block, so a server-streaming handler must consult
|
||||
* {@code ServerCallStreamObserver.isReady()} before writing. This test runs the identical
|
||||
* scenario against a handler that does exactly that.
|
||||
*
|
||||
* <p>It did not help. The transcript is committed as measured — on a loopback connection
|
||||
* with a client that has stopped reading, the ready flag stays set and the loop keeps producing
|
||||
* at the same rate. I have not established whether the messages accumulate in the server's
|
||||
* outbound queue, in the client transport, or are discarded after the deframer, and I am not
|
||||
* going to assert a mechanism I have not read the source for. What the numbers do establish is
|
||||
* narrower and still useful: <strong>{@code isReady()} is not a substitute for a demand
|
||||
* signal.</strong>
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"spring.rsocket.server.port=7004", "spring.grpc.server.port=9094"})
|
||||
class GrpcIsReadyTest {
|
||||
|
||||
private static final int WANTED = 100;
|
||||
|
||||
@Test
|
||||
void isReadyDoesNotBoundTheProducerHere() throws Exception {
|
||||
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9094)
|
||||
.usePlaintext().build();
|
||||
try {
|
||||
var stub = QuoteServiceGrpc.newBlockingStub(channel);
|
||||
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
|
||||
|
||||
System.out.println("=== gRPC server streaming, client takes 100 then stops ===");
|
||||
System.out.printf("%-28s %10s %10s%n", "handler", "quiet 0.5s", "quiet 2s");
|
||||
System.out.printf("%-28s %,10d %,10d%n", "plain onNext loop",
|
||||
run(stub, req, false, 500), run(stub, req, false, 2_000));
|
||||
System.out.printf("%-28s %,10d %,10d%n", "isReady() checked before write",
|
||||
run(stub, req, true, 500), run(stub, req, true, 2_000));
|
||||
}
|
||||
finally {
|
||||
channel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private long run(QuoteServiceGrpc.QuoteServiceBlockingStub stub, QuoteRequest req,
|
||||
boolean ready, long quietMs) throws Exception {
|
||||
Iterator<com.ankurm.protocols.grpc.Quote> it =
|
||||
ready ? stub.streamUnboundedReady(req) : stub.streamUnbounded(req);
|
||||
long received = 0;
|
||||
while (received < WANTED && it.hasNext()) {
|
||||
it.next();
|
||||
received++;
|
||||
}
|
||||
Thread.sleep(quietMs);
|
||||
return stub.produced(req).getCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import java.util.HexFormat;
|
||||
|
||||
import com.ankurm.protocols.grpc.Quote;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.dataformat.cbor.CBORMapper;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* The same five fields, encoded three ways.
|
||||
*
|
||||
* <p>This is the one comparison that needs no server, no warm-up and no statistics: it is a
|
||||
* property of the wire formats. It is also the honest place to start, because on a loopback
|
||||
* benchmark the encoding is most of what separates the three protocols.
|
||||
*/
|
||||
class PayloadSizeTest {
|
||||
|
||||
private static final com.ankurm.protocols.Quote POJO =
|
||||
new com.ankurm.protocols.Quote("AAPL", 42, 100.42, 100.44, 1_772_000_000_000_000L);
|
||||
|
||||
@Test
|
||||
void protobufVersusJsonVersusCbor() {
|
||||
byte[] proto = Quote.newBuilder()
|
||||
.setSymbol("AAPL").setSeq(42)
|
||||
.setBid(100.42).setAsk(100.44)
|
||||
.setEpochMicros(1_772_000_000_000_000L)
|
||||
.build().toByteArray();
|
||||
|
||||
byte[] json = new ObjectMapper().writeValueAsBytes(POJO);
|
||||
byte[] cbor = new CBORMapper().writeValueAsBytes(POJO);
|
||||
|
||||
System.out.println("=== one Quote, five fields, three encodings ===");
|
||||
System.out.printf("protobuf : %3d bytes %s%n", proto.length, HexFormat.of().formatHex(proto));
|
||||
System.out.printf("JSON : %3d bytes %s%n", json.length, new String(json));
|
||||
System.out.printf("CBOR : %3d bytes %s%n", cbor.length, HexFormat.of().formatHex(cbor));
|
||||
System.out.printf("JSON is %.2fx protobuf; CBOR is %.2fx protobuf%n",
|
||||
json.length / (double) proto.length, cbor.length / (double) proto.length);
|
||||
|
||||
// Protobuf is smallest because field names are numbers and there is no framing text.
|
||||
assertThat(proto.length).isLessThan(cbor.length);
|
||||
assertThat(cbor.length).isLessThan(json.length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* What Boot actually configures for RSocket, printed rather than quoted.
|
||||
*
|
||||
* <p>A bare {@code RSocketStrategies.create()} carries no JSON and no CBOR at all — only
|
||||
* the string and buffer codecs. Everything interesting comes from Boot's
|
||||
* {@code RSocketStrategiesAutoConfiguration}, which is also where the default data mime type is
|
||||
* decided, and it is not the one most people assume.
|
||||
*/
|
||||
@SpringBootTest(properties = {"spring.rsocket.server.port=7005", "spring.grpc.server.port=9095"})
|
||||
class RSocketDefaultsTest {
|
||||
|
||||
@Autowired
|
||||
RSocketStrategies strategies;
|
||||
|
||||
@Test
|
||||
void bootConfiguredCodecs() {
|
||||
var encoders = strategies.encoders().stream().map(e -> e.getClass().getSimpleName()).toList();
|
||||
var decoders = strategies.decoders().stream().map(d -> d.getClass().getSimpleName()).toList();
|
||||
|
||||
System.out.println("=== Boot-configured RSocketStrategies ===");
|
||||
System.out.println("encoders : " + encoders);
|
||||
System.out.println("decoders : " + decoders);
|
||||
System.out.println("bare RSocketStrategies.create() encoders : "
|
||||
+ RSocketStrategies.create().encoders().stream()
|
||||
.map(e -> e.getClass().getSimpleName()).toList());
|
||||
|
||||
assertThat(encoders).isNotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import com.ankurm.protocols.grpc.QuoteRequest;
|
||||
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Sequential request/response round trips, all three protocols, same JVM, same data source.
|
||||
*
|
||||
* <p>Read the numbers as a comparison of the three, not as absolutes. Client and server are the
|
||||
* same process on one loopback interface with two cores, so the network — the thing that
|
||||
* dominates every real deployment — is absent. That deletes the advantage a smaller
|
||||
* encoding would have on a real link and leaves framing and dispatch cost, which is exactly the
|
||||
* part a loopback measures well.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"spring.rsocket.server.port=7001", "spring.grpc.server.port=9091"})
|
||||
class RequestResponseBenchmarkTest {
|
||||
|
||||
private static final int WARMUP = 2_000;
|
||||
private static final int MEASURED = 5_000;
|
||||
|
||||
@LocalServerPort
|
||||
int httpPort;
|
||||
|
||||
@Autowired
|
||||
RSocketStrategies strategies;
|
||||
|
||||
@Test
|
||||
void threeProtocolsOneOperation() throws Exception {
|
||||
System.out.println("=== request/response, " + MEASURED + " sequential calls after "
|
||||
+ WARMUP + " warm-up, loopback, JDK " + System.getProperty("java.version") + " ===");
|
||||
System.out.println(grpc());
|
||||
System.out.println(rsocket());
|
||||
System.out.println(websocket());
|
||||
}
|
||||
|
||||
private String grpc() {
|
||||
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9091)
|
||||
.usePlaintext().build();
|
||||
try {
|
||||
var stub = QuoteServiceGrpc.newBlockingStub(channel);
|
||||
QuoteRequest req = QuoteRequest.newBuilder().setSymbol("AAPL").build();
|
||||
for (int i = 0; i < WARMUP; i++) {
|
||||
stub.getQuote(req);
|
||||
}
|
||||
Bench bench = new Bench(MEASURED);
|
||||
for (int i = 0; i < MEASURED; i++) {
|
||||
long t0 = System.nanoTime();
|
||||
var quote = stub.getQuote(req);
|
||||
bench.record(System.nanoTime() - t0);
|
||||
assertThat(quote.getSymbol()).isEqualTo("AAPL");
|
||||
}
|
||||
return bench.summary("gRPC");
|
||||
}
|
||||
finally {
|
||||
channel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private String rsocket() {
|
||||
RSocketRequester requester = RSocketRequester.builder()
|
||||
.rsocketStrategies(strategies).tcp("localhost", 7001);
|
||||
try {
|
||||
for (int i = 0; i < WARMUP; i++) {
|
||||
requester.route("quote").data("AAPL").retrieveMono(Quote.class).block();
|
||||
}
|
||||
Bench bench = new Bench(MEASURED);
|
||||
for (int i = 0; i < MEASURED; i++) {
|
||||
long t0 = System.nanoTime();
|
||||
Quote quote = requester.route("quote").data("AAPL")
|
||||
.retrieveMono(Quote.class).block();
|
||||
bench.record(System.nanoTime() - t0);
|
||||
assertThat(quote).isNotNull();
|
||||
}
|
||||
return bench.summary("RSocket");
|
||||
}
|
||||
finally {
|
||||
requester.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private String websocket() throws Exception {
|
||||
try (WsClient client = new WsClient(httpPort, 64)) {
|
||||
for (int i = 0; i < WARMUP; i++) {
|
||||
client.send("QUOTE AAPL");
|
||||
assertThat(client.take(5_000)).isNotNull();
|
||||
}
|
||||
Bench bench = new Bench(MEASURED);
|
||||
for (int i = 0; i < MEASURED; i++) {
|
||||
long t0 = System.nanoTime();
|
||||
client.send("QUOTE AAPL");
|
||||
String reply = client.take(5_000);
|
||||
bench.record(System.nanoTime() - t0);
|
||||
assertThat(reply).contains("AAPL");
|
||||
}
|
||||
return bench.summary("WebSocket");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import java.util.Iterator;
|
||||
|
||||
import com.ankurm.protocols.grpc.StreamRequest;
|
||||
import com.ankurm.protocols.grpc.QuoteServiceGrpc;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.messaging.rsocket.RSocketRequester;
|
||||
import org.springframework.messaging.rsocket.RSocketStrategies;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Server streaming: how fast can one connection carry N messages one way?
|
||||
*
|
||||
* <p>This is the shape most of these protocols are actually chosen for — a price feed, a
|
||||
* log tail, a progress stream — and it separates them differently from request/response,
|
||||
* because the per-message cost stops being dominated by a round trip.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"spring.rsocket.server.port=7002", "spring.grpc.server.port=9092"})
|
||||
class StreamThroughputBenchmarkTest {
|
||||
|
||||
private static final int WARMUP = 5_000;
|
||||
private static final int N = 50_000;
|
||||
|
||||
@LocalServerPort
|
||||
int httpPort;
|
||||
|
||||
@Autowired
|
||||
RSocketStrategies strategies;
|
||||
|
||||
@Test
|
||||
void fiftyThousandMessagesEachWay() throws Exception {
|
||||
System.out.println("=== server streaming, " + N + " messages on one connection, loopback ===");
|
||||
System.out.println(grpc());
|
||||
System.out.println(rsocket());
|
||||
System.out.println(websocket());
|
||||
}
|
||||
|
||||
private String grpc() {
|
||||
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9092)
|
||||
.usePlaintext().build();
|
||||
try {
|
||||
var stub = QuoteServiceGrpc.newBlockingStub(channel);
|
||||
drain(stub, WARMUP);
|
||||
long t0 = System.nanoTime();
|
||||
int seen = drain(stub, N);
|
||||
return report("gRPC", seen, System.nanoTime() - t0);
|
||||
}
|
||||
finally {
|
||||
channel.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private int drain(QuoteServiceGrpc.QuoteServiceBlockingStub stub, int count) {
|
||||
// The blocking stub returns an Iterator. Errors surface mid-iteration rather than at the
|
||||
// call, which is the first thing that surprises people about gRPC server streaming.
|
||||
Iterator<com.ankurm.protocols.grpc.Quote> it = stub.streamQuotes(
|
||||
StreamRequest.newBuilder().setSymbol("AAPL").setCount(count).build());
|
||||
int seen = 0;
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
seen++;
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
private String rsocket() {
|
||||
RSocketRequester requester = RSocketRequester.builder()
|
||||
.rsocketStrategies(strategies).tcp("localhost", 7002);
|
||||
try {
|
||||
var warm = new RSocketQuoteController.StreamSpec("AAPL", WARMUP, 0);
|
||||
requester.route("quotes").data(warm).retrieveFlux(Quote.class).blockLast();
|
||||
|
||||
var spec = new RSocketQuoteController.StreamSpec("AAPL", N, 0);
|
||||
long t0 = System.nanoTime();
|
||||
long seen = requester.route("quotes").data(spec)
|
||||
.retrieveFlux(Quote.class).count().block();
|
||||
return report("RSocket", (int) seen, System.nanoTime() - t0);
|
||||
}
|
||||
finally {
|
||||
requester.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private String websocket() throws Exception {
|
||||
// A generous inbox, because there is nothing else to do with messages that arrive faster
|
||||
// than they are read. This IS the WebSocket back-pressure story, in one constant.
|
||||
try (WsClient client = new WsClient(httpPort, N + WARMUP + 16)) {
|
||||
client.send("STREAM AAPL " + WARMUP + " 0");
|
||||
int warm = 0;
|
||||
String m;
|
||||
while ((m = client.take(20_000)) != null && !"END".equals(m)) {
|
||||
warm++;
|
||||
}
|
||||
assertThat(warm).isEqualTo(WARMUP);
|
||||
|
||||
long t0 = System.nanoTime();
|
||||
client.send("STREAM AAPL " + N + " 0");
|
||||
int seen = 0;
|
||||
while ((m = client.take(20_000)) != null && !"END".equals(m)) {
|
||||
seen++;
|
||||
}
|
||||
return report("WebSocket", seen, System.nanoTime() - t0);
|
||||
}
|
||||
}
|
||||
|
||||
private String report(String label, int seen, long elapsedNanos) {
|
||||
assertThat(seen).isEqualTo(N);
|
||||
double seconds = elapsedNanos / 1e9;
|
||||
return String.format("%-12s %,7d msgs in %6.3f s = %,10.0f msgs/s (%6.2f us each)",
|
||||
label, seen, seconds, seen / seconds, elapsedNanos / 1000.0 / seen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.protocols;
|
||||
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import jakarta.websocket.ContainerProvider;
|
||||
import jakarta.websocket.WebSocketContainer;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
/**
|
||||
* A minimal raw-WebSocket client for the benchmark.
|
||||
*
|
||||
* <p>The queue is the point of interest. A WebSocket client has no way to tell the server how
|
||||
* many messages it is ready for, so the only thing it can do with an over-eager producer is
|
||||
* buffer them — here, in a bounded queue that starts dropping. That is the shape of every
|
||||
* real WebSocket consumer, and it is what {@code BackPressureTest} contrasts with RSocket.
|
||||
*/
|
||||
final class WsClient implements AutoCloseable {
|
||||
|
||||
private final WebSocketSession session;
|
||||
private final BlockingQueue<String> inbox;
|
||||
|
||||
WsClient(int port, int inboxCapacity) throws Exception {
|
||||
this.inbox = new ArrayBlockingQueue<>(inboxCapacity);
|
||||
WebSocketContainer container = ContainerProvider.getWebSocketContainer();
|
||||
container.setDefaultMaxTextMessageBufferSize(1024 * 1024);
|
||||
this.session = new StandardWebSocketClient(container)
|
||||
.execute(new TextWebSocketHandler() {
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession s, TextMessage message) {
|
||||
inbox.offer(message.getPayload()); // drops when full; nothing else to do
|
||||
}
|
||||
}, "ws://localhost:" + port + "/quotes").get();
|
||||
}
|
||||
|
||||
void send(String command) throws Exception {
|
||||
session.sendMessage(new TextMessage(command));
|
||||
}
|
||||
|
||||
String take(long timeoutMs) throws Exception {
|
||||
return inbox.poll(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
int drained() {
|
||||
return inbox.size();
|
||||
}
|
||||
|
||||
boolean isOpen() {
|
||||
return session.isOpen();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user