Python 脚本工程化:从"能跑就行"到"生产级代码"

0 阅读7分钟

很多人学 Python 的第一步,是写一个几十行的脚本——跑通了,任务完成,皆大欢喜。但当脚本越来越长、团队越来越大、需求越来越复杂,那种"能跑就行"的写法就开始还债了:改一行代码牵一发动全身,日志全靠 print,配置写死在代码里,别人根本看不懂你在做什么。

脚本工程化,就是把这种"凑合能用"的脚本,改造成可维护、可扩展、可协作的工程级代码。不是玄学,而是一套有章可循的方法论。


🗂️ 一、工程化的灵魂:合理的项目结构

一个好的项目结构,是工程化的第一道门。目录组织得清晰,后续所有工作都会顺畅很多。

现代 Python 项目推荐的标准结构如下:

my_project/
├── src/
│   └── my_project/
│       ├── __init__.py
│       ├── core.py          # 核心业务逻辑
│       ├── utils.py         # 工具函数
│       └── config.py        # 配置管理
├── tests/
│   ├── test_core.py
│   └── test_utils.py
├── scripts/
│   └── run.py               # 入口脚本
├── pyproject.toml           # 项目元数据与依赖(现代标准)
├── README.md
└── .env                     # 环境变量(不提交到 git)

几个关键点:

  • src/ 布局:把源码放在 src/ 子目录,避免直接导入本地代码时出现奇怪的路径问题,是 2024 年最推荐的做法
  • pyproject.toml:取代老旧的 setup.py,统一管理依赖、版本、构建工具(配合 Poetry 或 Hatch 使用)
  • tests/ 独立存放:测试代码与业务代码分离,便于 CI/CD 流水线单独运行

⚙️ 二、核心概念逐一拆解

工程化涉及多个维度,下面逐一说清楚。


2.1 模块化设计(Modularity)

模块化的核心思想是单一职责——每个文件、每个函数,只做一件事。

# ❌ 反例:所有逻辑堆在一起
def run():
    import requests
    import json
    url = "https://api.example.com/data"
    r = requests.get(url)
    data = json.loads(r.text)
    for item in data:
        print(item['name'].upper())

# ✅ 正例:职责分离
# fetcher.py
def fetch_data(url: str) -> list:
    import requests
    response = requests.get(url)
    response.raise_for_status()
    return response.json()

# processor.py
def process_items(data: list) -> list:
    return [item['name'].upper() for item in data]

# main.py
from fetcher import fetch_data
from processor import process_items

def main():
    data = fetch_data("https://api.example.com/data")
    results = process_items(data)
    for r in results:
        print(r)

这样改造之后,fetch_dataprocess_items 都可以单独测试,互不干扰。


2.2 命令行接口(CLI)与参数管理

一个工程化的脚本,不应该靠"改代码里的变量"来切换行为,而应该通过命令行参数控制。Python 内置的 argparse 是标准选择。

# cli.py
import argparse

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="数据处理工具 v1.0",
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "--input", "-i",
        type=str,
        required=True,
        help="输入文件路径"
    )
    parser.add_argument(
        "--output", "-o",
        type=str,
        default="output.csv",
        help="输出文件路径(默认:output.csv)"
    )
    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="开启详细日志"
    )
    parser.add_argument(
        "--mode",
        choices=["fast", "accurate"],
        default="fast",
        help="处理模式"
    )
    return parser

if __name__ == "__main__":
    parser = build_parser()
    args = parser.parse_args()
    print(f"输入: {args.input}, 模式: {args.mode}")

运行效果:

$ python cli.py --input data.csv --mode accurate --verbose
$ python cli.py --help   # 自动生成帮助文档

2.3 配置管理(Configuration Management)

硬编码是工程化的大忌。配置应该从代码中剥离出来,支持环境变量配置文件命令行参数三层覆盖。

# config.py
import os
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class AppConfig:
    # 数据库配置
    db_host: str = field(default_factory=lambda: os.getenv("DB_HOST", "localhost"))
    db_port: int = field(default_factory=lambda: int(os.getenv("DB_PORT", "5432")))
    db_name: str = field(default_factory=lambda: os.getenv("DB_NAME", "mydb"))

    # API 配置
    api_key: Optional[str] = field(default_factory=lambda: os.getenv("API_KEY"))
    api_timeout: int = 30

    # 运行配置
    debug: bool = field(default_factory=lambda: os.getenv("DEBUG", "false").lower() == "true")
    log_level: str = field(default_factory=lambda: os.getenv("LOG_LEVEL", "INFO"))

    def validate(self):
        if not self.api_key:
            raise ValueError("API_KEY 环境变量未设置,请检查 .env 文件")
        return self

# 使用方式
config = AppConfig().validate()
print(f"连接数据库: {config.db_host}:{config.db_port}/{config.db_name}")

配合 .env 文件和 python-dotenv 库:

# .env 文件(不要提交到 git!)
DB_HOST=production-db.example.com
DB_PORT=5432
API_KEY=sk-xxxxxxxxxxxxxxxx
DEBUG=false
LOG_LEVEL=WARNING
# 在入口处加载 .env
from dotenv import load_dotenv
load_dotenv()  # 自动读取 .env 文件

2.4 日志系统(Logging)

print 调试是学生时代的习惯,生产代码必须用 logging。它能控制输出级别、写入文件、格式化时间戳,还能在不改代码的情况下静默所有调试信息。(logging 官方文档)

# logger.py
import logging
import sys
from pathlib import Path

