Python txt转xml:字段映射、编码处理与批量校验

发布时间:2026/10/25 23:33:22
Python txt转xml:字段映射、编码处理与批量校验

简介:面向需要把纯文本数据整理为结构化文档的编程学习者与数据处理人员,这份小工具提供从 txt 到 xml 的转换实现,重点解决 txt 缺乏元素层级、属性描述和跨系统交换能力的问题。压缩包共 37 个文件、约 65KB,以 C# 源码、WPF 界面文件、项目解决方案与工程配置以及可执行程序为主,并附少量资源、清单和设置文件,构成一个可查看、编译或运行的完整小程序项目。已有 3445 人学习下载,说明其在文本转 XML 这类基础数据加工场景中具有一定参考面。读者可从中了解 txt 行列数据的读取与解析、XML 元素和属性的映射、文件 I/O、异常处理与调试测试思路,并依据自身数据布局调整转换逻辑;对不熟悉 XML 的读者,也能借这个小项目熟悉结构化数据组织方式与不同系统间的数据交换流程。

1. txt 转 xml 到底在转什么:先认清三种输入形态

某公司的巡检数据,一线用记事本按「一行一条、制表符分隔」记下来,十年攒了几万份;下游报表系统是早年采购的,接口只接受 xml。中间这道转换,每天早上要人手跑一遍,直到有人写了这个小工具。txt 数据转换为 xml 数据,难点从来不在「转」这个动作,而在于 txt 根本没有 schema 可言——同一批文件里,字段个数、分隔符、空值写法可能每份都不一样。工具的职责其实是替你把「格式约定」这件事钉死:解析行与列,映射成节点路径,再写出带声明、带缩进、编码明确的 xml。它适合手上已经有成堆结构化文本、又必须对接只认 xml 的老系统或第三方接口的人;反过来,如果你的数据连稳定列结构都没有,先补规范,比找工具划算。

2. 拆开这个小工具:解析层、映射层、写出层

把脚本从头到尾读一遍,会发现它是很干净的三段式:解析层负责把一行文本切成列表,映射层负责把列表下标翻译成 xml 节点路径,写出层负责序列化、缩进和编码声明。三层之间只靠一个字典配置和一组列表传递数据,没有任何一层知道另外两层的细节。这种切法的好处是换数据源时只动解析层,换下游结构时只动映射配置,出问题也能一眼定位是哪一层的锅,不用在几百行里猜。

我见过有人偷懒,直接写字符串模板"<record><id>{}</id></record>".format(row[0]),样例数据跑得飞快,正式数据一上来就崩。原因是模板法把三层的边界全抹平了:字段里的&、<没转义,空值没兜底,节点层级写死在字符串里改一次要全文搜索。所以哪怕只是个小工具,也值得按层次切开,后面加字段、加校验、加批量处理都是顺手的事。

2.1 三种 txt 形态与对应的解析策略

在动手写解析器之前,先给手上的 txt 分个类,这决定了后面用什么读法。日常遇到的绝大多数文本逃不出下面三种形态,混着出现的也有,通常头部是键值信息、正文是行式数据。

形态典型特征推荐解析方式高频易错点
分隔符行式每行列数一致,csv / tsv / 竖线 / 分号line.split(delim, maxsplit)分隔符被表格软件换成空格
定宽列式主frame程序按字节位置对齐,肉眼看着像空格分开按固定下标切片line[a:b]中文字符占位宽度与字节不一致
键值块式key=value成段,空行分段逐行匹配k=v,空行切记录值里本身含有=号
混合式前若干行是元信息,之后才是数据表先定位表头行,再按行式处理表头行位置不固定

分隔符行式最省心,前提是分隔符真的是你以为的那个。从 Excel 复制粘贴出来的「制表符分隔」经常已经变成四个空格,肉眼看不出差别,split("\t")只会返回一个元素。所以工具里加了一个分隔符嗅探:拿前 N 行做投票,只有每行列数完全一致且大于 1 才算候选,候选里按「制表符优先、空格最后」的顺序取。

