1. 项目概述:这不是一篇“Python技巧合集”,而是一份资深开发者用十年踩坑经验写就的「反模式清单」

你有没有过这种感觉:代码能跑通,函数能返回正确结果,单元测试全绿,但一到线上环境就频繁报错;或者团队里新同事接手你的模块时,盯着某段逻辑反复挠头,最后不得不重写一遍?我做过六年Python后端架构,带过三个百人级技术团队,也给上百个创业公司做过代码审计——最常听到的一句话不是“这个功能怎么实现”,而是“这段代码为什么这么写”。标题里说的“Most Python Programmers Don’t do”,指的不是那些教科书里没写的冷门语法,而是 绝大多数人在日常编码中系统性忽略、主动回避、甚至刻意绕开的五类基础实践 。它们不难,不需要掌握asyncpg底层协议,也不依赖PyTorch最新版本特性;但恰恰因为“太基础”,反而成了技术债最密集的温床。比如,92%的Django项目从不配置 DEBUG=False 下的 ALLOWED_HOSTS 白名单校验逻辑,导致本地调试一切正常,部署后400错误频发却查不出原因;再比如,87%的Flask API服务在处理JSON请求时,直接 request.json 取值却不做 None 判空,结果上游传了个空body,后端直接抛 AttributeError 而非返回400 Bad Request。这些不是“不会”,而是“不做”——就像老司机知道该系安全带,但赶时间时总下意识忽略。本文要拆解的,就是这五类被集体忽视的“安全带动作”:类型标注的工程化落地、异常边界的显式声明、资源生命周期的确定性管理、配置与代码的物理隔离、以及日志上下文的结构化注入。它们不炫技,但每一条都对应着一次线上P0事故的根因。适合所有写Python超过6个月的人——无论你是刚转行的数据分析师,还是带团队的Tech Lead,只要你还用 print() 调试、还靠 try/except 裸包全局、还在 .env 文件里硬编码数据库密码,这篇文章就值得你逐行对照。

2. 核心设计思路:为什么这五件事被系统性忽略?真相是“成本感知错位”

2.1 类型标注:不是为了IDE提示,而是为接口契约埋下可验证的锚点

很多人把 def process_user(user_id: int) -> User: 当成IDE的装饰品,鼠标悬停时多一行提示而已。但我在审计一个支付对账系统时发现,核心函数 reconcile_transaction(txn_id: str, amount: float) 的参数类型标注是 str float ,而实际调用方传入的是 uuid.UUID 对象和 Decimal('12.34') 。Pydantic自动做了类型转换,表面看没问题,直到某天上游系统升级,开始传 bytes 类型的txn_id——函数内部 txn_id.upper() 直接崩溃。问题根源不在类型标注本身,而在于 标注未与运行时校验绑定 。真正的工程化落地必须包含三层:

  • 静态层 :用 mypy 做CI阶段检查,禁用 # type: ignore 随意绕过(我们团队规定每个ignore注释必须关联Jira编号);
  • 运行时层 :在FastAPI或Pydantic模型中强制启用 strict=True ,拒绝隐式转换;
  • 契约层 :将类型定义导出为OpenAPI Schema,供前端自动生成TypeScript接口,避免前后端字段类型漂移。

提示:别用 typing.Any 当万金油。我见过最离谱的案例是 def handle_event(data: Any) -> Any: ,结果这个函数成了整个微服务的“黑洞入口”,所有异常都在这里被吞掉,日志只留一句 event processed

2.2 异常边界: try/except Exception: 不是容错,是故障扩散器

新手常把 try/except 当创可贴,哪里报错贴哪里。但资深工程师知道, 异常处理的本质是控制故障传播半径 。举个真实案例:某电商库存服务有个 decrease_stock(sku: str, quantity: int) 函数,内部调用Redis执行 DECRBY 。原代码是:

try:
    redis_client.decrby(f"stock:{sku}", quantity)
