Python技术迷

读取文件不再使用 with open

那天是昨天晚上十一点多,我在公司楼下啃着个凉了的肉夹馍,本来想刷两条短视频就回去睡。结果我们组那个小李一条消息甩过来:“哥,你说我们项目里这堆 with open 能不能想个法子干掉?看得我脑壳疼。”

我一想,还真是。我们那个老项目,从配置加载、日志分析,到各种导入导出脚本,全是这种:

with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()

一页代码能给你写出四五个 with open,编码还经常有人忘记写,Windows 同学删不掉文件的时候就开始骂娘。之前我写数据库那篇就吐槽过一次“到处复制配置”的问题,这里其实是一条线上的东西

那天我就跟小李说,要不我们换个思路:不是“不再用 with open”,而是“业务代码里尽量看不到 with open”。

我先说一个以前栽跟头的小故事,你们肯定有人也干过。

有个同事写了个小脚本,跑批量任务用的,类似这样:

defprocess_file(path: str):
    f = open(path, "r", encoding="utf-8")
    data = f.read()
# ... 一堆处理逻辑 ...
return data

脚本一开始跑得挺欢,后来部署到线上,跑成常驻进程,没几天服务器就开始各种莫名其妙的“文件太多”“句柄耗尽”。查半天,才发现压根没 close()。

你说这玩意儿加一个 with 就好了对吧?问题是,等你意识到要改的时候,这种写法已经复制粘贴到了十几个脚本里,一个个去修,每次改完还得心里发毛:是不是哪儿又漏了一个。那感觉就跟之前排查 MQ 丢消息的时候,一点点找是哪一环没确认成功一样

所以后来我自己的一个习惯,就是——把“怎么打开文件、怎么关”这件事,封装死在一个地方,其他地方就当它是个“读字符串的函数”。

先从最简单的场景开始:绝大多数时候,我们只是想“把一个文本文件读成字符串”,对吧?根本不关心 file object 本身,那为啥要让业务代码直接接触 with open 呢。

我一般就搞一个小工具模块,比如 file_utils.py,里面放这种函数:

# file_utils.py
from pathlib import Path
from typing import Union

PathLike = Union[str, Path]

defread_text(path: PathLike, encoding: str = "utf-8") -> str:
"""稳定版读文本,业务代码别直接 open。"""
    path = Path(path)
# 这里还可以顺手做些校验,比如:
ifnot path.is_file():
raise FileNotFoundError(f"文件不存在:{path}")

# 真正的 with open 被封在这里
with path.open("r", encoding=encoding) as f:
return f.read()

业务代码里就变成:

from file_utils import read_text

config_str = read_text("config/app.json")

你看,业务那块儿真的就像在调用一个普通函数,压根不用再纠结 with、编码、FileNotFoundError 在哪儿抛。以后你想在读之前顺手打个日志,或者加个重试,都在 read_text 里搞定,跟当初我们给 Feign 调用统一加超时重试那种感觉挺像的

但很多人一看到“封装工具函数”,第一反应就是:又要写 util、又是搬砖。其实 Python 自己已经给你准备了一半的路——pathlib.Path。

很多人还在:

with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()

其实完全可以换成一句话:

from pathlib import Path

content = Path("data.txt").read_text(encoding="utf-8")

这玩意儿内部已经帮你做了 open / close,也不用管 with。你要二进制也很简单:

binary_data = Path("image.png").read_bytes()

我一般会这么玩:工具层里基于 Path 再加一层非常轻的壳,比如上面那个 read_text,真正实现里用的是 Path(path).read_text(),这样读起来更统一。

顺便说下,Path.read_text() 默认是用 utf-8,但有时候你遇到那种上古遗留的 gbk 文件,别忘了明确指定一下:

log_content = Path("legacy.log").read_text(encoding="gbk")

不然一打开全是乱码,你还以为是网络问题。

还有一种情况,很多人会问:那我要一行一行处理大文件怎么办?一行一行读的时候总得写个 with 吧?

其实也一样可以“把 with 收进去”,靠生成器解决。

比如我们做日志分析,经常会这样:

defiter_lines(path: PathLike, encoding: str = "utf-8"):
"""按行读取的大文件工具,调用方不需要管文件句柄。"""
from pathlib import Path

    path = Path(path)
with path.open("r", encoding=encoding) as f:
for line in f:
yield line.rstrip("\n")  # 顺带去掉换行

用的时候就很清爽:

from file_utils import iter_lines

error_count = 0

for line in iter_lines("logs/app.log"):
if"ERROR"in line:
        error_count += 1

print("错误行数:", error_count)

这里有一个小细节:虽然调用方没有写 with,但你不用担心文件关不掉。iter_lines 作为生成器,for 循环跑完之后,with 的上下文就结束了,文件句柄会老老实实关掉。

