1. 개요

Netty 는 Java 개발자에게 네트워크 계층에서 작동할 수 있는 기능을 제공하는 NIO 기반 클라이언트-서버 프레임워크입니다. 이 프레임워크를 사용하여 개발자는 알려진 프로토콜 또는 사용자 지정 프로토콜의 자체 구현을 구축할 수 있습니다.

프레임워크에 대한 기본적인 이해를 위해서는 Netty를 소개 하는 것이 좋습니다.

이 예제에서는 Netty에서 HTTP/2 서버와 클라이언트를 구현하는 방법을 볼 것 입니다.

2. HTTP/2 란 무엇입니까 ?

이름에서 알 수 있듯이 HTTP 버전 2 또는 단순히 HTTP/2 는 Hypertext Transfer Protocol의 최신 버전입니다.

인터넷이 탄생한 1989년 즈음에 HTTP/1.0이 등장했습니다. 1997년에 버전 1.1로 업그레이드되었습니다. 그러나 2015년이 되어서야 주요 업그레이드 버전 2가 나타났습니다.

이 글을 쓰는 시점에서 HTTP/3 도 사용할 수 있지만 아직 모든 브라우저에서 기본적으로 지원되지는 않습니다.

HTTP/2는 여전히 널리 수용되고 구현되는 최신 버전의 프로토콜입니다. 무엇보다도 멀티플렉싱 및 서버 푸시 기능이 있는 이전 버전과 크게 다릅니다.

HTTP/2의 통신은 프레임이라고 하는 바이트 그룹을 통해 발생하며 여러 프레임이 스트림을 형성합니다.

코드 샘플에서 Netty가 HEADERS , DATASETTINGS 프레임 의 교환을 처리하는 방법을 볼 수 있습니다 .

3. 서버

이제 Netty에서 HTTP/2 서버를 만드는 방법을 살펴보겠습니다.

3.1. SSLContext

Netty는 TLS를 통한 HTTP/2에 대한 APN 협상을 지원합니다 . 따라서 서버를 생성하기 위해 가장 먼저 필요한 것은 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();

여기에서 JDK SSL 공급자를 사용하여 서버에 대한 컨텍스트를 만들고 몇 가지 암호를 추가하고 HTTP/2에 대한 응용 프로그램 계층 프로토콜 협상을 구성했습니다.

이것은 우리 서버가 HTTP/2 및 기본 프로토콜 식별자 h2 만 지원 한다는 것을 의미합니다 .

3.2. ChannelInitializer로 서버 부트스트랩

다음 으로 Netty 파이프라인을 설정하기 위해 다중화 자식 채널에 대한 ChannelInitializer 가 필요합니다 .

이 채널에서 이전 sslContext사용하여 파이프라인을 시작한 다음 서버를 부트스트랩합니다.

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();
        }
    }
}

이 채널 초기화의 일부로 APN 처리기를 자체 유틸리티 클래스 Http2Util에 정의한 유틸리티 메서드 getServerAPNHandler() 의 파이프라인에 추가합니다 .

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;
}

This handler is, in turn, adding a Netty provided Http2FrameCodec using its builder and a custom handler called Http2ServerResponseHandler.

Our custom handler extends Netty's ChannelDuplexHandler and acts as both an inbound as well as an outbound handler for the server. Primarily, it prepares the response to be sent to the client.

For the purpose of this tutorial, we'll define a static Hello World response in an io.netty.buffer.ByteBuf – the preferred object to read and write bytes in Netty:

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

This buffer will be set as a DATA frame in our handler's channelRead method and written to the 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);
    }
}

그게 다야, 우리 서버가 Hello World 를 내놓을 준비가 되었습니다 .

빠른 테스트를 위해 서버를 시작하고 –http2 옵션 과 함께 curl 명령을 실행합니다 .

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

다음과 유사한 응답을 제공합니다.

> 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. 클라이언트

다음으로 클라이언트를 살펴보겠습니다. 물론 그 목적은 요청을 보낸 다음 서버에서 얻은 응답을 처리하는 것입니다.

우리의 클라이언트 코드는 두 개의 핸들러, 파이프라인에서 설정하기 위한 이니셜라이저 클래스, 마지막으로 클라이언트를 부트스트랩하고 모든 것을 함께 가져오는 JUnit 테스트로 구성됩니다.

4.1. SSLContext

그러나 다시 처음에는 클라이언트의 SslContext어떻게 설정 되는지 봅시다 . 우리는 이것을 클라이언트 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();
}

보시다시피 서버의 S slContext 와 거의 비슷 하지만 여기서 SelfSignedCertificate를 제공하지 않는다는 점만 다릅니다 . 또 다른 차이점은 검증 없이 모든 인증서를 신뢰하기 위해 InsecureTrustManagerFactory추가한다는 것 입니다.