except Exception as e:
    logger.error(f"Stock decrease failed for {sku}: {e}")
    return False

表面看很稳妥,但问题在于: Exception 捕获了 ConnectionError (Redis宕机)、 ResponseError (key不存在)、 ValueError (quantity为负)三类完全不同的问题。当Redis集群网络抖动时,本该降级为“库存预占”的逻辑,因错误被统一吞掉而直接返回False,导致用户下单失败率飙升300%。正确的做法是分层捕获:

try:
    result = redis_client.decrby(f"stock:{sku}", quantity)
except ConnectionError:
    # 降级策略:切换到本地缓存或返回兜底库存
    return fallback_decrease_stock(sku, quantity)
except ResponseError as e:
    if "key not found" in str(e):
        # 业务异常:SKU不存在,需告警而非静默失败
        alert_sku_not_found(sku)
        raise StockNotFoundError(sku) from e
    raise
except ValueError:
    # 参数校验失败,属于开发期bug,不应在线上捕获
    raise

注意:自定义异常类必须继承 Exception 而非 BaseException ,且命名需带 Error 后缀(如 StockNotFoundError ),这是PEP 257明确要求的文档规范,也是团队协作时快速识别异常性质的关键信号。

2.3 资源生命周期: with open() 之后,你真的关掉了文件吗?

with 语句被宣传为“自动关闭资源”的银弹,但现实远比文档复杂。我在重构一个日志分析工具时发现,核心函数 parse_log_file(filepath: str) 内部有:

with open(filepath, "r") as f:
    for line in f:
        process_line(line)
# 此处f已关闭,但process_line()内部又打开了新文件
def process_line(line: str):
    with open("/tmp/cache.json", "w") as cache:
        json.dump(extract_data(line), cache)

问题在于: process_line() 被循环调用上千次,每次打开 /tmp/cache.json 都会创建新文件句柄。Linux默认单进程文件描述符上限是1024,当处理第1025行日志时, OSError: [Errno 24] Too many open files 直接中断整个流程。根本原因在于 资源释放时机与业务逻辑耦合失当 。解决方案不是简单加 finally ,而是采用 资源池模式

class CacheManager:
    def __init__(self, filepath: str):
        self.filepath = filepath
        self._file = None
    
    def __enter__(self):
        self._file = open(self.filepath, "w")
        return self
    
    def write(self, data):
        self._file.write(json.dumps(data))
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if self._file:
            self._file.close()
            self._file = None

# 使用时
with CacheManager("/tmp/cache.json") as cache:
    for line in f:
        cache.write(extract_data(line))

这样整个循环只占用1个文件句柄,且 __exit__ 确保异常时也能释放。

2.4 配置与代码分离: .env 不是配置终点,而是环境变量注入的起点

把数据库密码写进 .env 文件,然后用 python-decouple 读取,这算“配置分离”吗?不算。真正的分离必须满足 物理隔离+环境感知+动态加载 三原则。我们曾接手一个遗留系统,其 .env 文件内容如下:

DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=myapp
DB_USER=admin
DB_PASSWORD=secret123

问题在于: DB_HOST 在生产环境必须是内网VIP,而 DB_PASSWORD 在K8s中应通过Secret挂载。当运维同学手动修改 .env 并重启Pod时, DB_HOST 被误写成 localhost ,导致所有Pod连接宿主机PostgreSQL而非集群,引发雪崩。正确方案是分三级配置:

  • 基础层 (代码内建): config/base.py 定义所有配置项的默认值和类型,如 DB_PORT: int = 5432
  • 环境层 (部署时注入):K8s Deployment中通过 envFrom 加载Secret, env 覆盖ConfigMap;
  • 运行时层 (启动时校验):应用启动时执行 validate_config() ,检查 DB_PASSWORD 是否为空字符串、 DB_PORT 是否在1-65535范围内,不通过则直接退出。

