Nettyでエコーサーバーを書く

はじめに

最近、Netty in Actionを読んでいるのですが、生のNettyを今まで触ったことがなかったのでHello, Worldとして、Echoサーバを書いてみたいと思います。あまり、Netty全体の要素に対して深堀すると言うよりはひとまず動かすのを目指し、その中で必要なものを少し深堀する感じでやろうと思います。

書いてみる

環境

実行環境は以下のとおり

$ uname -srvmpio
Linux 5.4.0-54-generic #60-Ubuntu SMP Fri Nov 6 10:37:59 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.1 LTS
Release:    20.04
Codename:   focal

$ java --version
openjdk 11.0.8 2020-07-14
OpenJDK Runtime Environment 18.9 (build 11.0.8+10)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.8+10, mixed mode)

$ mvn -v
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 11.0.8, vendor: N/A, runtime: /home/someone/.sdkman/candidates/java/11.0.8-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-54-generic", arch: "amd64", family: "unix"

プロジェクトを準備する

適当にMavenプロジェクトを作成し(私はIntelliJを使ってプロジェクトを作成しました)、Pomを以下のように書きます。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>netty-http-client-example</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.0.51.Final</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>


</project>

準備としては以上です。

やること

Nettyでサーバを書く際には大きく以下の2つをする必要があります。

  • ChannelHanlderの実装を書く
  • HanlderをChannelPipelineに登録し、起動の処理を記述する

ChannelHanlderの実装を書く

ChannelHandlerはネットワークイベントをトリガーとして実行されるメソッドを持ったインターフェースです。Nettyでは、このインターフェースを実装し、メソッドに対してアプリケーションロジックを記述することになります。
なお、このHanlderは、HandlerのコンテナであるChannelPilelineに登録され(実際の登録は事項で行いが)、Handlerが複数登録されている場合は処理がチェインされます。チェインの順番はPipelineへの登録順となります。
Hanlderには大きく分けて、Inbound用とOutbound用のものがありますがここでは深く触れません。 簡単にHanlderの説明をおえたので実際に受け取ったデータをそのまま返すだけのハンドラーを記述してみたいと思います。

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

@ChannelHandler.Sharable
public class EchoHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf input = (ByteBuf)  msg;
        System.out.println(input.toString(CharsetUtil.UTF_8));
        ctx.write(input);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        ctx.writeAndFlush(Unpooled.EMPTY_BUFFER)
                .addListener(ChannelFutureListener.CLOSE);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }
}

今回はChannelInboundHandlerのベースクラスであるChannelInboundHandlerAdapterを用いてハンドラーを記述しています。Nettyではこのようなインターフェースのデフォルト実装を行ってくれている〇〇Adapterと名の付くベースクラスが複数用意されているので、必要なメソッドだけをオーバーライドすればよいようになっています。これらのベースクラスは単に次のハンドラーにイベントをパスするような実装になっているようです。
各メソッドで、受け取っているChannelHandlerContextChannelPipelineや、他のハンドラーと相互作用を行なうためのコンテクストクラスです。
実装しているメソッドの簡単な説明を以下にまとめます。

  • channelRead(ChannelHandlerContext ctx, Object msg)
    • メッセージの入力があるたびに呼び出されます。上記の実装では受け取ったメッセージを標準出力に出力し、そのままChannelに同じ値を書き込んでいます。
  • channelReadComplete(ChannelHandlerContext ctx)
    • 読み込みオペレーションの最後に呼び出されます。writeAndFlush()では、書き込んだデータをすべてクライアント側に送信しています。その後、ChannelFutureListener.CLOSEでChannelをクローズしています。
  • exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
    • 読み込みオペレーション時に例外がスローされた際に呼び出されます。スタックトレースを行い、Channelをクローズしています。

ちなみに、ここでは深く調べませんが、ChannelはScoketと直接コミュニケーションをとる役割をもつコンポーネントです。Channelに対して、1つのChannelPipelineが割り当てられて、ハンドラーの処理がチェインされていくイメージです。

HanlderをChannelPipelineに登録し、起動の処理を記述する

ここまでで、Handlerがかけたので、次にこのハンドラーをChannelPipelineに登録し、サーバを起動する処理を書きたいと思います。

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

import java.net.InetSocketAddress;

public class Server {

    public static void main(String[] args) throws Exception {
        Server server = new Server();
        server.run();
    }

    public void run() throws Exception {
        final EchoHandler echoHandler = new EchoHandler();
        EventLoopGroup group = new NioEventLoopGroup();

        try {
            ServerBootstrap boot = new ServerBootstrap();
            boot.group(group).channel(NioServerSocketChannel.class)
                    .localAddress(new InetSocketAddress(8080))
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            socketChannel.pipeline()
                                    .addLast(echoHandler);
                        }
                    });
            ChannelFuture cf = boot.bind().sync();
            cf.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }


    }
}

メインスレッドでは自分自身のrun()を呼び出しています。その中を上から見ていくと、まずはハンドラーをインスタンス化しています。これは後の登録のところで使います。
次にEventLoopGroupインスタンス化しています。まず、EventLoopはコネクションのライフライムで発生するイベントをハンドリングする役割を持っています。EventLoop、1つに対して、1つのスレッドが割り当てられます。また、Channelは1つのEventLoopに割り当てられるのですが、その関係は多対一になり、1つのチャンネルは 1つのEventLoopに割り当てられ、1つのEventLoopは複数のチャンネルを割りてられる可能性があります。
EventLoopGroupの実装は、NioEventLoopGroupを利用しており、これは非同I/OのSelectorを使ったEventLoopGroupとなります。EventLoopGroupEventLoopを管理するコンテナとなります、1つ以上のEventLoopを保持します。

try句の中では、まずServerBootstrapインスタンス化しています。Nettyには2つのタイプのBootstrapの機構があり、ServerBooytstrapはNettyをクライアントではなくサーバーとして利用する際に利用する機構です。
インスタンス化したServerBootstrapに対して、上から順に以下のようなを設定していきます。

  • group()でEventLoopGroupeの登録
  • channel()で、Channel作る際に利用するChannelクラスの登録。今回はNIOのSelecter用いた非同期IOを行なうNioServerSocketChannelを利用
  • localAddress()でポートをバインド
  • childHandler()でハンドラーを登録。ChannelInitilizerはカスタムなChannelHandlerのセットをセットアップするためのHanlderで、チャンネルのHandlerを登録し終わったあとはChannelPipelineから削除されます。ここではEchoHandlerを登録しています。

ServerBootstrapの設定が終わったので、boot.bind()を呼び出して、サーバーをスタートさせます。

Telnetでつないでみる

telnetコマンドを使ってデータを送信します。アプリを起動します。私はinttelliJからメインメソッドを実行しましたが、以下のような感じでも起動できます。

$ mvn exec:java -Dexec.mainClass=server.Server
$ telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
aaa
aaa
Connection closed by foreign host.

1つ目のaaaはこちらが送信したもので、2つめのaaaがサーバから返されるものです。 これで、Echoサーバができました。