Java >> Java tutorial >  >> Tag >> HTTP

HTTP/2 i Netty

1. Oversigt

Netty er en NIO-baseret klient-server-ramme, der giver Java-udviklere magten til at operere på netværkslagene. Ved at bruge denne ramme kan udviklere bygge deres egen implementering af enhver kendt protokol eller endda brugerdefinerede protokoller.

For en grundlæggende forståelse af rammerne er introduktion til Netty en god start.

I denne selvstudie kan vi se, hvordan man implementerer en HTTP/2-server og -klient i Netty .

2. Hvad er HTTP/2 ?

Som navnet antyder, er HTTP version 2 eller blot HTTP/2 en nyere version af Hypertext Transfer Protocol.

Omkring år 1989, da internettet blev født, opstod HTTP/1.0. I 1997 blev den opgraderet til version 1.1. Det var dog først i 2015, at den så en større opgradering, version 2.

Når dette skrives, er HTTP/3 også tilgængelig, selvom den endnu ikke understøttes som standard af alle browsere.

HTTP/2 er stadig den seneste version af protokollen, der er bredt accepteret og implementeret. Den adskiller sig væsentligt fra de tidligere versioner med blandt andet dens multipleksing og server push-funktioner.

Kommunikation i HTTP/2 sker via en gruppe bytes kaldet frames, og flere frames danner en strøm.

I vores kodeeksempler vil vi se, hvordan Netty håndterer udvekslingen af ​​HEADERS, DATA og SETTINGS-rammer .

3. Serveren

Lad os nu se, hvordan vi kan oprette en HTTP/2-server i Netty.

3.1. SslContext

Netty understøtter APN-forhandling for HTTP/2 over TLS. Så det første, vi skal bruge for at oprette en server, er en SslContext :

SelfSignedCertificate ssc = new SelfSignedCertificate();
SslContext sslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey())
  .sslProvider(SslProvider.JDK)
  .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
  .applicationProtocolConfig(
    new ApplicationProtocolConfig(Protocol.ALPN, SelectorFailureBehavior.NO_ADVERTISE,
      SelectedListenerFailureBehavior.ACCEPT, ApplicationProtocolNames.HTTP_2))
  .build();

Her oprettede vi en kontekst til serveren med en JDK SSL-udbyder, tilføjede et par cifre og konfigurerede Application-Layer Protocol Negotiation til HTTP/2.

Det betyder, at vores server kun understøtter HTTP/2 og dens underliggende protokol-id h2 .

3.2. Bootstrapping af serveren med en ChannelInitializer

Dernæst har vi brug for en ChannelInitializer for vores multipleksing underordnede kanal, for at oprette en Netty pipeline.

Vi bruger den tidligere sslContext i denne kanal for at starte pipelinen, og bootstrap derefter serveren:

public final class Http2Server {

    static final int PORT = 8443;

    public static void main(String[] args) throws Exception {
        SslContext sslCtx = // create sslContext as described above
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.option(ChannelOption.SO_BACKLOG, 1024);
            b.group(group)
              .channel(NioServerSocketChannel.class)
              .handler(new LoggingHandler(LogLevel.INFO))
              .childHandler(new ChannelInitializer() {
                  @Override
                  protected void initChannel(SocketChannel ch) throws Exception {
                      if (sslCtx != null) {
                          ch.pipeline()
                            .addLast(sslCtx.newHandler(ch.alloc()), Http2Util.getServerAPNHandler());
                      }
                  }
            });
            Channel ch = b.bind(PORT).sync().channel();

            logger.info("HTTP/2 Server is listening on https://127.0.0.1:" + PORT + '/');

            ch.closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }
    }
}

Som en del af denne kanals initialisering tilføjer vi en APN-handler til pipelinen i en hjælpemetode getServerAPNHandler() som vi har defineret i vores egen hjælpeklasse Http2Util :

public static ApplicationProtocolNegotiationHandler getServerAPNHandler() {
    ApplicationProtocolNegotiationHandler serverAPNHandler = 
      new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_2) {
        
        @Override
        protected void configurePipeline(ChannelHandlerContext ctx, String protocol) throws Exception {
            if (ApplicationProtocolNames.HTTP_2.equals(protocol)) {
                ctx.pipeline().addLast(
                  Http2FrameCodecBuilder.forServer().build(), new Http2ServerResponseHandler());
                return;
            }
            throw new IllegalStateException("Protocol: " + protocol + " not supported");
        }
    };
    return serverAPNHandler;
}

Denne handler tilføjer til gengæld en Netty-leveret Http2FrameCodec ved at bruge sin builder og en brugerdefineret handler kaldet Http2ServerResponseHandler .

