CLOVER🍀

That was when it all began.

TCP Echo Server/Clientを書いて、Netty io_uringを試してみる

これは、なにをしたくて書いたもの?

Nettyのio_uringを少し見てみたいな、ということで。

io_uring

まずは、io_uring自体について。

io_uringは、非同期IO用のAPIです。カーネル5.1から導入されたそうです。

https://kernel.dk/io_uring.pdf

io_uringについては、以下あたりも見てみました。

Ubuntu Manpage: io_uring - Asynchronous I/O facility

Welcome to Lord of the io_uring — Lord of the io_uring documentation

Linuxにおける非同期IOの実装について - Qiita

io_uringで高速IO処理(?) | κeenのHappy Hacκing Blog

ソケットAPIが遅すぎる?新たなio_uringを試す!. 新しいAPIが作られるたびに、私たちは、古いAPIを置き換えるだけで高速化という… | by FUJITA Tomonori | nttlabs | Medium

io_uringの作者が書いたこちらのページが、io_uringの概要を把握するのによいかもしれません。

What is io_uring? — Lord of the io_uring documentation

どういうものかというと、以下のように2つのキューを使う仕組みのようです。

  • リクエスト送信用、リクエスト完了通知用の2つのキューを持つ
    • この2つのキューは、カーネルとユーザー空間で共有される
  • タスク(ファイルの読み書き、クライアント接続の受け入れなど)があると、送信キューエントリー(SQE)として送信キューの末尾に追加する
  • カーネルは送信されたリクエストを処理して、完了キューイベント(CQE)として完了キューの末尾に追加する

なので、io_uringという名前はカーネルとユーザー空間の間でコミュニケーションを行うリングバッファーをインターフェースとすることに
由来するようです。

The very name io_uring comes from the fact that the interfaces uses ring buffers as the main interface for kernel-user space communication.

パフォーマンス面では、カーネルとユーザー空間でキューが共有されているため以下の点が有利なようです。

  • データのコピーを避けることができる
  • システムコールを減らすことができる
    • ユーザー空間のキューとやり取りすればよい
    • 複数のリクエストをまとめてカーネルに送信することができる
    • 送信したエントリー(SQE)をカーネルにポーリングさせることができる

Submission Queue Polling — Lord of the io_uring documentation

ライブラリとしては、liburingを使うことが勧められています。

GitHub - axboe/liburing

Netty io_uring

Nettyのincubatorプロジェクトとして、io_uringベースのトランスポートライブラリが作られています。

GitHub - netty/netty-incubator-transport-io_uring

現時点でのバージョンは、0.0.16.Finalです。

io_uring APIを直接使っているみたいですね。

https://github.com/netty/netty-incubator-transport-io_uring/blob/netty-incubator-transport-parent-io_uring-0.0.16.Final/transport-native-io_uring/src/main/c/syscall.c

Nettyでのリリースに関するブログエントリー。

Netty.news: Netty/Incubator/Transport/Native/io_uring 0.0.1.Final released

Netty.news: Netty/Incubator/Transport/Native/io_uring 0.0.3.Final released

情報がほとんどないのですが、GitHubREADME.mdやブログエントリーを見る限り、既存のNettyの使い方から大きく変わりそうな
雰囲気はないので、サンプルを参考にTCP Echo Server/Clientを書いてみることにしましょう。

https://github.com/netty/netty/tree/netty-4.1.85.Final/example/src/main/java/io/netty/example

https://github.com/netty/netty/tree/netty-4.1.85.Final/example/src/main/java/io/netty/example/echo

環境

今回の環境は、こちら。

Ubuntu Linux 22.04 LTSです。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.1 LTS
Release:        22.04
Codename:       jammy


$ uname -srvmpio
Linux 5.15.0-58-generic #64-Ubuntu SMP Thu Jan 5 11:43:13 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux

カーネル5.1以上ですね。

Java

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu122.04)
OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu122.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.5, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-58-generic", arch: "amd64", family: "unix"

準備

Maven依存関係などは、こちら。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>io.netty.incubator</groupId>
            <artifactId>netty-incubator-transport-native-io_uring</artifactId>
            <version>0.0.16.Final</version>
            <classifier>linux-x86_64</classifier>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-handler</artifactId>
            <version>4.1.85.Final</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.24.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M7</version>
            </plugin>
        </plugins>
    </build>