CANDIDATE_DELIMS = ["\t", "|", ",", ";", " "] def sniff_delimiter(sample_lines, min_cols=2): """用样本行给候选分隔符投票,返回列数最多的那个;无法判断返回 None""" scores = {} for d in CANDIDATE_DELIMS: cols = [len(line.split(d)) for line in sample_lines if line.strip()] if not cols: continue # 列数必须稳定,且不少于 min_cols,否则视为误判 if len(set(cols)) == 1 and cols[0] >= min_cols: scores[d] = cols[0] if not scores: return None return max(scores, key=scores.get)

CANDIDATE_DELIMS的顺序是有意排的:空格最容易被误判成合法分隔符,放最后;制表符最可靠,放第一。min_cols默认 2,如果你的数据只有一列(整行当一个字段),嗅探必然失败,这时候要允许人工强制指定--delimiter。函数返回None不是异常,而是给上层一个明确信号:这文件得人工看一眼,别硬猜。

定宽列式是另一个坑。有些老设备的输出按固定字节宽度对齐,日志里看着整整齐齐,实际是中文字符占了两到三个字节宽度,按字符下标切片全错位。遇到这种先把文件按utf-8读进来,用len(line.encode("utf-8"))核对宽度,实在不行就退回「按连续空格压缩成单分隔符」的宽松模式,宁可丢一点精度也别静默错位。

键值块式通常用于配置类数据,key=value一行一个,空行表示一条记录结束。解析时要注意值里可能含=,所以split("=", 1)而不是split("=")。空行切记录之后还要丢掉纯空白的块,否则最后一条记录后面跟着的换行会多生成一个空节点。

2.2 字段映射:把列号写进配置而不是写进代码

映射关系一旦硬编码进代码,换一个数据源就得改脚本,改完还要重新测一遍,成本比想象中高。所以这个工具把字段映射抽成一份 JSON 配置,脚本只认配置、不认业务。

{ "source": {"delimiter": "\t", "encoding": "utf-8-sig", "has_header": true}, "root": "records", "record": "record", "fields": [ {"index": 0, "path": "meta/id", "type": "str", "required": true}, {"index": 1, "path": "meta/time", "type": "datetime", "format": "%Y-%m-%d %H:%M:%S"}, {"index": 2, "path": "payload/name", "type": "str", "default": ""}, {"index": 3, "path": "payload/value","type": "float", "default": "0"} ] }

这份配置表达的信息量其实不小:source.delimiter为null时启用自动嗅探,has_header决定第一条有效行是表头还是数据,path用斜杠表示节点层级,父节点不存在就自动创建。required的字段取值为空直接抛错,default的字段则静默补默认值——两者的区别是「数据坏了」和「数据缺了」,处理策略不该一样。

def build_record(record_el, row, fields): for spec in fields: idx = spec["index"] raw = row[idx].strip() if idx < len(row) else "" if raw == "" and spec.get("required"): raise ValueError("必填字段为空: " + spec["path"]) text = convert(raw, spec) # 类型转换见 4.3 node = record_el for part in spec["path"].split("/")[:-1]: child = node.find(part) # 显式判空,不要写 node.find(p) or SubElement(...) if child is None: child = ET.SubElement(node, part) node = child ET.SubElement(node, spec["path"].split("/")[-1]).text = text

这里有一行注释值得单独说:不要用node.find(part) or ET.SubElement(node, part)这种简写。Element 对象的布尔值取决于它有没有子元素,一个已经存在但没有子节点的meta会被判成False,于是重复创建同名节点,最后 xml 里出现两个meta,下游用find()只能拿到第一个,值全丢。这是标准库留给所有人的一个坑,写成显式判空虽然多两行,但省掉的排查时间远不止两行。

index用的是列下标而不是表头名,看起来不够优雅,但对老数据更稳——很多 txt 的表头是中文、带空格、甚至同名重复,按下标定位反而不会错。如果数据确实规范,可以在配置里加一个可选name字段,加载时用表头行反查下标,两种方式并存。

