1. 车载日志文件格式识别与解析入口构建
问题定位:来源混杂时,解析器无法自动分派
CTCS车载设备通常同时输出多类日志:ATP主控的二进制记录文件、事件文本日志、DMI操作日志,甚至BTM/应答器相关记录。这些文件在扩展名、命名规则上往往不可靠——现场常见.dat、.log、.txt混用,甚至同一扩展名下并存二进制与文本两种内容。若解析入口只按扩展名分派,必然出现“用文本解析器读二进制”或“用二进制解析器读文本”的错配。
因此解析入口的第一道工序不是解析内容,而是基于文件头特征做内容级识别,再据此分派到对应解析器。本节构建一个可运行的识别与分派入口:读取文件前若干字节,按特征表匹配,输出文件类型与解析器标识;无法识别时进入待定队列而非静默失败。
文件头特征设计
识别依据应选择“稳定、短、区分度高”的字节特征。以下为教学示例中约定的特征表(实际项目中应以设备文档或样本统计为准):
| 类型标识 | 特征位置 | 特征内容 | 分派解析器 |
|---|---|---|---|
| ATP_BIN | 偏移 0,4 字节 | 魔数 0x41 0x54 0x50 0x42("ATPB") | parse_atp_binary |
| EVT_TEXT | 偏移 0,行首 | 以 [EVT] 开头 | parse_event_text |
| DMI_OP | 偏移 0,行首 | 以 DMI| 开头 | parse_dmi_op |
| BTM_REC | 偏移 0,4 字节 | 魔数 0x42 0x54 0x4D 0x52("BTMR") | parse_btm_record |
识别顺序上,先判二进制魔数(长度固定、误判率低),再判文本行首标记。文本判定需容忍BOM与前后空白,避免因编码差异漏判。
识别与分派入口的实现
入口函数只做三件事:读头部、按特征表匹配、返回分派结果。解析器本身不在此处实现,入口只负责“认路”。以下代码可直接运行,示例文件由脚本生成,便于验证。
import os
import struct
# 特征表:类型标识 -> (匹配函数, 解析器名)
MAGIC_TABLE = [
(b"ATPB", "ATP_BIN", "parse_atp_binary"),
(b"BTMR", "BTM_REC", "parse_btm_record"),
]
TEXT_MARKERS = [
(b"[EVT]", "EVT_TEXT", "parse_event_text"),
(b"DMI|", "DMI_OP", "parse_dmi_op"),
]
def read_head(path, n=64):
with open(path, "rb") as f:
return f.read(n)
def strip_bom(head):
if head.startswith(b"\xef\xbb\xbf"):
return head[3:]
return head
def identify(path):
head = read_head(path)
if not head:
return {"file_type": "EMPTY", "parser": None,
"confidence": "none", "raw_head": head}
# 1) 二进制魔数优先
for magic, ftype, parser in MAGIC_TABLE:
if head.startswith(magic):
return {"file_type": ftype, "parser": parser,
"confidence": "magic", "raw_head": head}
# 2) 文本行首标记(容忍 BOM 与空白)
text_head = strip_bom(head).lstrip()
for marker, ftype, parser in TEXT_MARKERS:
if text_head.startswith(marker):
return {"file_type": ftype, "parser": parser,
"confidence": "marker", "raw_head": head}
return {"file_type": "UNKNOWN", "parser": None,
"confidence": "none", "raw_head": head}
def dispatch(paths):
result = {}
for p in paths:
result[p] = identify(p)
return result
用示例文件验证分派结果
构造四个最小样本文件,覆盖二进制魔数、文本标记与无法识别三种情况,验证入口是否按预期分派:
def make_samples(d="samples"):
os.makedirs(d, exist_ok=True)
files = {
"atp.dat": b"ATPB" + struct.pack("<I", 1) + b"\x00" * 20,
"btm.rec": b"BTMR" + b"\x01\x02\x03\x04" + b"\x00" * 16,
"evt.log": b"[EVT] 2024-09-12 08:00:01 mode=C2\n",
"dmi.txt": b"DMI|2024-09-12 08:00:02|btn=确认\n",
"bad.bin": b"\x99\x88\x77\x66" + b"\x00" * 20,
}
for name, data in files.items():
with open(os.path.join(d, name), "wb") as f:
f.write(data)
return [os.path.join(d, n) for n in files]
for path, info in dispatch(make_samples()).items():
print(os.path.basename(path), "->", info["file_type"],
"|", info["parser"], "|", info["confidence"])
预期输出(由上述输入直接推出,非实测):
atp.dat -> ATP_BIN | parse_atp_binary | magic
btm.rec -> BTM_REC | parse_btm_record | magic
evt.log -> EVT_TEXT | parse_event_text | marker
dmi.txt -> DMI_OP | parse_dmi_op | marker
bad.bin -> UNKNOWN | None | none
识别顺序不可随意调换:若先做文本标记匹配,二进制文件头部恰好含可打印字符时可能被误判为文本。先魔数、后行首标记,是降低误判率的关键约束。
结果判读与边界处理
- confidence 字段:
magic表示命中固定字节,可信度最高;marker表示命中行首文本标记,需注意编码与BOM;none表示未识别,必须进入待定队列。 - 空文件:单独标记为
EMPTY,与UNKNOWN区分,便于统计现场空日志比例。 - 头部不足 64 字节:
read_head返回实际长度,匹配逻辑不受影响;但若魔数跨越读取边界,应增大读取长度而非截断判断。 - 扩展名不参与判定:入口完全忽略文件名后缀,避免
.dat混装两类内容时错配。
特征表必须与设备实际输出一致。若现场出现新固件版本改变了魔数或行首标记,入口会将其归入
UNKNOWN——这是正确行为,不应通过放宽匹配(如只匹配前两字节)来“消灭”待定项,否则会引入静默错配。可直接使用的分派判定清单
| 检查项 | 通过条件 | 不通过时的处理 |
|---|---|---|
| 魔数匹配优先于文本标记 | 代码中魔数循环在文本循环之前 | 调整顺序,补充回归样本 |
| BOM 与空白已剥离 | 带 BOM 的 [EVT] 文件仍识别为 EVT_TEXT | 检查 strip_bom 与 lstrip |
| 未知文件不静默丢弃 | 返回 UNKNOWN 且 parser 为 None | 补待定队列与告警 |
| 空文件单独分类 | 返回 EMPTY | 区分统计口径 |
| 扩展名不参与判定 | 改名后识别结果不变 | 移除任何后缀判断分支 |
完成以上入口后,后续解析器只需按 parser 字段注册即可接入,新增日志类型时仅扩展特征表,不改动分派主逻辑。