Nettyでio_uringを使うには、netty-incubator-transport-native-io_uringがあればOKです。
netty-handlerは、ログ出力で使うLoggingHandlerクラスを使うために入れています。

netty-incubator-transport-native-io_uringを使う時には、classifierを指定する必要があります。

        <dependency>
            <groupId>io.netty.incubator</groupId>
            <artifactId>netty-incubator-transport-native-io_uring</artifactId>
            <version>0.0.16.Final</version>
            <classifier>linux-x86_64</classifier>
        </dependency>

現時点で指定可能なのは、linux-x86_64linux-aarch_64の2つです。

最後にテストコードも書くので、JUnitとAssertJも入れています。

サンプルコードを書く

それでは、こちらを参考にTCP Echo Server/Clientを書いてみましょう。

https://github.com/netty/netty/tree/netty-4.1.85.Final/example/src/main/java/io/netty/example/echo

サーバー側から書きます。

ハンドラー。

src/main/java/org/littlewings/netty/iouring/EchoServerHandler.java

package org.littlewings.netty.iouring;

import java.nio.charset.StandardCharsets;

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

@ChannelHandler.Sharable
public class EchoServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        String msgAsString = ((ByteBuf) msg).toString(StandardCharsets.UTF_8);

        if (msgAsString.endsWith("\r\n")) {
            msgAsString = msgAsString.substring(0, msgAsString.length() - 2);
        } else if (msgAsString.endsWith("\n")) {
            msgAsString = msgAsString.substring(0, msgAsString.length() - 1);
        }

        System.out.printf("received message = %s%n", msgAsString);

        ByteBuf sendMessage =
                Unpooled.wrappedBuffer(String.format("★%s★", msgAsString).getBytes(StandardCharsets.UTF_8));

        ctx.write(sendMessage);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

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

メッセージを返す時には、装飾するようにしておきました。

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        String msgAsString = ((ByteBuf) msg).toString(StandardCharsets.UTF_8);

        if (msgAsString.endsWith("\r\n")) {
            msgAsString = msgAsString.substring(0, msgAsString.length() - 2);
        } else if (msgAsString.endsWith("\n")) {
            msgAsString = msgAsString.substring(0, msgAsString.length() - 1);
        }

        System.out.printf("received message = %s%n", msgAsString);

        ByteBuf sendMessage =
                Unpooled.wrappedBuffer(String.format("★%s★", msgAsString).getBytes(StandardCharsets.UTF_8));

        ctx.write(sendMessage);
    }

Bootstrap。

src/main/java/org/littlewings/netty/iouring/IoUringEchoServer.java

package org.littlewings.netty.iouring;

import java.util.concurrent.ExecutionException;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.incubator.channel.uring.IOUringEventLoopGroup;
import io.netty.incubator.channel.uring.IOUringServerSocketChannel;

public class IoUringEchoServer implements AutoCloseable {
    int port;

    EventLoopGroup bossGroup;
    EventLoopGroup workerGroup;

    public static void main(String... args) throws InterruptedException, ExecutionException {
        try (IoUringEchoServer server = IoUringEchoServer.newServer(8080)) {
            server.start(true);
        }
    }

    public static IoUringEchoServer newServer(int port) {
        IoUringEchoServer server = new IoUringEchoServer();
        server.port = port;

        return server;
    }

    public void start(boolean block) throws InterruptedException {
        bossGroup = new IOUringEventLoopGroup();
        workerGroup = new IOUringEventLoopGroup();

        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap
                .group(bossGroup, workerGroup)
                .channel(IOUringServerSocketChannel.class)
                .option(ChannelOption.SO_BACKLOG, 100)
                .option(ChannelOption.SO_REUSEADDR, true)
                .childHandler(new ChannelInitializer<>() {
                    @Override
                    protected void initChannel(Channel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();

                        // pipeline.addLast(new LoggingHandler(LogLevel.INFO));
                        pipeline.addLast(new EchoServerHandler());
                    }
                });

        ChannelFuture future = bootstrap.bind(port).sync();

        if (block) {
            future.channel().closeFuture().sync();
        }
    }

    @Override
    public void close() throws ExecutionException, InterruptedException {
        if (bossGroup != null) {
            bossGroup.shutdownGracefully().get();
        }

        if (workerGroup != null) {
            workerGroup.shutdownGracefully().get();
        }
    }
}