2.3 XML 写出:声明、缩进、编码三件事

写出层看着最简单,实际上是最容易「本地跑得好、交给下游就报错」的一环。要点就三个:xml 声明里的 encoding 必须和实际写盘的编码一致;缩进只影响可读性,但写出来好看与否直接影响人工核对效率;空节点的写法<x />和<x></x>在部分老解析器里行为不同,要显式定死。

def write_xml(tree, out_path, encoding="utf-8"): """序列化并落盘,encoding 同时决定文件编码和声明内容""" try: ET.indent(tree, space=" ") # Python 3.9+ 才有,低版本走下面的兜底 except AttributeError: _manual_indent(tree.getroot()) tree.write( out_path, encoding=encoding, # 与声明里的 encoding 属性保持一致 xml_declaration=True, # 输出 <?xml version="1.0" encoding="utf-8"?> short_empty_elements=True # 空节点写成 <x />,而不是 <x></x> )

xml_declaration=True不加的话,某些只吃严格 xml 的系统会直接拒收;encoding传utf-8时 ElementTree 不会额外写 BOM,这一点对下游比对友好,省得文件头多出三个不可见字节导致哈希不一致。ET.indent是 3.9 才进标准库的,如果运行环境版本不确定,就保留那个try/except兜底,否则在旧机器上会直接AttributeError,而这行报错出现在写盘前,前面几分钟的解析全白做。

还有一个容易忽略的细节:tree.write()会以二进制模式打开文件,传进去的encoding只影响序列化方式。如果你的目标是「文件本身是 utf-8 无 BOM」,这样写没问题;如果下游明确要求带 BOM,就得自己用codecs.BOM_UTF8拼一下,别指望encoding="utf-8-sig"能直接生效。

3. 跑通第一条转换链路:命令行参数与批量目录

单文件转换只是验证阶段,真正上线一定是整个目录批量跑。这一章按「先跑通一个文件 → 再批量 → 最后校验」的顺序推进,每一步都能独立验证,出了问题不用一次怀疑全链路。参数设计上我倾向于把「输入、输出、配置」三个必填项显式化,其余全部给默认值,这样日常调用最短,特殊场景又能覆盖。

3.1 最小可运行脚本与六个必设参数

命令行接口用argparse就够了,不需要引入第三方框架。参数不多,但每一个都对应一类现实需求:分隔符要能强制指定(嗅探失败时救命),编码要能指定(老文件不是 utf-8),出错策略要能选(批量跑的时候不能一条坏数据就全停)。

import argparse def build_parser(): p = argparse.ArgumentParser(description="txt -> xml 批量转换") p.add_argument("-i", "--input", required=True, help="输入文件或目录") p.add_argument("-o", "--output", required=True, help="输出文件或目录") p.add_argument("-c", "--config", required=True, help="字段映射 JSON 路径") p.add_argument("-d", "--delimiter", default=None, help="强制指定分隔符,默认自动嗅探") p.add_argument("-e", "--encoding", default="auto", help="源文件编码,auto 表示探测") p.add_argument("--recursive", action="store_true", help="递归遍历子目录") p.add_argument("--on-error", choices=["skip", "abort", "report"], default="report", help="单文件失败时的处理策略") p.add_argument("--chunk", type=int, default=5000, help="每多少条记录写一次盘") return p

参数说明里最需要解释的是--on-error。skip是坏文件直接跳过、不留痕迹,适合每天定时跑、只要结果能出就行的场景;abort是遇到第一个错误立刻退出,适合调试期,能最快暴露问题;report是默认值,继续跑完但把所有失败文件的路径和异常收集起来,最后统一打印。生产上我一般用report,跑完看一眼失败清单,比中途停掉再重跑省事得多。

--chunk是为大文件准备的。默认 5000 条记录落一次盘,意味着一个几十万行的文件不会在内存里堆出一个巨大的 ElementTree,也不会因为最后一行崩溃导致前面几十分钟白跑。代价是分片写入需要自己管理根节点闭合,不能用一次性tree.write(),得用增量写法。