def setup_logger(name: str, log_level: str = "INFO", log_file: str = None) -> logging.Logger:
    """
    创建一个标准化的 logger
    - 同时输出到控制台和文件
    - 格式包含时间、级别、模块名
    """
    logger = logging.getLogger(name)
    logger.setLevel(getattr(logging, log_level.upper()))

    # 统一格式
    formatter = logging.Formatter(
        fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S"
    )

    # 控制台 handler
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)

    # 文件 handler(可选)
    if log_file:
        Path(log_file).parent.mkdir(parents=True, exist_ok=True)
        file_handler = logging.FileHandler(log_file, encoding="utf-8")
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)

    return logger

# 在各模块中使用
logger = setup_logger(__name__, log_level="DEBUG", log_file="logs/app.log")

def process_data(data: list) -> list:
    logger.info(f"开始处理数据,共 {len(data)} 条记录")
    results = []
    for i, item in enumerate(data):
        try:
            result = item['value'] * 2
            results.append(result)
        except KeyError as e:
            logger.warning(f"第 {i} 条记录缺少字段: {e},已跳过")
        except Exception as e:
            logger.error(f"处理第 {i} 条记录时发生未知错误: {e}", exc_info=True)
    logger.info(f"处理完成,成功 {len(results)} 条")
    return results

输出效果:

2026-07-21 07:41:00 | INFO     | processor:12 | 开始处理数据,共 100 条记录
2026-07-21 07:41:00 | WARNING  | processor:19 | 第 5 条记录缺少字段: 'value',已跳过
2026-07-21 07:41:01 | INFO     | processor:22 | 处理完成,成功 99 条

2.5 异常处理与健壮性

工程化代码不能"一遇到错误就崩",要有预期内的优雅降级

# exceptions.py —— 自定义异常体系
class AppError(Exception):
    """项目基础异常"""
    pass

class DataFetchError(AppError):
    """数据获取失败"""
    pass

class DataValidationError(AppError):
    """数据校验失败"""
    pass

# 在业务代码中使用
import requests
from exceptions import DataFetchError

def fetch_with_retry(url: str, max_retries: int = 3) -> dict:
    for attempt in range(1, max_retries + 1):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            return response.json()
        except requests.Timeout:
            logger.warning(f"第 {attempt} 次请求超时,URL: {url}")
        except requests.HTTPError as e:
            raise DataFetchError(f"HTTP 错误 {e.response.status_code}: {url}") from e
        except requests.ConnectionError:
            if attempt == max_retries:
                raise DataFetchError(f"连接失败,已重试 {max_retries} 次: {url}")
    return {}

2.6 依赖管理与打包

现代 Python 项目用 pyproject.toml 统一管理,告别混乱的 requirements.txt

# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-data-tool"
version = "1.2.0"
description = "一个数据处理工具"
requires-python = ">=3.10"
dependencies = [
    "requests>=2.28.0",
    "python-dotenv>=1.0.0",
    "pandas>=2.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "black",
    "ruff",
    "mypy",
]

[project.scripts]
# 安装后可直接在命令行调用 `my-tool`
my-tool = "my_project.cli:main"

🔄 三、工程化全貌:概念关系图

export_dk8vo.png


📦 四、完整工程化示例

把上面所有概念串联成一个完整的可运行项目:

# src/my_project/main.py —— 项目总入口

import sys
from dotenv import load_dotenv

# 第一步:加载环境变量
load_dotenv()

from .config import AppConfig
from .logger import setup_logger
from .cli import build_parser
from .fetcher import fetch_with_retry
from .processor import process_data
from .exporter import export_to_csv
from .exceptions import AppError

def main():
    # 解析命令行参数
    parser = build_parser()
    args = parser.parse_args()

    # 初始化配置和日志
    config = AppConfig().validate()
    logger = setup_logger(
        "my_project",
        log_level="DEBUG" if args.verbose else config.log_level,
        log_file="logs/app.log"
    )

    logger.info("=" * 50)
    logger.info(f"任务启动 | 模式: {args.mode} | 输入: {args.input}")

    try:
        # 核心流程
        raw_data = fetch_with_retry(args.input)
        processed = process_data(raw_data, mode=args.mode)
        export_to_csv(processed, args.output)
        logger.info(f"✅ 任务完成,结果已写入: {args.output}")

    except AppError as e:
        # 业务异常:友好提示,正常退出
        logger.error(f"业务错误: {e}")
        sys.exit(1)
    except Exception as e:
        # 未知异常:打印完整堆栈
        logger.critical(f"未知错误,程序异常退出", exc_info=True)
        sys.exit(2)

if __name__ == "__main__":
    main()

💡 五、核心概念速查表

概念解决什么问题推荐工具/方式
项目结构代码组织混乱src/ 布局 + 标准目录
模块化函数职责不清单一职责原则,按功能拆文件
CLI 参数配置写死在代码里argparse / click / typer
配置管理敏感信息泄露、环境切换麻烦.env + dataclass + 环境变量
日志系统print 无法控制、无法存档logging 标准库
异常处理程序崩溃无提示自定义异常体系 + 重试机制
依赖管理环境不可复现pyproject.toml + Poetry/Hatch
测试改了代码不知道有没有破坏pytest + 单元测试

结语

脚本工程化的本质,不是让代码变得更复杂,而是让它更容易被人理解、被机器执行、被时间考验。从一个 50 行的脚本到一个有结构的小项目,中间的距离其实并不远——无非是把"只有自己看得懂"的代码,改造成"三个月后的自己也看得懂"的代码。

这套方法论没有银弹,但每一个概念背后都有真实的痛点驱动。项目结构来自于"找不到文件"的抓狂,日志系统来自于"上线后出了 bug 却无从排查"的绝望,配置管理来自于"把密钥提交到 GitHub"的冷汗。理解了这些,工程化就不再是负担,而是一种自然而然的编程习惯。


参考资料