这跟我们查 TCP 报文那次很像,当时为了不在每个地方都写一堆抓包解析的细节,也是封成一个可以 for 的东西,然后各个业务就只关心“拿到一条记录怎么处理”

再说一个稍微高级一点的玩法:把“读文件 + 解析”绑成一体。

很多人会写这种重复代码:

with open("config.json", "r", encoding="utf-8") as f:
    raw = f.read()
config = json.loads(raw)

with open("users.json", "r", encoding="utf-8") as f:
    raw = f.read()
users = json.loads(raw)

其实你真正想要的不是“文本”,而是“已经解析好的 Python 对象”,那就干脆封成一个:

# file_utils.py 里继续加
import json

defload_json(path: PathLike, encoding: str = "utf-8"):
    path = Path(path)
    text = path.read_text(encoding=encoding)
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"解析 JSON 失败:{path}, {e}") from e

业务代码里面就剩下这一句:

config = load_json("config/app.json")

哪天你要支持注释 JSON、或者 YAML、或者从远程下载再解析,都在 load_json 里折腾就行了。外面那堆业务完全不用动。

这种“读 + 解析”绑在一起的思路,我后来也用在了读 CSV、读简单 key=value 配置文件上,写起来都特别顺手。

有人可能会问,那如果我就想拿到 file object 呢?比如我要把文件传给第三方库、或者用 shutil.copyfileobj 这种,你总得给我一个 f 出来吧。

这个场景我一般会用 contextlib.contextmanager 做一个“定制版的 with”。

比如:

from contextlib import contextmanager
from pathlib import Path
from typing import Iterator

@contextmanager
defopen_text(path: PathLike, encoding: str = "utf-8") -> Iterator:
"""自定义的文本文件上下文,统一打日志、做统计都在这里。"""
    path = Path(path)
    print(f"[open] 打开文件:{path}")  # 这里可以换成正式日志
    f = path.open("r", encoding=encoding)
try:
yield f
finally:
        print(f"[close] 关闭文件:{path}")
        f.close()

然后业务代码里写:

from file_utils import open_text

# 注意这里还是用 with,但用的是你自己的 open_text
with open_text("data.txt") as f:
for line in f:
        ...

这看起来好像没比原来的 with open 少几个字,但好处在于:以后所有想读文本文件的地方,全走你自己的入口,你想加监控、做埋点、限制文件大小,统统只动这一处就行了。

尤其是那种需要审计“谁读了哪些敏感文件”的项目,用这种统一入口真的会省很多命。

再说一个现在很常见但大家容易忽略的:异步场景下读文件。

比如你用 FastAPI 写了一个接口,要在请求里读一个大文件,如果直接:

defread_file_sync():
with open("big.data", "rb") as f:
return f.read()

然后在 async def 里直接调用,整个事件循环就被你堵住了。这个时候可以考虑类似 aiofiles 这样的库,写成这样:

import aiofiles

asyncdefread_file_async(path: str) -> str:
asyncwith aiofiles.open(path, "r", encoding="utf-8") as f:
returnawait f.read()

如果你不想在业务里到处写 async with aiofiles.open,同样可以来一层壳:

# async_file_utils.py
import aiofiles

asyncdefread_text_async(path: str, encoding: str = "utf-8") -> str:
asyncwith aiofiles.open(path, "r", encoding=encoding) as f:
returnawait f.read()

接口里就一句话:

content = await read_text_async("big.data")

这算是“异步版的:业务代码里不再出现 with open”。

说了半天,你会发现一个共通的点:

  • 真正的 with open(...) 其实我们还是在用
  • 只是把它们集中到少数几个工具函数 / 上下文管理器里
  • 业务代码看到的永远是“读文本”“读行”“读 JSON”这样更贴近业务语义的接口

这样做还有一个额外好处:测试起来舒服太多了。

比如你想测一个“读取配置并做一些校验”的函数:

defload_and_validate_config(path: str) -> dict:
    config = load_json(path)
# 各种校验逻辑...
return config

单测里根本不用管文件怎么打开,你直接给它准备几个临时文件路径就好了,甚至可以在测试里 monkeypatch 掉 load_json,完全不碰磁盘。

跟之前那种“到处都是 with open、每个地方都在读文件”的写法比起来,简直是一个天上一个地下。

最后我在楼下把肉夹馍吃完,回工位花了半个小时,把项目里最常见的几种读文件场景全部撸成了工具函数:读字符串、读行、读 JSON、读二进制。然后让小李搜了一把 with open(,从原来的几十处,直接缩到寥寥几处底层封装里。

他看完只来了一句:“舒服了。”

其实这东西也没啥高大上的算发,就是那个,别逞一时的快,在每个地方都自己写一遍 with open。把脏活累活集中到一两处,其他地方就干干净净用 Python 对象,出了问题也更好排。

行了我先去泡杯咖啡,等会儿要把之前那个导入脚本也改一改,里面 with open 多得我都不敢看。