From 77abbbac3f80dc118e237752a9fdea97c36761eb Mon Sep 17 00:00:00 2001 From: ZZZank <3410764033@qq.com> Date: Sun, 22 Mar 2026 00:52:22 +0800 Subject: [PATCH 1/3] BufferedSeekableByteChannel --- .../util/io/BufferedSeekableByteChannel.java | 165 ++++++++++++++++++ .../hmcl/util/io/CompressingUtils.java | 4 +- 2 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java new file mode 100644 index 0000000000..d0dca3507d --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java @@ -0,0 +1,165 @@ +package org.jackhuang.hmcl.util.io; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.channels.ClosedChannelException; +import java.util.Objects; + +/// [SeekableByteChannel] with read buffer for efficient reading. +/// +/// Writing ([SeekableByteChannel#write(java.nio.ByteBuffer)]) are passthrough directly to the underlying channel, and will invalidate the internal data buffer. +/// +/// There's no guarantee on thread safety of this implementation. +public class BufferedSeekableByteChannel implements SeekableByteChannel { + + private final SeekableByteChannel underlying; + private final ByteBuffer buffer; + /// `buffer[i] == underlying[i + bufferStart]` + private long bufferStart; + /// `false` if [#buffer] should be refreshed + private boolean bufferValid; + /// current position, relative to the start of file + private long position; + + /// Create a [BufferedSeekableByteChannel] with a buffer size of 8192 + public BufferedSeekableByteChannel(SeekableByteChannel underlying) { + this(underlying, 8192); + } + + /// Create a [BufferedSeekableByteChannel] with specified buffer size + /// + /// @throws IllegalArgumentException if `bufferSize <= 0` + /// @throws NullPointerException if `underlying == null` + public BufferedSeekableByteChannel(SeekableByteChannel underlying, int bufferSize) { + if (bufferSize <= 0) { + throw new IllegalArgumentException(String.format("int bufferSize <= 0 (%s <= 0)", bufferSize)); + } + this.underlying = Objects.requireNonNull(underlying, "SeekableByteChannel underlying == null"); + this.buffer = ByteBuffer.allocate(bufferSize); + this.bufferStart = 0; + this.bufferValid = false; + this.position = 0; + } + + @Override + public int read(ByteBuffer dst) throws IOException { + ensureOpen(); + if (!dst.hasRemaining()) { + return 0; + } + + int totalRead = 0; + while (dst.hasRemaining()) { + if (!bufferValid || buffer.remaining() == 0) { + fillBuffer(); + if (!bufferValid || buffer.remaining() == 0) { + // EOL + break; + } + } + + int bytesToCopy = Math.min(buffer.remaining(), dst.remaining()); + ByteBuffer slice = buffer.slice(); + slice.limit(bytesToCopy); + dst.put(slice); + buffer.position(buffer.position() + bytesToCopy); + totalRead += bytesToCopy; + position += bytesToCopy; + } + + return totalRead == 0 && !bufferValid ? -1 : totalRead; + } + + private void fillBuffer() throws IOException { + underlying.position(position); + + buffer.clear(); + int bytesRead = underlying.read(buffer); + if (bytesRead > 0) { + buffer.flip(); + bufferStart = position; + bufferValid = true; + } else { + // EOF + bufferValid = false; + } + } + + @Override + public int write(ByteBuffer src) throws IOException { + ensureOpen(); + underlying.position(position); + + int written = underlying.write(src); + if (written > 0) { + position += written; + invalidateBuffer(); + } + return written; + } + + @Override + public long position() throws IOException { + ensureOpen(); + return position; + } + + @Override + public SeekableByteChannel position(long newPosition) throws IOException { + ensureOpen(); + if (newPosition < 0) { + throw new IllegalArgumentException("Negative position"); + } + + // avoid rebuilding buffer if possible + if (bufferValid && newPosition >= bufferStart && newPosition < bufferStart + buffer.limit()) { + int bufferOffset = (int) (newPosition - bufferStart); + buffer.position(bufferOffset); + } else { + invalidateBuffer(); + } + position = newPosition; + return this; + } + + @Override + public long size() throws IOException { + ensureOpen(); + return underlying.size(); + } + + @Override + public SeekableByteChannel truncate(long size) throws IOException { + ensureOpen(); + underlying.truncate(size); + invalidateBuffer(); + if (position > size) { + position = size; + underlying.position(position); + } + return this; + } + + @Override + public boolean isOpen() { + return underlying.isOpen(); + } + + @Override + public void close() throws IOException { + underlying.close(); + invalidateBuffer(); + } + + private void invalidateBuffer() { + bufferValid = false; + buffer.clear(); + } + + private void ensureOpen() throws ClosedChannelException { + if (!isOpen()) { + throw new ClosedChannelException(); + } + } +} \ No newline at end of file diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java index d4875d909c..92602856a8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/CompressingUtils.java @@ -144,7 +144,7 @@ public static ZipArchiveReader openZipFileWithPossibleEncoding(Path zipFile, Cha if (possibleEncoding == null) possibleEncoding = StandardCharsets.UTF_8; - ZipArchiveReader zipReader = new ZipArchiveReader(Files.newByteChannel(zipFile)); + ZipArchiveReader zipReader = new ZipArchiveReader(new BufferedSeekableByteChannel(Files.newByteChannel(zipFile))); Charset suitableEncoding; try { @@ -161,7 +161,7 @@ public static ZipArchiveReader openZipFileWithPossibleEncoding(Path zipFile, Cha } zipReader.close(); - return new ZipArchiveReader(Files.newByteChannel(zipFile), suitableEncoding); + return new ZipArchiveReader(new BufferedSeekableByteChannel(Files.newByteChannel(zipFile)), suitableEncoding); } public static final class Builder { From d58ea1de193a687f7a4231a7db7eae082b6aab2f Mon Sep 17 00:00:00 2001 From: ZZZank <3410764033@qq.com> Date: Sun, 22 Mar 2026 00:53:06 +0800 Subject: [PATCH 2/3] add test Written by DeepSeek --- .../io/BufferedSeekableByteChannelTest.java | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java new file mode 100644 index 0000000000..fa45ee5b7a --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java @@ -0,0 +1,390 @@ +package org.jackhuang.hmcl.util.io; + +import kala.compress.utils.SeekableInMemoryByteChannel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.SeekableByteChannel; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class BufferedSeekableByteChannelTest { + + private SeekableByteChannel underlying; + private BufferedSeekableByteChannel channel; + + @BeforeEach + void setUp() { + underlying = new SeekableInMemoryByteChannel(); + channel = new BufferedSeekableByteChannel(underlying); + } + + // ---------- 构造测试 ---------- + @Test + void testConstructorWithDefaultBufferSize() { + assertNotNull(channel); + } + + @Test + void testConstructorWithCustomBufferSize() { + assertDoesNotThrow(() -> new BufferedSeekableByteChannel(underlying, 4096)); + } + + @Test + void testConstructorWithInvalidBufferSize() { + assertThrows(IllegalArgumentException.class, () -> new BufferedSeekableByteChannel(underlying, 0)); + assertThrows(IllegalArgumentException.class, () -> new BufferedSeekableByteChannel(underlying, -1)); + } + + // ---------- 读操作测试 ---------- + @Test + void testReadFromEmptyChannel() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(10); + int bytesRead = channel.read(dst); + assertEquals(-1, bytesRead); + assertEquals(0, dst.position()); + } + + @Test + void testReadSingleByte() throws IOException { + underlying.write(ByteBuffer.wrap(new byte[]{42})); + underlying.position(0); + + ByteBuffer dst = ByteBuffer.allocate(1); + int bytesRead = channel.read(dst); + assertEquals(1, bytesRead); + assertEquals(42, dst.get(0)); + assertEquals(1, channel.position()); + } + + @Test + void testReadMultipleBytesWithinBuffer() throws IOException { + byte[] data = new byte[100]; + for (int i = 0; i < 100; i++) { + data[i] = (byte) i; + } + underlying.write(ByteBuffer.wrap(data)); + underlying.position(0); + + ByteBuffer dst = ByteBuffer.allocate(50); + int bytesRead = channel.read(dst); + assertEquals(50, bytesRead); + for (int i = 0; i < 50; i++) { + assertEquals((byte) i, dst.get(i)); + } + assertEquals(50, channel.position()); + } + + @Test + void testReadAcrossBufferBoundary() throws IOException { + byte[] data = new byte[10000]; + for (int i = 0; i < 10000; i++) { + data[i] = (byte) (i % 256); + } + underlying.write(ByteBuffer.wrap(data)); + underlying.position(0); + + // 内部缓冲区默认8192,一次读取超过缓冲区大小 + ByteBuffer dst = ByteBuffer.allocate(9000); + int bytesRead = channel.read(dst); + assertEquals(9000, bytesRead); + for (int i = 0; i < 9000; i++) { + assertEquals((byte) (i % 256), dst.get(i)); + } + assertEquals(9000, channel.position()); + } + + @Test + void testReadToEOF() throws IOException { + byte[] data = new byte[500]; + for (int i = 0; i < 500; i++) { + data[i] = (byte) i; + } + underlying.write(ByteBuffer.wrap(data)); + underlying.position(0); + + ByteBuffer dst = ByteBuffer.allocate(600); + int bytesRead = channel.read(dst); + assertEquals(500, bytesRead); + for (int i = 0; i < 500; i++) { + assertEquals((byte) i, dst.get(i)); + } + assertEquals(500, channel.position()); + + // 再次读应该返回 -1 + dst.clear(); + bytesRead = channel.read(dst); + assertEquals(-1, bytesRead); + } + + @Test + void testReadWithZeroRemaining() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(0); + int bytesRead = channel.read(dst); + assertEquals(0, bytesRead); + assertEquals(0, channel.position()); + } + + // ---------- 定位测试 ---------- + @Test + void testPosition() throws IOException { + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5})); + underlying.position(0); + + assertEquals(0, channel.position()); + channel.position(3); + assertEquals(3, channel.position()); + + // 验证读位置正确 + ByteBuffer dst = ByteBuffer.allocate(2); + int read = channel.read(dst); + assertEquals(2, read); + assertEquals(4, dst.get(0)); + assertEquals(5, dst.get(1)); + } + + @Test + void testPositionWithinBuffer() throws IOException { + byte[] data = new byte[200]; + Arrays.fill(data, (byte) 1); + underlying.write(ByteBuffer.wrap(data)); + underlying.position(0); + + // 先读一些数据填充缓冲区 + ByteBuffer dst = ByteBuffer.allocate(100); + channel.read(dst); + assertEquals(100, channel.position()); + + // 定位到缓冲区内部(例如 50) + channel.position(50); + assertEquals(50, channel.position()); + + // 读数据应该从位置50继续 + dst.clear(); + int read = channel.read(dst); + assertEquals(150, channel.position()); + // 这里无需验证数据内容,仅验证位置正确即可 + } + + @Test + void testPositionOutsideBuffer() throws IOException { + byte[] data = new byte[200]; + Arrays.fill(data, (byte) 1); + underlying.write(ByteBuffer.wrap(data)); + underlying.position(0); + + // 先读一些数据填充缓冲区 + ByteBuffer dst = ByteBuffer.allocate(100); + channel.read(dst); + assertEquals(100, channel.position()); + + // 定位到缓冲区外部(例如 150) + channel.position(150); + assertEquals(150, channel.position()); + + // 读数据应该从位置150开始 + dst.clear(); + int read = channel.read(dst); + assertEquals(200, channel.position()); + assertEquals(50, read); + } + + @Test + void testPositionNegative() { + assertThrows(IllegalArgumentException.class, () -> channel.position(-1)); + } + + // ---------- 写操作测试 ---------- + @Test + void testWrite() throws IOException { + ByteBuffer src = ByteBuffer.wrap(new byte[]{10, 20, 30}); + int written = channel.write(src); + assertEquals(3, written); + assertEquals(3, channel.position()); + + // 验证底层通道内容 + underlying.position(0); + ByteBuffer dst = ByteBuffer.allocate(3); + underlying.read(dst); + dst.flip(); + assertEquals(10, dst.get()); + assertEquals(20, dst.get()); + assertEquals(30, dst.get()); + } + + @Test + void testWriteAfterRead() throws IOException { + // 先写入一些数据 + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5})); + underlying.position(0); + + // 读一部分 + ByteBuffer readBuf = ByteBuffer.allocate(2); + channel.read(readBuf); + assertEquals(2, channel.position()); + + // 在当前位置写入 + ByteBuffer writeBuf = ByteBuffer.wrap(new byte[]{99, 100}); + int written = channel.write(writeBuf); + assertEquals(2, written); + assertEquals(4, channel.position()); + + // 验证整个文件内容 + underlying.position(0); + byte[] expected = new byte[]{1, 2, 99, 100, 5}; + ByteBuffer full = ByteBuffer.allocate(5); + underlying.read(full); + full.flip(); + assertArrayEquals(expected, full.array()); + } + + @Test + void testWriteWithPositionMove() throws IOException { + // 写入初始数据 + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5})); + // 将通道定位到末尾,然后追加写入 + channel.position(5); + ByteBuffer writeBuf = ByteBuffer.wrap(new byte[]{6, 7}); + int written = channel.write(writeBuf); + assertEquals(2, written); + assertEquals(7, channel.position()); + + // 验证文件内容 + underlying.position(0); + ByteBuffer full = ByteBuffer.allocate(7); + underlying.read(full); + full.flip(); + byte[] expected = new byte[]{1, 2, 3, 4, 5, 6, 7}; + assertArrayEquals(expected, full.array()); + } + + @Test + void testWriteInvalidatesBuffer() throws IOException { + // 先读填充缓冲区 + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})); + underlying.position(0); + ByteBuffer readBuf = ByteBuffer.allocate(5); + channel.read(readBuf); + assertEquals(5, channel.position()); + + // 写入数据(会触发缓冲区失效) + assertEquals(1, channel.write(ByteBuffer.wrap(new byte[]{99}))); + + // 现在读取应该从位置6开始,而不是使用旧缓冲区内容 + readBuf.clear(); + int read = channel.read(readBuf); + assertEquals(4, read); // 原始数据从6开始:6,7,8,9,10 但缓冲区被覆盖,读出的应是原始数据 + readBuf.flip(); + assertEquals(7, readBuf.get()); + assertEquals(8, readBuf.get()); + assertEquals(9, readBuf.get()); + assertEquals(10, readBuf.get()); + } + + // ---------- 截断测试 ---------- + @Test + void testTruncate() throws IOException { + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})); + channel.position(8); + channel.truncate(5); + assertEquals(5, channel.size()); + assertEquals(5, channel.position()); // 位置被调整到末尾 + // 验证底层内容 + underlying.position(0); + ByteBuffer dst = ByteBuffer.allocate(5); + underlying.read(dst); + dst.flip(); + assertArrayEquals(new byte[]{1, 2, 3, 4, 5}, dst.array()); + } + + @Test + void testTruncateWhenPositionInsideNewSize() throws IOException { + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10})); + channel.position(3); + channel.truncate(8); + assertEquals(8, channel.size()); + assertEquals(3, channel.position()); // 位置未变 + // 验证底层内容 + underlying.position(0); + ByteBuffer dst = ByteBuffer.allocate(8); + underlying.read(dst); + dst.flip(); + assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}, dst.array()); + } + + // ---------- 其他方法 ---------- + @Test + void testSize() throws IOException { + assertEquals(0, channel.size()); + underlying.write(ByteBuffer.wrap(new byte[]{1, 2, 3})); + assertEquals(3, channel.size()); + } + + @Test + void testIsOpen() { + assertTrue(channel.isOpen()); + } + + @Test + void testClose() throws IOException { + channel.close(); + assertFalse(channel.isOpen()); + // 关闭后操作应抛出异常 + assertThrows(ClosedChannelException.class, () -> channel.read(ByteBuffer.allocate(1))); + assertThrows(ClosedChannelException.class, () -> channel.write(ByteBuffer.allocate(1))); + assertThrows(ClosedChannelException.class, () -> channel.position()); + assertThrows(ClosedChannelException.class, () -> channel.size()); + assertThrows(ClosedChannelException.class, () -> channel.truncate(0)); + } + + @Test + void testDoubleCloseDoesNotThrow() throws IOException { + channel.close(); + assertDoesNotThrow(() -> channel.close()); + } + + // ---------- 边界和缓冲区重用测试 ---------- + @Test + void testBufferReuseOnSequentialRead() throws IOException { + byte[] data = new byte[8192 * 2]; // 超过缓冲区两倍 + for (int i = 0; i < data.length; i++) { + data[i] = (byte) i; + } + underlying.write(ByteBuffer.wrap(data)); + underlying.position(0); + + // 第一次读,填充缓冲区 + ByteBuffer dst = ByteBuffer.allocate(5000); + channel.read(dst); + assertEquals(5000, channel.position()); + + // 第二次读,应该复用缓冲区中的数据 + dst.clear(); + int read = channel.read(dst); + assertEquals(5000, read); // 从5000读到10000 + // 验证数据连续性 + for (int i = 0; i < 5000; i++) { + assertEquals((byte) (5000 + i), dst.get(i)); + } + assertEquals(10000, channel.position()); + } + + @Test + void testPositionAfterWriteThenRead() throws IOException { + // 写入数据 + channel.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5})); + assertEquals(5, channel.position()); + // 定位到开头读 + channel.position(0); + ByteBuffer dst = ByteBuffer.allocate(5); + int read = channel.read(dst); + assertEquals(5, read); + dst.flip(); + assertArrayEquals(new byte[]{1, 2, 3, 4, 5}, dst.array()); + assertEquals(5, channel.position()); + } +} \ No newline at end of file From d44985ad42c81cd1b1bde58b580b2486e17e21b0 Mon Sep 17 00:00:00 2001 From: ZZZank <3410764033@qq.com> Date: Sun, 22 Mar 2026 01:09:28 +0800 Subject: [PATCH 3/3] fix checkstyle violation --- .../util/io/BufferedSeekableByteChannel.java | 19 ++++++++++++++++++- .../io/BufferedSeekableByteChannelTest.java | 19 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java index d0dca3507d..07cc235ed6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannel.java @@ -1,3 +1,20 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package org.jackhuang.hmcl.util.io; import java.io.IOException; @@ -162,4 +179,4 @@ private void ensureOpen() throws ClosedChannelException { throw new ClosedChannelException(); } } -} \ No newline at end of file +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java index fa45ee5b7a..2e4c0ae1e8 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/io/BufferedSeekableByteChannelTest.java @@ -1,3 +1,20 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package org.jackhuang.hmcl.util.io; import kala.compress.utils.SeekableInMemoryByteChannel; @@ -387,4 +404,4 @@ void testPositionAfterWriteThenRead() throws IOException { assertArrayEquals(new byte[]{1, 2, 3, 4, 5}, dst.array()); assertEquals(5, channel.position()); } -} \ No newline at end of file +}