数据处理脚本工程化:日志、配置、命令行与测试

数据处理脚本工程化:日志、配置、命令行与测试

摘要

Notebook 中能跑通的代码,未必能作为定时任务可靠运行。路径写死、异常被忽略、输入口径散落在代码中、失败时留下半份报表,都会让脚本难以交付。本文用订单日报贯穿配置、命令行、日志、校验、原子输出和自动化测试,把一次性处理逻辑整理为可复用的工程单元。

一、背景与问题

订单日报最初只需读取 CSV、筛选支付订单、按日期求和、写出结果。交给调度器后,文件路径随环境变化、上游增加新状态、金额格式错误被误算、任务中途失败截断旧报表等问题就会出现。

工程化不是先引入大框架,而是把边界写清楚:输入有哪些字段,如何读取配置,什么情况必须失败,成功后输出什么,以及如何验证重复运行的结果。

二、核心概念

能力 解决的问题 落地方式
配置 环境和业务参数分离 JSON 保存允许状态与金额下限
命令行 人工和调度器共用入口 --input、--output、--config
日志 还原任务执行情况 记录行数、有效订单数与错误类型
校验 阻止坏数据流入下游 检查表头、日期、金额精度、状态和唯一性
测试 修改后保护统计口径 临时目录验证成功与失败场景
原子输出 避免半成品被读取 临时文件写完后替换目标文件

金额使用整数"分"累计,避免浮点误差。输入以元表示时先用 Decimal 校验最多两位小数,再换算为分。日志不记录订单号等原始明细。

三、工作原理

text 复制代码
命令行解析 → 读取并检查配置 → 逐行验证 CSV → 按日聚合
     → 写入同目录临时文件 → 成功后替换目标 → 日志与测试验证

任务失败与"当天没有支付订单"是两种状态。前者不能发布新报表;后者可以按口径输出只有表头的结果,也可以按业务约定明确报错。

四、实战示例

仅需 Python 3.10+ 标准库。将下列代码保存为 report.py,再将测试代码保存为 test_report.py。两段各自是完整文件。

1. 命令行日报工具

python 复制代码
import argparse
import csv
import json
import logging
import os
import tempfile
from collections import defaultdict
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path

LOG = logging.getLogger("orders_report")
REQUIRED = {"order_id", "order_date", "status", "amount_yuan"}
OUTPUT_COLUMNS = ["order_date", "order_count", "gmv_cents"]


def load_config(path: Path) -> dict:
    config = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(config, dict):
        raise ValueError("配置必须是 JSON 对象")
    allowed = config.get("allowed_statuses")
    minimum = config.get("min_paid_amount_cents")
    if (not isinstance(allowed, list) or not allowed
            or not all(isinstance(x, str) and x for x in allowed)
            or "paid" not in allowed):
        raise ValueError("allowed_statuses 必须是非空列表并包含 paid")
    if type(minimum) is not int or minimum < 0:
        raise ValueError("min_paid_amount_cents 必须是非负整数")
    return {"allowed_statuses": set(allowed), "minimum": minimum}


def parse_cents(value: str) -> int:
    try:
        amount = Decimal(value)
    except (InvalidOperation, TypeError):
        raise ValueError("金额不是有效数字") from None
    cents = amount * 100
    if (not cents.is_finite() or amount.as_tuple().exponent < -2
            or cents != cents.to_integral_value()):
        raise ValueError("金额必须是有限数字且最多两位小数")
    return int(cents)


def write_atomic(path: Path, rows: list[dict]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp_path = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w", encoding="utf-8", newline="", suffix=".csv",
            prefix=".orders-", dir=path.parent, delete=False,
        ) as handle:
            temp_path = Path(handle.name)
            writer = csv.DictWriter(handle, fieldnames=OUTPUT_COLUMNS)
            writer.writeheader()
            writer.writerows(rows)
        os.replace(temp_path, path)
    finally:
        if temp_path is not None:
            temp_path.unlink(missing_ok=True)


