HarmonyOS ArkTS:跨行引号记录的增量扫描与缓冲收口(连载03)【共创稿事节】
上一节把 CSV 行解析封装进本地 HAR 后,调用方拿到的是稳定的 CsvParserPort 和错误结果。接着要处理一个文件导入中很容易被漏掉的场景:一条账单的说明列被双引号包裹,说明本身又包含换行。此时把文本先按 \n 拆成数组再送给单行解析器,会把一个合法账单拆成两条记录,后面的行号、金额和错误定位也会一起错位。
本篇仍使用 LedgerDock 的四条费用明细。TX001 金额 12500 分、说明为物料;TX002 为 8600 分,其说明从“配件”换行到“需复核”;TX003 金额写成 12.5x,仍属格式错误;TX004 为 4300 分、说明为办公。含表头的文件共有六行物理文本,但只有四条业务记录。目标结果仍是读取四条、有效三条、错误一条、待入库三条,不能因为 TX002 占了两行,就把它当成两次费用。

换行不是记录结束的唯一条件
CSV 里的逗号和换行只有在双引号外面才拥有分隔意义。例如 "配件\n需复核" 中间的换行属于同一个字段,而 TX002 末尾闭合引号后的换行才是真正的记录终止符。如果先使用 split(/\r?\n/),扫描器便没有机会判断当时是否位于引号内部。另一类错误发生在输入分块:一次读取恰好停在引号处,下一块的开头才到达第二个引号。前一块末尾的引号既可能意味着字段闭合,也可能与下一块组成转义双引号,不能立刻判定。
所以需要给原来的解析 HAR 新增一层“逻辑记录扫描器”,放在文件读取与旧的 CsvParserPort.parse 之间。它收取任意大小的文本块,维护当前字段、引号状态和物理行号,只在确认记录边界后输出字段数组。已有的金额分校验仍由业务映射层完成,HAR 不参与“差旅金额是不是有效整数”这类财务判断。这样升级的是输入边界,不会破坏上一节稳定下来的返回协议。
为了让处理规则可推导,我用四种状态描述字段:字段开始、未加引号、引号内、刚遇到可能闭合的引号。第四种状态很关键:如果紧接着还是引号,就把两个连续双引号当成一个字段内容字符;如果接着是逗号或记录终止,就允许字段结束;如果跟着普通字符,则记录格式问题,留给错误列表处理。
代码要解决的问题,是在不同输入块之间保留扫描状态。这里输出每条记录的字段和起止物理行,用于下一篇的字段级定位,扫描器本身不生成费用明细。
export interface CsvLogicalRow {
fields: string[];
firstLine: number;
lastLine: number;
issue: string;
}
enum CsvScanState { START, PLAIN, QUOTED, QUOTE_END }
export class CsvRecordScanner {
private state: CsvScanState = CsvScanState.START;
private fields: string[] = [];
private cell: string = '';
private issue: string = '';
private physicalLine: number = 1;
private recordStart: number = 1;
private skipLF: boolean = false;
private firstChar: boolean = true;
push(chunk: string): CsvLogicalRow[] {
const rows: CsvLogicalRow[] = [];
for (let i = 0; i < chunk.length; i++) {
let ch: string = chunk.charAt(i);
if (this.firstChar) {
this.firstChar = false;
if (ch === '\uFEFF') { continue; }
}
if (this.skipLF) {
this.skipLF = false;
if (ch === '\n') { continue; }
}
if (ch === '\r') {
this.skipLF = true;
ch = '\n';
}
this.accept(ch, rows);
if (ch === '\n') { this.physicalLine++; }
}
return rows;
}
private accept(ch: string, rows: CsvLogicalRow[]): void {
if (this.state === CsvScanState.QUOTED) {
if (ch === '"') { this.state = CsvScanState.QUOTE_END; }
else { this.cell += ch; }
return;
}
if (this.state === CsvScanState.QUOTE_END) {
if (ch === '"') { this.cell += '"'; this.state = CsvScanState.QUOTED; return; }
if (ch !== ',' && ch !== '\n') {
this.issue = '引号闭合后存在多余字符';
this.state = CsvScanState.PLAIN;
this.cell += ch;
return;
}
}
if (ch === ',') {
this.fields.push(this.cell);
this.cell = '';
this.state = CsvScanState.START;
} else if (ch === '\n') {
this.fields.push(this.cell);
this.emit(rows);
} else if (ch === '"' && this.state === CsvScanState.START) {
this.state = CsvScanState.QUOTED;
} else {
if (ch === '"') { this.issue = '普通字段出现双引号'; }
this.cell += ch;
this.state = CsvScanState.PLAIN;
}
}
private emit(rows: CsvLogicalRow[]): void {
rows.push({ fields: this.fields, firstLine: this.recordStart,
lastLine: this.physicalLine, issue: this.issue });
this.fields = [];
this.cell = '';
this.issue = '';
this.state = CsvScanState.START;
this.recordStart = this.physicalLine + 1;
}
finish(): CsvLogicalRow[] {
const rows: CsvLogicalRow[] = [];
if (this.state === CsvScanState.QUOTED) { this.issue = '双引号没有闭合'; }
if (this.fields.length > 0 || this.cell.length > 0 || this.issue.length > 0 ||
this.state !== CsvScanState.START) {
this.fields.push(this.cell);
this.emit(rows);
}
return rows;
}
}
这段实现把 Windows 风格的 \r\n 规整为一个 \n,也照顾 \r 和 \n 恰好被切进两个块的情况。skipLF 是跨块保留的,不能在每次 push 开始时清空。记录中的换行如果发生在引号内部,会作为说明正文加入 cell,同时推进物理行号;这样一条记录可以对应 firstLine=3、lastLine=4,而逻辑记录序号依然只有一个。
字段解析与领域校验继续隔离
单行解析器和增量扫描器应避免争夺同一层职责。旧 CsvParserPort.parse 继续供小文本调用;新的扫描器面向原始文本流,两者复用一致的 CsvLogicalRow 规则。页面拿到完成的逻辑记录后,再做表头和金额映射。若第一个完成的记录不是约定的四列“流水号、日期、金额分、说明”,直接给出表头错误,不必把后续数据猜成另一种表格。金额使用“分”为单位,要求整数字符串,不接受 12.5x 或浮点金额直接转入账本。
下面把记录映射写成独立函数。它不隐藏错误行,也不在扫描过程中写入数据库;这能防止文件中途被截断后留下一半已经提交的数据。
export interface ImportLine {
id: string;
date: string;
cents: number;
note: string;
}
export interface ImportIssue {
line: number;
field: string;
reason: string;
}
export interface ImportPreview {
accepted: ImportLine[];
issues: ImportIssue[];
}
export function buildPreview(rows: CsvLogicalRow[]): ImportPreview {
const accepted: ImportLine[] = [];
const issues: ImportIssue[] = [];
if (rows.length === 0 ||
rows[0].fields.join(',') !== '流水号,日期,金额分,说明') {
issues.push({ line: 1, field: '表头', reason: '字段名称或列数不匹配' });
return { accepted: accepted, issues: issues };
}
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
if (row.issue.length > 0 || row.fields.length !== 4) {
issues.push({ line: row.firstLine, field: 'CSV', reason: row.issue || '列数错误' });
continue;
}
const amount: string = row.fields[2];
if (!/^(0|[1-9][0-9]*)$/.test(amount) || !Number.isSafeInteger(Number(amount))) {
issues.push({ line: row.firstLine, field: '金额分', reason: '需要整数分' });
continue;
}
accepted.push({ id: row.fields[0], date: row.fields[1],
cents: Number(amount), note: row.fields[3] });
}
return { accepted: accepted, issues: issues };
}
对于 TX002,说明最终值会包含内部换行,但金额 8600 的校验并不受影响。对于 TX003,扫描器正常交付四个字段,映射器再明确指出金额列错误。如果在扫描阶段直接把 TX003 的 12.5x 当成流边界问题,用户看到的将是错误的提示层级,也会给将来换第三方 CSV 库带来额外耦合。

