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と名の付くベースクラスが複数用意されているので、必要なメソッドだけをオーバーライドすればよいようになっています。これらのベースクラスは単に次のハンドラーにイベントをパスするような実装になっているようです。
各メソッドで、受け取っているChannelHandlerContext
はChannelPipeline
や、他のハンドラーと相互作用を行なうためのコンテクストクラスです。
実装しているメソッドの簡単な説明を以下にまとめます。
- 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となります。EventLoopGroup
はEventLoop
を管理するコンテナとなります、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サーバができました。