ポイントは、EventLoopGroupとしてIOUringEventLoopGroupクラスを使うことと、

        bossGroup = new IOUringEventLoopGroup();
        workerGroup = new IOUringEventLoopGroup();

ChannelとしてIOUringServerSocketChannelクラスを指定することですね。

        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap
                .group(bossGroup, workerGroup)
                .channel(IOUringServerSocketChannel.class)

起動を行うstartメソッドの呼び出し時に、truefalseを渡すかでブロックするかどうかを指定できるようにしました。

        if (block) {
            future.channel().closeFuture().sync();
        }

trueだとstartメソッドはブロックします。

クライアント側。

ハンドラー。

src/main/java/org/littlewings/netty/iouring/EchoClientHandler.java

package org.littlewings.netty.iouring;

import java.nio.charset.StandardCharsets;
import java.util.concurrent.BlockingQueue;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;

public class EchoClientHandler extends ChannelInboundHandlerAdapter {
    BlockingQueue<String> queue;

    public EchoClientHandler(BlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        String msgAsString = ((ByteBuf) msg).toString(StandardCharsets.UTF_8);
        System.out.printf("received message = %s%n", msgAsString);

        try {
            queue.put(msgAsString);
        } catch (InterruptedException e) {
            // no-op
        }
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

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

Bootstrap。

src/main/java/org/littlewings/netty/iouring/IoUringEchoClient.java

package org.littlewings.netty.iouring;

import java.io.Console;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingDeque;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.TimeUnit;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.incubator.channel.uring.IOUringEventLoopGroup;
import io.netty.incubator.channel.uring.IOUringSocketChannel;

public class IoUringEchoClient implements AutoCloseable {
    String host;
    int port;

    BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);

    EventLoopGroup group;

    Channel channel;

    public static void main(String... args) throws InterruptedException {
        try (IoUringEchoClient client = IoUringEchoClient.newClient("localhost", 8080)) {
            client.start();

            Console console = System.console();

            while (true) {
                String message = console.readLine("> ");

                if ("exit".equals(message)) {
                    break;
                } else {
                    client.sendMessage(message);
                    TimeUnit.MILLISECONDS.sleep(500L);
                }
            }
        }
    }

    public static IoUringEchoClient newClient(String host, int port) {
        IoUringEchoClient client = new IoUringEchoClient();
        client.host = host;
        client.port = port;

        return client;
    }

    public void start() throws InterruptedException {


        group = new IOUringEventLoopGroup();

        Bootstrap bootstrap = new Bootstrap();
        bootstrap
                .group(group)
                .channel(IOUringSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();

                        // pipeline.addLast(new LoggingHandler(LogLevel.INFO));
                        pipeline.addLast(new EchoClientHandler(queue));
                    }
                });

        channel = bootstrap.connect(host, port).sync().channel();
    }

    public String sendMessage(String message) throws InterruptedException {
        ByteBuf msg = Unpooled.wrappedBuffer(message.getBytes(StandardCharsets.UTF_8));
        channel.writeAndFlush(msg);

        return queue.take();
    }

    @Override
    public void close() throws InterruptedException {
        if (channel != null) {
            channel.close().sync();
        }

        if (group != null) {
            group.shutdownGracefully().sync();
        }
    }
}

io_uringと全然関係ないですが、BlockingQueueを使ってサーバー側から返ってきたメッセージを呼び出し元に返せるようにしました。

    public String sendMessage(String message) throws InterruptedException {
        ByteBuf msg = Unpooled.wrappedBuffer(message.getBytes(StandardCharsets.UTF_8));
        channel.writeAndFlush(msg);

        return queue.take();
    }

mainメソッドから起動した時は、対話的なアプリケーションにしているのですが

    public static void main(String... args) throws InterruptedException {
        try (IoUringEchoClient client = IoUringEchoClient.newClient("localhost", 8080)) {
            client.start();

            Console console = System.console();

            while (true) {
                String message = console.readLine("> ");

                if ("exit".equals(message)) {
                    break;
                } else {
                    client.sendMessage(message);
                    TimeUnit.MILLISECONDS.sleep(500L);
                }
            }
        }
    }