Vores brugerdefinerede handler udvider Nettys ChannelDuplexHandler og fungerer som både en indgående såvel som en udgående handler for serveren. Primært forbereder det svaret til at blive sendt til klienten.

Til formålet med denne øvelse, definerer vi en statisk Hej verden svar i en io.netty.buffer.ByteBuf – det foretrukne objekt til at læse og skrive bytes i Netty:

static final ByteBuf RESPONSE_BYTES = Unpooled.unreleasableBuffer(
  Unpooled.copiedBuffer("Hello World", CharsetUtil.UTF_8));

Denne buffer vil blive sat som en DATA-ramme i vores handlers channelRead metode og skrevet til ChannelHandlerContext :

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    if (msg instanceof Http2HeadersFrame) {
        Http2HeadersFrame msgHeader = (Http2HeadersFrame) msg;
        if (msgHeader.isEndStream()) {
            ByteBuf content = ctx.alloc().buffer();
            content.writeBytes(RESPONSE_BYTES.duplicate());

            Http2Headers headers = new DefaultHttp2Headers().status(HttpResponseStatus.OK.codeAsText());
            ctx.write(new DefaultHttp2HeadersFrame(headers).stream(msgHeader.stream()));
            ctx.write(new DefaultHttp2DataFrame(content, true).stream(msgHeader.stream()));
        }
    } else {
        super.channelRead(ctx, msg);
    }
}

Og det er det, vores server er klar til at dele Hello World. op

For en hurtig test, start serveren og affyr en curl-kommando med –http2 mulighed:

curl -k -v --http2 https://127.0.0.1:8443

Hvilket vil give et svar svarende til:

> GET / HTTP/2
> Host: 127.0.0.1:8443
> User-Agent: curl/7.64.1
> Accept: */*
> 
* Connection state changed (MAX_CONCURRENT_STREAMS == 4294967295)!
< HTTP/2 200 
< 
* Connection #0 to host 127.0.0.1 left intact
Hello World* Closing connection 0

4. Klienten

Lad os derefter tage et kig på klienten. Dens formål er selvfølgelig at sende en forespørgsel og derefter håndtere svaret fra serveren.

Vores klientkode vil bestå af et par behandlere, en initialiseringsklasse til at sætte dem op i en pipeline og til sidst en JUnit-test for at bootstrap klienten og samle alt.

4.1. SslContext

Men igen, lad os først se, hvordan klientens SslContext er sat op. Vi skriver dette som en del af opsætningen af ​​vores klient JUnit:

@Before
public void setup() throws Exception {
    SslContext sslCtx = SslContextBuilder.forClient()
      .sslProvider(SslProvider.JDK)
      .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
      .trustManager(InsecureTrustManagerFactory.INSTANCE)
      .applicationProtocolConfig(
        new ApplicationProtocolConfig(Protocol.ALPN, SelectorFailureBehavior.NO_ADVERTISE,
          SelectedListenerFailureBehavior.ACCEPT, ApplicationProtocolNames.HTTP_2))
      .build();
}

Som vi kan se, ligner det stort set serverens SslContext , bare at vi ikke leverer noget Selvsigneret certifikat her. En anden forskel er, at vi tilføjer en InsecureTrustManagerFactory at stole på ethvert certifikat uden nogen form for bekræftelse.

Vigtigt, denne tillidsmanager er udelukkende til demoformål og bør ikke bruges i produktionen . For at bruge betroede certifikater i stedet tilbyder Nettys SslContextBuilder mange alternativer.

Vi vender tilbage til denne JUnit til sidst for at bootstrap klienten.

4.2. Håndtere

Lad os indtil videre tage et kig på handlerne.

Først skal vi bruge en handler, vi kalder Http2SettingsHandler , for at håndtere HTTP/2's SETTINGS-ramme . Det udvider Nettys SimpleChannelInboundHandler :

public class Http2SettingsHandler extends SimpleChannelInboundHandler<Http2Settings> {
    private final ChannelPromise promise;

    // constructor

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Http2Settings msg) throws Exception {
        promise.setSuccess();
        ctx.pipeline().remove(this);
    }
}

Klassen initialiserer simpelthen et ChannelPromise og markere det som vellykket.

Den har også en hjælpemetode awaitSettings som vores klient vil bruge for at vente på det første håndtryk færdiggørelse:

public void awaitSettings(long timeout, TimeUnit unit) throws Exception {
    if (!promise.awaitUninterruptibly(timeout, unit)) {
        throw new IllegalStateException("Timed out waiting for settings");
    }
}

Hvis kanallæsningen ikke sker inden for den angivne timeoutperiode, er en IllegalStateException er smidt.

For det andet bruger vi en handler til at håndtere svaret fra serveren , vil vi kalde det Http2ClientResponseHandler :

public class Http2ClientResponseHandler extends SimpleChannelInboundHandler {

    private final Map<Integer, MapValues> streamidMap;

    // constructor
}

Denne klasse udvider også SimpleChannelInboundHandler og erklærer et streamidMap af MapValues , en indre klasse af vores Http2ClientResponseHandler :

public static class MapValues {
    ChannelFuture writeFuture;
    ChannelPromise promise;

    // constructor and getters
}

Vi tilføjede denne klasse for at kunne gemme to værdier for et givet heltal nøgle.

Behandleren har også en hjælpemetode put , selvfølgelig for at sætte værdier i streamidMap :

public MapValues put(int streamId, ChannelFuture writeFuture, ChannelPromise promise) {
    return streamidMap.put(streamId, new MapValues(writeFuture, promise));
}

Lad os derefter se, hvad denne handler gør, når kanalen læses i pipelinen.

Grundlæggende er dette stedet, hvor vi får DATA-rammen eller ByteBuf indhold fra serveren som et FullHttpResponse og kan manipulere det på den måde, vi ønsker.

I dette eksempel logger vi det bare:

@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse msg) throws Exception {
    Integer streamId = msg.headers().getInt(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text());
    if (streamId == null) {
        logger.error("HttpResponseHandler unexpected message received: " + msg);
        return;
    }

    MapValues value = streamidMap.get(streamId);

    if (value == null) {
        logger.error("Message received for unknown stream id " + streamId);
    } else {
        ByteBuf content = msg.content();
        if (content.isReadable()) {
            int contentLength = content.readableBytes();
            byte[] arr = new byte[contentLength];
            content.readBytes(arr);
            logger.info(new String(arr, 0, contentLength, CharsetUtil.UTF_8));
        }

        value.getPromise().setSuccess();
    }
}

I slutningen af ​​metoden markerer vi ChannelPromise som vellykket for at indikere korrekt afslutning.

Som den første handler, vi beskrev, indeholder denne klasse også en hjælpemetode til vores klients brug. Metoden får vores begivenhedsløkke til at vente indtil ChannelPromise er vellykket. Eller med andre ord, den venter, indtil svarbehandlingen er fuldført:

public String awaitResponses(long timeout, TimeUnit unit) {
    Iterator<Entry<Integer, MapValues>> itr = streamidMap.entrySet().iterator();        
    String response = null;

    while (itr.hasNext()) {
        Entry<Integer, MapValues> entry = itr.next();
        ChannelFuture writeFuture = entry.getValue().getWriteFuture();

        if (!writeFuture.awaitUninterruptibly(timeout, unit)) {
            throw new IllegalStateException("Timed out waiting to write for stream id " + entry.getKey());
        }
        if (!writeFuture.isSuccess()) {
            throw new RuntimeException(writeFuture.cause());
        }
        ChannelPromise promise = entry.getValue().getPromise();

        if (!promise.awaitUninterruptibly(timeout, unit)) {
            throw new IllegalStateException("Timed out waiting for response on stream id "
              + entry.getKey());
        }
        if (!promise.isSuccess()) {
            throw new RuntimeException(promise.cause());
        }
        logger.info("---Stream id: " + entry.getKey() + " received---");
        response = entry.getValue().getResponse();
            
        itr.remove();
    }        
    return response;
}

4.3. Http2ClientInitializer

Som vi så i tilfældet med vores server, formålet med en ChannelInitializer er at oprette en pipeline:

public class Http2ClientInitializer extends ChannelInitializer {

    private final SslContext sslCtx;
    private final int maxContentLength;
    private Http2SettingsHandler settingsHandler;
    private Http2ClientResponseHandler responseHandler;
    private String host;
    private int port;

    // constructor

    @Override
    public void initChannel(SocketChannel ch) throws Exception {
        settingsHandler = new Http2SettingsHandler(ch.newPromise());
        responseHandler = new Http2ClientResponseHandler();
        
        if (sslCtx != null) {
            ChannelPipeline pipeline = ch.pipeline();
            pipeline.addLast(sslCtx.newHandler(ch.alloc(), host, port));
            pipeline.addLast(Http2Util.getClientAPNHandler(maxContentLength, 
              settingsHandler, responseHandler));
        }
    }
    // getters
}

I dette tilfælde starter vi pipelinen med en ny SslHandler for at tilføje TLS SNI-udvidelsen ved starten af ​​handshaking-processen.

Derefter er det ApplicationProtocolNegotiationHandlers ansvar at opstille en forbindelseshandler og vores tilpassede handlere i pipelinen:

public static ApplicationProtocolNegotiationHandler getClientAPNHandler(
  int maxContentLength, Http2SettingsHandler settingsHandler, Http2ClientResponseHandler responseHandler) {
    final Http2FrameLogger logger = new Http2FrameLogger(INFO, Http2ClientInitializer.class);
    final Http2Connection connection = new DefaultHttp2Connection(false);

    HttpToHttp2ConnectionHandler connectionHandler = 
      new HttpToHttp2ConnectionHandlerBuilder().frameListener(
        new DelegatingDecompressorFrameListener(connection, 
          new InboundHttp2ToHttpAdapterBuilder(connection)
            .maxContentLength(maxContentLength)
            .propagateSettings(true)
            .build()))
          .frameLogger(logger)
          .connection(connection)
          .build();

    ApplicationProtocolNegotiationHandler clientAPNHandler = 
      new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_2) {
        @Override
        protected void configurePipeline(ChannelHandlerContext ctx, String protocol) {
            if (ApplicationProtocolNames.HTTP_2.equals(protocol)) {
                ChannelPipeline p = ctx.pipeline();
                p.addLast(connectionHandler);
                p.addLast(settingsHandler, responseHandler);
                return;
            }
            ctx.close();
            throw new IllegalStateException("Protocol: " + protocol + " not supported");
        }
    };
    return clientAPNHandler;
}

Nu er alt, der er tilbage at gøre, at bootstrap klienten og sende en anmodning på tværs.

4.4. Bootstrapping af klienten

Bootstrapping af klienten svarer til serverens op til et punkt. Derefter skal vi tilføje en lille smule mere funktionalitet for at håndtere afsendelse af anmodningen og modtagelse af svaret.

Som nævnt tidligere, vil vi skrive dette som en JUnit-test:

@Test
public void whenRequestSent_thenHelloWorldReceived() throws Exception {

    EventLoopGroup workerGroup = new NioEventLoopGroup();
    Http2ClientInitializer initializer = new Http2ClientInitializer(sslCtx, Integer.MAX_VALUE, HOST, PORT);

    try {
        Bootstrap b = new Bootstrap();
        b.group(workerGroup);
        b.channel(NioSocketChannel.class);
        b.option(ChannelOption.SO_KEEPALIVE, true);
        b.remoteAddress(HOST, PORT);
        b.handler(initializer);

        channel = b.connect().syncUninterruptibly().channel();

        logger.info("Connected to [" + HOST + ':' + PORT + ']');

        Http2SettingsHandler http2SettingsHandler = initializer.getSettingsHandler();
        http2SettingsHandler.awaitSettings(60, TimeUnit.SECONDS);
  
        logger.info("Sending request(s)...");

        FullHttpRequest request = Http2Util.createGetRequest(HOST, PORT);

        Http2ClientResponseHandler responseHandler = initializer.getResponseHandler();
        int streamId = 3;

        responseHandler.put(streamId, channel.write(request), channel.newPromise());
        channel.flush();
 
        String response = responseHandler.awaitResponses(60, TimeUnit.SECONDS);

        assertEquals("Hello World", response);

        logger.info("Finished HTTP/2 request(s)");
    } finally {
        workerGroup.shutdownGracefully();
    }
}

Dette er især de ekstra trin, vi tog med hensyn til serverens bootstrap:

  • Først ventede vi på det første håndtryk ved at bruge Http2SettingsHandler 's awaitSettings metode
  • For det andet oprettede vi anmodningen som en FullHttpRequest
  • For det tredje sætter vi streamId i vores Http2ClientResponseHandler 's streamIdMap , og kaldte dens awaitResponses metode
  • Og til sidst bekræftede vi det Hej verden er faktisk opnået i svaret

I en nøddeskal, her er, hvad der skete – klienten sendte en HEADERS-ramme, det første SSL-håndtryk fandt sted, og serveren sendte svaret i en HEADERS- og en DATA-ramme.

5. Konklusion

I denne øvelse, så vi, hvordan man implementerer en HTTP/2-server og -klient i Netty ved hjælp af kodeeksempler for at få en Hello World svar ved hjælp af HTTP/2-rammer.

Vi håber at se mange flere forbedringer i Netty API til håndtering af HTTP/2-rammer i fremtiden, da der stadig arbejdes på det.

Som altid er kildekoden tilgængelig på GitHub.


Java tag