Python P6:文件读写——让程序把结果存下来
系列:Python 从零开始 · P6 这一课:open() 开门、with 自动关门、读写的四种姿势,附 SSH 日志分析实战
P5 我们造了函数这台机器:parse_log(log) 吃进一行日志,吐出结构化数据,漂亮。但有个扎心的问题——程序一退出,算出来的东西全没了。
变量、字典、函数的返回值,都住在内存里。内存是"临时工宿舍":程序活着,它在;程序一关门,人去楼空。想让数据活下去,得把它写进文件——硬盘才是"档案室"。
这篇解决三件事:怎么读文件、怎么写文件、怎么写得安全不丢数据。最后串起 P3、P4、P5 的全部本事,做一个 SSH 登录日报脚本。
一、open():开门的三个要素
Python 里跟文件打交道,只认一个入口:open()。把它想象成"开门"——开哪扇门、进去干什么、用什么语言交流,三个要素一次说清:
log_file = open("auth.log", "r", encoding="utf-8")
"auth.log":文件名,开哪扇门"r":模式,进去干什么(r = read,只读)encoding="utf-8":编码,用哪种语言交流——中文不乱码就靠它
门开了,open() 会递给你一个文件对象(这里叫 log_file),之后读和写都找它。
二、四种模式:r、w、a
模式决定了你进门之后能干什么,先看最常用的三个:
r 是默认模式,不写也行,但写上更清楚。
演示 1:r 模式读一个不存在的文件
python3 -c "open('no_such_file.log', 'r')"
Traceback (most recent call last):
File "<string>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'no_such_file.log'
只读模式很"老实":文件不在,它不替你新建,直接报错。(番外篇讲过怎么读报错:从下往上看,最后一行就是病因。)
演示 2:w 模式会清空原文件——这是个坑
echo "原来的内容" > demo.txt
cat demo.txt
原来的内容
python3 -c "
with open('demo.txt', 'w', encoding='utf-8') as f:
f.write('新的内容\n')
"
cat demo.txt
新的内容
看见没?"原来的内容"没了。w 一开门,先把屋子清空。 想保留原内容、往后接着写,用 a(append,追加):
python3 -c "
with open('demo.txt', 'a', encoding='utf-8') as f:
f.write('追加的一行\n')
"
cat demo.txt
新的内容
追加的一行
记住这个手感:w 是"推倒重写",a 是"续写"。 日志、日报这种越积越多的东西,永远用 a;配置文件整体重写,才用 w。
还有第四种 +(如 r+、w+),意思是"又读又写",新手期几乎用不上,先知道有它就行。
三、读文件:四种姿势
门开了(r 模式),怎么把内容拿出来?四种姿势:
log_file = open("auth.log", "r", encoding="utf-8")
all_text = log_file.read() # 姿势 1:一次全读进来,变成一个大字符串
log_file.seek(0) # 把"读指针"拨回开头(不然下面读到的是空的)
first_line = log_file.readline() # 姿势 2:只读一行
rest_lines = log_file.readlines() # 姿势 3:剩下的一次全读,装进一个列表
log_file.close()
多说一句 seek(0):文件对象里有个"读指针",读到哪它跟到哪。read() 一口气读到文件尾,指针就停在末尾了——后面再 readline() 只能读到空气。seek(0) 就是把指针拨回开头。
真实跑一下姿势 2,看它到底拿回来什么:
python3 -c "
with open('auth_demo.log', encoding='utf-8') as f:
first_line = f.readline()
print(repr(first_line))
"
'Oct 16 09:12:01 web sshd[1234]: Failed password for root from 203.0.113.7 port 51234\n'
注意末尾那个 \n:文件里的每一行,读进来都自带一个换行符。 print() 默认又会加一个换行,所以直接 print(first_line) 会多出一个空行——处理文本时记得 .strip() 去掉它(P2 讲过 strip())。
姿势 4(推荐):for 循环逐行读
with open("auth_demo.log", "r", encoding="utf-8") as log_file:
for log_line in log_file:
print(log_line.strip())
这是服务器脚本里最常用的读法,两个理由:
省内存:
read()是把整个文件一口气吞进内存,10GB 的日志能把小 VPS 直接吃爆;for循环一次只读一行,文件再大也不怕。顺手:每一行天然就是一个
log_line,P3 的for循环 +if "Failed" in log_line直接就能接上。
四、写文件:write() 只干一件事
report_file = open("daily_report.txt", "w", encoding="utf-8")
report_file.write("SSH 登录日报\n")
report_file.close()
write() 只干一件事:把字符串原样写进去,一个字节都不多给。想要换行,自己加 \n——忘了加,下一笔就糊在同一行末尾。这是新手最常踩的坑之一。
五、with:自动关门,数据不丢
你可能注意到了,上面的例子里我一会儿 log_file.close(),一会儿又 with open(...) as f:。with 是 Python 给文件准备的"自动门":
# 不用 with:门要自己关,忘了关就出事
f = open("report.txt", "w", encoding="utf-8")
f.write("重要数据")
# 万一这里报错崩了,close() 没执行——门没关!
# 用 with:代码块一结束,自动关门,天塌下来也关
with open("report.txt", "w", encoding="utf-8") as f:
f.write("重要数据")
# 到这里,门已经自动关了
为什么关门这么重要? 写文件不是"一写就落盘"——Python 为了快,会先把数据攒在内存的缓冲区里,攒够一批再一次性写进硬盘。close()(也就是 with 结束时自动做的事)就是"结账"的那一下:把缓冲区里剩下的数据全部刷进文件。
门没关 = 账没结 = 最后那点数据可能根本没写进文件。程序正常结束时 Python 会尽力收尾,但中途报错崩溃的话,没有 with 保护的那部分数据就丢了。
规矩:以后凡是 open(),一律配 with。 这是 Python 社区的铁律,照做就行。
六、实战:SSH 登录日报脚本
把 P3(循环+判断)、P4(字典)、P5(函数)的本事全用上。场景:每天看一眼 /var/log/auth.log,到底谁在爆破我的 SSH?
先准备一份模拟日志 auth_demo.log(8 行,混合了爆破和正常登录):
Oct 16 09:12:01 web sshd[1234]: Failed password for root from 203.0.113.7 port 51234
Oct 16 09:12:04 web sshd[1235]: Failed password for root from 203.0.113.7 port 51235
Oct 16 09:12:09 web sshd[1236]: Accepted password for chris from 198.51.100.9 port 2222
Oct 16 09:12:15 web sshd[1237]: Failed password for admin from 203.0.113.7 port 51236
Oct 16 09:12:20 web sshd[1238]: Failed password for root from 192.0.2.44 port 3333
Oct 16 09:12:31 web sshd[1239]: Failed password for root from 203.0.113.7 port 51237
Oct 16 09:12:40 web sshd[1240]: Accepted publickey for chris from 198.51.100.9 port 2223
Oct 16 09:12:55 web sshd[1241]: Failed password for test from 192.0.2.44 port 3334
脚本 parse_auth.py(变量名按规矩来:小写+下划线、见名知意):
log_path = "auth_demo.log"
report_path = "daily_report.txt"
failed_by_ip = {} # 字典:IP -> 失败次数(P4 的本事)
accepted_count = 0
# 读:逐行扫描日志
with open(log_path, "r", encoding="utf-8") as log_file:
for log_line in log_file:
if "Failed password" in log_line:
ip_address = log_line.split("from")[1].split()[0]
failed_by_ip[ip_address] = failed_by_ip.get(ip_address, 0) + 1
elif "Accepted" in log_line:
accepted_count += 1
# 写:把统计结果存进报告文件
with open(report_path, "w", encoding="utf-8") as report_file:
report_file.write("SSH 登录日报\n")
report_file.write("=" * 30 + "\n")
report_file.write(f"成功登录: {accepted_count} 次\n")
report_file.write(f"失败尝试: {sum(failed_by_ip.values())} 次\n")
report_file.write("失败来源 IP:\n")
for ip_address, fail_count in failed_by_ip.items():
report_file.write(f" {ip_address}: {fail_count} 次\n")
print(f"分析完成,报告已写入 {report_path}")
拆解几个关键点:
log_line.split("from")[1].split()[0]:先按"from"切开取后半段,再按空白切开取第一个词——就是 IP 地址。P2 的split()组合拳。failed_by_ip.get(ip_address, 0) + 1:字典的get(),key 不在就按 0 算,省掉一次if判断——P4 的字典在这里当了计数器。读和写各用一个
with,门都自动关,数据丢不了。
真实运行(Python 3.12.3):
python3 parse_auth.py
分析完成,报告已写入 daily_report.txt
生成的报告文件 daily_report.txt 内容:
SSH 登录日报
==============================
成功登录: 2 次
失败尝试: 6 次
失败来源 IP:
203.0.113.7: 4 次
192.0.2.44: 2 次
ls -l
-rw-rw---- 1 root root 684 Oct 16 17:46 auth_demo.log
-rw-rw---- 1 root root 146 Oct 16 17:46 daily_report.txt
-rw-rw---- 1 root root 956 Oct 16 17:46 parse_auth.py
一眼结论:203.0.113.7 在 30 秒里爆破了 4 次——这就是 Fail2Ban 该封的 IP(还记得那篇《Fail2Ban 防护》吗,同一个战场)。以前这种统计靠肉眼翻日志,现在一个脚本每天跑一遍,报告自动躺在文件里。
七、三个小坑
相对路径坑:
open("auth.log")找的是"当前目录"下的文件,不是脚本所在的目录。换个目录运行脚本就FileNotFoundError。稳妥做法:用绝对路径,或者先cd到日志目录再跑。编码坑:
encoding="utf-8"每次都写上。Windows 上默认是 GBK,中文一写就乱码、一读就报错。养成肌肉记忆。w误用坑:想追加却用了w,原文件内容一次清空,找不回来。拿不准就先备份:cp report.txt report.txt.bak,这是 L9 学过的保命习惯。
小结
下一课 P7:异常处理——文件不存在、权限不够,程序不再直接崩溃,try/except 让脚本优雅地兜住错误。