--delimiter默认None而不是"\t",这个设计很关键。默认给个具体值会让用户忘了嗅探的存在,而嗅探失败时必须显式指定,反而更清楚。同理--encoding默认"auto",走探测逻辑,探测不出来再报错让人指定,不要默默按 utf-8 读,那样只会读到一堆乱码而不报错,是更坏的结果。

3.2 批量转换:目录镜像、命名规则与失败清单

批量处理的核心约定是「输出目录结构和输入目录结构镜像」,输入data/2024/03/a.txt就输出out/2024/03/a.xml,只换扩展名、只换根目录。这样下游按日期或按设备号索引的逻辑完全不用改,也不用担心同名文件互相覆盖。

from pathlib import Path def convert_tree(src_dir, dst_dir, pattern="*.txt", recursive=False, on_error="report", convert_one=None): src_dir, dst_dir = Path(src_dir), Path(dst_dir) it = src_dir.rglob(pattern) if recursive else src_dir.glob(pattern) failed, done = [], 0 for src in sorted(it): rel = src.relative_to(src_dir).with_suffix(".xml") dst = dst_dir / rel dst.parent.mkdir(parents=True, exist_ok=True) # 镜像目录,缺哪层补哪层 try: convert_one(src, dst) done += 1 except Exception as exc: failed.append((str(src), repr(exc))) if on_error == "abort": raise print("完成 {} 个,失败 {} 个".format(done, len(failed))) return failed

sorted(it)不是为了美观,是为了让失败清单的顺序稳定,方便和上一次的清单做 diff;with_suffix(".xml")只换最后一个后缀,a.txt.bak这种命名会变成a.txt.xml,如果数据里真有这种备份文件,遍历时用pattern加if src.suffix == ".txt"双重过滤。dst.parent.mkdir(parents=True, exist_ok=True)是镜像目录的关键,少了它递归模式下一进子目录就FileNotFoundError。

失败清单一定要落盘,不能只print。跑批任务经常是后台执行,终端输出丢了,等下游反馈数据缺失时你已经无从查起。最简单的做法是写一个failed.tsv,两列分别是源路径和异常摘要,下次重跑直接读这个清单只重跑失败的,比全量重跑快一个数量级。

还有一个隐性约定:输出文件先写临时名再原子改名。批量转换中途被 kill 是常态,直接往目标路径写会留下半截 xml,下游读到解析失败的残文件还以为数据坏了。写法是dst.with_suffix(".xml.tmp")写完再os.replace,一行代码换来的确定性很值。

3.3 转换后的校验:回读、计数与结构比对

转换脚本自己说成功不算成功,得让 xml 解析器说成功。校验分三层,成本从低到高:语法层用解析器把文件读一遍,结构层比对记录数和关键字段是否存在,内容层抽几条和源 txt 逐字段对照。

import xml.etree.ElementTree as ET def verify(xml_path, expect_records=None, required_paths=("meta/id",)): tree = ET.parse(xml_path) # 语法错误在这一步就会抛 root = tree.getroot() records = root.findall("record") if expect_records is not None and len(records) != expect_records: raise AssertionError("记录数不符: {} != {}".format(len(records), expect_records)) for r in records[:50]: # 抽查前 50 条,别全量遍历拖慢流程 for path in required_paths: if r.find(path) is None: raise AssertionError("缺少节点: " + path) return len(records)

ET.parse是最硬的一道关,任何标签未闭合、非法字符、编码声明与实际不符都会在这里炸出来,成本极低,建议每个文件都跑。记录数比对的期望值从哪来?最省事的办法是数源文件的有效行数(去掉表头和空行),和 xml 里的record节点数对齐,两边差一条就说明某行解析异常被吞了。