实操心得:永远不要在代码里拼接配置路径。比如 os.path.join(BASE_DIR, os.getenv("LOG_PATH", "logs")) ,应改为 Path(BASE_DIR) / os.getenv("LOG_PATH", "logs") ,利用 pathlib / 操作符避免Windows/Linux路径分隔符差异。

2.5 日志上下文: logger.info("User logged in") 缺失的不是信息,是决策依据

日志不是给开发者看的,是给SRE和算法工程师看的。 User logged in 这条日志,在千万级用户系统中毫无价值——你无法回答“哪个用户?从哪个IP?用了什么设备?耗时多少?”。我们曾定位一个登录慢的问题,原始日志只有:

INFO:root:User login started
INFO:root:User login completed

花了两天才确认是某个iOS 15.4版本的UA字符串触发了正则匹配性能退化。正确做法是 结构化日志+关键字段注入

# 使用structlog替代logging
import structlog

logger = structlog.get_logger()

# 在请求中间件中注入上下文
def add_request_context(logger, method_name, event_dict):
    # 从当前请求上下文提取数据(如FastAPI的Request.state)
    request_id = getattr(request_state, "request_id", "unknown")
    user_id = getattr(request_state, "user_id", "anonymous")
    ip = getattr(request_state, "client_ip", "0.0.0.0")
    return {
        **event_dict,
        "request_id": request_id,
        "user_id": user_id,
        "client_ip": ip,
        "timestamp": datetime.utcnow().isoformat(),
    }

structlog.configure(processors=[add_request_context, structlog.processors.JSONRenderer()])

这样每条日志自动带上 {"request_id":"req-abc123","user_id":"u-789","client_ip":"203.0.113.45"} ,配合ELK就能秒级筛选“过去1小时所有iOS用户登录失败事件”。

3. 实操落地:从代码片段到可复用模板的完整迁移路径

3.1 类型标注工程化:用 pyright 替代 mypy 的实测对比

很多团队卡在 mypy 配置上: --strict 太严, --no-strict 又形同虚设。我们最终切换到Microsoft的 pyright ,原因很实在: 它对Python 3.12+新特性的支持更激进,且错误分类更符合工程直觉 。比如 mypy list.append() 返回 None 当成 error ,而 pyright 归为 information ——毕竟没人真会写 x = my_list.append(1) 。迁移步骤如下:

  1. 安装与初始化

    pip install pyright
    npx pyright --init  # 生成pyrightconfig.json
    

    关键配置项:

    {
      "include": ["src/**/*", "tests/**/*"],
      "exclude": ["**/node_modules", "**/__pycache__"],
      "typeCheckingMode": "basic",
      "reportGeneralTypeIssues": "error",
      "reportOptionalSubscript": "warning",
      "reportUnknownMemberType": "none"
    }
    
  2. CI集成(GitHub Actions示例)

    - name: Type Check
      run: |
        npx pyright --outputjson | \
        python -c "
        import json, sys
        data = json.load(sys.stdin)
        errors = [e for e in data['generalDiagnostics'] if e['severity'] == 'error']
        if errors:
            print(f'❌ Pyright found {len(errors)} type errors:')
            for e in errors[:5]:  # 只显示前5个
                print(f'  {e[\"file\"]}:{e[\"range\"][\"start\"][\"line\"]} - {e[\"message\"]}')
            sys.exit(1)
        print('✅ Type check passed')
        "
    
  3. 渐进式落地技巧

    • 第一周:只开启 reportGeneralTypeIssues ,修复所有 error 级问题;
    • 第二周:启用 reportOptionalSubscript ,强制处理 Optional[str] is None 判空;
    • 第三周:为所有公共函数添加 @overload 声明,解决 Union 类型推导歧义。

    实测数据:某中台服务接入后, AttributeError 线上报错下降76%,因为 dict.get() 返回 Optional 的场景全部被提前捕获。

3.2 异常处理标准化:自动生成 except 分支的VS Code插件配置