これは、Memcachedのクライアントサンプルを参考にしています。

https://github.com/netty/netty/blob/netty-4.1.85.Final/example/src/main/java/io/netty/example/memcache/binary/MemcacheClient.java

動作確認

では、動作確認してみましょう。

サーバー側を起動。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.netty.iouring.IoUringEchoServer

telnetで確認。

$ telnet localhost 8080
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

結果。

Hello World
★Hello World★

この時、サーバー側ではコンソールにこのように出力されます。

received message = Hello World

次に、作成したクライアントを使ってみます。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.netty.iouring.IoUringEchoClient

こんな感じになりますね。

> Hello World
received message = ★Hello World★
> こんにちは、世界
received message = ★こんにちは、世界★

exitで終了します。

> exit

それから、サーバー・クライアント双方とも以下のコメントアウトを解除すると

                        pipeline.addLast(new LoggingHandler(LogLevel.INFO));

Nettyのログが出力されるようになります。

サーバー側。

1月 22, 2023 12:09:54 午前 io.netty.handler.logging.LoggingHandler channelRegistered
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] REGISTERED
1月 22, 2023 12:09:54 午前 io.netty.handler.logging.LoggingHandler channelActive
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] ACTIVE
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler channelRead
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] READ: 11B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 65 6c 6c 6f 20 57 6f 72 6c 64                |Hello World     |
+--------+-------------------------------------------------+----------------+
received message = Hello World
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler write
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] WRITE: 17B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| e2 98 85 48 65 6c 6c 6f 20 57 6f 72 6c 64 e2 98 |...Hello World..|
|00000010| 85                                              |.               |
+--------+-------------------------------------------------+----------------+
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler channelReadComplete
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] READ COMPLETE
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] FLUSH
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler channelRead
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] READ: 24B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| e3 81 93 e3 82 93 e3 81 ab e3 81 a1 e3 81 af e3 |................|
|00000010| 80 81 e4 b8 96 e7 95 8c                         |........        |
+--------+-------------------------------------------------+----------------+
received message = こんにちは、世界
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler write
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] WRITE: 30B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| e2 98 85 e3 81 93 e3 82 93 e3 81 ab e3 81 a1 e3 |................|
|00000010| 81 af e3 80 81 e4 b8 96 e7 95 8c e2 98 85       |..............  |
+--------+-------------------------------------------------+----------------+
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler channelReadComplete
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] READ COMPLETE
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xfc878418, L:/127.0.0.1:8080 - R:/127.0.0.1:54800] FLUSH
1月 22, 2023 12:10:07 午前 io.netty.handler.logging.LoggingHandler channelReadComplete
情報: [id: 0xfc878418, L:/127.0.0.1:8080 ! R:/127.0.0.1:54800] READ COMPLETE
1月 22, 2023 12:10:07 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xfc878418, L:/127.0.0.1:8080 ! R:/127.0.0.1:54800] FLUSH
1月 22, 2023 12:10:07 午前 io.netty.handler.logging.LoggingHandler channelInactive
情報: [id: 0xfc878418, L:/127.0.0.1:8080 ! R:/127.0.0.1:54800] INACTIVE
1月 22, 2023 12:10:07 午前 io.netty.handler.logging.LoggingHandler channelUnregistered
情報: [id: 0xfc878418, L:/127.0.0.1:8080 ! R:/127.0.0.1:54800] UNREGISTERED

クライアント側。

