原创

写一个有边界的文本导入器:字节、行长、行数分别限额

读取文本文件时,给总文件大小设上限还不够。一份几兆字节的输入,可以是一行特别长的内容,也可以包含几百万个空行。前者放大单行处理成本,后者放大对象数量;若读完以后才检查长度,内存早已用掉。

这次用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关不关都可用,就声称清理路径通过。替换真实输入时,保持相同的所有权和关闭协议;测试替身应该让这份协议可观察。

正文到此结束
评论插件初始化中...
Loading...
本文目录