手动写 except 分支容易遗漏,我们用VS Code的 Code Snippets + Python Extension 实现智能补全:

  1. 创建snippets文件 python.json

    {
      "Exception Handler": {
        "prefix": "exh",
        "body": [
          "try:",
          "\t${1:# code}",
          "except ${2:ConnectionError} as e:",
          "\t${3:# handle network error}",
          "except ${4:ValueError} as e:",
          "\t${5:# handle validation error}",
          "except ${6:Exception} as e:",
          "\tlogger.exception(\"${7:Unexpected error}\")",
          "\traise"
        ],
        "description": "Standard exception handler"
      }
    }
    
  2. 关键分支的优先级规则 (按 except 顺序从上到下)

    异常类型 触发场景 处理原则 示例
    ConnectionError Redis/DB/HTTP连接失败 降级或重试 切换备用Redis节点
    ValidationError Pydantic模型校验失败 返回422 + 错误详情 {"detail": [{"loc": ["body", "email"], "msg": "value is not a valid email address"}]}
    BusinessLogicError 业务规则冲突(如库存不足) 返回400 + 业务码 {"code": "STOCK_INSUFFICIENT", "message": "库存不足"}
    Exception 兜底捕获 记录完整traceback, 绝不静默 logger.critical("Critical failure", exc_info=True)
  3. 自定义异常基类模板

    from typing import Optional, Dict, Any
    
    class AppError(Exception):
        """所有业务异常的基类"""
        def __init__(
            self,
            code: str,
            message: str,
            details: Optional[Dict[str, Any]] = None,
            status_code: int = 400
        ):
            super().__init__(message)
            self.code = code
            self.message = message
            self.details = details or {}
            self.status_code = status_code
    
    # 使用示例
    raise AppError(
        code="USER_NOT_FOUND",
        message="用户不存在",
        details={"user_id": user_id},
        status_code=404
    )
    

3.3 资源管理自动化: contextlib.closing() 的局限性与 aclose() 的正确用法

contextlib.closing() 只能用于实现了 close() 方法的对象,但现代异步库(如 httpx.AsyncClient )需要 aclose() 。我们封装了一个通用资源管理器:

from contextlib import asynccontextmanager
from typing import AsyncIterator, Any

@asynccontextmanager
async def managed_resource(
    resource_factory,
    *args,
    **kwargs
) -> AsyncIterator[Any]:
    """通用异步资源管理器"""
    resource = await resource_factory(*args, **kwargs)
    try:
        yield resource
    finally:
        if hasattr(resource, "aclose"):
            await resource.aclose()
        elif hasattr(resource, "close"):
            resource.close()
        else:
            # 降级:尝试调用__aexit__或__exit__
            pass

# 使用示例
async def fetch_data():
    async with managed_resource(httpx.AsyncClient, timeout=30) as client:
        response = await client.get("https://api.example.com/data")
        return response.json()

关键参数说明

  • timeout=30 :避免 AsyncClient 默认的5秒超时在高延迟网络下频繁触发;
  • resource_factory 必须是协程函数( async def ),否则 await 会报错;
  • finally 块中的 hasattr 检查顺序不能颠倒,因为 aclose() 是异步的, close() 是同步的。

3.4 配置中心化: pydantic-settings 替代 python-decouple 的深度改造

python-decouple 只能读取字符串,而 pydantic-settings 支持类型转换、默认值、环境变量前缀等企业级特性:

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    # 数据库配置
    DB_HOST: str = "localhost"
    DB_PORT: int = 5432
    DB_NAME: str = "myapp"
    DB_USER: str = "admin"
    DB_PASSWORD: str = ""
    
    # 缓存配置
    REDIS_URL: str = "redis://localhost:6379/0"
    CACHE_TTL_SECONDS: int = 300
    
    # 安全配置
    SECRET_KEY: str
    JWT_ALGORITHM: str = "HS256"
    
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore"  # 忽略.env中未定义的变量
    )

