AI 技术博客
返回首页
Python 基础 34 分钟阅读

里程碑项目:通讯录管理系统

Python项目实战通讯录JSON单元测试

引言

第十章是课程 01 的收官章,本篇是章内第 8 篇,也是整个阶段一(第 1–10 章)的里程碑项目。前七篇各有分工:第 1–3 篇的三场测验回测了语法、容器、函数与面向对象,第 4 篇汇总了全书的坑,第 5 篇做了代码审查,第 6 篇体验了性能工具,第 7 篇练了调试。但”会做题”和”会做东西”之间还差一步——把前九章的知识拧成一件能用的产品。本篇要完成的通讯录管理系统(contacts)会用到:类与 property 校验(第八章)、JSON 持久化与损坏兜底(第七章)、正则搜索(第五章)、defaultdict 与 Counter 统计、argparse 子命令(第九章)、CSV 导出导入、logging 操作记录与 unittest 单元测试。它是待办工具(第九章收官项目)的”同族升级版”:数据从单层列表变成带校验的模型,命令从三个变成五个,还加了跨文件格式的导入导出。

先交代一个定位问题:本篇交付的是命令行版——所有功能通过终端里的 contacts 子命令完成。为什么不做成桌面 GUI 或 Web 版?其一,零第三方依赖、随处可跑,符合课程阶段一”每题可直接运行”的约束;其二,命令行天然可脚本化、可自动化测试,逻辑层与界面层分离后,未来加 GUI(课程 01 第 28 章的 Tkinter)或 Web 前端(课程 04/05)时,本篇的模型层与存储层可以原样复用。换言之,本篇做的是”内核”,壳是可以换的。

设计总览:三层架构

项目分三个文件、三层职责:

  • 模型层(contact_model.py):定义 Contact 类与字段校验。姓名非空、电话符合格式,这些规则收在 property 里,构造时即生效;
  • 存储层(storage.py):只负责联系人列表与 JSON 文件的进出——读取、原子写、损坏兜底;
  • 命令层(contacts.py):argparse 解析子命令并调起上面两层,负责打印结果、写日志、返回退出码。

与第九章待办工具的存储/命令两分层相比,本篇多了独立模型层:待办的字段几乎没有约束(文本 + 布尔),而联系人有强校验规则,把它们放进模型层,才能做到”非法数据进不了系统”——第八章讲过的”非法状态不可表示”(invalid state unrepresentable)原则:如果 Contact("", "123") 构造不出来,所有下游代码都不需要再防御空姓名和坏电话。

数据形状上,每条联系人是一个三字段字典:

{"name": "张三", "phone": "13800138000", "group": "同学"}

整个通讯录是这样一个字典的列表,整体存进单个 JSON 文件 contacts.json。下面四条主命令的业务语义先定下来:add 添加、search 按姓名/电话模糊搜索、list 列出(可带 --group 查看分组统计)、export 导出 CSV、import 从 CSV 导入(按电话去重,电话视作弱主键)。

第 1 块:数据模型与 property 校验

Contact 有三个字段,规则如下:姓名必须是非空字符串(前后空白自动去除);电话必须匹配中国大陆手机号格式(1 开头、共 11 位的 1[3-9]\d{9});分组默认”默认”,为空时回退默认值。三个字段都写成 property:读属性返回内部 _name 等私有名,写属性先校验再存储——这样”校验”在每次赋值时都会执行,而不是只在某一处构造函数里检查。

# contact_model.py:联系人数据模型(姓名/电话的 property 校验)
import re

PHONE_RE = re.compile(r"^1[3-9]\d{9}$")  # 中国大陆手机号:1 开头,共 11 位