1月 22, 2023 12:09:54 午前 io.netty.handler.logging.LoggingHandler channelRegistered
情報: [id: 0xbe67cbc7] REGISTERED
1月 22, 2023 12:09:54 午前 io.netty.handler.logging.LoggingHandler connect
情報: [id: 0xbe67cbc7] CONNECT: localhost/127.0.0.1:8080
1月 22, 2023 12:09:54 午前 io.netty.handler.logging.LoggingHandler channelActive
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] ACTIVE
> Hello World
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler write
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] WRITE: 11B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 65 6c 6c 6f 20 57 6f 72 6c 64                |Hello World     |
+--------+-------------------------------------------------+----------------+
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] FLUSH
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler channelRead
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] READ: 17B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| e2 98 85 48 65 6c 6c 6f 20 57 6f 72 6c 64 e2 98 |...Hello World..|
|00000010| 85                                              |.               |
+--------+-------------------------------------------------+----------------+
received message = ★Hello World★
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler channelReadComplete
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] READ COMPLETE
1月 22, 2023 12:09:58 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] FLUSH
> こんにちは、世界
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler write
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] WRITE: 24B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| e3 81 93 e3 82 93 e3 81 ab e3 81 a1 e3 81 af e3 |................|
|00000010| 80 81 e4 b8 96 e7 95 8c                         |........        |
+--------+-------------------------------------------------+----------------+
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] FLUSH
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler channelRead
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] READ: 30B
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| e2 98 85 e3 81 93 e3 82 93 e3 81 ab e3 81 a1 e3 |................|
|00000010| 81 af e3 80 81 e4 b8 96 e7 95 8c e2 98 85       |..............  |
+--------+-------------------------------------------------+----------------+
received message = ★こんにちは、世界★
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler channelReadComplete
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] READ COMPLETE
1月 22, 2023 12:10:04 午前 io.netty.handler.logging.LoggingHandler flush
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] FLUSH
> exit
1月 22, 2023 12:10:05 午前 io.netty.handler.logging.LoggingHandler close
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 - R:localhost/127.0.0.1:8080] CLOSE
1月 22, 2023 12:10:05 午前 io.netty.handler.logging.LoggingHandler channelInactive
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 ! R:localhost/127.0.0.1:8080] INACTIVE
1月 22, 2023 12:10:05 午前 io.netty.handler.logging.LoggingHandler channelUnregistered
情報: [id: 0xbe67cbc7, L:/127.0.0.1:54800 ! R:localhost/127.0.0.1:8080] UNREGISTERED

テストを書く

最後に、テストコードを書いておきます。

src/test/java/org/littlewings/netty/iouring/IoUringEchoTest.java

package org.littlewings.netty.iouring;

import java.util.concurrent.ExecutionException;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class IoUringEchoTest {
    @Test
    void echoClientServer() throws InterruptedException, ExecutionException {
        String host = "localhost";
        int port = 8080;

        try (IoUringEchoServer server = IoUringEchoServer.newServer(port);
             IoUringEchoClient client = IoUringEchoClient.newClient(host, port)) {
            server.start(false);
            client.start();

            assertThat(client.sendMessage("Hello World"))
                    .isEqualTo("★Hello World★");

            assertThat(client.sendMessage("Hello Netty"))
                    .isEqualTo("★Hello Netty★");

            assertThat(client.sendMessage("こんにちは 世界"))
                    .isEqualTo("★こんにちは 世界★");
        }
    }
}

こんなところでしょうか。

まとめ

Netty io_uringを試してみました。

そもそもio_uringをよく知らなかったので、io_uring自体を調べるにそれなりに時間を使いましたが。直接は使っていないので、理解が
進んだかというと微妙なところです。

こういう世界にも、いずれ踏み込んだ方がいいのかな、とは時々思います。

あと、Nettyを使ったプログラムを久しぶりに書いたので、これもこれでけっこう苦労しました。

DateTimeFormatterでパースとフォーマット時の桁数や空白について調べる

これは、なにをしたくて書いたもの?

DateTimeFormatterで文字列として表現された日時をパースする時に桁数や空白の扱いについてあまり意識していなかったので、
ちょっと見てみることにしました。

DateTimeFormatter (Java SE 17 & JDK 17)

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu122.04)
OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu122.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.5, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-58-generic", arch: "amd64", family: "unix"

準備

確認は、テストコードで行います。必要なMaven依存関係等はこちら。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.24.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M7</version>
            </plugin>
        </plugins>
    </build>

パースする

最初はパースから。

以下をテストコードの雛形にしていきます。

src/test/java/org/littlewings/datetimeformatter/ParseTest.java

package org.littlewings.datetimeformatter;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.Calendar;
import java.util.GregorianCalendar;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class ParseTest {

    // ここに、テストを書く!!
}
SimpleDateFormatとDateTimeFormatterにおける、パース時の桁数に対する振る舞い

このエントリーを書くきっかけになった話ですが。

