读取文件不再使用 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 多得我都不敢看。