class Contact:
    """一个联系人:姓名非空,手机号必须匹配手机号格式,分组默认为"默认"。"""

    def __init__(self, name, phone, group="默认"):
        self.name = name      # 三个赋值都会触发 property 校验
        self.phone = phone
        self.group = group

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str) or not value.strip():
            raise ValueError("姓名不能为空")
        self._name = value.strip()

    @property
    def phone(self):
        return self._phone

    @phone.setter
    def phone(self, value):
        if not PHONE_RE.fullmatch(value):
            raise ValueError(f"手机号格式不正确:{value!r}(需为 1 开头的 11 位手机号)")
        self._phone = value

    @property
    def group(self):
        return self._group

    @group.setter
    def group(self, value):
        self._group = value.strip() or "默认"

    def to_dict(self):
        """转成可 JSON 序列化的字典。"""
        return {"name": self.name, "phone": self.phone, "group": self.group}

    @classmethod
    def from_dict(cls, data):
        """从字典恢复联系人;字段缺失或非法时抛异常。"""
        return cls(data["name"], data["phone"], data.get("group", "默认"))

    def __repr__(self):
        return (f"Contact(name={self.name!r}, phone={self.phone!r}, "
                f"group={self.group!r})")

两个细节值得解释。第一,电话校验用的是 fullmatch 而不是 search:search 只要求”某处包含”,"abc13800138000xyz" 也会通过;fullmatch 要求整串精确匹配,杜绝这类漏网之鱼。第二,to_dict 与 from_dict 是一对序列化接口:模型层不直接接触 JSON,存储层通过这两个方法完成”对象 ↔ 字典”的转换——如果将来字段增加,只改这两处和 setter。

第 2 块:存储层与 JSON 持久化

存储层复用了第七章与第九章的技术组合:ensure_ascii=False 让 JSON 文件里的中文可读(否则全变成 \uXXXX 转义);indent=2 让文件可手工编辑;原子写先写同目录临时文件再整体替换,避免写一半断电留下坏文件;损坏兜底则是”文件读不动就改名备份、重新开始”,保证任何情况下程序都能启动。

# storage.py:JSON 持久化(读取 / 原子写 / 损坏兜底)
import json
import logging
from pathlib import Path

from contact_model import Contact

LOG = logging.getLogger("contacts.storage")


class ContactStore:
    """把联系人列表持久化到单个 JSON 文件。"""

    def __init__(self, path):
        self.path = Path(path)

    def load(self):
        """读回全部联系人;文件不存在返回空列表,损坏则备份后重建。"""
        if not self.path.exists():
            return []
        try:
            raw = json.loads(self.path.read_text(encoding="utf-8"))
            return [Contact.from_dict(item) for item in raw]
        except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
            backup = self.path.with_suffix(".corrupted.json")
            LOG.error("存储文件损坏(%s),已改名备份为 %s,重新开始", exc, backup)
            self.path.replace(backup)
            return []

    def save(self, contacts):
        """原子写:先写同目录临时文件,再整体替换目标文件。"""
        payload = [c.to_dict() for c in contacts]
        tmp = self.path.with_name(self.path.stem + ".tmp.json")
        tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
                       encoding="utf-8")
        tmp.replace(self.path)

兜底分支抛出的异常类型要解释清楚:解析失败是 json.JSONDecodeError;字段缺失是 KeyError;字段值非法(如电话坏了)是 ValueError;数据结构错乱(如列表里混进数字)是 TypeError。四类都按住,才不会让一个坏文件把整个工具砸崩。兜底触发时日志会输出一行,例如(路径按你的机器显示):

ERROR contacts.storage: 存储文件损坏(Expecting value: line 1 column 1 (char 0)),已改名备份为 contacts.corrupted.json,重新开始

数据丢了总是不幸的,但”备份坏文件”保留了事后手工抢救的可能——这是第九章原子写的第二道保险。

第 3 块:命令层与 argparse 子命令

命令层是五个子命令的胶水。为了排版,contacts.py 拆成三部分展示:**上半部(第 3a 块)**是导入、分组聚合辅助函数与 add/search;**下半部(第 3b 块)**是 list/export/import;**入口(第 3c 块)**是 build_parser(子命令注册)与 main。按 3a → 3b → 3c 的次序拼成同一个文件 contacts.py 即可运行。

# contacts.py:通讯录管理系统命令行入口(零第三方依赖)
import argparse
import csv
import logging
import re
import sys
from collections import Counter, defaultdict