结构层校验只抽查前 50 条是刻意的取舍。全量遍历几十万节点在批量场景下会把总耗时翻倍,而结构错误几乎必然出现在头部,抽查足够发现系统性问题。真正需要逐条比对的是内容层,那属于抽样人工核对或者单独写回归脚本的范畴,不该塞进每次跑批的默认路径。

如果机器上有xmllint,加一道外部校验也很划算。它和 Python 解析器的实现不同,能覆盖一些标准库容忍但严格解析器不容忍的写法:

# 只做语法校验,不输出内容;返回非 0 表示文件有问题 find out -name "*.xml" -print0 | xargs -0 -n1 xmllint --noout

-print0配xargs -0是为了应付带空格的文件名,批量脚本里凡是遍历文件的地方都建议这么写,别等某天出现一个report 2024 03.txt才发现整条流水线断在空格上。

4. 特殊字符与编码:转义、BOM、空值三处最容易翻车的地方

前三章跑的是「干净数据」,现实里的 txt 从来不干净。业务字段里带&、备注里带尖括号、从旧系统导出的文件是 GBK、空值有时候是空字符串有时候是NULL有时候干脆缺列。这些问题不会让脚本立刻崩,但会让下游拿到「能解析、值不对」的 xml,排查成本比直接报错高得多。这一章把三类问题拆开处理。

4.1 五个必须处理的字符与 CDATA 的取舍

xml 里有五个字符在文本节点中具有特殊含义:&、<、>、"、'。前两个不转义会直接破坏文档结构,后三个在属性值里必须转义、在文本节点里可转可不转。好消息是只要用序列化器写节点,这五个字符都会被自动处理,你什么都不用做;坏消息是很多人会在某一步「优化」成字符串拼接,把保护全拆了。

# 错误示范:手工拼接,字段里一个 & 就让整个文件解析失败 bad = "<name>{}</name>".format(name) # 正确做法:交给序列化器,文本赋值即可 el = ET.SubElement(record_el, "name") el.text = name

值得强调的是el.text赋值和el.set(attr, value)都会自动转义,但如果你往el.text里塞的是已经转义过的&amp;,序列化器会再转一次变成&amp;amp;,下游解析出来就是字面量&amp;。这类双重转义问题在数据经过两套系统时特别常见,判断方法很简单:源数据里看到&amp;就说明上游已经转过一次,你要做的是还原而不是再转。

CDATA 是另一个常见诉求。有人觉得字段里有 html 片段,必须用<![CDATA[...]]>包起来,实际上标准库的 ElementTree 不支持直接输出 CDATA,需要手工拼或者换 lxml。多数情况下没必要:CDATA 的唯一作用是让<和&不用转义,而序列化器已经帮你转义了,下游解析出来的文本内容完全一致。只有当字段内容本身就是一段需要人眼阅读的 xml/html 片段、且下游明确要求原样保留时才值得折腾,否则纯属给自己加一道工序。

真正需要留意的是控制字符。从老设备或数据库导出的文本里偶尔混入\x00、\x0b这类不可见字符,它们在 xml 1.0 里是非法的,序列化时不报错,写出来解析时炸。稳妥做法是在赋值前统一过滤:

import re ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") def clean(text): return ILLEGAL.sub("", text) if text else text

这个正则放过的\x09(制表符)、\x0a(换行)、\x0d(回车)是 xml 允许的,别一起干掉,否则多行文本会被压成一行。

4.2 编码探测与 BOM:utf-8-sig 与 gb18030

编码问题最气人的地方在于它经常不报错。用 utf-8 去读一个 GBK 文件,Python 可能直接抛UnicodeDecodeError,这是好事;但如果文件里恰好全是 ASCII 字符,读进来一切正常,等某天出现一个中文人名,整批数据才开始崩。所以编码处理要分两步:先探测,探测不出来就报错让人指定,绝不静默降级。