收口时才能判断最后一条是否完整
分块读取时,不存在“这一块读完就意味着一条记录结束”的规则。push 的返回值可以是零条记录,也可以是多条;只有遇到引号外的记录换行,才能确认本条边界。如果源文件末尾没有换行,必须在全部文本输入完成后调用 finish,把最后一条合法记录输出。反过来,如果输入结束时仍位于引号内部,就要返回“引号没有闭合”的格式问题,不能把残缺字段写进有效列表。
处理流入前还有两个上限需要明确。其一是单条逻辑记录的最大字符数:特别长的说明如果无限堆积在 cell 中,会抬高内存峰值,生产实现应按业务允许长度提前终止并给出长度错误。其二是每次推给页面的结果数量:大量文件不应为每条完成记录立即刷新一个组件,而是按固定批量或节流间隔更新摘要。扫描器保存的只是当前未完成记录,已完成的条目要尽快交给上层消费并从临时队列释放。
下面是一份小型输入检查,故意把带引号的 TX002 拆在不同块里。逻辑上拼接后仍是一份 CSV 文件;这比只拿完整字符串调用一次解析,更容易暴露跨块状态缺陷。
export function inspectIncrementalSample(): string[] {
const fragments: string[] = [
'流水号,日期,金额分,说明\r\nTX001,2026-10-01,12500,物料\r\n',
'TX002,2026-10-02,8600,"配件\r',
'\n需复核"\r\nTX003,2026-10-03,12.5x,差旅\r\n',
'TX004,2026-10-04,4300,办公'
];
const scanner = new CsvRecordScanner();
const rows: CsvLogicalRow[] = [];
for (const part of fragments) { rows.push(...scanner.push(part)); }
rows.push(...scanner.finish());
const result = buildPreview(rows);
return [
`物理行=6,业务记录=${rows.length - 1}`,
`有效=${result.accepted.length},错误=${result.issues.length}`,
`TX002说明=${result.accepted[1].note.replace('\n', '↵')}`
];
}
当全部内容按这组数据读取,业务记录应为四条,三条接受、一条待修;TX002 的说明显示为“配件↵需复核”。这几个输出属于可检查的预期值,目的是让每次调整缓冲策略后都能定位哪一块改变了记录边界。实际导入逻辑仍需要按文件编码、超长记录和用户取消导入三类条件补上错误出口,不能让扫描器在取消后继续积累字符串。
把解析状态留在 HAR 内,把提交决定留在页面
应用页面应该拿到四条逻辑记录、错误位置和安全的三条候选记录,而不直接读扫描器的 state、cell、skipLF 等内部变量。继续通过 Index.ets 暴露稳定的解析端口,新增扫描能力可以采用新的公开类型,不随意改变上篇 CsvLineResult 的含义。模块依赖的版本字段、入口文件及本地 HAR 关系可按华为HAR 静态共享包指导和oh-package.json5 配置说明核对。
页面确认导入前,还应让使用者能看到 TX003 的问题,而不是只给一个“有一条错误”的数字。TX002 的换行备注建议按一条卡片展示,折叠时保留主要说明与“需复核”提示,防止它在窄屏上被误解为两个账单。确认按钮必须以候选有效记录为输入;若原文件在预览后发生变动,应重新计算结果,不沿用旧的候选数组。输出界面还需允许清空当前解析会话,避免切换文件时残留上一份文件的半条记录。

跨行 CSV 的重点不是识别换行字符,而是识别换行发生时解析器所处的状态。把“读入块”“逻辑记录”“领域金额”分成三层后,既能解释六行文本为何只有四条账单,也能保持 TX003 的金额错误独立可见。下一篇再沿用扫描器保留下来的起止行号,处理错误字段位置、引号错配后的回退策略和用户重新选择文件时的状态重置。
更多推荐




所有评论(0)