from contact_model import Contact
from storage import ContactStore

LOG = logging.getLogger("contacts")


def group_by_group(contacts):
    """按分组聚合联系人:defaultdict 在键不存在时自动创建空列表。"""
    groups = defaultdict(list)
    for c in contacts:
        groups[c.group].append(c)
    return groups


def cmd_add(args, store):
    try:
        contact = Contact(args.name, args.phone, args.group)
    except ValueError as exc:
        LOG.error("%s", exc)
        return 1
    store.save(store.load() + [contact])
    LOG.info("已添加联系人:%s %s", contact.name, contact.phone)
    print(f"已添加:{contact.name} <{contact.phone}> [{contact.group}]")
    return 0


def cmd_search(args, store):
    contacts = store.load()
    if not contacts:
        print("通讯录是空的")
        return 0
    pattern = re.compile(re.escape(args.keyword), re.IGNORECASE)
    hits = [c for c in contacts
            if pattern.search(c.name) or pattern.search(c.phone)]
    if not hits:
        print(f"没有找到包含 {args.keyword!r} 的联系人")
        return 0
    for c in hits:
        print(f"{c.name} {c.phone} [{c.group}]")
    print(f"共 {len(hits)} 条")
    return 0

三处要点。第一,cmd_add 里 Contact(...) 可能抛 ValueError(坏电话、空姓名),命令层捕获后记 LOG.error、返回退出码 1——报错与”成功输出”走不同通道,脚本化调用时靠退出码判断成败。第二,搜索是正则模糊匹配:re.escape(args.keyword) 把用户输入里的正则元字符([、.、* 等)转义成字面量,避免”搜 [”反而触发正则语法;re.IGNORECASE 让英文大小写不敏感。搜索对象同时覆盖姓名和电话。第三,defaultdict(list) 第一次登场:它是 dict 的子类,访问不存在的键时自动调用工厂函数造默认值,groups[c.group].append(c) 无需先判断键是否存在,group_by_group 就是”分组”类需求的标准样板。

第 3b 块:list / export / import

def cmd_list(args, store):
    contacts = store.load()
    if not contacts:
        print("通讯录是空的")
        return 0
    if args.group:
        members = group_by_group(contacts)
        stats = Counter(c.group for c in contacts)
        for g in sorted(members):
            print(f"[{g}] {stats[g]} 人")
            for c in members[g]:
                print(f"  {c.name} {c.phone}")
        return 0
    for c in contacts:
        print(f"{c.name} {c.phone} [{c.group}]")
    print(f"共 {len(contacts)} 人")
    return 0


def cmd_export(args, store):
    contacts = store.load()
    with open(args.output, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow(["姓名", "电话", "分组"])
        for c in contacts:
            writer.writerow([c.name, c.phone, c.group])
    LOG.info("已导出 %d 条到 %s", len(contacts), args.output)
    print(f"已导出 {len(contacts)} 条到 {args.output}")
    return 0


def cmd_import(args, store):
    added = skipped = 0
    current = store.load()
    known = {c.phone for c in current}
    with open(args.file, "r", encoding="utf-8-sig", newline="") as f:
        for row in csv.DictReader(f):
            try:
                contact = Contact(row["姓名"], row["电话"], row.get("分组") or "默认")
            except (ValueError, KeyError):
                LOG.warning("跳过无效行:%r", row)
                skipped += 1
                continue
            if contact.phone in known:
                skipped += 1
                continue
            current.append(contact)
            known.add(contact.phone)
            added += 1
    store.save(current)
    LOG.info("导入完成:新增 %d 条,跳过 %d 条", added, skipped)
    print(f"导入完成:新增 {added} 条,跳过 {skipped} 条")
    return 0

list --group 用上了 Counter(计数的专用字典子类,Counter(c.group for c in contacts) 一次统计各分组人数,缺失键自动记为 0),与 defaultdict 各司其职:一个负责”按分组把联系人聚起来”,一个负责”数人头”。export/import 是 CSV 的进出对称:导出用 csv.writer 写表头加数据,导入用 csv.DictReader 按列名取字段,utf-8-sig 加 newline="" 的细节在易错点部分展开。导入按电话去重:known 是当前库中全部电话的集合,重复电话整行跳过——用电话当”弱主键”简洁可靠,代价是同一人换号会重复入库,这属于业务取舍而非缺陷。

第 3c 块:子命令注册与入口

def build_parser():
    parser = argparse.ArgumentParser(
        prog="contacts",
        description="通讯录管理系统(命令行版,零第三方依赖)",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    p = sub.add_parser("add", help="添加联系人")
    p.add_argument("name")
    p.add_argument("phone")
    p.add_argument("-g", "--group", default="默认")
    p.set_defaults(func=cmd_add)

    p = sub.add_parser("search", help="按姓名或电话模糊搜索")
    p.add_argument("keyword")
    p.set_defaults(func=cmd_search)

    p = sub.add_parser("list", help="列出所有联系人")
    p.add_argument("--group", action="store_true", help="按分组展示并统计")
    p.set_defaults(func=cmd_list)

    p = sub.add_parser("export", help="导出为 CSV 文件")
    p.add_argument("output")
    p.set_defaults(func=cmd_export)

    p = sub.add_parser("import", help="从 CSV 文件导入")
    p.add_argument("file")
    p.set_defaults(func=cmd_import)
    return parser


def main(argv=None):
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
        datefmt="%H:%M:%S",
    )
    args = build_parser().parse_args(argv)
    sys.exit(args.func(args, ContactStore("contacts.json")))


if __name__ == "__main__":
    main()

子命令注册是第九章的 add_subparsers 标准写法,这里是五点聚一:每个子命令 set_defaults(func=...) 把”命令名”绑定到”处理函数”,main 里统一 sys.exit(args.func(args, store)) 按函数返回的退出码退出——0 成功、1 业务失败(如坏电话)、2 用法错误(argparse 自身的既定行为)。logging.basicConfig 在 main 里配置一次,命令层与存储层各自 getLogger 派生的记录器都会继承这个格式。

第 4 块:单元测试

测试不碰真实 contacts.json,用 tempfile.TemporaryDirectory 生成临时目录,setUp/tearDown 负责创建与清理——第七章讲过,“测试只能测没有残留状态的单元”,临时目录保证了每次运行从白纸开始。测试开头 logging.disable(logging.CRITICAL) 让运行日志静默,避免”损坏兜底”测试把 ERROR 行泄进测试输出。

# test_contacts.py:核心逻辑单元测试(unittest)
import logging
import tempfile
import unittest
from pathlib import Path

logging.disable(logging.CRITICAL)  # 测试运行时不打印日志,保持输出干净

from contact_model import Contact
from storage import ContactStore


class TestContact(unittest.TestCase):
    def test_name_required(self):
        with self.assertRaises(ValueError):
            Contact("", "13800138000")

    def test_name_strip(self):
        c = Contact("  张三  ", "13800138000")
        self.assertEqual(c.name, "张三")

    def test_phone_format_ok(self):
        Contact("张三", "13912345678")  # 合法号码不抛异常即通过

    def test_phone_format_bad(self):
        with self.assertRaises(ValueError):
            Contact("张三", "12345")


class TestStore(unittest.TestCase):
    def setUp(self):
        self.dir = tempfile.TemporaryDirectory()
        self.path = Path(self.dir.name) / "contacts.json"

    def tearDown(self):
        self.dir.cleanup()

    def test_save_and_load_roundtrip(self):
        store = ContactStore(self.path)
        store.save([Contact("张三", "13800138000", "同学")])
        loaded = store.load()
        self.assertEqual(len(loaded), 1)
        self.assertEqual(loaded[0].name, "张三")
        self.assertEqual(loaded[0].phone, "13800138000")

    def test_load_missing_file_returns_empty(self):
        store = ContactStore(self.path)
        self.assertEqual(store.load(), [])

    def test_load_corrupted_file_falls_back(self):
        self.path.write_text("这不是 JSON {{{", encoding="utf-8")
        store = ContactStore(self.path)
        self.assertEqual(store.load(), [])
        self.assertTrue(self.path.with_suffix(".corrupted.json").exists())


if __name__ == "__main__":
    unittest.main(verbosity=2)

7 条用例覆盖两类契约:模型类 4 条(姓名必填、姓名去空白、电话合法、电话非法)与存储层 3 条(读写往返、文件缺失、文件损坏兜底)。注意”往返”(roundtrip)用例的价值:它同时验证了 to_dict、from_dict、save、load 四条通路,是一条顶四条的基础设施测试。

完整会话:从录入到”换机恢复”

把四个文件放进同一目录后,下面的会话是笔者在 Python 3.12.10 上的真实转录($ 开头的是输入的命令)。录入三位联系人,含一次坏电话的拒绝,然后是搜索、列表、分组统计、导出 CSV、删掉数据文件模拟换机器/误删、再从 CSV 导回、最后跑一遍测试。

$ python contacts.py add 张三 13800138000 -g 同学
00:13:01 INFO contacts: 已添加联系人:张三 13800138000
已添加:张三 <13800138000> [同学]
$ python contacts.py add 李四 13912345678 -g 同学
00:13:01 INFO contacts: 已添加联系人:李四 13912345678
已添加:李四 <13912345678> [同学]
$ python contacts.py add 王五 13700001111 -g 家人
00:13:01 INFO contacts: 已添加联系人:王五 13700001111
已添加:王五 <13700001111> [家人]
$ python contacts.py add 测试 12345
00:13:01 ERROR contacts: 手机号格式不正确:'12345'(需为 1 开头的 11 位手机号)
退出码:1
$ python contacts.py search 138
张三 13800138000 [同学]
共 1 条
$ python contacts.py search 张
张三 13800138000 [同学]
共 1 条
$ python contacts.py list
张三 13800138000 [同学]
李四 13912345678 [同学]
王五 13700001111 [家人]
共 3 人
$ python contacts.py list --group
[同学] 2 人
  张三 13800138000
  李四 13912345678
[家人] 1 人
  王五 13700001111
$ python contacts.py export 通讯录.csv
00:13:02 INFO contacts: 已导出 3 条到 通讯录.csv
已导出 3 条到 通讯录.csv
$ cat 通讯录.csv
姓名,电话,分组
张三,13800138000,同学
李四,13912345678,同学
王五,13700001111,家人
$ rm contacts.json
(数据文件已删除,模拟换机器/误删)
$ python contacts.py import 通讯录.csv
00:13:02 INFO contacts: 导入完成:新增 3 条,跳过 0 条
导入完成:新增 3 条,跳过 0 条
$ python contacts.py import 通讯录.csv
00:13:02 INFO contacts: 导入完成:新增 0 条,跳过 3 条
导入完成:新增 0 条,跳过 3 条
$ python contacts.py list
张三 13800138000 [同学]
李四 13912345678 [同学]
王五 13700001111 [家人]
共 3 人
$ python -m unittest test_contacts -v
test_name_required (test_contacts.TestContact.test_name_required) ... ok
test_name_strip (test_contacts.TestContact.test_name_strip) ... ok
test_phone_format_bad (test_contacts.TestContact.test_phone_format_bad) ... ok
test_phone_format_ok (test_contacts.TestContact.test_phone_format_ok) ... ok
test_load_corrupted_file_falls_back (test_contacts.TestStore.test_load_corrupted_file_falls_back) ... ok
test_load_missing_file_returns_empty (test_contacts.TestStore.test_load_missing_file_returns_empty) ... ok
test_save_and_load_roundtrip (test_contacts.TestStore.test_save_and_load_roundtrip) ... ok

----------------------------------------------------------------------
Ran 7 tests in 0.009s

OK
测试退出码:0

这段会话把项目的能力串成了一条完整故事线:校验堵住脏数据 → 模糊搜索命中 → 分组统计 → 导出成电子表格可读的 CSV → 数据文件没了也能从 CSV 恢复 → 七条测试全绿。坏电话那次返回退出码 1,是与第九章退出码约定的连贯体现;search 138 与 search 张 分别验证了电话维度与姓名维度的模糊匹配。CSV 内容里首行开头有一个不可见字符——utf-8-sig 写入的 BOM(byte order mark,字节序标记),Excel 靠它识别”这是 UTF-8 文件”从而正确显示中文,终端里看不见它属于正常现象。

易错点与陷阱

第一,CSV 的换行与 BOM。写 CSV 必须给 open 传 newline="":csv.writer 内部按平台规则处理换行,若不关掉 open 的通用换行转换,\r\n 会被二次转换,Windows 上导出的文件会出现空行。编码则用 utf-8-sig:普通 utf-8 写的 CSV 在 Excel 里打开会乱码(Excel 默认按本地编码猜),-sig 后缀追加的 BOM 正是给它发的”我是 UTF-8”信号;导入方向同样用 utf-8-sig 读,两边的 BOM 与表头都被 DictReader 正确消化。

第二,JSON 中文必须 ensure_ascii=False。默认 json.dumps 会把中文全部转成 \u5f20\u4e09 之类的转义序列——没错,\u 在 ASCII 里,所以不传该参数”也能跑”,但文件没法看、没法手工改,排查数据问题时非常痛苦。传 False 后中文原样落盘,代价只是文件稍大,对本项目量级完全无感。

第三,损坏兜底的异常面要收全。只抓 json.JSONDecodeError 是最常见的漏网写法:字段缺失(KeyError)、字段非法(ValueError)、类型错乱(TypeError)都会漏出去把程序砸崩。反过来也要提醒:兜底是一道保险,不该指望它替你挡住业务 bug——测试只验证了”坏文件不崩、且有备份”,业务正确性由另外 6 条用例保证。

第四,fullmatch 与 search 的差别。电话校验用 fullmatch 是因为 search 会放过”前后夹带垃圾”的串;同理,搜索时的 re.escape 不可省——用户输入 1[3-9] 这类带元字符的关键词时,不转义会把它当正则执行,行为瞬间从”子串搜索”变成”字符类匹配”,结果完全无法预料。

小结

本篇用 4 个文件、约 200 行代码,把前九章的核心知识合流成一件能真实使用的产品:property 把校验焊进数据模型,JSON 原子写与损坏兜底守住持久化,正则负责模糊搜索,defaultdict 与 Counter 各管一摊分组统计,argparse 五子命令统一入口,CSV 双向转换实现数据进出,7 条 unittest 守住回归,logging 让每一次操作留痕。更重要的是一条工程心智:分层让各部分可以独立更换与测试——未来换界面、换存储、加字段,都只动其中一层。至此,阶段一的知识闭环已经完成,下一篇的路线图将告诉你课程 02–10 往哪走,第 10 篇收官测验则会把全书的每一个决策点再考一遍。

练习与思考题

  1. 增加第六个子命令 remove(按姓名精确删除:contacts remove 张三),删除后打印剩余人数。提示:删除匹配项用列表推导式,store.save 保存新列表,找不到时返回退出码 1。(答案导向:实现后手动验证”删一个、少一个”,再想想按什么字段删除更稳妥。)

  2. 给 search 增加 --group 分组名 选项,只搜索指定分组。用 group_by_group 复用现有代码而非重写逻辑。(答案导向:在现有 hits 基础上加一层分组过滤即可。)

  3. 把 Contact 增加可选字段 email(空串合法,非空时必须包含 @,用 property 校验),并同步调整 to_dict/from_dict,确保旧 contacts.json 不带该字段时也能读入。(答案导向:from_dict 用 data.get 给默认值;这题同时检验你对”向后兼容”的理解。)

  4. 思考题:导入去重把电话当作”弱主键”,若一个家庭共用一个座机号码会怎样?如果改成”姓名 + 电话”联合去重,又有什么新问题?(答案导向:单字段主键有冲突风险,复合键可能误伤同名者,现实中通常再引入内部编号,见第九章的编号语义讨论。)