まずは昔ながらのSimpleDateFormatを素直に使ってみます。日付と日時に対して、それぞれパース。

    @Test
    void simpleDateFormat() throws ParseException {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
        simpleDateFormat.setLenient(true);

        Calendar date = new GregorianCalendar(2022, 9, 25);

        assertThat(simpleDateFormat.parse("2022-10-25"))
                .isEqualTo(date.getTime());

        SimpleDateFormat simpleDateTimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        simpleDateTimeFormat.setLenient(true);

        Calendar dateTime = new GregorianCalendar(2022, 9, 25, 16, 30, 25);

        assertThat(simpleDateTimeFormat.parse("2022-10-25 16:30:25"))
                .isEqualTo(dateTime.getTime());
    }

続いて、DateTimeFormatter

    @Test
    void dateTimeFormatter() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2022-10-25", dateFormatter))
                .isEqualTo(LocalDate.of(2022, 10, 25));

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
    }

まあ、ふつうだと思います。

次に、桁数が少ないデータを渡してみます。

SimpleDateFormat

    @Test
    void shortWidthSimpleDateFormat() throws ParseException {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
        simpleDateFormat.setLenient(true);

        Calendar date = new GregorianCalendar(2023, 0, 5);

        assertThat(simpleDateFormat.parse("2023-1-5"))
                .isEqualTo(date.getTime());

        SimpleDateFormat simpleDateTimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        simpleDateTimeFormat.setLenient(true);

        Calendar dateTime = new GregorianCalendar(2023, 0, 5, 2, 3, 4);

        assertThat(simpleDateTimeFormat.parse("2023-1-5 2:3:4"))
                .isEqualTo(dateTime.getTime());
    }

問題なくパースできます。

