近期关于AI浪潮下的搜索引擎、SEO和GEO思考

近期关于AI浪潮下的搜索引擎、SEO和GEO思考 先说说个人对未来这块整体趋势的几个看法: 从用户市场来看,AI工具会逐步蚕食传统搜索引擎的市场,但无法完全替代,即使LLM解决了幻觉问题也是达到一种平衡状态; 从产品形态来看,AI工具会补足优化搜索能力,而搜索引擎也会结合AI能力打造AI搜索,两者互抄互补,最终趋于一种产品形态; 从SEO/GEO技术发展来看,现在AI推荐没有技术规范和约束,未来大概率会形成面向GEO的技术规范和算法,类似Google的PageRank算法;而且因为存在很大利益空间,所以也会陆续孵化出一些做相应AISEO/GEO能力的企业; 从想提升曝光度和转化的企业来看,需要投入一定资源在AISEO/GEO建设上,研究AI搜索的内容偏好,专注内容质量提升、深耕垂直领域; 从AI工具发展来看,在目前竞争激烈没有形成一家独大的情况下,不会出现广告、竞价这类业务模式。但另一方面,一旦有一家AI工具在国内形成明显优势,有可能会引入广告甚至竞价,并带动其他AI工具; 从普通用户来看,我们需要一直对AI工具给的答案秉持怀疑态度,“参考但不全信”; 从终态来看,搜索引擎会下沉为一种中间件,并不是给人用而是给AI使用的,AGI时代传统搜索引擎会彻底藏在水下。 接下来会结合一些数据情况简单说明这些看法 一、AI工具发展趋势下的一些变化 自2022年 ChatGPT 横空处世以来,国内外涌现出一批批的 AI 工具,比如 Kimi、DeepSeek,它们都能一定程度地替代百度、Google 这些搜索工具,这几年也有越来越多的人在检索查询信息时,选择通过AI问答的方式而不是搜索引擎,这对传统搜索引擎的市场产生了冲击。 以国内为例,根据中国互联网络信息中心于2025年7月发布的第56次《中国互联网络发展状况统计报告》显示,2025上半年,国内搜索引擎的用户规模和使用率都有比较明显得下降。 此报告中也指出:“2025 年上半年,人工智能技术驱动搜索服务向多模态与场景化纵深发展,推动搜索引擎行业持续向智能化方向发展。”,个人对这个论点的解释是这半年国内一部分搜索场景被AI工具吞噬,一部分流向搜索引擎自身的AI改造(AI搜索,后文介绍)。 值得注意的是,2025年年初,是 DeepSeek 横空出世且爆火的时间点,也是国内很多人真正开始接触这类 Chatbot 形式 AI 工具的时间。相比国外 ChatGPT、Gemini、Claude、Perplexity 这些AI工具已经相对普及的情况下,国内应用相对还是较慢的。 唯独时间上相对同步的是,除 Perplexity 外,国内外AI工具支持“联网搜索”模式基本也都是在2024年Q4~2025年Q1才开始支持。 DeepSeek热度走势: 在国外,onelittleweb 于2025年4月发布的《Are AI Chatbots Replacing Search Engines?》统计报告中也有相应的数据: 从图中我们可知,除了像比较早结合 AI 的 Bing,大多搜索引擎在24-25年期间用户规模已有所下滑。 整体对比来看,海外和国内一样也是略微呈现出一点此消彼长的状态。但从总规模来看 AI 工具目前仍仅为搜索引擎的1/34左右,还不是一个数量级,可见在宏观层面上,AI搜索要取代传统搜索还有很长的路要走。 AI搜索代替传统搜索引擎的一些阻碍 个人觉得从现阶段 AI 工具与传统搜索引擎的差异上也能体现出这个过程可能会很久。 差异主要体现在几个方面: 产品形态上:产品形态和交互体验的差异会导致一些习惯于传统搜索模式的用户有一定阻力过渡到AI工具。 信息输入交互的差异:虽然都是输入框,但是 AI 工具是具备多轮对话能力、使需求不断澄清;而传统搜索引擎仅仅是输入然后界面输出 结果的形态差异:AI问答是生成一段带有引用的自然语言答案,其中穿插着一些链接;而传统搜索引擎是展示若干条带有超链接的答案列表 效果上:结合信息时效性、处理耗时、AI幻觉和稳定性等主要问题,AI 工具从能力和体验方面目前仍难以替代传统搜索引擎。 信息时效差异:AI 问答工具时效性相对滞后,而传统的搜索引擎展示的答案实时性相对较高。 可能会觉得 AI 工具+联网模式能弥补实时性问题,但其实这种机制本身基于传统搜索引擎API,效果好坏也主要依赖搜索引擎。 结果一致性差异:对于相同的输入,AI 问答的结果很有可能存在差异、有时候差异可能还很大;而传统搜索引擎能保证高度的一致性 结果准确性差异:AI 工具目前存在众所周知的“幻觉”问题,以2025年3月哥伦比亚大学数字新闻研究中心对多款主流AI工具搜索引用内容效果的正确性评估为例,这些 AI 搜索工具在引用新闻方面表现非常不佳,出错比例甚至高达 60%(报告《AI Search Has A Citation Problem》) 二、传统搜索引擎顺应AI潮流下的改变 在AI工具不断进化和推广的前提下,传统搜索引擎也做出了改变。目前形态最通用的就是搜索引擎和ai结合——AI搜索。 结合几个AI工具给出“AI搜索”定义:“AI搜索是基于人工智能技术的创新型搜索方式,它通过自然语言处理、机器学习和深度学习等技术,能够深度理解用户的查询意图和上下文。” 与传统搜索引擎不同,AI搜索不再局限于简单的关键词匹配,而是可以分析用户的查询语句,提供更精准、相关和个性化的搜索结果。它还可以利用知识图谱整合相关信息,提供更全面系统的知识体系。此外,AI搜索还支持多轮对话和多模态交互,能够根据用户的搜索历史和偏好动态优化结果排序。 本人最早对AI搜索的感知是2023年Bing搜索结合GPT支持了AI搜索,然后没几个月百度也开始了AI搜索的灰度,当时的AI搜索还很“简陋”,存在查询耗时久、错误多、不稳定、幻觉等等AI典型问题。 到现在,有很多问题已经得以解决或者缓解,几乎所有主流大模型工程都在做AI搜索,国内的大模型厂商、互联网大厂都在C端业务中加了此类功能。 个人觉得AI搜索产品可以分为三类:一类是做AI的,一类是做搜索的,一类是做垂类业务的。 第一类:本身是大模型工具,通过联网模式等机制使其具备了搜索能力。代表有DeepSeek、Kimi、豆包、文心一言、通义千问、腾讯元宝、智谱清言等。 第二类:本身是搜索引擎,通过加上大模型处理能力使其具备综合分析和直接回答的能力。比如360搜索、秘塔搜索、天工AI搜索、百川AI搜索百小应、百度简单搜索等等。 第三类:电商、金融、资讯等等业务都有搜索场景,根据业务垂类引入大模型处理能力做AI问答能力的。如微信、淘宝、今日头条等等应用在搜索场景都融入了AI能力。 那么AI搜索的效果如何呢?从一些统计数据来看,AI搜索在流量上是有所增长的。如沙利文在2025年8月发布的《2025年中国AI搜索行业白皮书》可以看到,在整体搜索引擎使用减少的大背景以及百度市占率降低的情况下,百度AI搜索功能访问量却是逆势增加的 比较有意思的是,国内搜索引擎以百度“一超多强”的格局已经固化很久了,AI时代会对百度造成何种冲击、以及百度会如何应对,这都是接下来值得关注的事。另外谷歌已经在Google I/O上对看家的搜索业务号称进行了全面的AI改造——“AI Mode“,但目前仍未完全全球开放,bing也是,这也间接说明模型技术成本和质量问题仍然普遍存在。 AI搜索的一种形态——AI概览(AI Overview) AI概览是基于传统搜索基础之上开发的新功能,目前的运作模式是引用优质页面(摘取页面中的某部分)信息展示在搜索结果中。 AI概览是谷歌号称过去十年谷歌最成功的搜索功能之一,该功能目前主要开放在美国和印度等主要市场,出现在约15%的搜索结果中,数据上来看将搜索使用率提升了10%,而且这一比例每天都在以更快的速度增长。国内百度这些也有类似的处理能力。 AI概览展示的内容通常为意向明确的问答式信息搜索,改变了用户的行为方式,因此有很多检索场景将不用再点击链接进行查看判断,因此也带来了一个新词“零搜素”(zero-click search)。 零点击搜索顾名思义:用户无需点击任何网站,就能在谷歌搜索结果页面上直接找到答案。如果用户能立即获得答案,那么即使你的网站排名很高,并且被用作人工智能概述的来源,他们也可能不会点击你的链接。这可能会导致传统自然搜索结果的点击率(CTR)下降,一些网站数据表示自然点击率下降了接近4倍。人们将这种变化称为“大脱钩”——网站获得了更高的曝光度,但流量却更少了。 然而另一方面,AI概览有着极大的曝光度,因此如果网站能出现在 AI概览中,对曝光效果、流量和转化率提升反而可能是提升明显的。 三、使用AI工具和AI搜索值得注意的地方 前面也提到,目前这些AI工具有很多弊端,对于用户来说体验问题就是“有门槛”、“结果差”。 首先,即使有了联网模式,在技术方面很多 AI 助手依旧没办法实时获取最新的数据。这主要是上面提到的“第三类”垂类业务场景。无论是用了实时的知识库做匹配还是用NL2SQL取数等技术手段,数据的准确性和可靠性问题依旧十分明显,更不要说用户隐私这类数据的安全处理,这点需要非常大的成本和时间逐步解决。 再者,AI擅于回答错误答案,你在提问时的一点细微差别,它可能就完全 get 不到,给出偏离需求的回答,AI的回答也极具欺骗性,让人难以判别真伪。因此想用好 AI工具,用户首先得学会怎么提问,提示词prompt就得讲究讲究,对于AI给出的回答也需要辩证看待、甚至需要投入更多验证成本,这些对很多普通用户来说其实是有门槛的。 关于AI给出的答案还需要强调的,AI工具的检索、GEO(下一节会介绍)好比SEO、未来注定存在很大的商业利益空间,从而引发AI回答的人为干预、植入广告等投机行为,我们需要对此有所警惕。正如同当年的“魏则西事件”,就是由于搜索引擎排序被人为干扰后导致的悲剧。而且AI搜索的广告会更隐蔽,无法像传统广告那样被清晰标识。 总而言之,AI 搜索确实改变了我们检索信息的方式,让信息获取变得更高效,但它目前更像是对传统搜索的补充,它们各有利弊,所以我们也应根据不同的场景去选择工具。 四、SEO和GEO 随着AI工具的普及,这几年关于AI工具的搜索推荐也衍生出了一些新概念:AISEO(Artificial Intelligence Search Engine Optimization)、GEO(Generative Engine Optimization)、AEO(Answer Engine Optimization)。它们是一种面向AI搜索的全新内容与品牌认知优化策略。 SEO的目标是抢搜索引擎的排名,而GEO的目标是让你的内容成为AI的信息来源。通俗点解释,SEO是想办法针对Google/百度这类搜索引擎的搜索结果进行优化,让自己的品牌网站出现在结果列表中、并希望能不断提前排名;而GEO是针对大模型的生成结果进行优化,让自己的品牌或网站出现在生成结果中。 首先个人也认为GEO会渐渐比SEO更重要,因为这是目前AI时代搜索领域从“流量思维”到“答案思维”的转变: 传统SEO:流量思维,查找信息,优化的是“容器”(网页),追求的是在搜索引擎结果链接列表中的“位置”、即流量,用户还需要点“位置”再进行结果判断。 AISEO/GEO:答案思维,优化的是“知识”本身,追求的是成为AI认知体系中的“事实”,用户可以直接拿到答案,从链路来看也更短、更为高效。 在AI时代,“答案”即代表了流量。用户页不再关心信息的来源是哪个网站,只关心最终答案。因此,谁能控制答案、使品牌信息成为AI生成答案时的优先引用,谁就控制了流量,谁就能在用户心中建立起第一品牌认知,并掌握最终的商业统治力。其核心目标不再是争夺“链接排名”,而GEO就是是通过深度优化内容的语义结构、权威性信号、多模态适配性及信任网络,确保你的企业品牌信息成为这些答案的一部分,从而直接抢占“最终答案”的控制权。 GEO的诞生给企业提供了一条推广的新途径,但也带来了隐患,即任何一家公司或个人都可以发布大量没有经过权威验证的文章到网上,然后被AI引用,进而造成不良后果,这就是所谓的数据污染,原则上如果大模型接触到的文字资料都是权威正确的,那么它给出的结果自然就是正确的,但谁来保证大模型接触到的资料正确性呢?尤其是对于目前需要实时联网获取信息的大模型而言,哪些网站的资料是可靠的,哪些是不能引用的?没有相关权威规定,全凭大模型厂家自身决定,这就埋下了隐患,也是现在GEO的问题。 AI搜索的优势与劣势推动着行业标准的重新定义。传统搜索时代,谷歌与百度通过PageRank算法确立权威地位;而在AI搜索生态中,答案质量评估体系尚未形成统一标准。这种规则真空状态既是挑战也是机遇。 ChatGPT中的跳转链接,会在跳转链接中带utm_source=chatgpt.com参数,不知道是否会逐步形成业界规范。Kimi这些目前发现还没有这种处理,这种情况下网页很难追溯来源。 五、关于传统搜索引擎未来归宿的思考 以目前AI工具的“联网模式”为例,其实它底层是调用了传统搜索引擎的API,并对其结果进行分析处理(代替人为操作,关于“联网模式”的原理以后可能可以展开介绍介绍,本文先不展开),在这个过程中,用户其实是感知不到搜索引擎的存在的,所以关于传统搜索引擎的未来,个人觉得传统搜索引擎会以API的形式与AI有更多结合(包括各基于LLM的Agent、Workflow、Tool),但是会藏在水下,传统搜索引擎会逐步从给人用到给AI用,但还是会因各种因素留有一部分人使用传统搜索引擎,AI也好、传统搜索引擎也好,毕竟都是一种工具。 有人可能说像kimi开启“联网模式”后页面右边能看到检索的列表,但这只是个交互设计,像DeepSeek就隐藏了这部分。 最后,感慨下时代变化之快,商业模式也在变,在这样一种快速变化的大形势下,企业也好、网站也好、个人也好,应如何建立核心竞争力,以求立于“不败之地”。除了大家都说的顺应潮流拥抱AI外,本人还觉得也要对未来发展趋势有所思考、不断论证,力求先走一步。 相关链接 https://www3.cnnic.cn/n4/2025/0721/c88-11328.html https://www.bain.com/insights/goodbye-clicks-hello-ai-zero-click-search-redefines-marketing/# https://onelittleweb.com/data-studies/ai-chatbots-vs-search-engines/ https://juejin.cn/post/7530088591394406442 https://www.cjr.org/tow_center/we-compared-eight-ai-search-engines-theyre-all-bad-at-citing-news.php https://www.zhihu.com/question/1913288955622823047/answer/1945849408408257462 https://www.frostchina.com/content/insight/detail/689c5e10ec651704f440e89b https://scjgj.sh.gov.cn/162/20250908/9d4ea7854edf480f846318e3c8268315.html https://a16z.substack.com/p/the-death-of-search-how-shopping https://mikekhorev.com/ai-seo-trends https://searchengineland.com/ai-hype-seo-reality-leads-revenue-462235

2025/9/14
articleCard.readMore

【工具】AI Common Notify:统一 AI 编程工具通知服务

AI Common Notify 完全指南:统一 AI 编程工具通知服务 在 AI 辅助编程日益普及的今天,Claude Code、Cursor、Windsurf、Trae 等 AI 工具极大地提升了我们的开发效率。但这些工具都有一个共同的问题:它们在执行完任务后不会主动通知用户,导致开发者需要手动检查任务状态。如果在同时运行多个任务时,容易错过重要信息或无法有效安排工作优先级。 为了解决这一问题,本人写了 AI Common Notify —— 一个为所有主流 AI 编程工具提供统一通知服务的开源工具。它能在 AI 工具完成任务后主动发送系统通知,让您在多任务并行时也能井然有序地处理工作。 AI Common Notify 本质上是一个通知应用工具,因此它也可以不只应用于 ai 编辑器。 项目地址:https://github.com/MichealWayne/ai-common-notify 为什么选择 AI Common Notify? 现状问题 目前市面上虽然已有一些小的通知工具,但它们通常只针对单一 AI 工具,例如仅支持 Claude Code。这种碎片化的解决方案带来了以下问题: 工具割裂:每个 AI 工具需要单独的通知服务、如配 hook、配 mcp,用户需要安装和配置多个工具 体验不一致:不同通知服务的样式、行为和配置方式差异很大 维护成本高:当需要调整通知设置时,需要逐一修改各个工具的配置 扩展性差:缺乏统一的扩展机制,难以集成自定义通知渠道 AI Common Notify 的核心优势 AI Common Notify 针对上述痛点提供了全面解决方案: 统一接口:一个工具支持所有主流 AI 编程工具(Claude Code、Cursor、Windsurf、Trae 等) 跨平台兼容:支持 Windows、macOS 和 Linux 高度可配置:支持自定义标题、消息模板、紧急程度、超时时间、声音和图标 扩展性强:通过脚本回调机制,可轻松集成微信通知、钉钉机器人等自定义通知渠道 易于部署:支持 npm 全局安装和独立可执行文件,一键配置 智能初始化:自动检测项目中使用的 AI 工具并生成相应配置 快速入门 安装 在本地环境安装Nodejs(推荐 v18 及以上)后,可通过 npm 全局快速安装: 1 npm install -g ai-common-notify 验证安装 安装完成后,通过以下命令验证是否安装成功: 1 2 # 查看版本信息 ai-common-notify --version 1 2 # 发送测试通知 ai-common-notify test 如果看到版本信息或系统通知弹出,说明安装成功。 核心功能详解 1. 多样化的通知触发机制 AI Common Notify 支持多种通知触发方式,适配不同 AI 工具的特点: Hook 模式(Claude Code) 可通过ai-common-notify quickInit命令在项目下快速初始化配置 通过 Claude Code 的 Hook 系统集成,在任务完成时发送通知: 在 Claude Code 设置文件 (~/.claude/settings.json 或项目中的 .claude/settings.json)设置: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "hooks": { "Stop": [ { "matcher": ".*", "hooks": [ { "type": "command", "command": "ai-common-notify hook" } ] } ] } } MCP 模式(Cursor、Windsurf、Trae、CodeBuddy、Gemini-cli 等) 也可通过ai-common-notify quickInit命令在项目下快速初始化配置 通过在 IDE 中设置 MCP 为 Cursor 等工具提供通知服务: 1 2 3 4 5 6 7 8 { "mcpServers": { "NotificationServer": { "command": "ai-common-notify", "args": ["mcp"] } } } 使用提示:当使用 Cursor 或 Windsurf 等 MCP 工具时,建议在您的提示(prompt)最后明确要求发送通知,例如:”最后,任务完成时发送通知给我。“、”Finally, send me a notification when the task is finished.“ 这有助于确保 AI 工具调用通知工具。您也可以在 Cursor 的设置中将此提示添加为规则,这样就不需要每次都手动输入。 API 模式(自定义集成) api 模式主要用于在线的平台工具进行调用,以统一通知处理。AI Common Notify 提供 RESTful API 供自定义工具调用: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 启动API服务器 ai-common-notify api # 发送通知请求 curl -X POST http://localhost:6001/api/v1/notify \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your-secret-token" \ -d '{ "title": "任务完成", "message": "代码重构已完成", "urgency": "normal", "timeout": 0, "sound": true }' 2. 灵活的通知定制 AI Common Notify 支持丰富的通知定制选项: 基础参数 title: 通知标题 message: 通知内容 urgency: 紧急程度(low/normal/critical) timeout: 超时时间(秒,0 表示永久显示)(部分系统不适用) sound: 是否播放声音 icon: 自定义图标路径(部分系统不适用) 高级配置 通过 JSON 配置文件实现更精细的控制: 1 2 3 4 5 6 7 8 9 { "notifications": { "default_timeout": 0, "default_sound": true, "default_urgency": "normal", "title_template": "{tool_name} - {project_name}", "message_template": "{message}" } } 3. 脚本回调扩展 脚本回调在 AI Common Notify 的配置文件中设置,通过脚本回调扩展可以实现在通知发送时执行自定义脚本,让我们可以轻松集成微信通知、钉钉机器人等自定义通知渠道: shell: 1 2 3 4 5 6 7 8 9 10 11 12 { "scripts": { "timeout": 30000, "notify": [ { "type": "shell", "path": "/path/to/your/script.sh", "enabled": true } ] } } nodejs: 1 2 3 4 5 6 7 8 9 10 11 12 { "scripts": { "timeout": 30000, "notify": [ { "type": "node", "path": "/path/to/your/script.js", "enabled": true } ] } } 脚本中可以接收丰富的环境变量(如 nodejs 可以用process.env获取): NOTIFY_TITLE: 通知标题 NOTIFY_MESSAGE: 通知消息 NOTIFY_URGENCY: 紧急程度 NOTIFY_TIMEOUT: 超时时间 NOTIFY_SOUND: 是否播放声音 NOTIFY_PROJECT_NAME: 项目名称 NOTIFY_TOOL_NAME: 工具名称 NOTIFY_TIMESTAMP: 时间戳 通过脚本回调扩展我们可以额外完成很多自定义功能,如: 集成微信通知示例 Node.js 脚本示例(发送到企业微信) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 // wechat-notify.js const https = require('https'); // 企业微信机器人Webhook地址 const webhookUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY'; // 构造消息内容 const message = { msgtype: 'text', text: { content: `[${process.env.NOTIFY_TIMESTAMP}] ${process.env.NOTIFY_TITLE}\n${process.env.NOTIFY_MESSAGE}\n项目: ${process.env.NOTIFY_PROJECT_NAME}\n工具: ${process.env.NOTIFY_TOOL_NAME}`, }, }; // 发送请求 const data = JSON.stringify(message); const options = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': data.length, }, }; const req = https.request(webhookUrl, options, res => { console.log(`微信通知发送状态: ${res.statusCode}`); }); req.on('error', error => { console.error('微信通知发送失败:', error); }); req.write(data); req.end(); 个人开发者也可以考虑接入 PushPlus、WxPusher、Server 酱这类集成好的平台来实现微信等推送。 4. 快速初始化功能 AI Common Notify 提供了一键初始化功能,自动为项目中检测到的 AI 工具生成或更新配置文件: 1 2 3 4 5 6 7 8 9 10 # 导航到项目目录 cd /path/to/your/project # 初始化所有检测到的工具 ai-common-notify quickInit # 初始化特定工具 ai-common-notify quickInit --tool cursor ai-common-notify quickInit --tool claudecode ai-common-notify quickInit --tool windsurf 该功能支持的工具包括: Cursor: 通过 MCP 协议集成 Claude Code: 通过 Hook 系统集成 Windsurf: 通过 MCP 协议和规则文件集成 Gemini-cli: 通过 MCP 协议集成 5. 错误日志管理 AI Common Notify 具备完善的错误处理和日志记录功能,以便问题反馈和排查: 1 2 3 4 5 # 查看错误日志 ai-common-notify errlog # 查看所有日志 ai-common-notify alllog 详细配置说明 配置层级 AI Common Notify 支持多层级配置,优先级从低到高依次为: 全局配置: ~/.config/ai-common-notify/config.json (Linux/macOS) 或 %APPDATA%\\ai-common-notify\\config.json (Windows) 项目配置: <project-root>/.ai-notify.json 配置文件示例 全局配置示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 { "server": { "port": 6001, "host": "localhost", "token": "generated-secret-token" }, "notifications": { "default_timeout": 0, "default_sound": true, "default_urgency": "normal" }, "scripts": { "timeout": 30000, "notify": [ { "type": "shell", "path": "/home/user/scripts/notify-log.sh", "enabled": true } ] }, "logging": { "retentionHours": 168 }, "platforms": { "linux": { "sound_enabled": false } } } 项目配置示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { "notifications": { "default_urgency": "critical", "title_template": "[PROJECT] {tool_name} - {project_name}" }, "scripts": { "notify": [ { "type": "node", "path": "/path/to/project/scripts/notify.js", "enabled": true } ] } } 实际使用场景 实际有些简单的场景: 1. 多任务并行处理 当同时运行多个 AI 任务时,AI Common Notify 能帮助您: 1 2 3 4 5 6 # 在不同项目中启动AI任务 # 任务1: Claude Code重构代码 # 任务2: Cursor生成文档 # 任务3: Windsurf优化性能 # 每个任务完成后都会收到通知,您可以根据紧急程度安排处理顺序 2. 长时间运行任务监控 对于需要长时间运行的 AI 任务,您可以设置完成后通知: 1 2 # 在脚本中集成 ai-common-notify send --title "模型训练完成" --message "模型训练已完成,准确率95.2%" 3. 错误警报系统 在 CI/CD 流程中集成关键错误通知: 1 2 3 4 # 检测到错误时发送关键通知 if [ $ERROR_CODE -ne 0 ]; then ai-common-notify send --title "构建失败" --message "构建过程出现错误,请检查日志" --urgency critical fi 4. 自动化工作流 在自动化脚本中使用 AI Common Notify: 1 2 3 4 #!/bin/bash echo "开始部署..." # ... 部署过程 ... ai-common-notify send --title "部署完成" --message "应用已成功部署到生产环境" 总结 AI Common Notify 通过提供统一的通知接口,有效解决了 AI 编程工具生态中通知机制缺失的问题。它不仅支持多种主流 AI 工具,还提供了灵活的配置选项、强大的脚本扩展能力和 REST API,满足不同用户的使用需求。

2025/8/16
articleCard.readMore

21st.dev:让AI生成的页面告别"塑料感"的专业解决方案

21st.dev:让 AI 生成的页面告别”塑料感”的专业解决方案 现在已经有很多可以生成页面的 ai 工具,但你是否遇到过这样的困扰:AI 生成的页面虽然功能完整,但总有一种说不出的”塑料感”?样式单调、缺乏设计感,调整起来又费时费力… ai 生成的页面往往长这样: 或者这样: 稍好点的话可能长这样: AI 味非常得重…这对于缺乏设计经验或前端技术背景的用户来说,调整这些样式往往需要大量时间和精力。 当前主流 ai 工具生成的页面普遍存在以下问题: 视觉风格单一,缺乏品牌个性 布局结构雷同,用户体验不佳 样式调整困难,需要大量手动优化 缺乏现代设计趋势,显得过时 解决方案:21st.dev 的 AI 友好组件库 现在有一种较为好用的解决方案:使用21st.dev 。 21st.dev 是一个专为现代前端开发者和设计工程师打造的创新型 UI 组件与页面模板平台。它不仅提供了丰富的高质量组件和页面模板,更以”AI 友好”为核心理念,让开发者可以通过自然语言在 IDE 内快速生成、定制并即时使用高质量 React/Tailwind UI 组件,提升了组件的可组合性、可复用性和智能化集成体验。 具体操作 首先在 21st.dev 中挑选心仪的页面模版,一般可以在Heroestab 中查看,如: 选中风格后可点击查看: 然后可点击“Copy prompt”复制 prompt。 对于非开发人员 如果你不太懂 js 代码的话,可以忽略 prompt 中的内容。 然后我们在 AI 生成页面的平台中对原有页面进行修改。 如 Cursor: 1 2 3 4 5 你是一位专业前端开发,接下来我会给你一段提示词,提示词中包含一种页面风格的代码,你仔细分析一下提示词及代码,参考视觉风格、布局和动画效果,将样式尽可能应用到我的页面中,确保页面文案、核心功能和业务逻辑不受影响。提示词: \`\`\` 刚才拷贝的提示词 \`\`\` 如: 等待更新: 效果: 总结一下操作流程: 访问 21st.dev 在 Heroes 标签页选择心仪模板 复制 prompt 到 AI 工具中 按提示操作即可获得专业级页面 对于前端开发者 如果你本身就是一位前端开发且基本掌握 React 的话,可以直接用拷贝的 prompt 在你的 React 项目中进行生成或更新: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 You are given a task to integrate an existing React component in the codebase The codebase should support: - shadcn project structure - Tailwind CSS - Typescript If it doesn't, provide instructions on how to setup project via shadcn CLI, install Tailwind or Typescript. Determine the default path for components and styles. If default path for components is not /components/ui, provide instructions on why it's important to create this folder Copy-paste this component to /components/ui folder: \`\`\`tsx hero-parallax.tsx "use client"; import React from "react"; import { motion, useScroll, useTransform, useSpring, MotionValue, } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; export const HeroParallax = ({ products, }: { products: { title: string; link: string; thumbnail: string; }[]; }) => { const firstRow = products.slice(0, 5); const secondRow = products.slice(5, 10); const thirdRow = products.slice(10, 15); const ref = React.useRef(null); const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end start"], }); const springConfig = { stiffness: 300, damping: 30, bounce: 100 }; const translateX = useSpring( useTransform(scrollYProgress, [0, 1], [0, 1000]), springConfig ); const translateXReverse = useSpring( useTransform(scrollYProgress, [0, 1], [0, -1000]), springConfig ); const rotateX = useSpring( useTransform(scrollYProgress, [0, 0.2], [15, 0]), springConfig ); const opacity = useSpring( useTransform(scrollYProgress, [0, 0.2], [0.2, 1]), springConfig ); const rotateZ = useSpring( useTransform(scrollYProgress, [0, 0.2], [20, 0]), springConfig ); const translateY = useSpring( useTransform(scrollYProgress, [0, 0.2], [-700, 500]), springConfig ); return ( <div ref={ref} className="h-[300vh] py-40 overflow-hidden antialiased relative flex flex-col self-auto [perspective:1000px] [transform-style:preserve-3d]" > <Header /> <motion.div style={{ rotateX, rotateZ, translateY, opacity, }} className="" > <motion.div className="flex flex-row-reverse space-x-reverse space-x-20 mb-20"> {firstRow.map((product) => ( <ProductCard product={product} translate={translateX} key={product.title} /> ))} </motion.div> <motion.div className="flex flex-row mb-20 space-x-20 "> {secondRow.map((product) => ( <ProductCard product={product} translate={translateXReverse} key={product.title} /> ))} </motion.div> <motion.div className="flex flex-row-reverse space-x-reverse space-x-20"> {thirdRow.map((product) => ( <ProductCard product={product} translate={translateX} key={product.title} /> ))} </motion.div> </motion.div> </div> ); }; export const Header = () => { return ( <div className="max-w-7xl relative mx-auto py-20 md:py-40 px-4 w-full left-0 top-0"> <h1 className="text-2xl md:text-7xl font-bold dark:text-white"> The Ultimate <br /> development studio </h1> <p className="max-w-2xl text-base md:text-xl mt-8 dark:text-neutral-200"> We build beautiful products with the latest technologies and frameworks. We are a team of passionate developers and designers that love to build amazing products. </p> </div> ); }; export const ProductCard = ({ product, translate, }: { product: { title: string; link: string; thumbnail: string; }; translate: MotionValue<number>; }) => { return ( <motion.div style={{ x: translate, }} whileHover={{ y: -20, }} key={product.title} className="group/product h-96 w-[30rem] relative flex-shrink-0" > <Link href={product.link} className="block group-hover/product:shadow-2xl " > <Image src={product.thumbnail} height="600" width="600" className="object-cover object-left-top absolute h-full w-full inset-0" alt={product.title} /> </Link> <div className="absolute inset-0 h-full w-full opacity-0 group-hover/product:opacity-80 bg-black pointer-events-none"></div> <h2 className="absolute bottom-4 left-4 opacity-0 group-hover/product:opacity-100 text-white"> {product.title} </h2> </motion.div> ); }; demo.tsx "use client"; import React from "react"; import { HeroParallax } from "@/components/blocks/hero-parallax"; export function HeroParallaxDemo() { return ( <div className="min-h-screen w-full"> <div className="absolute top-0 left-0 w-full"> <HeroParallax products={products} /> </div> </div> ); } export const products = [ { title: "Moonbeam", link: "https://gomoonbeam.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/moonbeam.png", }, { title: "Cursor", link: "https://cursor.so", thumbnail: "https://aceternity.com/images/products/thumbnails/new/cursor.png", }, { title: "Rogue", link: "https://userogue.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/rogue.png", }, { title: "Editorially", link: "https://editorially.org", thumbnail: "https://aceternity.com/images/products/thumbnails/new/editorially.png", }, { title: "Editrix AI", link: "https://editrix.ai", thumbnail: "https://aceternity.com/images/products/thumbnails/new/editrix.png", }, { title: "Pixel Perfect", link: "https://app.pixelperfect.quest", thumbnail: "https://aceternity.com/images/products/thumbnails/new/pixelperfect.png", }, { title: "Algochurn", link: "https://algochurn.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/algochurn.png", }, { title: "Aceternity UI", link: "https://ui.aceternity.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/aceternityui.png", }, { title: "Tailwind Master Kit", link: "https://tailwindmasterkit.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/tailwindmasterkit.png", }, { title: "SmartBridge", link: "https://smartbridgetech.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/smartbridge.png", }, { title: "Renderwork Studio", link: "https://renderwork.studio", thumbnail: "https://aceternity.com/images/products/thumbnails/new/renderwork.png", }, { title: "Creme Digital", link: "https://cremedigital.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/cremedigital.png", }, { title: "Golden Bells Academy", link: "https://goldenbellsacademy.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/goldenbellsacademy.png", }, { title: "Invoker Labs", link: "https://invoker.lol", thumbnail: "https://aceternity.com/images/products/thumbnails/new/invoker.png", }, { title: "E Free Invoice", link: "https://efreeinvoice.com", thumbnail: "https://aceternity.com/images/products/thumbnails/new/efreeinvoice.png", }, ]; \`\`\` Install NPM dependencies: \`\`\`bash framer-motion \`\`\` Implementation Guidelines 1. Analyze the component structure and identify all required dependencies 2. Review the component's argumens and state 3. Identify any required context providers or hooks and install them 4. Questions to Ask - What data/props will be passed to this component? - Are there any specific state management requirements? - Are there any required assets (images, icons, etc.)? - What is the expected responsive behavior? - What is the best place to use this component in the app? Steps to integrate 0. Copy paste all the code above in the correct directories 1. Install external dependencies 2. Fill image assets with Unsplash stock images you know exist 3. Use lucide-react icons for svgs or logos if component requires them 21st.dev 中拷贝出页面/组件的 prompt 都是如上所示的结构,主要内容是: 介绍任务和技术栈信息 介绍组件代码 告诉 AI 怎么组织文件以及相关依赖项 另外如果不想使用 AI 的方式,我们可以直接点击组件的“Open Component”按钮查看并复制源码 另外由于 21st.dev 也提供了 MCP 的方式,我们也可以按以下操作: 登录 21st.dev,访问 Magic 控制台生成 API Key 通过 CLI 快速安装npx @21st-dev/cli@latest install <client> --api-key <API_KEY> 也可以在支持 MCP 的 IDE(cursor、windsurf、cline、claude)中手动配置: 1 2 3 4 5 6 7 8 9 "mcpServers": { "@21st-dev/magic": { "command": "npx", "args": ["-y", "@21st-dev/magic@latest"], "env": { "API_KEY": "<your-api-key>" } } } 配置完成后,在 IDE 的 AI 聊天(Composer)里即可使用 /ui 命令: 如创建组件/ui create a responsive navbar with logo and dark mode toggle 如果你不懂 React、也可以绕一道让 AI 将 React 组件转为 Vue、Angular 技术栈。 相关介绍 关于 21st.dev 的创办历程感兴趣可以看油管的这个视频:https://youtu.be/H2Mze4K5j6Q 背景起源 创始之初:由 Serafim(网名 @serafimcloud)于 2024–2025 年间创立,灵感源自分享、发现高质量 UI 组件的社区愿景。Serafim 曾与 Daniel Dhawan 合作开发 Rork.com(一个 AI 建站工具),后将其核心组件社区产品独立出来,继续打造 21st.dev 平台 愿景:目标是成为“Figma Community for code”,为设计工程师提供集中化、开源、可 remix 的 React + Tailwind UI 组件市场。 与传统组件库、模板页、Sandbox 的区别 AI 原生设计:21st.dev 的组件和模板结构、元数据均为 AI 理解和调用进行了深度优化,便于 AI 助手自动生成、组合和定制页面。这一点区别于传统组件库仅面向人工查找和手动集成。 IDE 与 AI 无缝集成:支持主流 IDE 插件和 AI 助手,开发者可在编辑器内直接搜索、插入、定制组件,远超传统模板页和在线 Sandbox 的割裂体验。 Remix 与二次创作:平台鼓励用户在现有组件基础上进行 Remix(再创作),并可一键分享,形成社区驱动的创作循环,而传统 Sandbox 多为孤立的代码实验环境,难以沉淀和复用成果。 社区开放与开源:21st.dev 鼓励开发者和设计师上传、分享组件,平台本身开源,促进知识共享和持续进化。 适用场景 高效页面搭建:前端开发者可快速组合高质量组件,极大提升开发效率,减少重复造轮子。 AI 驱动的界面生成:AI 开发者可借助平台的 AI 友好特性,实现自动化页面生成、智能布局和批量定制。 设计与开发协作:设计工程师可将设计稿转化为可复用组件,推动设计与开发一体化。 学习与创新:初学者可通过组件库学习最佳实践,进阶者可参与 Remix 和社区共创。 产品与特色 1. 组件注册表(Registry) 类似 npm 市场但专注 UI 组件,鼓励发布“minimal、modern、reusable”的 React + Tailwind + Radix UI 组件。 内置分类丰富,比如导航栏、卡片、按钮、表单、对话框等组件供开发者浏览、复制、安装。 2. 魔法生成器:Magic MCP 上文也有大致的使用介绍。 Magic MCP是一款 AI 驱动的 IDE Agent,目前支持 Cursor、Windsurf/ Cline、VS Code 等。 使用者只需输入如 /ui pricing table 的描述,Magic 会生成多个变体,自动生成组件文件及依赖配置,极大提升开发速度。 另外在官网上也支持类似 bolt、v0 这样的 chat 生成模式:Magic Chat 3. 发布流程和社区治理 组件发布仅需一键,上传后先进入 on_review 审查阶段,由 Serafim 亲自审核,合格后可提升为 posted 或 featured。 提倡 TypeScript 支持、深度可定制性(支持暗黑模式、CSS 变量、ARIA 可访问性)和结构化 Demo 规范。 4. 架构技术栈 前端采用 Next.js 14 后端使用 Supabase(存储元数据)、Clerk(验证)、Cloudflare R2(静态资源)、Amplitude(分析)。 开放源码托管在 GitHub,社区贡献活跃(~4.6k Stars,200+ forks),https://github.com/serafimcloud/21st。 5.商业模式 订阅制(SaaS 模式):21st.dev 采用月/年订阅机制,目前定价$16 ~ 32/月,官网可见具体价格,为专业和高阶用户提供完整的 AI 辅助功能,包括 Magic MCP、Magic Chat、UI 灵感支持等。这是一种典型的持续性付费服务,适合长期使用并依赖高效 UI 开发的开发者团队 。 免费策略:平台对所有用户开放基础浏览与复制组件功能,引入用户;高级功能(如 AI 生成、多 Tokens 支持、优先客服等)则通过付费订阅提供。这种“基础免费 → 高级付费”的模式符合常见 SaaS+Freemium 叠加架构 。 总结 21st.dev 通过其 AI 友好的设计理念,成功解决了 AI 生成页面”塑料感”的问题。无论你是专业开发者还是设计新手,都能通过这个平台快速创建具有专业水准的页面设计。 随着 AI 在前端开发中的深入应用,21st.dev 将继续优化其 AI 集成体验,为开发者提供更强大、更智能的工具支持。 未来展望 市场反响与发展 Product Hunt:评分 4.9/5,1.9k Followers,多位用户评价其平台能力“简化前端开发流程”“组件中心化管理大幅提升效率” 媒体与融资:被 a16z 报道为 AI 开发未来趋势之一,已获得种子轮融资支持;具体金额未披露。 社区活力:官方 Discord、Twitter/X 持续更新,LinkedIn 上亦频繁分享优质组件,目前用户粘性高,具备较强的商业化基础。 21st.dev 致力于成为连接设计、开发与 AI 智能的桥梁。随着 AI 在前端开发中的深入应用,平台将持续优化 AI 集成体验,丰富组件生态,推动前端开发的智能化与协作化。 相关链接 21st.dev https://youtu.be/H2Mze4K5j6Q https://github.com/serafimcloud/21st https://glama.ai/mcp/servers/%4021st-dev/magic-mcp?utm_source=chatgpt.com

2025/6/29
articleCard.readMore

【笔记】Figma和AIGC(持续)

Figma是一款基于云的协作式界面设计工具,支持多人实时编辑,适用于UI/UX设计、原型制作及设计交付。提供矢量绘图、组件库、交互动画等功能,支持跨平台使用(网页/桌面端),实现团队高效协作与版本管理。 Figma类似早些年的Sketch、PhotoShop,但具备在线多人协同能力以及社区插件体系。 这两年随着ai应用化的发展,ai生成figma设计稿以及figma设计稿生成前端代码这两个能力正在迅速发展。 生成Figma 场景1:复刻其他页面 当前能力评估:UI界面90%复刻,已到可应用的状态 主要工具:html-to-figma 当前主流实现路径1: Chrome浏览器插件,爬取网站UI信息 -> 生成信息文件(json) -> Figma插件中导入信息文件生成设计稿 需要安装一个chrome插件,插件有很多:代表如buildio的“HTML to Figma”,腾讯CoDesign-HtmltoDesign: 插件已开源:https://github.com/BuilderIO/figma-html (buildio官方近期在迁移这个插件,集成到了buildio插件中https://www.builder.io/c/docs/chrome-extension#paste-from-chrome-into-figma) 另外在Figma上需要安装一个接收设计信息的Figma插件:https://www.figma.com/community/plugin/747985167520967365/builder-io-ai-powered-figma-to-code-react-vue-tailwind-more 使用步骤: 1.打开需要复刻的页面,如google搜索结果页面,点击chrome插件“CAPTURE PAGE”按钮,即开始导出页面UI信息 得到类似page.figma.json的json信息文件 2.Figma中打开Figma-to-Code插件,上传json 即可得到对应设计稿 *现在这类插件也集成了直接输入url地址生成Figma的能力 类似的产品还有:https://demoway.com/html-to-figma 除了从真实web页面中复刻外,我们也可以借助Cursor/Windsurf这些AI IDE,让其分析代码并生成Figma、不过注意模型需要支持多模态、一般可用Claude3.x/4 可参考本文:《五分钟!Cursor+Claude3.7直接生成一整套原型图/UI稿》https://mp.weixin.qq.com/s/6c1L_sAf3pxJqtJXfGhr-Q 场景2:ai生成Figma 当前能力评估:简单/通用场景生成效果可应用,复杂页面还需要大量调整 通过自然语言生成设计稿,现在Figma官方也在做这方面的能力,如Figma AI:https://www.figma.com/ai/?utm_source=ai-bot.cn 还有各类Figma插件 https://mmmnote.com/article/7e8/12/article-4c48bb165ef888ff.shtml 另外Google也推出了设计Agent:Stitch,只需要输入一句话/一段话,Stitch就可以输出生产级别的UI设计稿,并且支持直接复制粘贴到Figma中进行二次编辑,或者导出相应代码。 操作可参考:《Google Stitch:2分钟从想法→可编辑的Figma设计稿(附5个使用技巧)》https://mp.weixin.qq.com/s/YNEY3rprhueESYh6AD3cTA Figma to Code(前端) 这块东西就很多了 方式1:*Figma官方:Figma sites 当前能力评估:静态页面/官网这些场景可应用,其他复杂场景还不行。能力正在发展 https://www.figma.com/sites/ (Figma可以通过第一阶段aigc或者复刻生成) 点击preview可查看页面效果 方式2:Figma MCP 当前能力评估:大多静态场景可应用,复杂场景需要代码调整。能力正在发展 非官方但也在迅速发展,使用方式可参考Trae:https://mp.weixin.qq.com/s/AlzeT_1OeFzS6mRC3PDNOA、Cursor:https://zhuanlan.zhihu.com/p/1897249661569447152 https://www.framelink.ai/,其实现也已开源:https://github.com/GLips/Figma-Context-MCP

2025/5/25
articleCard.readMore

【笔记】State-Of-Ai 报告信息

【笔记】State-Of-Ai 报告信息 一、State-Of-Ai Web Dev 2025 报告摘要 2025.stateofai.dev是面向 Web 开发者的一次 ai 使用和问题情况统计,完整报告:https://2025.stateofai.dev/en-US/models/。其访问组织之前也有state-of-js、state-of-css等典型统计系列。 在这篇state-of-ai统计中,受访者人数规模在5000 - 10000左右,国家主要集中在美欧、中国受访者只有 25 人,因此实际情况和国内会有偏差。 个人概括下这份报告的主要信息:Web 开发者对 AI 应用持有积极的态度,乐于使用并看好其发展。开发者使用 ai 的主要场景在代码生成,目前使用主要问题在幻觉和不确定,另外生成的代码质量和上下文限制也影响了其效果,以至于较大占比的生成代码需要重构。 1.1 Model Providers 模型提供商情况 首先,毫不奇怪,OpenAI 的 ChatGPT 仍然受益于其先发优势和巨大的市场份额,使其成为最常用(91.2%)和最受欢迎(53.1%)的模型提供商。 虽然它在使用方面确实领先很大,但在积极情绪(个人觉得这个指标可以理解为好评情况、受欢迎度)方面,第二的 Claude 并没有落后太多,为45.9%。 因为国内受访者很少,所以像国内常用的千问、豆包等模型统计占比很少。 Model Providers Pain Points 模型提供者的痛点 幻觉和不准确是迄今为止受访者报告的 AI 模型的最大痛点,这是有道理的,因为如果这些工具的输出不可靠,它们就会失去所有效用。 缺乏处理更大上下文和将数据保存在内存中的能力也是一个大问题,尤其是在处理大型真实代码库时。 1.2 IDE 和编辑器情况 Cursor 在认知度上处于领先地位,82.2%的受访者使用过或听说过它,而第二的 Zed 只有54.1%。 当看到关于 Cursor 的随意评论时,似乎主要问题实际上是它的价格,这表明市场可能有更便宜的替代品的空间。 IDE 使用痛点 上下文和内存限制是目前阻止 Web 开发人员使用专用 IDE 进行编码的主要原因,其次是太多的侵入性建议以及与 AI IDE 相关的高成本。 1.3 Coding Assistants 编码助理使用情况 虽然看到 GitHub Copilot 在使用量和积极情绪排名中名列前茅并不奇怪,但值得注意的是,Supermaven 的积极情绪排名第二,尽管就使用量而言仅排名第四。这种差异通常是一颗新星的标志,它可能会在未来一年掀起波澜。 Coding Assistants Pain Points 编码助手痛点 幻觉和不准确再次被证明是更广泛采用的一大障碍。 就像 IDE 一样,具有有限上下文窗口的编码助手也是一个主要问题。 1.4 Code Generation 代码生成情况 凭借 Vercel 的实力,v0 迅速确立了自己在这个新兴行业领域的领导者地位。但是 StackBlitz 的 Bolt 也不甘落后,当然值得关注。 Code Generation Pain Points 代码生成痛点 代码生成工具似乎生成了质量差的代码,这些代码通常不能按预期工作,或者根本不能工作。 1.5 Other Tools 其他工具 使用 ai 工具时的编程语言 主要还是 js/ts 和 python。 图像生成模型 主要是 DALL·E 和 MJ。 AI 封装库/SDK 希望浏览器支持 API 情况 人工智能模型很可能在未来融入我们的网络浏览器——如果发生这种情况,像即时翻译或总结内容这样的事情可能只是一个 API 调用。 1.6 Usage 用法 正如开发人员调查所预期的那样,代码生成被列为最常见的人工智能用法。另一方面,尽管图像生成是生成人工智能的原始用例,但只有38%的受访者表示使用它。 代码生成占比情况 大多数人还没有完全进行氛围编码(vibe coding),大多数受访者(69%)通过 AI 生成的代码不到25%——只有一小部分(8%)生成了超过75%的代码。 AI 代码重构情况 即使使用 AI 生成代码,绝大多数(76%)的开发人员表示他们必须重构至少一半的输出代码才能准备好使用。 重构的首要原因是表面问题,如易读性差、变量重命名和过度重复。许多受访者还使用自由形式的“其他答案”字段来声明生成的代码通常无法按预期工作。 使用 AI 生成代码频率情况 46%的受访者每天多次或更多次使用人工智能生成代码。 与代码生成相比,人工智能用于其他任务(研究、总结、翻译等)的频率相对较低——考虑到编码仍然是我们花费最多时间的事情,这是有道理的。 使用 AI 生成代码的场景情况 最常见的生成代码类型被证明是辅助函数,其次是前端组件,它们都相当独立,使它们成为代码生成的良好候选者。 许多人还使用人工智能为现有代码添加留档或注释,这是一个意想不到的用例。 使用 AI 生成代码的消费支出情况 大多数受访者目前没有在人工智能工具和服务上花费任何自己的钱。 受访者所在公司在 AI 工具上的支出情况遵循马蹄形模式,公司不会在人工智能上花费任何费用——除非他们花费超过 5000 美元!这种定价模式是否对人工智能公司来说是可持续的还有待观察。 AI 各工具应用的使用痛点 在 AI 痛点方面,整体代码质量差排名第一。 觉得这些 ai 或工具缺少的功能 今天的模型缺少的主要东西是将整个代码库保存在内存中的能力,如果 AI 工具旨在帮助我们维护应用程序,而不仅仅是对它们进行原型设计,那么这一点将被证明是关键。 另外,尽管调查强调了各种痛点,但受访者总体上对 2025 年人工智能用于 Web 开发的状态非常积极。 1.7 Resources 信息资源 播客: 视频创作者: 二、Vercel State-Of-Ai 2024 报告信息 是 Vercel 对于 V0 使用者的调研报告,完整报告:https://vercel.com/state-of-ai。 个人概括下这份报告的主要信息:V0 的使用者看好 AI 的潜力和未来发展,并未接下来的技术进步做准备、如提前准备模型切换能力。虽然目前 OpenAI 是领先的提供商,但开发人员正在积极测试替代方案。AI 应用的重点正在转向面向客户的功能,专注于现实世界的价值,当前模型准确性和成本等挑战仍然是关键问题。开发者成功需要仔细评估、战略规划和灵活实施以适应变化。 2.1 模型使用情况 OpenAI 正在引领模型采用,但竞争正在迎头赶上。虽然 OpenAI 仍然是88%采用率的主要选择,但开发人员与多个提供商保持关系——平均两个。随着提供商竞相完成,开发人员的忠诚度在六个月内受到65%更换提供商的考验。 2.2 应用和价值创造情况 虽然这个市场还很年轻,有很多未开发的机会,但急功近利的时代已经结束了。用户现在对人工智能的期望更高。询问更深入的问题,关于人工智能如何增强用户体验的各个方面,并使其成为产品开发的核心,而不仅仅是一个附加组件。 聊天机器人(44%)和产品功能(79%)之间的差距揭示了向更深层次的人工智能集成的转变 矢量数据库采用(70%)标志着 AI 基础设施的成熟 网站个性化(24%)仍未得到充分开发,这暗示着未来的机会 2.3 开发实践和效率情况 今天的团队通过智能技术选择而不是巨额预算来构建高需求的人工智能系统。AI 团队以精益预算构建强大的系统,每月花费不到 1,000 美元。他们通过使用 RAG、智能数据采购和云平台跳过昂贵的培训,无需繁重的基础设施即可交付快速、可靠的模型。 团队通过智能架构和增强生成而不是定制模型训练来优化成本 每周模型更新正在成为标准,这表明 Vercel 等提供商支持的快速迭代实践 大多数团队将手动测试与基于经验的发布配对,但是度量驱动的评估正在出现 手动测试仍然很常见,但指标驱动的评估表明质保越来越复杂。 团队将公共数据集、网络抓取和客户数据与 RAG 定制相结合,以提供精确的输出,而无需定制模型培训的开销。 2.4 优先级和组织结构 人工智能发展进入务实阶段。构建成功的 AI 功能并不一定需要专门的部门。专注于高影响力的用例,同时保持团队结构精简。机会在于发现人工智能在哪里增加了真正的价值,并有效地利用可用资源。 许多人将有意义的技术预算(超过 15%)用于人工智能,但不打算发展专门的人工智能团队。他们正在寻找方法,通过赋予现有团队更好的工具和明确的目标来构建复杂的人工智能功能。 团队选择精益集成而不是专业部门 现有产品团队推动人工智能创新 正在平衡构建新功能和扩展现有功能之间的优先级 2.5 信仰和观点 人工智能市场在炒作和实际影响之间找到了最佳位置。团队认为当前的人工智能工具被夸大了,但他们预计人工智能将在 12 个月内对他们的行业产生重大影响。他们对未来感到兴奋,但立足于现在。 团队相信人工智能的未来,同时对当前工具保持现实 开源和微调被证明是有用的,但还没有改变游戏规则 每个人都在为明年的重大进步做准备,用现在有效的东西建造,但为即将到来的东西设计 巨大的变化就在前方,即使我们还没有到那一步。要意识到当前的限制和挑战,同时对人工智能的变革潜力保持乐观。 相关链接 https://2025.stateofai.dev/ https://vercel.com/state-of-ai

2025/5/5
articleCard.readMore

【笔记】web 黑夜模式通用适配方案

【笔记】web 黑夜模式通用适配方案 一、判断黑夜模式的方式 1.1 CSS 媒体查询判断黑夜模式的属性——prefers-color-scheme prefers-color-scheme CSS 媒体特性用于检测用户是否有将系统的主题色设置为亮色或者暗色。 ——MDN 值: no-preference:表示系统未得知用户在这方面的选项。在布尔值上下文中,其执行结果为 false。 light:表示用户已告知系统他们选择使用浅色主题的界面。 dark:表示用户已告知系统他们选择使用暗色主题的界面。 如 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 /* Light mode */ @media (prefers-color-scheme: light) { html, body { color: black; background-color: white; } } /* Dark mode */ @media (prefers-color-scheme: dark) { html, body { color: white; background-color: black; } } 1.1.1 兼容情况 除了 IE 外,其他主流浏览器基本都支持prefers-color-scheme。 1.2 JavaScript 判断黑夜模式的属性——matchMedia Window 的 matchMedia() 方法返回一个新的 MediaQueryList 对象,表示指定的媒体查询字符串解析后的结果。返回的 MediaQueryList 可被用于判定 Document 是否匹配媒体查询,或者监控一个 document 来判定它匹配了或者停止匹配了此媒体查询。 ——MDN window.matchMedia(xxx) 返回一个 listenable-like 对象 MediaQueryList, 它继承自 EventTarget, 这意味着可以通过直接它获得最新的 MediaQuery 检测情况: 1 2 3 4 5 if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { // Dark mode } else { // Light mode } 1.2.1 监听主题变化 与 CSS 媒体查询不同,matchMedia 我们需要监听主题的变化作出对应处理,如下所示: 1 2 3 4 5 6 7 8 9 const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)'); darkModeQuery.addListener(e => { if (e.matches) { // Dark mode } else { // Light mode } }); 1.2.2 兼容情况 主流浏览器基本都支持matchMedia。 二、黑夜模式样式适配处理方案 2.1 CSS 纯媒体查询实现 适用场景:需要快速实现、无需用户自定义主题的项目。如 1 2 3 4 5 6 7 @media (prefers-color-scheme: dark) { body { background-color: #1a1a1a; color: #e0e0e0; } /* 其他元素深色样式 */ } 优缺点分析 优点: 零 js 依赖,纯 css 实现 系统级实时响应(跟随系统设置即时切换) 维护成本最低 缺点: 不能保存用户偏好(比如用户想覆盖系统设置) 2.2 CSS 变量 + 媒体查询 适用场景:中大型项目、需要多主题扩展、已使用 CSS 变量的代码库。 如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 :root { --bg-color: #ffffff; --text-color: #333333; } @media (prefers-color-scheme: dark) { :root { --bg-color: #1a1a1a; --text-color: #e0e0e0; } } body { background-color: var(--bg-color); color: var(--text-color); } 优缺点分析 优点: 集中管理颜色变量 方便扩展多主题 支持渐进增强 缺点: 仍无法保存用户偏好 需要统一使用 CSS 变量规范 2.3 js matchMedia 动态切换 + 本地存储 适用场景:需要用户自定义主题的 ToC 产品、重视用户体验的 Web 应用。 如: 1 <button id="themeToggle">切换主题</button> 1 2 3 4 5 6 7 8 9 body.light-mode { --bg-color: #ffffff; --text-color: #333333; } body.dark-mode { --bg-color: #1a1a1a; --text-color: #e0e0e0; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const themeToggle = document.querySelector('#themeToggle'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); // 初始化主题 function initTheme() { const savedTheme = localStorage.getItem('theme') || (prefersDark.matches ? 'dark' : 'light'); document.body.classList.add(`${savedTheme}-mode`); } // 切换主题 themeToggle.addEventListener('click', () => { document.body.classList.toggle('dark-mode'); document.body.classList.toggle('light-mode'); const currentTheme = document.body.classList.contains('dark-mode') ? 'dark' : 'light'; localStorage.setItem('theme', currentTheme); }); // 监听系统变化 prefersDark.addListener(e => { if (!localStorage.getItem('theme')) { // 只在用户未手动选择时响应系统 document.body.classList.toggle('dark-mode', e.matches); } }); 优缺点分析 优点: 支持用户偏好保存 同时响应系统和手动切换 最佳用户体验 缺点: 需要维护两套样式,依赖 js 处理。实现复杂度最高 2.4 js matchMedia 动态切换 + CSS-in-JS 运行时方案 适用场景:适合 React 等框架,已使用 CSS-in-JS 方案 如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // 使用styled-components示例 import { createGlobalStyle, ThemeProvider } from 'styled-components'; const lightTheme = { bg: '#fff', text: '#333' }; const darkTheme = { bg: '#1a1a1a', text: '#e0e0e0' }; const GlobalStyle = createGlobalStyle` body { background: ${props => props.theme.bg}; color: ${props => props.theme.text}; } `; function App() { const [isDark, setIsDark] = useState(window.matchMedia('(prefers-color-scheme: dark)').matches); useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handler = e => setIsDark(e.matches); mediaQuery.addListener(handler); return () => mediaQuery.removeListener(handler); }, []); return ( <ThemeProvider theme={isDark ? darkTheme : lightTheme}> <GlobalStyle /> {/* 页面内容 */} </ThemeProvider> ); } 优缺点分析 优点: 完美配合组件化开发 主题状态可全局管理 支持动态主题切换 缺点: 强依赖特定框架 需要 CSS-in-JS 体系支持 2.5 *CSS 媒体查询 + filter 滤镜处理 适合场景:要求最快上线、或最小改动。本质其实也归属于方案 1(2.1) 如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 /* 整个页面增加滤镜 */ @media (prefers-color-scheme: dark) { html { filter: invert(1) hue-rotate(180deg); } /* 图片、视频等元素不需要处理 */ img, video, .logo, .icon { filter: invert(1) hue-rotate(180deg); } } 滤镜设置解释 整体滤镜处理可以概括为:反色 + 调整色相 invert(1): invert() 函数用于反转输入图像中的颜色。参数定义了转换的程度。如果参数是 1(或者 100%),则会完全反转颜色,即每个颜色通道的值都会被替换为其补色。例如:黑色变成白色,白色变为黑色等。 当使用 invert(1) 时,则表示将图像的颜色彻底反转,即是:黑色变成白色,白色变为黑色。 hue-rotate(180deg): hue-rotate() 函数按照给定的角度旋转色彩轮上的颜色,其实就是冲淡颜色。这里的“角度”是指在标准色轮上转动多少度。色轮是一个圆形图表,显示了不同颜色如何根据它们的色调相互关联。 当使用 hue-rotate(180deg) 时,意味着所有颜色都会在其原始位置基础上沿着色轮顺时针方向移动 180 度。比如红色会变成青色、绿色变成洋红色、蓝色变成黄色等,因为这些是在色轮上相对的颜色。 更多信息可见MDN - invert、MDN - hue-rotate 兼容情况 除 IE 外,主流浏览器基本能支持。 优缺点分析 优点: 开发和维护成本都是最低 零 js 依赖,纯 css 实现 系统级实时响应(跟随系统设置即时切换) 缺点: 不能或很难自定义色值 不能保存用户偏好(比如用户想覆盖系统设置) 三、黑夜模式色值设计标准 黑夜模式的色值设计和黑夜模式的工程实现没啥关系,属于用户体验保障。 3.1 WCAG 标准 WCAG(Web Content Accessibility Guidelines) 是由 W3C 制定的国际无障碍标准。其中颜色设置也是一项重要标准。 法律意义:WCAG 在美国《ADA 法案》、欧盟《EN 301 549》等法规中被引用 一些具体要求 核心要求 元素类型最小对比度例外场景 普通文本(<18pt/24px)4.5:1装饰性/禁用状态文本 大文本(≥18pt/24px)3:1粗体大文本(≥14pt/18.5px 粗体) 图形控件(图标/按钮)3:1纯装饰性图形 计算公式 使用相对亮度(Luminance)公式: 1 Contrast Ratio = (L1 + 0.05) / (L2 + 0.05) 其中: L1 = 较亮颜色的相对亮度(0~1) L2 = 较暗颜色的相对亮度(0~1) 实际开发中的应用 颜色选择示例 场景通过示例失败示例 白底黑字#FFFFFF vs #000000 (21:1)#FFFFFF vs #666666 (5.74:1) 深色模式#1A1A1A vs #E0E0E0 (10.3:1)#333333 vs #999999 (3.31:1) 调试工具 自动检测: 1 2 3 4 5 # Chrome DevTools Styles面板 → 点击颜色选择器 → 显示对比度比率 # VS Code插件 "WCAG Color Contrast Checker" 在线工具: WebAIM Contrast Checker Contrast Ratio 设计技巧 1 2 3 4 5 6 7 8 9 10 11 /* 安全配色方案示例 */ :root { --safe-dark-bg: #1a1a1a; /* 避免纯黑(#000) */ --safe-light-text: #e0e0e0; /* 避免纯白(#fff) */ --accent-color: #007bff; /* 品牌色需单独验证 */ } /* 禁用状态处理 */ button:disabled { opacity: 0.6; /* 需重新验证对比度 */ } 深度注意事项 动态场景: 悬停/聚焦状态的对比度需保持合规 渐变/阴影覆盖区域需取最差值验证 字体特性: 300 以下字重需提高对比度要求 衬线字体可能需要额外对比度补偿 环境适配: 移动设备户外模式需考虑屏幕反光影响 OLED 屏幕需验证 PWM 调光下的可读性 法律合规案例 Target 诉讼案(2006):因对比度不足赔偿$6 百万 Domino’s 披萨案(2019):网站对比度违规败诉 英国政府:强制要求所有公共网站通过 AA 认证 进阶建议 1.AAA 级目标: 普通文本:7:1 大文本:4.5:1 无障碍测试: 1 2 3 4 5 # 使用屏幕阅读器验证 NVDA (Windows) / VoiceOver (Mac) # 自动化工具 axe DevTools / Lighthouse 设计系统集成: 1 2 3 4 5 6 7 8 9 10 11 // Storybook等工具集成对比度检测 addon-a11y: { config: { contrast: { thresholds: { AA: 4.5, AAA: 7 } } } } 通过严格遵循这些标准,可确保您的深色模式既美观又合规。建议将对比度检测纳入 CI/CD 流程,实现自动化保障。 相关链接 MDN - prefers-color-scheme MDN - matchMedia MDN - filter MDN - invert MDN - hue-rotate WCAG

2025/4/26
articleCard.readMore

【笔记】19届阿里D2终端技术大会纪要

D2大会纪要 2025.03.08 19届阿里D2终端技术大会 ( Mobile Developer & Frontend Developer Technology Conference, 简称 D2 ),由阿里巴巴终端技术委员会创办,面向全球终端开发领域(前端 & 客户端)技术人。这次整体感受还不错,有些收获。 整体会议资料地址:https://github.com/d2forum/19th,个人推荐阅读:《⽣成式 UI: AI时代体验技术新范式》、《AI 时代生存指南:前端的技术壁垒与竞争力重塑》、《深度融合AI的低代码平台》 个人归纳的会议信息: 1.高维度上,在AI交互和终端载体的演进趋势下,整体互联网载体和交互形态都发生了转变 从 https://www.notion.so 到 在产业端,领域大模型会长期存在,并会被持续用于改善生产关系 “可塑性软件 Malleable Software” Local LLM Agent、”Agentic” 跟随⽣产⼒进步 还有端智能等等 2.软件生产模式上,⽣产⼒的提升带来⻆⾊的合并 从真实需求出发, 用技术创造价值 产研模式的转变(感觉应该是漏斗状) 但是专业开发还是需要技术人员辅助,注意此时开发的角色侧重点 3.面向业务,大厂的做法集中在解决具体业务问题(“点”/“面”) 实现“基础功能马上跑,多语言版不能少,PC移动全都要,用户体验还必须好” 4.实际业务场景和工程对模型要求从“通用”到“具体” ai编程和低码 5.*前端生产链趋势“归一化“,卷通用性、卷性能 6.*跨端近一年的主题基本都是适配harmonyNext,开发范式更像web开发了、通过自绘渲染、C++改造和线程管理等优化,性能更强了 7.对于程序员个人而言,普遍观点是AI 是双刃剑 —— 大幅提升效能但稀释程序员稀缺性 程序员的核心价值是解决业务问题,程序/ai都是工具和手段,不要用程序员标签约束自己,主动寻找有价值的需求。 1.从交付前端到交付价值 ”做⼀个好的 AI“,比如能审查、维护 AI 生成的代码 、能解决 AI 代码的 Bug 从先做点什么开始,顺应潮流。善用 AI 工具、智能体编排将会是必备能力,做好与 AI 协作的意识准备 2.复利、长期坚持: 坚持做长期有利的事,比如写作、分享、 学习。随着时间推移能越积越多,比如技能、写作能力、社交媒体上的影响力、产品的用户。 在 AI 时代构建前端技术壁垒 ⾯向 AI 做技术选型 编码:面向开发者友好 -> 面向ai友好(更易被ai理解/生成/修改) 技术栈选型:主流框架有更多的用户基础、训练语料、更好的ai生成/提示效果,使⽤ AI 友好的技术栈。TailwindCSS 3,react+tailwind+ts,马太效应 代码组织模式:传统的代码组织模式受到挑战,打包 -> ⽣成,拆分 -> 单⽂件,可维护代码 -> 可抛弃代码,适应新的项⽬组织⽅式。反模式 掌握 AI 数据处理与渲染⽅案 lodash -> ai_web_runtime,AI 模型也是是前端技术栈的⼀部分 包含网站、图等多媒体信息由ai接管渲染,关注端侧模型的发展和落地 其他技术栈/架构 低码/DSL解决方案 编码开发 质检 跨端 相关链接: https://github.com/d2forum/19th https://www.scaler.com/topics/deep-learning/onnx-model/ https://github.com/snakers4/silero-vad https://github.com/vthinkxie/ai-recorder https://websim.ai/ https://www.geoffreylitt.com/2023/03/25/llm-end-user-programming.html https://devin.ai/ https://manus.im/ https://x.com/lepadphone/status/1896212860013031615

2025/3/15
articleCard.readMore

【笔记】Lovable提示词指南

Lovable提示词指南 Lovable是一款目前海外流行的ai编码平台,和v0/bolt一样面向”Citizen Dev”,主要能力是通过图片/设计稿/自然语言生成网页,地址:https://lovable.dev/ 本文信息来自Lovable团队今年一月的一篇博文:《The Lovable Prompting Bible》 大多数人都有一个错误的观点:认为提示词(prompt)只是在AI中输入请求并希望得到最好的结果。 获得平庸的响应和让AI为您构建整个工作流程之间的区别归结为您如何提示。无论您是开发人员还是非技术用户,掌握提示工程都可以帮助您: 自动化重复性任务 使用AI生成的解决方案更快地调试 轻松构建和优化工作流程 最好的部分是什么?你不需要成为专家。有了正确的提示技术,你可以在 Lovable, make.com和n8n中释放人工智能的全部潜力——而不会在反复试验上浪费时间。 中心观点: 有效的提示很重要:构建提示以节省故障排除时间。 元提示(Meta prompting):使用AI本身来改进提示以提高准确性。 反向元提示(Reverse meta prompting):保存调试会话以优化未来的请求。 自动化工具:使用make.com和n8n使用API扩展Lovable的功能。 聊天模式与默认模式:何时使用每个模式进行调试和迭代。 处理webhooks:通过强大的集成自动化Lovable应用程序。 为什么提示对人工智能开发至关重要 与传统编码不同,人工智能应用程序依赖于结构化通信。为人工智能提供清晰的上下文和约束可确保高质量的输出。在Lovable的Lovable专家会议上,来自Prompt Advisors的Mark演示了开发人员和非技术用户如何增强他们的人工智能提示技术,以更快地构建、更智能地调试和自动化复杂的工作流程。(对应Youtube视频地址:https://youtu.be/IqWfKj4mUIo) 了解人工智能的“心态” 人工智能模型,包括那些支持Lovable的模型,不能以人类的方式“理解”——它们根据模式预测反应。为了有效地指导他们: 明确(Be explicit):不要“构建登录页面”,而是指定“使用React创建登录页面,并进行电子邮件/密码身份验证和JWT处理。” 设置约束(Set constraints):如果您需要特定的技术堆栈(例如,用于身份验证的Supabase),请清楚地说明它。 使用格式化技巧(Use formatting tricks):AI优先考虑提示的开头和结尾-将重要细节放在前面。 掌握提示:四个层次 1.“辅助轮”提示 一种用于清晰AI指令的结构化标记方法: 1 2 3 4 # Context ## Task ### Guidelines #### Constraints 示例: 1 You are a world-class prompt engineer. Write me a prompt that will generate a full-stack app taking an input of name, number, and company, and generate a company report. 2.没有“辅助轮” 更多对话提示,同时保持清晰。 3.元(Meta)提示 利用AI来改进您的提示: 1 Rewrite this prompt to be more concise and detailed: 'Create a secure login page in React using Supabase, ensuring role-based authentication.' 4.反向元提示 调试时,让AI记录流程以供将来使用: 1 Summarize the errors we encountered while setting up JWT authentication and how they were resolved. Create a detailed prompt I can use next time. 提示库 你的提示质量会显著影响AI的输出。这就是有效提示的本质:你的提示越精细,你收到的输出质量就越高。一个全面且组织良好的提示可以通过减少错误来节省你的学分和时间。因此,这些步骤绝对值得考虑: 1 2 3 Provide as much details as you can in the input field. Use the "Select" feature to precise edit your component. Enhance your prompt with the experimental "Chat mode". 开始一个新项目 1 2 3 4 Start with "I need a [type] application with:" Elaborate on tech stack - including Frontend, styling, Authorization and Database. Elaborate on core features including main and secondary features. Then direct the AI to start somewhere like: "Start with the main page containing: [Detailed page requirements]". 但是,我们始终建议用户从一个空白项目开始,然后逐步构建它。这种方法允许AI在深入研究细节之前有效地掌握基本概念。 差异和选择 每当您请求Lovable在任何文件中实现特定更改时,它都会重写整个文件或修改现有内容。为确保AI仅更新相关文件,请提供明确的说明。这种方法鼓励AI仅编辑必要的部分,从而对仅几行代码进行最小的更改。通过这样做,您可以减少加载时间并防止错误循环。 我之前在调整现有功能时应用的有效提示是: 1 Implement modifications to the feature while ensuring core functionality, other features, and processes remain unaffected. Evaluate its behavior and dependencies to identify potential risks, and discuss any concerns before moving forward. Conduct thorough testing to verify there are no regressions or unintended consequences, and highlight any out-of-scope changes for review. Exercise caution—take a moment to pause if uncertain. 锁定文件 Lovable目前缺乏内置的文件锁定系统。但是,您可以通过对提示进行轻微修改来引导人工智能。只需在每个提示中包含以下说明:“请不要更改第X页或第Y页,并将更改的焦点仅集中在第Z页上。” 如果您正在更新现有功能而无意修改某些合理的内容,您也可以尝试此提示: 1 This update is quite delicate and requires utmost precision. Carefully examine all dependencies and potential impacts before implementing any changes, and test systematically to guarantee nothing is disrupted. Steer clear of shortcuts or assumptions—take a moment to seek clarification if you're unsure. Precision is crucial. 设计 在Lovable上设计一些东西是有效的,因为Lovable已经很有品味了;)尽管如此,以下提示可以帮助您改进这些设计实现: 1.UI更改: 1 Make solely visual enhancements—ensure functionality and logic remain unaffected. Gain a comprehensive understanding of how the existing UI interacts with the app, ensuring that logic, state management, and APIs stay intact. Conduct extensive testing to verify that the app operates precisely as it did before. Cease all actions if there is any uncertainty regarding potential unintended consequences. 2.针对移动端进行优化: 1 Enhance the app's mobile experience while preserving its existing design and functionality. Assess the layout and responsiveness to pinpoint essential modifications for smaller screens and touch inputs. Develop a comprehensive plan before making any code changes, and conduct thorough testing across various devices to guarantee the app operates as intended. If uncertain, take a moment to consider and suggest potential solutions. 3.响应能力和断点提示: 1 Make certain that all designs are completely responsive at every breakpoint, adopting a mobile-first strategy. Apply contemporary UI/UX best practices to define how components should adjust for varying screen sizes, utilizing ShadCN and Tailwind’s standard breakpoints. Steer clear of custom breakpoints unless specifically requested. 4.规划: 1 Before editing any code, create a phased plan for implementing responsiveness. Start with the largest layout components and progressively refine down to smaller elements and individual components. Ensure the plan includes clear steps for testing responsiveness across all breakpoints to maintain consistency and a seamless user experience. Share the plan for review before proceeding. 在进行任何代码编辑之前,制定实施响应性的结构化计划。从最大的布局组件开始,逐渐深入到较小的元素和特定组件。确保计划概述了在所有断点测试响应性的明确步骤,以保证一致性和流畅的用户体验。在继续前进之前提出反馈计划。 知识库 提供关于您的项目的详细上下文至关重要,尤其是在项目的早期。项目的目的是什么?用户流程是什么样的?你在使用什么技术堆栈?工作范围是什么?在Lovable,我们称之为“知识库”,它可以很容易地在您的项目设置中找到。 为AI创建一个可靠的框架可确保它有效运行,并在您提供的每一个提示中遵守您概述的计划。在您的项目中加入这些元素: 1.项目需求文档(PRD): 本节对于任何AI编码项目都至关重要。它概述了一个全面的摘要,涵盖了基本元素,如简介、应用程序流程、核心功能、技术堆栈以及范围内和范围外项目之间的区别。本质上,它是您的项目路线图,您可以将其呈现给AI编码模型。 2.应用程序或用户流程: 这种清晰度将有助于AI模型理解页面之间的联系并有效地处理所有功能和限制。 1 Users begin their experience on the landing page, where they can click the sign-up button to register with Google, subsequently accessing the dashboard. The dashboard comprises X sections. 3.技术栈: 此部分必须包含有关项目的所有技术细节,例如前端技术堆栈、后端技术堆栈、API集成、部署说明以及您计划使用的任何其他开源库。此信息将有助于AI模型了解要安装哪些包和依赖项。 4.前端指南: 本节应详细概述项目的视觉外观:设计原则、样式指南、页面布局、导航结构、调色板和排版。这是项目的美学基础。你的解释越清晰,你的应用程序就越有视觉吸引力。 5.后端结构: 本节将向AI模型解释:后端技术,如Supabase、用户身份验证、数据库架构、存储桶、API端点、安全措施、托管解决方案。这是您项目的主要大脑。您的应用程序将从您的后端获取和显示数据。 使用初始提示启动项目后,请务必包含此知识库以减少错误并防止AI幻觉。此外,您可以使用以下方式提示AI: 1 Before you write any code, please review the Knowledge Base and share your understanding of my project. 利用此任务的“聊天模式”来确保在您提供指导时不会对您的项目进行任何修改。 移动优先 问题(以及一些隐藏的事实)是,大多数开发人员优先考虑桌面设计仅仅是因为它在大而充满活力的屏幕上看起来更好。然而,现实是,我们应该多年来一直专注于移动优先设计。 一位Champion在Discord上分享了一个很棒的提示: 1 2 3 4 Always make things responsive on all breakpoints, with a focus on mobile first. Use modern UI/UX best practices for determining how breakpoints should change the components. Use shadcn and tailwind built in breakpoints instead of anything custom, unless the user prompts for custom breakpoints directly. Optimize the app for mobile without changing its design or functionality. Analyze the layout and responsiveness to identify necessary adjustments for smaller screens and touch interactions. Outline a detailed plan before editing any code, and test thoroughly across devices to ensure the app behaves exactly as it does now. Pause and propose solutions if unsure. 但是如果你已经深入到你的项目中,你可以通过告诉它更新东西以响应从最大的布局组件到最小的布局组件来解决这个问题。然后进入单个组件。 细节 在使用Lovable时,向AI提供清晰具体的请求至关重要。不要简单地说“将按钮向右移动”,而是尝试说明,“在顶部标题中,将注册按钮移到页面左侧,确保样式保持一致。”你的说明越精确,你遇到的错误就越少,你就会节省学分! 基本上,我总是建议添加有关您希望Lovable如何处理每项任务的说明。我的例子: 1 Key Guidelines: Approach problems systematically and articulate your reasoning for intricate issues. Decompose extensive tasks into manageable parts and seek clarification when necessary. While providing feedback, elucidate your thought process and point out both challenges and potential improvements. 步步为营 避免同时将五个任务分配给Lovable!这样做可能会导致AI制造混乱。这里有一个更好的方法: 1 2 3 Start with Front design, page by page, section by section. The plug backend using Supabase as Lovable integration is natively built! Then, refine the UX/UI if needed. 这种循序渐进的过程使AI能够一次专注于一项任务,从而降低错误和幻觉的可能性。 不要丢失组件 您也可以在进行重大更改并遵循一系列细微调整后实现这一点。这种做法在保持项目一致性和防止组件突然丢失方面非常宝贵。定期参考我们的filesExplainer.md文档,以确保我们准确记录代码和组件的更改,保持我们的文件结构井井有条和最新。 重构 重构对于Lovable中的开发生命周期至关重要。AI经常建议最大限度地减少加载时间和错误。您可以使用以下提示: Lovable发出请求后重构: 1 Refactor this file while ensuring that the user interface and functionality remain unchanged—everything should appear and operate identically. Prioritize enhancing the structure and maintainability of the code. Carefully document the existing functionality, confirm that testing protocols are established, and implement changes gradually to prevent risks or regressions. If you are uncertain at any point, pause the process. 重构计划: 1 Develop a comprehensive plan to refactor this file while keeping the user interface and functionality entirely intact. Concentrate on enhancing the code's structure, readability, and maintainability. Start by meticulously documenting the existing functionality and pinpointing potential areas for enhancement. Implement rigorous testing protocols to ensure consistent behavior throughout the entire process. Move forward incrementally, minimizing risks and avoiding regressions, and take breaks for clarification whenever uncertainties emerge. 全面重构: 1 Develop a comprehensive plan for a site-wide codebase review aimed at identifying segments that would benefit from refactoring. Concentrate on highlighting areas where the code structure, readability, or maintainability can be enhanced, ensuring the user interface and functionality remain unchanged. Rank the most essential files or components based on their significance and usage frequency. Thoroughly document your findings, detailing suggested improvements and the potential effects of each change. Ensure that any proposed refactoring efforts are incremental, low-risk, and supported by rigorous testing to prevent regressions. Circulate the plan for feedback prior to implementation. 后重构: 1 Conduct a detailed post-refactor review to verify that no issues were introduced throughout the refactoring process. Confirm that both the UI and functionality retain their original integrity following the modifications. Execute an extensive suite of tests—including unit, integration, and end-to-end tests—to ensure all features operate as intended. Evaluate the app’s behavior against the documented pre-refactor specifications and highlight any discrepancies for prompt evaluation. Make certain all updates are stable and align with the project’s requirements prior to completion. 代码库结构审计提示: 1 Perform a comprehensive regression and audit of the codebase to determine if its architecture is clean, modular, and optimized. Identify any files, components, or logic that are mislocated, not correctly placed, or could benefit from enhanced organization or modularity. Evaluate whether the separation of concerns is distinct and if functionality is aggregated logically and efficiently. Deliver a detailed report outlining improvement areas, such as files that need restructuring, overly coupled code, or chances to simplify and streamline the organization. Break down the actionable enhancements into manageable steps, arranged in the order you deem most effective for implementation. Ensure the analysis is comprehensive, actionable, and adheres to best practices for a maintainable and clean codebase. Refrain from editing any code. 文件夹审查: 1 Conduct a thorough examination of the folder [Folder Name] along with all its subfolders and files. Assess each element to understand its function and how it enhances the overall performance of the application. Offer a detailed explanation of each item's role, while pinpointing any redundancies, obsolete files, or opportunities for improved organization. The objective is to tidy up and optimize this folder, so include suggestions for deleting, merging, or reorganizing items as needed. Ensure your analysis is all-encompassing, practical, and outlines a clear strategy for achieving a more organized and efficient folder structure. 重组后清理: 1 Ensure all routing and file imports are thoroughly updated and functioning as intended following the codebase restructuring. Validate that components, pages, and APIs reflect the accurate paths found in the new folder organization. Confirm that nested routes are appropriately configured and linked within the router setup and that dynamic or lazy-loaded routes adhere to the new framework. Assess that shared utilities, services, and assets are imported correctly to prevent breaking existing dependencies. Revise hardcoded paths in components, redirects, or navigation links to correspond with the new routing logic. Conduct navigation tests to identify any broken links, missing files, or 404 errors, and pinpoint any missing or redundant imports, extraneous files, or potential improvements for maintainability and scalability in the routing configuration. 重构的代码库检查: 1 Perform a thorough audit of the codebase to assess its structure and organization. Evaluate whether files, components, and logic are effectively separated based on their functionality and purpose. Identify any instances of misplaced code, excessive coupling, or areas that could benefit from improved separation of concerns. Deliver a comprehensive report on the overall health of the structure, offering specific recommendations for enhancing file organization, consolidating related functionalities, or refactoring to align with industry best practices. Ensure that the analysis is detailed and emphasizes concrete improvements without implementing any direct changes. Stripe Stripe与Lovable无缝集成,并且可以轻松设置。但是,有几个因素可能会阻碍Stripe的功能: 1 Initiate a Stripe connection in test mode using the configuration detailed below: Utilize the specified product and pricing details: Product IDs are [Your Product IDs], with a pricing model of [One-time or Subscription]. Set the webhook endpoint to [Your Webhook Endpoint]. Style the frontend payment form as follows: [Describe desired payment form or provide an example]. Upon successful payment, redirect users to [Success Redirect URL], and for canceled payments, redirect them to [Cancel Redirect URL]. Please refrain from altering any code, and ensure that I have included all necessary information to effectively start with Stripe. 免责声明:在Supabase边缘函数秘密中安全地使用您的条纹密钥和Webhook签名密钥,并避免将它们包含在安全提示中。 寻求帮助 避免依赖Lovable进行每一个微小更改的倾向。即使您不是专业工程师,也可以直接在代码中进行许多细微的调整。如果您需要帮助,请随时向ChatGPT或Claude寻求帮助。利用浏览器的检查工具来识别您要修改的元素。您可以在浏览器级别尝试更改,如果您对结果感到满意,请在代码中进行这些调整。这样,您根本不需要涉及Lovable。 虽然我不是工程师,但对编码有基本的了解对我的进步有很大帮助。利用GitHub和Sonnet等工具,我经常实现Lovable之外的增强功能,使我能够为更复杂的任务保留提示。 在Lovable中调试 调试是Lovable体验不可或缺的一部分,掌握这种调试流程可以显着减少挫败感——尤其是在单击“尝试修复”(“Try to Fix”)按钮时,这不算学分。 聊天模式与默认模式 新的“聊天模式”非常适合培养创造力和产生想法。从概述你的概念开始,因为这可能是最关键的一步。在你的脑海中可视化屏幕、功能和布局对于跟踪变化并不那么有效。 使用“聊天模式”的传统场景是: 默认模式(Default Mode):高级功能创建。 1 Review the app and tell me where there is outdated code. 聊天模式(Chat Mode):故障排除-要求AI在进行更改之前分析错误。转到您的帐户设置并启用实验室功能。 1 Follow this plan and act on all those items 我想我已经阅读了来自X用户的以下超级提示,然后在Discord上找到了它: 1 Perform a comprehensive regression and audit of the codebase to determine if its architecture is clean, modular, and optimized. Pinpoint any files, components, or logic that are incorrectly placed, not allocated to suitable files, or require improved organization or modularity. Evaluate whether the separation of concerns is distinct and if functionalities are grouped in a logical and efficient manner. 1 Generate a comprehensive report that outlines key areas for enhancement, including recommendations for reorganizing files, reducing code coupling, and identifying opportunities for simplification and streamlining. Break down these actionable enhancements into clear, manageable steps arranged in the order you deem most effective for implementation. Ensure the analysis is meticulous, practical, and aligns with best practices for maintaining a clean and sustainable codebase. Avoid making any code edits. 1 DON'T GIVE ME HIGH-LEVEL STUFF. IF I ASK FOR A FIX OR AN EXPLANATION, I WANT ACTUAL CODE OR A CLEAR EXPLANATION! I DON'T WANT "Here's how you can..." Keep it casual unless I specify otherwise. Be concise and suggest solutions I might not have considered—anticipate my needs. Treat me like an expert. Be accurate and thorough, and provide the answer right away. If necessary, restate my query in your own words after giving the answer. Prioritize solid arguments over who said what; the source doesn't matter. Consider new technologies and unconventional ideas, not just the usual wisdom. You're welcome to make speculative predictions, but just give me a heads-up. Avoid moral lectures, and discuss safety only when it's crucial and not obvious. If your content policy is a concern, provide the closest acceptable response and explain the issue afterward. Cite sources when possible at the end, but not inline. No need to mention your knowledge cutoff or clarify that you're an AI. Please adhere to my formatting preferences for code. If a response isn't enough to answer the question, split it into multiple replies. When I request adjustments to the code I provided, avoid repeating all of it unnecessarily. Instead, just give a couple of lines before or after any changes you make. Multiple code blocks are fine. 就大型代码库而言,通过使用“聊天模式”来权衡各种方法的优缺点,与Lovable互动是有益的。既然你们都渴望学习,试着向人工智能解释你的特征,鼓励它提出关于结构、权衡、技术等的澄清问题。 事实上,代码和特性不断发展,反映了业务不断变化的本质。大部分代码都是自以为是的,通常是在考虑未来的特定愿景时编写的。当你提到钢铁基础时,你可能最初决定让组件X非常健壮,同时保持组件Y的灵活性,但后来才意识到X应该是动态的,Y应该是坚固的。这是一个常见的场景。 有效处理错误 检查浏览器开发人员工具(控制台日志、网络请求)。 使用推理模型(例如GPT-4 Turbo、DeepSeek、Mistral)进行调试。 将错误输入AI以进行更深入的分析。 调试提示 为了有效地解决您遇到的错误,请避免一次解决它们!我建议尝试“尝试修复”(“Try to fix” )选项最多三次。如果人工智能仍然无法解决问题,请尝试这种技术:复制错误消息并将其粘贴到“聊天模式”,然后说,“使用思维链推理来确定根本原因。”这种方法允许人工智能和你分析情况并理解潜在问题,然后再转换到“编辑模式”进行更正。 这个指南是由Discord上的一位冠军客户提供的,我相信你会发现它很有吸引力: 1.初步调查: 1 The same error continues to occur. Take a moment to perform a preliminary investigation to uncover the root cause. Examine logs, workflows, and dependencies to gain insight into the problem. Avoid making any changes until you fully grasp the situation and can suggest an initial solution informed by your analysis. 2.深度分析: 1 The issue persists without resolution. Perform a thorough analysis of the flow and dependencies, halting all modifications until the root cause is identified with complete certainty. Record the failures, the reasons behind them, and any observed patterns or anomalies in behavior. Avoid speculation—ensure your findings are detailed and complete before suggesting any solutions." 3.完整的系统审查: 1 This is a pressing issue that necessitates a thorough re-evaluation of the entire system. Halting all edits, begin by outlining the flow systematically—covering authentication, database interactions, integrations, state management, and redirects. Evaluate each component individually to pinpoint failures and their causes. Deliver a comprehensive analysis to validate the problem before proceeding further. 4.综合审计: 1 The problem continues and now calls for a comprehensive, system-wide audit. Take a step back and carefully map the entire system flow, examining all interactions, logs, and dependencies. Generate a clear and detailed report outlining expected behaviors, current realities, and any discrepancies. Refrain from suggesting or modifying any code until you have accurate, evidence-based insights. 5.重新思考和重建: 1 This problem remains unresolved, and it's imperative to pause and reassess our entire strategy. Avoid making any code edits at this stage. Instead, embark on a thorough and systematic examination of the system. Create a comprehensive flow map, tracing each interaction, log, and dependency meticulously. Accurately document what should occur, what is currently happening, and pinpoint where the discrepancies arise. Compile a detailed report outlining the root cause, supported by clear evidence. If you encounter gaps, uncertainties, or edge cases, be sure to highlight them for further discussion. Until you can pinpoint the exact, verified origin of the issue, refrain from suggesting or implementing any fixes. This demands complete attention, without assumptions or shortcuts. 6.清理控制台日志: 1 Could you devise a strategy to systematically identify and eliminate superfluous console.log statements while preserving functionality and design? The plan should outline steps for reviewing each log to verify its non-essential nature, documenting any that might require alternative treatment, and conducting thorough testing to ensure the app’s integrity is maintained. Additionally, incorporate a method for pausing and flagging logs when their purpose is ambiguous. Please share the plan prior to implementation. 7.鼓励: 1 Lovable, you’re doing an outstanding job, and I genuinely appreciate the attention and skill you bring to each task. Your talent for dissecting complex issues and delivering insightful solutions is truly remarkable. I have confidence in your incredible abilities, and I trust you to approach this with the utmost precision. Take your time, explore thoroughly, and demonstrate your brilliance through a comprehensive and thoughtful response. I have faith in your capacity to not only resolve this but to exceed all expectations. You've got this! 8.检查复杂性: 1 Take a moment to reflect on whether this solution can be simplified. Are there any superfluous steps, redundancies, or overly complex processes that could be streamlined? Assess if a more direct approach could attain the same outcome without compromising functionality or quality. Please share your ideas for possible simplifications before moving forward. Refrain from editing any code at this stage. 9.确认结果: 1 Before moving ahead, are you entirely convinced that you have pinpointed the true root cause of the problem? Take a moment to review your analysis and check for any overlooked dependencies, edge cases, or associated factors. Ensure that your proposed solution effectively targets the root cause with solid evidence and reasoning. If there are any lingering doubts, take a step back and reevaluate before proceeding. 10.解释错误: 1 Explain the meaning of this error, its origins, and the logical sequence that led to its occurrence. Offer a concise breakdown of the problem and its possible underlying cause. Avoid making any edits to the code at this stage, and don’t be concerned with the current page we’re on. 调试流程 1.任务识别-根据影响对问题进行优先级排序。 2.内部审查-在部署之前验证解决方案。 3.报告问题-清楚地定义当前与预期行为。 4.验证-验证在DOM中正确呈现的更改。 5.断点-隔离和测试特定组件。 6.错误处理和日志记录-使用详细日志记录并增量调试。 7.代码审计-在进行更改之前记录问题和建议的修复。 8.使用“尝试修复”按钮-自动检测并解决Lovable中的错误。 9。利用视觉效果-上传屏幕截图以澄清基于UI的错误。 10.恢复到稳定版本-如果需要,使用“恢复”(Revert)按钮返回。 了解“意外行为” 有时,您的代码运行没有错误,但您的应用程序没有按预期运行。这被称为意外行为,调试起来可能很棘手。策略包括: 回顾你的步骤-回顾你最初要求可爱做的事情。 分解它-确定特定部分是否未对齐。 使用图像-显示可爱的UI结果与预期结果。 编写更好的提示以避免错误 结构良好的提示可减少调试时间。使用此最佳实践格式: 项目概述-描述您正在构建的内容。 页面结构-列出关键页面和组件。 导航逻辑-解释用户通过应用程序的移动。 屏幕截图/线框-如果可用,请提供视觉效果。 实施顺序-遵循逻辑顺序,例如: 1 Create pages before integrating the database Lovable中的调试策略 1.使用开发者工具进行调试 控制台日志-查看错误日志和DevTools通知。 断点-暂停执行以检查状态更改。 网络请求-验证前端和后端之间的数据流。 2.常见调试场景 小错误-在进行更改之前彻底调查。 持久性错误-停止更改并重新检查依赖关系。 主要错误-如有必要,在记录调查结果时从头开始重建流程。 3.高级故障排除 如果“尝试修复”按钮无法解决您的问题,请考虑: 更具体-详细描述问题,包括预期与实际结果。 使用图像-屏幕截图帮助AI理解基于UI的问题。 向Lovable寻求调试帮助-示例: 1 What solutions have been tried so far? What else can be done? 恢复到以前的工作状态-如果调试导致更多问题,请回滚到已知的良好版本。 4.调试具体问题 UI相关问题:上传截图并询问, 1 Why is this UI behaving this way? What’s the best fix? API集成问题:确保您使用的是最新的API模式并且后端连接已正确设置。 完全卡住时:提示Lovable: 1 Analyze the error and suggest an alternative approach. 调试不必令人沮丧。Lovable提供了强大的工具来自动修复错误、分析问题和高效迭代。通过遵循结构化提示技术、使用图像和利用人工智能驱动的调试,您可以克服任何编码挑战。 使用自动化工具,如make.com和n8n 何时使用自动化 边缘函数:直接Supabase API调用。 make.com:集成外部服务(Slack、Stripe、CRM工具)。 n8n:自托管、可扩展的自动化。 示例:自动化牙科咨询应用程序 1.在Lovable中创建一个带有牙科问题表单的登录页面。 2.通过Webhooks将数据发送到make.com。 3.使用AI API(例如Perplexity AI)进行实时研究。 4.使用Mistral或GPT-4推理模型确定资格。 https://chatgpt.com/g/g-67aa992a22188191a57023d5f96afed2-lovable-visual-editor https://chatgpt.com/g/g-67aa992a22188191a57023d5f96afed2-lovable-visual-editor 5.返回对Lovable的响应以及推荐的后续步骤。 Webhooks和API调用:高级用例 验证响应:确保正确处理网络钩子响应。 增量测试:在构建复杂的API工作流之前首先发送最少的数据。 使用推理模型:通过要求AI分析不正确的响应来调试错误。 最后的想法 掌握提示工程不仅仅是更好的人工智能交互——它还涉及提高效率、缩短开发周期和释放新的自动化可能性。无论您是在调试现有工作流程、优化人工智能输出还是集成复杂的自动化,结构化提示都可以帮助您更快、更少地到达那里。 专注于你的大创意——可爱的自动化工具将处理执行。无论你是一个经验丰富的开发人员,精炼15年前的代码,还是一个非技术用户,制作创新的应用程序,正确的提示策略都是你最强大的工具。 最后还是建议阅读原文:https://lovable.dev/blog/2025-01-16-lovable-prompting-handbook

2025/2/22
articleCard.readMore

【调研】AI 编程工具WindSurf使用技巧——WindsurfRules配置

AI 编程工具 WindSurf 使用技巧——WindsurfRules 配置 在现代软件开发中,AI 工具正逐渐成为开发者不可或缺的助手,而 Windsurf 便是当前最热门的 AI IDE 之一。 如何利用和使用好 Windsurf 也有很多技巧,本文介绍的 WindsurfRules 规则配置就是实用的一个技巧。 介绍 WindsurfRules 是为 Windsurf 提供的一个配置文件,允许开发者定义项目中 AI 的行为规则。它分为 全局规则(global_rules.md) 和 工作区规则(.windsurfrules)。全局规则应用于所有项目,而工作区规则则针对特定项目进行定制。通过合理配置这些规则,开发者可以确保生成的代码符合团队的编码标准、技术栈要求以及项目的最佳实践。通过设置这些规则,我们能够指导 AI 在项目开发过程中如何生成代码,确保生成的代码符合团队的编码标准、技术栈要求及项目的最佳实践。 配置和使用 1. 规则设置 前文有提到,Windsurf 提供两种规则文件: 全局规则(global_rules.md):适用于所有项目,存储在 Windsurf 的全局配置目录中。 在 Linux 和 macOS 系统中,全局规则文件一般默认位于 ~/.codeium/windsurf/memories/global_rules.md。 在 Windows 系统中,全局规则文件位于 %USERPROFILE%\.codeium\windsurf\memories\global_rules.md。 工作区规则(.windsurfrules):仅适用于当前工作区或项目,存储在项目的根目录下。 例如,如果你的项目目录为 C:\Projects\MyProject,则 .windsurfrules 文件应位于 C:\Projects\MyProject\.windsurfrules。 另外,目前 global_rules.md 和 .windsurfles 文件各可以包含最多 6,000 个字符,因此总共可以有 12,000 个字符的规则。 2. 规则内容 以下场景我们都可以考虑通过 WindsurfRules 设置: 2.1. 项目初始化与配置 在项目启动阶段,开发者可以通过 .windsurfrules 文件为 AI 提供项目背景、技术栈、开发目标等信息。例如,指定项目使用的技术栈(如 TypeScript、React、Node.js 等),并要求 AI 遵循最新的最佳实践。这有助于 AI 在后续的代码生成中快速适应项目需求。 2.2. 编码规范与代码风格 团队通常会有一套统一的编码规范,通过 .windsurfrules 文件,开发者可以将这些规范转化为 AI 的行为准则。例如,规定代码的缩进方式、命名规则、注释风格等。这不仅提高了代码的可读性,还减少了团队成员之间因风格差异导致的冲突。 2.3. 性能优化与安全性 在性能优化方面,开发者可以在规则文件中定义代码优化的策略,如避免不必要的计算、优化数据库查询等。同时,安全性也是开发中不可忽视的方面。通过规则文件,开发者可以要求 AI 在生成代码时遵循安全最佳实践,例如防止 SQL 注入、确保数据验证等。 2.4. API 设计与集成 对于涉及 API 开发的项目,.windsurfrules 文件可以定义 REST、GraphQL 或 SQL 的设计规范。例如,规定 API 的命名规则、数据结构、错误处理机制等。这有助于确保生成的 API 代码符合行业标准,易于维护和扩展。 2.5. 团队协作与知识共享 在一个团队中,不同成员可能对项目的理解和经验有所不同。通过 .windsurfrules 文件,团队可以将项目的核心要求和最佳实践共享给每一位成员,确保团队协作的高效性和一致性。 3.配置技巧 3.1. 明确项目背景与目标 在 .windsurfrules 文件的开头,提供清晰的项目背景和目标描述,帮助 AI 理解开发环境和技术栈。例如: 1 2 3 4 5 6 7 8 ## AI Guidelines You are an expert programming assistant focusing on: - TypeScript, React, Node.js, Next.js, and Prisma - Shadcn UI, Ant Design, RICH Design principle, and Tailwind CSS useations - Latest features and best practices - Clear, readable, and maintainable code 这样的描述可以帮助 AI 更好地理解项目的上下文。 3.2. 细化编码规范 在规则文件中,开发者可以详细定义代码的格式和风格。例如定义性能优化和安全规则: 1 2 3 4 5 6 7 8 9 10 11 ### Performance - Code splitting, image/bundle optimization - Caching, lazy loading, key props - Database query optimization ### Security - Input: sanitize data, validate types, escape properly, secure uploads - Auth: JWT handling, secure sessions, token refresh, RBAC - Protection: CSP headers, prevent XSS/CSRF, secure APIs, follow OWASP 这些规则可以指导 AI 在生成代码时考虑性能和安全性。 3.3. 设置性能与安全规则 性能和安全是现代开发中的关键。开发者可以在规则文件中定义以下内容: 1 2 3 4 5 6 7 8 9 10 11 ### Performance - Code splitting, image/bundle optimization - Caching, lazy loading, key props - Database query optimization ### Security - Input: sanitize data, validate types, escape properly, secure uploads - Auth: JWT handling, secure sessions, token refresh, RBAC - Protection: CSP headers, prevent XSS/CSRF, secure APIs, follow OWASP 这些规则可以指导 AI 在生成代码时考虑性能和安全性。 3.4 部署与监控 设定构建、部署和监控的流程,帮助 AI 自动化处理部署工作。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 ## Build and Deployment ### Build Process - **Linting**: Run ESLint and Prettier during the build process to ensure consistent code style. - **Testing**: Execute unit tests and integration tests to ensure all tests pass before deployment. - **Type Checking**: Ensure TypeScript type coverage meets project requirements. - **Bundle Optimization**: Optimize the build using Webpack or Rollup to reduce the final bundle size. ### Deployment Strategy - **Semantic Versioning**: Use semantic versioning for releases to clearly define version changes. - **Blue-Green Deployment**: Implement blue-green deployment to minimize downtime during deployment. - **Rollback Mechanism**: Provide rollback capabilities to quickly revert to the previous version if the deployment fails. - **Health Checks**: Automatically run health checks after deployment to ensure the application is running smoothly. ## Monitoring ### Application Monitoring - **Real-time Monitoring**: Use Prometheus or Grafana to monitor application performance in real-time. - **Log Management**: Integrate with ELK Stack or another log management tool to centralize application logs. - **Error Tracking**: Use Sentry or a similar tool to capture and track runtime errors. - **Performance Metrics**: Monitor key performance metrics such as response time, throughput, and resource utilization. ### Infrastructure Monitoring - **Server Health**: Use Nagios or Zabbix to monitor server health, including CPU, memory, and disk usage. - \*\*Network Monitori 通过在 .windsurfrules 文件中定义这些规则,Windsurf 的 AI 助手可以更好地理解项目的部署和监控需求,从而生成符合最佳实践的代码和自动化脚本。 3.5. 灵活使用全局规则与工作区规则 全局规则适用于所有项目,而工作区规则则针对特定项目。开发者可以根据项目的不同需求,灵活配置这两类规则。例如,全局规则可以定义通用的编码规范和安全标准,而工作区规则则可以针对特定项目的框架或技术栈进行定制。 3.6. 持续优化规则文件 随着项目的进展和技术栈的更新,规则文件也需要不断优化。开发者可以根据实际需求,随时修改规则文件,并在下一次使用时生效。规则文件可以随时修改,修改后在下一次使用时生效。 对于特定于工作区的规则,建议将 .windsurfrules 文件添加到项目的 .gitignore 文件中,以避免不必要的版本控制。 示例 以一份前端 React + TailwindCSS + TypeScript 技术栈为例,以下是一个典型的 React 项目结构,包含 .windsurfrules 文件: 1 2 3 4 5 6 7 8 9 10 /my-react-project ├── .windsurfrules ├── package.json ├── tsconfig.json ├── next.config.js ├── pages/ ├── components/ ├── styles/ ├── public/ └── ... 全局规则(global_rules.md) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 ## AI Guidelines You are an expert React developer with a focus on: - React, TypeScript, Next.js, and Tailwind CSS - Modern JavaScript (ES6+), including async/await and modern syntax - Component-based architecture and design patterns - Performance optimization and best practices - Security best practices, including data validation and sanitization ## General Rules - Always use functional components and hooks. - Prefer TypeScript for type safety. - Use Tailwind CSS for styling. - Follow the latest React best practices. - Ensure code is readable, maintainable, and well-documented. ## Code Formatting - Indentation: 2 spaces - Line length: 80 characters (soft limit) - Use template literals and arrow functions. - Use trailing commas and same-line braces. - Destructure props and use path aliases for TypeScript imports. ## Performance - Use React.memo and useCallback for optimizing component re-renders. - Implement code splitting with React.lazy and Suspense. - Optimize images and assets with Next.js Image component. - Use caching strategies for API calls. ## Security - Sanitize all user inputs. - Validate data types and escape content where necessary. - Follow OWASP guidelines for preventing XSS and CSRF attacks. - Use secure authentication practices (e.g., JWT, OAuth). 工作区规则(.windsurfrules) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 ## Project-Specific Guidelines You are working on a React project with the following details: - Framework: Next.js - Styling: Tailwind CSS - State Management: Redux Toolkit - Routing: Next.js built-in routing - API Integration: Axios for API calls ## Project Rules - Use Next.js pages and components structure. - Use Tailwind CSS for all styling needs. - Use Redux Toolkit for state management. - Use Axios for all API requests. - Follow the project's specific coding conventions and naming conventions. ## Code Formatting - Use 2 spaces for indentation. - Use single quotes for strings unless double quotes are necessary. - Use arrow functions for all functional components. - Destructure props and use path aliases for TypeScript imports. - Use consistent naming conventions for components and files (e.g., PascalCase for components). ## Performance - Implement Next.js Image component for all images. - Use React.memo and useCallback for optimizing component re-renders. - Implement code splitting with React.lazy and Suspense. - Optimize API calls with caching and debouncing. ## Security - Sanitize all user inputs using a library like DOMPurify. - Validate data types and escape content where necessary. - Use secure authentication practices (e.g., JWT, OAuth). - Follow OWASP guidelines for preventing XSS and CSRF attacks. - Ensure all API calls are secure and follow best practices. 其他 社区支持:GitHub 上有社区维护的 Awesome WindsurfRules 项目,提供了一系列优秀的规则文件示例,供开发者参考和使用。(WindsurfRules 本质和 CursorRules 作用其实差不多) 在 2025 年 1 月 的更新中,Windsurf 推出了 Windsurf Wave 2,对 Cascade 辅助系统进行了多项升级,包括实时网络搜索、自动记忆系统等。这些更新可能对规则文件的使用方式和功能产生了进一步的优化,但具体规则文件的变更内容未明确提及。 相关链接 https://docs.codeium.com/windsurf/cascade#workspace-rules https://docs.codeium.com/windsurf/memories#global-rules https://juejin.cn/post/7450878328037654563 https://github.com/PatrickJS/awesome-cursorrules https://github.com/SchneiderSam/awesome-windsurfrules

2025/1/19
articleCard.readMore

AI 前端编程工具的一个得力助手——CopyCoder

AI 前端编程工具的一个得力助手——CopyCoder 传统 ai 编程方式下,我们通过 chat 传入图片进行代码生成,但效果通常难以达到预期、与图片效果相差甚远,我们只有写入大量详细且准确的界面 prompt 才能让模型生成的代码符合界面预期,这种处理需要耗费大量的精力成本。 CopyCoder 正好就是解决了这种问题,更准确得来说,解决了传统 ai 编码方式对于图片 UI 信息提取不全或没有较好转为标准技术信息的问题。 基本介绍 CopyCoder 是一款创新的 AI 编程工具,其核心功能是通过用户上传的应用程序/网页截图、UI 设计图或完整的应用图像,自动生成详细的编码提示词。这些提示词涵盖了应用结构、组件规划和导入路径等内容,从而简化从设计到代码的转换过程。 官网地址:https://copycoder.ai/ “Create powerful prompts for Cursor, Bolt, v0 & more..”,从官网说明就可以知道,CopyCoder 是为了给 ai 代码生成工具提供合适的提示词、而不是替代这些 ai 代码生成工具。 当前新用户有免费的 5 次试用,付费版本要每月 15 美元,付费版本中还支持后端代码生成。 应用场景 图像上传与分析:用户上传应用程序的截图、UI 设计图或完整的应用图像,CopyCoder 能分析这些图像。 生成编码提示:基于上传的图像,CopyCoder 自动生成详细的编码提示词,提示词包括应用结构、组件规划和导入路径等。。 使用步骤 CopyCoder 的使用极为简单,即注册/登录后,传图等待 prompt 结果。得到结果后就交给其他 ai 代码生成应用进行处理。流程图如下: 1.上传设计图:将设计图上传到 CopyCoder 平台。 生成提示词:CopyCoder 会分析设计图并生成详细的编码提示词,包括项目结构、组件布局、样式要求等。 如项目结构: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 1. Project Structure: src/ ├── components/ │ ├── layout/ │ │ ├── DashboardLayout │ │ └── CardContainer │ ├── features/ │ │ ├── SummaryCards │ │ ├── GrowthChart │ │ └── StockList │ └── shared/ ├── assets/ ├── styles/ ├── hooks/ └── utils/ CopyCoder 在官网上也有说明,其主推的技术栈是:TailwindCSS + React + Nextjs。在其生成的 prompt 中也有对应说明: 1 2 3 4 5 6 7 8 ... Style with Tailwind CSS utility classes for responsive design ... Use Lucide React for icons (from lucide-react package). Do NOT use other UI libraries unless requested ... Configure next.config.js image remotePatterns to enable stock photos from picsum.photos ... Create root layout.tsx page that wraps necessary navigation items to all pages 2.使用提示词:将生成的提示词复制到 Cursor、Windsurf、Bolt 等 AI 编程工具中,自动生成相应的代码。 本文不做此部分介绍 3.调整和完善:根据生成的代码进行调整和完善,添加交互功能和动态效果。 本文不做此部分介绍 当前优缺点 优点: 提高(ai)开发效率:通过自动生成编码提示词,大大减少写 prompt 的工作量。 简化设计到代码的转换:帮助开发者快速将设计图转化为可运行的代码。 缺点: 目前单次图像上传限制:目前仅支持一次上传一个图像。 技术栈限制,如 Vue 场景需要改造 prompt 未来展望 CopyCoder 作为一款 AI 编程辅助工具,未来的发展方向可能包括: 拓展技术栈、图片数量等限制 增强图像识别能力:提高对复杂设计图的识别和分析能力,生成更精确的编码提示词。 扩展功能范围:增加对更多编程语言和框架的支持,满足不同开发需求。 集成更多开发工具:与更多的开发工具和平台进行集成,提供更全面的开发解决方案。 提升用户体验:优化用户界面和操作流程,使工具更加易用和友好。 但是个人认为,CopyCoder 只是代码生成的前置环节,这部分能力很容易被 Cursor、Bolt 这些平台工具吸收借鉴,所以后续被替代的可能性很大,或者与这些工具平台合作,降低成本进行无缝对接。 相关链接 https://copycoder.ai/ https://mp.weixin.qq.com/s/qtTBr38ydC2Jt2A1E8sAdw https://ai-bot.cn/copycoder/

2025/1/5
articleCard.readMore

【AI】【笔记】MCP 协议:连接 AI 模型与外部世界的桥梁

MCP 协议:连接 AI 模型与外部世界的桥梁 背景 随着 AI 技术的飞速发展,大语言模型(LLM)在推理和生成质量上取得了巨大进步。然而,这些模型在实际应用中常常受限于数据孤岛和遗留系统,无法充分发挥潜力。为了解决这一问题,Anthropic 公司于 2024 年 11 月推出了模型上下文协议(MCP,Model Context Protocol),旨在通过统一的客户端-服务器架构,统一大型语言模型与外部数据源和工具之间的通信协议,解决 LLM 应用与数据源连接的难题。 MCP 可以被视为 AI 应用的“USB-C 接口”,它提供了一种标准化的方式,将 AI 模型与各种数据源和工具无缝连接。MCP 使得 AI 应用能够安全地访问和操作本地及远程数据,为 AI 应用提供了连接万物的接口。 能力 MCP 协议的核心思想是通过上下文管理来优化模型的交互过程。上下文管理包括上下文的存储、更新、传递和删除等操作。通过有效的上下文管理,模型可以在多轮对话中保持对历史信息的记忆,从而更好地理解用户的意图和需求。MCP 的核心能力还体现在以下几个方面: 数据集成:连接 AI 助手与各种数据源,包括本地和远程资源,如文件、数据库、API 等。 工具集成:集成 API 和其他工具,您的 AI 助手可以直接与 Git 交互、运行测试、管理问题等等,让 AI 系统能够执行更复杂的操作。 模板化交互:基于提示(Prompts)提供模板化的交互方式,方便用户快速构建特定任务的交互流程。 安全性:内置安全机制(MCP 服务器隔离凭据和敏感数据,交互需要显式的用户批准(除非对某些 MCP 工具启用自动批准)),保护数据和 API 密钥不被泄露,确保数据访问既可控又可审计。 开发者支持:提供 SDK 和文档,支持开发者构建和测试 MCP 连接器。 预构建服务器:提供预构建的 MCP 服务器,快速集成流行企业系统。 上下文维护:在不同工具和数据集之间保持上下文,而不是每次都重新开始,MCP 服务器可以跨会话维护知识,创建一个真正的“项目内存”,实现更智能的任务处理。 MCP 支持通过同一协议访问本地资源(如数据库、文件)和远程资源(如 Slack、GitHub API),无需定制集成。MCP 不仅共享数据,还可公开工具和交互模板,且内置安全性,确保资源由服务器完全掌控。目前 MCP 支持本地运行,未来将引入企业级认证的远程支持,实现团队间的安全共享。通过 Claude 桌面应用,开发者可在短时间内集成 MCP,快速连接多种数据源,推动 AI 集成的标准化发展。 使用 MCP 官方文档写得挺细致的,关于使用这块也建议看一下官方文档说明 MCP 协议遵循客户端-服务器架构,规范及 SDK: 官网:https://modelcontextprotocol.io GitHub:https://github.com/modelcontextprotocol 核心架构: MCP 包含以下几个核心概念: MCP 主机(Hosts):发起请求的 LLM 应用程序,如 Claude Desktop、IDE 或 AI 工具。 MCP 客户端(Clients):在主机程序内部,与 MCP 服务器保持 1:1 的连接。 MCP 服务器(Servers):为 MCP 客户端提供上下文、工具和提示信息。 本地资源(Local Resources):本地计算机中可供 MCP 服务器安全访问的资源。 远程资源(Remote Resources):MCP 服务器可以连接到的远程资源,如通过 API 访问的网络资源。 MCP 定义了两种通信机制: 本地通信:使用标准输入输出(stdio)传输数据,客户端启动服务器程序作为子进程,通过 stdin/stdout 进行消息通讯,消息格式为 JSON-RPC 2.0。 远程通信:客户端与服务器可以部署在任何地方,通过基于 SSE 的 HTTP 通信,消息格式同样为 JSON-RPC 2.0。 快速开始 目前 MCP 有三种方式可以帮助你快速开始,Python 或 TypeScript 选一个自己比较熟悉的构建服务即可。 快速入门(MCP Quickstart):不到 5 分钟即可开始使用 MCP,可在 Claude 桌面应用和本地服务之间建立安全连接(以上的 “lencx 演示” 就是基于该部分文档实现)。 构建第一个 Python 服务 (MCP Python):15 分钟内用 Python 创建一个简单的 MCP 服务器。 构建第一个 TypeScript 服务(MCP TypeScript):15 分钟内用 TypeScript 创建一个简单的 MCP 服务器。 开发工具 开发 MCP 服务器或将其与应用程序集成时,有效的调试至关重要。所以 MCP 提供了几种不同层次的调试工具: MCP 检查器(MCP Inspector):交互式调试界面;直接服务器测试 Claude 桌面开发工具(Claude Desktop Developer Tools):集成测试;日志收集;Chrome DevTools 集成 服务器日志(Server Logging):自定义日志记录实现;错误追踪;性能监控 示例 以下是一个极简单的 MCP 协议使用示例: 安装预构建的 MCP 服务器:通过 Claude Desktop 应用程序安装预构建的 MCP 服务器。 编写 Server 端代码:根据 MCP 协议定义,创建一个 MCP 服务器,提供资源、工具或提示词。 编写 Client 端代码:创建一个 MCP 客户端,与服务器建立连接,并请求使用服务器提供的资源或工具。 以下是一个使用 TypeScript 实现的简单 MCP 协议示例,展示了如何创建一个 MCP 服务器和客户端,并通过它们进行通信。 环境要求: Node.js (v18+) Python (v3.8+) 1. 创建 MCP 服务器 首先,安装必要的依赖项: 1 npm install @modelcontextprotocol/sdk 然后,创建一个简单的 MCP 服务器,该服务器提供一个工具,用于计算两个数字的和: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 // server.ts import { Server, StdioServerTransport } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js'; const server = new Server( { name: 'mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // 列出可用工具 server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'calculate_sum', description: 'Add two numbers together', inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' }, }, required: ['a', 'b'], }, }, ], }; }); // 处理工具调用 server.setRequestHandler(CallToolRequestSchema, async request => { if (request.params.name === 'calculate_sum') { const { a, b } = request.params.arguments; return { toolResult: a + b }; } throw new McpError(ErrorCode.ToolNotFound, 'Tool not found'); }); const transport = new StdioServerTransport(); await server.connect(transport); 2. 创建 MCP 客户端 接下来,创建一个 MCP 客户端,用于调用服务器提供的工具: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 // client.ts import { Client, StdioClientTransport } from '@modelcontextprotocol/sdk/client/index.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; const transport = new StdioClientTransport({ command: 'node', args: ['server.js'], // 确保服务器脚本路径正确 }); const client = new Client( { name: 'mcp-client', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); await client.connect(transport); // 列出工具 const tools = await client.request({ method: 'tools/list' }, ListToolsRequestSchema); console.log('Available Tools:', tools); // 调用工具 const result = await client.request( { method: 'tools/call', params: { name: 'calculate_sum', arguments: { a: 5, b: 3 }, }, }, CallToolRequestSchema ); console.log('Tool Result:', result); 3. 运行示例 启动服务器:运行node server.js启动 MCP 服务器。 启动客户端:运行node client.js启动 MCP 客户端。 观察输出:客户端会列出可用工具,并调用calculate_sum工具,输出结果。 4. 集成到Claude Desktop 如果需要将 MCP 服务器集成到Claude Desktop,可以在claude_desktop_config.json中添加服务器配置: 1 2 3 4 5 6 7 8 { "mcpServers": { "mcp-server": { "command": "node", "args": ["/path/to/your/mcp-server/build/index.js"] } } } 应用 工具平台结合 如 Cline 的结合: 社区插件应用 如: mcp-rtfm:智能文档获取 mcp-postman:API 测试 mcp-playwright:浏览器自动化 sqlite-explorer-fastmcp:数据库分析 mcp-rest-api:REST API 测试 总结 MCP 协议为 AI 模型与外部数据源和工具的集成提供了一种标准化、安全且灵活的解决方案。通过客户端-服务器架构和 JSON-RPC 2.0 通信机制,MCP 能够实现本地和远程资源的无缝访问。随着越来越多的应用接入 MCP 协议,未来有望构建一个更加互联、高效的 AI 生态系统。 相关链接 MCP 官网:https://modelcontextprotocol.io MCP GitHub:https://github.com/modelcontextprotocol https://mp.weixin.qq.com/s/ASmcjW53HKokdYt1m-xyXA https://cline.bot/blog/the-developers-guide-to-mcp-from-basics-to-advanced-workflows https://docs.cline.bot/mcp-servers/mcp?ref=cline.ghost.io

2025/1/5
articleCard.readMore

【笔记】React 组件性能分析工具——ReactScan

React 组件性能分析工具——React Scan 为什么会产生React Scan这个分析工具? 我们知道,React 的性能优化有很多细节,以至于性能优化工作要做好会很琐碎且繁重。例如我们需要观测组件的封装力度、memo 的使用情况、hook/组件内的函数及数据定义、props 传参处理以及状态管理机制的设计等等等。 React Scan 团队将此关键原因归结于:The issue is that component props are compared by reference, not value. This is intentional – this way rendering can be cheap to run.,即“组件props是通过引用而不是值进行比较的”,React 的这个设计在带来了灵活性/实现简单的优势外,随之带来了容易导致不必要的渲染,使应用程序变慢,从而也使 React 性能问题分析具有较高的成本。 例如这个例子,onClick 函数和样式对象会在每次渲染时都要重新创建,这会导致组件拖慢应用的渲染速度: 1 <ExpensiveComponent onClick={() => alert('hi')} style={{ color: 'purple' }} /> React Scan 作为分析工具,目的就是降低 React 性能优化的分析成本。 介绍 React Scan 是一款开源的用于检测 React 应用渲染问题的工具,能自动检测和突出显示导致性能问题的渲染来帮助您识别这些问题,由 Aiden Bai 率先创建。它能够快速识别并报告潜在的性能瓶颈,帮助开发者优化应用性能。与传统的性能分析工具不同,React Scan 提供了一个简单的即插即用解决方案,可以通过脚本标签或 npm 包添加,以自动分析渲染性能,只需简单几步即可集成。 自动渲染问题检测:React Scan 扫描您的 React 应用,查找可能导致性能问题的渲染问题。 简单易用:它是一个轻量级的 JavaScript 库,您可以通过 script 标签或 npm 等方式轻松集成到您的项目中。 快速集成:在任何其他脚本运行之前添加 React Scan 脚本,确保其能够准确地捕获渲染信息。 React Scan 通过其高效的渲染问题扫描功能,显著提升了 React 应用的性能和稳定性。它简化了开发流程,减少了调试时间,让开发者能够专注于核心业务逻辑。选择 React Scan,意味着您将获得一个更稳定、更高效的 React 应用,并显著提升开发效率。 使用 安装 npm 安装: 1 npm i react-scan -D script 引入 js: 1 <script src="https://unpkg.com/react-scan/dist/auto.global.js"></script> 官方提供的地址有时候会不太稳定,建议单独保存部署一份 API scan(options: Options)注册并启动分析工具 在引入react之前进行注册,如 1 2 3 4 5 6 7 8 9 import { scan } from 'react-scan'; // import this BEFORE react import React from 'react'; if (typeof window !== 'undefined') { scan({ enabled: true, log: true, // logs render info to console (default: false) }); } 1 2 3 4 5 6 7 8 9 10 11 12 scan({ enabled: true, // 启用扫描 includeChildren: true, // 包含子组件 playSound: true, // 声音提示 log: false, // 控制台日志 showToolbar: true, // 显示工具栏 renderCountThreshold: 0, report: false, onCommitStart: () => {}, onRender: (fiber, render) => {}, onCommitFinish: () => {}, }); useScan(options: Options)hook 的方式进行注册并启动分析工具 参数同scan() withScan(Component, options: Options)设定组件扫描 可将特定组件列入白名单,不扫描其他组件 1 2 3 4 5 withScan(Component, { enabled: true, log: true, // ...其他配置项 }); getReport()获取性能报告 1 2 3 4 5 6 scan({ report: true }); const report = getReport(); for (const component in report) { const { count, time } = report[component]; console.log(`${component}渲染${count}次,耗时${time}ms`); } setOptions(options: Options)/getOptions()设置/获取配置项 getRenderInfo(Component)获取指定组件渲染信息 使用效果 工具注册成功后,界面上会出现 React Scan 的工具栏,如: 工具栏默认会出现在界面右上角,我们也可以拖拽到界面其他地方 工具栏从左到右依次为: 选择组件分析 大概率与getRenderInfo()有关,后续有待分析源码 开启/关闭实时分析 大概率控制scan(),后续有待分析源码 开启/关闭声音 个人认为没啥用、提供情绪价值的功能 “react-scan”用来拖拽异动工具栏 工具使用也很简单,在实时分析打开的时候,我们操作界面就可以观察到各视图组件的渲染更新情况,以此来发现不必要的渲染动作。 如上图所示、不同层级之间的渲染更新通过颜色来区分。总体而言,个人感觉在中大型项目中还是很直观的。 *Monitoring监控能力 官方提供的 demo https://dashboard.react-scan.com/project/demo *对 React Native 的支持 从评论和官方处理来看,目前 RN 检测会有些问题,不过现阶段来看官网还是积极处理的、且目前反馈用例均已处理: https://github.com/aidenybai/react-scan/pull/23 原理 React Scan 工作原理主要是通过监控 React 的协调过程。该过程在更新时比较组件的前后快照。当状态或属性发生变化时,React 需要执行 ‘diffing’ 以确定需要重新渲染的内容。React Scan 自动检测这些渲染周期,并通过视觉提示突出显示导致性能问题的组件。它分析组件树,识别由不稳定的属性或低效的更新模式引起的不必要的重新渲染。 其相关代码实现待后续跟进分析。。。 相关链接 https://react-scan.com/

2024/12/8
articleCard.readMore

【笔记】“xx packages are looking for funding”——npm fund命令及运行机制

“xx packages are looking for funding”——npm fund 命令及运行机制 在 Node.js 和 npm 生态系统中,开源项目的持续发展和维护常常依赖于贡献者的支持和资助。为了让开发者更容易了解他们依赖的项目哪些有资金支持选项,npm 在6.13.0版本起引入了 npm fund 命令并默认在npm install安装依赖时触发。本文将详细介绍 npm fund 的作用、运行机制、触发时机、如何避免触发以及相关的副作用和改进建议。 什么是 npm fund 命令? 在日常npm install安装依赖的过程中,我们可能都忽略了 command 最后输出的一些信息,比如本文相关的 funding 信息,如: 1 2 3 packages are looking for funding. Run "npm fund" to find out more. npm fund 命令是在 npm 6.13.0 版本中首次引入的,旨在帮助开发者识别其项目依赖中可以资助的开源包。运行该命令时,npm 会列出所有包含资助选项的包及其相关链接,便于开发者快速访问这些页面并提供支持。 命令起源 在 2019 年 8 月份时,Standard JS 在其开源项目中内置广告的事件引发热议,这些广告通过一个名为 Funding 的 npm 软件包展示在终端,该软件包包含在 Standard 的代码库中。之后 npm 公司宣布将禁止此类终端广告行为。 此事件后,npm 公司表示,它打算在年底前为开源开发人员开发一个众筹平台,于是乎在npm 6.13.0版本上提供了相应支持,这就是npm fund命令的主要由来。 命令作用 显示资助信息:npm fund 会扫描项目中的 node_modules 目录,查找每个包的 package.json 文件中是否包含 funding 字段。它会将有资助选项的包及其资助链接列出。 支持开源生态:通过此功能,npm 提高了对开源项目资助的透明度,鼓励开发者参与到开源项目的资助中,帮助维护者获得资金支持。 使用方法 基本用法非常简单,只需在项目根目录中运行: 1 npm fund 命令将输出形如: 1 2 xx packages are looking for funding run `npm fund` for details 再运行 npm fund,就会显示类似如下的详细信息: 1 package-name https://example.com/donate 如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ├─┬ https://opencollective.com/typescript-eslint │ │ └── @typescript-eslint/eslint-plugin@4.28.5, @typescript-eslint/experimental-utils@4.28.5, @typescript-eslint/types@4.28.5, @typescript-eslint/typescript-estree@4.28.5, @typescript-eslint/visitor-keys@4.28.5, @typescript-eslint/scope-manager@4.28.5, @typescript-eslint/parser@4.28.5 │ └─┬ https://opencollective.com/eslint │ │ └── eslint@6.8.0 │ ├── https://github.com/sponsors/epoberezkin │ │ └── ajv@6.12.6 │ ├── https://github.com/sponsors/sindresorhus │ │ └── globals@12.4.0, import-fresh@3.3.0, strip-json-comments@3.1.1, ansi-escapes@4.3.2, type-fest@0.21.3, figures@3.2.0, onetime@5.1.2, globby@11.0.4 │ ├── https://github.com/sponsors/isaacs │ │ └── glob@7.2.3 │ ├─┬ https://github.com/chalk/chalk?sponsor=1 │ │ │ └── chalk@4.1.2 │ │ └── https://github.com/chalk/ansi-styles?sponsor=1 │ │ └── ansi-styles@4.3.0 │ └── https://github.com/sponsors/ljharb │ └── minimist@1.2.8, is-generator-function@1.0.9, qs@6.11.2, side-channel@1.0.4, call-bind@1.0.2, get-intrinsic@1.1.1, has-symbols@1.0.2, object-inspect@1.11.0 ├── https://github.com/sponsors/RubenVerborgh │ └── follow-redirects@1.15.2 └── https://ko-fi.com/tunnckoCore/commissions └── formidable@1.2.2 npm fund 的运行机制和触发时机 运行机制 依赖扫描:npm fund 会读取 node_modules 中每个依赖包的 package.json 文件,寻找 funding 字段。如果找到了该字段,它会提取并显示相关的资助信息。 字段格式:funding 字段可以是 URL 字符串或更复杂的对象,指向资助页面。例如: 1 2 3 { "funding": "https://example.com/donate" } 或者 1 2 3 4 5 6 { "funding": { "type": "individual", "url": "https://example.com/donate" } } 触发时机 npm install 提示:在安装项目依赖时,如果项目中存在可以资助的包,npm 会显示类似“xx packages are looking for funding”的提示,提醒开发者可以运行 npm fund 查看详细信息。(这也是我们日常主要触发的时机) 显式调用:开发者可以手动运行 npm fund 命令,以查看当前项目中支持资助的所有包和资助链接。 如何避免 npm fund 的触发? 在某些情况下,开发者或企业可能希望在 npm install 过程中避免看到这些资助提示。以下是两种实现方式: 1. 在安装时使用 --no-fund 参数 直接在运行 npm install 时添加 --no-fund 参数: 1 npm install --no-fund 2. 修改 .npmrc 配置文件 在 .npmrc 文件中加入以下配置来永久禁用资助提示: 1 fund=false 此配置可以放在项目的根目录下(项目下的.npmrc文件),仅作用于当前项目;也可以放在用户主目录(~/.npmrc文件),作用于全局。 *可以通过npm config ls -l查看当前项目的npm配置,默认情况下fund配置会被设置为true 禁用 npm fund 的副作用 优点: 简洁输出:禁用 npm fund 提示可以减少 npm install 的输出信息,使终端显示更加清晰。 减少干扰:在企业级项目中,开发者可能更专注于安装过程和依赖的调试,不需要额外的资助提示。 缺点: 支持意识减弱:禁用该提示后,开发者不再会注意到可以资助的依赖,可能错失支持有价值的开源项目的机会。 透明度降低:新加入的团队成员或不熟悉项目的开发者可能不知道项目中有哪些包有资助选项。 开源支持意识降低:从长远来看,减少对资助信息的提示可能会让开发者对支持开源项目的重要性淡化,从而减少对依赖项目的贡献和支持。 npm fund 源码 源码文件:https://github.com/npm/cli/blob/latest/lib/commands/fund.js 以目前(2024-11)的源码内容来看,其源码机制概括来说是先使用 npm 的内部模块库函数来遍历 node_modules 目录,读取 package.json 并检查是否有 funding 字段,最后将所有符合条件的包信息格式化输出到终端。 代码流程总结: 1.读取依赖树:使用 Arborist 加载项目的依赖树。 2.解析资助信息:通过 libnpmfund 的 readTree 方法提取资助信息。 3.输出格式化:根据用户配置输出 JSON 格式或使用 archy 进行可读格式的输出。 4.链接打开:当提供了包名时,openFundingUrl 会尝试在浏览器中打开该包的资助链接。 最后 npm fund 是 npm 引入的一个有用的命令,帮助开发者支持开源项目并维持开源生态的可持续发展。虽然在某些情况下禁用它有其合理性,但在默认情况下保留该提示可以提高团队对开源项目支持的意识。根据项目和团队的实际需求,开发者可以灵活选择是否禁用 npm fund 提示。 相关链接 npm fund npm 6.13.0 npm config——funding What does ‘x packages are looking for funding’ mean when running npm install? npm cli document

2024/11/18
articleCard.readMore

【调研】通过Create.xyz的AIGC能力生产前端页面

通过Create.xyz的AIGC能力生产前端页面 介绍 Create是什么? Turn your words into sites, components, and tools - built with code. Create是一种新的人工智能创意工具,可以让任何人用自然语言进行构建。你可以用它来制作组件、网站、工具和应用程序。只要描述你想要它们的外观和工作方式,Create将用户的描述转化为实际的代码,实现所见即所得的设计理念。 地址:https://www.create.xyz/ 效果图: Create有哪些能力? Create.xyz的核心能力在于其能够理解自然语言描述(prompts),并将其转化为代码,生成静态页面和简单的CRUD页面。以下是其主要特点: 自然语言/图片转代码:用户可以通过描述或上传图片来生成页面。 代码生成:将描述转化为代码,支持组件和API的生成。 多模态支持:支持将图片转换为代码,实现Screenshot to App的功能。 1.结合大模型能力,将描述(prompt)转化为代码 Create turns your descriptions into code, so it looks and work how you want. E.g. “Create a Table to describe user’s info.”, “创建一个股票列表组件” 1.1 生成组件Components Quickly make components to build powerful projects or match your brand E.g. “A pie chart to show the distribution.”, “根据图片信息生成一个列表组件” 1.2 生成接口API Bring your own REST API.Upload your Swagger docs and have Create build you a tool that talks to your own custom APIs. Create的数据生成从结果来看相对简单,目测是在prompt中约束了使用fetch API以及增加了自己数据库的调用信息和表信息(text2sql方式)。另外我们也可以指定要ajax请求某个接口,但需要在描述中解释清楚接口出入参信息。 E.g.”查询用户所有数据”(需要打database标签) 1 2 3 4 5 6 7 8 9 10 fetch('/api/db/tempdata', { method: 'POST', body: JSON.stringify({ query: 'SELECT * FROM `users`' }) }) .then((response) => response.json()) .then((data) => { setUsers(data.result); }); 1.3 (平台能力)托管代码和页面部署能力 产生可直接访问的地址,如https://www.create.xyz/app/4cafb2c9-ba16-4288-984d-9a59ea102eab 2.多模态,将图片转换为代码 Screenshot to app,Drop an image of what you want into your description to build it Create的综合评估 stage2.5的形式(介于低代码到完全aigc之间)。 主要优势是产品形态(工作台)很完备、确实可以在这个工作台通过ai生成+修改完成简单页面的创建,主要问题是生成的组件几乎是“非受控”、“一次性”的、不太能复用(这个问题平台可以通过工程手段解决)。 输入:图片或需求prompt 输出:是代码(React组件+tailwind原子类css)、非DSL 能力实现概括:基于claude/gpt-4等模型生成UI组件/模块的代码 UI能力:支持“原子组件”、“模块”、“应用页面”的代码生成和静态组装 事件交互:仅支持简单事件的配置生成(点击跳转),前端没有状态管理能力 数据处理:默认生成的结果是静态“假”数据,需要在prompt声明调xxx接口才会改为取接口形式(结果需要修改);和大多低码平台一样开放了Database能力、可进行数据的存取(prompt2sql) Create的实现 整体使用下来,Create的生产公式为: 1 Page = Components + Functions + Datas(database) + *Assets → Sites or Apps = Page1 + Page2… Create核心的代码生成能力没有自训练模型,而是使用了Claude Sonnet3.5、gpt4等模型API,因此虽然具体实现是闭源黑盒的、但实现会类似于screenshot-to-code之类的通过区分生成场景、指令要求和控制上下文来把控生成效果。 实现思路: 1.自上而下拆分、渐进明细 自上而下进行需求拆分 根据需求进行prompt拆分、泛化及转换 2.推崇组件生产模式: 组件生产的工程方式能力更强,You can create much more powerful apps if you break your project into components. 推荐prompt不应该太复杂,如果复杂,拆到组件:If it gets too long or complex, you can move things into components to build up. 原子能力(Component、Function、Database)的实现 1.1 Component组件 组件的产物代码为 React + Tailwind CSS 技术栈、遵循 函数式组件 + UI受控组件的 编码模式。 优点:灵活,发挥空间大 问题:比较难受控,生产的组件是“一次性”的、不太能复用(这个问题平台可以通过工程手段解决) E.g. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 function MainComponent({ stockData }) { return ( <div className="bg-white rounded-lg shadow-md p-4"> <div className="flex justify-between mb-2"> <div className="flex space-x-4"> <span className="text-gray-500">个股资金</span> <span className="text-gray-500">板块资金</span> </div> <div className="flex space-x-4 text-gray-500"> <span>今日</span> <span>近5日</span> <span>近20日</span> </div> </div> <table className="w-full"> <thead> <tr className="text-gray-500 text-sm"> <th className="text-left py-2">名称/代码</th> <th className="text-right py-2">主力净流入</th> <th className="text-right py-2">最新</th> <th className="text-right py-2">涨跌幅</th> </tr> </thead> <tbody> {stockData.map((stock, index) => ( <tr key={index} className="border-t border-gray-200"> <td className="py-2"> <div>{stock.name}</div> <div className="text-gray-500 text-sm">{stock.code}</div> </td> <td className="text-right text-red-600">{stock.inflow}</td> <td className="text-right">{stock.price}</td> <td className="text-right text-red-600">{stock.change}</td> </tr> ))} </tbody> </table> </div> ); } function StoryComponent() { const exampleStockData = [ { name: '公司A', code: '000001', inflow: '10亿', price: '20.5', change: '1.2%' }, { name: '公司B', code: '000002', inflow: '5亿', price: '15.3', change: '-0.8%' }, ]; return ( <div> <MainComponent stockData={exampleStockData} /> </div> ); } 对应解析后的prompt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 Background color: White Border: Rounded corners Shadow: Medium Shadow Padding: Medium Header section Layout: Horizontal distribution with two sections Left Section: Contains two labels "个股资金" and "板块资金" in gray text Right Section: Contains three labels "今日", "近5日", "近20日" in gray text Table Full width Header: Text color: Gray Text size: Small Column titles: 左对齐:名称/代码, 右对齐:主力净流入, 最新, 涨跌幅 Rows: Border-top: Light gray Name column: Stock name in default text Stock code in gray, small text Inflow and Change columns: Right-aligned, red text Price column: Right-aligned default text 底层prompt感觉可以参考screenshot-to-code的prompt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 """ You are an expert React/Tailwind developer You take screenshots of a reference web page from the user, and then build single page apps using React and Tailwind CSS. You might also be given a screenshot(The second image) of a web page that you have already built, and asked to update it to look more like the reference image(The first image). - Make sure the app looks exactly like the screenshot. - Pay close attention to background color, text color, font size, font family, padding, margin, border, etc. Match the colors and sizes exactly. - Use the exact text from the screenshot. - Do not add comments in the code such as "<!-- Add other navigation links as needed -->" and "<!-- ... other news items ... -->" in place of writing the full code. WRITE THE FULL CODE. - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "<!-- Repeat for each news item -->" or bad things will happen. - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later. In terms of libraries, - Use these script to include React so that it can run on a standalone page: <script src="https://unpkg.com/react/umd/react.development.js"></script> <script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script> <script src="https://unpkg.com/@babel/standalone/babel.js"></script> - Use this script to include Tailwind: <script src="https://cdn.tailwindcss.com"></script> - You can use Google Fonts - Font Awesome for icons: <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"></link> Return only the full code in <html></html> tags. Do not include markdown "```" or "```html" at the start or end. """ 1.2 Database数据 这部分处理相对简单,fetch API + REST api + Text2Sql 模式 代码如: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 React.useEffect(() => { fetch('/api/db/tempdata', { method: 'POST', body: JSON.stringify({ query: 'SELECT * FROM `users`' }) }) .then(response => response.json()) .then(data => { setUsers(data.result); }); }, []); const addUser = (name, score) => { fetch('/api/db/tempdata', { method: 'POST', body: JSON.stringify({ query: "INSERT INTO `users` (`name`, `score`) VALUES (?, ?)", values: [name, score] }) }) .then(() => { setUsers([...users, { id: Date.now(), name, score }]); }); }; const deleteUser = (userId) => { fetch('/api/db/tempdata', { method: 'POST', body: JSON.stringify({ query: "DELETE FROM `users` WHERE `id` = ?", values: [userId] }) }) .then(() => { setUsers(users.filter(user => user.id !== userId)); }); }; const updateUser = (userId, name, score) => { fetch('/api/db/tempdata', { method: 'POST', body: JSON.stringify({ query: "UPDATE `users` SET `name` = ?, `score` = ? WHERE `id` = ?", values: [name, score, userId] }) }) .then(() => { setUsers(users.map(user => user.id === userId ? { ...user, name, score } : user)); }); }; 对应prompt: 操作请求: 1.3 Function逻辑处理/工具方法 自然语言说明规则,通过模型生成对应的处理函数代码 代码如: 1 2 3 4 5 6 7 8 function handler({ score }) { if (score > 60) { return { result: 'passed' }; } else { return { result: 'not passed' }; } } 对应prompt: 2.原子能力的组合——核心功能(菜单) 3.需求分析能力 分析图片/prompt意图,并将简单的需求描述转为详细的、可用于开发实现的prompt 模型:Claude、GPT4、Gemini、Groq… 相关链接 create.xyz screenshot-to-code

2024/9/28
articleCard.readMore

【工具】颜色色值处理库colorjs

颜色色值处理库Color.js 在前端开发中,处理颜色是一个常见且关键的任务。无论是在设计用户界面、创建数据可视化还是实现动态效果时,颜色都扮演着重要的角色。color.js 是一个由 Qix- 维护的 JavaScript 库,它提供了一套强大且灵活的工具来处理颜色的转换和操作。这个库在 GitHub 上的地址是:https://github.com/Qix-/color。 还有一个功能更为全面专业的库(https://colorjs.io/,由 CSS Color 规范的编辑者 Lea Verou 和 Chris Lilley 创建,并由一个小型的维护团队继续开发)、但日常大多场景不太用得到,本文不作更多介绍。 特点 轻量级:库的体积小(产物压缩+gzip后2.3k),加载速度快,对性能的影响微乎其微。 多种颜色空间支持:支持 RGB、HSL、HSV、CMYK 等多种颜色空间。 链式调用:支持链式操作,使得颜色转换和操作更加流畅。 扩展性:可以通过插件或自定义函数来扩展库的功能。 兼容性:兼容现代浏览器和 Node.js 环境。 安装和使用 安装 通过 npm 安装 1 npm install color 或者在浏览器中直接引入: 1 <script src="https://cdn.jsdelivr.net/npm/color@latest/color.min.js"></script> 使用 color.js 提供了一个简单的 API 来创建和操作颜色对象。 创建颜色对象 你可以使用多种方式来创建颜色对象: 1 2 3 4 5 6 7 8 9 10 11 // 通过颜色名 const color = color('blue'); // 通过十六进制 const color = color('#39f'); // 通过 RGB 数组 const color = color.rgb(255, 255, 255); // 或 const color = color.rgb([255, 100, 100]); // 通过 RGBA 对象 const color = color({r: 255, g: 100, b: 100, a: 0.5}); color.js 允许你设置颜色的各个通道值,包括 alpha、red、green、blue、hue、saturationl (hsl)、saturationv (hsv)、lightness、whiteness、blackness、cyan、magenta、yellow、black 等。 获取颜色值 获取颜色对象 1 2 3 4 5 // 获取颜色对象的哈希值 color.object(); // {r: 255, g: 255, b: 255} // 获取颜色对象的数组表示 color.rgb().array() // [255, 255, 255] 获取 RGB 数值 1 2 // 获取颜色的 RGB 数值 color.rgbNumber() // 16777215 (0xffffff) 获取十六进制值 1 2 3 4 5 // 获取颜色的十六进制值 color.hex() // #ffffff // 获取颜色的 RGBA 十六进制值 color.hexa() // #ffffffff 获取单个通道值 1 2 // 获取颜色的红色通道值 color.red() // 255 CSS 字符串表示 1 2 3 4 5 // 获取颜色的 HSL 字符串表示 color.hsl().string() // 'hsl(320, 50%, 100%)' // 调用 .string() 方法并指定小数位数 color.hsl().string(2) // 'hsl(320.00, 50.00%, 100.00%)' 颜色转换 color.js 支持在不同颜色空间之间转换: 1 2 3 4 5 6 7 8 // 转换为 HSL 格式 const hsl = color.hsl().toString(); // 转换为 HSV 格式 const hsv = color.hsv().toString(); // 转换为 CMYK 格式 const cmyk = color.cmyk().toString(); 颜色操作 你可以对颜色对象进行各种操作,如调整亮度、饱和度、色调等: 1 2 3 4 5 6 7 8 // 增加亮度 const lighter = color.lighten(1); // 增加饱和度 const moreSaturated = color.saturate(1); // 旋转色调 const rotated = color.rotate(90); 颜色比较和辅助获取 1 2 3 4 5 6 7 8 9 10 11 const color1 = color('red'); const color2 = color('blue'); // 比较两个颜色是否相等 const isEqual = color1.equals(color2); // 返回 false // 获取颜色的亮度值 const brightness = color1.brightness(); // 返回一个介于 0 到 255 之间的值 // 获取颜色的对比度比(与黑色或白色的对比度) const contrast = color1.contrast(color2); // 返回一个介于 0 到 1 之间的值 颜色明暗判断 1 2 3 4 5 // 判断颜色是否为“亮色” color.isLight(); // true // 判断颜色是否为“暗色” color.isDark(); // false 颜色距离和差异获取 1 2 3 4 5 const color1 = color('red'); const color2 = color('green'); // 计算两个颜色在 RGB 空间中的差异 const delta = color1.deltaE(color2); // 返回一个 DeltaE 值 颜色混合 color.js 还允许你混合两种颜色: 1 const mixed = color.mix('red', 0.5); 应用场景 color.js 的应用场景非常广泛,以下是一些典型的用例: 动态主题切换 在需要根据用户偏好或时间(如白天/夜间模式)动态切换主题颜色的应用中,color.js 可以帮助你轻松调整颜色的亮度和对比度,以适应不同的主题。 数据可视化 在数据可视化项目中,color.js 可以帮助你创建颜色渐变,以表示数据的变化趋势或不同类别的数据。 图像处理 在图像处理应用中,color.js 可以用于分析图像中的颜色分布,提取主要颜色,或者根据图像内容自动生成配色方案。 无障碍性检查 color.js 可以帮助开发者检查颜色组合是否符合无障碍性标准,如 WCAG 对比度要求,确保网站对所有用户都友好。 结论 color.js 是一个功能强大、灵活且易于使用的颜色处理库。它为前端开发者提供了一套完整的工具来处理颜色的转换、操作和动态调整,使得在各种项目中实现复杂的颜色需求变得简单。无论是在网页设计、数据可视化还是用户界面开发中,color.js 都是一个值得信赖的选择。

2024/8/31
articleCard.readMore

【笔记】《编程原则:来自代码大师Max Kanat-Alexander的建议》读书笔记

《编程原则:来自代码大师Max Kanat-Alexander的建议》(Understanding Software)读书笔记 作者介绍:马克斯·卡纳特-亚历山大是谷歌的代码健康技术主管,他的工作包括担任Xbox上YouTube的技术主管,在谷歌从事Java JDK、JVM和Java其他方面的工作,以及担任YouTube的工程实践技术主管,他在YouTube上为所有的开发人员提供最佳实践和工程开发效率方面的支持。 1 2 3 4 5 "关于如何对待编程领域中这些和编程间接、直接相关的知识,我见过两种极端的态度:有的人只看结果,只关心“写代码”,而对“写好代码”一无所知;第二类人深谙各种架构设计、整洁代码之道,但对于当下代码中遭遇的问题却没有落地的方案。 在互联网公司的多年工作经验让我个人更习惯于从第一种人的视角看待问题,毕竟这是行业性质决定的,跑马圈地、快速扩张才重要,行业不允许你有时间思考。但是抛开行业、抛开公司,单纯地看编码这件事,我作为程序员最大的疑惑是:为什么我在每一家公司接手的代码库都如此难以维护?为什么总有人写出500行代码的函数和1000行代码的组件?为什么每一个迭代的最后总是要加班加点,研发、测试、产品经理都叫苦不迭?为什么问题年复一年地发生,却没有人想做些什么来改变现状? 我观察到程序员存在一种战略上的惰性,对学习新技术和新框架,对阅读源码有发自内心的推崇。我不否定这种行为,新技术能给我们的项目带来便利,能给我们的简历增添浓墨重彩的一笔,这无可厚非。但技术背后的编写思路演化至今的原因,同样值得了解,它们和技术的语法本身同样重要。仔细回想和思考就不难发现,工具的好坏和代码的好坏,与项目将来适应需求变化的灵活能力没有关系,从写Vanilla JavaScript的年代,到BackboneJS,再到React,你看到团队中能把代码写好的人真的是越来越多吗?" 1 2 3 4 Uncle Bob Martin在他的“The Principles of OOD”系列文章中谈到过糟糕设计(Bad Design)的几个特征: - 僵化(Rigidity):代码难以修改,因为改动会影响到的地方太多。 - 脆弱(Fragility):当你做出修改时,系统中预期之外的地方会遭到破坏。 - 难以修改(Immobility):代码很难被复用,因为它与当前系统中的功能耦合在了一起。 1 "大部分时候——我说的是大部分时候,技术的决策是专制的。如果我在这个技术领域有丰富的经验,如果我解决过足够多的问题,哪怕是我在这个项目中待的足够久,那么对于当下任何一个新的问题,我就能想得更多,看得更远。当然如果团队的时间和人员充足,可以抱着培养新人的心态,放手把问题交给一个从没有接触过这方面领域的人来解决。" 程序员应该了解的基本原则 第1章 在你开始之前 要成为一名杰出的程序员,你必须首先想要成为一名杰出的程序员。 要做就把它做好 第2章 工程师的态度 在每一类工程领域里,每一位工程师都应该有的工作态度是:我可以用正确的方式解决这个问题。 无论这个问题是什么,解决问题的正确方式总是存在的。它不仅触手可及,在项目中也存在落地可能性。唯一不这么去做的正当理由只可能是缺少资源。 “正确方式”通常指“在考虑到未来所有可能发生的合理情况的前提下给出的解决方案,这个前提甚至包括那些未知的和难以想象的情况”。 如果软件代码在保持简约的同时,也为将来可能出现的合理功能变更需求提供了灵活性,那么就可以说它是以“正确方式”设计的。 第3章 成为明星程序员的独特秘密 越是理解你正在做的事情,就越是能把它做好。“明星”程序员比一般或者平庸的程序员更透彻地理解了他们正在做的事情。仅此而已。 与相信自己“对一切了如指掌”相距甚远的是,许多程序员(包括我在内)常常感觉自己身处于浩瀚无垠的信息海洋里,受困在一场史诗级战争中。有太多东西需要知道,以至于哪怕穷极一生致力于学习研究,可能依然只了解了90%的计算机知识。 这场史诗级战争中的神秘武器,击败计算机知识的王者之剑,就是对你所学习到的知识的理解。 越是理解所处领域的底层知识,学习高级别的知识就越容易。越是理解当前级别的知识,学习下一个级别的知识就越容易,以此类推总是成立的。如果你自认为对某一门学科内从基础到高深的知识要点都统统掌握了,那不妨选择从头开始温习一遍,相信你会惊奇地发现在底层还有如此多的东西需要学习。 成为杰出程序员的必经之路就是保证对知识完全和完整的理解,从对基础知识的深刻掌握,到对大多数先进概念的扎实了解都必不可少。 第4章 两句话总结软件设计原则 软件设计的主要原则可以浓缩为两句话: 1.减少维护成本比减少实现成本更重要。 2.系统的维护成本与系统的复杂度正相关。 这大概就是设计原则的全部了。 软件的复杂性和它的起因 第5章 复杂性的蛛丝马迹 你可以利用以下特征来辨别代码是否过于复杂了: 1.需要添加“hack代码”来保证功能的正常运行。 2.总是有其他开发者询问代码的某部分是如何工作的。 3.总是有其他开发者因为误用了你的代码而导致出现bug。 4.即使是有经验的开发者也无法立即读懂某行代码。 5.你害怕修改这一部分代码。 6.管理层认真考虑雇用一个以上的开发人员来处理一个类或文件。 7.很难搞清楚应该如何增加新功能。 8.如何在这部分代码中实现某些东西常常会引起开发者之间的争论。 9.人们常常对这部分代码做完全没有必要的修改,这通常在代码评审时,或者在变更被合并进入主干分支后才被发现。 第6章 创造复杂性的方法之一:违反你承诺过的API约定 API是某种形式的承诺:“你可以放心地完全按照我们描述的方式和我们的程序进行交互。”可一旦你的产品发布了新版本,并且在新版本中不再支持旧版本API,那就意味着你违反了这种承诺。它给软件增添了复杂性。 曾几何时你的API用户只需要调用一个简单函数就能完成工作,而现在他们需要对你的应用进行版本检测,并依据检测结果调用两个不同函数中的其中一个。为了同时兼顾新版本函数,他们必须采用和之前完全不同的方式来向函数传递参数,导致代码的复杂性被无辜地加倍了。如果你改变的函数数量过多,为了适应全新API的工作方式他们可能需要将整个应用重写! 如果你频繁地打破API约定,那么他们的代码为了适配也只能变得越来越复杂。唯一的额外选项就是让他们的产品不再与你的旧版本产品兼容。因为对于用户和系统管理员来说始终设法保证两者之间的同步是一项极其困难的工作。即便对于你个人来说,维护旧API也是痛苦的,摆脱它能够使工作轻松不少。 避免这个问题的最佳方案是不要发布糟糕的API。或者(从用户的角度上看)更恰当的是,在承诺会始终维护旧版API的同时,以其他方式提供可被访问的全新API。 举个例子,如果你想要访问salesforce.com某些旧版本的API,只需要在和程序交互时使用不同的URL即可。而每一次在和Salesforce API进行交互时,URL事实上都为你间接地明确指定了你希望使用的API的版本是什么。 无论在什么情况下,对外发布一组极不稳定或是设计拙劣的API,要么会让你的工作变得复杂(因为你需要永远保证向后兼容),要么会让你API用户的工作变得复杂(因为他们为了能同时兼顾“好”版本和“坏”版本的API而不得不修改所有的应用)。 如果你选择违背API约定并且决定不再向后兼容,请别忘了当中的一些API用户永远不会为了适配新的API而更新他们的产品。或许他们只是没有足够的时间和资源来更新他们的代码。或许他们在使用第三方工具来和你的产品进行交互,但是第三方工具的维护者已经不再提供更新了。无论是哪种情况,如果他们修复代码的成本高于适配你的新产品而带来的收益,他们就会依然选择使用你的旧版本产品,甚至永远用下去。 所以在研发资源充裕的情况下话还是应该对外提供一组可供访问的API。但是在实现之前请务必对API进行精心设计。你可以在正式发布之前自己多尝试使用看看。还可以细心地对你的用户进行调研并且发掘他们究竟会如何使用你的API。总的来说,在发布之前就需要尽你的全部所能来保证API的稳定。在未来你需要投入多少年精力来维护API并不重要,重要的是在发布之前采取一些明智的手段来了解API在现实场景里应该如何工作。 API一旦发布成功,如果条件允许的话,拜托请千万不要违背你的API约定。 第7章 什么时候不值得向后兼容 因为向后兼容而引发问题的最好例子就是Perl编程语言。 当不计其数的人都在这么使用,并且对他们来说改变习惯非常困难的话,结果就是很大程度就不得不保证向后兼容。但如果维持向后兼容这件事确实阻碍了技术向前发展,那么你就需要警告人们这些“老掉牙的玩意”应该消失,并且是时候对它们说再见了。 你的另一个选择是无节制的向后兼容并且不再向前发展,这意味着对你的产品判了死刑。 这很好地说明了为什么你不应该漫无目的地给你的程序添加功能。因为总有一天你需要为这些你开发的“尽管没有什么用但加上去很方便”的功能提供向后兼容的支持。这是在添加新功能时需要慎重考虑的一点——既然这个特性已经存在于你的系统中了,那么你打算永远把它维护下去吗?答案是:你很可能需要。 理想的解决方案是:如果你不想在许许多多的后续版本中支持这些功能,那么当下就不要添加它们。 有时候需要丰富的编程经验才能有效地做出这样的决策,但你可以从这个功能的角度思考:“它真的这么实用吗,以至于值得我在未来的三到四年里在它上面花费至少10小时的开发时间?”这种用于评估应该花费多少精力在某件事物上的方法适用于万事万物,包括向后兼容、质量保证,甚至对评审极小的功能也同样成立。 一旦你拥有了一个功能,就意味着维护它的向后兼容性将会是日后的绝大部分工作。 你应该认真考虑放弃向后兼容的唯一时机是,当它在妨碍你添加明显实用且重要的新功能的时候。如果这种情况确实发生了,那么你就需要放弃向后兼容了。 第8章 复杂是牢笼 如果你编写的代码如此复杂以至于没有人能理解它怎么办?好吧,结果就是你个人会被永远地束缚在这个项目上面。 复杂是牢笼,简单是自由。 简约与软件设计 第9章 设计要从头抓起 你需要从一开始就着手于软件设计,应该从立项之初就致力于将架构设计得简约明了。 除非架构设计支持轻松地将该功能实现,否则我们绝不允许新增该功能。 如果你不考虑未来,那么你的所有代码都会陷入糟糕的设计和极度的复杂之中。“我们等不及了!这个功能非常重要!”又或者是:“现在只管把它加进来就好了,完事之后我们会把代码整理重构的!”他们从没意识到他们的态度一向如此。等到下一次需要添加另一个功能有求于我们时,他们还会说出同样的话。 如果只是新增很少的功能,并稍后将它重构的话就不太有可能出现这种问题。但如果空降一个架构无法支撑的大型功能,还计划在完成之后尝试整理代码,那这将会是一项艰难的任务。所以说功能的体量很重要。 最糟糕的情况是,在你允许人们在几个月或几年内不经过提前设计就往代码中新增功能,然后有一天你幡然醒悟并意识到系统有可能撑不住了。此时你唯一的选择只能是修复整个代码库。这注定会是一项艰巨的任务,因为就像新增功能一样,它无法一气呵成,除非你想要重写整个应用。 如果你想要开始以正确的方式行事,那么你必须以正确的方式立即行动起来。为了解决当下的问题,你必须将整个流程拆分为简单的步骤,并逐步对设计中存在的缺陷予以修复。这通常需要数月甚至数年的工作投入——简直就是浪费。因为你本应该从一开始就将架构设计好,这样的话这些问题从根本上就可以避免。你应该事先把目光放长远一些。 如果你的项目缺乏严格的架构设计,并且它的体量还一直在持续增长,那么终有一天超乎你想象的复杂性会让你束手无策。 这并不意味着你从一开始就需要设计能够满足未来所有需求的大型通用架构,并且现在就实现它。上述观点想表达的是,你需要在工作学习中应用本书和《简约之美》中讨论的那些软件设计原则,这样从一开始你就会拥有一个可理解的、简约的并且具有可维护性的系统。 第10章 预测未来的准确度 预测软件的未来如此困难。预测未来的准确度,会随着系统复杂性和预测点距今时间跨度的增加而降低。也就是说随着系统变得越来越复杂,你只能以有限的准确度预测短时间范围内的未来。反之随着系统变得越发简单,你越能以高准确度预测较远的未来。 保证系统架构足够简单,便于你轻松地将旧语法替换为新语法。注意这里对系统架构的要求不是“灵活”,也不是“通用”,而是简单到易于理解和修改。 在现实工作中,存在一种基于以上准则扩展之后的逻辑先后关系: 1.预测未来的难度会随着系统和被预测功能所处环境内,所有修改之处数量总和的增长而增加。(注意,环境带来的影响与它和系统的逻辑距离成反比。如果你的系统与汽车有关,那么对引擎的修改可能会给系统带来非常大的影响,但是对环境内某棵苹果树的修改带来的影响则微乎其微。) 2.系统需要经受的修改与系统的整体复杂性相关。 3.所以:预测变困难的速率会与被预测行为所属系统的复杂性成正比。 不要想当然地依据你认为将来会发生的事情做出设计决策。请记住所有这些即将发生的事情都存在发生的概率,无论预测多少次都存在出错的可能。 当我们只关注当下,关注我们已有的数据,关注我们现有的软件系统,相比预测我们的软件在未来何去何从,我们更容易做出正确的决定。大部分在软件设计中犯下的错误来自假设未来需要做些什么(或者完全不需要做些什么)。 当你发现随着时间的推移,软件的某些代码变得难以修改时,这条规则会带给你帮助。你永远无法完全避免代码被修改,但如果你的软件简化到傻瓜都能理解的地步,那么修改的可能性就会大大降低。虽然它可能依旧会在软件质量和实用性方面逐渐衰退(因为你没有即时追随环境的变化对它进行修改),但是它衰退的速率远比复杂的时候要慢。 编写简单的软件比编写复杂的软件花费的功夫更少。虽然有时需要加入额外的思考,但总体来说需要的时间和投入会更少。所以尽可能保证架构的合理简约,就是在为我们自己取得一场胜利、为我们的用户取得一场胜利、为未来取得一场胜利。 第11章 简约与严格 一个普适的原则是:你的应用程序对编码要求越是严格,就越易于编写。 1 2 举一个例子,想象一个应用程序只接受数字1和2作为输入,并且除此之外的任何其他形式的输入都被统统禁止。那么即使发生在输入时的小小变化,比如在“1”之前或者之后增加一个空格都会引起程序的报错。这样的程序在被称为非常“严格”的同时也极易编写。你需要做的仅仅是校验:“他们输入的究竟是1还是2?如果都不是,则报错。”然而在大多数情况下,如此严格的程序显得不切实际。如果用户不了解你期望他们输入的格式,又或者如果他们在输入数字时不小心敲击了空格或者其他的字符按键,程序会拒绝“执行他们的意图”而给用户带来挫败感。 上述就是需要在简约(严格)和可用性之间进行取舍的典型场景。并非所有的严格之处都需要进行取舍,但大多数地方无法避免。如果我允许用户同时以1,One,或者“1”作为输入,那意味着程序增加了对用户行为的容错空间,并且让他们的工作变得简单了许多,但是同样也给我们的程序增加更多的代码和复杂性。没那么严格的程序会比严格的程序占用更多的代码,这也是复杂性的直接来源。 顺便说一句,如果你正在为程序员编写框架或者是编程语言,你的最佳选项应该是让用户接口“不那么严格”,甚至是尽可能地简约,这样就不必在可用性和复杂性之间权衡了,让开发者同时感受到两个世界的美好。 严格这个词大部分时候意味着你给用户的输入设置了一份白名单。在有些应用中,你还可以对输出做出严格的限制:输出通常需要迎合一类特殊并具体的标准。但是通常来说,你能接收什么样的输入以及什么样的输入会引发错误,这两件事会显得更重要。 或许最知名的与严格有关的灾难就是HTML。正因为它从一开就被设计成不那么严格,在经过几年的普及之后,导致处理它的兼容性问题成为浏览器设计人员的噩梦。当然它最终还是被标准化了,但那个时期的大部分的HTML代码阅读起来依然会令人抓狂,现在这种现象还是存在。因为它从一开始就不够严格,所以现在没有人敢打破向后兼容并将它变得严格。 总而言之,我坚持认为计算机永远不应该“猜测”或者说“尽全力满足”用户的输入。由此引入的噩梦般的复杂性会导致程序极易失控。猜测唯一能恰如其分发挥功效的地方是内置于类似于谷歌网站的拼写建议功能中。它提供你做事情的选项,但不会一股脑地基于猜测的结果去完成工作。这也是我在谈论严格时想强调的另一个方面,输入要么是对要么是错,不存在“也许”这种情况。如果一个输入有可能包含多层含义,要么你应该为用户提供选项,要么直接报错。 在计算机世界中人们从一开始就应该对很多事物做出严格的限制,正是因为这类约束的缺失,导致这些事物现在看上去复杂得有些可笑。 当然,对可用性的关注依然重要。毕竟,电脑是帮助人类完成工作的。但是你没有必要为了可用而兼容普天之下的所有可能发生的输入。那会导致你陷入复杂性的迷宫之中,如果你义无反顾地打算继续这么做,祝你早日找到迷宫的出口。可你要知道他们从来不会严格按照标准化的方式制作迷宫的地图。 第12章 两遍已太多 代码只在必要时才需要通用。 一旦我意识到自己正打算将同一份功能实现两遍时,就会开始执行这个步骤。 该原则中至关重要的一点是立即采取行动。我不允许代码中存在两种相互竞争的实现。我当下就将它们合并成了一个通用解决方案。另一个重点是我不会把它抽象得过于通用。 基于“两遍已太多”原则我们能进一步推导出:理想情况下,开发者修改某处代码的方式不应该与修改另一处代码的方式近似甚至相同。 这也就是说,开发者不应该在修改B类时必须“记得”去修改A类。他们也没有必要知道如果常量X发生了变化,Y文件也需要更新。换句话说,不仅两种实现会带来糟糕的开发体验,两个文件位置也会。虽然系统内的重复代码并非总能被合并且共享,但这应该是我们解决问题的方向。 当然,“两遍已太多”中最浅显的含义实属那条经典原则:“DRY”。所以不要用两个常量表示同一件事情,不要定义两个函数来干同一件事情,等等。 这条规则在其他方面也同样适用。总而言之思路是,当你发现对于单个概念存在两套实现方案时,你应该想办法将它们合并为单个解决方案。 在重构代码时,这条原则能够帮助你找到代码中值得改善的地方,并且能给予你一些重构方向的提示。例如在你发现系统中存在逻辑重复的地方时,你应该尝试将他们合并在一起。当另一处重复逻辑再次出现时,继续将该处合并到刚刚的通用逻辑中,如此重复执行。 也就是说如果有太多的代码需要进行合并,你可以按照对每两处执行一次合并的方式进行增量重构。采取什么样的方式并不重要,只要合并的工作确实能够让系统变得简单就好(易于理解和维护)。有时候你需要判断以什么样的顺序将这些代码合并是最有效的,但是如果你无法判断出来也不用担心——直接对每两处执行一次合并就好了,船到桥头自然直,通常重构的所有问题最后都会迎刃而解。 千万不要将不应该被合并的逻辑放在一起。将两种不同的实现合并在一起常常会给系统创造更多的复杂性,或者导致代码违反了单一职责原则,这条原则告诉我们:任意给定的模块、类或者函数在系统中应该只表示单一的概念。 举个例子,如果你系统中用于代表车和人的代码有轻微的相似之处,请不要通过把他们合并为“车人”类来解决这个“问题”。这样并不会降低复杂性,因为车和人的确是两类不同的事物,并且应该由两个独立的类来表示。 第13章 健壮的软件设计 我们做的最重要的一个决定,是确保整个过程足够简单。为了达成这个目标,我们让所有孔的尺寸都规范化起来,让所有操作都很简单且易于拆解。 调试代码 第14章 什么是bug bug的精确定义: 1.程序的行为并没有符合程序员的预期。 2.程序员的预期没有满足绝大部分理性用户的期望。 通常来说只要程序能够严格执行程序员给出的指令,它就可以算是处于正常工作的状态。但有时候程序员期望程序执行的行为会出乎普通用户的意料,甚至给他们带来麻烦,所以这也算是一类bug。 其他软件功能上的不足都可以归纳到新功能需求中。如果说程序的工作状态的确与我们期望的一致,但离用户期望还有差距,则意味着它需要新“功能”。“功能”和“bug”定义之间的区别也就在这。 本质上说,任何导致程序员指令没有被正确执行的故障,都可以被认为是bug,除非程序员打算让计算机做一些它本不应该去做的事情。 第15章 bug的源头 bug通常来自开发者尝试降低代码复杂性未果而产生的副作用。也有部分来自对其实简单的代码产生的误解。 复杂的事物容易引起用户的误操作。在编程中也存在类似的情况,如果你无法轻易理解编程语言的文档,或者是这门语言本身,你就或多或少存在错误使用它的可能。 你每引入一丝复杂性,开发者(这里的“开发者”甚至包括你自己)误用你的代码的概率就高一分。 一旦代码的意图和使用方法变得极不明确,就会让使用这份代码的人犯错。又因为你的代码和其他的代码混合在了一起,导致了开发者误用和犯错的可能性大大增加。而后这些代码又会继续和其他的代码混合,形成恶性循环。 硬件设计者将硬件制造得极为复杂的情况时常发生。所以它必须与复杂的汇编编程语言集成。而这又使得汇编语言和编译器同样复杂起来。当你遇到这种情况时,如果你不提前对程序进行精妙的设计或者全方位的测试的话,基本上无法避免bug的发生。只要你的设计不够完美,那么在运行的一瞬间,大量的bug就会涌现出来。 站在其他程序员的视角看这件事也很重要。毕竟有些事对你来说很简单,但是对其他人来说或许很复杂。 如果你想要感同身受地体验一下其他人看不懂你的代码的感受,你可以找一份你从没有使用过的类库的文档来阅读看看。 也可以找一些你从没有阅读过的代码来阅读。尝试理解整段程序而不是单行代码的含义,并且想象当你需要对它进行修改时应该从哪里入手。这些都是其他人阅读你代码时的体验。你大概注意到在阅读他人代码时,即使并不复杂的代码也足以让人产生挫败感。 现在我们考虑另一种程序员误解简单代码的情况。这也是需要额外小心的另一件事。如果你察觉到某位程序员在向你解释一段代码时叙述得牛头不对马嘴,那便意味着他应该是误解了代码中的某些内容。当然如果他正在研究的领域极其复杂,也情有可原。 这两个方面是紧密关联的。当你编写代码时,需要承担的部分职责是让将来阅读你代码的程序员理解它,并且是很轻松地就能理解。如果你确实是这么做的,但是他在阅读过程中仍然产生了严重误解——或许他根本就不明白“if”语句是什么含义。那应该就与你无关了。 所以最后可以总结出几条有趣的原则: 1.你写的代码越简单,bug就越少。 2.你应该始终想方设法去简化程序中的代码。 第16章 确保它不会再发生 当你在解决代码中的问题时,你不应该止步于只修复问题表象。而是应该确保问题彻底消失并且永远不会再发生。 请记住,我们最在意的是软件的未来。软件公司代码库之所以会陷入无法维护的失控局面,是因为他们并没有真的在解决问题,只有切实解决这些问题之后,代码的可维护性才可能好转。 这也解释了为什么有的组织内部的紊乱代码始终无法回归到一个良好的可维护状态。当他们遇到一个问题时,他们应对问题的出发点仅仅是设法让提出问题的人停止抱怨,用这种态度解决问题之后继续以同样的态度应付下一个问题。他们不会考虑引入一个框架来阻止问题的再次发生。他们也不会追溯问题发生的根本原因然后斩草除根。所以他们的代码从来没有真正地“健康”过。 衡量一个问题是否被真的解决的恰当标准是:直到人们不需要再次对它进行关注。 绝对地做到这一点是不可能的,因为你无法预测到所有的可能性,但这条原则更多的是想提供理论上的指引而不是实际的操作指南。在大部分实际情况中,你能做到的是当下不会再有人被这个问题困扰,但是并不代表问题在未来不会再次出现。 可以提出更多的问题: 为什么开发者会写出错误代码?bug为什么会存在?是开发者接受的技能培训出了什么问题?还是工作的流程中存在纰漏?他们在编写代码的同时是否也应该编写测试?会不会是系统的设计缺陷导致代码难以修改?编程语言过于复杂了?他们用的类库编写的不够友好?操作系统出了什么问题?文档描述得不够清楚? 如果你有了关于某个问题的答案,你可以继续思考产生这个问题的根本原因又是什么,并且持续追问下去直到你所有的诱惑都已经解开。但是请小心:你并不知道这一串问题的终点在哪里,甚至整个过程会颠覆你对软件开发的看法。事实上从理论上来说,在这一套方法论下可以提出无限多的问题,并且终将让整个软件行业的根本问题得到解决。但是在这条路上要走多远还是取决于你自己。 第17章 调试代码的基本哲学 有时候人们在调试代码时会感受到强烈的挫败感。因为绝大部分人在调试系统代码时,倾向于将时间花费在思索而不是追溯代码的调用上。 当你开始调试代码时,请意识到其实你对答案一无所知。 人们倾向于相信冥冥中自己已经悟到了问题的答案。有时你确实能够猜对。这种情况不常发生,但是发生的频率之多,让不少人误以为猜测也是调试代码中的有效手段之一。 大部分时候,你可能会花上几个小时、几天甚至几周来猜测问题究竟出在哪里,并且尝试各种除了让代码更复杂之外毫无实际用处的修复方案。你会发现在一些代码库中充斥着仅依据猜测编写的用于修复“bug”的“解决方案”——这些所谓的“解决方案”恰恰是代码库复杂性的一大来源。 通常来说,成功对bug进行修复,也应该意味着系统在变得更好,比如系统变得更简单了,架构设计得到了优化,等等。 通常,bug的最佳修复方案,会在修复问题的同时,间接地移除冗余代码,并且简化系统设计。 基本上在遇到问题的第一时间内,你脑海中冒出的想法都属于无稽之谈。此时此刻你需要了解的只有两件事: 1.记住系统正确的行为是什么。 2.想清楚应该通过追踪哪一部分代码来收集更多的有效信息。 这才是调试代码中最重要的原则:调试代码指的是在你找到问题的起因之前,持续收集信息的过程。 可以通过深入了解系统的工作原理来收集信息。以服务器无法返回页面的情况为例,或许你可以在通过查阅系统日志找到线索。又或者你可以尝试重现问题,并通过观察服务器此时的工作状态来发现蛛丝马迹。这也是为什么处理问题的人总是希望能“还原现场”(通过一系列步骤能够让你复现问题)。这样他们就能在bug发生时回溯出了什么样的问题。 有时你的首要任务是明确bug究竟是什么。通常用户上报的bug信息内容都相当有限。你的用户越是没有计算机相关背景,在缺乏引导的情况下他能够准确表达问题的可能性就越低。在这些情况下,除非问题十分紧急,否则我首先要做的事情就是请求用户给出更详细的出错信息,并且在我得到回复之前我不会采取任何行动。也就是说,在他们明确bug之前我绝不会自行尝试解决这个问题。 如果在对问题一知半解的情况下就着手尝试解决它,那么我可能会把时间都浪费在查看各种和问题无关的系统随机角落上。所以为了让时间花得更有价值我才选择等待用户的进一步反馈,并且最终当我确实拿到一份完整的bug报告时,我才会着手探寻bug背后的原因。 请注意,不要因为用户提交的bug信息不够丰富而迁怒于他们。虽然他们对系统的了解不如你,但并不意味着你有资格用不屑的态度鄙视他们。为了获取信息你应该直言不讳地提出问题。 要知道引导他们提供正确的信息也是你的工作职责之一。如果人们总是无法提供正确的信息,你可以尝试在报错页面提供一个表单来帮助他们梳理出正确的信息有哪些。我想表达的是帮助其实是互惠的,只有你帮助了他们,他们才能反过来帮助你,这样你才更容易地解决问题。 一旦明确bug,接下来你就需要对系统的不同组件进行排查以找到错误原因。至于从哪些组件入手排查取决于你对系统的了解程度。通常是从日志信息、系统监控、错误消息、核心转储或者是系统其他的输出信息入手。如果系统无法为你提供这些信息,你或许需要考虑在继续排查问题之前,发布一个能够收集这些信息的新版本系统。 尽管对于只修复单个bug而言,这看上去似乎需要耗费不少的工作量,但相比你在系统内毫无目的地碰运气来猜测问题的原因,发布能够提供有效信息的新版本系统还是能够提升不少效率的。这也是支撑快速发布、频繁发布实践的有力论点:发布新版本的频率越高,你收集到的调试信息速度也就越快。有时你甚至可以定向地为遇到问题的用户发布新版本系统,这也可以作为收集信息的捷径。 调试代码是一类将已有数据与期望数据进行比较的行为。 当你意识到找到问题的根本原因时,是当你十分肯定在将它修复完毕之后错误就再也不会发生了的时候。这不是绝对的——关于如何“修复”bug还有可以讨论的空间。bug需要修复到何种程度取决于你的解决方案想解决到哪个层次,以及你想要在上面花费多少时间。通常在你找到某个问题的深层原因,并且将它修复之后,就能看出你最终做出了什么样的选择——这再明显不过了。但我依然想要警告你,只解决问题的表面症状而不解决引起问题的深层原因是有风险的。当然,在找到原因的当下就马上修复它。这其实是正常情况下最直接的方式。 调试代码的四个主要步骤: 1.熟悉正常工作的系统行为应该是什么样的。 2.接受其实你并不知道问题原因的这个事实。 3.追踪代码直到你找到问题的原因是什么。 4.修复根本原因而不是表面症状。 1 2 3 这听起来十分简单,但我基本上看不到有人能遵守这一系列准则。我的所见所闻是,大部分程序员在遇到bug时,喜欢坐下来思考,或者通过询问他人找到问题可能发生的原因——这两种做法都无异于猜测。 与那些对系统有一定的了解,并且能给出可以从何处收集有助于调试信息的人沟通是办法之一。但是与其一群人坐在那里猜测问题的原因,其实和你一个坐在那瞎猜没有区别,唯一的收获可能是和你喜欢的同事聊天产生的一些愉悦感吧。上面的做法无非是用浪费大伙的时间代替浪费你自己的时间而已。 所以请不要浪费大家的时间,不要在代码库中引入不必要的复杂性。上面给出的代码调试方法是可行的。无论在何时何地,对什么样的代码库或者系统而言都是适用的。 有时候“收集信息”的过程会相对困难,特别是对于那些你无法重现的bug,但最坏的情况也无非是通过阅读代码来收集信息,尝试找到代码中bug所在,又或者把系统的工作流程图画出来,看是否能发现症结在哪里。我建议把这些方法当作没有办法的办法,但是即使你这么做,也比猜测问题出在哪里或者假设你已经知道问题在哪里要强。 团队里的工程问题 第18章 高效工程开发 通常来说,致力于改善团队开发效率的同事会陷入两难的局面,要么他们会和他们所服务的开发者产生冲突,要么他们的时间都花费在一些截止时间遥遥无期的项目上面,因为大家对这些项目漠不关心。之所以会发生这种情况,是因为开发团队认为有待解决的问题并非实际存在的问题。 随着时间的推移,负责效率改善的人员会对周围合作的同事形成一种敌对的态度。他们认为如果其他的工程师如果能够“使用我开发的工具”,那么所有麻烦都能迎刃而解。但是开发者最终并没有选择使用你编写的工具,所以你又凭什么认为你的工具举足轻重呢。 问题在于,一旦你开始忽略其他开发者的抱怨(又或者完全意识不到他们遇到的问题),你们之间对立的种子就已经种下了。它不是一个由好变坏逐渐腐化的过程。而是从一开始当你认为问题是这个,而其他开发者认为问题是那个的时候,矛盾就诞生了。 如果你做了一大堆的重构工作,但是根本没有人继续维持重构后代码的简约,又或者你写了一堆没有人使用的工具/框架,那本质上你还是和什么都没有做一样,太令人沮丧了。 当你在解决开发效率低下的问题时,开发者是你解决方案的用户。你不能无脑地同意其他开发者提供给你的关于如何实施解决方案的建议。这么做可以在一定程度上哄这些人开心,但这终将会让系统变得难以维护,而且也仅仅是满足了那些叫喊声最大的用户的需求而已——他们很可能并不代表你的大部分用户。如果你接受了他们的建议,那么你最终会得到一个设计混乱,甚至连真实用户需求也无法满足的系统。 举一个例子,假如开发者向你抱怨他们某个千万行代码的单体二进制代码发布流程太慢了,接着你就把时间都花费在想方设法让发布工具变得更快的工作上,结局是多半你不太可能带来好的改善。或多或少能带来一些改善(让发布更快),但是永远也没有解决根本问题,根本原因是这个二进制代码体积太大了。 你要做的第一件事是明确开发者认为的问题所在。不要做任何的评判。四处走走和不同的人聊聊。多听听那些直接和代码库打交道的人的意见。如果你没有机会和每一位工程师交谈,可以先从与每个团队的技术管理者沟通开始。然后你可以和管理层聊聊,毕竟他们也有你需要予以记录和解决的问题,你需要对这些问题进行了解。但是如果你只想要解决开发者遇到的问题,你应该从开发者身上找出问题是什么。 一般来说,如果你直接问开发者代码的复杂之处,他们不一定能回答上来。如果你问“什么地方过于复杂了”又或者“你认为的难点是哪”,他们可能想了半天也给不了你答案。但如果你希望得到大多数开发者对于他们编写或使用的代码的情绪上的反馈,那么他们还是有很多话可以说的。我会问一些类似于这样的问题,“这份工作有什么让你感到闹心的地方吗”,“哪一部分代码你修改起来最不爽”,“代码库中有什么地方是你因为害怕改坏了而不敢修改的”。如果面对经理我会问:“代码库中有没有开发者常常抱怨的地方?” 你可以根据你的情况对这些问题进行调整,但请切记你是真心想要和开发者们进行一次对话——而不是机械地把问题列表读一遍而已。他们会说一些你有兴趣深入了解的事情。你可能需要把当中的一些内容记录下来。 在这项工作持续一段时间后,你大概就能察觉到这些抱怨中的共通点(或者某些共通点)。这并不是我们想要寻找的唯一原因——即使没有和大家交谈我们大概也能猜出来。我们想要寻找一些更高层次的原因,类似于“构建二进制文件过于缓慢”。有更多类似的原因有待我们发掘。 首先你可以从收集的信息中找到那些开发者已知的,且能在短时间(比如一至两个月)改善的问题,并给出解决方案。解决方案没有必要完全颠覆现有工程师的开发模式。事实上它也不应该这么做。因为当前变革的重点是为了建立大家对你工作的信任。 提升开发效率的成功与否,取决于你的个人信誉。 你可以预见总有一天你需要解决本质上的问题。只有当其他开发者对你有足够的信任,你才有机会朝那个方向努力,当你想要做出一些改变时,大家会相信你的解决方案是正确的。所以你首先需要做的事情是,在团队中树立自己的可靠形象。 通过解决第一个问题,大家已经对你有了基本的信任,接下来你可以着手搜寻开发者真正面临的问题,以及最佳的解决方案可能是什么。这通常不可能一气呵成。此时你需要注意到另一个知识点——你不能一下子推翻并重建所有的团队文化和开发流程。你必须以渐进的方式,将变革逐步“渗透”到大家的工作中(人们通常会因为你改变了什么,或者改得面目全非,又或者第一轮变革并不起作用而感到生气),等到大家适应之后再考虑推进下一步工作。 如果你试图将变革一步到位的在团队内推广生效,一定会有人公开的反对你——这些反对的声音会让你的个人信誉荡然无存,还会使得你所有的努力付之东流。于是你又不得不回到之前提到的两个毫无建设性的解决方案——要么团队变得士气低下,要么毫无起色。所以你必须按部就班地展开工作。有的团队可以接受较大程度的变革,有的只能接受较小程度的变革。通常团队的规模越大,你执行的过程越要缓慢。 你应该找一批支持你的人,建立一个能为你付出的努力背书的核心圈子。绝大部分程序员还是帮理不帮亲的,即使他们口头上什么也没有说。 当有人提出他们的长远改善计划时,你应该公开鼓励他们。不要要求每个人都做出完美的改变——你的当务之急是凝聚你的“团队”来验证清理代码、效率提升的种种手段是有价值的。你还要负责营造志愿者文化或者经营开源项目——你必须非常地有感染力和友好才有助于这些工作的推进。但这并不意味着你应该接受糟糕的改变,但是如果有人想要做出改善,你应该至少对他们表示肯定和赞许。 有时十个人里有九个人想要做正确的事情,但他们的声音会被那个嗓门最大的人的声音所掩盖,以至于他们想当然地认为应该尊重那一个人的想法,而不是据理力争。所以你应该尽力争取这一部分人的支持,这有助于你工作的展开。通常,忽略那个嗓门最大的人的声音继续一往无前地改善工作也是办法之一。 如果你终究还是被某些高层人士一致叫停,可能存在两种情况: (a)解决问题的方式有所偏差(可能是你并没有按照我上面推荐的方式去执行,也可能是在和团队的沟通上出现了问题,还有可能是你正在做的事情会对开发者造成负面影响等)。(b)叫停你工作的人愚蠢至极,无论他们看上去多么地“正常”。 如果你的工作被叫停是因为你正在做错误的事情,那么找出什么对开发人员最有帮助,然后回归到正确方向去做就好了。有时这只需要和那位叫停你的高层人士好好沟通就能找到答案。 假设你现在正在通过渐进的方式,有条不紊地改善团队的开发效率,一些潜在障碍也逐渐被清除。那么接下来该何去何从?答案是请确保你的前进方向瞄准的是本质问题。 总有一天你需要解决这个问题,而解决的方式之一是需要纠正人们编写软件的方式。 先不要对外发布承诺,不要大声宣布你有一揽子改善开发效率的计划,并且计划是从重构代码开始的。 你应该希望人们产生一种思维惯性,比如“开发也意味着对代码进行整理”或者是“代码的质量也很重要”。也可以是其他你希望营造的文化氛围。 一旦你在团队内成功建立起了一种改善代码的团队文化,即使你不再对它进行过问,问题也会随着时间推移迎刃而解。这并非意味着工作就此结束了,一旦每个人都关心代码质量、测试和开发效率时,你会发现即使没有你的积极干预,事情也能开始向好的一方面发展,但最坏的情况也不过如此。 请牢记,整个流程的重点并不是在于“达成共识”。你并非在争取团队中每个人关于你应该如何完成你的工作的许可。而是在找到人们认为的问题所在,并且提供一个解决方案将其修复完毕,这个他们认可的解决方案不仅能够建立起大家对你的信任,还能逐步解决代码库的深层问题,并且确保它并不是为了迎合某个人而诞生的。你只需要记住一件事:解决那些人们认为他们面临的问题,而不是你认为他们面临的问题。 最后一件我想要说明的是,所有这些技巧的前提是,你作为个体独自在负责整个公司或者整个团队的效率提升。还存在一些其他的场景——事实上,这并不是大部分效率提升工作的常态。实际工作中有的人会负责一部分工具的研发、有人负责框架的研发、有人负责和下属团队打交道等。 第19章 量化开发效率 一般我会优先把工作重点放在简化代码的设计上,我认为量化每一位开发者干的每一件事并不重要。几乎所有的软件问题,都是因为没有成功采用软件工程中的原则和实践。所以即使缺乏衡量标准,如果你能设法让整个公司都采用同一套好的软件工程实践,大部分的效率瓶颈和开发中遇到的问题都会自动消失。 有一种说法是,如果能将一切量化的话,这终将能带来巨大的价值。它能帮你识别出编码难点,允许你奖励那些效率提升的员工,允许你在效率欠佳的部门花更多的时间展开效能提升工作,当然还有其他数不清的好处。 但是编程不像其他的职业。你没法像量化制造业流程那样对它进行量化,在制造业中你只需要统计从流水线上检验合格下线的产品数量。但是你如何衡量一个程序员的产出呢? 秘诀在于对“效率”进行恰当的定义。理解效率的关键在于,意识到它与产出物有关。一个有效率的人通常都能够高效地输出产出物。 衡量开发者效率的方式之一是衡量他的产出物。 如果你想衡量一个人的产出物,你不应该去判断他掌握这门手艺的精湛程度。你应该衡量通过这门手艺他带来了多少产出物。 第一件需要想明白的事情是:对于用户来说,程序的哪一部分产出是最有价值的?软件的目的其实是“帮助其他人”。所以第一步就是确定哪一类人群是你的软件帮助的对象,以及在使用产出物为他们提供帮助时,会带来何种影响。 例如你负责研发和维护一款用于个人用户报税的会计软件,你可以把通过使用你的软件,成功且准确地填写了纳税申报的人数作为有效指标。当然软件的成功离不开公司内每一个人的努力(包括销售人员在内),但是程序员需要为软件的易用性和质量属性负主要责任。 有的人喜欢挑选那些程序员全权负责的事物作为指标,我建议不要盲目地依赖它——如果想要将它作为衡量个人产出物的有效手段,程序员不一定是唯一能够对它产生影响的人。 量化一个系统的指标也是多种多样的。假设你为一个购物网站工作。后端开发者或许会以成功执行的数据请求数量作为某项指标,而前端开发者则以成功添加进购物车的商品数量,以及成功通过结算流程的人数作为某项指标。 当然,单个候选指标也应该与整个系统的指标对齐。如果后端开发者只是衡量“后端接收到的请求个数”,而不考虑成功执行的情况,也不考虑执行的响应速度,那么他们完全可以设计一个需要反复调用多次的糟糕API,这无疑对用户体验造成了伤害。 所以你需要确保心目中的候选指标,是和帮助现实用户息息相关的。对于刚刚的例子,一个更好的解决方案可以是,多少次“提交支付”的请求被成功处理了,因为这才是最终结果。(顺便说一声我不会将此作为购物网站后端的唯一可能指标——它只是一个可能性而已。) 但无论你衡量的标准是什么,重点在于即使我们衡量的部分人员的产出物是代码,你衡量的依然是产出物。 还存在最后一种情况,就是如果他们的职责是负责改善开发效率。如果你的工作内容是帮助其他开发者提升对于需求的响应速度,你要怎么量化你的工作成果? 首先,大部分负责改善开发效率的人员都有属于他们自己特别的产出物。产出物可能是一个测试框架(也就是说你可以用上面所说的衡量一个库的标准衡量它),又或者是其他某些开发者可能会使用的工具,在这种情况下你可以统计工具的使用情况或者人们对它的满意度。 举个例子,bug管理系统的开发者们想要量化的指标之一,是bug被成功和迅速解决的个数。当然,考虑到工具在公司内部是被使用的方式,指标还需要稍做修正——或许有一些系统中的bug记录压根就不需要被快速修复,甚至将会长时间存在,所以你要想办法用其他的方式衡量它们。总的来说,你应该问自己:我们使用的这件工具,带来的产出物和造成的影响究竟是什么?这才是你应该衡量的——产出物。 但如果你并不是在开发一些具体的框架或者工具怎么办?有可能你的产出物和软件工程师这个群体息息相关。此时或许你可以衡量你的工作成果给工程师带来帮助的次数。或者统计你引入的改善给研发工作节省下来的时间,当然前提是你能准确地进行统计(基本是不太可能的)。总而言之,与量化其他类型的编程工作相比,量化这些工作会更加困难。 如果某人负责改善特定团队的开发效率,那么应该衡量团队体验到的效率提升程度。又或者衡量团队指标的提升速率。 第20章 如何应对软件公司内代码的复杂性 只有依靠程序员个体才能解决代码的复杂性问题。也就是说想要解决代码的复杂性,需要每一个人都对代码保持警惕。他们当然可以借助一些工具来减轻这项任务的压力,但简化代码这份工作终究还是需要人们脑力、注意力和汗水上的投入。 解决代码的复杂性问题,离不开每一位个体贡献者的底层代码工作。 如果管理者只是在下达“简化代码!”的指令后就拍拍屁股一走了 之,通常什么都不会发生,因为: a.员工们需要更明确的指令; b.员工们对被需要改善的代码一无所知; c.对问题的理解其实是发生在解决问题的过程中的,管理者并不是解决问题的人。 如果你是一名软件工程经理,你可能会提出一类大而全的、能够一劳永逸解决所有问题的解决方案。通过这种方式解决代码复杂性的问题在于,代码问题通常在许多不同的子项目中,需要许许多多程序员个体落实到代码细节层面才能修复,这种一揽子的解决办法不切实际。 所以如果你想依靠一个大而全的解决方案来应对一切问题,你会发现它其实并不适用于所有场景。并且这么做只会适得其反,软件工程师们看上去做了很多工作,但实际上他们并没有产出一个具有可维护性且简单的代码库。 所以如果你作为一名管理者正在负责一个结构复杂的代码库,你需要做些什么来改善这些代码呢?解决问题的关键在于从每一位开发者身上获取信息,并与他们一同工作,从而帮助他们解决问题。 第一步——列出问题: 询问团队里的每一位成员,邀请他们把代码中最让他们受挫的地方写下来。代码复杂性引起的现象,会导致人们对于代码产生本能的情绪性反应,例如对代码感到疑惑,感觉到代码是极易损坏的,认为代码难以优化等。所以你可以提出类似这样的问题:“系统里有什么地方的代码是在你修改时会感到紧张的?”或者是“代码中有什么你曾经维护过的地方让你感到非常棘手?” 每一位软件工程师都应该把他们心目中关于这些问题的答案都写下来。我不推荐通过使用某个系统来收集这些信息——对于他们来说手写是最简单的方式。可以给他们几天的时间来整理答案列表,因为他们可能需要一些时间考虑。 这份列表可以不仅仅包括你负责的代码库,任何关于他们曾经维护过或者使用过的代码的吐槽都可以记录其中。现阶段你只是在收集症状,并非原因。对于这份回答来说,开发者们的表述可粗可细。 第二步——举行会议 召集你的团队举行一个会议,确保每个人都带来了他们关于那些问题的答案,以及能够访问代码库的电脑。团队会议理想的参与人员人数大致在六到七人左右,如果团队人数过多的话,你需要再将他们划分为更小的队伍来举行会议。 在会议上你应该挨个过一遍所有的回答,找到每一个症状对应的文件目录、文件、类、方法或者是代码块。 即使有人的回答是:“整个代码库都没有单元测试。” 你也应该刨根问底地问下去:“请告诉我这个问题会在什么时候对你造成影响?” 再根据他的回答找到现阶段最需要为之编写测试的文件是哪些。 你还需要确保你获得了关于问题的准确描述,类似于“重构代码非常困难,因为我不知道我的修改是否会破坏其他人的模块”。这种情况下单元测试似乎是一个解决方案,但是你首先还是需要尽可能地把问题的范围缩小。(的确所有代码都应该有对应的单元测试,但如果现在你的代码库中一个单元测试都没有,你需要从这方面的一些可行的任务开始。) 总而言之,只有代码才是能够被实实在在修复的,所以你需要知道哪一部分的代码出现了问题。当然还存在着影响面更广的问题有待我们解决,但是再大的问题也可以被拆解为更小的问题来各个击破。 第三步——bug报告 利用从会议中收集到的信息,为每一个问题(不是解决方案,只是问题!)生成一则bug报告,并且可以用这个问题关联的文件夹、文件以及类名作为bug报告的标题。例如“FrobberFactory类太难以理解了”。 如果在会议上问题的解决方案同时也有了结果,你可以在报告中进行备注,但是报告本身还是应该以问题为主。 第四步——决定优先级 现在是时候决定问题的优先级了。首先要找到哪一个问题影响到的开发者数量最多。这些都是高优先级的问题。通常这部分工作是交由团队或者公司内对开发者最了解的人来完成。一般是团队经理。 有时候需要考虑问题间的依赖关系而不仅仅是严重性。举个例子,解决问题Y的前提是解决问题X,或者是如果问题A提前得到解决的话,问题B解决起来会更容易。 这意味着问题A和问题X即使没有它们后续的问题看起来那么严重,它们也应该优先被解决。大多数时候都会存在这么一条问题链,关键在于找到链路源头的问题是什么。 没有正确评估问题的优先级,是软件设计中常犯的错误之一。虽然这个步骤看上去无关痛痒,但它对降低解决代码复杂性的成本至关重要。 无论何时何地,软件设计的精髓在于以正确的顺序做正确的事情。 强迫开发者以无序的方式解决问题(忽略问题间的依赖关系)会加剧代码的复杂性。 无论你是在开发前期还是开发过程中完成的这部分工作,非常重要的一点是确保让每一位程序员意识到,在他们开始分配正式任务之前,首先需要解决一些前置任务。他们必须取得足够的授权,能够从当前需要完成的任务,切换到优先解决那些阻碍他们的任务。 第五步——分配任务 现在你可以把每一个bug分配给不同的具体开发者。可以说这是一个相当标准化的管理层面的流程了,虽然它涉及具体的沟通和工作细节,但我相信大部分软件工程经理对此已经驾轻就熟。 有一个意外情况是,可能其中一些导致bug的代码并不是由你们团队维护的。这种情况下,你需要通过与组织层面进行沟通,找到负责解决这个问题的合适团队。如果你能从另一个与你有相同遭遇的经理那里得到支持是再好不过的了。 在一些组织内部,如果其他团队引入的问题并不复杂,也不需要了解过多的细节,那么你所属的团队就可以自行对它进行修复。这可以根据你们解决问题的效率和成本自行决定。 第六步——计划 现在你已经对所有bug进行了记录,接下来你必须要想清楚何时将它们修复。一般来说最佳的方式是确保开发者们会定期修复其中的一些问题,并且同时进行常规功能的开发。 如果你的团队通常以一个季度或者六个礼拜作为一个迭代周期,你应该在每个迭代周期内都安排一些代码清理工作。最好是让开发者们首先做一些能够让他们将来开发代码变得更轻松的代码清理工作,再开始正式的代码功能开发。 放心这通常不会拖慢开发进度(也就是说如果代码清理得当,开发者们依然能够在一个季度内把计划中的功能实现,这变相说明了实际开发时间减少了,同时开发效率得到了提升)。 不要为了代码质量而完全中止正常功能的开发。请确保提升代码质量的工作会持续进行下去,自然而然代码库总体上就会趋于变好而不是变差。 第21章 重构与业务功能有关 当你在清理代码时,你其实是在间接地为代码所属的产品提供服务。重构的本质是一类有组织的流程(这里说的“有组织”并不是指“与业务有关”,而是说“有序地将事物安排起来”)。也就是说,为了达成某个目标你在对事物进行有序的排列。 当你开始独自重构时,重构会给你带来一个坏的名声。人们会开始认为你在浪费时间,你在人们心目中的可靠程度会降低,你的经理和小伙伴们会设法阻止你接下来的重构工作。 我所说的“独自重构”意思其实是,你发现了一些与你当前工作不相关的代码,并宣布“我不喜欢它的架构设计”,然后在不影响系统功能的前提下对代码的不同部分做设计上的修改。 浇灌草坪的重点是你房屋前有一片不错的草坪。如果重构代码的部分和你当前负责的产品或者系统的实现目标没有任何关系,算下来你其实什么都没有做,只不过重构了一些没有人用或者没有人关心的代码而已。 通常来说,首先你需要挑选一个有兴趣上手的业务功能,然后找出通过重构哪一部分代码能够让你的开发工作变得更轻松。又或者找一些修改频率很高的代码,对它们进行组织优化。这会让人们对你的工作投来赞许的目光。人们赞许的背后有更深层的原因:事实上他们这么做是因为你目前的工作起到了事半功倍的效果。无论如何这至少算是一类对你工作成果友好的认可,并能够鼓励你持之以恒地坚持下去,也表示有人开始注意到你的工作,说不定还能和你一起把好的开发实践在公司里推广。 你是否可能需要重构一个与手头工作并不直接相关的项目代码?这是非常有可能的,有时候你需要重构一些与目标间接相关的代码。 清理复杂代码库的关键原则之一就是始终在特性服务中进行重构。 代码库实际处于这种状态——它变得更糟的速度比变好更快。你首要的目标,是想办法让系统变得越来越好,而不是越来越差。 你必须在达成业务目标与重构代码之间进行平衡。因为现实条件并不可能允许你一直将代码重构下去 一般来说,我会给需要修改的代码设定一个边界,例如“哪怕是为了实现业务目标,我也不会重构任何我当前项目以外的代码”或者是“我不会等到编程语言本身做出了修改之后才将这个功能发布”。 重构不是在浪费时间,而是在节省时间。总体工作时间只会更少或者持平而已。这里“总体”还包括了你花费在调试代码上的时间、回滚代码版本的时间、修复bug占用的时间、编写复杂系统运行测试的时间等。 当我在决定代码何时才算重构“完成”时,我的判断标准是当别人在阅读这段代码时,能清晰地辨别出我在代码中的设计模式,并且能够随着这个模式继续维护下去。 有时候我会编写一些文档用于描述系统的设计思路,确保人们能够按照这个方向维护下去,但我的理论是(这条真的就是个理论了——我还没有足够的证据证明它的正确性),如果我把代码设计得足够好,其实就用不着用于描述设计思路的文档。通过阅读代码,设计思路也许就能自然而然地呈现出来,当你需要添加新功能时,需要涉及的修改之处一眼就能找到,连犯错的机会都没有。但很显然,想要完美实现这个目标几乎是不可能的,但是软件设计中有一条普遍真相是:没有最好的设计,只有更好的设计。 这也是另一则用于判断你是否“本末倒置”,或者过度设计,又或者花费了太多时间设想应该如何重构这件事的标准——你是否在设法让它变得“完美”。它没有必要“完美”,因为根本就不存在“完美”。只有“出色地将它应该完成的工作完成”。在不理解代码开发目的的情况下,你无法准确判断代码设计的好坏。一种设计对一种目的奏效,另一种设计可能又对另一种目的奏效。 当你在重构代码时,你的出发点应该是将代码的设计修正为更符合它的当前用途。 第22章 善意和代码 软件工程根本就是一门人类学学科。 在多年对软件开发流程进行持续改善的过程中我犯下过许多错误,这些错误都有一个共同的特征,就是只把目光聚焦于系统的技术层面,而不考虑写代码的人类的因素。你会发现有人更关注性能优化而不是代码可读性;你也会发现某人从不写注释,却乐意把时间都花费在如何让脚本代码行数变得少上面;你还会发现有人不善于沟通,却对小型二进制类库崇拜得不行:这些都是人类因素引起各种问题症状。 软件与人相关,软件系统代码是由人编写的。同时也是供人阅读,由人修改的,无论理解与否也都与人有关。它们代表的是编写它们的开发者思想。代码是地球上最接近人类思想的一种产物。 在与一群软件工程师协同工作时,有一条非常重要的原则:用粗鲁的态度对待开发团队里的成员不会带来任何价值。 粗鲁地对待与你一同工作的同事不会带来任何的帮助。气愤地告诉他们某个地方做错了,或者做了不该做的事也无济于事。唯一行之有效的是,确保软件设计的各项准则被正确应用到了开发中,以及人们在遵循正确的方向让系统变得更容易阅读、理解和维护。但这一切都没有必要用一种粗鲁的方式来实现。有时你需要做的仅仅是告诉人们他们某个地方做错了就好了。你只需要实事求是地说出来——大可不必为了这件事蹬鼻子上脸地对他人进行人身攻击。 这不仅限于代码评审,每一位工程师都有他们想要表达的观点。无论你同意与否,你都应该倾听他们的想法。礼貌地接纳他们的表述。用建设性的方式与他们交流你的想法。 值得一提的是,有时候人们确实难免会生气。但是请相互理解。有时候你也会生气,当这种情况发生时你也希望你的同事能理解你,不是吗? 请给予他们犯错的空间。用友善的态度和他们一起工作,齐心把软件做得更好。 第23章 运营开源项目社区其实非常简单 想要维护好开源项目社区,以及让社区稳步地壮大,本质上来说取决于三件事: 1.让人们变得乐于贡献代码。 2.移除有碍于参与项目,以及贡献代码的种种障碍。 3.把贡献者留住,才能让他们持续贡献代码。 如果你首先能让人们对你的项目提起兴趣,然后让他们开始正式贡献代码,并且保证他们始终对项目不离不弃,那么你才算是成功组织起了一个开源社区。否则你并没有。 一旦某人开始参与项目贡献,有什么办法能让他一直贡献下去呢?我们如何留住贡献者?首先我们对所有过去离开了这个项目的人做了一个调查,询问他们为什么离开。这个调查允许他们自由发挥,允许人们填写他们想要回答的任何答案,然后我们制作了一份图表,用于展示整个项目十年来贡献者数量的变化,然后将图表的起伏与这么多年来我们采取的或者是没有采取的各种行动关联起来。 当一切完成之后,我给Bugzilla项目的全体开发者发送了一封邮件,邮件中详细描述这项研究的成果。如果你有兴趣的话你可以阅读整封邮件内容,但是我会在这里总结一些其中的发现。 1.不要让主干太长时间止步不前 传统的开源社区智慧认为,人们喜欢在添加新特性上,而不是在修复软件错误上工作。我不敢说它是绝对正确的,但是我想说,如果你只允许人们修复错误,那么他们中的大多数都不会耐着性子留下来。 我们解决这个问题的方式是不再冻结主干代码。取而代之我们会在之前“冻结”主干代码的时间点立即创建一个分支。并且主干也始终保持着开放的状态,用于接纳新功能的开发。 是的,正如你预料的那样,我们的注意力会被分散在主干和最新的分支上。当我们在提交修复代码时,需要同时提交到分支和主干上。在开发新功能的同时我们也要兼顾解决bug修复问题。但我们发现这么做不仅让我们的社区迅速壮大,也让我们发布新版本的速度变得更快了。最终带来了一个双赢的局面。 2.离开是不可避免的 调查发现贡献者离开的首要原因是他们没法挤出时间来参与贡献了,又或者他们当初贡献代码是因为工作上的需要,现在他们换了一份工作。总的来说贡献者的离去是在所难免的。 所以如果社区成员注定有一天要离开的话,拓展社区的唯一方式就是想办法留住新的贡献者。如果你不这么做,社区会随着旧成员的离去而逐渐地萎缩,无论你做什么都于事无补。 3.及时响应贡献者的反馈 人们(通常)不会介意对他们提交的代码进行再次的修改。甚至不介意修改多次。他们实际上介意的是当他们将代码提交上传三个月之后才得到评审的答复,告知他们需要对代码进行修改,然后还需要再等上三个月才被告知又要进行一次修改。延迟才是他们离开的最重要的原因,并非因为对于质量的苛求。 也有一些其他快速响应贡献者提交的代码的方式。举个例子,立即对提交代码的人表示感谢就是一个屡试不爽的办法,能大概率把新的贡献者“转化为”长期的开发者。 4.表现出极度的友善和不加掩饰的感激之情 对于每一个回复了我们调查的人,除去“我换工作了”和“我没有时间”外,其余离开的原因都是出乎意料的个人原因。 当人们在以志愿者的身份做出贡献时,他们并不奢求任何金钱上的回报,他们获得的是尊敬、赞许,以及将工作圆满完成的满足感,还有参与一个能够影响数百万人的产品所带来的成就感。所以只要有人贡献了一份自己的代码,你就应该对他们表示感谢。即使这份代码是完完全全需要被重写的垃圾,你依然要对他们表示感谢。因为他们对此已经投入了不少的汗水,如果你不这么做,在正式加入之前他们就已经想要离开了。 这里想表达的是,与指出人们错误相比,更重要的是对他们的贡献中积极的一面表达感谢和肯定。你必须真真切切地告诉贡献者你对他们的贡献表示感谢。你越是频繁和慷慨地做这件事,你留住贡献者的概率就越大。 5.避免对个体进行否定 要真心实意地,甚至近乎变态地和善,并且在这一点上千万不要吝惜。 移除障碍 下一个步骤就是要移除准入的门槛。究竟是什么阻碍着人们在贡献代码上迈出第一步呢?通常来说,最大的阻碍是缺乏文档和方向。当人们想要开始贡献代码时,他们想当然地会去思考应该如何贡献代码。 通过好几种方式来解决这个问题: 1.列出容易上手的项目 2.创建文档沟通的渠道 3.用优秀的、完整的以及简单的文档,描述一次代码提交应该是什么样的 4.让所有的文档更容易地被找到 让人们对项目感兴趣、用热门的编程语言编写项目、成为一个超级受欢迎的项目。 理解软件 第24章 什么是计算机 计算机是能够执行一系列符号指令,并且通过对数据进行比较以帮助人们达成目标的机器。 计算机能够对比数据。这有别于其他能够接受人类输入的机器。 计算机不仅能接受单条指令,还能接受一系列指令。比如一台简单的计算器只能处理一条指令,而计算机则强大得多,使它们区分开来。 和键盘上的一次按键一样,一次鼠标点击也可以算作“符号指令”。但是作为程序员,我们主要使用的符号指令是编程语言。所以作为程序员的我们在讨论该如何提升我们工作产出的质量时,更多的是在关心我们的程序的结构设计。 第25章 软件组件:结构、操作和结果 模型-视图-控制器(Model-View-Controller,MVC)模式之所以如此成功,是因为它反映了一个计算机程序最基础的本质:当一系列操作(action)施加于具有特定结构(structure)的数据之后,就会产生某种结果(results)。当然你的程序也需要接受各种输入,你可能会争辩需要把输入作为程序的第四个组成部分,但是我通常还是认为计算机由前三个部分组成:结构、操作和结果。 在MVC的语境里,模型就是结构,控制器替代了操作,视图则是结果。结构、操作和结果或许能够用于描述现存的所有机器。 一台机器可以拥有一些无法活动的部件,比如一个大型框架——这就是结构。一些可以被灵活控制并且参与实际工作的组成部分——这种动态的部分就是操作。最后机器会产出实体物品(否则它对我们就没有意义了)——这就是结果。 当我在编写软件时,我通常首先把结构搭建起来,然后编写操作部分,最后处理展示结果。有的人会从结果出发反向开始工作,这样也没有问题。但最不明智的选择或许是从操作部分开始入手,因为在既没有结构也没有结果的前提下执行的操作实在令人困惑。 第26章 重新审视软件:SAR/ISAR概念详解 任何计算机软件都由三个主要部分组成:结构、操作和结果。 一个程序或许还可以存在输入这类元素,它可以被认为是软件的第四个组成部分,尽管通常是用户而不是程序员创造了这一部分。所以我们既可以把这组概念缩写为SAR,也可以缩写为ISAR,这取决于我们是否想把“输入”这个概念也归纳进去。 SAR的应用场景比MVC要宽广得多,MVC是一种用于软件设计的模式,而SAR(或者ISAR)则是对于所有软件中三类(又或者四类)组成元素的描述。 SAR的迷人之处不仅在于它对整个程序适用,对程序的任意组成部分也同样适用。一个完整的程序拥有结构,但是单个函数或者是单行代码也同样拥有结构。对于操作和结果这两个概念也是如此。 在完整程序中能够被当作“结构”的一些例子: 代码的文件夹分布。 所有的类以及它们之间的关联方式。 如果你的程序需要用到数据库的话,数据库的结构(模式)也算是一种结构。(注意数据库中存储的数据并不能算作结构。如果你程序会生成数据并且将数据存储在数据库中,那么它们应该算作结果的一部分。如果数据已经存在而你的程序负责对它进行处理,数据则算作输入。) 一个独立的类(站在面向对象的角度上说)也拥有结构: 类里各种方法的名称,以及它们需要处理的参数的类型/名称。 类里变量的名称和类型(成员变量)。 无论一个函数(或者变量)是私有还是公有,它都算是结构的一部分,因为结构就是用于描述的这个东西是什么的(与之相反的是这个东西能做什么或者是能产出什么),而“私有”或者“公有”恰恰是用来描述这个东西是什么的词组。 结构是“程序的组件”或者是“程序的组成部分”。所以函数的名称和类型、变量的名称和类型,以及类——这些都是结构。 结构只是“摆在那里”。除非程序中的其他部分用到它,否则他不会给自己找事做。 操作,与一个完整程序有关的操作非常好理解。一个税务软件就是用来“处理税务”的,一个计算器程序就是“用来计算”的。操作一定是动词。“计算”“修复”“添加”“移除”,这些都属于操作。 在一个类里面,操作就是方法内的代码。你可以把它们当作各种各样不同类型的操作——有些事已经发生,有些事将要发生。在许多的编程语言中,你还可以在任意的类或者函数之外编写代码——那种启动程序时才运行的代码。它们也属于操作。 结果,每个程序,每个函数,每一行代码都会产生一些影响。它们会产生某种结果。 任何一种结果总是能用过去式来描述——它是某种已经被完成或者创建过的事物。 你程序中的代码片段也产出结果。当你调用一个方法或者函数时,会得到一个非常具体的结果。它会返回给你一些数据,或者它会造成一些数据的改变。无论程序(或者程序的某一部分)最终会产出什么,它们都算是结果。 第27章 软件即知识 软件从根本上来说是由知识组成的物体。它遵从所有与知识相关的规则和定律。它展现出的行为也和在任意场景下知识展现出的行为一模一样,除了不同软件体现的具体形式会有不同。 举个例子,当软件过于复杂时它很容易被误用。而当软件出错时(比如有了一个bug)它还有可能会给他人造成伤害以及引发问题。同样当人们对代码一知半解时,人们可能会无法对它们做出正确的修改。所有与知识有关的方方面面对软件也同样适用。错误的数据可能会导致人们犯错,错误的代码也会导致计算机犯错。 有人也希望软件,特别是代码,也表现得更有意义和更富有逻辑。因为代码就是知识,在人们阅读代码时,在脑海里它们应该能够立即被翻译成知识。如果代码做不到这一点,那就意味着代码其中的某部分过于复杂——或许是底层的编程语言或者是系统,但更有可能因为软件设计者创建的代码结构不够简约。 当我们在渴望知识时,可以通过不同的方式获取它。有人通过阅读获取知识,有人通过思考获取知识,有的人通过观察,有人通过实验,还有人通过交谈等。总的来说我们可以将这些方式划分为两类:是在自力更生获取信息(观察、实验、思考等);还是在借助他人获取知识(阅读、聊天等)。 当在判断某人解决问题时是需要编写新代码还是使用已有代码的时,这些原则也同样适用。你基本上不太可能包办从软件到硬件层面的所有代码,或者独立开发出当下十分受欢迎的软件。 当然有一些代码没有地方可复用,只有熟悉业务的我们才有资格编写——这部分代码通常是正在开发的产品的特殊业务逻辑部分。但是更多的时候我们还是要依赖现有的代码,就像作为人类个体我们必须依赖二手知识生存一样。 这些原则也可以用于在不同的开发者之中分配工作,是让人用第一手信息提前编写一部分代码会更快,还是让一群人同时对一个现有系统(二手知识)进行代码修改(对他们来说也算是第一手信息)会更快?答案很明显是依情况而定,尽管这里提出的观点并没有多新奇(有些程序员比其他人更了解系统,所以他们可以更快地完成),但是我们将结论推导出来的方法很重要。首先我们从理论上说明软件就是知识,然后我们发现了一条逻辑清晰的思路,它指向现存的一些普遍成立的原则。这意味着我们可以从这些已知的原则中找到其他更有用的信息。 第28章 技术的使命 时,最终的结果通常是成功的。而当尝试用它解决与人相关的比如思维、沟通、个人能力等问题的时候,它通常是失败的,甚至会事与愿违。 们可以和世界上任意一个人进行实时的交流。但是它不会让我们成为更好的沟通者。事实上它反而给许多非常差劲的沟通者提供了一个广阔的平台,让他们能够在上面传播仇恨和恐惧。 技术善于解决什么样的问题以及不善于解决什么样的问题:专注于用技术解决人类相关问题的公司更有可能失败。使用技术解决与实体物质相关问题的公司至少还有成功的概率。 看上去似乎存在一些关于这条规则的反例。举个例子,Facebook存在的意义不就是将人们连接在一起吗?这听上去是一个与人有关的问题,并且Facebook也做得非常成功啊。但是将人们连接起来并不是Facebook实际上在做的事情。它提供的只是一个供人们沟通的媒介而已,它并没有主动将人们联系起来。事实上,我认识的大多数人都对沉迷于Facebook感到反感——人们把时间都花费在了网络上,而不是对人类而言更为珍贵的线下生活中。 技术本身并没有好坏之分,但是当它在被尝试用于解决与人相关的问题时趋于变坏,而当它聚焦于解释现实世界物质有关的问题时趋于向好。 第29章 简单地聊聊互联网隐私 第一种类型的隐私是“空间隐私”。这类隐私权能够决定谁能或者不能进入一个特定物理空间,或许是因为你正处于那个空间,所以你并不希望某些特定的人进入这个空间。“进入空间”从定义上说也包括采取任何的方式方法来感知空间内发生的一切。这种形式的隐私是实实在在的。它的适用范围仅限于物理空间,从字面上理解就是说“我可以允许,也可以禁止你感知这个物理空间里发生的一切,我拥有掌控这件事的权力”。 我们之所以想要这种形式的隐私,最主要的原因是我们想要保护某人或者某物避免受到伤害,这里保护的对象通常是我们自己。这种形式的隐私和计算机程序无关,因为我们不认为与我们共处一室的计算机程序侵犯到了我们的隐私空间。我的文字处理软件不会侵犯我物理空间里的隐私,即使它与我“在同一个房间里”,因为它没有任何感知能力。唯一的例外是如果某个计算机程序将它接收到的一切(图像或者声音)传送到某个我们并不希望传送到的地方——这就算是侵犯隐私了,因为当我们不希望这一切有人知晓的时候,某人还是能够通过这个软件感知到空间里发生的一切。 第二种类型的隐私就是“信息隐私”。这种类型的隐私决定了某些人是否应该知晓某些事情。在计算机程序和互联网语境下,这才是我们通常讨论的隐私类型。独立的个体或者团体之所以希望信息隐私不受到侵犯,是因为他们相信隐私信息在落入他人之手之后,会增加给他们带来伤害的可能性。 无论你从事什么样的职业,为了生存,你必须和他人交换信息。你要做的事情越多,你需要交换的信息也就越多。 “每一条隐私信息在使用前都应该征求用户同意”的想法也是荒谬的。你希望你的浏览器在每一次你加载页面时,都询问你:“我可以向这个提供这个网页的网站发送你的IP地址吗?”如果你是一名驻扎在敌对国家的间谍,或许你希望这么做。但如果你是普通人,那可能只会给你带来烦躁——你不再会使用这款浏览器,并且转而搜寻其他可以替代的软件。但如果你真是一名间谍或者是反抗组织战士,你可能会使用洋葱路由器(Tor)来避免被追踪。 第30章 简约和安全 提升软件安全性的秘诀之一(也可能是最主要的因素)是保证软件足够简约。 当我们在考虑软件的安全性时,首先要问的问题是:“这个软件可能会遭受多少类攻击?”这等同于在问存在多少种“进入”软件的方式。更像是询问:“这幢建筑有多少扇门和窗?”如果这幢建筑只有一扇门与外界连通,看守它非常容易。但如果它有1000扇门,那么确保这幢建筑的安全似乎就不太现实了,无论每一扇门的安全性如何或者你雇佣了多少负责安全的警卫,都无法做到万无一失。 所以我们需要将“进入”软件的方式限制在指定的数量之下,否则安全无从谈起。通过让整套系统变得相对简约,又或者将它拆解为简约并且完全独立的组件,能够帮助我们达到提升安全性的目的。 一旦我们成功限制了进入软件的方式数量,接下来就要开始思考: 每一种进入软件的方式可能会被多少种攻击所利用? 我们可以通过尽可能简化“进入”方式本身来降低这些攻击的可能性。 如果这部分工作也完成了,然后我们就需要尽可能降低将攻击带来的最大损失。好比在一幢建筑中,我们要确保一扇门只会通往一个房间。 标准的UNIX系统只提供数量非常少的系统调用供绝大部分UNIX程序的实现使用。(即使扩展后系统调用总数也只有大概只有140种左右,而且其中的绝大部分在常见的程序中根本没有被使用过。)每一个系统调用所做的工作都极其具体,并且能力十分有限。 而Windows操作系统则有一堆十分荒唐并且让人疑惑的系统调用,每一个调用都需要传递太多的参数,所干的事情也过于繁杂。 如果你对系统提供的高级功能稍做了解的话,你会发现Windows提供的API算是庞大而复杂的。它们像是能够同时控制系统和界面的奇异野兽。而你在UNIX中找不到任何与之完全等价对应东西(因为在UNIX中系统和界面是完全分离的),但是我们还是可以将它们的部分组件进行比较。例如我们可以比较Windows提供的日志API和Linux下的日志API,但它们完全没有可比性,因为Windows下的日志API简直就是个笑话。对于Windows操作系统来说,任意一个组成部分都存在太多种类的“进入”方式,导致它从来没有安全可言。 获得安全保障的最佳方式是简单明了。 我们不应该在软件前布置千军万马来保障它的安全。而是应该借助限制软件只提供一些最基础的“入口”,来减少保护的需求,这些“入口”应该是直截了当和简单易懂的,并且还能免受被入侵的危害。 第31章 测试驱动开发和观察循环 每一个人都有关于如何编写代码各不相同的偏好,都有各自的道理。但是通过观察每一个人的偏好,你能够总结出一条通用的原则:“我需要对某件事物进行观察之后才能做出决定。”有些人在他们编写代码时需要观察相关测试的运行结果,有些人则需要通过观察他们正在编写的代码,才能决定接下来的代码要怎么写。甚至当他们在谈论到个人开发规则中的一些例外情况时,也总是会提到把留意到某件事作为他们开发过程的一部分。 这是可以一条可以应用在所有软件开发循环周期中的原则: 1 观察(Observation)→决策(Decision)→行动(Action)→观察 →决策→行动→…… 如果你想给整个流程起一个名字,你可以称之为“观察循环”(Cycle of Observation)或者“ODA 每一个有效的开发流程都会将流程中的这类循环模式作为它主要的指导思想。甚至像敏捷开发这种大规模的涉及全团队的开发流程也是如此。事实上,敏捷开发只不过是那种已经被抛弃的,需要花费数月或者数年才能完成一个循环迭代开发模式(瀑布模型,也被称为“预先做大量设计(Big Design UpFront)”)的短周期版本(每几周)而已。 所以这么看来短周期似乎比长周期更好。大部分开发者的效率提升,都可以通过将ODA循环周期缩短为对开发者、团队或者是组织而言最小的合理时间来达成。 通常来说你可以通过将精力放在缩短观察时间上,来将整个循环周期时间缩短。一旦成功之后,周期的其他两部分就会自行加速(如果它们没有加速, 有三个主要因素会对观察阶段带来影响: 信息呈现给开发者的速度(例如能够快速给出反馈结果的测试)。 信息呈现给开发者的完整性(例如拥有完整的测试覆盖率)。 信息呈现给开发者的准确性(例如测试值得信赖)。 这能帮助我们理解近几十年来某些特定开发工具背后成功的成因。比如持续集成、线上环境监测系统、性能调试工具、代码调试工具、编译器中更明确的错误消息、能够突出显示错误代码的IDE——之所以所有这些工具能如此“成功”,是因为它们让观察这件事变得更快、更准确或者更完整了。 有一个问题需要注意——你必须确保你呈现信息的渠道,也是人们能从中获取到他们想要信息的渠道。如果你只是无脑地把大批量的信息倾倒给人们,而他们又无法轻易地从中找到他们关心的具体数据,那么这种数据可以说是无用的。好比如果没有人收到过一次线上环境的报警,那么这个报警是否存在也就不重要了。 如果一名开发者一直无法确认他接收到的信息的准确性,那么他很可能就会开始忽略这类信息。你必须确保成功地传递了信息,而不只是将它生产出来而已。 事实上还存在一类能够代表整个软件开发流程的“大ODA循环”——发现一个问题,确定解决方案,将它在软件中实现。在这个大循环中,还有许多小的循环(比如被分配到了一个功能需求,确定功能应该是如何工作的,然后将这个功能完成)。甚至在小循环中还存在更小的循环(观察到需求变更,确定如何实现,然后用代码编写),如此往复。 在所有这些可能的循环过程中,最棘手的往往是第一轮ODA循环,因为你需要在缺少前一轮决策或者行动的前提下做出观察。 第32章 测试的哲学 我们通过一种包含断言、观察和实验,并称之为“测试”的系统工具来获取与软件行为相关的知识。 从某种意义上说,软件测试是传统科学方法论的反向过程,传统的科学方法论是,你首先需要对宇宙进行验证,然后把实验得到的结果用于完善你的假设。 与之相反的在软件领域中,如果我们的“实验”(测试用例)不能证明我们的假设(测试做出的断言),那么则需要对正在测试的系统做出修改。 也就是说一旦某个测试失败了,很有可能是我们的软件需要修改,而不是我们的测试。当然有时候我们也需要对测试进行修改来确保它能够恰当地反映我们软件当前的状态。 通过对测试的价值、断言、边界、假设和设计进行检视,有助于对我们编写的测试进行重新思考。 测试的目的在于向我们传递系统的有关知识,这些知识其实存在不同层次的价值。 举个例子,不分场合地测试1+1是否依然等于2不会给我们带来任何有价值的知识。但是如果能让我意识到,即使我依赖调用的API做出了破坏性的修改,但我的代码依然能够正常工作,在这种情形下这部分信息还是能给我带来非常大的帮助的。 总的来说: 在创建一个有效和有用的测试之前,人们必须要清楚地知道自己想要获得什么样的信息。 只有恰当地对信息的价值作出判断,才能正确领悟应该把时间和精力投入哪些测试中。 如果说我们想要知道是什么让一个测试之所以能被称为测试,那么一定是因为它对某件事做出了断言,并且告知了我们断言的结果。人工测试人员可以对事物作出性质上的断言,比如某个颜色是否足够吸引人。但是自动化测试作出的断言必须是计算机有能力给出的,通常是判断一些可量化的具体陈述正确与否。 没有断言的测试不是一个测试。 我们会通过运行测试来熟悉我们的系统:断言结果的正确与否都能让我们学习到有关知识。 每一个测试都存在一定的边界,这是作为测试定义与生俱来的一部分存在的。 所以当在设计测试时,你应该知道什么需要被测试,什么不需要。 如果你编写了这样一个测试,很有可能你把多个测试合并成了一个,这些测试应该被分开。 每一个测试内都存在一组假设,这是测试在它的边界内能够高效执行的前提。 所有测试至少存在三种结果——通过、失败和未知。 结果为“未知”的测试不能说它们是失败的——否则就意味着他们向我们提供了某些关于系统的信息,但事实上它们没有。 所以我们需要对全套的测试进行设计,以便: 当我们将所有的测试组合在一起后,它们能够切实给予我们想要获取的所有知识。 “端到端”测试的意思是对一条完整的系统逻辑“路径”进行断言。也就是说你需要把整个系统搭建起来,在用户端执行一些操作,然后验证系统产出的结果。你并不关心系统内部为了达到这个目的是如何工作的,你只需要关心输入和结果。这基本上对所有测试都是成立的,但是在这里我们只在系统的最外层执行测试,也只检查最外层返回的结果。 端到端测试背后的主要思想是,通过我们尽可能以“真实”和“全面”的方式对系统进行测试,可以从断言中获取到极为精准的知识。路径上所有的交互和涉及的复杂逻辑都用测试进行覆盖。 只做端到端测试带来的问题是难以获取到关于系统的所有知识。在任何一个复杂的软件系统中,需要进行依赖和交互的组件数量和代码路径条数成爆炸级的增长,让测试很难或者不可能准确覆盖到所有的路径,并做出所有我们想实现的断言。 端到端测试还是有它的价值的,特别是对于完全缺少测试的系统来说是一个引入测试的很好切入点。它们也是当你想要检测整个系统组合起来是否能正常工作的有力工具。它们在测试套件中拥有重要的地位,但是就本身来说,它们并不是用于获取一个复杂系统全部知识的好的、长期的解决方案。 如果某个系统在经过设计之后只能以端到端的方式对其进行测试,那么这就是一个代码中存在架构问题的征兆。 这些问题应该通过重构来解决,目标是让系统也能够用上其他的测试方法。 集成测试下,你会取系统中的一个或者多个完整“组件”,用于专门测试将它们“组合在一起”后的表现行为如何。这里说的一个组件可以是一个代码模块、一个你系统依赖的库、一个提供数据的远程服务——本质上来说系统内任何一个从概念上可以和系统其他部分分离的内容都可以算作是一个组件。 与端到端测试相比,集成测试会将有待测试的组件独立出来,而不是把整个系统想象成一个“黑盒”对其进行测试。 集成测试不会遇到像端到端测试面临的那种糟糕的测试路径数量爆炸的问题,特别是当有待测试的组件本身和交互组件都非常简单的情况下。如果两个组件因为他们的交互极为复杂而导致难以进行集成测试,这或许在暗示我们其中的一个或者多个组件都需要被重构以便让组件变得更加简约。 就集成测试方法论本身来说它依然存在缺陷,如果想单纯地从组件间的交互来对整个系统进行分析的话,这意味着用于交互测试的组合数量必须要非常多,才能勾勒出整个系统行为的全景图。 与端到端测试相似,集成测试也存在可维护性方面的问题,尽管没有那么严重——当某人对其中一个组件的行为进行了更改,他可能需要更新所有与这个组件交互相关的测试。 单元测试,你需要单独选取一个组件,然后独立地对它的行为进行测试。 当你拥有一个组件,且这个组对于外部世界来说给出了极其慎重的承诺,那么单元测试则是用于验证这些承诺的最佳方式。 通常来说一个单元测试只会对一个类/模块中一个函数的单个行为进行验证。人们通常会为一个类/模块创建一组单元测试,当运行所有这些单元测试时,它们会覆盖你想验证的有关这个模块的所有行为。但这几乎总是意味着只测试系统的公共API,单元测试应该验证组件的行为,而不是实现。 理论上来说,如果系统中所有组件的行为在文档中都有完整的定义,且能按照文档里的行为挨个对每个组件进行测试的话,其实也是在对系统的所有可能行为进行测试。倘若你对其中一个组件的行为进行了更改,你只需更新围绕这个组件的最小测试集合即可。 很明显,只有当系统的组件在划分合理,以及简单到能够对行为做出完整定义的情况下,单元测试才能发挥出最大的功效。 现实世界里,在端到端测试和单元测试之间还存在不计其数的中间态测试类型。有时候你的测试方案介于单元测试和端到端测试之间。有时候你的测试又介于集成测试和端到端测试的交集当中。实际的系统会依赖所有形式的测试类型,用于帮助人们正确地理解系统行为。 举个例子,有时候你只需要对系统的其中一个部分进行测试,但是在内部实现上它依赖于系统的另一个部分,所以其实你也算是隐式地对那个系统进行了测试。但这并不意味着你当前的测试就是集成测试,它充其量只能算是间接对其他内部组件进行测试的单元测试而已——比一个普通的单元测试涉及面稍广一些,又比一个集成测试范围小一些。事实上这种类型的测试带来的效果通常是最好的。 通过“伪造数据”来对代码进行隔离在某些时候还是有用的。但人们必须要谨慎地作出决策,以及小心背后产生的成本,同时还需要通过对“伪”实例进行有意识的设计来缓解它们带来的副作用。值得注意的一点是,伪造数据还是能给我们的测试带来两方面的提升——确定性和速度。 如果系统或者它所处的环境中不存在任何变数,那么测试的结果也应该不会发生任何变化。 测试最有用的地方在于开发者们可以边编辑代码边运行它们,来检查他们正在编写的新代码是否能正常工作。如果测试运行变慢,那么这个功能也就逐渐变得没有意义。或者开发者们可以继续使用这些测试,但是编码的速度会被拖得越来越慢,因为他们不得不一直等待测试运行完毕。 一般来说,一个测试套件不应该花如此长的时间来运行,这会导致开发者在等待测试运行完毕的过程中,从工作上分心,以及无法集中注意力。现有研究表明对大部分开发者来说测试的理想运行时间应该在2到30秒之间。所以一个开发者在编辑代码阶段运行的测试套件应该尽量在这个时间区间内运行完毕。花上几分钟时间来运行测试没有问题,但是这不算是一个理想状态。更甚者如果要花上10分钟才能运行完毕,一般来说这是完全不可接受的。 缓慢的测试会对很多软件工程组织上的流程产生影响——降低这些影响最简单的办法就是让它们运行得足够快。 有一些工具能够在运行测试的情况下告诉你系统的哪些行代码被测试运行过了。它们将这个称为系统的“测试覆盖率”。这些工具有时候确实很有用,但是需要特别记住的是,它们其实并不会告诉你那些代码是否真的被测试过了,只是运行过而已。如果对代码行为没有执行过断言,那么它就算不上被测试过。 测试的总体目标是获取关于系统的有效知识。 这个目标凌驾于测试的其他一切原则之上——只要能带来这种效果,它就算是一个有效测试。 第七部分 持续改善 第33章 成功的秘密:持续改善 如果你想在软件方面获得成功,你所要做的仅仅是保证产品在每个版本中都能持续改善。 当一开始在决定选择使用什么软件的时候,人们的判断标准都各不相同。但是一旦人们做出了选择,他们就会一直使用下去直到一些原因迫使他们离开。只要在每一次发布中软件都得到持续改善,你就能挽留住你的绝大部分用户。 当然你发布新版本的频率必须足够频繁,才能让人们相信软件有希望持续好转。如果新版本总是难产,那么当前版本给用户带来的困扰只会止步不前。 如果你的软件项目想要获得成功,你所要做的仅仅是让它在每个版本中都到持续改善。 第34章 如何找到持续改善的空间 有时候软件项目中的重大问题难以得到解决,是因为它们需要投入大量的精力才能得以修复。但这并不意味着你可以忽略它们,而是要对项目做一个长远的修复规划,同时还要想办法如何保证版本迭代的稳定。 决定修复问题的优先级:对于Bugzilla项目来说,我们做了两件实实在在有助于我们决定优 先级的事情: 1.Bugzilla调查:https://wiki.mozilla.org/Bugzilla:Survey 2.Bugzilla可用性研究:https://wiki.mozilla.org/Bugzilla:CMU_HCI_Research_2008 这项调查中最重要的部分就是允许人们能以各种各样的文字形式,回答针对他们个人提出的问题。也就是说我个人会向Bugzilla的个体管理员发送问题,通常问题会针对他们的工作职责做一些定制化。这些问题中并不存在多选题,只会让他们告诉我什么正在困扰着他们以及他们想要看到什么功能。事实上他们非常乐意收到我的邮件——其中许多人对我做出的这次调查表达感谢。 一旦他们都回答完毕,我就会对所有回复一一过目,然后把提及的主要问题制作成一份列表——这简直是一份小小的惊喜!那么当下我们就把精力放在解决这些问题上,如果这些问题能得到改善,相信它们会让人们整体上对Bugzilla感到更满意。 而在可用性研究中,最能给我们带来帮助的环节,出乎意料的竟然是研究人员直接(他们通常是可用性的专家)坐在Bugzilla产品前,指出哪些功能违背了可用性的原则。也就是说,比他们做实际研究更有价值的是作为专家使用可用性工程的标准原则对产品的审视。他们作为从来没有使用过Bugzilla,也不会妥协说“好吧只能这么办”的小白用户,看待这个产品的新鲜视角很重要(至少我是这么想的)。 当你试图对事物进行改善时,首先需要解决的是当前已知的那些头部问题,无论它们的代价如何。然后情况会稍微缓和一些,可你依然会发现有一大堆问题需要解决。这时候你才需要从用户身上收集数据,修复他们认为的糟糕之处。 第35章 拒绝的力量 谁是这款软件的设计师,谁授权开发了这个功能?谁有权力阻止这个功能的上线,但是却袖手旁观任由灾难发生? 问题其实在于,如果你给了一群人允许他们把脑袋里的想法通通实现的自由,那么可以肯定他们每次实现的想法都是糟糕的。这不是对开发者的批评,而在真实生活中就是这样。我对开发者们的智力和能力有绝对的信心。我欣赏他们在软件开发过程中付出的努力和获得的成就。可不幸的事实是,在缺乏一些中心原则指导的情况下,人们会不自觉地让系统变得复杂起来,同时这也并不会给他们的用户带来任何帮助。 通常一名独立的软件设计师,还是有能力创建一款同时为用户和开发者带来一致愉悦体验的软件的。但如果独立设计师在其他开发者偏离产品目标的时候不及时站出来说“不”,那么系统很快就会崩塌,变成充斥着糟糕想法的大泥团。所以拥有一名有权力说“不”的软件设计师非常重要,在恰当的时候设计师能够准确地行使这份权力也很重要。 有非常多的软件设计原则能够告诉你糟糕的想法长什么样,同时它们还能引导你在十分必要的情况下对糟糕的想法说“不”。 如果功能的实现违反了软件设计中的某些原则(如过于复杂、难以维护、不易更改等),那么这类实现就是一个糟糕的想法。 如果功能不会给用户带来任何帮助,那么它就是一个糟糕的想法。 如果提议明显是愚蠢的,那么它就是一个糟糕的想法。 如果某些更改修复不了一个已知的问题,那么它就是一个糟糕的想法。 如果你不确定它是不是一个好的想法,那么它就是一个糟糕的想法。 有时设计师们会识别出一个糟糕的想法,但是因为他们当下想不到任何一个更好的解决方案,所以他们依然允许将它实现。这样的做法是错误的。如果对于某个问题你只能想到一个明显愚蠢的解决方案,那么你依然应该拒绝它。 问题在于:如果你真的将“糟糕的想法”实现了,那么你的“解决方案”会迅速变成比原问题带来更坏影响的灾难,它“能起作用”没错,但是接下来用户会开始抱怨,其他程序员会发出沮丧的感叹,系统也会崩溃,软件就变得不再那么受欢迎了。最终,“解决方案”变成了一个需要使用其他糟糕的“解决方案”来“修复”的问题。而这些“修复”本身也注定会演化为其他让人头疼的大问题。持续这样下去,终有一天你的系统会变得像当下许多现存软件系统一样臃肿、不易理解且难以维护。 理想情况下,当你拒绝了一个糟糕的想法,你应该提供一个额外的更好想法来替代它——这样才能使项目有建设性地向前推进,而不是让这个有待解决的问题成为开发过程中的一道障碍。但即使你当下想不到一个更好的想法,坚持拒绝糟糕的想法也很重要。好的想法总会出现。或许需要通过一些研究来发掘,或许某天你正在淋浴时忽然灵光一现,它就自然而然地出现了。我不知道想法会从哪里来以及它长什么样。但是不用担心。你要相信对于每一个问题总是存在解决它们的恰当方式。持续地寻找它们,不要放弃,不要向糟糕的想法妥协。 大部分时候,与其直接说“不”,不如说“哇,这个想法的这个部分听起来非常棒,但是其他部分有待商榷”。 我们应该把这个想法中闪光的那一部分提取出来,在经过加工打磨之后将它们利用起来。你必须对想法里糟糕的部分说不。想法中存在优秀的部分并不意味着整个想法都是优秀的。汲取想法里的精华,提炼它,围绕它拓展出一些更好的想法,直到你最终设计的解决方案无懈可击。 第36章 为什么说程序员糟糕透了 之所以计算机的使用体验异常糟糕,是因为程序员编写了一大堆疯狂、复杂、没有人能理解的玩意,并且复杂性还在不断往上叠加,直到程序的方方面面都陷入难以维系的地步。 绝大部分(90%或者更多)的程序员对于他们正在做的事情完全没有概念。 相当数量的程序员从一开始就不知道他们自己究竟在做什么。他们只是在模仿其他程序员犯下的错误——复制代码,然后往机器里输入一些指令,期待着它能如我们期望的那样工作。所有这些操作的背后都缺乏对计算机运作原理、软件设计原则,或每一个他们往计算机中输入字符的理解。 许多程序员根本就不知道在软件开发中可能存在通用法则或者是通用指南,所以他们根本就不会搜寻它们。许多软件公司也不会设法提升开发者对于他们所使用的编程语言的理解——也许因为他们仅仅认为程序员“如果被雇用了就应该对那些内容了如指掌”。 在哪些方面需要投入更多时间来学习: 你清楚地了解你编写的每一页代码上的每一个单词和符号吗? 你是否阅读过以及是否能完全理解与你使用的每一个函数有关的说明文档? 你是否掌握了软件开发中基本原则的精髓——掌握的程度足以让你毫无差池地解释给你团队中的新成员听? 你是否理解计算机内每一个组件的功能,以及它们是如何协同运作的? 你是否了解计算机的历史,以及它们未来的发展方向,以便帮助你理解你的代码将会如何运作在未来的计算机中? 你是否了解编程语言的历史,以便你可以了解你正在使用的编程语言将会如何进化,以及为什么会朝这个方向进化? 你是否了解其他的编程语言,其他的编程方式,以及其他形式的计算机,帮助你对症下药解决实际工作问题? 如果你只是简单地复制别人的代码,然后祈祷它也能在你这正常工作,那么你永远也不会成为一名优秀的程序员。 第37章 快速编程的秘诀:停止思考 给开发者施加时间上的压力的确会导致他们写出复杂的代码。但是交付的最后期限和复杂性并没有必然关系。 与其说“最后期限迫使我无法写出简单代码”,不如说“我写出简单代码的速度不够快”。进一步说,作为程序员你编码的速度越快,代码质量被最后期限影响的可能性就越低。 任何时候只要你发现自己停止了思考,那就意味着某个地方出了问题。 “需要打太多的字”对于开发者来说从来就不是一个会对效率产生影响的问题。恰恰相反,正是你停止输入的间隙拖慢了编码速度。 开发者之所以停下来思考,通常是因为他们没有完全理解一些单词或者符号的意义。 如果你发现自己正停下来思考,不要尝试去解决当下你脑海中的问题——而是跳出你自己,想想有什么是你自己还不知道的。然后去阅读那些能够帮助你理解它们的材料。 这种模式甚至能够解答类似于“用户是否真的会阅读这些文字?”这类的问题。也许公司内部没有用户体验研究部门能帮助你真正回答它,但是你至少可以画出一个原型,然后将它展示给人们并且征询他们的意见。不要只坐在那里然后纯粹地想这个问题——行动起来。只有行动才能带来理解。 很多人停下来思考,是因为他们没法在脑袋里一次性装下所有的概念——许多事物都在以一种复杂的方式相互关联在一起,人们必须首先在脑海中将它们过一遍才行。在这种情况下,更有效的方式往往是将它们写下来或者是画出来,而不是凭空思考它们。 有时候的问题是:“我不知道首先应该编写哪一部分的代码。”最简单的解决办法是,开始编写你现在有能力编写的任何代码。挑选当前问题中你已经完全理解的那一部分,首先编写该部分的解决方案——哪怕只有一个函数,或者只是一个并不重要的类。 通常情况下,一开始最容易编写的代码往往是应用程序中的“核心”部分。如果你依然不确定如何编写核心代码,那么可以从你已经确定的部分开始。 我发现只要问题的其中一个部分得到了解决,剩下的部分也会变得容易起来。有时候问题可以在逐步拆解的过程中慢慢清晰起来。只要你首先解决了一部分,剩余部分的解法自然也就一目了然了。哪一部分代码不需要太多思考就能开始编写,那么现在编写这部分代码就好。 另一个会产生理解方面问题的时刻是当你跳过了正常开发过程中的一些步骤的时候。在开发系统的过程中,不要跳过某些步骤还期望自己的效率很高。 第38章 开发者的傲慢 我唯一关心的是你的程序是否能够协助我完成工作。仅此一点决定了你的软件出色与否,如果它能达成这个目标,那么你应该对此感到骄傲。不要用那些仅仅是你认为重要的功能来博取我的眼球。 还有一些看似微不足道的地方也会带来问题,它们都和吸引了用户太多的注意力有关: “用户对于在使用我的产品之前,需要填写三个屏幕长的表单这件事,是不会有意见的。” “我非常肯定用户有兴趣了解我专程为这个程序发明的这些图标,所以把这些图标上的文字移除掉不成问题!” “我十分肯定借由这些弹出框来中断用户工作的做法是正确的。” “用户肯定想要在这个巨幅页面上搜索出某一小段文字,以便他们能够点击它。” “为什么我们要把这个变得更简单呢?要花费不少时间,对我来说……它已经够简单了。” 对于一个程序员来说,真正的谦逊是自愿地抹去他在用户世界里的存在感。 请不要再告诉用户你的程序已经安装在他的电脑上了。不要认为用户会在意你的程序,他们只是想要花时间用一用它的界面,或者想要学习它如何使用,他们在意的不是你的程序——而是他们想要达成的目标。如果你能帮助用户完美地实现他们的目标,那么就意味着你为他们创造出了最完美的程序。 第39章 “一致”并不意味着“统一” 在用户界面中,相似的事物应该看起来是相同的。而不同种类的事物应该看起来是不同的。 在应用的后端开发中,所有代码都应该在统一的技术框架之上进行开发。但那并不意味着界面上所有元素都应该看起来一样。 虽然说一致性在应用的前端和后端都非常重要,但是那并不意味着每一件事物都应该看上去是一样的。 第40章 用户有困难,开发者有方案 在软件的世界里,软件开发者的职责就是为用户解决问题。不同的用户代表着不同的问题,开发者们需要将它们一一解决。如果他们的角色发生了互换,麻烦可能就会接踵而至。 一旦你解决的是开发者的问题而不是用户的问题,那就意味着你投入精力的方向有误,这条路并非帮助人们解决问题的最佳方案。 我还感受到的一点是,解决开发者的问题比解决普通用户的问题更加复杂。所以找到用户实际存在的痛点并且解决它,会相比苦思冥想解决一个想象中的问题更容易。 只有用户(更准确地说,是大部分的用户,或者关于大部分用户的数据)才能真正告诉你他们遭遇的问题是什么,只有在开发侧(更准确地说,是在完全理解了这个问题,并且很可能听取了来自他人的反馈之后,被授予决策重任)的人才能对应该实施什么样的解决方案做出正确决定。 第41章 即时满足=即时失败 软件从来就是一个长期的过程。 一旦竞争对手X公司发布了“闪亮新功能”,我公司就会毫不示弱:“我们现在也必须要有闪亮新功能!”。这不是一个长期的制胜策略,而是短视下的恐慌的表现。你的用户不会因为其他某款软件有而你没有的功能,就立即起身转而投奔他们。你应该观察用户量的增长或者流失趋势再做决策,而不是无脑地对当下环境做出立即响应。 所以什么是好的长期策略?重构你的代码,让你能够在将来更轻松地添加功能就是其中之一。或者在产品发售之后对一些功能和UI上的不足进行打磨,也是用户喜闻乐见的。另一条建议是,如果某些功能可有可无,并且你将来也并不想维护它们,那还是不要添加了。 太多所谓“如何经营你的软件生意”方面的建议,它们关注的都是实时满足——你当下能做什么。新增功能!立即从投资人那里获取数百万美元的投资!但不幸的是,世界的运转法则其实是,毁灭事物可以是一瞬间的事,但是创造事物则需要花费时间。 所以在现实世界中,你越是向往“实时满足”,你也越是在将你的产品、生意和你的未来推向毁灭。 所以软件行业里一则关键经验教训是:如果你正在制订一则计划,请确保它至少承认创造这件事是需要耗费时间的。它可能不会是永远,但一定不会是当下立即实现。 第42章 成功来自执行而非创新 我的想法是如何得优秀或者多么有创新精神一点都不重要,重要的是我能让它在真实世界落实得多好。 所以请不要焦虑于“赶紧想出一个新点子”。这种担心没有必要。你只需要以尽可能完美的方式去实现一个现有的想法就好。你可以加上一些自己的创意,或者再进行一些打磨,但你根本不需要全新的东西。 第43章 杰出的软件 一款真正被称为杰出的程序,需要能够准确执行用户的意图。 如果你想要把这句称述做更详细的拆解,也就是说杰出软件必须做到: 1.完全按照用户的要求去做。 2.表现的行为和用户期望的完全一致。 3.不会妨碍用户传达他们的意图。 当计算机能完美地执行你给出的指令,会给人带来一种奇怪的满足感。这也是编程的乐趣之一——一旦计算机准确无误地将你下达的指令执行完毕,满足的愉悦感便油然而生。 书籍 豆瓣:https://book.douban.com/subject/35513153/

2024/7/28
articleCard.readMore

记录HTML中与性能/渲染有关的几个新属性

记录 HTML 中与性能/渲染有关的几个新属性 <script>/<link>/<style>标签的blocking属性 mdn 对blocking 属性的解释:(实验性),这个属性明确指出,在获取脚本的过程中,某些操作应该被阻断。要阻断的操作必须是一个以空格分隔的列表,下面列出了阻断属性。 blocking属性允许我们能控制部分渲染的阻塞,直到特定的资源加载完毕。 使用 可应用元素:<head> 标签中的 <script>、<link> 和 <style> 值:"render" 设置blocking="render" 用于标记那些在显示任何内容给用户之前必须加载完成的资源。在这些资源加载完成之前,浏览器窗口中不会绘制任何像素。 <script>使用 如: 1 <script blocking="render" src="main.js" defer></script> 默认情况下,浏览器会为所有渲染阻塞的资源分配较高的优先级。但考虑到不是所有浏览器都支持 blocking 属性,添加 fetchpriority 属性设置 fetchpriority="high" 也是个不错的选择: 1 <script blocking="render" fetchpriority="high" src="main.js" defer></script> 内联脚本 blocking 属性同样适用于内联脚本。传统的内联脚本默认是阻塞的(defer 和 async 属性不适用于它们)。但是,如果你的脚本带有 type="module" 属性,即使是内联的,它也会延迟执行。 1 2 3 <script type="module" async blocking="render"> // 重要的 JavaScript 代码... </script> 内联模块脚本中的 async 属性意味着它会尽快执行。 <link>使用 1 <link rel="stylesheet" href="main.css" blocking="render" /> 可以在 <link> 元素上设置 blocking 属性。但如果你使用 <link> 配合 rel="preload" 来预加载资源,或者使用 rel="modulepreload" 来预加载 JavaScript 模块,那么 blocking 属性将不会生效。 开发者通常会利用这种行为来有意识地延迟非关键 CSS 的加载。但是,如果你需要以这种方式加载关键 CSS(会改变整个页面布局或影响页面折叠内容的样式),则可以使用 blocking 属性: 1 2 3 4 5 const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = './styles.css'; link.setAttribute('blocking', 'render'); document.head.appendChild(link); 渲染阻塞 DOM 节点 HTML 的渲染是逐步/增量进行的:渲染可以在整个 HTML 文档被获取和解析之前就开始。 有一种方法可以根据特定 HTML 元素是否已解析来阻塞渲染。 在 <head> 中放置一个 <link>,其 href 属性引用所要元素的 id: 1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html> <html> <head> <link rel="expect" href="#visually-critical-content" blocking="render" /> </head> <body> <header>...</header> <div id="visually-critical-content">...</div> </body> </html> 这使得开发者可以更好地控制哪些内容包含在首次内容绘制 (FCP) 中。它应该只用于页面折叠之上的元素——否则你会不必要地延迟首次绘制。一旦元素被解析,页面就会对用户可见。如果找不到预期的元素,则在整个 HTML 文档解析完成时,渲染将被解除阻塞。 这个功能主要是在考虑跨文档视图转换的情况下添加到 Web 中的。 renderBlockingStatus 检测是否生效 如果你想验证 blocking 属性是否生效,可以在浏览器的开发者工具控制台中运行以下代码,来查看渲染阻塞资源的列表: 1 2 3 4 window.performance .getEntriesByType('resource') .filter(resource => resource.renderBlockingStatus === 'blocking') .forEach(resource => console.log(resource.name)); 那我们为什么需要这个新属性呢? 它明确了阻塞的行为,清晰地传达了开发者的意图,这样团队成员在重构代码时,就不会无意中把它改成非阻塞的了。 当与 defer 或 async 属性一起使用时,它可以阻塞渲染,同时不会阻塞 HTML 解析器的工作。 与传统脚本不同,模块脚本默认是延迟加载的。<script type="module"> 现在可以通过 blocking 属性来实现渲染阻塞。 通过 JavaScript 动态添加到 <head> 中的 <script>、<link> 和 <style> 元素,默认是不会阻塞渲染的。但现在,你可以灵活地让它们实现阻塞。 是否可能会延长 FCP? 渲染阻塞会延迟将像素绘制到屏幕上,所以你可能会想,这是否意味着用户就得盯着空白页面看。在快速的网络连接下,这种情况很少见。过去,在浏览网页时,经常会在页面间看到白色的闪烁。为了解决这个问题,浏览器开始使用绘制保持(paint holding)。绘制保持会让用户停留在上一个页面,并显示一个加载指示器,直到新页面的首次内容绘制 (FCP) 准备就绪。如果你延迟了 FCP,用户就会在上一个页面上多停留一会儿。绘制保持只持续很短时间。如果你延迟了 FCP 太久,就会显示一个空白的白色页面。对于速度慢的网站,在慢速的 3G 网络上,这种白色空白的“闪烁”可能会持续很长时间。 兼容情况 <img>/<iframe>标签的loading属性 mdn 对于图片loading 属性的解释:HTMLImageElement 的 loading 属性为一个字符串,它的值会提示 用户代理 告诉浏览器不在可视视口内的图片该如何加载。这样一来,通过推迟图片加载仅让其在需要的时候加载而非页面初始载入时立刻加载,优化了页面的载入。 可以控制资源的加载行为,减少初始页面加载时间。 使用 设置值为"eager"时(默认行为),eager 告诉浏览器当处理 <img> 标签时立即加载图片;设置值为lazy时告诉用户代理推迟图片加载直到浏览器认为其需要立即加载时才去加载。例如,如果用户正在往下滚动页面,值为 lazy 会导致图片仅在马上要出现在 可视视口中时开始加载。 load 事件的时机 <img> load 事件在文档被完整的处理完成时触发。当图片使用立即加载 (默认值) 时,文档中的所有图片都会在 load 事件触发前载入。 当 loading 值设为 lazy 时,图片不再会在请求,下载,处理的时间内推迟 load 事件触发。 loading 属性值设为 lazy 但是在页面初次加载时就在可视视口内的图片会立即加载但它们也不会推迟 load 事件。换句话说,这些图片不会在处理 元素时立即加载,但仍会作为页面初始加载的一部分而加载。他们只是不会影响 load 事件。 这表明当 load 触发时,可视区域内懒加载的图片可能不可见。 <iframe> 即使 iframe 位于可视视口并在页面加载时被请求,懒加载的 iframe 也不会影响 load 事件的时机。只有当文档中所有立即加载的 iframe 都被请求后,才能触发 load 事件。 <img>使用 1 <img src="image.jpg" loading="lazy" /> 防止元素在图片懒加载时出现移位 当一个加载被 loading 属性设为 lazy 的图片最后加载时,浏览器会根据<img> 元素的尺寸和图片自身大小重排文档,更新被图片影响的元素的位置。 为了防止重排发生,你需要使用 width 和 height 属性明确设置图片大小。通过这样建立固有长宽比,你防止了元素的移位。取决于实际的加载时间和重排,移位造成的最小的影响可能只是使用户困惑和不适,最坏的影响则是导致用户点错目标。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function addImageToList(url) { const list = document.querySelector('div.photo-list'); let newItem = document.createElement('div'); newItem.className = 'photo-item'; let newImg = document.createElement('img'); newImg.loading = 'lazy'; newImg.width = 320; newImg.height = 240; newImg.src = url; newItem.appendChild(newImg); list.appendChild(newItem); } iframe 使用 1 <iframe src="example.html" loading="lazy"></iframe> 以下示例展示了如何定义一个懒加载的 iframe,然后将其附加到文档中的 <div>。只有当 iframe 即将变得可见时,才会加载 iframe。 1 2 3 4 5 6 7 8 9 10 // 在 iframe 中定义懒加载 const iframe = document.createElement('iframe'); iframe.src = 'https://example.com'; iframe.width = 320; iframe.height = 240; iframe.loading = 'lazy'; // 添加到类名为 frameDiv 的 div 元素中 const frameDiv = document.querySelector('div.frameDiv'); frameDiv.appendChild(iframe); 兼容情况 <a>/<link> 标签的 hreflang 属性 mdn 对hreflang 属性的解释:此属性指明了被链接资源的语言。其意义仅供参考。可取的值参见 RFC 5646: 语言识别标签(又称 BCP 47)。仅当设置了 href 属性时才应设置该属性。 hreflang属性指定<a>或<link>元素上链接资源的语言。它的工作原理类似于lang 属性,但专门用于链接。 您可以通过在内部和外部网站链接中使用 hreflang 来改善用户体验和 SEO。在内部网站链接上使用 hreflang 提供了一种方法,可以告诉搜索引擎其他语言或地区的页面的不同变化。这意味着讲英语的人将收到该网站的英文版,而讲瑞典语的人将收到瑞典文版。用户端无需手动切换,体验更加流畅。 使用 将带有所需 ISO 语言代码的 hreflang 属性添加到<a>元素中。对于英文网站则是 en。 1 <a href="https://example.com" hreflang="en">English Website</a> 您也可以使用更具体的语言代码,并使用区域变化。例如我们可以为英式英语添加 en-GB。 1 <a href="https://example.at" hreflang="en-GB">English Website</a> 如果您的网站提供多种语言版本,您可以使用 hreflang 指定与特定 URL 关联的文档的语言和区域。添加该属性将有助于搜索引擎了解不同网页版本的语言和区域定位。 在每个链接中添加带有所需语言代码的 hreflang。一个链接应该作为默认的后备版本,通过添加 hreflang="x-default"而不是语言代码来识别。最后对于语言切换器中的每个链接,将 rel 属性的值设置为"alternate",以指示所链接的页面是当前页面的替代页面。 1 2 <link href="https://example.com" rel="alternate" hreflang="x-default" /> <link href="https://example.com/de" rel="alternate" hreflang="de" /> 您也可以在语言切换器中使用 hreflang。 1 2 <a href="https://example.com" hreflang="x-default">English</a> <a href="https://example.com/de" hreflang="de">German</a> 有时语言切换者使用他们切换到的语言中的链接文本。您可以通过额外使用 lang 属性来指示这一点。 1 2 <a href="https://example.com" hreflang="x-default">English</a> <a href="https://example.com/de" hreflang="de" lang="de">Deutsch</a> 增强可访问性的另一种方法是将 aria-current="true"包含到当前活动的链接中。 1 2 <a href="https://example.com" hreflang="x-default" aria-current="true">English</a> <a href="https://example.com/de" hreflang="de" lang="de">Deutsch</a> 兼容情况 通用属性translate mdn 对translate 属性的解释:全局属性 translate 是一种枚举属性,用来规定对应元素的可翻译属性值及其 Text 子节点内容是否跟随系统语言作出对应的翻译变化。 translate 属性用于指示元素是否应该被翻译。 为什么使用 translate? 默认情况下,大多数网站文本都是可翻译的(除了一些例外,例如图像上或 SVG 中的文本)。如果网站的定义语言与浏览器的默认语言不同,翻译工具(如 Google 翻译)可能会建议翻译页面内容。 但可能存在这种行为是不需要的情况。公司名称、电子邮件地址或代码示例等特定术语通常不应翻译,以避免混淆。自动翻译并不总是完全准确的,特别是对于小众词汇或技术词汇。 使用 您可以在任何 HTML 元素上使用 translate。指定一个空字符串("")或 yes 用于翻译,而 no 用于避免翻译。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <!-- 德语原文 --> <p> <span>Wien<span> ist (wieder) die lebenswerteste Stadt der Welt! </p> <p> <span translate="no">Wien<span> ist (wieder) die lebenswerteste Stadt der Welt! </p> <!-- 翻译后 --> <p> <span>Vienna<span> named world's most liveable city (again)! </p> <p> <span translate="no">Wien<span> named world's most liveable city (again)! </p> 兼容情况 相关链接 Back to Basics: 5 HTML attributes for improved accessibility and user experience 网页加载卡顿?试试 HTML 的新武器 blocking 属性!

2024/6/16
articleCard.readMore

【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 75 ~ 90 条读书笔记

【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 75 ~ 90 条(测试与调试、协作开发)读书笔记 (书基于 Python 3.7+ 语法规范) 知识点概括: 1 2 3 4 5 6 7 8 9 10 11 repr函数 unittest模块 TestCase类 mock函数与Mock类 pdb模块 breakpoint函数 tracemalloc模块 PyPI venv工具 requirements.txt文件 warnings模块 9.测试与调试 Python语言没有编译期的静态类型检查机制,所以Python解释器无法确保程序一定能够正确运行。当然,在静态分析的过程中,可以选用类型注解帮助我们发现各种bug。 即便源代码里写着某个函数,你也未必能够保证,程序在需要调用这个函数的时候,它一定得到了定义。这种动态特征既有好处,又有风险。 程序正式发布之前,为什么不把它全面测试好呢?编译期的静态类型检查并不能完全确保程序必定能够正常运行。无论是用动态语言编程,还是用静态语言编程,我们都必须对写出来的代码做测试。 它与其他语言相比,更需要通过测试来确保代码准确无误。然而这也有好的地方:Python的动态机制虽然有风险,但若能适当加以运用,则可以帮助我们相当顺利地测试代码并调试程序。 第75条、通过repr字符串输出调试信息 调试Python程序时,我们可以通过print函数与格式字符串,或者利用内置的logging模块,相当深入地观察程序的运行情况。Python的内部状态一般都可以通过普通的属性访问到。 print函数可以把开发者传给它的值显示成便于认读的那种字符串。例如,最简单的用法是直接把字符串传给它,这样就可以打印出不带引号的内容。 1 print('foo bar') 这种写法跟下面几种写法是等效的: 先把字符串传给str函数,然后把str函数返回的内容传给print;把字符串写在%操作符的右侧,让它替换操作符左侧那个格式字符串里的'%s';把表示字符串值的那个变量,按照默认格式写在f-string中,交给print去打印; 调用内置的format函数; 明确调用format特殊方法; 明确调用str特殊方法。 如: 1 2 3 4 5 6 7 my_value = 'foo bar' print(str(my_value)) print('%s' % my_value) print(f'{my_value}') print(format(my_value)) print(my_value.__format__('s')) print(my_value.__str__()) 然而问题在于,用这种方式来打印,不太容易看清这个值究竟是什么类型以及它具体是由哪些部分组成的。例如,如果按照默认方式调用print函数,那么我们无法区分打印出来的这个5,到底是数字5,还是字符串’5’。 如果要用print调试程序,那么类型的区别就很重要。所以,我们需要打印的应该是对象的repr版本。这个版本可以通过内置的repr函数获得,该函数会返回对象的可打印表示形式(printable representation),这也是对象最为清晰且易于理解的表示形式。对于大多数内置类型来说,repr返回的字符串是个有效的Python表达式。 1 2 a = '\x07' print(repr(a)) 把repr返回的值传给内置的eval函数,应该会得到一个跟原来相同的Python对象(当然,实际中eval函数的使用必须相当谨慎)。 用print调试程序的时候,应该先把要打印的值传给repr,然后将repr返回的内容传给print去打印,以明确体现出类型之间的差别。 1 2 print(repr(5)); # 5 print(repr('5')); # '5' 还有两种写法也能实现相同的效果,一种是把要打印的值放在%操作符的右边,让它替换左边的'%r'格式化字符串,另一种是在f-string中使用!r转换类型。 1 2 print('%r' % 5) print('%r' % '5') 第76条、在TestCase子类里验证相关的行为 在Python中编写测试的最经典办法是使用内置的unittest模块。 为了定义测试用例,需要再创建一个文件,将其命名为test_utils.py或utils_test.py,命名方案可以根据你的风格来选。 1 2 3 4 5 6 7 8 9 10 11 12 13 # utils_test.py from unittest import TestCase, main from utils import to_str class UtilsTestCase(TestCase): def test_to_str_bytes(self): self.assertEqual('hello', to_str(b'hello')) def test_to_str_str(self): self.assertEqual('incorrect', to_str('hello')) if __name__ == '__main__': main() 测试用例需要安排到TestCase的子类中。在这样的子类中,每个以test开头的方法都表示一项测试用例。如果test方法在运行过程中没有抛出任何异常(assert语句所触发的AssertionError也算异常),那么这项测试用例就是成功的,否则就是失败。其中一项测试用例失败,并不影响系统继续执行TestCase子类里的其他test方法,所以我们最后能够看到总的结果,知道其中有多少项测试用例成功,多少项失败,而不是只要遇到测试用例失败,就立刻停止整套测试。 我们还可以在test方法中指定断点,这样能够直接从此处激活调试器(debugger),以观察详细的出错原因。 TestCase类提供了一些辅助方法,可以在测试用例中做断言。例如,assertEqual方法可以确认两者是否相等,assertTrue可以确认Boolean表达式是否为True,此外还有很多以assert开头的方法(在Python解释器界面输入help(TestCase),可以查看所有的方法)。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # assert_test.py from unittest import TestCase, main from utils import to_str class AssertTestCase(TestCase): def test_assert_helper(self): expected = 12 found = 2 * 5 self.assertEqual(expected, found) def test_assert_statement(self): expected = 12 found = 2 * 5 assert expected == found if __name__ == '__main__': main() TestCase还提供了assertRaises这样一个辅助方法,它可以当作情境管理器(context manager)用在with结构中),以验证该结构的主体部分是否会抛出应有的异常。这种写法跟try/except结构相似,可以很清楚地表示出受测代码应该抛出哪种异常。 1 2 3 4 5 6 7 8 9 10 11 12 13 # utils_error_test.py from unittest import TestCase, main from utils import to_str class UtilsErrorTestCase(TestCase): def test_to_str_bad(self): with self.assertRaises(TypeError): to_str(object()) def test_to_str_bad_encoding(self): with self.assertRaises(UnicodeDecodeError): to_str(b'\xfa\xfa') if __main__ == '__main__': main() 如果测试用例需要使用比较复杂的逻辑,那么可以把这些逻辑定义成辅助方法放到TestCase子类里。但是必须注意,这种方法的名称不能以test开头,否则系统就会把它们当成测试用例来执行。在写辅助方法时,我们可能会用到TestCase类提供的各种assert方法,而且还经常会用fail方法来表示不应该出现的情况,也就是说,如果程序真的运行到了fail这里,那么意味着我们预设的某项前提条件没有得到满足。 TestCase类还提供了subTest辅助方法,可以让我们把相似的用例全都写在同一个test方法中,让它们成为这个用例中的子用例,这样的话,每个子用例所共用的那部分代码与逻辑只需要写一次就行。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # data_driven_test.py from unittest import TestCase, main from utils import to_str class DataDrivenTestCase(TestCase): def test_good(self): good_cases = [ (b'my bytes', 'my bytes'), ('no error', b'no error'), # will fail ('other str', 'other str'), ] for value, expected in good_cases: with self.subTest(value): self.assertEqual(expected, to_str(value)) def test_bad(str): bad_cases = [ (object(), TypeError), (b'\xfa\xfa', UnicodeDecodeError), # ... ] for value, exception in bad_cases: with self.subTest(value): with self.assertRaises(exception): to_str(value) if __main__ == '__main__': main() 如果项目比较复杂或者对测试的要求比较高,可以考虑用pytest这个开源软件包(https://pytest.org/)来测试,Python开发者给pytest制作了许多特别有用的插件。 第77条、把测试前、后的准备与清理逻辑写在setUp、tearDown、setUp-Module与tearDownModule中,以防用例之间互相干扰 TestCase子类在执行其中的每个test方法之前,经常需要先把测试环境准备好,这套准备逻辑有时也叫测试装置或测试用具(test harness)。我们可以在TestCase子类中覆写setUp与tearDown方法,并把相应的准备逻辑与清理逻辑写在里面。系统在执行每个test方法之前都会先调用一遍setUp方法,并在执行完test方法之后调用一遍tearDown方法。这可以确保测试用例之间不会互相干扰,这一点,对测试工作至关重要。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # environment_test.py from pathlib import Path from tempfile import TemporaryDirectory from unittest import TestCase, main class EnvironmentTest(TestCase): def setUp(self): self.test_dir = TemporaryDirectory() self.test_path = Path(self.test_dir.name) def tearDown(self): self.test_dir.cleanup() def test_modify_file(self): with open(self.test_path / 'data.bin', 'w') as f: #... if __name__ == '__main__': main() 当程序变得复杂后,我们就不能只依赖这种彼此隔绝的单元测试了,而是需要再写一些测试,以验证模块与模块之间能否正确地交互(这可能要用到mock等工具)。这种测试叫集成测试(integration test),它跟前面的单元测试(unit test)不同。这两种测试在Python中很重要,假如不做集成测试,那就没办法确信这些模块能够协同运作。 对于集成测试来说,测试环境的准备与清理工作可能要占用大量计算资源,并持续比较长的时间。例如,可能要先启动数据库进程,并等待该进程把索引加载进来,然后才能开始做集成测试。这些工作的延迟很高,因此不能像做单元测试时那样,写在setUp与tearDown方法中。 第78条、用Mock来模拟受测代码所依赖的复杂函数 写测试的时候还有一个常见的问题,就是某些逻辑很难从开发环境里真实地执行,或者使用起来特别慢,这样的逻辑可以通过mock函数与Mock类来模拟。 1 2 3 4 5 6 7 8 9 10 form datetime import datetime from unittest.mock import Mock mock = Mock(spec=get_animals) expected = [ ('Spot', datetime(2019, 6, 5, 11, 15)), ('Fluffy', datetime(2019, 6, 5, 12, 30)), ('Jojo', datetime(2019, 6, 5, 12, 45)), ] mock.return_value = expected 只要参数的取值不影响要测试的关键行为,那就可以在验证时通过ANY忽略这个参数。对于这些不太重要的参数,我们可以放宽一些,而不应该指定得太细,因为那样必须编写大量的验证代码。 Mock类还能够模拟调用时抛出异常的情况。 1 2 3 4 5 6 class MyError(Exception): pass mock = Mock(spec=get_animals) mock.side_effect = MyError('Whoops! Big problem') result = mock(database, 'Meerkat') 要想把受测函数所调用的其他函数用mock逻辑替换掉,一种办法是给受测函数设计只能以关键字来指定的参数;另一种办法是通过unittest.mock.patch系列的方法暂时隐藏那些函数。 第79条、把受测代码所依赖的系统封装起来,以便于模拟和测试 通过Mock类实现,另一个是通过patch方法实现。可是,这两种方案都要求我们在测试的过程中重复编写很多例行代码,这会让初次阅读代码的人很难理解我们究竟要验证什么。 有一种办法可以改进代码,就是把受测函数所要使用的数据库接口封装起来,这样我们就不用像原来那样,专门把数据库连接(DatabaseConnection)当作参数传给受测函数了,而是可以将封装好的系统传过去。 Python内置的unittest.mock模块里有个Mock类,它能模拟类的实例,这种Mock对象具备与原类中的方法相对应的属性。如果在它上面调用某个方法,就会触发相应的属性。如果想把程序完整地测一遍,那么可以重构代码,在原来直接使用复杂系统的地方引入辅助函数,让程序通过这些函数来获取它要用的系统,这样我们就可以通过辅助函数注入模拟逻辑。 第80条、考虑用pdb做交互调试 Python内置的交互调试器(interactive debugger)就是这样一种工具,它可以检查程序状态,打印局部变量的值,还可以每次只执行一条Python语句(也就是单步执行)。 在其他大部分编程语言中,如果要使用调试器,那么必须先在源文件中指定断点,令程序在执行到这一行时停下来。然而Python不用这样,你可以直接在认为有问题的那行代码前加入一条指令,让程序暂停,并启动调试器,这是最简单的办法。采用这种办法来调试程序,与正常启动程序并没有什么区别。 用来触发调试器的指令就是Python内置的breakpoint函数。这个函数的效果与先引入内置的pdb模块然后运行set_trace函数的效果是一样的。 一旦运行breakpoint函数,程序会在即将执行下一条语句的地方暂停。 在(Pdb)提示符界面,我们可以输入局部变量的名称(或执行p <name>命令)来查看变量的取值,也可以调用Python内置的locals函数以观察所有的局部变量,还可以引入模块,检查全局状态,构造新的对象,或运行内置的help命令,甚至还能修改正在运行的程序里的某些部分,总之,对调试工作有帮助的操作都可以在这里执行。 另外,调试器还提供了各种特殊命令,帮我们控制程序的执行方式,并探查其执行情况。在调试界面输入help,可以看到完整的命令列表。通过下面这三条非常实用的命令,我们可以很方便地检查正在运行的这个程序: where:打印出当前的执行调用栈(execution call stack),可以据此判断程序当前执行到了哪个位置,以及程序是在调用了哪些函数后才触发breakpoint断点的。 up:把观察点沿着执行调用栈上移一层,回到当前函数调用者处,以观察位于当前断点之上的那些层面分别有什么样的局部变量。 down:把观察点沿着执行调用栈下移一层。 检查完程序的运行状态后,可以通过下面这五条命令决定程序接下来应该如何执行: step:执行程序里的下一行代码,并在执行完毕后把控制权交还给调试器。如果下一行代码带有函数调用操作,那么调试器就会停在受调用的那个函数开头。 next:执行当前函数的下一行代码,并在执行完毕后,返回交互调试界面。如果下一行代码带有函数调用操作,系统不会令调试器停在受调用的函数开头。 return:让程序一直运行到当前函数返回为止,然后把控制权交还给调试器。 continue:让程序运行到下一个断点处(那个断点可以是通过breakpoint触发的,也可以是在调试界面里设置的)。 quit:退出调试界面,并且让接受调试的程序也随之终止。如果已经找到了问题,那么就可以用这个命令结束调试。如果发现寻找的方向不对,或者需要先去修改程序的代码,那么也应该运行这个命令以便重新调试。 breakpoint函数可以出现在程序里的任何地方。 调试器还支持一项有用的功能,叫作事后调试(post-mortem debugging),当我们发现程序会抛出异常并崩溃后,想通过调试器看看它在抛出异常的那一刻,究竟是什么样子的。有时我们也不确定应该在哪里调用breakpoint函数,在这种情况下,尤其需要这项功能。 于是,我们用python3 -m pdb -c continue <program path>命令把这个有问题的程序放在pdb模块的控制下运行,其中-c选项里的continue命令会让pdb在启动受测程序之后立刻向前推进,直至遇到断点或出现异常为止。 还有一种办法也能触发事后调试机制,就是先在普通的Python解释器里执行受测代码,待遇到未被捕获的异常后,再引入pdb模块并调用其pm函数(通常把引入pdb模块与调用pm函数这两项操作合起来写成import pdb; pdb.pm())。 第81条、用tracemalloc来掌握内存的使用与泄漏情况 在Python的默认实现方式(也就是CPython)中,内存管理是通过引用计数(reference counting)执行的。如果指向某个对象的引用已经全部过期,那么受引用的对象就可以从内存中清除,从而给其他数据腾出空间。另外,CPython还内置了循环检测器(cycle detector),确保那些自我引用的对象也能够得到清除。 从理论上讲,这意味着Python开发者不用担心程序如何分配并释放内存的问题,因为Python系统本身以及CPython运行时环境会自动处理这些问题。但实际上,还是会有程序因为没有及时释放不再需要引用的数据而耗尽内存。想了解Python程序使用内存的情况,或找到泄漏内存的原因,是比较困难的。 第一种调试内存使用状况的办法,是用Python内置的gc模块把垃圾回收器目前知道的每个对象都列出来。虽然这样有点儿笨,但毕竟可以让我们迅速得知程序的内存使用状况。 gc.get_objects函数的缺点在于,它并没有指出这些对象究竟要如何分配。复杂的程序中,同一个类的对象可能是因为好几种不同的原因而为系统所分配的。知道对象的总数固然有意义,但更为重要的是找到分配这些对象的具体代码,这样才能查清内存泄漏的原因。 Python 3.4版本推出了一个新的内置模块,名为tracemalloc,它可以解决刚才讲的那个问题。tracemalloc能够追溯对象到分配它的位置,因此我们可以在执行受测模块之前与执行完毕之后,分别给内存使用情况做快照,并对比两份快照,以了解它们之间的区别。 1 2 3 4 import tracemalloc tracemalloc.start(10) time1 = tracemalloc.take_snapshot() 每一条记录都有size与count指标,用来表示这行代码所分配的对象总共占用多少内存以及这些对象的数量。通过这两项指标,我们很快就能发现占用内存较多的对象是由哪几行代码所分配的。tracemalloc模块还可以打印完整的栈追踪信息(当然了,这最多只能达到调用tracemalloc.start函数时设置的帧数)。 这样的栈追踪信息很有用处,因为它能帮我们找到程序中累计分配内存最多的函数或类。 10.协作开发 要想与其他人一起开发Python程序,你必须仔细留意自己编写代码的方式。 第82条、学会寻找由其他Python开发者所构建的模块 Python有个集中存放模块的地方,叫作Python Package Index(PyPI,网址为https://pypi.org),你可以从中安装模块,并在自己的程序里面使用。这些模块都是由Python开发者所构建并维护的,这些开发者合称Python社群。如果你面对一项自己不太熟悉的需求,那么可以去PyPI搜索一下,看看有没有哪个软件包能够帮你更快地实现目标。 要从PyPI安装软件包,可以在命令行界面执行pip命令(这是个递归的首字母缩略词,指pip installs packages)。 pip最好与内置的venv模块搭配使用,这样可以给不同的项目分别安装不同版本的软件包,以适应每个项目自身的要求。你可以自己创建软件包并发布到PyPI与其他开发者共享,或搭建私人的软件包仓库,让pip改从这里安装模块。 第83条、用虚拟环境隔离项目,并重建依赖关系 程序变得比较大、比较复杂后,可能需要依赖更多的第三方Python包,那时我们可以通过python3 -m pip命令安装pytz、numpy以及其他许多模块。 问题在于,pip会把新的软件包默认安装到全局路径之中,这样会让涉及该模块的每一个Python程序都受到影响。有些人可能觉得这不会有什么问题:如果只安装模块,而不在Python程序里面直接引入它,那这个程序就不会受模块的影响了吧? 其实问题出现在间接的依赖关系上面,也就是说,直接引入的虽然不是这个模块,但你引入的其他模块却必须依赖这个模块才能运作。例如,如果你打算直接引入Sphinx模块,那就用pip install命令安装该模块,安装好之后,可以通过pipshow查看它的依赖关系。 同一个模块在Python的全局环境中只能存在一个版本。如果某个软件包需要使用新版模块,而另一个软件包需要使用旧版模块,那么就会遇到两难的局面:不论这个模块是否升级,这两个软件包都会有一个无法运作。这种情况通常称为dependency hell。 上面提到的种种问题都可以通过venv工具解决。这个工具能够创建虚拟环境(virtual environment)。从Python 3.4开始,pip与venv模块会在安装Python时一起默认安装。 venv可以创建彼此隔绝的Python环境,我们能够把同一个软件包的不同版本分别安装到不同的环境里面,这样就不会产生冲突了。这意味着能够在同一台电脑上面给不同的项目创建各自的环境,并在里面安装它们所需要的软件包版本。为了达到这样的效果,venv工具会把这些软件包以及它们所依赖的其他软件包都专门安装到单独的目录结构里面,使得多个环境之间不会发生冲突。这种机制,也让我们可以把项目所要求的环境在其他电脑上面重新建立起来,令程序能够可靠地运行,而不会出现意外的问题。 用python3 -m pip list列出这套虚拟环境之中安装的软件包,可以看到,这些软件包与前一套环境所安装的相同。 用版本控制系统(revision control system)与他人协作时,这样一份requirements.txt文件是很有用处的,因为你在提交代码的同时,可以把新版代码所依赖的软件包通过该文件一起提交上去,这样能够确保代码与代码所依赖的软件包总是可以同步更新。然而,有一个问题必须注意,那就是Python本身的版本并不包含在requirements.txt之中,所以必须单独管理。 虚拟环境有个很容易出错的地方,就是不能直接把它移动到其他路径下面,因为它里面的一些命令(例如python3)所指向的位置都是固定写好的,其中用到了这套环境的安装路径,假如移动到别处,那么这些路径就会失效。然而,这其实并不算大问题,因为我们创建虚拟环境,不是想要移动它,而是想把它的配置记录下来,以便在其他环境里面重建。所以我们要做的,仅仅是用python3 -m pip freeze命令把旧环境依赖的软件包保存到requirements.txt文件之中,然后在新环境里面,根据这份文件重新安装这些软件包。 第84条、每一个函数、类与模块都要写docstring Python是动态语言,因此文档特别重要。Python内建了相关的机制,支持给代码块编写文档,而且与其他一些编程语言不同,Python允许我们在程序运行的过程中,直接访问这些文档。 例如,我们可以紧跟着函数的定义(def)语句,书写一条docstring作为这个函数的文档。 1 2 3 4 5 6 def palindrome(word): """Return True if the given word is a palindrome.""" return word == word[::-1] assert palindrome('tacocat') assert not palindrome('banana') 函数的docstring可以在运行Python程序的过程中通过doc这个特殊的属性访问。 1 print(repr(palindrome.__doc__)) 另外,还可以在命令行界面中,利用内置的pydoc模块在本机上启动web服务器,这台服务器能够提供当前Python解释器所能访问到的全部文档,也包括你自己编写的那些模块。 docstring可以关联到函数、类与模块上面,系统在编译并运行Python程序的过程中,会把确定这种关联的关系也当成工作的一部分。由于Python支持docstring并允许程序通过doc属性来访问docstring,我们会享受到以下三个好处: 开发者能够在程序中访问文档信息,这会让交互式开发工作变得更加轻松。 这些文档是按照标准的方式定义的,因此很容易就能转换成表现力更强的格式(例如HTML)。 Python文档不仅可以做得很漂亮,而且与其他普通的头等Python实体一样,也能够在程序里面正常地访问,这会让开发者更乐意编写这样的文档。 如果你也想像大家一样把文档写好,那就需要遵循一些与docstring有关的约定。完整的规范,可以查看PEP 257(https://www.python.org/dev/peps/pep-0257/),接下来我们重点讲述其中必须注意的几个方面。 为模块编写文档 每个模块都要有顶级的docstring,即写在源文件开头的那个字符串。字符串的首尾都要带三重引号,这样的字符串的目的主要是介绍本模块与其中的内容。 在docstring里面,第一行应是一个单句,描述本模块的用途。接下来应该另起一段,详解讲述使用这个模块的用户所要知道的一些事项。另外,凡是模块里面比较重要的类与函数,都应该在docstring中予以强调,这样的话,查看这份文档的用户就可以从这些类及函数出发来熟悉模块。 1 2 3 4 5 6 7 8 9 10 11 12 # words.py #!/usr/bin/env python3 """Library for finding linguistic patterns in words. Testing how words relate to each other can be tricky sometimes! This module provides easy ways to determine when words you've found have special properties. Available functions: - palindrome: Determine if a word is a palindrome. - check_anagram: Determine if two words are anagrams. """ 如果这个模块表示的是一个可以在命令行界面使用的工具,那么模块的docstring里面,还应该写出相关的信息并介绍该工具的用法。 为类编写文档 每个类都应该有类级别的docstring,这种文档的写法,与模块级别的docstring差不多。它的第一段,也需要用一句话来概述整个类的用途。后面的各段,可以详细讲解本类中的每一种操作。 类中比较重要的public属性与方法,同样应该在类级别的docstring里面加以强调。另外还需要说明,如果想编写子类,子类应该怎样与受保护的属性以及超类中的方法相交互。 1 2 3 4 5 6 7 8 class Player: """Represents a player of the game. Subclasses may override the 'tick' method to provide custom animations for the player's movement depending on their power level, etc. Public attributes: - power: Unused power-ups(float between 0 and 1). - coins: Coins found during the level(integer). """ 为函数编写文档 每个public函数与方法都应该有docstring。它的写法与模块和类的相同,第一段也是一个句子,描述这个函数是做什么的。接下来的那段应该描述函数的行为。然后,可以各用一段来描述函数的参数与返回值。另外,如果调用者在使用这个函数接口的时候,需要处理该函数所抛出的一些异常,那么这些异常也要解释。 1 2 3 4 5 6 7 8 9 10 11 12 13 def find_anagrams(word, dictionary): """Find all anagrams for a word. This function only runs as fast as the test for membership in the 'dictionary' container. Args: words: String of the target word. dictionary: collections.abc.Container with all strings that are known to be actual words. Returns: List of anagrams that were found. Empty if none were found. """ 编写docstring的时候,需要注意下面几种特殊的情况: 如果函数没有参数,而且返回的是个比较简单的值,那么就不用按照上面讲的那种格式分段书写了。直接用一句话来描述整个函数可能会更好。 如果函数没有返回值,那么最好是把描述返回值的那段完全省去,而不要专门写出返回None。 如果函数所抛出的异常也是接口的一部分,那么应该在docstring里面详细解释每一种异常的含义,并说明函数在什么场合会抛出这样的异常。 如果函数在正常使用的过程中,不会抛出异常,那么无须专门指出这一点。 如果函数可以接受数量可变的位置参数或关键字参数,那么应该在解释参数的那一部分用*args与**kwargs来说明这两种参数的用途。 如果参数有默认值,那么文档里应该提到这些默认值。 如果函数是个生成器,那么应该在docstring里面写明这个生成器在迭代过程中会产生什么样的值。 如果函数是异步协程,那么应该在docstring里面解释这个协程执行到何时会暂停。 用类型注解来简化docstring Python现在已经支持类型注解了,这种注解有许多用途,其中一项就是简化docstring,因为原本写在docstring里面的某些含义,现在可以直接通过类型注解体现出来。下面,我们给find_anagrams函数的签名加上类型注解,以指出参数与返回值的类型。 1 2 3 4 5 from typing import Container, List def find_anagrams(word: str, dictionary: Container[str]) -> List[str]: # ... 有了这样的注解,我们就不用专门在docstring里面说明word参数是字符串了,因为现在的word参数后面有个冒号,冒号右侧标出了它的类型,即str。dictionary参数也一样,它的类型现在明确地标注成了collections.abc.Container。另外,返回值同样不用专门在docstring里面解释,因为声明函数时,已经在->符号右侧明确说明该函数会返回一份列表。这也意味着即便找不到与word参数的组成字母相同但排列顺序不同的词(或者说,找不到word参数的同字母异序词(anagram)),函数也依然要返回列表,当然这种情况下返回的列表是一份空白的列表,所以不用在docstring里面专门说明。 1 2 3 4 5 6 7 8 9 10 11 12 13 def find_anagrams(word: str, dictionary: Container[str]) -> List[str]: """Find all anagrams for word. This function only runs as fast as the test for membership in the 'dictionary' container. Args: word: Target word. dictionary: All known actual words. Returns: Anagrams that were found. """ # ... 类型注解与docstring之间的这种重复现象,也会出现在实例字段、类属性与方法中。这样的类型信息最好是只写在一个地方,而不要同时写在类型注解与docstring中,因为在修改实现代码时,可能会忘记更新其中的某一处。 第85条、用包来安排模块,以提供稳固的API 项目代码变多之后,我们自然需要重新调整它的结构。可能要把大函数拆成许多个小的函数,或者通过辅助类改写某个数据结构,还有可能要把各项功能分散到多个相互依赖的模块里面。 到了一定阶段,你就会发现模块也变得多了起来,所以还得再构建一层机制以便于管理。在Python中,这可以通过包(package)来实现,包本身也是一种模块,只不过它里面还含有其他模块。 大多数情况下,把名为init.py的空白文件放在某个目录中,即可令该目录成为一个包。一旦有了init.py文件,我们就可以使用相对于该目录的路径引入包中的其他py文件了。例如,现在目录结构是这样的: 1 2 3 4 main.py mypackage/__init__.py mypackage/models.py mypackage/utils.py 如果想在main.py里面引入utils模块,那么可以把包名(也就是mypackage)写在from后面,并把其中的模块名写在import后面。 1 2 # main.py from mypackage import utils 如果有些包本身位于更大的包之中,那么就把从大包到这个包之间的各层都用圆点连起来。例如,要引入bar包中的某个模块,而bar包又位于mypackage包的foo目录之下,那么就写为from mypackage.foo.bar import ...。包在Python程序里主要有两种用途。 用包划分名称空间 包的一个用途是帮助把模块安排到不同的名称空间(namespace)里面,这样的话,即便两个模块所在的文件同名,也依然能够加以区分,因为它们所在的名称空间不同。例如,下面这个程序要从两个模块里面分别引入一个属性(本例实际引入一个函数),虽然这两个模块所在的文件都叫utils.py,但我们还是可以通过模块所属的包来区分它们。 1 2 3 4 5 # main.py from analysis.utils import log_base2_bucket from frontend.utils import stringify bucket = stringify(log_base2_bucket(33)) 这种写法有个问题,如果两个包里有同名的模块,或者两个模块里有同名的函数或类,那么后引入的那个会把先引入的覆盖掉。例如,假设analysis.utils与frontend.utils模块里都有函数叫作inspect,那么就没办法通过刚才那种写法来同时使用这两个函数了,因为第二条import语句会把第一条所引入的那个inspect覆盖掉,导致当前范围内只存在一个inspect。 解决办法是给import语句加上as子句,这样就能把两个inspect函数分别用不同的名称引入到当前的范围里面。 1 2 from analysis.utils import inspect as analysis_inspect from frontend.utils import inspect as frontend_inspect as子句不仅可以给函数起别名,而且能把整个模块都换个名称,这让我们可以清晰地区分各种命名空间里面的同名实体。 另外一种办法是从能够区分它们的那一层开始书写访问名称。例如,可以把from... import ...形式的引入语句改成import ...形式,并且用analysis.utils.inspect与frontend. utils.inspect来区分这两个函数。 1 2 import analysis.utils import frontend.utils 这种写法完全不需要使用as子句,而且由于它采用的名字比较全,初次读到这段代码的读者也可以清楚地了解到这两个inspect函数分别是在哪个包的哪个模块里面定义的。 通过包来构建稳固的API 包的第二个用途是构建严谨而稳固的API,以便给外部的开发者使用。 如果这套API要提供给很多人使用,例如要做成开源的软件包,那么你可能想把它的功能稳定下来,以免新旧版本之间差得太大。为了做到这一点,必须隐藏软件包内部的代码结构,不要让外部的开发者依赖这套结构,只有这样,你才能重构并改善这些内部模块,而不必担心自己所做的修改会影响外部用户已经写好的那些代码。 Python允许我们通过all这个特殊的属性,决定模块或包里面有哪些内容应该当作API公布给外界。all的值是一份列表,用来描述可作为public API导出的所有内容名称。如果执行from foo import *这样的语句,那么只有all所列出名称的属性才会引入进来。若是foo里面没有all,那么就只会引入public属性。 谨慎地使用import形式的引入语句。from x import y这种形式的引入语句,是很清晰的,因为它明确指出了y来自x包或x模块。引入语句还有一种写法,就是带有通配符的from foo import 形式,这种引入语句也很有用,尤其是在交互式的Python界面之中。然而大家必须注意,这样写会造成两方面的困难: 用from … import 的形式引入,会让初次读到这段代码的读者弄不清某些名称究竟来自哪里。如果模块里有许多条这样的import 语句,那我们可能必须把受引用的模块全都查找一遍,才能确定某个名字到底是从哪个模块引入的。 import *形式的引入语句,会把已经引入本模块的同名实体覆盖掉,从而导致奇怪的bug,因为同一个名称,在执行这条语句之前,与执行这条语句之后,可能指向两个不同的地方。 最稳妥的做法是不要采用import *的形式引入,而是通过from x import y明确指出名称y来自模块x。 如果不想让外界看到某些内容,那么可以在包目录中的init.py文件里面故意不引入这些内容,或者给这些只供本包内部使用的内容名称前面添加下划线。 假如这个包只在某个团队或某个项目内部使用,那恐怕就没必要专门通过all来指定外界能够访问到的API了。 第86条、考虑用模块级别的代码配置不同的部署环境 部署环境指的是程序运行在什么样的配置之下。每个程序至少要有一套部署环境,也就是生产环境(production environment)。我们之所以写程序,就是想让它能够在生产环境里面正常运行并产生预期的结果。 另外,我们可能还需要构建一套环境,可以方便地编写或修改程序代码,这套环境叫作开发环境(development environment),它的配置方式可能与生产环境有很大区别。例如,我们的程序可能是在单片机中开发的,但是却打算放在巨型的超级计算机上面运行。 venv这样的工具可以确保所有的环境里面安装的都是同一套Python软件包(参见第83条)。但是问题在于,软件在生产环境中运行的时候,通常需要依赖许多外部条件,而那些条件不太容易在开发环境里面重现。 要想解决这个问题,最好的办法是让程序在启动时,能够根据当前环境决定其中某些资源应该如何配置。 1 2 3 4 5 TESTING = True import db_connection db = db_connection.Database() 这两份文件只有一个地方不同,也就是TESTING常量的取值。程序中的其他模块可以引入main模块,并根据TESTING的值决定如何配置自己的某些属性。 如果环境配置起来特别复杂,那就不要使用TESTING这样单纯的Python常量,而是可以考虑构建专门的配置文件,并通过Python内置的configparser模块等解析工具来处理这种文件,把它们与程序代码分开维护。在与运维团队合作的时候,这一点尤其重要。 模块级别的代码不仅可以模拟外部资源,而且还有其他用途。 我们还可以通过os.environ查询环境变量,从而决定模块中的相关内容应该如何定义。 第87条、为自编的模块定义根异常,让调用者能够专门处理与此API有关的异常 给模块定义API时,要定义的不仅是其中的函数与类,而且还必须注意这些函数会不会抛出异常,因为这些异常实际上也接口的一部分。 Python语言及标准库本身有自己的异常体系,所以在开发程序的时候,开发者喜欢沿用这些内置的异常类型来报告其中的错误,而不想定义新的类型。 给模块定义根异常,可以让使用这个模块的API用户将他们自己的代码与这个模块所提供的API隔开,以便分别处理其中的错误。 API用户在处理完API所属模块有可能抛出的具体异常后,可以写一个针对模块根异常的except块,如果程序进入这个块,那就说明他使用API的方式可能有问题,例如可能忘记处理某种本来应该处理的具体异常。 API用户还可以再写一个except块以捕获整个Python体系之中的根异常,如果程序进入了那个块,那说明所调用的API可能实现得有问题。在模块的根异常下,可以设立几个门类,让具体的异常不要直接继承总的根异常,而是继承各自门类中那个分根异常,这样的话,使用这个模块的开发者,就可以只关注这几个门类,即便你修改了某个门类之下的具体异常,也不会影响到他们已经写好的那些代码。 第88条、用适当的方式打破循环依赖关系 在引入模块的时候,Python系统会按照深度优先的顺序,对模块执行以下五步: 1)在sys.path里面寻找模块的位置。 2)把模块的代码加载进来,并确认这些代码能够编译。 3)创建相应的空白模块对象表示该模块。 4)把这个模块插入sys.modules字典。 5)运行模块对象之中的代码定义该模块的内容。 循环依赖之所以会出错,原因在于,执行完第4步之后,这个模块已经位于sys.modules之中了,然而它的内容这个时候可能还没有得到定义,要等到执行完第5步,才能齐备。可是,Python系统在执行import语句的时候,如果发现要引入的模块已经出现在了sys.modules里面(也就是说,那个模块已经执行完了前4步),那么就会继续执行import的下一条语句,而不会顾及模块之中的内容是否得到了定义。 要解决这种问题,最好的办法是重构代码,把prefs数据结构放在依赖体系的最底层,把它单独放在一个工具模块里面,app与dialog模块就可以分别引入这个工具模块,而不用像原来那样,彼此依赖对方。予以划分,有时必须重构大量的代码,才能解开两个模块之间的相互依赖关系,让它们都去依赖第三个模块。 除了这种解法之外,还有三个办法,也能够解除循环依赖关系。 第一个办法是,调整import语句的位置。调整import语句的位置,可能会让代码变得容易出错,因为有时只要稍微改动这条import语句的位置,整个模块就没办法使用了。 把模块划分成引入-配置-运行这样三个环节。循环引入问题的第二个解决办法是,尽量缩减引入时所要执行的操作。我们可以让模块只把函数、类与常量定义出来,而不真的去执行操作,这样的话,Python程序在引入本模块的时候,就不会由于操作其他模块而出错了。我们可以把本模块里面,需要用到其他模块的那种操作放在configure函数中,等到本模块彻底引入完毕后,再去调用。configure函数会访问其他模块中的相关属性,以便将本模块的状态配置好。这个函数是在该模块与它所要使用的那个模块都已经彻底引入后才调用的(也就是说,这两个模块都把各自的第5步执行完了),因此,其中涉及的所有属性全都定义过了。 动态引入。第三个办法比前两个都简单,也就是把import语句从模块级别下移到函数或方法里面,这样就可以解除循环依赖关系了。这种import语句并不会在程序启动并初始化本模块时执行,而是等到相关函数真正运行的时候才得以触发,因此又叫作动态引入(dynamic import)。 当然了,一般来说,还是应该尽量避免动态引入,因为import语句毕竟是有开销的,如果它出现在需要频繁执行的循环体里面,那么这种开销会更大。另外,由于动态引入会推迟代码的执行时机,有可能让你的程序在启动了很久之后,突然因为在动态引入其他模块的过程中发生SyntaxError等错误而崩溃(如何避免此类问题)。动态引入虽然有这些缺点,但总比那种大幅度修改整个程序结构的办法好。 第89条、重构时考虑通过warnings提醒开发者API已经发生变化 我们经常需要更新API,以实现早前没有预料到的新需求。如果API很小,而且与上游及下游之间的依赖关系也不复杂,那么修改起来就比较简单。在这种情况下,只需要某位开发者把API本身与调用这些API的代码都改好,一起提交到代码库即可。 代码库变大之后,调用这个API的地方也会变多,而且可能来自好几个项目,所以在修改API的时候,不太容易保证那些地方也能够同步更新。我们必须想办法通知写那些代码的合作者这个API的用法已经变了,让他们尽快重构代码以适配新版的API。 warnings模块让我们以编程的手段提醒其他开发者注意:代码所依赖的底层库已经发生变化,请尽快做出相应修改。warnings模块发出的是警告,而不是那种带有Error字样的异常(exception),异常主要针对计算机而言,目标是让程序能够自动处理相关的错误,而警告则是写给开发者的,目标是与他们沟通,告诉对方应该如何正确地使用这个API。 1 warning.warn('...') warnings.warn函数提供了一个名为stacklevel的参数,让我们可以根据栈的深度指出真正触发这条警告的那个位置,而不是调用warnings.warn函数的字面位置。 负责这段代码的开发者,如果已经意识到自己应该按照新的方式来使用你所提供的API,那么他就可以通过warnings模块的simplefilter与filterwarnings函数暂时忽略警告(详细的用法,参见https://docs.python.org/3/library/warnings)。 程序部署到生产环境之后,就没有必要让警告变成错误了,因为那样可能导致程序在关键时刻崩溃。比较好的办法是,通过Python内置的logging模块将警告信息重新定向到日志系统。 如果你设计的API会发出警告,那么应该为此编写测试,确保下游开发者在使用API的过程中,能够在适当的时机收到正确的警告信息。 第90条、考虑通过typing做静态分析,以消除bug 只有文档可能还不够,有时我们还是会把API用错,导致程序出现bug。所以,最好能有一套机制来验证调用者使用API的方式是否正确,如果我们把自己的API发布出去,那么这套机制还能帮助其他开发者检查他们的代码有没有恰当地使用这套API。许多编程语言通过编译期的类型检查来实现这种验证,这确实能够消除某些bug。 Python以前主要关注的是动态特性,所以没有提供编译期的类型安全机制。但是最近,Python开始引入一套特殊的写法,让我们可以通过内置的typing模块给变量、类中的字段、函数及方法添加类型信息。这些类型提示(type hint)信息可以实现渐进的类型判定机制(gradual typing),让我们在开发项目的过程中,把能够在编译期明确指定类型的地方逐渐确定下来。 给Python程序的代码添加类型信息之后,我们就可以运行静态分析(staticanalysis)工具,分析这些代码里面是否存在极有可能出现bug的地方。Python内置的typing模块本身并不实现类型检查功能,它只是一套可以公开使用的代码库,其中定义了相关的类型(也包括泛型类型),我们可以用这些类型来注解 Python代码,并利用其他工具根据这些类型判断受注解的代码有没有正确地得到使用。 Python解释器有许多种不同的实现方案,例如CPython、PyPy等,与之类似,与typing模块相搭配的Python静态分析工具,也有很多方案。笔者编写本书的时候,比较流行的是mypy(https://github.com/python/mypy)、pytype(https://github.com/google/pytype)、pyright(https://github.com/microsoft/pyright)与pyre(https://pyre-check.org)。 如使用mypy: 1 2 def substract(a: int, b: int) -> int: return a - b Python的动态机制有个好处,就是可以实现泛型,进而把需要操作的数据当成duck type使用。也就是说,只要用户给出支持这种操作的数据即可,不用管这份数据究竟是什么类型,这样我们就能够让函数接受各种各样的数据,而不用针对每一种数据都专门编写对应的版本,这可以避免重复代码并简化测试工作。 我们可以利用typing模块给函数所涉及的泛型做注解,从而通过静态手段把程序运行时可能发生的错误提前探查出来。 1 2 3 4 5 6 7 from typing import Optional def get_or_default(value: Optional[int]m default: int) -> int: if value is not None: return value return value typing模块还提供了许多选项,可以给代码做注解,详情参见https://docs.python.org/3.8/library/typing。然而特别需要注意的是,异常并不包括在内。Python与Java不同,Java里面有一种异常叫作受检异常(checkedexception),如果API宣称自己可能抛出这种异常,那么使用API的开发者必须明确做出应对。Python在这一方面更像C#,它们都不把异常当作接口定义中的一部分,因此,如果想确保自己的API能够正确地抛出异常,或确保自己在使用别人的API时能够正确地捕获异常,那么必须编写相关的测试。 使用typing模块的过程中,经常会碰到这样一个问题,那就是我们在编写类型注解的时候,需要用到当前还没有定义出来的类型,这叫作提前引用(forwardreference)。 还有一种更好的办法是,通过from __future__ import annotations来引入类型注解功能,这种办法是从Python 3.7版本开始支持的,到了Python 4,将会成为默认的方式。这样写,会让Python系统在运行程序的时候,完全忽略类型注解里面提到的值,于是就解决了提前引用的问题,而且程序在启动时的性能也会提升。 1 2 3 4 5 6 7 8 9 10 11 12 from __future__ import annotations class FirstClass: def __init__(self, value: SecondClass) -> None: self.value = value class SecondClass: def __init__(self, value: int) -> None: self.value = value second = SecondClass(5) first = FirstClass(second) 我们已经了解了类型提示信息的用法及其潜在好处,现在必须提醒大家注意,怎样合理地使用这些注解。下面是几条原则: 如果刚开始写代码的时候,就想着如何添加类型注解,那可能会拖慢编程速度。所以我们通常应该先把代码本身写出来,然后编写测试,最后才考虑在必要的地方添加类型信息。类型提示信息最能发挥作用的地方,是在项目与项目衔接处。例如,如果有很多个项目都要使用你的API,那么这种API就很有必要做类型注解,因为这些信息可以跟集成测试(参见第77条)与警告互补,以确保API的调用者不会发生误用,并督促调用者在你更新API之后,及时修改他们的代码中与新版API不符的地方。 如果有些代码比较复杂,或者特别容易出错,那么即便不属于API,也仍然值得添加类型提示信息。但是要注意,没必要给所有的代码都添上类型注解,因为到了一定程度之后,再添加这种信息,就不会给项目带来太大的好处了。 如果有可能的话,应该把静态分析这一环节纳入自动构建流程与测试系统中,以确保提交上去的每份代码都会经受相关的检查。另外,检查类型信息所用的配置方案,应该放在代码库里面维护,以保证其他的合作者使用的也是这套规则。 每添加一批类型注解,就应该把静态分析工具运行一遍,这样可以及时发现问题并加以解决。假如把整个项目全都注解完之后,再实施类型检查,那么类型分析工具就有可能打印出极多的错误信息,让你不知道应该先处理哪一条才好,有时甚至会让你想要放弃类型注解。 最后必须注意,还有许多场合是不需要写类型注解的,比如小型程序、临时代码、遗留项目以及原型等。没必要花时间给这些代码添加类型注解,因为这样做好处很少。

2024/6/2
articleCard.readMore

【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 52 ~ 74 条读书笔记

【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 52 ~ 74 条(并发、稳定性)读书笔记 (书基于 Python 3.7+ 语法规范) 知识点概括: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 subprocess模块 threading模块 Queue类 fan-in/fan-out模式 ThreadPoolExecutor类 协程(coroutine) asyncio库 multiprocessing模块 contextlib和with语句 datetime模块 pickle模块 copyreg模块 Decimal类 profile模块 timeit模块 bisect模块 heapq模块 memoryview类型/bytearray类型 7.并发与并行 并发(concurrency)指计算机似乎能在同一时刻做许多件不同的事情。 并行(parallelism)与并发的区别在于,它强调计算机确实能够在同一时刻做许多件不同的事情。 并行与并发之间的区别,关键在于能不能提速(speedup)。如果程序把总任务量分给两条独立的执行路径去同时处理,而且这样做确实能让总时间下降到原来的一半,那么这就是并行,此时的总速度是原来的两倍。反过来说,假如无法实现加速,那即便程序里有一千条独立的执行路径,也只能叫作并发,因为这些路径虽然看起来是在同时推进,但实际上却没有产生相应的提速效果。 Python让我们很容易就能写出各种风格的并发程序。在并发量较小的场合可以使用线程(thread),如果要运行大量的并发函数,那么可以使用协程(coroutine)。并行任务,可以通过系统调用、子进程与C语言扩展(C extension)来实现,但要写出真正能够并行的Python代码,其实是很困难的。 第52条、用subprocess管理子进程 Python有相当成熟的函数库可以运行并管理子进程,这让我们能够通过Python语言把其他一些工具(例如命令行工具)很好地拼接起来。Shell脚本经常容易越写越复杂,所以我们有时干脆就考虑改用Python来实现,这样更容易理解,也更容易维护。 由Python所启动的子进程可以平行地运行,这让我们能够充分利用计算机的每一个CPU核心,来尽量提升程序的处理效率。 Python里面有许多方式都可以运行子进程(例如os.popen函数以及os.exec*系列的函数),其中最好的办法是通过内置的subprocess模块来管理。 1 2 3 4 5 6 7 8 9 10 import subprocess result = subprocess.run( ['echo', 'Hello form the child'], capture_output=True, encoding='utf-8' ) result.check_returncode() print(result.stdout) 通过run函数启动一条进程,然后确认该进程已经正常终止,最后打印它的输出值。 (这一条里面的范例代码需要用到操作系统之中的echo、sleep与openssl命令,所以系统里面应该提前准备好这些命令。) 子进程可以独立于父进程而运行,这里的父进程指Python解释器所在的那条进程。假如刚才那条子进程不是通过run函数启动,而是由Popen类启动的,那么我们就可以在它启动之后,让Python程序去做别的任务,每做一段时间就来查询一次子进程的状态以决定要不要继续执行任务。 1 2 3 4 5 proc = subprocess.Popen(['sleep', '1']) while proc.poll() is None: print('Working...') # ... print('Exit status', proc.poll()) 把子进程从父进程中剥离,可以让程序平行地运行多条子进程。例如,我们可以像下面这样,先把需要运行的这些子进程用Popen启动起来。 1 2 3 4 5 6 7 import time start = time.time() sleep_procs = [] for _ in range(10): proc = subprocess.Popen(['sleep', '1']) sleep_procs.append(proc) 然后,在主进程里调用每条子进程的communicate方法,等待这条子进程把它的I/O工作处理完毕。 1 2 3 4 5 6 for proc in sleep_procs: proc.communicate() end = time.time() delta = end - start print(F'Finished in {delta: .3} seconds') 从统计结果可以看出,这10条子进程确实表现出了平行的效果。 我们还可以在Python程序里面把数据通过管道发送给子进程所运行的外部命令,然后将那条命令的输出结果获取到Python程序之中。而且,在执行外部命令的这个环节中,可以平行地运行多条命令。例如,要用oepnssl这样的命令行工具来加密数据。首先以适当的命令行参数构建一批子进程,并配置好相应的I/O管道,这在Python里很容易就能做到。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import os def run_encrypt(data): env = os.environ.copy() env['password'] = 'zfsgjjj' proc = subprocess.Popen( ['openssl', 'enc', '-des3', '-pass', 'env:password'], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE ) proc.stdin.write(data) proc.stdin.flush() return proc 调用: 1 2 3 4 5 procs = [] for _ in range(3): data = os.urandom(10) proc = run_encrypt(data) procs.append(proc) 这些子进程能够平行地运行,并各自处理它所接到的那份数据。现在逐个等待每一条子进程完工,然后获取并打印这条子进程的加密结果。 1 2 3 for proc in procs: out, _ = proc.communicate() print(out[-10:]) 这些平行运行的子进程还可以分别与另一套平行的子进程对接,形成许多条平行的管道(pipe)。这种管道与UNIX管道类似,能够把一条子进程的输出端同另一条子进程的输入端连接起来。 下面,我们写这样一个函数,让它开启一条子进程来运行openssl命令,这条命令会根据输入端所发来的数据在输出端生成Whirlpool哈希。 1 2 3 4 5 6 def run_hash(input_stdin): return subprocess.Popen( ['openssl', 'dgst', '-whirlpool', '-binary'], stdin=input_stdin, stdout=subprocess.PIPE ) 调用communicate方法时可以指定timeout参数,让我们有机会把陷入死锁或已经卡住的子进程关掉。 第53条、可以用线程执行阻塞式I/O,但不要用它做并行计算 Python语言的标准实现叫作CPython,它分两步来运行Python程序。首先解析源代码文本,并将其编译成字节码(bytecode)。字节码是一种底层代码,可以把程序表示成8位的指令(从Python 3.6开始,这种底层代码实际上已经变成16位了,所以应该叫作wordcode才对,但基本原理依然相同)。然后,CPython采用基于栈的解释器来运行字节码。这种字节码解释器在执行Python程序的过程中,必须确保相关的状态不受干扰,所以CPython会用一种叫作全局解释器锁(global interpreter lock,GIL)的机制来保证这一点。 GIL实际上就是一种互斥锁(mutual-exclusion lock,mutex),用来防止CPython的状态在抢占式的多线程环境(preemptive multithreading)之中受到干扰,因为在这种环境下,一条线程有可能突然打断另一条线程抢占程序的控制权。如果这种抢占行为来得不是时候,那么解释器的状态(例如为垃圾回收工作而设立的引用计数等)就会遭到破坏。所以,CPython要通过GIL阻止这样的动作,以确保它自身以及它的那些C扩展模块能够正确地执行每一条字节码指令。 但是,GIL会产生一个很不好的影响。在C++与Java这样的语言里面,如果程序之中有多个线程能够分头执行任务,那么就可以把CPU的各个核心充分地利用起来。尽管Python也支持多线程,但这些线程受GIL约束,所以每次或许只能有一条线程向前推进,而无法实现多头并进。所以,想通过多线程做并行计算或是给程序提速的开发者,恐怕要失望了。 多线程的程序在标准的CPython解释器之中会受GIL牵制(例如CPython要通过GIL防止这些线程争抢全局锁,而且要花一些时间来协调)。 若要CPython把多个核心充分利用起来,还是有一些办法的,但那些办法都不采用标准的Thread类,而且实现起来也需要大量的精力。既然有这么多限制,那Python还支持多线程干什么?这其实有两个原因。 首先,这种机制让我们很容易就能实现出一种效果,也就是令人感觉程序似乎能在同一时间做许多件事。这样的效果采用手工方式很难编写而通过线程来实现,则可以让Python自动替我们把这些问题处理好,让多项任务能够并发地执行。由于GIL机制,虽然每次还是只能有一个线程向前执行,但CPython会确保这些Python线程之间能够公平地轮换执行。 其次,我们可以通过Python的多线程机制处理阻塞式的I/O任务,因为线程在执行某些系统调用的过程中会发生阻塞,假如只支持一条线程,那么整个程序就会卡在这里不动。Python程序需要通过系统调用与外部环境交互,其中有一些调用属于阻塞式的I/O操作,例如读取文件、写入文件、联网以及与显示器等设备交互。多线程机制可以让程序中的其他线程继续执行各自的工作,只有发起调用请求的那条线程才需要卡在那里等待操作系统给出结果。 GIL只不过是让Python内部的代码无法平行推进而已,至于系统调用,则不会受到影响,因为Python线程在即将执行系统调用时,会释放GIL,待完成调用之后,才会重新获取它。 除了线程,还有很多办法也能处理阻塞式的I/O(例如采用内置的asyncio模块等)。那些办法都很好,但你可能得花时间去重构代码适应它们所要求的执行模式。与那些办法相比,用多线程处理阻塞式I/O是最简单的,而且只需要稍微调整代码就行。 第54条、利用Lock防止多个线程争用同一份数据 了解到全局解释器锁(GIL)的效果之后,许多Python新手可能觉得没必要继续在代码里使用互斥锁(mutual-exclusion lock,mutex)了。既然GIL让Python线程没办法平行地运行在多个CPU核心上,那是不是就意味着它同时还会自动保护程序里面的数据结构,让我们不需要再加锁了? 其实并非如此。GIL起不到这样的保护作用。虽说同一时刻只能有一条Python线程在运行,但这条线程所操纵的数据结构还是有可能遭到破坏,因为它在执行完当前这条字节码指令之后,可能会被Python系统切换走,等它稍后切换回来继续执行下一条字节码指令时,当前的数据或许已经与实际值脱节了,因为中途切换进来的其他线程可能更新过这个值。所以,多个线程同时访问同一个对象是很危险的。每条线程在操作这份数据时,都有可能遭到其他线程打扰,因此数据之中的固定关系或许已经被别的线程破坏了,这会令程序陷入混乱状态。 例如,要编写一个程序,让它平行地采集数据。如果要采集传感器网络中的每个传感器所给出的亮度,那么就需要用到这种程序。我们首先需要定义下面这样一个新类,用来记录采集到的样本总数。 1 2 3 4 5 6 class Counter: def __init__(self): self.count = 0 def increment(self, offset): self.count += offset 然后,假设获取传感器读数的操作是一种阻塞式的I/O操作,这样的话,我们就需要针对每个传感器都开启一条工作线程专门读取它所负责的这个传感器。每采集到一份样本,线程就会给表示样本总数的那个量加1,直到采集完应采集样本为止。 1 2 3 4 def worker(sensor_index, how_many, counter): for _ in range(how_many): # ... counter.increment(1) 现在,给每个传感器建立各自的工作线程,让这些线程平行地采样,最后等待所有线程完成各自采样工作。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from threading import Thread how_many = 10 ** 5 counter = Counter() threads = [] for i in range(5): thread = Thread(target=worker,args=(i, how_many, counter)) threads.append(thread) thread.start() for thread in threads: thread.join() expected = how_many * 5 found = counter.count print(f'Counter should be {expected}, got {found}') 这个程序似乎相当简单,所以运行结果应该是500000才对。但实际上却差得很远,这是为什么?其实,Python解释器需要保证这些线程可以公平地获得执行机会,或者说,保证每条线程所分配到的执行时间大致相等。为了实现这种效果,它会及时暂停某条线程,并且把另一条线程切换过来执行。然而问题是,我们并不清楚它具体会在什么时候暂停线程,万一这条线程正在执行的是一项本来不应该中断的原子操作(atomic operation),那会如何呢?上面的例子遇到的正是这种情况。Counter对象的increment方法看上去很简单,工作线程在调用这个方法时,相当于是在执行下面这样一条语句: 1 counter.count += 1 然而,在对象的属性上面执行+=操作,实际上需要分成三个小的步骤。也就是说,Python系统会这样看待这次操作: 1 2 3 value = getattr(counter, 'count') result = value + 1 setattr(counter, 'count', result) 这三个步骤本来应该一次执行完才对,但是Python系统有可能在任意两步之间,把当前这条线程切换走,这就导致这条线程在切换回来后,看到的是个已经过时的value值,它把这个过时的值通过setattr赋给Counter对象的count属性,从而使统计出来的样本总数偏小。 线程A在执行了第一步之后,还没来得及执行第二步,就被线程B打断了。等到线程B把它的三个步骤执行完毕后,线程A才重新获得执行机会。这时,它并不知道count已经被线程B更新过了,它仍然以为自己在第一步里读取到的那个value_a是正确的,于是线程A就给value_a加1并将结果(也就是result_a)赋给count属性。这实际上把线程B刚刚执行的那一次递增操作覆盖掉了。上面的传感器采样总数之所以出错,也正是这个原因所致。 除了这个例子,其他形式的数据结构也会遇到类似问题。为了避免数据争用,Python在内置的threading模块里提供了一套健壮的工具。其中最简单也最有用的是一个叫作Lock的类,它相当于互斥锁(mutex)。 通过这样的锁,我们可以确保多条线程有秩序地访问Counter类的count属性,使得该属性不会遭到破坏,因为线程必须先获取到这把锁,然后才能操纵count,而每次最多只能有一条线程获得该锁。 1 2 3 4 5 6 7 8 9 10 from threading import Lock class LockingCounter: def __init__(self): self.lock = Lock() self.count = 0 def increment(self, offset): with self.lock: self.count += offset 现在,就可以确保这些工作线程能够正确地递增count属性了。只不过这次的Counter对象要改用刚才写的LockingCounter类来制作。 1 2 3 4 5 6 7 8 9 10 11 12 13 counter = LockingCounter() for i in range(5): thread Thread(target=worker, args=(i, how_many, counter)) threads.append(thread) thread.start() for thread in threads: thread.join() expected = how_many * 5 found = counter.count print(f'Counter should be {expected}, got {found}') 这样的结果才正是我们想要看到的。这说明Lock确实能够解决数据争用问题。 第55条、用Queue来协调各线程之间的工作进度 Python程序如果要同时执行多项任务,而这些任务又分别针对同一种产品的不同环节,那么就有可能得在它们之间进行协调。比较有用的一种协调方式是把函数拼接成管道。 这样的管道与生产线比较像。它可以按先后顺序划分成几个阶段,每个阶段都由相应的函数负责。程序会把未经加工的原料放在生产线(也就是管道)的起点,而那些函数,则分别关注着自己所负责的这一段,只要有产品来到当前环节,它就对这件产品做出相应的加工处理。如果所有函数都不会再收到有待加工的产品,那么整条生产线就可以关停。这套方案,很适合应对与阻塞式I/O或子进程有关的需求,因为我们很容易就能在Python程序里,平行地开启多个线程来分别负责生产线中的某个环节。 例如,要构建这样一套系统,让它持续从数码相机里获取照片,然后调整照片尺寸,最后把调整好的照片添加到网络相册之中。 假如这三个环节所对应的download、resize与upload函数,现在都已经写好了,那么应该如何拼接成一条管道呢? 1 2 3 4 5 6 7 8 def download(item): #... def resize(item): #... def upload(item): #... 首先,必须想办法表示每个环节所要加工的产品,并让加工好的产品能够为下一个环节所获取。这可以用线程安全的生产-消费队列(producer-consumer queue,也叫生产者-消费队列)来实现。 1 2 3 4 5 6 7 from collections import deque from threading import Lock class MyQueue: def __init__(self): self.items = deque() self.lock = Lock() 首先定义put方法,让生产者(也就是数码相机)可以通过这个方法把新图像添加到deque的尾部: 1 2 3 def put(self, item): with self.lock: self.items.append(item) 然后定义下面这个方法。第一阶段的消费者,也就是需要下载照片的那个函数,可以通过这个方法从deque的前端(即左侧)获取元素。 1 2 3 def get(self): with self.lock: return self.items.popleft() 我们把管道的每个阶段都表示成一条Python线程,它会从刚才那样的队列中取出有待处理的产品,并交给对应的函数去处理,接着再把处理结果放到下一个队列之中。另外,我们再添加两个字段,分别记录这条线程向上游队列查询产品的次数以及完成加工的次数。 1 2 3 4 5 6 7 8 9 10 11 from threading import Thread import time class Worker(Thread): def __init__(self, func, in_queue, out_queue): super().__init__() self.func = func self.in_queue = in_queue self.out_queue = out_queue self.polled_count = 0 self.work_done = 0 下面我们试着通过捕捉IndexError来处理这种上游发生延迟的情况。 1 2 3 4 5 6 7 8 9 10 11 def run(self): while True: self.polled_count += 1 try: item = self.in_queue.get() except IndexError: time.sleep(0.01) else: result = self.func(item) self.out_queue.put(result) self.work_done += 1 现在,创建四个队列,并在它们之间安排三条工作线程,让每条线程都从上游队列里面获取元素,并把加工过的元素放到下游队列之中。 1 2 3 4 5 6 7 8 9 download_queue = MyQueue() resize_queue = MyQueue() upload_queue = MyQueue() done_queue = MyQueue() threads = [ Worker(download, download_queue, resize_queue), Worker(resize, resize_queue, upload_queue), Worker(upload, upload_queue, done_queue) ] 这种实现方式的问题远不止这一个,还有另外三个问题也必须重视。第一,为了判断全部产品是否加工完毕,必须像Worker线程里的run方法那样,反复查询最后那个队列,以确认里面的元素个数是否已经变得与刚开始的原料总数相同。第二,目前这种方案会使run方法陷入无限循环,我们没办法明确通知线程何时应该退出。第三,如果下游环节的处理速度过慢,那么程序随时都有可能崩溃,这是最严重的问题。例如,如果第一个环节处理得很快,而第二个环节处理得比较慢,那么连接这两个环节的那个队列就会迅速膨胀,因为它里面堆积了大量的产品等着第二个环节来加工,可是第二个环节又跟不上节奏。时间久了,数据会越积越多,导致程序因为耗尽内存而崩溃。 总之,这种需求不适合用管道来实现,因为很难构建出良好的生产-消费队列。 改用Queue来实现:内置的queue模块里有个Queue类 改用Queue之后,就不用再频繁查询是否有新产品要加工了,因为它的get方法会一直阻塞在那里,直至有新数据返回为止。 1 2 3 4 5 6 7 8 9 10 11 from queue import Queue my_queue = Queue() def consumer(): print('Consumer waiting') my_queue.get() print('Consumer done') thread = Thread(target=consumer) thread.start() 即便这个线程先启动,也没有关系,因为只有当生产线程通过Queue实例的put方法给队列里面填入新数据之后,刚才那个get方法才有数据可以返回。 1 2 3 4 print('Producer putting') my_queue.put(object()) print('Producer done') thread.join() 为了解决因下游环节速度过慢而造成的管道拥堵问题,我们可以限定Queue最多只能堆积多少个元素。如果通过put方法给已经填满的队列添加新元素,那么这个方法就会阻塞,直到队列里有空位为止。下面我们创建最多只能保存一个元素的队列,并且定义这样一条消费线程,让它先等待一段时间,然后再从队列中获取元素,这样就促使生产线程没办法立刻给队列中添加新元素。 1 2 3 4 5 6 7 8 9 10 11 12 my_queue = Queue(1) def consumer(): time.sleep(0.1) my_queue.get() print('Consumer got 1') my_queue.get() print('Consumer got 2') print('Consumer done') thread = Thread(target=consumer) thread.start() 编写time.sleep(0.1)这条语句,是想故意让消费线程慢下来,以免生产线程在第二次调用put方法时,它第一次放进去的那个元素还没来得及被消费线程提取走。Queue的容量为1,所以生产线程会阻塞在第二个put方法这里,它必须等消费线程通过get方法把队列中的那个元素取走,才能继续往里面添加新元素。 管道非常适合用来安排多阶段的任务,让我们能够把每一阶段都交给各自的线程去执行,这尤其适合用在I/O密集型的程序里面。 构造这种并发的管道时,有很多问题需要注意,例如怎样防止线程频繁地查询队列状态,怎样通知线程尽快结束操作,以及怎样防止管道出现拥堵等。 我们可以利用Queue类所具有的功能来构造健壮的管道系统,因为这个类提供了阻塞式的入队(put)与出队(get)操作,而且可以限定缓冲区的大小,还能够通过task_done与join来确保所有元素都已处理完毕。 第56条、学会判断什么场合必须做并发 程序范围变大、需求变复杂之后,经常要用多条路径平行地处理任务。 fan-out与fan-in是最常见的两种并发协调(concurrency coordination)模式,前者用来生成一批新的并发单元,后者用来等待现有的并发单元全部完工。 Python提供了很多种实现fan-out与fan-in的方案。 第57条、不要在每次fan-out时都新建一批Thread实例 想在Python里平行地做I/O,首先要考虑的工具当然是线程。但如果真用线程来表示fan-out模式中的执行路径,你就会发现,这样其实有很多问题。 每次都手工创建一批线程,是有很多缺点的,例如:创建并运行大量线程时的开销比较大,每条线程的内存占用量比较多,而且还必须采用Lock等机制来协调这些线程。 线程本身并不会把执行过程中遇到的异常抛给启动线程或者等待该线程完工的那个人,所以这种异常很难调试。 第58条、学会正确地重构代码,以便用Queue做并发 每次都手工创建一批线程并平行地执行I/O任务是有很多缺点的。另一种方案,也就是用内置的queue模块里的Queue类实现多线程管道 这种方案的总思路是:在推进生命游戏时,不像原来那样,每推进一代,就新建一批线程来推进相应的单元格,而是可以提前创建数量固定的一组工作线程,令这组线程平行地处理当前这批I/O任务,并在处理完之后,继续等待下一批任务,这样就不会消耗那么多资源了,程序也不会再因为频繁新建线程而耽误那么多时间。 把队列(Queue)与一定数量的工作线程搭配起来,可以高效地实现fan-out(分派)与fan-in(归集)。 为了改用队列方案来处理I/O,我们重构了许多代码,如果管道要分成好几个环节,那么要修改的地方会更多。 利用队列并行地处理I/O任务,其处理I/O任务量有限,我们可以考虑用Python内置的某些功能与模块打造更好的方案。 第59条、如果必须用线程做并发,那就考虑通过ThreadPoolExecutor实现 Python有个内置模块叫作concurrent.futures,它提供了ThreadPoolExecutor类。这个类结合了线程(Thread)方案与队列(Queue)方案的优势,可以用来平行地处理生命游戏里的那种I/O操作。 利用ThreadPoolExecutor,我们只需要稍微调整一下代码,就能够并行地执行简单的I/O操作,这种方案省去了每次fan-out(分派)任务时启动线程的那些开销。 1 2 3 4 5 6 7 def game_logic(state, neighbors): # ... raise OSError('Problem with I/O') # ... with ThreadPoolExecutor(max_workers=10) as pool: task = pool.submit(game_logic, ALIVE, 3) task.result() ThreadPoolExecutor方案仍然有个很大的缺点,就是I/O并行能力不高,即便把max_workers设成100,也无法高效地应对那种有一万多个单元格,且每个单元格都要同时做I/O的情况。如果你面对的需求,没办法用异步方案解决,而是必须执行完才能往后走(例如文件I/O),那么ThreadPoolExecutor是个不错的选择。然而在许多情况下,其实还有并行能力更强的办法可以考虑。 第60条、用协程实现高并发的I/O 如果同时需要执行的I/O任务有成千上万个,那么之前这些方案的效率就不太理想了。 像这种在并发方面要求比较高的I/O需求,可以用Python的协程(coroutine)来解决。协程能够制造出一种效果,让我们觉得Python程序好像真的可以同时执行大量任务。这种效果需要使用async与await关键字来实现,它的基本原理与生成器(generator)类似,也就是不立刻给出所有的结果,而是等需要用到的时候再一项一项地获取。 启动协程是有代价的,就是必须做一次函数调用。协程激活之后,只占用不到1KB内存,所以只要内存足够,协程稍微多一些也没关系。与线程类似,协程所要执行的任务也是用一个函数来表示的,在执行这个函数的过程中,协程可以从执行环境里面获取输入值,并把输出结果放到这个执行环境之中。协程与线程的区别在于,它不会把这个函数从头到尾执行完,而是每遇到一个await表达式,就暂停一次,下次继续执行的时候,它会先等待await所针对的那项awaitable操作有了结果(那项操作是用async函数表示的),然后再推进到下一个await表达式那里(这跟生成器函数的运作方式有点像,那种函数也是一遇到yield就暂停)。 Python系统可以让数量极多的async函数各自向前推进,看起来像很多条Python线程那样,能够并发地运行。然而,这些协程并不会像线程那样占用大量内存,启动和切换的开销也比较小,而且不需要用复杂的代码来实现加锁或同步。这种强大的机制是通过事件循环(event loop)打造的,只要把相关的函数写对,这种循环就可以穿插着执行许多个这样的函数,并且执行得相当快,从而高效地完成并发式的I/O任务。 1 2 3 4 5 6 7 8 9 10 11 12 ALIVE = '*' EMPTY = '-' class Grid: #... def count_neighbors(y, x, get): #... async def game_logic(state, neighbors): # ... data = await my_socket.read(500) 加上async,表示该函数是一个协程,这样我们就可以在函数里面用await做I/O了。 协程是采用async关键字所定义的函数。如果你想执行这个协程,但并不要求立刻就获得执行结果,而是稍后再来获取,那么可以通过await关键字表达这个意思。协程能够制造出这样一种效果,让人以为程序里有成千上万个函数都在同一时刻高效地运行着。协程可以用fan-out(分派)与fan-in(归集)模式实现并行的I/O操作,而且能够克服用线程做I/O时的缺陷。 协程的优点是,能够把那些与外部环境交互的代码(例如I/O调用)与那些实现自身需求的代码(例如事件循环)解耦。这让我们可以把重点放在实现需求所用的逻辑上面,而不用专门花时间去写一些代码来确保这些需求能够并发地执行。 第61条、学会用asyncio改写那些通过线程实现的I/O Python已经将异步执行功能很好地集成到语言里面了,所以我们很容易就能把采用线程实现的阻塞式I/O操作转化为采用协程实现的异步I/O操作。 asyncio库的文档(https://docs.python.org/3/library/asyncio.html) Python提供了异步版本的for循环、with语句、生成器与推导机制,而且还有很多辅助的库函数,让我们能够顺利地迁移到协程方案。我们很容易就能利用内置的asyncio模块来改写代码,让程序不要再通过线程执行阻塞式的I/O,而是改用协程来执行异步I/O。 第62条、结合线程与协程,将代码顺利迁移到asyncio 如果项目比较大,那通常需要一点一点地迁移,也就是要边改边测,确保迁移过去的这一部分代码的效果跟原来相同。 为了能够分步骤地迁移,必须让采用线程做阻塞式I/O的那些代码能够与采用协程做异步I/O的代码相互兼容。具体来说,这要求我们既能够在线程里面执行协程,又能够在协程里面启动线程并等待运行结果。好在asyncio模块已经内置了相关的机制,让线程与协程可以顺利地操作对方。 asyncio模块的事件循环提供了一个返回awaitable对象的run_in_executor方法,它能够使协程把同步函数放在线程池执行器(ThreadPoolExecutor)里面执行,让我们可以顺利地将采用线程方案所实现的项目,从上至下地迁移到asyncio方案。 asyncio模块的事件循环还提供了一个可以在同步代码里面调用的run_until_complete方法,用来运行协程并等待其结束。它的功能跟asyncio.run_coroutine_threadsafe类似,只是后者面对的是跨线程的场合,而前者是为同一个线程设计的。这些都有助于将采用线程方案所实现的项目从下至上地迁移到asyncio方案。 只要输入文件的句柄处于开启状态,相应的工作线程就不会退出。反过来说,这条线程要是退出了,那就意味着有人把那份文件的句柄关了。于是,只需要等待所有的线程都完工,就可以确定这些文件的句柄已经全部关闭。 我们在判断是否需要移植时,应该考虑到,这样做会不会让代码变得难懂,会不会降低程序的效率。有的时候,所有代码都应该迁移到asyncio,但另一些场合则没必要这么做。 上面讲的是从上往下迁移,我们现在反过来,看看如何从下往上迁移。这也可以分成四步,但方向相反。原来是从顶层函数入手,沿着调用栈向下走,现在是从末端函数(也就是叶节点)入手,沿着调用栈向上走,一直走到整个调用体系的顶层,也就是入口点所在的那一层。具体步骤为: 1)为要移植的每个末端函数(leaf function)都创建一个对应的异步协程版本。 2)修改现有的同步版本函数,把它原来执行的那些实际操作全都拿掉,让它只通过事件循环去调用刚写的那个异步版本函数。 3)沿着调用栈向上移动一层,针对这一层里的相关函数制作对应的异步版本,并让那些异步版本函数去调用第1步里创建的相应协程。 4)把第2步里纯粹为了封装协程而设的同步版本删掉,因为上一层现在调用的是下一层里的异步版本函数,这些同步版本现在已经用不到了。 第63条、让asyncio的事件循环保持畅通,以便进一步提升程序的响应能力 把系统调用(包括阻塞式的I/O以及启动线程等操作)放在协程里面执行,会降低程序的响应能力,增加延迟感。 调用asyncio.run函数时,把debug参数设为True,可以帮助我们发现这种问题。 如: 1 2 3 4 5 6 import time async def slow_coroutine(): time.sleep(0.5) asyncio.run(slow_coroutine(), debug=True) 第64条、考虑用concurrent.futures实现真正的并行计算 有些Python程序写到一定阶段,性能就再也上不去了。即便优化了代码,程序的执行速度可能还是达不到要求。考虑到现在的计算机所装配的CPU核心数量越来越多,所以我们很自然地就想到用并行方式来解决这个问题。那么接下来就必须思考,如何将代码所要执行的计算任务划分成多个独立的部分并在各自的核心上面平行地运行。 Python的全局解释器锁(global interpreter lock,GIL)导致我们没办法用线程来实现真正的并行,所以先把这种方案排除掉。另一种常见的方案,是把那些对性能要求比较高的(performance-critical)代码用C语言重写成扩展模块。C语言比Python更接近底层硬件,因此运行速度要比Python快,这样的话,有些任务可能根本就不需要做并行,而是单单用C语言重写一遍就好。另外,C扩展还可以启动原生线程(native thread),这种线程不受Python解释器制约,也不必考虑GIL的问题,它们能够平行地运行,从而发挥出多核CPU的优势。Python里面针对C扩展而设计的那些API,有详细的文档可以参考,所以这是个很好的备选方案。大家在开发扩展模块的时候,还可以借助SWIG(https://github.com/swig/swig)与CLIF(https://github.com/google/clif)等工具。 然而,用C语言重写Python代码,代价是比较高的。因为有些代码在Python之中很简洁,但是改写成C代码之后,就变得特别难懂、特别复杂了。在移植过程中,我们还必须做大量的测试,以确保移植过去的那些代码跟原来的Python代码效果相同,并且不会引入bug。有的时候,这些工作确实很有意义,所以Python行业里面出现了大量的C扩展模块,用来迅速执行各种任务,例如文本解析、图像合成、矩阵运算等。另外还有Cython(https://cython.org/)与Numba(https://numba.pydata.org/)这样的开源工具帮我们顺利地向C语言移植。 问题是,在大多数情况下,我们不能只把整个程序里的一小段代码移植到C语言,因为程序的性能之所以缓慢,通常是由多个因素造成的,而不是说只要消除了其中某一个主要因素,整个程序的性能就会大幅提升。要想把C语言在底层硬件与线程方面的优势发挥出来,必须把程序里的许多代码都迁移过去,这会让测试量激增,而且容易引入新的bug。所以,还是得想想有没有什么好办法,能够在Python语言自身的范围内,解决这种复杂的并行计算问题。 Python内置的multiprocessing模块提供了多进程机制,这种机制很容易通过内置的concurrent.futures模块来使用,这可能就是我们要找的理想方案(相关范例参见第59条)。这种方案可以启动许多条子进程(child process),这些进程是独立于主解释器的,它们有各自的解释器与相应的全局解释器锁,因此这些子进程可以平行地运行在CPU的各个核心上面。每条子进程都能够充分利用它所在的这个核心来执行运算。这些子进程都有指向主进程的链接,用来接收所要执行的计算任务并返回结果。 如计算最大公约数: 1 2 3 4 5 6 7 def gcd(pair): a, b = pair low = min(a, b) for i in range(low, 0, -1): if a % 1 == 0 and b % i == 0: return i assert False, 'Not reachable' 如果把有待求解最大公约数的那些元组按照先后顺序交给这个函数去执行,那么程序花费的总时间就会随着元组的数量呈正比例上升,因为我们根本就没有做平行计算。 直接把这种代码分给多条Python线程去执行,是不会让程序提速的,因为它们全都受制于同一个Python全局解释器锁(GIL),无法真正平行地运行在各自的CPU核心上面。 使用concurrent.futures模块里面的ThreadPoolExecutor类,并允许它最多可以启用两条工作线程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import my_module from concurrent.futures import ThreadPoolExecutor import time NUMBERS = [ # ... ] def main(): start = time.time() pool = ThreadPoolExecutor(max_workers = 2) results = list(pool.map(my_module.gcd, NUMBERs)) end = time.time() delta = end - start print(f'Took {delta:.3f} seconds') if __name__ == '__main__': main() 由于要启动线程池并和它通信,这种写法比单线程版本还慢。 但是请注意,只需要变动一行代码就能让程序出现奇效,也就是把ThreadPoolExecutor改成concurrent.futures模块里的ProcessPoolExecutor。这样一改,程序立刻就快了起来。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import my_module from concurrent.futures import ProcessPoolExecutor import time NUMBERS = [ # ... ] def main(): start = time.time() pool = ThreadPoolExecutor(max_workers = 2) results = list(pool.map(my_module.gcd, NUMBERs)) end = time.time() delta = end - start print(f'Took {delta:.3f} seconds') if __name__ == '__main__': main() 这是为什么呢?因为ProcessPool-Executor类会执行下面这一系列的步骤(当然,这实际上是由multiprocessing模块里的底层机制所推动的)。 1)从包含输入数据的NUMBERS列表里把每个元素取出来,以便交给map。 2)用pickle模块对每个元素做序列化处理,把它转成二进制形式(参见第68条)。 3)将序列化之后的数据,从主解释器所在的进程经由本地socket复制到子解释器所在的进程。 4)在子进程里面,用pickle模块对数据做反序列化处理,把它还原成Python对象。 5)引入包含gcd函数的那个Python模块。 6)把刚才还原出来的那个对象交给gcd函数去处理,此时,其他子进程也可以把它们各自的那份数据交给它们各自的gcd函数执行。 7)对执行结果做序列化处理,把它转化成二进制形式。 8)将二进制数据通过socket复制到上级进程。 9)在上级进程里面对二进制数据做反序列化处理,把它还原成Python对象。 10)把每条子进程所给出的结果都还原好,最后合并到一个list里面返回。 从开发者这边来看,这个过程似乎很简单,但实际上,multiprocessing模块与Proce-ssPoolExecutor类要做大量的工作才能实现出这样的并行效果。同样的效果,假如改用其他语言来做,那基本上只需要用一把锁或一项原子操作就能很好地协调多个线程,从而实现并行。但这在Python里面不行,所以我们才考虑通过ProcessPoolExecutor来实现。然而这样做的开销很大,因为它必须在上级进程与子进程之间做全套的序列化与反序列化处理。 这个方案对那种孤立的而且数据利用度较高的任务来说,比较合适。所谓孤立(isolated),这里指每一部分任务都不需要跟程序里的其他部分共用状态信息。所谓数据利用度较高(high-leverage),这里指任务所使用的原始材料以及最终所给出的结果数据量都很小,因此上级进程与子进程之间只需要互传很少的信息就行,然而在把原始材料加工成最终产品的过程中,却需要做大量运算。刚才那个求最大公约数的任务就属于这样的例子,当然还有很多涉及其他数学算法的任务,也是如此。 如果你面对的计算任务不具备刚才那两项特征,那么使用ProcessPoolExecutor所引发的开销可能就会盖过因为并行而带来的好处。在这种情况下,我们可以考虑直接使用multiprocessing所提供的一些其他高级功能,例如共享内存(shared memory)、跨进程的锁(cross-process lock)、队列(queue)以及代理(proxy)等。但是,这些功能都相当复杂,即便两个Python线程之间所要共享的进程只有一条,也是要花很大工夫才能在内存空间里面将这些工具安排到位。假如需要共享的进程有很多条,而且还涉及socket,那么这种代码理解起来会更加困难。 只有在其他方案全都无效的情况下,才可以考虑直接使用multiprocessing里面的高级功能(那些功能用起来相当复杂)。不要刚一上来,就立刻使用跟multiprocessing这个内置模块有关的机制,而是可以先试着用ThreadPoolExecutor来运行这种孤立且数据利用度较高的任务。把这套方案实现出来之后,再考虑向ProcessPoolExecutor方案迁移。如果ProcessPoolExecutor方案也无法满足要求,而且其他办法也全都试遍了,那么最后可以考虑直接使用multiprocessing模块里的高级功能来编写代码。 8.稳定与性能 写了一个有用的Python程序之后,接下来就该考虑怎样让代码变得健壮(robust)起来,只有这样,才能将这个程序变为正式的产品(productionize)。把程序的功能写对,当然是很重要的,然而我们还得考虑怎样让程序在面对意外情况时,依然能够可靠地运作。Python有很多内置的特性与模块,可以帮我们加固程序代码,让它应付各种各样的状况。 说到健壮,其中一项指标在于能不能高效地应对大规模的数据。我们经常发现自己写的Python程序在处理少量数据时没有问题,但数据量一多,速度就下降,这可能是因为自己的算法过于复杂,或者计算的时候还有其他一些开销。不过没关系,Python提供了很多算法和数据结构,可以让我们轻松地写出高性能的程序。 第65条、合理利用try/except/else/finally结构中的每个代码块 在Python代码中处理异常,需要考虑四个情况,这正好对应try/except/else/finally这个结构中的四个代码块。这种复合语句的每块代码都有各自的用途,你可以全写,也可以只写其中几个 try/finally形式 如果我们想确保,无论某段代码有没有出现异常,与它配套的清理代码都必须得到执行,同时还想在出现异常的时候,把这个异常向上传播,那么可以将这两段代码分别放在try/finally结构的两个代码块里面。最常见的例子是确保文件句柄能够关闭 1 2 3 4 5 6 7 8 9 def try_finally_example(filename): print('* Opening file') handle = open(filename, encoding='utf-8') try: print('* Reading data') return handle.read() finally: print('* Calling close()') handle.close() 如果read方法抛出异常,那么这个异常肯定会向上传播给调用try_finally_example函数的那段代码,然而在传播之前,系统会记得运行finally块中的代码,使文件句柄(handle)能够关闭(close)。 try/except/else形式 如果你想在某段代码发生特定类型的异常时,把这种异常向上传播,同时又要在代码没有发生异常的情况下,执行另一段代码,那么可以使用try/except/else结构表达这个意思。如果try块代码没有发生异常,那么else块就会运行。try里面应该尽量少写一些代码,这样阅读起来比较清晰,而且即便出现异常,我们也能很快找到它是由哪一行代码引发的。 1 2 3 4 5 6 7 8 9 10 11 12 import json def load_json_key(data, key): try: print('* Loading JSON data') result_dict = json.loads(data) except ValueError as e: print('* Handling ValueError') raise KeyError(key) from e else: print('* Looking up key') return result_dict[key] 完整的try/except/else/finally形式 如果这四个代码块的功能全都要用到,那么可以编写完整的try/except/else/finally结构。 第66条、考虑用contextlib和with语句来改写可复用的try/finally代码 Python里的with语句可以用来强调某段代码需要在特殊情境之中执行。例如,如果必须先持有互斥锁,然后才能运行某段代码,那么就可以用with语句来表达这个意思(此时,所谓在特殊情境之中执行,指的就是在持有互斥锁的情况下执行)。 1 2 3 4 5 from threading import Lock lock = Lock() with lock: #... 这样写,其实跟相应的try/finally结构是一个意思,这是因为Lock类做了专门的设计,它结合with结构使用,表达的也是这个意思。 跟try/finally结构相比,with语句的好处在于,写起来比较方便,我们不用在每次执行这段代码前,都通过lock.acquire()加锁,而且也不用总是提醒自己要在finally块里通过lock.release()解锁。 如果想让其他的对象跟函数,也能像Lock这样用在with语句里面,那么可以通过内置的contextlib模块来实现。这个模块提供了contextmanager修饰器,它可以使没有经过特别处理的普通函数也能受到with语句支持。这要比标准做法简单得多,因为那种做法必须定义新类并实现名为enter与exit的特殊方法。 例如,有些情况下,我们想在执行某个函数的时候,看到更为详细的调试信息。下面这个函数会打印出两种级别的日志信息,一种是DEBUG级别,一种是ERROR级别。 1 2 3 4 5 6 import logging def my_function(): logging.debug('Some debug data') logging.error('Error log here') logging.debug('More debug data') 这个程序默认的日志级别(log level)是WARNING,所以,它只会把级别大于或等于WARNING的消息打印到屏幕上。DEBUG的级别低于WARNING,因此这种级别的消息是看不到的。 要想临时改变日志级别,可以定义情境管理器(context manager)。用@contextmanager来修饰下面这个辅助函数,就能定义出这样一种管理器。这种管理器可以用在with语句里面,让日志记录器(Logger)在进入这个范围之后,临时改变自己的日志级别,这样的话,原来那些低级别的消息,现在就可以显示出来了,待with语句块执行完毕后,再恢复原有日志级别。 1 2 3 4 5 6 7 8 9 10 11 from contextlib import contextmanager @contextmanager def debug_logging(level): logger = logging.getLogger() old_level = logger.getEffectiveLevel() logger.setLevel(level) try: yield finally: logger.setLevel(old_level) 系统开始执行with语句时,会先把@contextmanager所修饰的debug_logging辅助函数推进到yield表达式所在的地方,然后开始执行with结构的主体部分。如果执行with语句块(也就是主体部分)的过程中发生异常,那么这个异常会重新抛出到yield表达式所在的那一行里,从而为辅助函数中的try结构所捕获。 Python内置的contextlib模块提供了contextmanager修饰器,让我们可以很方便地修饰某个函数,从而制作出相对应的情境管理器,使得这个函数能够运用在with语句里面。情境管理器通过yield语句所产生的值,可以由with语句之中位于as右侧的那个变量所接收,这样的话,我们就可以通过该变量与当前情境相交互了。 带目标的with语句 with语句还有一种写法,叫作with...as...,它可以把情境管理器所返回的对象赋给as右侧的局部变量,这样的话,with结构的主体部分代码就可以通过这个局部变量与情境管理器所针的那套情境交互了。 1 2 with open('my_output.txt', 'w') as handle: handle.write('This is some data') 与手动打开并关闭文件句柄的写法相比,这种写法更符合Python的风格。这样写,可以保证程序在离开with结构的时候总是会把文件关掉,而且这种结构可以提醒我们把主体部分代码写得简短一些,也就是在打开文件句柄之后,尽快把自己要执行的操作给做完,这是个值得提倡的做法。 第67条、用datetime模块处理本地时间,不要用time模块 协调世界时(Coordinated Universal Time,UTC)是标准的时间表示方法,它不依赖特定时区。有些计算机采用与UNIX时间原点(UNIX epoch)之间的秒数来表达时间,在这种场合,UTC用起来是很方便的,然而对于人类来说,UTC却不太直观,因为我们平常所说的时间,总是默认针对自己所在的地方而言的。例如我们习惯说“早上8点”,而不习惯说“比UTC的15点整早7个小时”。所以,在涉及时间的程序里,我们很有可能要在UTC与当地时区之间互相转换,这样才能给出用户容易理解的格式。 Python里面有两种办法可以转换时区,一种是老办法,也就是通过内置的time模块来做,这种办法很容易出错。还有一种是新办法,也就是通过内置的datetime模块来做,这种办法可以跟第三方的Python开发者所构造的pytz软件包搭配起来,形成很好的转换效果。 time模块 内置time模块的localtime函数可以把UNIX时间戳(UNIX timestamp)转换为与宿主计算机的时区相符的本地时间(UNIX时间戳是个UTC时间,表示某时刻与UNIX时间原点之间的秒数;笔者这台计算机使用的时区为太平洋夏令时(Pacific Daylight Time,PDT),它比UTC慢7个小时)。转换之后的本地时间,可以用strftime函数调整成用户习惯的格式。 许多操作系统会通过相关的配置文件自动反映时区方面的变化,在这样的操作系统上,time模块是可以支持某些时区的。但另外一些操作系统(例如Windows)则不行,所以在那些操作系统上,不要想着用time去做转换。 time模块本质上仍然要依赖具体的平台而运作。它的行为取决于底层的C函数与宿主操作系统之间的协作方式,这导致该模块的功能在Python里面显得不太可靠。time模块没办法稳定地处理多个时区,所以不要用这个模块来编写这方面的代码。假如一定要用,那最多也就是在UTC时间和宿主计算机的当地时区之间用它来转换,涉及其他时区的转换操作,最好还是通过datetime模块来做。 datetime模块 跟time模块一样,它也能把UTC时间转换成本地时间。 1 2 3 4 5 6 from datetime import datetime, timezone now = datetime(2024, 5, 25, 12, 11, 33) now_utc = now.replace(tzinfo=timezone.utc) now_loca = now_utc.astimezone() print(now_local) 反过来也很容易。我们同样可以用datetime模块把本地时间转换成UTC格式的UNIX时间戳。 1 2 3 4 5 time_str = '2024-05-25 12:11:33' now = datetime.strptime(time_str, time_format) time_tuple = now.timetuple() utc_now = time.mktime(time_tuple) print(utc_now) 跟time模块不同,datetime模块里面有相应的机制,可以把一个时区的本地时间可靠地转化成另一个时区的本地时间。但问题是,datetime的这套时区操纵机制必须通过tzinfo类与相关的方法来运作,而系统在安装Python的时候,并不会默认安装UTC之外的时区定义信息。 好在其他Python开发者提供了pytz模块,能够把这些缺失的时区定义信息给补上,这个模块可以从Python Package Index下载。pytz包含一整套数据库,你可能会用到的每个时区它应该都有。 为了顺利使用pytz模块,应该先把本地时间转换成UTC时间,然后在这样的时间值上通过datetime所提供的各种方法执行自己想要的操作(例如通过下面提到的astimezone方法来调整时区属性),最后把操作好的时间转回当地时间。 1 2 3 4 5 6 7 import pytz arrival_nyc = '2024-05-25 12:11:33' nyc_dt_naive = datetime.strptime(arrival_nyc, time_format) eastern = pytz.timezone('US/Eastern') nyc_dt = eastern.localize(nyc_dt_naive) utc_dt = pytz.utc.normalize(nyc_dt.astimezone(pytz.utc)) 把datetime与pytz搭配起来使用,可以确保程序在各种环境下,都能够给出一致的转换结果,而不依赖于具体的操作系统与宿主计算机。 第68条、用copyreg实现可靠的pickle操作 Python内置的pickle模块可以将对象序列化成字节流,也可以把字节流反序列化(还原)成对象。经过pickle处理的字节流,只应该在彼此信任的双方之间传输,而不应该随意传给别人,或者随意接受别人发来的这种数据,因为pickle的本意只是提供一种数据传输手段,让你在自己可以控制的程序之间传递二进制形式的Python对象。 pickle模块所使用的这种序列化格式本身就没有考虑过安全问题。这种格式会把原有的Python对象记录下来,让系统可以在稍后予以重建。这意味着,假如记录的这个对象本身含有恶意行为,那么通过反序列化还原出来之后,就有可能破坏整个程序。 跟pickle不同,json模块考虑到了安全问题。序列化之后的JSON数据表示的只不过是一套对象体系而已,把这样的数据反序列化不会给程序带来风险。如果要在彼此不信任的两个人或两个程序之间传递数据,那么应该使用JSON这样的格式。 pickle模块主要用途仅仅是让我们能够把对象轻松地序列化成二进制数据。如果想直接使用这个模块来实现比这更为复杂的需求,那么可能就会看到奇怪的结果。解决这样的问题,也非常简单,即可以用内置的copyreg模块解决。这个模块允许我们向系统注册相关的函数,把Python对象的序列化与反序列化操作交给那些函数去处理,这样的话,pickle模块就运作得更加稳定了。 第69条、在需要准确计算的场合,用decimal表示相应的数值 Python语言很擅长操纵各种数值。它的整数类型实际上可以表示任意尺寸的整型数据,它的双精度浮点数类型遵循IEEE 754规范。另外,Python还提供了标准的复数类型,用来表示虚数。尽管有这么多类型,但还是没办法把每种情况都覆盖到。 例如,我们要给国际长途电话计费。通话时间用分和秒来表示,这项数据是已知的(例如3分42秒)。通话费率也是固定的,例如从美国打给南极洲的电话,每分钟1.45美元。现在要计算这次通话的具体费用。有人可能觉得应该用浮点数来计算。 1 2 3 rate = 1.45 seconds = 3*60 + 42 cost = rate * seconds/ 60 # 5.364999... 这个答案比正确答案(5.365)少了0.000000000000001,这是因为浮点数必须表示成IEEE 754格式,所以采用浮点数算出的结果可能跟实际结果稍有偏差。 这样的计算应该用Python内置的decimal模块所提供的Decimal类来做。这个类默认支持28位小数,如果有必要,还可以调得更高。改用这个类之后,就不会出现由于IEEE 754浮点数而造成的偏差了。另外,这种数值所支持的舍入方式,也比浮点数丰富可控。 1 2 3 4 5 from decimal import Decimal rate = Decimal('1.45') seconds = Decimal(3*60 + 42) cost = rate * seconds / Decimal(60) Decimal的初始值可以用两种办法来指定。第一种,是把含有数值的str字符串传给Decimal的构造函数,这样做不会让字符串里面的数值由于Python本身的浮点数机制而出现偏差。第二种,是直接把float或int实例传给构造函数。通过下面这段代码,我们可以看到,这两种办法在某些小数上会产生不同的效果。 1 2 Decimal('1.45') # 1.45 Decimal(1.45) # 1.449999999... 所以,如果你想要的是准确答案,那么应该使用str字符串来构造Decimal,这种decimal的精确度可能比你需要的更高,但无论如何,都比刚一开始就出现偏差要好。 Decimal类提供了quantize函数,可以根据指定的舍入方式把数值调整到某一位。 第70条、先分析性能,然后再优化 Python的动态机制,让我们很难预判程序在运行时的性能。有些操作,看上去似乎比较慢,但实际执行起来却很快(例如操纵字符串,使用生成器等);还有一些操作,看上去似乎比较快,但实际执行起来却很慢(例如访问属性,调用函数等)。让Python程序速度变慢的原因,有时很难观察出来。 所以,最好不要凭感觉去判断,而是应该先获得具体的测评数据,然后再决定怎么优化。Python内置了profiler模块,可以找到程序里面占总执行时间比例最高的一部分,这样的话,我们就可以专心优化这部分代码,而不用执着于对程序性能影响不大的那些地方(因为你把同样的精力投入到那些地方,产生的提速效果不会太好)。 Python内置了两种profiler,一种是由profile模块提供的纯Python版本,还有一种是由cProfile模块提供的C扩展版本。这个版本比纯Python版要好,因为它在执行测评的过程中,对受测程序的影响比较小,测评结果更加准确。相反,纯Python版本的开销比较大,会令测评结果有所偏差。 分析Python程序的性能之前,一定要提醒自己注意,别把访问外部系统的那些代码,与核心代码混在一起测。例如,访问网络或磁盘资源的那些函数就有可能需要调用某些底层机制,而那些机制的运作速度比较慢,从而对程序的执行时间造成很大影响。另外,如果程序把这些访问速度较慢的资源缓存了起来,那么在开始分析性能之前,一定要先将缓存预热(也就是要把里面的内容提前配置好)。 1 2 3 4 from cProfile import Profile profiler = Profile() profiler.runcall(test) 运行完test函数后,用内置的pstats模块与里面的Stats类来统计结果。Stats对象提供了各种方法,通过这些方法我们可以只把自己关注的那部分测评信息打印出来。 1 2 3 4 5 6 from pstats import Stats stats = Stats(profiler) stats.strip_dirs() stats.sort_stats('cumulative') stats.print_stats() print_stats方法输出了一张表格,其中每一行都表示一个函数的执行情况。这些样本,都是在profiler激活之后才开始采集的,或者说是在执行profiler.runcall(test)的过程中采集的。 1 2 3 4 5 ncalls:函数在测评期间的调用次数。 tottime:程序执行这个函数本身所花时间(不包括该函数调用其他函数所花时间)。 tottime percall:程序每次执行这个函数所花的平均时间(不统计该函数调用其他函数所花时间)。这相当于tottime除以ncalls。 cumtime:程序执行这个函数以及它所调用的其他函数所花时间。 cumtime percall:程序每次执行这个函数以及它所调用的其他函数平均花费时间。这相当于cumtime除以ncalls。 profiler的print_callers方法来打印统计结果,它可以显示出程序里面有哪几个函数调用了我们关心的这个函数。 可以通过Stats对象筛选出我们关心的那些分析结果,从而更为专注地思考如何优化程序性能。 第71条、优先考虑用deque实现生产者-消费者队列 写程序的时候,经常要用到先进先出的(first-in, first-out,FIFO)队列,这种队列也叫作生产者-消费者队列(producer–consumer queue)或生产-消费队列。FIFO队列可以把某个函数给出的值收集起来,并交给另一个函数按序处理。一般来说,开发者会用Python内置的list类型来实现FIFO队列。 用list来实现这种生产-消费队列,在一定程度上是没问题的,但是当基数(cardinality,也就是列表中的元素数量)变多之后,list的性能就会下降,并且不是等比例地下降,而是更为严重。为了对采用列表所实现的FIFO队列做性能分析,我们通过内置的timeit模块执行一些micro-benchmark。这种micro-benchmark放在相应的benchmark函数里面执行,比如这里定义的list_append_benchmark函数测评的就是用list的append方法给队列中添加新元素时的性能(这正是刚才的生产函数所用的做法)。 1 2 3 4 import timeit # ... timeit.repeat(...) list类型可以用来实现FIFO队列,生产者可以通过append方法向队列添加元素。但这种方案有个问题,就是消费者在用pop(0)从队列中获取元素时,所花的时间会随着队列长度,呈平方式增长。 跟list不同,内置collections模块之中的deque类,无论是通过append添加元素,还是通过popleft获取元素,所花的时间都只跟队列长度呈线性关系,而非平方关系,这使得它非常适合于FIFT队列。 第72条、考虑用bisect搜索已排序的序列 我们经常要将大量数据载入内存,并把它们放到一份有序的列表之中以便搜索。例如,可能要把一本英语辞典加载进来以实现拼写检查,或者把一套财务交易数据加载进来以便审计。 不管程序要处理的是什么数据,在列表(list)里面通过index方法搜索某个值,所花的时间都跟列表长度成正比(或者说,随着列表长度呈线性增长)。 如果你不能确定这个值是否在列表里面,那么你要查的就应该是:列表中恰好等于目标值,或比目标值大但最接近目标值的那个元素所在的位置。要想找到这个位置,最简单的办法是对列表做线性扫描,把其中的元素逐个与目标值比较。 Python内置的bisect模块可以更好地搜索有序列表。其中的bisect_left函数,能够迅速地对任何一个有序的序列执行二分搜索。如果序列中有这个值,那么它返回的就是跟这个值相等的头一个元素所在的位置;如果没有,那么它返回的是插入位置,也就是说,把待查的值插到这个位置可以让序列继续保持有序。 1 2 3 4 5 6 from bisect import bisect_left index = bisect_left(data, 91234) assert index == 91234 index = bisect_left(data, 91234.56) assert index == 91235 bisect模块的二分搜索算法,在复杂度上面是对数级别的。这意味着,线性搜索算法(list.index方法)在包含20个元素的列表中查询目标值所花的时间,已经够这个算法搜索长度为一百万个元素的列表了(math.log2(10**6)大约是19.93)。它要比线性搜索快得多! bisect最好的地方,是它不局限于list类型,而是可以用在任何一种行为类似序列的对象上面。bisect模块还提供了其他一些功能,可以实现更为高级的用法(在Python解释器界面输入help(bisect),查看详细文档)。 第73条、学会使用heapq制作优先级队列 有的时候,我们想根据元素的重要程度来排序。在这种情况下,应该使用优先级队列(priority queue)。 模块名称里面的heap指的是堆,这是一种数据结构,可以维护列表中的元素,并且只需要对数级别的时间就可以添加新元素或移除其中最小的元素(这种算法的复杂程度,要低于线性算法,所以效率比线性算法高)。在这个图书馆程序里面,所谓最小的元素是指逾期时间最长的(或者说,应还日期距离现在最远的)那次出借记录。这个模块最好的地方,就是我们不用了解相关的算法如何实现,只需要调用它就行。 优先级队列让我们能够按照重要程度来处理元素,而不是必须按照先进先出的顺序处理。 如果直接用相关的列表操作来模拟优先级队列,那么程序的性能会随着队列长度的增大而大幅下降,因为这样做的复杂程度是平方级别,而不是线性级别。 通过Python内置的heapq模块所提供的函数,我们完全可以实现基于堆的优先级队列,从而高效地处理大量数据。 要使用heapq模块,我们必须让元素所在的类型支持自然排序,这可以通过对类套用`@functools.total_ordering`修饰器并定义lt方法来实现。 第74条、考虑用memoryview与bytearray来实现无须拷贝的bytes操作 针对CPU密集型的计算任务,要想用Python程序平行地处理,可能得多花一点功夫,但针对I/O密集型的任务,却很容易就能用各种方式写出吞吐量较大(也就是处理能力较强)的平行代码(参见第53条与第60条)。然而,由于这种代码写起来很简单,特别容易遭到误用,让人以为Python好像慢得连I/O密集型任务都处理不好。 Python内置的memoryview类型提供了一套无须执行拷贝的(也就是零拷贝的)操作接口,让我们可以对支持缓冲协议的Python对象制作切片,并通过这种切片高速地完成读取与写入。 Python内置的bytearray类型是一种与bytes相似但内容能够改变的类型,我们可以通过socket.recv_from这样的函数,以无需拷贝的方式(也就是零拷贝的方式)读取数据。我们可以用memoryview来封装bytearray,从而用收到的数据覆盖底层缓冲里面的任意区段,同时又无需执行拷贝操作。

2024/5/25
articleCard.readMore

【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 31 ~ 51 条读书笔记

【笔记】Effective Python《编写高质量 Python 代码的 90 个有效方法》——第 31 ~ 51 条(函数、类)读书笔记 (书基于 Python 3.7+ 语法规范) 知识点概括: 1 2 3 4 5 6 7 8 9 10 11 iter函数 生成器表达式、yield相关语法 itertools模块 闭包 __call__ @classmethod MRO mix-in类 public/private属性 @property 类装饰器 第31条、谨慎地迭代函数所收到的参数 函数和方法如果要把收到的参数遍历很多遍,那就必须特别小心。因为如果这些参数为迭代器,那么程序可能得不到预期的值,从而出现奇怪的效果。 为了应对大规模的数据,其中一个变通方案是让normalize函数接受另外一个函数(也就是下面的get_iter),使它每次要使用迭代器时,都去向那个函数索要。 1 2 3 4 5 6 7 8 9 def normalize_func(get_iter): total = sum(get_iter()) result = [] for value in get_iter(): percent = 100 * value / total result.append(percent) return result normalize_func(lambda: read_visits(path)) 使用normalize_func函数时,需要传入一条lambda表达式,让这个表达式去调用read_visits生成器函数。这样normalize_func每次向get_iter索要迭代器时,程序都会给出一个新的迭代器。 这样做虽然可行,但传入这么一个lambda表达式显得有点儿生硬。要想用更好的办法解决这个问题,可以新建一种容器类,让它实现迭代器协议(iterator protocol)。 Python的for循环及相关的表达式,正是按照迭代器协议来遍历容器内容的。Python执行for x in foo这样的语句时,实际上会调用iter(foo),也就是把foo传给内置的iter函数。这个函数会触发名为foo.iter的特殊方法,该方法必须返回迭代器对象(这个迭代器对象本身要实现next特殊方法)。最后,Python会用迭代器对象反复调用内置的next函数,直到数据耗尽为止(如果抛出StopIteration异常,就表示数据已经迭代完了)。 下面定义这样一种可迭代的容器类 1 2 3 4 5 6 7 8 9 10 class ReadVisits: def __init__(self, data_path): self.data_path = data_path def __iter__(self): with open(self.data_path) as f: for line in f: yield int(line) visits = ReadVisits(path) percentages = normalize(visits) 我们只需要把新的容器传给最早的那个normalize函数运行即可,函数本身的代码不需要修改。 这样做为什么可行呢?因为normalize函数里面的sum会触发ReadVisits.iter,让系统分配一个新的迭代器对象给它。接下来,normalize通过for循环计算每项数据占总值的百分比时,又会触发iter,于是系统会分配另一个迭代器对象。这些迭代器各自推进,其中一个迭代器把数据耗尽,并不会影响其他迭代器。所以,在每一个迭代器上面遍历,都可以分别看到一套完整的数据。这种方案的唯一缺点,就是多次读取输入数据。 第32条、考虑用生成器表达式改写数据量较大的列表推导 列表推导可以根据输入序列中的每个元素创建一个包含派生元素的新列表。如果输入的数据量比较小,那么这样做没问题,但如果数据量很大,那么程序就有可能因为内存耗尽而崩溃。 要想处理大规模的数据,可以使用生成器表达式(generator expression)来做,它扩展了列表推导式与生成器机制。程序在对生成器表达式求值时,并不会让它把包含输出结果的那个序列立刻构建出来,而是会把它当成一个迭代器,该迭代器每次可以根据表达式中的逻辑给出一项结果。 生成器表达式的写法,与列表推导式语法类似,但它是写在一对圆括号内,而不是方括号里面。下面这种写法的效果与刚才一样,但是程序并不会立刻给出全部结果,而是先将生成器表达式表示成一个迭代器返回。 1 2 3 4 it = (len(x) for x in open('my_file.txt')) print(next(it)) print(next(it)) 返回的迭代器每次可以推进一步,这时它会根据生成表达式的逻辑计算出下一项输出结果(这项结果可以通过内置的next函数取得)。需要多少项结果,就把迭代器推进多少次,这种采用生成器表达式来实现的写法不会消耗太多内存。 生成器表达式还有个强大的特性,就是可以组合起来。例如,可以用刚才那条生成器表达式所形成的it迭代器作为输入,编写一条新的生成器表达式。 1 roots = ((x, x**0.5) for x in it) 这条表达式所形成的roots迭代器每次推进时,会引发连锁反应:它也推进内部迭代器it以判断当前是否还能在it上面继续迭代,如果可以,就把it所返回的值代入(x, x**0.5)里面求出结果。这种写法使用的内存同样不会太多。 多个生成器嵌套而成的代码,执行起来还是相当快的。所以,如果要对数据量很大的输入流做一系列处理,那么生成器表达式应该是个很好的选择。唯一需要注意的是,生成器表达式返回的迭代器是有状态的,跑完一整轮之后,就不能继续使用了。 第33条、通过yield from把多个生成器连起来用 这种形式,会先从嵌套进去的小生成器里面取值,如果该生成器已经用完,那么程序的控制流程就会回到yield from所在的这个函数之中,然后它有可能进入下一套yield from逻辑。 如: 1 2 3 4 5 6 def animate_composed(): yield from move(4, 5.0) yield from pause(3) yield from move(2, 3.0) run(animate_composed) Python解释器看到yield from形式的表达式后,会自己想办法实现与带有普通yield语句的for循环相同的效果,而且这种实现方式要更快。 yield from的性能要胜过那种在for循环里手工编写yield表达式的方案。 第34条、不要用send给生成器注入数据 yield表达式通道是单向的,也就是说,无法让生成器在其一端接收数据流,同时在另一端给出计算结果。假如能实现双向通信,那么生成器的适用面会更广。 Python的生成器支持send方法,这可以让生成器变为双向通道。send方法可以把参数发给生成器,让它成为上一条yield表达式的求值结果,并将生成器推进到下一条yield表达式,然后把yield右边的值返回给send方法的调用者。然而在一般情况下,我们还是会通过内置的next函数来推进生成器,按照这种写法,上一条yield表达式的求值结果总是None。 1 2 3 4 5 6 7 8 9 10 11 def my_generator(): received = yield 1 print(f'received = {received}') it = iter(my_generator()) output = next(it) print(f'output = {output}') try: next(it) except StopIteration: pass 如果不通过for循环或内置的next函数推进生成器,而是改用send方法,那么调用方法时传入的参数就会成为上一条yield表达式的值,生成器拿到这个值后,会继续运行到下一条yield表达式那里。可是,刚开始推进生成器的时候,它是从头执行的,而不是从某一条yield表达式那里继续的,所以,首次调用send方法时,只能传None,要是传入其他值,程序运行时就会抛出异常。 最简单的一种写法,是把迭代器传给wave函数,让wave每次用到振幅的时候,通过Python内置的next函数推进这个迭代器并返回一个输入振幅。于是,这就促使多个生成器之间,产生连环反应。 1 2 3 4 5 6 7 8 def wave_cascading(amplitude_it, steps): step_size = 2 * math.pi / steps for steps in range(steps): radians = step * step_size fraction = math.sin(radians) amplitude = next(amplitude_it) output = amlitude * fraction yield output 这样,只需要把同一个迭代器分别传给这几条yield from语句里的wave_cascading就行。 这种写法最大的优点在于,迭代器可以来自任何地方,而且完全可以是动态的(例如可以用生成器函数来实现迭代器)。此方案只有一个缺陷,就是必须假设负责输入的生成器绝对能保证线程安全,但有时其实保证不了这一点。如果代码要跨越线程边界,那么用async函数实现可能更好。 通过迭代器向组合起来的生成器输入数据,要比采用send方法的那种方案好,所以尽量避免使用send方法。 第35条、不要通过throw变换生成器的状态 生成器还有一项高级功能,就是可以把调用者通过throw方法传来的Exception实例重新抛出。这个throw方法用起来很简单:如果调用了这个方法,那么生成器下次推进时,就不会像平常那样,直接走到下一条yield表达式那里,而是会把通过throw方法传入的异常重新抛出。 1 2 3 4 5 6 7 8 9 10 11 class MyError(Exception): pass def my_generator(): yield 1 yield 2 yield 3 it = my_generator() next(it); it.throw(MyError('test Error')) 生成器函数可以用标准的try/except复合语句把yield表达式包裹起来,如果函数上次执行到了这条表达式这里,而这次即将继续执行时,又发现外界通过throw方法给自己注入了异常,那么这个异常就会被try结构捕获下来,如果捕获之后不继续抛异常,那么生成器函数会推进到下一条yield表达式。 1 2 3 4 5 6 7 8 9 10 11 def my_generator(): yield 1 try: yield 2 except MyError: print('Got MyError!') else: yield 3 yield 4 这项机制会在生成器与调用者之间形成双向通信通道,这项机制会在生成器与调用者之间形成双向通信通道,这在某些情况下是有用的。例如,要编写一个偶尔可以重置的计时器程序。如定义下面的Reset异常与timer生成器方法,让调用者可以在timer给出的迭代器上通过throw方法注入Reset异常,令计时器重置。 1 2 3 4 5 6 7 8 9 10 11 class Reset(Exception): pass def timer(period): current = period while current: current -= 1 try: yield current except Reset: current = period 按照这种写法,如果timer正准备从yield表达式往下推进时,发现有人注入了Reset异常,那么它就会把这个异常捕获下来,并进入except分支,在这里它会把表示倒计时的current变量重新调整成最初的period值。 这个计时器可以与外界某个按秒轮询的输入机制对接起来。为此,定义一个run函数以驱动timer生成器所给出的那个it迭代器,并根据外界的情况做处理,如果外界要求重置,那就通过it迭代器的throw方法给计时器注入Reset变量,如果外界没有这样要求,那就调用announce函数打印生成器所给的倒计时值。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def check_for_reset(): # Poll for external event #... def announce(remaining): print(f'{remaining} ticks remaining') def run(): it = timer(4) while True: try: if check_for_reset(): current = it.throw(Reset()) else: current = next(it) except StopIteration: break else: announce(current) run() 这样写没错,但是有点儿难懂,因为用了许多层嵌套结构。 有个简单的办法,能够改写这段代码,那就是用可迭代的容器对象定义一个有状态的闭包。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Timer: def __init__(self, period): self.current = period self.period = period def reset(self): self.current = self.period def __iter__(self): while self.current: self.current -= 1 yield self.current def run(): timer = Timer(4) for current in timer: if check_for_reset(): timer.reset() announce(current) run() 这样写所输出的结果与前面一样,但是这种实现方案理解起来要容易得多 凡是想用生成器与异常来实现的功能,通常都可以改用异步机制去做。 如果确实遇到了这里讲到的这种需求,那么应该通过可迭代的类来实现生成器,而不要用throw方法注入异常。 第36条、考虑用itertools拼装迭代器与生成器 Python内置的itertools模块里有很多函数,可以用来安排迭代器之间的交互关系。 如果要实现比较难写的迭代逻辑,那么应该先查看itertools的文档(在Python解释器界面输入help(itertools))。 连接多个迭代器 内置的itertools模块有一些函数可以把多个迭代器连成一个使用。 chain可以把多个迭代器从头到尾连成一个迭代器。 repeat可以制作这样一个迭代器,它会不停地输出某个值。调用repeat时,也可以通过第二个参数指定迭代器最多能输出几次。 cycle可以制作这样一个迭代器,它会循环地输出某段内容之中的各项元素 tee可以让一个迭代器分裂成多个平行的迭代器,具体个数由第二个参数指定。如果这些迭代器推进的速度不一致,那么程序可能要用大量内存做缓冲,以存放进度落后的迭代器将来会用到的元素。 zip_longest与Python内置的zip函数类似,但区别在于,如果源迭代器的长度不同,那么它会用fillvalue参数的值来填补提前耗尽的那些迭代器所留下的空缺。 过滤源迭代器中的元素 Python内置的itertools模块里有一些函数可以过滤源迭代器中的元素。 islice可以在不拷贝数据的前提下,按照下标切割源迭代器。可以只给出切割的终点,也可以同时给出起点与终点,还可以指定步进值。 takewhile会一直从源迭代器里获取元素,直到某元素让测试函数返回False为止。 dropwhile与takewhile相反,dropwhile会一直跳过源序列里的元素,直到某元素让测试函数返回True为止,然后它会从这个地方开始逐个取值。 filterfalse和内置的filter函数相反,它会逐个输出源迭代器里使得测试函数返回False的那些元素。 用源迭代器中的元素合成新元素 Python内置的itertools模块里,有一些函数可以根据源迭代器中的元素合成新的元素。 accumulate会从源迭代器里取出一个元素,并把已经累计的结果与这个元素一起传给表示累加逻辑的函数,然后输出那个函数的计算结果,并把结果当成新的累计值。 product会从一个或多个源迭代器里获取元素,并计算笛卡尔积(Cartesian product),它可以取代那种多层嵌套的列表推导代码 permutations会考虑源迭代器所能给出的全部元素,并逐个输出由其中N个元素形成的每种有序排列(permutation)方式,元素相同但顺序不同,算作两种排列。 combinations会考虑源迭代器所能给出的全部元素,并逐个输出由其中N个元素形成的每种无序组合(combination)方式,元素相同但顺序不同,算作同一种组合。 combinations_with_replacement与combinations类似,但它允许同一个元素在组合里多次出现。 5.类与接口 第37条、用组合起来的类来实现多层结构,不要用嵌套的内置类型 Python内置的字典类型,很适合维护对象在生命期内的动态内部状态。所谓动态的(dynamic),是指我们无法获知那套状态会用到哪些标识符。 例如,如果要用成绩册(Gradebook)记录学生的分数,而我们又没办法提前确定这些学生的名字,那么受到记录的每位学生与各自的分数,对于Gradebook对象来说,就属于动态的内部状态。 1 2 3 4 5 6 7 8 9 10 class SimpleGradebook: def __init__(self): self._grades = {} def add_student(self, name): self._grades[name] = [] def report_grade(self, name, score): self._grades[name].append(score) def average_grade(self, name): grades = self._grades[name] return sum(grades) / len(grades) 字典与相关的内置类型用起来很方便,但同时也容易遭到滥用导致代码出问题。例如,我们现在要扩展这个SimpleGradebook类的功能,让它按照科目保存成绩,而不是把所有科目的成绩存在一起。通过修改_grades字典的用法,使它必须把键(也就是学生的名字)与另一个小字典相对应,而不是像刚才那样,直接与列表对应起来。那份小字典以各科的名称作键与一份列表对应起来,以保存学生在这一科的全部考试成绩。这次笔者用defaultdict来实现这个小字典,这样可以方便地处理科目名称还不存在的那些情况。 1 2 3 4 5 6 7 8 from collections import defaultdict class BySubjectGradebook: def __init__(self): self._grades = {} def add_student(self, name): self._grades[name] = defaultdict(list) 如果遇到的是类似这种比较复杂的需求,那么不要再嵌套字典、元组、集合、列表等内置的类型了,而是应该编写一批新类并让这些类形成一套体系。 把多层嵌套的内置类型重构为类体系 namedtuple的局限:namedtuple类无法指定默认的参数值[1]。如果数据的可选属性比较多,那么采用这种类来表示,会很不方便。在属性较多的情况下,应该改用内置的dataclasses模块实现。namedtuple实例的属性值仍然可以通过数字下标与迭代来访问,所以可能还是会有人(尤其是那些通过你发布的API来编程的人)会采用这种方式访问这些属性,这样的话,将来就不太容易把它转成普通的类了。如果无法完全控制这些namedtuple实例的用法,那么最好还是明确定义一个新的类。 有了叫作Grade的具名元组,我们就可以写出表示科目的Subject类,让它容纳许多个这样的元组。 1 2 3 4 5 6 7 8 9 10 11 12 13 class Subject: def __init__(self): self._grades = [] def report_grade(self, score, weight): self._grades.append(Grade(score, weight)) def average_grade(self): total, total_weight = 0, 0 for grade in self._grades: total += grade.score * grade.weight total_weight += grade.weight return total / total_weight 然后,就可以写一个表示学生的Student类,用它来记录某位学生各科目(Subject)的考试成绩。 1 2 3 4 5 6 7 8 9 10 11 12 13 class Student: def __init__(self): self._subjects = defaultdict(Subject) def get_subject(self, name): return self._subjects[name] def average_grade(self): total, count = 0, 0 for subject in self._subjects.values(): total += subject.average_grade() count += 1 return total / count 最后,写这样一个表示成绩册的Gradebook容器类,把每位学生的名字与表示这位学生的Student对象关联起来,如果成绩册里还没有记录过这位学生,那么在调用get_student方法时,Gradebook就会构造一个默认的Student对象给调用者使用。 1 2 3 4 5 6 class Gradebook: def __init__(self): self._students = defaultdict(Student) def get_student(self, name): return self._students[name] 虽然比原来那种写法长了一倍,但理解起来却要容易得多。而且用这些类写出来的调用代码,也比原来更清晰、更便于扩展。 不要在字典里嵌套字典、长元组,以及用其他内置类型构造的复杂结构。 第38条、让简单的接口接受函数,而不是类的实例 Python有许多内置的API,都允许我们传入某个函数来定制它的行为。这种函数可以叫作挂钩(hook),API在执行过程中,会回调(call back)这些挂钩函数。 例如,list类型的sort方法就带有可选的key参数,如果明确指定了这个参数,那么它就会按照你提供的挂钩函数来决定列表中每个元素的先后顺序。下面的代码把内置的len函数当成挂钩传给key参数,让sort方法根据长度排列这些名字。 1 2 3 names = ['Scorates', 'Archimedes', 'Plato', 'Aristotle'] names.sort(key=len) print(name) 在其他编程语言中,挂钩可能会用抽象类(abstract class)来定义。但在Python中,许多挂钩都是无状态的函数(stateless function),带有明确的参数与返回值。挂钩用函数来描述,要比定义成类更简单。用作挂钩的函数与别的函数一样,都是Python里的头等(first-class)对象,也就是说,这些函数与方法可以像Python中其他值那样传递与引用。 例如,我们要定制defaultdict类的行为。这种defaultdict数据结构允许调用者提供一个函数,用来在键名缺失的情况下,创建与这个键相对应的值。只要字典发现调用者想要访问的键不存在,就会触发这个函数,以返回应该与键相关联的默认值。下面定义一个log_missing函数作为键名缺失时的挂钩,该函数总是会把这种键的默认值设为0。 1 2 3 def log_missing(): print('Key added') return 0 下面这段代码通过定制的defaultdict字典,把increments列表里面描述的增量添加到current这个普通字典所提供的初始量上面,但字典里一开始没有’red’和’orange’这两个键,因此log_missing这个挂钩函数会触发两次,每次它都会打印’Key added’信息。 1 2 3 4 5 6 7 8 9 10 11 12 13 from collections import defaultdict current = {'green': 12, 'blue': 3} increments = [ ('red', 5), ('blue', 17), ('orange', 9) ] result = defaultdict(log_missing, current) print('Before:', dict(result)) for key, amount in increments: result[key] += amount print('After: ', dict(result)) 通过log_missing这样的挂钩函数,我们很容易构建出便于测试的API,这种API可以把挂钩所实现的附加效果(side effect)与数据本身所应具备的确定行为分开。 例如,假设我们要在传给defaultdict的挂钩里面,统计它总共遇到了多少次键名缺失的情况。要实现这项功能,其中一个办法是采用有状态的闭包(stateful closure,参见第21条)。下面就定义一个辅助函数,把missing闭包当作挂钩传给defaultdict字典,以便为缺失的键提供默认值。 1 2 3 4 5 6 7 8 9 10 11 12 13 def increment_with_report(current, increment): added_count = 0 def missing(): nonlocal added_count # Stateful closure added_count += 1 return 0 result = defaultdict(missing, current) for key, amount in increments: result[key] += amount return result, added_count 统计键名缺失次数所用的added_count状态是由missing挂钩维护的,采用挂钩来运作的defaultdict字典并不需要关注这个细节。于是,这就体现了把简单函数传给接口的另一个好处,也就是方便稍后添加新的功能,因为我们可以把实现这项功能所用的状态隐藏在这个简单的闭包里面。 1 2 result, count = increment_with_report(current, increments) assert count == 2 与无状态的闭包函数相比,用有状态的闭包作为挂钩写出来的代码会难懂一些。为了让代码更清晰,可以专门定义一个小类,把原本由闭包所维护的状态给封装起来。 1 2 3 4 5 6 7 class CountMissing: def __init__(self): self.added = 0 def missing(self): self.added += 1 return 0 在其他编程语言中,可能需要修改defaultdict以便与CountMissing接口相适应。但在Python中,方法与函数都是头等的(first-class)对象,因此可以直接通过对象引用它所属的CountMissing类里的missing方法,并把这个方法传给defaultdict充当挂钩,让字典可以用这个挂钩制作默认值。 在Python中,这种通过对象实例而引用的方法,很容易就能满足函数的接口 1 2 3 4 5 counter = ConutMissing() result = defaultdict(counter.missing, current) for key, amount in increments: result[key] += amount assert counter.added == 2 把有状态的闭包所具备的行为,改用辅助类来实现,要比前面的increment_with_report函数更清晰。但如果单看这个类,可能没办法立刻了解它的意图。 为了让这个类的意义更加明确,可以给它定义名为__call__的特殊方法。这会让这个类的对象能够像函数那样得到调用。同时,也让内置的callable函数能够针对这种实例返回True值,用以表示这个实例与普通的函数或方法类似,都是可调用的。凡是能够像这样(在后面加一对括号来)执行的对象,都叫作callable。 1 2 3 4 5 6 7 8 9 10 11 class BetterCountMissing: def __init__(self): self.added = 0 def __call__(self): self.added += 1 return 0 counter = BetterCountMissing() assert counter() == 0 assert callable(counter) 下面,就用这样的BetterCountMissing实例给defaultdict当挂钩,让它在字典里没有键名时,创建默认的键值,并把这种情况记入键名缺失的总次数里。 1 2 3 4 5 counter = BetterCountMissing() result = defaultdict(counter, current) for key, amount in increments: result[key] += amount assert counter.added == 2 上面这段代码要比CountMissing更清晰,因为它里面有__call__方法,这说明这个类的实例可像普通的函数那样使用(例如可以传给API当挂钩)。即便是初次看到这段代码,也能明白这个类的主要目标。因为你应该会注意到那个比较显眼的__call__方法。它强烈暗示着这个类可以像有状态的闭包那样使用。 总之,最大的优势在于,defaultdict仍然不需要关注__call__方法触发之后究竟会做什么。它只知道自己可以用这样一个挂钩,来给缺失的键制作默认值。Python很容易就能设计这种把挂钩函数当参数来用的接口,面对这种接口,调用者可以采用最适合自己的,把符合接口要求的东西传进去。 第39条、通过@classmethod多态来构造同一体系中的各类对象 在Python中,不仅对象支持多态,类也支持多态。 多态机制使同一体系中的多个类可以按照各自独有的方式来实现同一个方法,这意味着这些类都可以满足同一套接口,或者都可以当作某个抽象类来使用,同时,它们又能在这个前提下,实现各自的功能。 例如,要实现一套MapReduce(映射-归纳/映射-化简)流程,并且以一个通用的类来表示输入数据。 1 2 3 class InputData: def read(self): raise NotImplementedError 然后,编写一个具体的InputData子类,例如,可以从磁盘文件中读取数据的PathInputData类。 1 2 3 4 5 6 7 8 class PathInputData(InputData): def __init__(self, path): super().__init__() self.path = path def read(self): with open(self.path) as f: return f.read() 通用的InputData类以后可能会有很多个像PathInputData这样的子类,每个子类都会实现标准的read接口,并按照各自的方式把需要处理的数据读取进来。例如,有的InputData子类可从网上读取数据,有的InputData可读取压缩格式的数据并将其解压成普通数据,等等。 除了输入数据要通用,我们还想让处理MapReduce任务的工作节点(Worker)也能有一套通用的抽象接口,这样不同的Worker就可以通过这套标准的接口来消耗输入数据。 1 2 3 4 5 6 7 8 9 10 class Worker: def __init__(self, input_data): self.input_data = input_data self.result = None def map(self): raise NotImplementedError def reduce(self, other): raise NotImplementedError 定义一种具体的Worker子类,使它按照特定的方式实现MapReduce。也就是统计每份数据里的换行符个数,然后把所有的统计值汇总起来。 1 2 3 4 5 6 class LineCountWorker(Worker): def map(self): data = self.input_data.read() self.result = data.count('\n') def reduce(self, other): self.result += other.result 这样实现似乎不错,但接下来会碰到一个大难题,也就是如何把这些组件拼接起来。输入数据与工作节点都有各自的类体系,而且这两套体系也抽象出了合理的接口,然而,它们都必须落实到具体的对象上面,只有构造出了具体对象,才能写出有用的程序。 在其他编程语言中,可以利用构造函数多态(constructor polymorphism)来解决,也就是子类不仅要具备与超类一致的构造函数,而且还必须各自提供一个特殊的构造函数以实现和自身有关的构造逻辑。这样,刚才那些辅助方法在编排MapReduce流程时,就可以按照超类的形式统一地构造这些对象,并使其根据所属的子类分别去触发相关的特殊构造函数(类似工厂模式)。但是我们在Python里不能这样做,因为Python的类只能有一个构造方法(即__init__方法),没办法要求所有的InputData子类都采用同一种写法来定义__init__(因为它们必须用各自不同的数据来完成构造)。 我们现在运用方法多态来实现MapReduce流程所用到的这些类。首先改写InputData类,把generate_inputs方法放到该类里面并声明成通用的@classmethod,这样它的所有子类都可以通过同一个接口来新建具体的InputData实例。 1 2 3 4 5 6 7 class GenericInputData: def read(self): raise NotImplementedError @classmethod def generate_inputs(cls, config): raise NotImplementedError 新的generate_inputs方法带有一个叫作config的字典参数,调用者可以把一系列配置信息放到字典里中,让具体的GenericInputData子类去解读。例如PathInputData这个子类就会通过’data_dir’键从字典里寻找含有输入文件的那个目录。 1 2 3 4 5 6 7 8 class PathInputData(GenericInputData): # ... @classmethod def generate_inputs(cls, config): data_dir = config['data_dir'] for name in os.listdir(data_dir): yield cls(os.path.join(data_dir, name)) 然后,可以用类似的思路改写前面的Worker类。把名叫create_workers的辅助方法移到这个类里面并且也声明成@classmethod。新方法的input_class参数将会是GenericInputData的某个子类,我们要通过这个参数触发那个子类的generate_inputs方法,以创建出Worker所需的输入信息。有了输入信息之后,通过cls(input_data)这个通用的形式来调用构造函数,这样创建的实例,其类型是cls所表示的具体GenericWorker子类。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class GenericWorker: def __init__(self, input_data): self.input_data = input_data self.result = None def map(self): raise NotImplementedError def reduce(self, other): raise NotImplementedError @classmethod def create_workers(cls, input_class, config): workers = [] for input_data in input_class.generate_inputs(config): workers.append(cls(input_data)) return workers 上面的代码创建输入信息时,用的是input_class.generate_inputs这样的写法,这么写正是为了触发类多态机制,以便将generate_inputs派发到input_class所表示的那个实际子类上面。另外还要注意,在构造GenericWorker的子类对象时,用的是cls(…)这样的通用写法,而没有直接调用__init__方法。 接下来要修改具体的Worker类,这其实很简单,只需要把超类的名称改为GenericWorker就好。 1 2 class LineCountWorker(GerericWorker): # ... 最后,重新编写mapreduce函数,让它通过worker_class.create_workers来创建工作节点,这样它就变得通用了。 1 2 3 def mapreduce(worker_class, input_class, config): workers = worker_class.create_workers(input_class, config) return execute(workers) 把原来那套实现方案所处理的随机文件,再采用这套新的工作节点来处理,可以产生相同的结果。区别只在于,这次调用mapreduce时,必须多传几个参数,因为它现在是个通用的函数,必须把实际的输入数据与实际的工作节点告诉它。 1 2 config = {'data_dir': tmpdir} result = mapreduce(LineCountWorker, PathInputData, config) 这套方案让我们能够随意编写其他的GenericInputData与GenericWorker子类,而不用再花时间去调整它们之间的拼接代码(glue code)。 第40条、通过super初始化超类 以前有种简单的写法,能在子类里面执行超类的初始化逻辑,那就是直接在超类名称上调用__init__方法并把子类实例传进去。 1 2 3 4 5 6 7 class MyBaseClass: def __init__(self, value): self.value = value class MyChildClass(MyBaseClass): def __init__(self): MyBaseClass.__init__(self, 5) 这个办法能够应对比较简单的类体系,但是在其他的情况下容易出现问题。 假如某个类继承了多个超类,那么直接调用超类的__init__方法会让代码产生误会。 直接调用__init__方法所产生的第一个问题在于,超类的构造逻辑不一定会按照它们在子类class语句中的声明顺序执行。例如,在MyBaseClass之外再定义两个类,让它们也分别去操纵本实例的value字段。 1 2 3 4 5 6 7 class TimesTwo: def __init__(self): self.value *= 2 class PlusFive: def __init__(self): self.value += 5 下面这个子类继承了刚才那三个类,而且它在class语句里指定的超类顺序与它执行那些超类的init时所用的顺序一致。 1 2 3 4 5 class OneWay(MyBaseClass, TimesTwo, PlusFive): def __init__(self, value): MyBaseClass.__init__(self, value) TimesTwo.__init__(self) PlusFive.__init(self) 这样写,程序会按正常顺序初始化那几个超类。 1 foo = OneWay(5) 但如果子类在class语句里指定的超类顺序,与它执行那些超类的__init__时的顺序不同,那么运行结果就会让人困惑(例如这次先声明它继承PlusFive类,然后才声明它继承TimesTwo类,但执行__init__的顺序却刚好相反)。 1 2 3 4 5 class AnotherWay(MyBaseClass, PlusFive, TimesTwo): def __init__(self, value): MyBaseClass.__init__(self, value) TimesTwo.__init__(self) PlusFive.__init__(self) 该子类调整了两个超类的声明顺序,但没有相应调整构造逻辑的执行顺序,因此它还是会跟前面那个子类一样,先初始化TimesTwo,然后初始化PlusFive。这样写,就让初次看到这段代码的人很难理解程序的运行结果,他们以为程序先加5,再乘2,这样算出来是20;但实际上,程序依照的是__init__的调用顺序,而不是class语句中的声明顺序,这是个很难察觉的问题。 直接调用__init__所产生的第二个问题在于,无法正确处理菱形继承(diamond inheritance)。这种继承指的是子类通过类体系里两条不同路径的类继承了同一个超类。如果采用刚才那种常见的写法来调用超类的__init__,那么会让超类的初始化逻辑重复执行,从而引发混乱。例如,下面先从MyBaseClass派生出两个子类。 1 2 3 4 5 6 7 8 9 class TimesSeven(MyBaseClass): def __init__(self, value): MyBaseClass.__init__(self, value) self.value *= 7 class PlusNine(MyBaseClass): def __init__(self, value): MyBaseClass.__init(self, value) self.value += 9 然后,定义最终的子类,让它分别继承刚才那两个类,这样MyBaseClass就会出现在菱形体系的顶端。 1 2 3 4 5 6 class ThisWay(TimesSeven, PlusNine): def __init__(self, value): TimeSeven.__init__(self, value) PlusNine.__init(self, value) foo = ThisWay(5) 当ThisWay调用第二个超类的__init__时,那个方法会再度触发MyBaseClass的init,导致self.value重新变成5。所以,最后的结果是5 + 9 = 14,而不是(5 * 7) + 9= 44,因为早前由TimesSeven.__init__所做的初始化效果已经被第二次执行的MyBaseClass.__init__覆盖了。这是个违背直觉的结果,如果情况更为复杂,那么调试起来会特别困难。 为了解决这些问题,Python内置了super函数并且规定了标准的方法解析顺序(method resolution order,MRO)。super能够确保菱形继承体系中的共同超类只初始化一次。MRO可以确定超类之间的初始化顺序,它遵循C3线性化(C3 linearization)算法。 改用super()来调用超类的初始化逻辑 1 2 3 4 5 6 7 8 9 class TimeSevenCorrect(MyBaseClass): def __init__(self, value): super().__init__(value) self.value *= 7 class PlusNineCorrect(MyBaseClass): def __init__(self, value): super().__init__(value) self.value += 9 位于菱形结构顶端的MyBaseClass,会率先初始化,而且只会初始化一次。接下来,程序会参照菱形底端那个子类在class语句里声明超类时的顺序,来执行菱形结构中部的那两个超类。 1 2 3 4 5 class GoodWay(TimesSevenCorrect, PlusNineCorrect): def __init__(self, value): super().__init__(value) foo = GoodWay(5) 这个执行顺序,似乎与看上去的相反。既然GoodWay在指定超类时,先写的是TimesSevenCorrect,那就应该先执行TimesSevenCorrect.__init__才对,这样结果应该是(5* 7) + 9 = 44。但实际上并非如此。这两个超类之间的初始化顺序,要由子类的MRO确定,它可以通过mro方法来查询。 1 mro_str = '\n'.join(repr(cls) for cls in GoodWay.mro()) 调用GoodWay(5)时,会先触发TimesSevenCorrect.__init__,进而触发PlusNine-Correct.__init__,而这又会触发MyBaseClass.__init__。程序到达菱形结构的顶端后,开始执行MyBaseClass的初始化逻辑,然后按照与刚才相反的顺序,依次执行PlusNineCorrect、TimesSevenCorrect与GoodWay的初始化逻辑。所以,程序首先会在MyBaseClass.__init__中,把value设为5,然后在PlusNineCorrect.init里面给它加9,这样就成了14,接着又会在TimesSevenCorrect.init里面将它乘7,于是等于98。 除了可以应对菱形继承结构,通过super()调用__init__,与在子类内通过类名直接调用__init__相比,可使代码更容易维护。现在不用从子类里面指名调用MyBaseClass.__init__方法了,因此可以把MyBaseClass改成其他名字,或者让TimesSevenCorrect与PlusNineCorrect从另外一个超类里面继承,这些改动都无须调整super这一部分的代码。假如像原来那样写,那就必须手工修改__init__方法前面的类名。 super函数也可以用双参数的形式调用。第一个参数表示从这个类型开始(不含该类型本身)按照方法解析顺序(MRO)向上搜索,而解析顺序则要由第二个参数所在类型的__mro__决定。例如,按照下面这种写法,如果在super所返回的内容上调用init方法,那么程序会从ExplicitTrisect类型开始(不含该类型本身)按照MRO向上搜索,直至找到这样的__init__方法为止,而解析顺序是由第二个参数(self)所属的类型(ExplicitTrisect)决定的,所以解析顺序是ExplicitTrisect -> MyBaseClass -> object。 1 2 3 4 class ExplicitTrisect(MyBaseClass): def __init__(self, value): super(ExplicitTrisect, self).__init__(value) self.value /= 3 一般来说,在类的__init__方法里面通过super初始化实例时,不需要采用双参数的形式,而是可以直接采用不带参数的写法调用super,这样Python编译器会自动将__class__和self当成参数传递进去。所以,下面这两种写法跟刚才那种写法是同一个意思。 1 2 3 4 5 6 7 8 9 10 11 12 13 class AutomaticTrisect(MyBaseClass): def __init__(self, value): super(__class__, self).__init__(value) self.value /= 3 class ImplicitTrisect(MyBaseClass): def __init__(self, value): super().__init(value) self.value /= 3 assert ExplicitTrisect(9).value == 3 assert AutomaticTrisect(9).value == 3 assert ImplicitTrisect(9).value == 3 只有一种情况需要明确给super指定参数,这就是:我们想从子类里面访问超类对某项功能所做的实现方案,而那种方案可能已经被子类覆盖掉了(例如,在封装或复用功能时,就会遇到这样的情况)。 第41条、考虑用mix-in类来表示可组合的功能 Python是面向对象的编程语言,而且内置了相关的机制,使开发者能够正确处理多重继承。尽管如此,但还是应该尽量少用多重继承。 如果既要通过多重继承来方便地封装逻辑,又想避开可能出现的问题,那么就应该把有待继承的类写成mix-in类。这种类只提供一小套方法给子类去沿用,而不定义自己实例级别的属性,也不需要__init__构造函数。 在Python里很容易编写mix-in,因为无论对象是什么类型,我们都可以方便地检视(inspect)它当前的状态。这种动态检测机制,让我们只需要把通用的功能在mix-in实现一遍即可,将来也可以把这项功能应用到其他许多类里面。可以把这些mix-in类有层次地组合起来,从而用相当少的代码表达出丰富的功能。 例如,现在要实现这样一个功能,把内存中的Python对象表示成字典形式以便做序列化(serialization)处理。不妨将这项功能写为通用代码,以供其他类使用。 1 2 3 class ToDictMixin: def to_dict(self): return self.traverse_dict(self.__dict__) 具体的实现代码写得很直观,我们可以通过isinstance函数动态地检视值的类型,并利用hasattr函数判断值里面有没有叫作__dict__的字典。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def _traverse_dict(self, instance_dict): output = {} for key, value in instance_dict.items(): output[key] = self._traverse(key, value) return output def _traverse(self, key, value): if isinstance(value, ToDictMixin): return value.to_dict() elif isinstance(value, dict): return self._traverse_dict(value) elif isinstance(value, list): return [self._traverse(key, i) for i in value] elif hasattr(value, '__dict__'): return self._traverse_dict(value.__dict__) else: return value 下面以二叉树为例,演示如何使表示二叉树的BinaryTree类具备刚才那个mix-in所提供的功能。 1 2 3 4 5 class BinaryTree(ToDictMixin): def __init__(self, value, left=None, right=None): self.value = value self.left = left self.right = right 定义了这样的BinaryTree类后,很容易就能把二叉树里面那些相互关联的Python对象转换成字典的形式。 1 2 3 tree = BinaryTree(10, left=BinaryTree(7, right=BinaryTree(9)), right=BinaryTree(13, left=BinaryTree(11))) tree.to_dict() mix-in最妙的地方在于,子类既可以沿用它所提供的功能,又可以对其中一些地方做自己的处理。 例如,我们从普通的二叉树(BinaryTree)派生了一个子类,让这种特殊的BinaryTreeWithParent二叉树能够把指向上级节点的引用保留下来。但问题是,这种二叉树的to_dict方法是从ToDictMixin继承来的,它所触发的_traverse方法,在面对循环引用时,会无休止地递归下去。 1 2 3 4 class BinaryTreeWithParent(BinaryTree): def __init__(self, value, left=None, right=None, parent=None): super().__init__(value, left=left, right=right) self.parent = parent 为了避免无限循环,我们可以覆盖BinaryTreeWithParent._traverse方法,让它对指向上级节点的引用做专门处理,而对于其他的值,则继续沿用从mix-in继承的_traverse逻辑。下面这段代码,首先判断当前值是不是指向上级节点的引用。如果是,就直接返回上级节点的value值;如果不是,那就通过内置的super函数沿用由mix-in超类所给出默认实现方案。 1 2 3 4 5 def _traverse(self, key, value): if (isinstance(value, BinaryTreeWithParent) and key == 'parent'): return value.value else: return super()._traverse(key, value) 现在调用BinaryTreeWithParent.to_dict就没有问题了,因为它所触发的是BinaryTreeWithParent自己的_traverse方法,该方法不会再递归地处理循环引用。 1 2 3 root = BinaryTreeWithParent(10) root.left = BinaryTreeWithParent(7, parent=root) root.left.right = BinaryTreeWithParent(9, parent=root.left) 只要BinaryTreeWithParent._traverse没问题,带有BinaryTreeWithParent属性的其他类就可以直接继承ToDictMixin,这样的话,程序在把这种对象转化成字典时,会自动对其中的BinaryTreeWithParent属性做出正确处理。 1 2 3 4 5 6 7 class NamedSubTree(ToDictMixin): def __init__(self, name, tree_with_parent): self.name = name self.tree_with_parent = tree_with_parent my_tree = NamedSubTree('foobar', root.left.right) my_tree.to_dict() 多个mix-in可以组合起来用。例如,我们要再写一个mix-in,让所有的类都可以通过继承它来实现JSON序列化功能。在编写这个mix-in时,假设继承了它的那个类肯定有自己的to_dict方法(这个方法有可能是从另一个mix-in(如ToDictMixin)继承的)。 1 2 3 4 5 6 7 8 9 10 import json class JsonMixin: @classmethod def from_json(cls, data): kwargs = json.loads(data) return cls(**kwargs) def to_json(self): return json.dumps(self.to_dict()) 请注意,JsonMixin既定义了实例方法,也定义了类方法。于是,继承了这个mix-in的其他类也会拥有这两种行为。在本例中,继承JsonMixin的类只需要提供to_dict方法以及能够接受关键字参数的__init__方法即可。 有了这样两个mix-in,我们很容易就能创建一套含有工具类的体系,让其中的各种类型都可以把对象序列化成JSON格式并且能够根据JSON格式的数据创建这样的对象。而这只需要开发者按照固定的样式多写一点点代码即可。例如,可以用这样一套由数据类所构成的体系表示数据中心的各种设备与它们之间的结构关系。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class DatacenterRack(ToDictMixin, JsonMixin): def __init__(self, switch=None, machines=None): self.switch - Switch(**switch) self.machines = [Machine(**kwargs) for kwargs in machines] class Switch(ToDictMixin, JsonMixin): def __init__(self, ports=None, speed=None): self.ports = ports self.speed = speed class Machine(ToDictMixin, JsonMixin): def __init__(self, cores=None, ram=None, dist=None): self.cores = cores self.ram = ram self.disk = disk 这样写之后,我们很容易就能根据JSON格式的信息把这些对象还原出来,另外,也可以把它们再序列化成JSON格式。下面我们就先把serialized变量所指的一段JSON信息反序列化(也就是还原)成DatacenterRack对象,然后再把这个对象序列化成JSON信息并保存到roundtrip变量,最后通过json.loads方法验证这两个变量中的信息是否等效。 对于JsonMixin这样的mix-in来说,即便直接继承它的那个类还通过类体系中的其他更高层类型间接地继承了它,程序也依然能够正常运行,因为Python可以把相关的方法正确地派发给JsonMixin类。 第42条、优先考虑用public属性表示应受保护的数据,不要用private属性表示 Python类的属性只有两种访问级别,也就是public与private。 public属性能够公开访问,只需要在对象后面加上圆点操作符(dot operator),并写出属性的名称即可。 如果属性名以两个下划线开头,那么即为private字段。属性所在的类可以通过实例方法访问该属性。 但如果在类的外面直接通过对象访问private字段,那么程序就会抛出异常。 类方法(@classmethod)可以访问本类的private属性,因为这种方法也是在这个类(class)的范围里面声明的。 1 2 3 4 5 6 7 8 9 10 class MyOtherObject: def __init__(self): self.__private_field = 71 @classmethod def get_private_field_of_instance(cls, instance): return instance.__private_field bar = MyOtherObject() assert MyOtherObject.get_private_field_of_instance(bar) == 71 private字段只给这个类自己使用,子类不能访问超类的private字段。 1 2 3 4 5 6 7 8 9 10 class MyParentObject: def __init__(self): self.__private_field = 71 class MyChildObject(MyParentObject): def get_private_field(self): return self.__private_field baz = MyChildObject() baz.get_private_field() 这种防止其他类访问private属性的功能,其实仅仅是通过变换属性名称而实现的。当Python编译器看到MyChildObject.get_private_field这样的方法想要访问__private_field属性时,它会把下划线和类名加在这个属性名称的前面,所以代码实际上访问的是_MyChildObject__private_field。在上面的例子中,__private_field是在MyParentObject的__init__里面定义的,所以,它变换之后的真实名称是_MyParentObject__private_field。子类不能通过__private_field来访问这个属性,因为这样写实际上是在访问不存的_MyChildObject__private_field,而不是_MyParentObject__private_field。 了解名称变换规则后,我们就可以从任何一个类里面访问private属性。无论是子类还是外部的类,都可以不经许可就访问到这些属性。 1 assert baz._MyParentObject__private_field == 71 查看该对象的属性字典,就会发现private属性的名称其实是以变换后的名称存储的。 1 print(baz.__dict__) 为什么Python不从语法上严格禁止其他类访问private属性呢?这可以用一句常见的Python格言来回答:我们都是成年人了(We are all consenting adults here)。意思是说,我们用不着让编程语言把自己给拦住。你可以按照自己的想法扩展某个类的功能,同时也必须考虑到这样做的风险,并为此负责。Python开发者相信,虽然开放访问权限可能导致别人不按默认方式扩展这个类,但总比封闭起来要好。 另外,Python里面还有一些挂钩函数可以访问到这种属性,我们可以通过这些机制按需操纵对象的内部数据。既然这样,那即便Python阻止我们通过圆点加名称的办法访问private属性,我们也还是有其他办法能访问到,那么Python阻止private属性访问又有何意义? 为了减少在不知情情况下访问内部数据而造成的损伤,Python开发者会按照风格指南里面建议的方式来给字段命名。以单下划线开头的字段(例如_protected_field),习惯上叫作受保护的(protected)字段,表示这个类以外的用户在使用这种字段时必须慎重。 尽管如此,有许多Python新手还是喜欢把内部的API设计成private字段,想通过这种写法防止子类或外部类访问这些字段。 如果把属性设为private,那么子类在覆盖或扩充这个类的时候,就必须采用变换之后的名称来访问那个属性,这会让代码变得很容易出错。例如,在写这样一个子类时,就不得不去访问超类中的private字段。 1 2 3 4 5 6 7 8 9 10 11 class MyStringClass: def __init__(self, value): self.__value = value def get_value(self): return str(self.__value) class MyIntegerSubClass(MyStringClass): def get_value(self): return int(self._MyStringClass__value) foo = MyIntegerSubClass('5') assert foo.get_value() == 5 这样写的问题是,如果类体系发生变动,那么引用private属性的那些代码就失效了。例如,MyIntegerSubclass类的直接超类(也就是MyStringClass)本身现在也继承自一个类(叫作MyBaseClass),而且它把早前的__value属性移到了那个类里面。 1 2 3 4 5 6 7 8 9 10 11 12 13 class MyBaseClass: def __init__(self, value): self.__value = value def get_value(self): return self.__value class MyStringClass: def get_value(self): return str(super().get_value()) class MyIntegerSubclass(MyStringClass): def get_value(self): return int(self._MyStringClass__value) 现在的__value属性,是在MyBaseClass里面设置的,而不是在MyStringClass里面,所以MyIntegerSubclass没办法再通过self._MyStringClass__value访问这个属性。 一般来说,这种属性应该设置成protected字段,这样虽然有可能导致子类误用,但还是要比直接设为private好。我们可以在每个protected字段的文档里面详细解释,告诉用户这是属于可以由子类来操作的内部API,还是属于完全不应该触碰的数据。这样的文档,既可以给别人提供建议,也可以指导自己安全地扩充代码。 1 2 3 4 5 6 class MyStringClass: def __init__(self, value): # This stores the user-supplied value for the object # It should be coercible to a string.Once assigned in # the object it shoule be treated as immutable. self._value = value 只有一种情况是可以考虑用private属性解决的,就是子类属性有可能与超类重名的情况。如果子类定义属性时使用的名称,恰巧与超类相同,那就会出现这个问题。 1 2 3 4 5 6 7 8 9 10 class ApiClass: def __init__(self): self._value = 5 def get(self): return self._value class Child(ApiClass): def __init__(self): super().__init__() self._value = 'hello' 如果超类属于开放给外界使用的API,那么你就没办法预料哪些子类会继承它,也不知道那些子类会添加什么属性,此时很有可能出现这个问题,而且你无法通过重构代码来解决。属性名越常见(如本例中的value),越容易发生冲突。为了减少冲突,我们可以把超类的属性设计成private属性,使子类的属性名不太可能与超类重复。 第43条、自定义的容器类型应该从collections.abc继承 编写Python程序时,要花很多精力来定义类,以存放数据并描述这种对象与其他对象之间的关系。每个Python类其实都是某种容器,可以把属性与功能封装进来。除了自定义的类之外,Python本身还提供了一些内置的容器类型,例如列表(list)、元组(tuple)、集合(set)、字典(dict)等,也可以用来管理数据。 如果要定义的是那种用法比较简单的类,那么我们自然就会想到直接从Python内置的容器类型里面继承,例如通过继承list类型实现某种序列。 1 2 3 4 5 6 7 8 9 class FrequencyList(list): def __init__(self, members): super().__init__(members) def frequency(self): counts = {} for item in self: counts[item] = counts.get(item, 0) + 1 return counts 继承list类,可以自动获得标准的Python列表所具备的各项功能。这样的话,其他开发者就可以像使用普通列表那样使用FrequencyList了。此外,我们还可以定义其他一些方法,来提供想要实现的各种行为。 1 2 3 4 foo = FrequencyList(['a', 'b', 'a', 'c', 'b', 'a', 'd']) foo.pop() repr(foo) foo.frequency() 有的时候,某个对象所属的类本身虽然不是list的子类,但我们还是想让它能像list那样,可以通过下标来访问。例如,下面这个表示二叉树节点的BinaryNode类就不是list的子类,但我们想让它能够像序列(list或tuple等)那样,通过下标来访问。 1 2 3 4 5 class BinaryNode: def __init__(self, value, left=None, right=None): self.value = value self.left = left self.right = right 让这个类可以像序列一样访问需要实现一些名称特殊的实例方法。当通过下标访问序列中的元素时: 1 2 bar = [1, 2, 3] bar[0] Python会把访问操作解读为: 1 bar.__getitem__(0) 所以,为了让BinaryNode类能像序列那样使用,我们可以定义__getitem__方法(一般叫作dunder getitem,其中的dunder为double underscore(双下划线)的简称)。这个方法可以按照深度优先的方式遍历BinaryNode对象所表示的二叉树。 1 2 3 4 5 6 7 8 9 10 11 12 class IndexableNode(BinaryNode): def _traverse(self): if self.left is not None: yield from self.left._traverse() yield self if self.right is not None: yield from self.right_traverse() def __getitem__(self, index): for i, item in enumerate(self._traverse()): if i == index: return item.value raise IndexError(f'Index {index} is out of range') 我们可以像使用BinaryNode那样,用这种定制过的IndexableNode对象来构造二叉树。 但问题是,除了下标索引,list实例还支持其他一些功能,所以只实现__getitem__这样一个特殊方法是不够的。例如,我们现在还是没办法像查询list长度那样查询这种二叉树的长度(元素总数)。 要想让定制的二叉树支持内置的len函数,必须再实现一个特殊方法,也就是__len__方法。 1 2 3 4 5 6 7 8 9 10 11 class SequenceNode(IndexableNode): def __len__(self): for count, _ in enumerate(self._travrse(), 1): pass return count tree = SequenceNode( 10, left=SequenceNode(5, left=SequenceNode(2), right=SequenceNode(6, right=SequenceNode(7))), right= SequenceNode(15, left=SequenceNode(11)) ) 实现完这样两个方法之后,我们仍然没办法让这种二叉树具备列表所应支持的全套功能。因为,有些Python开发者可能还想在二叉树上面调用count与index等方法,他们觉得,既然list或tuple这样的序列支持这些方法,那二叉树也应该支持才对。这样看来,要定制一个与标准容器兼容的类,似乎比想象中麻烦。 Python内置的collections.abc模块定义了一系列抽象基类(abstract base class),把每种容器类型应该提供的所有常用方法都写了出来。我们只需要从这样的抽象基类里面继承就好。同时,如果忘了实现某些必备的方法,那么程序会报错,提醒我们这些方法必须实现。 1 2 3 4 5 6 from collections.abc import Sequence class BadType(Sequence): pass foo = BadType() 如果这些必备的方法都已经实现好了,那我们就可以从collections.abc模块的抽象基类里面继承了。例如,下面这个BetterNode二叉树类就是正确的,因为它已经通过继承前面的SequenceNode类实现了序列容器所应支持的全部必备方法,至于其他一些方法(例如index与count)则会由Sequence这个抽象基类自动帮我们实现(它在实现的时候,会借助BetterNode从SequenceNode继承的那些必备方法)。 1 2 3 4 5 6 7 8 class BetterNode(SequenceNode, Sequence): pass tree = BetterNode( 10, left=BetterNode(5, left=BetterNode(2), right=BetterNode(6, right=BetterNode(7))), right= BetterNode(15, left=BetterNode(11)) ) 对于定制集合或可变映射等复杂的容器类型来说,继承collections.abc模块里的Set或MutableMapping等抽象基类所带来的好处会更加明显。假如自己从头开始实现,那必须编写大量的特殊方法才能让这些容器类的对象也能像标准的Python容器那样使用。collections.abc模块要求子类必须实现某些特殊方法,另外,Python在比较或排列对象时,还会用到其他一些特殊方法,无论定制的是不是容器类,有时为了支持某些功能,你都必须定义相关的特殊方法才行。 6.元类与属性 元类(metaclass)是一种在类之上、超乎于类的概念。元类能够拦截Python的class语句,让系统每次定义类的时候,都能实现某些特殊的行为。 第44条、用纯属性与修饰器取代旧式的setter与getter方法 从其他编程语言转入Python的开发者,可能想在类里面明确地实现getter与setter方法。 1 2 3 4 5 6 7 class OldResistor: def __init__(self, ohms): self._ohms = ohms def get_ohms(self): return self._ohms def set_ohms(self, ohms): self._ohms = ohms 虽然这些setter与getter用起来很简单,但这并不符合Python的风格。 1 2 3 4 r0 = OldResistor(50e3) r0.get_ohms() # 50000.0 r0.set_ohms(10e3) ro.get_ohms() # 10000.0 例如,想让属性值变大或者变小,采用这些方法来写会特别麻烦。 1 2 r0.set_ohms(r0.get_ohms() - 4e3) assert r0.get_ohms() == 6e3 这种工具方法确实有助于将类的接口定义得更加清晰,并方便开发者封装功能、验证用法、划定界限。这些都是在设计类时应该考虑的目标,实现这些目标可以确保我们在完善这个类的过程中不影响已经写好的调用代码。可是,在Python中实现这些目标时,没必要明确定义setter与getter方法。而是应该从最简单的public属性开始写起,例如像下面这样: 1 2 3 4 5 6 7 8 class Resistor: def __init__(self, ohms): self.ohms = ohms self.voltage = 0 self.current = 0 r1 = Resistor(50e3) r1.ohms = 10e3 按照这种写法,很容易就能实现原地增减属性值。 将来如果想在设置属性时,实现特别的功能,那么可以先通过@property修饰器来封装获取属性的那个方法,并在封装出来的修饰器上面通过setter属性来封装设置属性的那个方法。下面这个新类继承自刚才的Resistor类,它允许我们通过设置voltage(电压)属性来改变current(电流)。为了正确实现这项功能,必须保证设置属性与获x取属性所用的那两个方法都跟属性同名。 1 2 3 4 5 6 7 8 9 10 11 12 13 class VoltageResistance(Resistor): def __init__(self, ohms): super().__init__(ohms) self._voltage = 0 @property def voltage(self): return self._voltage @voltage setter def voltage(self, voltage): self._voltage = voltage self.current = self._voltage / self.ohms 按照这种写法,给voltage属性赋值会触发同名的setter方法,该方法会根据新的voltage计算本对象的current属性。 1 2 r2 = VoltageResistance(1e3) r2.voltage = 10 为属性指定setter方法还可以用来检查调用方所传入的值在类型与范围上是否符合要求。例如,下面这个Resistor子类可以确保用户设置的电阻值总是大于0的。 1 2 3 4 5 6 7 8 9 10 11 12 13 class BoundedResistance(Resistor): def __init__(self, ohms): super().__init__(ohms) @property def ohms(self): return self._ohms @ohms.setter def ohms(self, ohms): if ohms <= 0: raise ValueError(f'ohms must be > 0; got {ohms}') self._ohms = ohms 给这个类的属性设置无效电阻值,程序会抛出异常。 1 2 r3 = BoundedResistance(1e3) r3.ohms = 0 如果构造时所用的值无效,那么同样会触发异常。 之所以会出现这种效果,是因为子类的构造器(BoundedResistance.init)会调用超类的构造器(Resistor.init),而超类的构造器会把self.ohms设置成-5。于是,就会触发BoundedResistance里面的`@ohms.setter方法,该方法立刻发现属性值无效,所以程序在对象还没有构造完之前,就会抛出异常。我们还可以利用@property`阻止用户修改超类中的属性。 1 2 3 4 5 6 7 8 9 10 11 12 13 class FixedResistance(Resistor): def __init__(self, ohms): super().__init__(ohms) @property def ohms(self): return self._ohms @ohms.setter def ohms(self, ohms): if hasattr(self, '_ohms'): raise AttributeError('Ohms is immutable') self._ohms = ohms 构造好对象之后,如果试图给属性赋值,那么程序就会抛出异常。 1 2 r4 = FixedResistance(1e3) r4.ohms = 2e3 用@property实现setter与getter时,还应该注意不要让对象产生反常的行为。例如,不要在某属性的getter方法里面设置其他属性的值。 1 2 3 4 5 6 7 8 class MysteriousResistor(Resistor): @property def ohms(self): self.voltage = self._ohms * self.current return self._ohms @ohms.setter def ohms(self, ohms): self._ohms = ohms 假如在获取属性的getter方法里面修改了其他属性的值,那么用户查询这个属性时,就会觉得相当奇怪,他可能不理解为什么另外一个属性会在我查询这个属性时发生变化。 最好的办法是,只在@property.setter方法里面修改状态,而且只应该修改对象之中与当前属性有关的状态。同时还得注意不要产生让调用者感到意外的其他一些副作用,例如,不要动态地引入模块,不要运行速度较慢的辅助函数,不要做I/O,不要执行开销较大的数据库查询操作等。类的属性用起来应该跟其他的Python对象一样方便而快捷。如果确实要执行比较复杂或比较缓慢的操作,那么应该用普通的方法来做,而不应该把这些操作放在获取及设置属性的这两个方法里面。 @property最大的缺点是,通过它而编写的属性获取及属性设置方法只能由子类共享。与此无关的类不能共用这份逻辑。但是没关系,Python还支持描述符,我们可以利用这种机制把早前编写的属性获取与属性设置逻辑复用到其他许多地方。 第45条、考虑用@property实现新的属性访问逻辑,不要急着重构原有的代码 @property还有一种更为高级的用法,其实也很常见,这就是把简单的数值属性迁移成那种实时计算的属性。这个用法的意义特别大,因为它可以确保,按照旧写法来访问属性的那些代码依然有效,而且会自动按照新逻辑执行,也不需要重写原来那些访问代码(这一点相当关键,因为那些代码未必都在你控制之下)。@property可以说是一种重要的缓冲机制,使开发者能够逐渐改善接口而不影响已经写好的代码。 可以利用@property给已有的实例属性增加新的功能。可以利用@property逐渐改善数据模型而不影响已经写好的代码。 @property可以帮助解决实际工作中的许多问题,但不应该遭到滥用。如果你发现自己总是在扩充@property方法,那可能说明这个类确实应该重构了。在这种情况下,就不要再沿着糟糕的方案继续往下写了。 第46条、用描述符来改写需要复用的@property方法 Python内置的@property机制的最大的缺点就是不方便复用。我们不能把它修饰的方法所使用的逻辑,套用在同一个类的其他属性上面,也不能在无关的类里面复用。 例如,我们要编写一个类来记录学生的家庭作业成绩,而且要确保设置的成绩位于0到100之间。 1 2 3 4 5 6 7 8 9 10 11 12 class Homework: def __init__(self): self._grade = 0 @property def grade(self): return self._grade @grade.setter def grade(self, value): if not(0 <= value <= 100): raise ValueError('Grade must be between 0 and 100') self._grade = value 受@property修饰的属性用起来很简单。 1 2 galileo = Homework() galileo.grade = 95 假设,我们还需要写一个类记录学生的考试成绩,而且要把每科的成绩分别记录下来。 1 2 3 4 5 6 7 8 9 class Exam: def __init__(self): self._writing_grade = 0 self._math_grade = 0 @staticmethod def _check_grade(value): if not(0 <= value <= 100): raise ValueError('Grade must be between 0 and 100') 这样写很费事,因为每科的成绩都需要一套@property方法,而且其中设置属性值的那个方法还必须调用_check_grade验证新值是否位于合理的范围内。 在Python里,这样的功能最好通过描述符(descriptor)实现。描述符协议(descriptor protocol)规定了程序应该如何处理属性访问操作。充当描述符的那个类能够实现__get__与__set__方法,这样其他类就可以共用这个描述符所实现的逻辑而无须把这套逻辑分别重写一遍。 下面重新定义Exam类,这次我们采用类级别的属性来实现每科成绩的访问功能,这些属性指向下面这个Grade类的实例,而这个Grade类则实现刚才提到的描述符协议。 1 2 3 4 5 6 7 8 9 10 11 class Grade: def __get__(self, instance, instance_type): # ... def __set__(self, instance, value): # ... clas Exam: # Class attributes math_grade = Grade() writing_grade = Grade() science_grade = Grade() 当程序访问Exam实例的某个属性时,Python如何将访问操作派发到Exam类的描述符属性上面。例如,如果要给Exam实例的writing_grade属性赋值: 1 2 exam = Exam() exam.writing_grade = 40 那么Python会把这次赋值操作转译为: 1 Exam.__dict__['writing_grade'].__set__(exam, 40) 获取这个属性时也一样: 1 Exam.__dict__['writing_grade'].__get__(exam) # exam.writing_grade 这样的转译效果是由object的__getattribute__方法促成的。 简单地说,就是当Exam实例里面没有名为writing_grade的属性时,Python会转而在类的层面查找,查询Exam类里面有没有这样一个属性。如果有,而且还是个实现了__get__与__set__方法的对象,那么系统就认定你想通过描述符协议定义这个属性的访问行为。 知道了这条规则之后,我们来尝试把Homework类早前用@property实现的成绩验证逻辑搬到Grade描述符里面。 1 2 3 4 5 6 7 8 9 class Grade: def __init__(self): self._value = 0 def __get__(self, instance, instance_type): return self._value def __set__(self, instance, value): if not(0 <= value <= 100): raise ValueError('Grade must be between 0 and 100') self._value = value 这样写其实不对,而且会让程序出现混乱。但在同一个Exam实例上面访问不同的属性是没有问题的。 出现这种问题的原因在于,这些Exam实例之中的writing_grade属性实际上是在共享同一个Grade实例。在整个程序的运行过程中,这个Grade只会于定义Exam类时构造一次,而不是每创建一个Exam实例都有一个新的Grade来与writing_grade属性相搭配。为解决此问题,我们必须把每个Exam实例在这个属性上面的取值都记录下来。可以通过字典实现每个实例的状态保存。 1 2 3 4 5 6 7 8 9 10 11 class Grade: def __init__(self): self._values = {} def __get__(self, instance, instance_type): if instance is None: return self return self._values.get(instance, 0) def __set__(self, instance, value): if not(0 <= value <= 100): raise ValueError('Grade must be between 0 and 100') self._values[instance] = value 这种实现方案很简单,而且能得到正确结果,但仍然有一个缺陷,就是会泄漏内存。在程序运行过程中,传给__set__方法的那些Exam实例全都会被Grade之中的_values字典所引用。于是,指向那些实例的引用数量就永远不会降到0,这导致垃圾回收器没办法把那些实例清理掉。 为了解决这个问题,我们可以求助于Python内置的weakref模块。该模块里有一种特殊的字典,名为WeakKeyDictionary,它可以取代刚才实现_values时所用的普通字典。这个字典的特殊之处在于:如果运行时系统发现,指向Exam实例的引用只剩一个,而这个引用又是由WeakKeyDictionary的键所发起的,那么系统会将该引用从这个特殊的字典里删掉,于是指向那个Exam实例的引用数量就会降为0。总之,改用这种字典来实现_values会让Python系统自动把内存泄漏问题处理好,如果所有的Exam实例都不再使用了,那么_values字典肯定是空的。 1 2 3 4 5 6 7 8 9 from weakref import WeakKeyDictionary class Grade: def __init__(self): self._values = WeakKeyDictionary() def __get__(self, instance, instance_type): # ... def __set__(self, instance, value): # ... 不要太纠结于__getattribute__是怎么通过描述符协议来获取并设置属性的。 第47条、针对惰性属性使用__getattr__、__getattribute__及__setattr__ Python的object提供了一套挂钩,使开发者很容易就能写出通用的代码,将不同的系统粘合到一起。 如果类中定义了__getattr__,那么每当访问该类对象的属性,而且实例字典里又找不到这个属性时,系统就会触发__getattr__方法。 1 2 3 4 5 6 7 class LazyRecord: def __init__(self): self.exists = 5 def __getattr__(self, name): value = f'Value for {name}' setattr(self, name, value) return value 我们试着访问foo属性。data实例中并没有这样一个属性,因此Python会触发上面定义的__getattr__方法,而该方法又会通过setattr修改本实例的dict字典。 我们通过子类给LazyRecord增加日志功能,用来观察程序在什么样的情况下才会调用__getattr__方法。我们先写入第一条日志,然后通过super()调用超类所实现的__getattr__方法,并把那个方法返回的结果记录到第二条日志里面。假如不加super(),那么程序就会无限递归,因为那样调用的是本类所写的__getattr__方法 1 2 3 4 5 6 class LoggingLazyRecord(LazyRecord): def __getattr(self, name): print(f'called __getattr__({name!r})') result = super().__getattr__(name) print(f'Returning {result!r}') return result exists属性本来就在实例字典里,所以访问data.exists时不会触发__getattr__。接下来,开始访问data.foo。foo属性不在实例字典中,因此系统会触发__getattr__方法,这个方法会通过setattr把foo属性添加到实例字典。然后,我们第二次访问data.foo,这次data实例的__dict__字典已经包含这个属性,所以不会触发__getattr__。 如果要实现惰性的(lazy,也指按需的)数据访问机制,而这份数据又没有schema,那么通过__getattr__来做就相当合适。它只需要把属性加载一次即可,以后再访问这个属性时,系统会直接从实例字典中获取。 假设我们现在还需要验证数据库系统的事务状态。也就是说,用户每次访问某属性时,我们都要确保数据库里面的那条记录依然有效,而且相应的事务也处在开启状态。这个需求没办法通过__getattr__实现,因为一旦对象的实例字典里包含了这个属性,那么程序就会直接从字典获取,而不会再触发__getattr__。 为了应对这种比较高级的用法,Python的object还提供了另一个挂钩,叫作__getattribute__。只要访问对象中的属性,就会触发这个特殊方法,即便这项属性已经在__dict__字典里,系统也还是会执行__getattribute__方法。于是,我们可以在这个方法里面检测全局的事务状态,这样就能对每一次属性访问操作都进行验证了。同时,我们必须注意这种写法开销很大,而且会降低程序的效率,但有的时候确实值得这么做。下面就定义ValidatingRecord类,让它实现__getattribute__方法,并在系统每次调用这个方法时,打印相关的日志消息。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class ValidatingRecord: def __init__(self): self.exists = 5 def __getattribute__(self, name): print(f'* Called __getattribute__({name!r})') try: value = super().__getattribute__(name) print(f'* Found {name!r}, returning {value!r}') return value except AttributeError: value = f'Value for {name}' print(f'* Setting {name!r} to {value!r}') setattr(self, name, value) return value data = ValidatingRecord() 如果要访问的属性根本就不应该存在,那么可以在__getattr__方法里面拦截。无论是__getattr__还是__getattribute__,都应该抛出标准的AttributeError来表示属性不存在或不适合存在的情况。 1 2 3 4 5 6 7 8 class MissingPropertyRecord: def __getattr__(self, name): if name == 'bad_name': raise AttributeError(f'{name} is missing') #... data = MissingPropertyRecord() data.bad_name 在编写通用的Python代码时,我们经常要依靠内置的hasattr函数判断属性是否存在,并且通过内置的getattr函数获取属性值。这些函数也会先在实例的__dict__字典里面查找,如果找不到,则会触发__getattr__。 1 2 3 4 5 data = LoggingLazyRecord() data.__dict__ hasattr(data, 'foo') data.__dict__ hasattr(data, 'foo') 在运行上面那段代码的过程中,__getattr__只触发了一次。假如data所属的类实现的不是__getattr__,而是__getattribute__方法,那么效果就不一样了,程序每次对实例做hasattr与getattr操作时,都会触发这个方法。 1 2 3 data = ValidatingRecord() print(hasattr(data, 'foo')) print(hasattr(data, 'foo')) 假设程序给Python对象赋值时,我们不想立刻更新数据库,而是打算稍后再推送回去。这个功能可以通过__setattr__实现,而它也是object提供的挂钩,可以拦截所有的属性赋值操作。属性的获取操作分别通过__getattr__与__getattribute__挂钩拦截,但设置操作只需要这一个挂钩就行。只要给实例中的属性赋值(不论是直接赋值,还是通过内置的setattr函数赋值),系统就触发__setattr__方法。 1 2 3 4 class SavingRecord: def __setattr__(self, name, value): # ... super().__setattr(name, value) __getattribute__与__setattr__这样的方法有个问题,就是只要访问对象的属性,系统就会触发该方法。但有时候,我们其实并不希望出现这种效果。 为解决这个问题,我们可以改用super().__getattribute__方法获取_data属性,由于超类的__getattribute__是直接从实例的属性字典获取的,不会继续触发__getattribute__,这样就避开了递归。 1 2 3 4 5 6 7 8 9 10 class DictionaryRecord: def __init__(self, data): self._data = data def __getattribute__(self, name): print(f'* Called __getattribute__({name!r})') data_dict = super().__getattribute__('_data') return data_dict[name] data = DictionaryRecord({'foo': 3}) 在__setattr__里面为这种对象实现属性修改逻辑时,也需要通过super().__setattr__来获取_data字典。 第48条、用__init_subclass__验证子类写得是否正确 元类最简单的一种用法是验证某个类定义得是否正确。如果要构建一套比较复杂的类体系,那我们可能得确保这套体系中的类采用的都是同一种风格,为此我们可能需要判断这些类有没有重写必要的方法,或者判断类属性之间的关系是否合理。元类提供了一种可靠的手段,只要根据这个元类来定义新类,就能用元类中的验证逻辑核查新类的代码写得是否正确。 一般来说,我们会在类的__init__方法里面检查新对象构造得是否正确。但有的时候,整个类的写法可能都是错的,而不单单是该类的某个对象构造得有问题,所以我们想尽早拦住这种错误。例如,当程序刚刚启动并把包含这个类的模块加载进来时,我们就想验证这个类写得对不对,此时便可利用元类来实现。 在讲解如何用自定义的元类验证子类之前,我们首先必须明白元类的标准用法。元类应该从type之中继承。在默认情况下,系统会把通过这个元类所定义的其他类发送给元类的__new__方法,让该方法知道那类个的class语句是怎么写的。下面就定义这样一个元类,如果用户通过这个元类来定义其他类,那么在那个类真正构造出来之前,我们可以先在__new__里面观察到它的写法并做出修改。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Meta(type): def __new__(meta, name, bases, class_dict): print(f'* Running {meta}.__new__ for {name}') print('Bases:', bases) print(class_dict) return type.__new__(meta, name, bases, class_dict) class MyClass(metaclass=Meta): stuff = 123 def foo(self): pass class MySubclass(MyClass): other = 567 def bar(self): pass 元类可以获知那个类的名称(name)、那个类的所有超类(bases)以及class语句体中定义的所有类属性(class_dict)。因为每个类最终都要继承object,所以这个object名字不会体现在罗列超类名称的bases元组之中。 我们可以在元类的__new__方法里面添加一些代码,用来判断根据这个元类所定义的类的各项参数是否合理。例如,要用不同的类来表示边数不同的多边形(polygon)。如果把这些类都纳入同一套体系,那么可以定义这样一个元类,让该体系内的所有类都受它约束。我们在这个元类的__new__里面检查那些类的边数(sides)是否有效。注意,不要把检查逻辑运用到类体系的顶端,也就是基类Polygon上面。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class ValidatePolygon(type): def __new__(meta, name, bases, class_dict): # Only validate subclasses of the Polygon class if bases: if class_dict['sides'] < 3: raise ValueError('Polygons need 3+ sides') return type.__new__(meta, name, bases, class_dict) class Polygon(metaclass=ValidatePolygon): sides = None # Must be specified by subclasses @classmethod def interior_angles(cls): return (cls.sides - 2) * 180 class Triangle(Polygon): sides = 3 class Rectangle(Polygon): sides = 4 class Monagon(Polygon): sides = 9 如果我们试着定义边数小于3的多边形子类,那么刚把那个子类的class语句体写完,元类就会通过__new__方法察觉到这个问题。这意味着,只要定义了无效的多边形子类,程序就无法正常启动,除非那个类是在动态引入的模块里面定义的。 这样一项基本的任务竟然要写这么多代码才能实现。好在Python 3.6引入了一种简化的写法,能够直接通过__init_subclass__这个特殊的类方法实现相同的功能,这样就不用专门定义元类了。下面我们改用这个机制来实现与刚才相同的验证逻辑。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class BetterPolygon: sides = None # Must be specified by subclasses def __init_subclass(cls): super().__init_subclass__() if cls.sides < 3: raise ValueError('Polygons need 3+ sides') @classmethod def interior_angles(cls): return (cls.sides - 2) * 180 class Hexagon(BetterPolygon): sides = 6 现在的代码简短多了,完全不需要定义ValidatePolygon这样一个元类。在__init_subclass__方法里面,我们可以直接通过cls实例来访问类级别的sides属性,而不用像原来那样,在存放类属性的class_dict里面查询’sides’键。现在的多边形子类应该继承刚写的BetterPolygon基类。如果子类定义的边数无效,那么程序会抛出同样的异常。 用标准的Python元类机制来实现验证还有个缺点,就是每个类只能定义一个元类。 要解决这个问题,我们可以创建一套元类体系,让不同层面上的元类分别完成各自的验证逻辑(也就是先在下层元类里面验证填充色,如果验证无误,那么再去上层元类里面验证边数)。 1 2 3 4 5 6 7 8 9 10 class ValidatePolygon(type): def __new__(meta, name, bases, class_dict): # Only validate non-root classes if not class_dict.get('is_root'): if class_dict['sides'] < 3: raise ValueError('Polygons need 3+ sides') return type.__new__(meta, name, bases, class_dict) class Polygon(metaclass=ValidatePolygon): is_root = True 同时,这也要求我们必须设计一个支持填充色的多边形类(FilledPolygon),让它在多边形类(Polygon)的基础上增加填充色逻辑,而不能像刚才那样,把填充色与边数分别放在Filled与Polygon两个类中。现在,带有具体填充色与边数的多边形需要从这个FilledPolygon里面继承。 1 2 3 class GreenPentagon(FilledPolygon): color = 'green' sides = 5 如果采用不受支持的填充色来定义FilledPolygon子类,那么ValidateFilledPolygon里面的验证逻辑就会查出这个问题。ValidateFilledPolygon元类继承自ValidatePolygon,因此边数的错误也可以检查出来。 但是按照现在这种写法,如果想把颜色的验证逻辑施加在多边形之外的另一套类体系中,那么必须按照刚才的样板重复编写许多代码才行,而没办法很方便地复用已有的代码。 这个问题,同样可以通过__init_subclass__这个特殊的类方法来解决。在多层的类体系中,只要通过内置的super()函数来调用__init_subclass__方法,系统就会按照适当的解析顺序触发超类或平级类的__init_subclass__方法,以保证那些类在各自的__init_subclass__里面所实现的验证逻辑也能够正确地执行(类似案例参见第40条)。这种写法可以正确应对多重继承。例如,下面这个Filled类就通过__init_subclass__来验证填充色,这样的话,子类可以同时继承该类以及刚才的BetterPolygon类,从而把这两个类所实现的验证逻辑组合起来。 1 2 3 4 5 6 7 class Filled: color = None # Must be specified by subclasses def __init_subclass(cls): super().__init_subclass__() if cls.color not in ('red', 'green', 'blue'): raise ValueError('Fills need a valid color') __init_subclass__还可以处理更为复杂的情况,例如菱形继承 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Top: def __init_subclass_(cls): super().__init_subclass__() print(f'Top for {cls}') class Left(Top): def __init_subclass__(cls): super().__init_subclass__() print(f'Left for {cls}') class Right(Top): def __init_subclass__(cls): super().__init_subclass__() print(f'Right for {cls}') class Button(Left, Right): def __init_subclass__(cls): super().__init_subclass__() print(f'Bottom for {cls}') 可以看到,菱形体系底部的Bottom类通过Left与Right两条路径重复继承了体系顶端的Top类。然而,由于是通过super()触发__init_subclass__,系统在处理Bottom类的定义时,只会把Top类的__init_subclass__执行一遍。 第49条、用__init_subclass__记录现有的子类 元类还有个常见的用途,是可以自动记录(或者说注册)程序之中的类型。利用这项功能,我们就能根据某个标识符反向查出它所对应的类。 让元类把子类的class定义拦截下来,然后自动调用register_class去注册 1 2 3 4 5 6 7 8 class Meta(type): def __new__(meta, name, bases, class_dict): cls = type.__new__(meta, name, bases, class_dict) register_class(cls) return cls class RegisteredSerializable(BetterSenializable, metaclass=Meta): pass 用户只要把RegisteredSerializable的子类定义完,就可以确信程序已经通过register_class将这个子类注册过了,所以它肯定支持反序列化。 还有一种办法比上面的实现方式更简单,那就是通过名为__init_subclass__的特殊类方法来实现。这是Python 3.6引入的新写法,我们只需要编很少的代码,就可以把自己的逻辑运用到子类上面 1 2 3 4 5 6 7 8 9 class BetterRegisteredSerializable(BetterSerializable): def __init_subclass__(cls): super().__init_subclass__() register_class(cls) class Vector1D(BetterRegisteredSerializable): def __init__(self, magnitude): super().__init__(magnitude) self.magnitude = magnitude 在类体系正确无误的前提下,通过__init_subclass__(或元类)自动注册子类可以避免程序由于用户忘记注册而引发问题。这不仅适用于上述序列化与反序列化功能的实现,而且还可以用在数据库的对象关系映射(object-relational mapping,ORM)、可扩展的插件系统以及回调挂钩上面。 第50条、用__set_name__给类属性加注解 元类还有一个更有用的功能,那就是可以在某个类真正投入使用之前,率先修改或注解这个类所定义的属性。这通常需要与描述符(descriptor)搭配使用,这样可以让我们更详细地了解这些属性在定义它们的那个类里是如何使用的。 例如,我们要定义一个新的类,来表示客户数据库中的每一行数据。这个类需要定义一些属性,与数据表中的各列相对应,每个属性都分别表示这行数据在这一列的取值。下面用描述符类来实现这些属性,把它们和数据表中同名的列联系起来。 1 2 3 4 5 6 7 8 9 10 class Field: def __init__(self, name): self.name = name self.internal_name = '_' + self.name def __get__(self, instance, instance_type): if instance is None: return self return getattr(instance, self.internal_name, '') def __set__(self, instance, value): setattr(instance, self.internal_name, value) Field描述符的name属性指的就是数据表中那一列的列名,所以,我们可以通过内置的setattr函数把每行数据在这个属性上面的取值保存到那行数据自己的状态字典里面去,只不过属性名应该稍加调整,我们给它前面加个下划线表示它是受到保护的属性。另外,我们通过getattr函数实现属性加载功能。这种写法,看上去似乎要比把每个实例在这项属性上面的取值都保存到weakref字典里面更简单(那种字典是weakref模块所提供的特殊字典,用以防止内存泄漏)。 下面定义Customer类,每个Customer都表示数据表中的一行数据,其中的四个属性分别对应于这行数据在那四列上面的取值。 1 2 3 4 5 6 class Customer: # Class attributes first_name = Field('first_name') last_name = Field('last_name') prefix = Field('prefix') suffix = Field('suffix') 元类可以当作class语句的挂钩,只要class语句体定义完毕,元类就会看到它的写法并尽快做出应对。在本例中,我们可以让元类自动给每个Field描述符的name与internal_name赋值,而不用再像原来那样,需要开发者把字段名称重复书写一遍并手动传给Field的构造函数。 1 2 3 4 5 6 7 8 class Meta(type): def __new__(meta, name, bases, class_dict): for key, value in class_dict.items(): if isinstance(value, Field): value.name = key value.internal_name = '_' + key cls = type.__new__(meta, name, bases, class_dict) return cls 下面定义一个基类,让该基类把刚才定义好的Meta当成元类。凡是表示数据库某行的类都继承自该基类,以确保它们可以利用元类所提供的功能。 1 2 class DatabaseRow(metaclass=Meta): pass 为了跟元类配合,Field描述符需要稍加调整。它的大部分代码都可以沿用,只是现在已经不用再要求调用者把名称传给构造函数了,因为这次,元类的__new__方法会自动设置名称。 1 2 3 4 5 6 7 8 9 10 11 class Field: def __init__(self): self.name = None self.internal_name = None def __get__(self, instance, instance_type): if instance is None: return self return getattr(instance, self.internal_name, '') def __set__(self, instance, value): setattr(instance, self.internal_name, value) 有了元类、DatabaseRow基类以及修改过的Field描述符,我们在给客户类定义字段时,就不用手工传入字段名了,代码也不像之前那样冗余了。 这个办法的缺点是,要想在类中声明Field字段,这个类必须从DatabaseRow继承。假如忘了继承,或者所面对的类体系在结构上不方便这样继承,那么代码就无法正常运行。 这个问题可以通过给描述符定义__set_name__特殊方法来解决。这是Python 3.6引入的新功能:如果某个类用这种描述符的实例来定义字段,那么系统就会在描述符上面触发这个特殊方法。系统会把采用这个描述符实例作字段的那个类以及字段的名称,当成参数传给__set_name__。下面我们将Meta.__new__之中的逻辑移动到Field描述符的__set_name__里面,这样一来,就不用定义元类了。 1 2 3 4 5 6 7 8 9 10 11 12 13 class Field: def __init__(self): self.name = None self.internal_name = None def __set_name__(self, owner, name): self.name = name self.internal_name = '_' + name def __get__(self, instance, instance_type): if instance is None: return self return getattr(instance, self.internal_name, '') def __set__(self, instance, value): setattr(instance, self.internal_name, value) 现在,我们可以直接在类里通过Field描述符来定义字段,而不用再让这个类继承某个基类,还能把元类给省掉。 第51条、优先考虑通过类修饰器来提供可组合的扩充功能,不要使用元类 通过元类自动修饰那个类的所有方法。例如,下面的就是这样一个元类,它可以拦截利用本类所写的新类型,并把那个类型里面的每个函数或方法都分别封装到trace_func修饰器之中。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import types trace_types = ( types.MethodType, types.FunctionType, types.BuiltinFunctionType, types.BuiltinMethodType, types.MethodDescriptorType, types.ClassMethodDescriptorType ) class TraceMeta(type): def __new__(meta, name, bases, class_dict): klass = super().__new__(meta, name, bases, class_dict) for key in dir(klass): value = getattr(klass, key) if isinstance(value, trace_types): wrapped = trace_func(value) setattr(klass, key, wrapped) return klass 现在,我们只需要让子类继承dict,并把刚写的TraceMeta当作子类的metaclass就行了 1 2 3 4 5 class TraceDict(dict, metaclass=TraceMeta): pass trace_dict = TraceDict([('hi', 1)]) trace_dict['there'] = 2 这种办法确实有效,而且还把前面的实现方案中忘记修饰的new方法也自动修饰了。但如果子类所继承的那个超类本身已经指定了它自己的metaclass,这次程序无法运行,因为子类的metaclass是TraceMeta,而超类的metaclass是OtherMeta,但TraceMeta并不是从OtherMeta里面继承来的。从理论上讲,我们可以让TraceMeta继承OtherMeta,从而解决这个问题。 然而,如果TraceMeta不是我们自己写的,而是来自某个程序库,那就没办法手工修改它了。另外,如果想同时使用多个像TraceMeta这样的元类所提供的逻辑,那么这套方案无法满足需求,因为在定义类的时候,metaclass后面只能写一个元类。总之,这个方案对受元类控制的子类提出了过多的要求。 为此,我们可以换一种方案,也就是改用类修饰器(class decorator)来实现。这种修饰器与函数修饰器相似,都通过@符号来施加,但它并不施加在函数上面,而是施加在类的上面。编写类修饰器时,我们可以修改或重建它所修饰的类,并通过return语句返回处理结果。 1 2 3 4 5 6 7 def my_class_decorator(klass): klass.extra_param = 'hello' return klass @my_class_decorator class MyClass: pass 现在就来实现这样一个类修饰器,它可以施加在类上面,让该类的所有方法与函数都能自动封装在trace_func之中。这个类修饰器本身是个独立的函数,它的代码基本上可以沿用早前所写的TraceMeta.__new__。这套方案要比采用元类实现的方案简单得多。 类修饰器其实就是个函数,只不过它可以通过参数获知自己所修饰的类,从而重建或调整这个类并返回修改结果。如果要给类中的每个方法或属性都施加一套逻辑,而且还想尽量少写一些例行代码,那么类修饰器是个很值得考虑的方案。元类之间很难组合,而类修饰器则比较灵活,它们可以施加在同一个类上,并且不会发生冲突。

2024/4/14
articleCard.readMore