# 启动时校验
settings = Settings()
if not settings.SECRET_KEY:
    raise ValueError("SECRET_KEY must be set in environment")

# 环境变量前缀示例(生产环境用)
# export MYAPP_DB_HOST=prod-db.example.com
# export MYAPP_REDIS_URL=redis://prod-redis:6379/0
# 启动时指定前缀
# settings = Settings(_env_prefix="MYAPP_")

生产环境最佳实践

  • .env 文件仅用于本地开发, 禁止提交到Git
  • 生产环境通过K8s Secret注入,Secret Key名与配置项一致(如 DB_PASSWORD );
  • 添加 health_check() 方法,启动时验证数据库连通性:
    async def health_check(self):
        try:
            conn = await asyncpg.connect(
                host=self.DB_HOST,
                port=self.DB_PORT,
                database=self.DB_NAME,
                user=self.DB_USER,
                password=self.DB_PASSWORD
            )
            await conn.fetchval("SELECT 1")
            await conn.close()
        except Exception as e:
            raise RuntimeError(f"Database health check failed: {e}")
    

3.5 结构化日志: structlog uvicorn 的深度集成

uvicorn 默认日志格式无法注入请求上下文,必须重写 accessor

import structlog
from uvicorn.protocols.utils import get_client_addr, get_path_with_query_string

# 自定义access日志处理器
def add_uvicorn_context(logger, method_name, event_dict):
    # 从event_dict中提取uvicorn的request信息
    if "request" in event_dict:
        request = event_dict["request"]
        event_dict["client_ip"] = get_client_addr(request.scope)
        event_dict["method"] = request.method
        event_dict["path"] = get_path_with_query_string(request.scope)
        event_dict["status_code"] = event_dict.get("status", 0)
    return event_dict

# 初始化structlog
structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        add_uvicorn_context,
        structlog.stdlib.filter_by_level,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer()
    ],
    context_class=dict,
    logger_factory=structlog.stdlib.LoggerFactory(),
    wrapper_class=structlog.stdlib.BoundLogger,
    cache_logger_on_first_use=True,
)

# FastAPI中间件注入request_id
@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = str(uuid.uuid4())
    structlog.contextvars.bind_contextvars(request_id=request_id)
    response = await call_next(request)
    structlog.contextvars.unbind_contextvars("request_id")
    return response

日志字段标准化清单 (必须包含的7个字段):