def run(input_path: Path, output_path: Path, config_path: Path) -> tuple[int, int]:
    if input_path.resolve() == output_path.resolve():
        raise ValueError("输入和输出不能指向同一个文件")
    config = load_config(config_path)
    daily = defaultdict(lambda: {"count": 0, "cents": 0})
    seen_ids = set()
    input_rows = paid_orders = 0
    with input_path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None or not REQUIRED.issubset(reader.fieldnames):
            raise ValueError("缺少必需的 CSV 表头")
        for row_number, row in enumerate(reader, start=2):
            input_rows += 1
            if None in row or any(row.get(key) is None for key in REQUIRED):
                raise ValueError(f"第 {row_number} 行列数不符")
            order_id = row["order_id"].strip()
            if not order_id or order_id in seen_ids:
                raise ValueError(f"第 {row_number} 行订单号为空或重复")
            seen_ids.add(order_id)
            try:
                day = date.fromisoformat(row["order_date"].strip()).isoformat()
            except ValueError:
                raise ValueError(f"第 {row_number} 行日期无效") from None
            status = row["status"].strip()
            if status not in config["allowed_statuses"]:
                raise ValueError(f"第 {row_number} 行订单状态无效")
            try:
                cents = parse_cents(row["amount_yuan"])
            except ValueError as exc:
                raise ValueError(f"第 {row_number} 行:{exc}") from exc
            if cents < 0:
                raise ValueError(f"第 {row_number} 行金额不能为负")
            if status == "paid":
                if cents < config["minimum"]:
                    raise ValueError(f"第 {row_number} 行支付金额低于配置下限")
                daily[day]["count"] += 1
                daily[day]["cents"] += cents
                paid_orders += 1

    rows = [
        {"order_date": day, "order_count": item["count"],
         "gmv_cents": item["cents"]}
        for day, item in sorted(daily.items())
    ]
    write_atomic(output_path, rows)
    LOG.info("completed input_rows=%d paid_orders=%d days=%d",
             input_rows, paid_orders, len(rows))
    return input_rows, paid_orders


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description="按日汇总已支付订单")
    parser.add_argument("--input", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--config", type=Path, required=True)
    parser.add_argument("--log-level", choices=["INFO", "DEBUG"], default="INFO")
    args = parser.parse_args(argv)
    logging.basicConfig(level=getattr(logging, args.log_level),
                        format="%(levelname)s %(name)s %(message)s")
    try:
        run(args.input, args.output, args.config)
    except (OSError, ValueError, UnicodeError, json.JSONDecodeError) as exc:
        LOG.error("failed: %s", exc)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

run() 是可独立测试的业务入口,main() 负责命令行和退出码。完整校验输入后才写目标文件,因此解析到最后一行才失败,也不会覆盖已有报表。

2. 配置与输入样例

text 复制代码
config.json:
{"allowed_statuses": ["paid", "refunded", "cancelled"], "min_paid_amount_cents": 0}

orders.csv:
order_id,order_date,status,amount_yuan
O001,2026-04-01,paid,120.50
O002,2026-04-01,paid,50.00
O003,2026-04-02,refunded,20.00
O004,2026-04-02,paid,99.00

运行命令:

text 复制代码
python report.py --input orders.csv --output daily.csv --config config.json
python report.py --help

输出数据行为 2026-04-01,2,17050 和 2026-04-02,1,9900,GMV 单位为分。本例约定退款不计入支付 GMV;跨日退款和支付撤销需在业务口径中另行定义。

3. 自动化测试

python 复制代码
import csv
import json
import tempfile
import unittest
from pathlib import Path

from report import main, run


class ReportTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        root = Path(self.temp.name)
        self.source, self.output = root / "orders.csv", root / "daily.csv"
        self.config = root / "config.json"
        self.config.write_text(json.dumps({
            "allowed_statuses": ["paid", "refunded", "cancelled"],
            "min_paid_amount_cents": 0,
        }), encoding="utf-8")
        self.source.write_text(
            "order_id,order_date,status,amount_yuan\n"
            "O001,2026-04-01,paid,120.50\n"
            "O002,2026-04-01,paid,50.00\n"
            "O003,2026-04-02,refunded,20.00\n"
            "O004,2026-04-02,paid,99.00\n", encoding="utf-8")

    def test_report_and_repeatability(self):
        self.assertEqual(run(self.source, self.output, self.config), (4, 3))
        with self.output.open(encoding="utf-8", newline="") as handle:
            rows = list(csv.DictReader(handle))
        self.assertEqual(rows, [
            {"order_date": "2026-04-01", "order_count": "2", "gmv_cents": "17050"},
            {"order_date": "2026-04-02", "order_count": "1", "gmv_cents": "9900"},
        ])
        before = self.output.read_bytes()
        run(self.source, self.output, self.config)
        self.assertEqual(before, self.output.read_bytes())

    def test_failure_keeps_previous_output(self):
        self.output.write_text("previous result\n", encoding="utf-8")
        with self.source.open("a", encoding="utf-8") as handle:
            handle.write("O001,2026-04-03,paid,15.00\n")
        code = main(["--input", str(self.source), "--output", str(self.output),
                     "--config", str(self.config)])
        self.assertEqual(code, 1)
        self.assertEqual(self.output.read_text(encoding="utf-8"), "previous result\n")

    def test_rejects_wrong_precision(self):
        for invalid_amount in ["15.001", "15.000"]:
            with self.subTest(amount=invalid_amount):
                invalid_source = Path(self.temp.name) / "invalid.csv"
                invalid_source.write_text(
                    self.source.read_text(encoding="utf-8")
                    + f"O005,2026-04-03,paid,{invalid_amount}\n",
                    encoding="utf-8",
                )
                with self.assertRaisesRegex(ValueError, "最多两位小数"):
                    run(invalid_source, self.output, self.config)
        self.assertFalse(self.output.exists())


if __name__ == "__main__":
    unittest.main()
text 复制代码
python -m unittest -v test_report.py

测试覆盖准确聚合、重复运行结果稳定、失败保留旧产物和非法金额拒绝。还可补充缺列、未知状态、坏日期与输入输出同路径等用例。

五、常见问题与实践建议

1. 配置不校验

配置外置不等于可信。应校验结构、类型和取值范围,并记录配置版本;密钥不应放进 JSON 或日志。

2. 捕获异常后返回成功

失败必须返回非零退出码供调度器检测。对已知数据问题给出行号;未知程序错误保留堆栈,不要 except Exception: pass。

3. 输出被下游读到一半

同目录临时文件写完并关闭后再替换。网络文件系统、跨卷操作和断电持久性仍需单独验证。

4. 唯一键集合太大

全局去重集合占用 O(n) 内存。超大数据应使用数据库唯一索引、外部排序或有明确分区边界的去重方案。

5. 日志泄露业务明细

日志记录批次标识、文件名、行数、规则和错误行号即可。敏感数据应控制权限、采集范围和保留期限。

六、进阶思考

1. 幂等与可追溯

同一输入重复运行应生成一致结果;若源数据会回填,还应把数据快照、配置版本和代码版本写入任务元数据,解释产物变化。

2. 扩展为模块化流水线

职责增加时再拆分 io.py、validation.py、metrics.py、cli.py 和 tests/。保留不依赖命令行的 run() 入口,便于调度器和测试复用。

3. 大文件处理

结合前文分块读取增量聚合;但全局唯一性不能把每块的去重数简单相加。性能优化必须沿用同一套测试和对账口径。

结论

数据处理脚本的工程化起点,是让参数可校验、入口可调用、日志可追踪、失败可感知、输出不残缺、代码可自动验证。下一篇将把读取、质量检查、聚合与报告输出串成可复用的数据分析流水线。

参考资料

相关推荐
正在走向自律2 小时前
数据集成产品的技术底座:从日志解析原理到 KFS 同步链路搭建(含建表与增删改查完整代码)
数据库·oracle·信创·数据集成·国产数据库·kingbasees·全量+增量同步
程序员塔塔喵2 小时前
【MySQL篇|第七篇】MySQL事务
数据库·mysql·mysql事务
2601_962885723 小时前
如何用 Python 把 K 线重采样成任意周期?
开发语言·python
独隅3 小时前
Claude API 实战教程:从入门到完整项目
android·java·数据库·claude·claude code
yyuuuzz3 小时前
跨境站点日志时区混乱的排查记录
运维·服务器·网络·云计算·aws·1024程序员节
_孤傲_3 小时前
快速掌握 MySQL 语法
数据库·后端·mysql
Wang's Blog3 小时前
Java 项目实战: 交友项目 - 圈子功能实现:总结
java·开发语言·交友
下一站春风里4 小时前
位运算完整学习笔记:按位与(&)+ 异或(^)
开发语言·c++·算法
数字新视界5 小时前
一体化机柜在数据中心运维中的创新应用与优势分析
大数据·服务器·数据库·动环监控系统·动力与环境监控系统