중요한 것은 이 신뢰 관리자는 순전히 데모용이며 프로덕션 환경에서 사용해서는 안 됩니다 . 대신 신뢰할 수 있는 인증서를 사용하기 위해 Netty의 SslContextBuilder 는 많은 대안을 제공합니다.

클라이언트를 부트스트랩하기 위해 마지막에 이 JUnit으로 돌아올 것입니다.

4.2. 핸들러

지금은 핸들러를 살펴보겠습니다.

먼저 HTTP/2의 SETTINGS 프레임을 처리하기 위해 Http2SettingsHandler 라고 하는 핸들러가 필요합니다 . Netty의 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);
    }
}

클래스는 단순히 ChannelPromise 를 초기화하고 성공한 것으로 플래그를 지정합니다.

또한 클라이언트가 초기 핸드셰이크 완료를 기다리기 위해 사용할 유틸리티 메서드 waitSettings 가 있습니다.

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

채널 읽기가 규정된 시간 초과 기간 동안 발생하지 않으면 IllegalStateException 이 발생합니다.

둘째, 서버에서 얻은 응답을 처리하기 위한 핸들러가 필요합니다. 이름을 Http2ClientResponseHandler로 지정합니다 .

public class Http2ClientResponseHandler extends SimpleChannelInboundHandler {

    private final Map<Integer, MapValues> streamidMap;

    // constructor
}

이 클래스는 확장 SimpleChannelInboundHandler을 하고 선언 streamidMapMapValues 의 내부 클래스를 우리 Http2ClientResponseHandler :

public static class MapValues {
    ChannelFuture writeFuture;
    ChannelPromise promise;

    // constructor and getters
}

주어진 정수 키에 대해 두 개의 값을 저장할 수 있도록 이 클래스를 추가했습니다 .

핸들러에는 물론 streamidMap 에 값을 넣는 유틸리티 메소드 put 도 있습니다 .

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

다음으로 파이프라인에서 채널을 읽을 때 이 핸들러가 수행하는 작업을 살펴보겠습니다.

기본적으로 서버에서 FullHttpResponse로 DATA 프레임이나 ByteBuf 콘텐츠를 가져와 원하는 방식으로 조작할 수 있는 입니다.

이 예에서는 다음과 같이 기록합니다.

@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();
    }
}

메서드가 끝나면 적절한 완료를 나타내기 위해 ChannelPromise 에 성공 플래그 를 지정합니다.

우리가 설명한 첫 번째 핸들러로서 이 클래스에는 클라이언트가 사용할 유틸리티 메소드도 포함되어 있습니다. 이 메서드는 ChannelPromise 가 성공할 때까지 이벤트 루프를 기다리게 합니다 . 즉, 응답 처리가 완료될 때까지 기다립니다.

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

서버의 경우에서 보았듯이 ChannelInitializer 의 목적은 파이프라인을 설정하는 것입니다.

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
}

이 경우 핸드셰이킹 프로세스 시작 시 TLS SNI 확장 을 추가하기 위해 새 SslHandler파이프라인을 시작합니다.

그런 다음 파이프라인에서 연결 핸들러와 사용자 지정 핸들러를 정렬하는 것은 ApplicationProtocolNegotiationHandler 의 책임입니다 .

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;
}

이제 클라이언트를 부트스트랩하고 요청을 전송하는 일만 남았습니다.

4.4. 클라이언트 부트스트랩

클라이언트의 부트스트랩은 어느 정도까지는 서버의 부트스트래핑과 유사합니다. 그런 다음 요청을 보내고 응답을 수신하는 기능을 조금 더 추가해야 합니다.

이전에 언급했듯이 이것을 JUnit 테스트로 작성할 것입니다.

@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();
    }
}

특히, 다음은 서버 부트스트랩과 관련하여 취한 추가 단계입니다.

  • 먼저 Http2SettingsHandlerawaitSettings 메서드를 사용하여 초기 핸드셰이크를 기다렸습니다.
  • 둘째, FullHttpRequest 로 요청을 생성했습니다.
  • 셋째, 우리는 넣어 streamId을 우리에 Http2ClientResponseHandlerstreamIdMap , 그라고 awaitResponses의 방법
  • 그리고 마침내 응답에서 Hello World 가 실제로 획득 되었음을 확인했습니다.

간단히 말해서 클라이언트가 HEADERS 프레임을 보내고 초기 SSL 핸드셰이크가 발생했으며 서버가 HEADERS 및 DATA 프레임으로 응답을 보냈습니다.

5. 결론

이 예제에서는 HTTP/2 프레임을 사용하여 Hello World 응답 을 얻기 위해 코드 샘플을 사용하여 Netty에서 HTTP/2 서버와 클라이언트를 구현하는 방법을 보았습니다 .

HTTP/2 프레임을 처리하기 위한 Netty API가 아직 작업 중이므로 앞으로 훨씬 더 많은 개선 사항을 볼 수 있기를 바랍니다.

항상 그렇듯이 소스 코드는 GitHub에서 사용할 수 있습니다 .

HTTPClient footer