def open_text(path, encoding="auto"): """按指定编码打开;auto 时先看 BOM,再按 utf-8 试读""" if encoding and encoding != "auto": return open(path, "r", encoding=encoding, newline="") with open(path, "rb") as f: head = f.read(4) if head.startswith(b"\xef\xbb\xbf"): return open(path, "r", encoding="utf-8-sig", newline="") # 吃掉 BOM return open(path, "r", encoding="utf-8", newline="")

newline=""这个参数容易被忽略。默认的通用换行模式会把\r\n统一成\n,对多数场景没问题,但如果某个字段本身要保留原始换行符,统一转换就丢了信息。批量转换工具我倾向于关掉自动转换,让数据原样进来,需要处理再显式处理。

BOM 是另一个高频坑。带 BOM 的 utf-8 文件用encoding="utf-8"打开,第一个字段的开头会多出一个不可见的\ufeff,肉眼完全看不出来,但用它做字典键、做字符串比较、写进 xml 都会出问题。判断方法:repr(row[0])看开头是不是'\ufeff'。解决就是utf-8-sig,它在读取时自动剥掉 BOM,写入时不加 BOM。

GBK 系文件没有 BOM 可依,靠utf-8试读失败来兜底是最简单的策略:

def read_with_fallback(path): for enc in ("utf-8-sig", "gb18030"): try: with open(path, "r", encoding=enc, newline="") as f: return f.read(), enc except UnicodeDecodeError: continue raise UnicodeDecodeError("无法确定编码,请用 --encoding 指定: " + str(path))

gb18030而不是gbk,是因为前者是超集,能覆盖更多生僻字,代价是偶尔会把非 GBK 文件「成功」解成乱码。所以这个兜底只该在明确知道数据源是中文旧系统时启用,不要无脑加进通用流程。探测顺序也别搞反:先 utf-8 再 gb18030,反过来会让很多 utf-8 文件被误判。

4.3 空值、缺字段与类型推断

空值处理是最容易被低估的一节。同一条记录,字段为空可能有三种含义:值确实为空、这个字段在源数据里不存在、这个字段本来该有值但采集失败了。三种情况在 xml 里应该有不同的表达,全塞成空字符串等于把信息丢了。

工具里的约定是:配置里标了required的字段为空直接报错;标了default的字段为空补默认值;既没标 required 也没标 default 的字段为空就写空节点。这个规则简单到不用记,但能覆盖九成场景。

类型转换则要防两个坑。第一个是浮点数:str(0.1 + 0.2)会得到0.30000000000000004,直接写进 xml 下游拿去算金额就出事了;第二个是科学计数法:很小的数会被str()成1e-05,很多只认定点小数的老系统直接报格式错误。

from datetime import datetime def convert(raw, spec): """按配置把原始字符串转成 xml 文本;空值走 default""" if raw == "" or raw is None: return spec.get("default", "") t = spec.get("type", "str") if t == "int": return str(int(raw)) if t == "float": # 定点格式化,保留 6 位后裁掉多余的 0,避免科学计数法和浮点尾差 return format(float(raw), ".6f").rstrip("0").rstrip(".") if t == "datetime": return datetime.strptime(raw, spec["format"]).isoformat() return raw

.6f这个精度不是随便定的。金融和计量场景一般到小数点后 4 到 6 位,再多就是噪声;rstrip("0").rstrip(".")是为了让1.500000变成1.5、2.000000变成2.再变成2,注意顺序不能反,先裁 0 再裁点,否则2.000000裁完点是2.,会留下一个非法的小数点。

datetime的format必须写在配置里而不是代码里,因为源数据的日期格式五花八门,%Y/%m/%d、%Y-%m-%d %H:%M:%S、甚至20240301都有。统一转成 ISO 8601 再写进 xml,下游排序和比较都不用再解析一次。

5. 避坑记录:五条从现场捡回来的报错

下面这五条都不是理论推演,是批量跑真实数据时踩出来的。每条按「现象 → 原因 → 解决」写,如果你正准备跑第一批全量数据,建议先对照着扫一眼自己的输入。

5.1 所有行只解析出一列,分隔符凭空消失

