写一个有边界的文本导入器:字节、行长、行数分别限额
读取文本文件时,给总文件大小设上限还不够。一份几兆字节的输入,可以是一行特别长的内容,也可以包含几百万个空行。前者放大单行处理成本,后者放大对象数量;若读完以后才检查长度,内存早已用掉。
这次用Java 17实现一个有明确预算的UTF-8导入器。规则有三条:实际读取字节不能超限,单行UTF-16长度不能超限,记录行数不能超限。输入损坏时拒绝整份结果,不静默替换错误字符,也不返回前半份数据。
示例只完成文本分行,不解析CSV,也不连接数据库。先把这一层资源和失败协议做清楚,业务字段解析才有可靠输入。
先写下换行与空输入的含义
程序接受LF、CRLF和单独CR作为行结束。CRLF算一次,不产生额外空行。空文件没有记录;一个换行符表示一条空记录;最后一行不带换行仍被接收。a\n只有一条a,而a\n\n还有第二条空记录。这与按行读取的常见预期一致,但它仍然是一项应该写出来的格式约定。
行长以Java字符串的UTF-16码元计数,不以用户眼中的可见字符计数。一个补充平面字符可能占两个码元;组合字符也可能占多个。这个单位与实现一致,容易精确限制内存。若产品限制的是字素数量,需要额外的分割规则,不能只改一个变量名。
实际字节上限放在解码器下面。即使文件名、上传元数据或Content-Length说文件很小,也以真正进入读取路径的字节为准。任何预算超出都会让本次调用失败。
完整代码与运行方式
保存为 BoundedImport.java,在JDK 17中执行 java BoundedImport.java。程序自己创建临时文件,最后删除;其他测试都用内存字节构造,没有外部环境依赖。
import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;
import java.util.*;
public class BoundedImport {
static final class ByteLimit extends FilterInputStream {
final long limit; long count;
ByteLimit(InputStream in, long limit) { super(in); this.limit = limit; }
private int checked(int n) throws IOException {
if (n > 0 && (count += n) > limit) throw new IOException("BYTE_LIMIT");
return n;
}
public int read() throws IOException {
int b = in.read(); checked(b == -1 ? 0 : 1); return b;
}
public int read(byte[] b, int off, int len) throws IOException {
if (len == 0) return 0;
int ask = (int)Math.min((long)len, limit - count + 1);
return checked(in.read(b, off, ask));
}
}
static void line(List<String> rows, StringBuilder value, int maxRows) throws IOException {
if (rows.size() == maxRows) throw new IOException("ROW_LIMIT");
rows.add(value.toString()); value.setLength(0);
}
static List<String> read(InputStream source, long maxBytes, int maxChars, int maxRows)
throws IOException {
if (maxBytes < 1 || maxBytes > 1_000_000 || maxChars < 1 || maxRows < 1)
throw new IllegalArgumentException("positive bounded budgets required");
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
List<String> rows = new ArrayList<>(); StringBuilder value = new StringBuilder();
boolean afterCr = false;
try (Reader reader = new InputStreamReader(new ByteLimit(source, maxBytes), decoder)) {
for (int c; (c = reader.read()) != -1; ) {
if (afterCr) { afterCr = false; if (c == '\n') continue; }
if (c == '\r' || c == '\n') {
line(rows, value, maxRows); afterCr = c == '\r';
} else {
if (value.length() == maxChars) throw new IOException("LINE_LIMIT");
value.append((char)c);
}
}
if (value.length() > 0) line(rows, value, maxRows);
}
return List.copyOf(rows);
}
static void reject(String name, byte[] bytes, long maxBytes, int maxChars, int maxRows)
throws Exception {
try { read(new ByteArrayInputStream(bytes), maxBytes, maxChars, maxRows); throw new AssertionError(); }
catch (CharacterCodingException expected) { System.out.println(name + "=ENCODING"); }
catch (IOException expected) { System.out.println(name + "=" + expected.getMessage()); }
}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("bounded-import-", ".txt");
try {
Files.writeString(file, "a\r\n\r\nb\rc\nlast", StandardCharsets.UTF_8);
System.out.println("rows=" + read(Files.newInputStream(file), 100, 8, 10));
} finally { Files.deleteIfExists(file); }
reject("long_line", "12345".getBytes(StandardCharsets.UTF_8), 100, 4, 10);
reject("many_rows", "a\nb\n".getBytes(StandardCharsets.UTF_8), 100, 8, 1);
reject("many_bytes", "abcdef".getBytes(StandardCharsets.UTF_8), 4, 8, 10);
reject("broken", new byte[]{(byte)0xe4}, 100, 8, 10);
System.out.println("empty=" + read(new ByteArrayInputStream(new byte[0]), 10, 8, 10).size());
}
}
输出:
rows=[a, , b, c, last]
long_line=LINE_LIMIT
many_rows=ROW_LIMIT
many_bytes=BYTE_LIMIT
broken=ENCODING
empty=0
成功测试同时包含三种换行、空行和无末尾换行。后面依次触发行长、行数、字节预算和坏编码。没有只测试“超大文件”这一种路径,因为四类拒绝发生在不同层次,不能互相代替。
为什么先限制字节,再解码
ByteLimit是输入流包装器。读请求最多向下游多探测一个字节,用来判断是否超过边界;一旦超过就抛出明确错误。限制的是被实际读取的字节,而不是文件当前size。若输入在读取期间增长,仍会受到同一预算约束。
包装器没有把全部内容先readAllBytes,避免为了检查限额而先分配整份字节数组。读操作本身仍可能阻塞;字节限额限制规模,不限制等待时间。网络输入还要由对应网络库设置读取或整体截止时间,这里不把文件导入函数伪装成通用超时设施。
InputStreamReader内部可能预读。于是某些错误会在业务拿到第一行以前被发现,这不影响本例的全有或失败协议。若程序要求逐行流式提交,就需要认真面对预读、后期错误和已经提交的数据;不能把异常发生得早晚当成某一行一定已经可靠完成的证据。
字符解码器显式使用REPORT。它可以在EOF发现尚未完成的UTF-8序列,而不是用替代字符制造一份看似有效的文本。若业务允许容错,应建立独立的失败记录与人工修复路径,不能让导入器偷偷决定丢掉哪些字节。
行长检查要发生在 append 之前
若先调用BufferedReader.readLine拿到整行,再检查length,完整字符串已经创建。对不受控输入,这个时点太晚。程序逐个字符组装行,在达到上限以后拒绝追加下一字符,因此每条正在组装的记录有确定上界。
这份代码为展示规则采用逐字符调用,Reader本身有字节到字符的缓冲。追求吞吐时可以改成批量读字符缓冲区,但CRLF跨块、代理对跨块以及预算检查仍要保持同样语义。改成块读取不应顺便改变空行和EOF规则。
line在创建下一份行字符串以前检查行数。样本把所有行收集成List,适合预算明确的小型导入;总行数和单行上限结合起来,才能为最终对象规模给出上界。若有很多小行,对象头和List引用本身也会占内存,预算不能只加总文字字节。
谁关闭输入,失败后交付什么
read方法取得输入所有权,在内部的try-with-resources结束时关闭底层流。这个约定是本例接口的一部分。如果调用方需要继续使用同一条流,应另设不关闭输入的接口,并由外层管理生命周期,不能靠猜测Reader是否会传递close。
成功结果使用List.copyOf交付,调用者不能通过增删记录改变返回集合。这里的元素都是String,因此没有继续共享可变行对象的问题。失败时局部List不可见,不会把已经读到的前几行悄悄当成功结果返回。
接入数据库时,也别把“读一行就写一行”当自然延伸。最后一行坏编码、行数超额或业务校验失败时,前面的写入可能已经提交。可以先在有界输入范围内完整验证再开启写事务,或设计暂存表与明确的批次确认状态;大量数据则采用分块处理,但要把部分成功和重启协议写出来。
接入真实上传还要留下哪些限制
本例不处理压缩。若输入先经过解压,原始上传体积与解压后字节量需要分别限额,不能只信压缩包大小。多文件上传还要有整个批次预算,避免每个文件都合规,总量却超出容量。
分行也不是CSV解析。带引号字段可能合法包含换行,直接逐行再split会把一条记录拆开;那时应选择支持该格式的解析器,并在相应记录层设置限制。UTF-8 BOM是否允许、首列是否需要移除,也应由格式决定,本例不做隐藏的全局字符删除。
测试还可以进一步加入恰好到上限、零字节、连续CR、CRLF横跨读取边界以及关闭失败。扩展时以规则为单位增加样本,而不是随机多造几份大文件。导入器最重要的产物不只是几行字符串,而是一个可依赖的承诺:规模受控,字符合法,行边界明确,失败不会混成成功。
别把预算边界测成只有“超过就报错”
还应核对恰好等于限制时成功,以及多一个字节、一个码元或一行时分别失败。否则很容易把比较符号写错,正常的最大输入也被拒绝。比如四码元行长限制应接受1234,拒绝12345;两行预算应接受a换行b,第三行才失败。
字符与字节的交叉样本也有用。两个汉字可能只占两个UTF-16码元,却需要六个UTF-8字节;如果误把字节预算放到字符串length上,这份样本就能暴露问题。CRLF应在输入块被刻意拆开以后仍只产生一次换行,避免块读取优化引入额外空记录。
异常清理则要用能记录close的输入包装器核对,不能因为ByteArrayInputStream关不关都可用,就声称清理路径通过。替换真实输入时,保持相同的所有权和关闭协议;测试替身应该让这份协议可观察。