DateTimeFormatter

    @Test
    void shortWidthDateTimeFormatter() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
        assertThatThrownBy(() -> LocalDate.parse("2023-1-5", dateFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023-1-5' could not be parsed at index 5");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
        assertThatThrownBy(() -> LocalDateTime.parse("2023-1-5 2:3:4", dateTimeFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023-1-5 2:3:4' could not be parsed at index 5");
    }

こちらは、パースに失敗します。これを回避するにはどうしたら?というのが、今回のエントリーを書いたきっかけですね。

当たり前ですが、足りない桁を0埋めすればパースには失敗しません。

    @Test
    void shortWidthDateTimeFormatter2() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2023-01-05", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.parse("2023-01-05 02:03:04", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
    }

ちなみに、空白を置いてもダメです。

    @Test
    void shortWidthDateTimeFormatter3() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu/MM/dd").withResolverStyle(ResolverStyle.STRICT);
        assertThatThrownBy(() -> LocalDate.parse("2023/ 1/ 5", dateFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023/ 1/ 5' could not be parsed at index 5");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu/MM/dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
        assertThatThrownBy(() -> LocalDateTime.parse("2023/ 1/ 5  2: 3: 4", dateTimeFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023/ 1/ 5  2: 3: 4' could not be parsed at index 5");
    }

このパターンの時は、区切り文字を/にしています。

パターンを1文字にする

それで、こういう時にパースしたかったらどうするかというと、パターンを1文字にするとパースできるようになります。

    @Test
    void shortWidthShortDateTimeFormatter() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-M-d").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2022-10-25", dateFormatter))
                .isEqualTo(LocalDate.of(2022, 10, 25));
        assertThat(LocalDate.parse("2023-1-5", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-M-d H:m:s").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
        assertThat(LocalDateTime.parse("2023-1-5 2:3:4", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
    }
オプションで空白を置く

DateTimeFormatterには「オプション」というものがあり、[]で表現します。

オプションのセクション: オプションのセクションのマーカーは、DateTimeFormatterBuilder.optionalStart()およびDateTimeFormatterBuilder.optionalEnd()の呼出しとまったく同様に機能します。

DateTimeFormatter (Java SE 17 & JDK 17)

DateTimeFormatterBuilder.html#optionalStartの説明を見ると、こんなことが書かれています。

書式設定の出力には、オプションのセクションを含めることができ、それらは入れ子にすることができます。 オプションのセクションは、このメソッドの呼出しによって始まり、optionalEnd()の呼出しかビルダー・プロセスの終了によって終わります。

オプションのセクションに含まれるすべての要素は、オプションとして扱われます。 書式設定時は、セクション内のすべての要素に関するデータがTemporalAccessorで使用可能な場合のみ、セクションが出力されます。 解析時は、解析された文字列からセクション全体が欠けている場合があります。

DateTimeFormatterBuilder#optionalStart)

DateTimeFormatterの場合は、[]で囲った範囲がオプションになるということみたいです。

試してみましょう。

    @Test
    void shortWidthOptionalSpaceDateTimeFormatter() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2022/10/25", dateFormatter))
                .isEqualTo(LocalDate.of(2022, 10, 25));
        assertThat(LocalDate.parse("2023/1/5", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));
        assertThat(LocalDate.parse("2023/ 1/ 5", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));
        assertThat(LocalDate.parse("2023/01/05", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d [ ]H:[ ]m:[ ]s").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.parse("2022/10/25 16:30:25", dateTimeFormatter))
                        .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
        assertThat(LocalDateTime.parse("2023/1/5 2:3:4", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
        assertThat(LocalDateTime.parse("2023/ 1/ 5  2: 3: 4", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
        assertThat(LocalDateTime.parse("2023/01/05 02:03:04", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
    }

空白があってもなくてもよくなり、2桁であってもパースできますね。1桁でも2桁でもパースできるのは、パターンを1文字にしているからで
あくまでオプションなのは空白なのですが。

オプションをもう少し

もう少し、オプションのパターンを試してみましょう。

日時の部分をオプションにしてみます。

    @Test
    void optionalDateTimeFormatter() {
        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd[ HH:mm:ss]").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2022-10-25", dateTimeFormatter))
                .isEqualTo(LocalDate.of(2022, 10, 25));
        assertThat(LocalDate.parse("2023-01-05", dateTimeFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));
        assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
        assertThatThrownBy(() -> LocalDateTime.parse("2023-01-05 2:3:4", dateTimeFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023-01-05 2:3:4' could not be parsed, unparsed text found at index 10");
    }

オプションをネストさせることもできます。こちらは秒の部分をオプションにしています。

    @Test
    void nestedOptionalDateTimeFormatter() {
        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd[ HH:mm[:ss]]").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2022-10-25", dateTimeFormatter))
                .isEqualTo(LocalDate.of(2022, 10, 25));
        assertThat(LocalDate.parse("2023-01-05", dateTimeFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));
        assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
        assertThat(LocalDateTime.parse("2023-01-05 02:03:04", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
        assertThat(LocalDateTime.parse("2022-10-25 16:30:25", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
        assertThat(LocalDateTime.parse("2023-01-05 02:03", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 0));
    }
パディング

少し観点を変えて、パディングを指定する場合はpを使います。

    @Test
    void shortWidthPaddingDateTimeFormatter() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu/ppM/ppd").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2022/10/25", dateFormatter))
                .isEqualTo(LocalDate.of(2022, 10, 25));
        assertThat(LocalDate.parse("2023/ 1/ 5", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));
        assertThat(LocalDate.parse("2023/01/05", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu/ppM/ppd ppH:ppm:pps").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.parse("2022/10/25 16:30:25", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2022, 10, 25, 16, 30, 25));
        assertThat(LocalDateTime.parse("2023/ 1/ 5  2: 3: 4", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
        assertThat(LocalDateTime.parse("2023/01/05 02:03:04", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
    }

ppで、2桁であることを求めていますが、桁数に満たない場合は空白が必須になります。

なので、以下のように桁数が不足する場合にパディングしていないとパースできません。

    @Test
    void shortWidthPaddingDateTimeFormatter2() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu/ppM/ppd").withResolverStyle(ResolverStyle.STRICT);
        assertThatThrownBy(() -> LocalDate.parse("2023/1/5", dateFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023/1/5' could not be parsed at index 10");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu/ppM/ppd ppH:ppm:pps").withResolverStyle(ResolverStyle.STRICT);
        assertThatThrownBy(() -> LocalDateTime.parse("2023/1/5 2:3:4", dateTimeFormatter))
                .isInstanceOf(DateTimeParseException.class)
                .hasMessage("Text '2023/1/5 2:3:4' could not be parsed at index 10");
    }
1文字のパターンは長さを見ていない

こう見ると、1文字のパターンとオプションの組み合わせが柔軟で良さそうに見えますが、1文字のパターンは長さを見ていない感じが
しますね。

以下のようなパターンもパースできてしまいます。

    @Test
    void looseDateTimeFormatter() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-M-d").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.parse("2023-00001-00005", dateFormatter))
                .isEqualTo(LocalDate.of(2023, 1, 5));

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-M-d H:m:s").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.parse("2023-00001-00005 00002:00003:00004", dateTimeFormatter))
                .isEqualTo(LocalDateTime.of(2023, 1, 5, 2, 3, 4));
    }

この桁数を厳密に制御しようと思うと、DateTimeFormatter#ofPatternでパターン文字列から作るのではなく、
DateTimeFormatterBuilderクラスのappendValue`メソッドを使うことになりそうです。

厳密解析モードでは、解析される桁数の最小はminWidth、最大はmaxWidthです。 非厳密解析モードでは、解析される桁数の最小は1、最大は19です(隣接値解析によって制限される場合を除く)。

DateTimeFormatterBuilder#appendValue)

今回は、DateTimeFormatterBuilderクラスは扱いませんが。

フォーマットする

パースができたので、次はフォーマットしてみます。

こちらもテストコードで確認するので、雛形から。

src/test/java/org/littlewings/datetimeformatter/FormatTest.java

package org.littlewings.datetimeformatter;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class FormatTest {

    // ここに、テストを書く!!
}
シンプルなパターンでフォーマットする

まずは、シンプルなパターンでフォーマットしてみます。

    @Test
    void format() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter))
                .isEqualTo("2022-10-25");
        assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter))
                .isEqualTo("2023-01-05");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter))
                .isEqualTo("2022-10-25 16:30:25");
        assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter))
                .isEqualTo("2023-01-05 02:03:04");
    }

この場合、桁数に満たない値は0埋めされます。

パターン文字を1にする

次に、パターン文字を1文字にしてみます。

    @Test
    void shortFormat() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu-M-d").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter))
                .isEqualTo("2022-10-25");
        assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter))
                .isEqualTo("2023-1-5");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu-M-d H:m:s").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter))
                .isEqualTo("2022-10-25 16:30:25");
        assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter))
                .isEqualTo("2023-1-5 2:3:4");
    }

この場合、桁数がそのまま文字列長に反映される感じになりますね。

オプションで空白を置いてみる

パースする時に、空白をオプションにするようにしてみましたが、これだとどうなるでしょう。

    @Test
    void optionalShortFormat() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter))
                .isEqualTo("2022/ 10/ 25");
        assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter))
                .isEqualTo("2023/ 1/ 5");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu/[ ]M/[ ]d [ ]H:[ ]m:[ ]s").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter))
                .isEqualTo("2022/ 10/ 25  16: 30: 25");
        assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter))
                .isEqualTo("2023/ 1/ 5  2: 3: 4");
    }

常に空白が確保されるようになりました…。

パディングを使う

パディングを使うと、pで指定した桁数に満たない場合は空白で埋められます。

    @Test
    void paddingShortFormat() {
        DateTimeFormatter dateFormatter =
                DateTimeFormatter.ofPattern("uuuu/ppM/ppd").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDate.of(2022, 10, 25).format(dateFormatter))
                .isEqualTo("2022/10/25");
        assertThat(LocalDate.of(2023, 1, 5).format(dateFormatter))
                .isEqualTo("2023/ 1/ 5");

        DateTimeFormatter dateTimeFormatter =
                DateTimeFormatter.ofPattern("uuuu/ppM/ppd ppH:ppm:pps").withResolverStyle(ResolverStyle.STRICT);
        assertThat(LocalDateTime.of(2022, 10, 25, 16, 30, 25).format(dateTimeFormatter))
                .isEqualTo("2022/10/25 16:30:25");
        assertThat(LocalDateTime.of(2023, 1, 5, 2, 3, 4).format(dateTimeFormatter))
                .isEqualTo("2023/ 1/ 5  2: 3: 4");
    }

こんなところでしょうか。

まとめ

いかに雰囲気でDateTimeFormatterを使っていたのかが、よくわかりました…。

書式文字列のチェックでDateTimeFormatterを使う時は、他の確認方法と組み合わせることも考えた方がいいのかもしれません。
また、パースとフォーマットで同一のパターン定義で良いのかも考えどころですね。

あとは、こちらを使うなどでしょうか。

DateTimeFormatter#parse(java.lang.CharSequence,java.text.ParsePosition))