现象很典型:本地用记事本打开文件,肉眼能看到字段之间有明显间隔,脚本跑完却每条记录只有一个字段,值是一整行拼接后的字符串,sniff_delimiter返回None。

原因是分隔符在流转过程中被换了。从 Excel 或网页表格复制粘贴出来的「制表符」,经过邮件、聊天工具、某些编辑器保存之后,制表符\t会被展开成 4 个或 8 个空格;也有的是反过来,源文件本来是多个空格对齐,被某个工具压成了一个空格。两种情况下split("\t")都拿不到多列。

解决办法分两层。第一层是嗅探函数必须把空格列进候选,并且允许「连续空格视为一个分隔符」的宽松模式,用re.split(r" +", line.strip())处理。第二层是加一道列数断言:解析出的第一行如果只有 1 列而配置里的fields有 4 个,直接报错退出并打印前 200 个字符的repr,让人一眼看到实际的分隔符是什么。我在批量脚本里把这条断言做成了默认开启,宁可跑失败也不要跑出一堆看似正常的错数据。

5.2 Windows 上跑批,中文全变成问号或直接抛编码错

现象是在开发机上跑得好好的脚本,换到另一台机器(尤其是中文 Windows 服务器)上,日志里的中文变成???,严重时直接UnicodeEncodeError: 'gbk' codec can't encode character。

原因和转换逻辑无关,出在标准输出。Python 在 Windows 上的sys.stdout默认编码跟随系统区域设置,中文环境就是 GBK。脚本里任何一句print("处理完成: " + 中文路径)都会走这个编码,遇到 GBK 表示不了的字符就炸。更隐蔽的是,如果脚本把日志重定向到文件,文件也会按 GBK 写,用 utf-8 打开就是乱码。

解决办法是别依赖默认编码。写文件时显式encoding="utf-8",所有open()调用都带上;需要print调试信息时,在入口处加一句sys.stdout.reconfigure(encoding="utf-8", errors="replace")(3.7 起可用),或者干脆把日志写进文件而不是打在终端。errors="replace"是保命的,它让日志不会因为一个字符打不出来而中断整个跑批。

5.3 数值被写成科学计数法,下游报类型不匹配

现象是转换后的 xml 里出现<value>1e-05</value>或<value>3.0000000000000004</value>,本地看没人发现,下游做数值解析时报格式错误,或者算出来的金额差了几分钱。

原因是中间经过了float再str()。二进制浮点表示不了 0.1 这种十进制小数,累加之后尾差就出来了;小于 0.0001 的数str()时自动切科学计数法,这是 Python 的显示规则,不是 bug。

解决办法是在配置里给每个数值字段显式标type: "float",转换时走定点格式化,并且在格式化之前用Decimal(str(raw))收一道,把源字符串按十进制语义解析,避免float()引入误差。如果字段是金额,更好的做法是配置里加个scale: 2,转换时乘 100 存整数、写 xml 时再除以 100,全程不碰浮点。

5.4 xml 里出现重复的同名节点,下游只取到第一个

现象是下游反馈「某些记录的 name 全空了」,你把文件打开一看,结构是这样的:<record><payload><name>A</name></payload><payload><name>B</name></payload></record>,两个payload并列,下游用find("payload/name")只拿到 A,B 被忽略。

原因是映射代码里用了node.find(part) or ET.SubElement(node, part)这种简写。Element 的布尔值由子元素数量决定,一个已存在但暂时没有子节点的父节点会被判成假,于是又创建了一个同名节点。层级越深越容易触发,meta下面先建了id之后才建time的那一瞬间就是假的。

解决办法就是 2.2 里那段显式判空。另外在写出之前加一道自检:遍历每条记录的直接子节点名,如果出现重复就报错。这道检查成本很低,但能挡住一整类结构性问题。

5.5 大文件跑一半内存爆掉,机器卡死

现象是处理几万行的小文件一切正常,换成一个几十万行的日志文件,脚本跑到中途内存曲线直线上升,最后MemoryError或者机器开始换页卡死。