字段名 类型 说明 示例
event string 日志事件名称 "user_login_success"
request_id string 请求唯一ID "req-abc123"
level string 日志级别 "info"
timestamp string ISO8601时间戳 "2023-10-05T14:23:18.123Z"
duration_ms float 请求耗时(毫秒) 124.56
status_code int HTTP状态码 200
user_id string 用户ID(匿名时为 "anonymous" "u-789"

4. 常见问题排查:那些让你加班到凌晨三点的“幽灵Bug”

4.1 类型标注失效: mypy 报告 No module named 'xxx' 的5种根因

现象 根因 排查命令 解决方案
No module named 'myapp.models' myapp 未被识别为包 python -c "import sys; print(sys.path)" 在项目根目录执行 pip install -e . (需 setup.py packages=find_packages()
Cannot find implementation or library stub for module 'pandas' 第三方库缺少类型存根 pip install types-pandas 为常用库安装 types-* 包,或配置 mypy.ini [mypy-pandas]
Skipping analyzing 'xxx': found module but no type hints or library stubs 模块无类型标注且无存根 mypy --show-traceback xxx.py 对关键模块添加 # type: ignore 并记录技术债,或用 pyright 替代
Incompatible import of 'xxx' 循环导入导致类型解析失败 mypy --disallow-untyped-defs --disallow-incomplete-defs xxx.py 重构为 if TYPE_CHECKING: 条件导入,或提取公共类型到 types/ 目录
Ignoring because its path contains 'venv' mypy 跳过虚拟环境目录 mypy --show-files mypy.ini 中配置 [mypy] exclude = venv/ | .git/

实操心得:在 pyproject.toml 中统一配置,避免 mypy.ini 和命令行参数冲突:

[tool.mypy]
python_version = "3.11"
disallow_untyped_defs = true
disallow_incomplete_defs = true
show_error_codes = true

4.2 异常捕获失效: except 块没执行的3个隐蔽陷阱

陷阱1:异常被 asyncio 事件循环吞掉

# 错误写法:task被创建但未await,异常在后台静默丢失
async def risky_task():
    raise ValueError("Boom!")

# 这行代码不会触发except
asyncio.create_task(risky_task())  # 异常在task中被吞掉

# 正确写法:用asyncio.gather()收集所有task
await asyncio.gather(
    risky_task(),
    another_task(),
    return_exceptions=True  # 关键!让异常作为结果返回而非抛出
)

陷阱2: sys.excepthook 被第三方库覆盖
某些监控SDK(如Sentry)会重写全局异常钩子,导致 try/except 外的异常不走你定义的日志逻辑。验证方法:

import sys
print(sys.excepthook)  # 如果输出<function sentry_sdk...>,说明被覆盖
# 修复:在SDK初始化后手动恢复
original_hook = sys.excepthook
sys.excepthook = lambda *args: (original_hook(*args), logger.critical("Uncaught exception", exc_info=True))

陷阱3: KeyboardInterrupt signal.signal() 拦截

# 错误:Ctrl+C无法中断程序
signal.signal(signal.SIGINT, lambda s, f: logger.info("Received SIGINT"))

# 正确:保留默认行为,只做清理
def cleanup(signum, frame):
    logger.info("Cleaning up before exit")
    # 执行清理逻辑
    sys.exit(0)

signal.signal(signal.SIGINT, cleanup)

4.3 文件句柄泄漏: lsof -p <pid> 输出中 REG 类型文件过多的诊断

lsof -p $(pgrep -f "myapp.py") | grep REG | wc -l 返回值持续增长,说明存在文件句柄泄漏。典型场景:

场景 代码特征 诊断命令 修复方案
tempfile.NamedTemporaryFile() 未删除 f = tempfile.NamedTemporaryFile(delete=False); f.close() `lsof -p grep "/tmp/"`
subprocess.Popen() wait() proc = subprocess.Popen(["ls"]); # 忘记proc.wait() `lsof -p grep "pipe"`
sqlite3.Connection close() conn = sqlite3.connect("db.sqlite"); # 忘记conn.close() `lsof -p grep "db.sqlite"`

注意: lsof 输出中 DEL 状态表示文件已被删除但句柄仍被进程持有,这是典型的“删除后未关闭”问题。

4.4 配置加载失败: .env 文件明明存在却读不到的4个元凶

现象 根因 验证方式 解决方案
decouple.config("DB_HOST") 返回 None .env 文件编码不是UTF-8 file -i .env iconv -f GBK -t UTF-8 .env > .env.utf8 转换
Settings() 构造失败 .env 中有中文注释 cat -A .env (查看 ^M 等不可见字符) 删除所有 # 开头的注释行,改用 # (空格后跟#)
os.getenv("DB_HOST") 为空 环境变量被父进程覆盖 `printenv grep DB_HOST`
pydantic-settings ValidationError 环境变量值含空格未加引号 echo $DB_PASSWORD (查看是否截断) .env 中用双引号包裹: DB_PASSWORD="my secret"

4.5 日志丢失: structlog 不输出任何内容的终极排查表

检查项 命令/方法 期望结果 不通过的修复
structlog.is_configured() python -c "import structlog; print(structlog.is_configured())" True 调用 structlog.configure()
logger._logger.__class__ python -c "import structlog; print(structlog.get_logger()._logger.__class__)" <class 'structlog.stdlib._StdlibLoggerAdapter'> 检查 logger_factory 参数
structlog.stdlib._GLOBAL_LOGGER python -c "import structlog.stdlib; print(structlog.stdlib._GLOBAL_LOGGER)" <RootLogger ...> 设置 logger_factory=structlog.stdlib.LoggerFactory()
logging.getLogger().handlers python -c "import logging; print(logging.getLogger().handlers)" [<StreamHandler ...>] 手动添加 logging.basicConfig(level=logging.INFO)

终极技巧:在 structlog.configure() 后立即调用 structlog.get_logger().info("config_test") ,如果这行没输出,则一定是配置顺序问题——必须在所有 import 之前完成配置。

5. 工程化落地 checklist:从个人习惯到团队规范的12个动作

5.1 开发者本地环境强制检查项(pre-commit hook)

.pre-commit-config.yaml 中加入:

- repo: https://github.com/pre-commit/mirrors-mypy
  rev: "v1.8.0"
  hooks:
    - id: mypy
      args: [--show-error-codes, --disallow-untyped-defs]

- repo: https://github.com/pycqa/pylint
  rev: "v2.17.0"
  hooks:
    - id: pylint
      args: [--disable=all, --enable=missing-module-docstring,missing-class-docstring,missing-function-docstring]

- repo: local
  hooks:
    - id: check-env-file
      name: Ensure .env is in .gitignore
      entry: bash -c 'grep -q "\\.env$" .gitignore || (echo ".env not in .gitignore"; exit 1)'
      language: system
      pass_filenames: false

5.2 CI/CD流水线必检项(GitHub Actions)

name: Python CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: |
          pip install -e .
          pip install pytest pytest-cov mypy pyright
      - name: Type check with pyright
        run: npx pyright --outputjson | python -c "import json,sys; data=json.load(sys.stdin); print(f'Errors: {len([e for e in data[\"generalDiagnostics\"] if e[\"severity\"]==\"error\"])}')"
      - name: Run tests
        run: pytest tests/ --cov=myapp --cov-report=xml
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3

5.3 生产环境健康检查清单(部署后10分钟内必做)

检查项 命令 预期结果 不通过的后果
配置加载验证 `curl -s http://localhost:8000/health jq '.config_loaded'` true
数据库连通性 `curl -s http://localhost:8000/health jq '.database.status'` "healthy"
Redis可用性 `curl -s http://localhost:8000/health jq '.cache.status'` "healthy"
日志结构化 `tail -n 10 /var/log/myapp/app.log head -1 jq -r 'has("request_id") and has("user_id")'`
类型标注覆盖率 `mypy --show-stats src/ grep "Success: no issues found"` 包含该行

5.4 团队知识沉淀:把“最佳实践”变成“检查清单”

我们为每个被忽视的实践制作了 一页纸检查清单(One-Pager Checklist) ,例如《异常处理检查清单》包含:

  • [ ] 所有 except 分支按 ConnectionError → ValidationError → BusinessLogicError → Exception 顺序排列
  • [ ] BusinessLogicError 类必须继承 AppError 基类
  • [ ] Exception 分支必须包含 logger.critical(..., exc_info=True)
  • [ ] 每个 raise 语句必须有对应的单元测试覆盖( pytest.raises(AppError)
  • [ ] CI中 mypy 检查必须启用 --disallow-any-expr

这些清单不是文档,而是 代码审查(Code Review)的强制项 。PR提交时,Reviewer必须逐项打钩,任一未通过则拒绝合并。

6. 我的个人体会:为什么坚持做这些“不酷”的事?

去年我们上线了一个新功能,按传统做法,我会先写核心逻辑,再补日志,最后加类型标注。但这次我反过来了:先用 pydantic 定义好所有输入输出模型,再基于模型写函数签名,最后填充业务逻辑。结果开发周期比预期长了30%,但上线后零P0事故,SRE反馈“这次变更的监控指标异常干净”。最让我意外的是,新来的实习生在第三天就独立修复了一个Redis连接池泄漏问题——因为他看到`managed_resource

更多推荐