原因是两层叠加:f.readlines()一次性把整个文件读进内存;ElementTree 的整棵树也全在内存里,几十万个节点每个都是对象,开销远大于原始文本。一个 200MB 的文本能撑出 2GB 以上的内存占用。

解决办法是两头都改成流式。读用for line in f:逐行迭代,不要readlines();写用增量模式,每--chunk条记录就把当前这批子节点序列化追加到文件,然后清空。增量写的实现稍微绕一点,需要手工写<?xml ...?>声明和根节点开标签,中间追加序列化后的片段,最后补上根节点闭标签。如果下游不强制要求单文件,更省事的方案是按固定行数切片输出多个 xml,part-0001.xml、part-0002.xml,下游按序读取即可,改造成本几乎为零。

6. 进阶:把映射关系外置成模板,顺手加一道回归校验

字段映射写进 JSON 之后,下一个自然的需求是「多个数据源共用大部分字段,只差几列」。这时候复制多份配置会让维护变成灾难,改一个公共字段要改十几份文件。我的做法是给配置加一层extends,子配置只声明差异字段,加载时先继承父配置再覆盖,实现也就二十行。

模板字段含义常见取值
extends父模板路径,相对当前配置解析base/common.json
source.encoding源文件编码,auto走探测utf-8-sig/gb18030
fields[].index列下标,子模板可用-1表示追加到末尾0~n-1
fields[].required为空时是报错还是补默认值true/false
output.pretty是否缩进,交付给人看开,给机器读可关true/false
import json from pathlib import Path def load_config(path, _seen=None): """支持 extends 继承,子配置的 fields 按 path 覆盖父配置""" _seen = _seen or set() path = Path(path).resolve() if path in _seen: raise ValueError("配置循环继承: " + str(path)) _seen.add(path) cfg = json.loads(path.read_text(encoding="utf-8")) parent = cfg.pop("extends", None) if not parent: return cfg base = load_config(path.parent / parent, _seen) merged = dict(base) merged.update({k: v for k, v in cfg.items() if k != "fields"}) by_path = {f["path"]: f for f in base.get("fields", [])} for f in cfg.get("fields", []): by_path[f["path"]] = f # 同路径覆盖,新路径追加 merged["fields"] = list(by_path.values()) return merged

_seen这个集合是防循环继承的,A 继承 B、B 又继承 A 的情况在配置文件里比想象中容易出现,尤其是多人维护的时候。fields用path做键而不是用index,是因为同一个字段在不同数据源里列位置经常变,但节点路径是稳定的语义标识。

配套的回归校验也很关键。改完配置之后,拿一份已知正确输出的样本重新跑一遍,把结果和基线文件做结构化比对——不比字节,比「记录数、每条记录的子节点路径集合、关键字段的值」,这样缩进变化、属性顺序变化不会误报,真正的字段丢失和值错误一个都跑不掉。

def diff_structure(a_root, b_root): """比对两棵树的节点路径与文本,返回差异列表""" def walk(el, prefix=""): cur = prefix + "/" + el.tag yield cur, (el.text or "").strip() for c in el: yield from walk(c, cur) left, right = dict(walk(a_root)), dict(walk(b_root)) return [(k, left.get(k), right.get(k)) for k in set(left) | set(right) if left.get(k) != right.get(k)]

这套东西我用了很久,最大的价值不是发现 bug,而是让「改配置」这件事从「提心吊胆跑一遍看看」变成「跑完看差异列表是不是空的」。有一回一个同事把default从一个空字符串改成"0",结构比对立刻列出了两千多条差异,五分钟就回滚了;要是靠人工抽查,这个改动会安安静静躺在数据里,等下游算出一个全零的报表才被发现。从那以后我每次动映射配置,都强制先跑一遍基线比对再上线,哪怕只是改了一个缩进。希望这套思路能帮到你,源码包和示例配置在整理好的下载里,拿到后先跑自带的样例,再换自己的字段。

本文还有配套的精品资源,点击获取