普通视图

发现新文章,点击刷新页面。
昨天以前电波障害

外部化的J人

2025年11月3日 22:07

生活中有一类人,喜欢催促别人制定计划。只要事情还悬在半空,他就感到不安。

这种人不一定是公司老板,也不一定多强势,他就是喜欢问“什么时候能定下来?”“周末怎么安排,到底去哪?”,类似这样,向外寻找确定性。

这种人,我们暂且叫他们“外部化的 J 人”或“Ex J 人”,J 人就是 MBTI 人格分析中的 Judging 型性格的人,喜欢预判未来走向,热爱制定计划。与之相对的是 P 人,随性自在。

Ex J 人,就是把这种 J 属性外溢给他人的人,同化他人成为 J 人的 J 人。

Ex J 人把确定性留给自己,把焦虑传给世界

我观察这类人通常控制欲较强。控制欲有向内生长的控制欲,比如让工作和生活都井井有条,充满秩序感。也有向外生长的控制欲,比如试图操纵别人,要求他人跟自己保持高度一致。

Ex J 人是控制欲外溢型,他们的控制欲大多时候是向内生长的。当他们内部的控制欲过多时,就不经意间外溢给别人。一旦他们的 J 望落空,生活和工作无法井井有条时,他们就把负面情绪传递到不确定的根源的那个人,展现出压迫感。

我最近工作和生活中遇到两个这样的人。反思之后,我也通过自查机制(I 人的内心搜索引擎)发现我自己也不经意间成为过 Ex J 人。

如何避免自己成为 Ex J 人呢?

我想第一条原则是不要重复追问。我在外企里跟欧洲同事沟通,发现他们与中国同事很大的一点不同:他们除了“肯定”和“否定”回应之外,还有一种“不回应”。

如果他没想好,他有权不回应。不回应也是一种回应。代表“我没想好,等我想好了再说”。但在我们的语言体系里,不回应显得不礼貌,没自信……

从咱们的应试教育体系也能看出来,我们是盛产 J 人的,这些 J 人有一些逐渐蜕化为更极端的 Ex J 人,走向危害社会的一面(开玩笑,其实也没有那么严重)。

所以,别人不回应时,你当作是另一种回应。你就不是 Ex J 人了。

如何跟 Ex J 人沟通呢?

我也不知道答案,所以不耻 Chat 问,请教了 GPT 答案:要分成两步,先缓和对方情绪,表达理解;再用模糊的步骤代替精确的结论,比如你定不出计划,就说现在定不下来,一旦发生什么,就可以如何应对。让对方看到你的思考过程。

这样,用大概的步骤,或模糊的时间点,代替精确的回答,让对方的控制欲有所缓解,也没有被对方卷入节奏。

啊,做人好累。最后,希望你成为 P 人吧,如果实在不行,也答应我,当个好 J 人,好吗。

Omarchy 一些中文环境下的设置

2025年10月22日 23:21

最近把家里电脑装上了 DHH 的 Omarchy(一个基于 Hyprland 桌面环境的 Arch Linux 发行版)。

装完后有一些需要改造的配置记录在这篇文章中,供大家参考。

4K 显示器设置

修改系统菜单 Setup - Monitor,按照你自己显示器的分辨率,根据配置文件中的注释设置参数,比如我的显示器 27 寸 4k:

env = GDK_SCALE,1.75
monitor=,preferred,auto,1.875

额外为 QT 软件添加缩放设置

env = QT_AUTO_SCREEN_SCALE_FACTOR,1
env = QT_SCALE_FACTOR,1.75

中文输入法

参考 Fcitx 最佳配置实践。文章中“安装 emacs-rime”这一章节如果不使用 emacs 可以忽略。

终端字体修改设置

系统自带字体在终端环境下对中文都不太友好。我喜欢用Maple Mono这个字体,可以安装 AUR 包maple-mono-nf-cn这个库。然后在~/.config/alacritty/alacritty.toml中修改字体设置:

[font]
normal = { family = "Maple Mono NF CN" }
bold = { family = "Maple Mono NF CN" }
italic = { family = "Maple Mono NF CN" }
size = 12

键盘默认的 numlock 关闭

Omarchy 安装后会默认开启小键盘的 numlock,可以在系统菜单 Setup - Input 中设置:

numlock_by_default = false

Neovim 的一些设置

Neovim 默认使用了 Lazyvim,我在~/.config/nvim/lua/config/options.lua里添加了一些配置,可以参考注释选择性添加:

-- 修复中文字体在终端里有下划线的问题
vim.opt.spelllang = { "en", "cjk" }

-- 在 markdown 文件中关闭语法检查
vim.api.nvim_create_autocmd("FileType", {
 pattern = "markdown",
 callback = function()
 vim.diagnostic.enable(false)
 end,
})

然后创建~/.config/nvim/lua/plugins/flush.lua并添加内容,恢复 Vim 默认的 normal 模式下 s 键的功能:

return {
 {
 "folke/flash.nvim",
 keys = {
 { "s", mode = { "n", "x", "o" }, false },
 },
 },
}

AI Agent + 产品经理 = 产品测试工程师

2025年10月8日 20:39

9 月公司组织讨论 AI 在工作场景中的应用。正好我在研究 E2E 测试相关的话题,于是尝试了一下 OpenCode 和 Playwright,发现效果惊人的好。

用 OpenCode 而没有选其他 AI Agent 框架(如 Claude Code)是因为它可以集成公司的企业版 Github Copilot 账号,这样我们在公司内网可以无限量调用 GPT-4 和 Claude Sonnet 等大语言模型。

其次微软做的 Playwright 是一个可以调用浏览器 API 的自动化测试框架。相比于 Selenium 更轻量,社区维护更积极,和大模型结合也更好(有官方的 MCP Server)。Playwright 还内置了 webdriver,免去了很多环境配置的麻烦。

基于 OpenCode 和 Playwright-MCP-Server,稍加少量提示词模板,就可以不写一行测试代码,完整跑通一组 Web UI 的 E2E 测试用例。这在过去简直无法想象。

一直以来我都认为,让程序员去编写 E2E 测试代码费事费力,实属弊大于利的行为。对于边界情况和性能,单元测试和 API 测试可以满足90%以上的需求。E2E 测试的价值主要在于发现UI交互和集成方面的问题。用自动化 E2E 测试代码去覆盖集成测试和 UI 测试场景,不但维护成本极其高昂,每个微小的 UI 调整,都可能破坏测试代码,而且统计下来,测试组合中失败的用例有一半以上并不是功能异常引起,而是 UI 加载延迟、前端修改了变量名称、测试环境网速慢等原因。而对于一些真正威胁集成环境的特殊情况,比如网络中断造成的请求重试、接口修改造成的参数越界,编写 E2E 测试的效率都不如 UT 和 API 测试。因此我一直鼓励团队招聘一名全职的测试开发工程师,而不是让开发每个迭代都留出一部分时间去维护 E2E 测试用例。

另一方面,站在团队项目负责人的角度,我更关心需求是否真正被理解和落地,如何去验证程开发工程师实现的结果。

AI Agent 的出现让敏捷开发的工作流程有了变化。如上文提到的 OpenCode + Playwright-MCP-Server 的组合,AI 只需要阅读用户文档了解一些 UI 操作的基础知识,就能根据测试用例的自然语言描述,自动打开浏览器,根据提示词的要求一步步点击页面元素完成整个业务功能的操作,如果稍加指导,还能给出具体的执行步骤、结果、遇到的问题,生成完整的测试报告。这并不亚于聘请了一名初级测试工程师。

因为维护成本的极大降低(只需要维护一组测试用例的 markdown 描述文件),过去很多细节的 UI 测试场景可以用 AI Agent 来覆盖。最重要的是,这种工作完全不依赖研发人员,作为产品经理或者 PO、BA,都可以直接用自然语言编写测试用例,使编写用户故事 - 验证功能形成闭环,消除了业务 - 研发 - 测试三者之间互相转述需求带来的歧义。

丰田模式的原则中提到生产中造成浪费的几种情况:

  • 过度生产
  • 等待
  • 不必要的运输
  • 过度加工
  • 过多的库存
  • 不必要的移动
  • 缺陷

AI Agent 一定程度上解决了“过度生产(要重复编写测试代码)”,“等待(从需求实现到测试用例实现,最后才能验证功能)”,“不必要的运输(业务需求在不同人员之间的传递)”三个方面的浪费。

遇到 Linux 系统 Kernel Panic 了该如何应对

2025年7月19日 01:06

晚上打开家里的零刻 Ser6 主机,赫然发现 Kernel Panic 了 😱。

这时候很多人就慌了,其实完全不必慌。只需要用一个 LiveUSB 启动盘修复一下。

不过我这个 Ubuntu 安装了一年多,一直很稳定,家里也没预备 LiveUSB,无奈只能掏出吃灰好几年的旧电脑,开机密码猜了半个多小时才进入系统……下载 Ubuntu ISO 文件,制作 LiveUSB。

下面是从 LiveUSB 启动后进入 Try Ubuntu,用 Terminal 排错的过程,供大家参考。

1. 找到根分区和 EFI 分区

lsblk -f

会返回类似如下结果,其中vfat格式是efi分区,ext4是系统根分区。

NAME FSTYPE LABEL UUID MOUNTPOINT
nvme0n1 
├─nvme0n1p1 vfat 1234-5678 /boot/efi
└─nvme0n1p2 ext4 955b06a9-983d-4e04-b2ef-60b559db46e6 

2. 用fsck修复分区错误

注意这一步及之后的步骤,分区的路径要用上一步你的系统中的分区路径。

先修复根分区:

sudo fsck -f /dev/nvme0n1p2

出现提示输入y允许,或者a全部允许。这一步我发现了一些错误并成功修复了。

接下来检查修复efi分区:

sudo fsck -f /dev/nvme0n1p1

我在这一步出现提示:

there are different between boot sector and it's backup:
1) Copy original to backup
2) Copy backup to original
3) No action

根据网上搜索到的结果,如果系统能正常进入grub,说明我原始扇区是好的,所以我选择 1) Copy original to backup 复制原始引导扇区到备份扇区。

3. 挂载原系统并重建 initramfs

下面这一步,要把原系统根分区挂载到当前 LiveUSB 系统里,同时为了执行必要的命令,要把 LiveUSB 系统的四个关键目录挂到原系统。

sudo mkdir -p /mnt/ubuntu
sudo mount /dev/nvme0n1p2 /mnt/ubuntu


sudo mount --bind /dev /mnt/ubuntu/dev
sudo mount --bind /proc /mnt/ubuntu/proc
sudo mount --bind /sys /mnt/ubuntu/sys
sudo mount --bind /run /mnt/ubuntu/run

sudo mount /dev/nvme0n1p1 /mnt/ubuntu/boot/efi

一顿操作后,就可以切换到原系统 root shell 了。

sudo chroot /mnt/ubuntu

然后是安装 grub 并重新为系统内核生成 initramfs 启动镜像。grub-install命令的参数要根据自己的系统设置。

grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu
update-initramfs -c -k all
update-grub

最后,退出原系统 root shell,重启。

exit

sudo reboot

拔掉U盘进入原系统,我这时就可以正常登录了。

总结

  1. 不要慌

  2. 家中常备 Live USB

  3. fsck 命令修复分区错误

  4. mount挂载原系统必要文件,进入原系统并重建 initramfs

如何与「老登」相处

2025年7月13日 12:40

Thou Shalt Not

每一天,我们难免要和一些资历稍长(不一定指年龄)的「老登」打交道。大部分打工人在退休以前,都要活在一个由老登制定游戏规则的世界里。

如何应对这样的世界?下面是一个参考,不构成建议,请勿模仿。

1934年美国电影制片人与发行人协会出台了历史上最严格的电影制作守则──「Hays Code(海思法典)」。这部守则旨在提高观众的道德水准

「禁止画面出现裸露、挑逗和情欲接吻。禁止描绘宗教、吸毒、跨种族恋,不准出现复仇情节。」

就像它的初衷那样,这部法典促进了好莱坞电影业的发展……以另一种形式。

很快啊,好莱坞导演发现,这些严苛的禁令能帮助他们票房大卖,只要他们巧妙地规避掉那些字面上的要求

事实证明,观众确实还是喜欢看脏的。好莱坞小将们在遵守和违背之间,选择了擦边。

Observe its letter and violate its spirit as much as possible.

形式上遵守规则,并极力背离其初衷

这是一种与现实妥协,又不完全妥协的哲学。

派拉蒙摄影师 Whitey Schafer 拍摄一张巨幅讽刺性摄影作品(本文标题配图), 名为「你不可」,内容是「制片人绝对不能做的十件事」,并在画面中将这十件事全部展现出来。

  • Law Defeated(正义被击败)
  • Inside of Thigh(大腿内侧)
  • Lace Lingerie(蕾丝内衣)
  • Dead Man(死人)
  • Narcotics(毒品)
  • Drinking(饮酒)
  • Exposed Bosom(裸露胸部)
  • Gambling(赌博)
  • Pointing Gun(枪口指人)
  • Tommy Gun(汤普森冲锋枪)

数年后好莱坞取消了这些规定,取而代之的是更宽松的电影分级制度。这幅照片也成了讽刺那个时代的经典艺术作品。

回到开头的问题,在一个老登们制定规则的世界里,如何自处?

答案就是玩一个无限的游戏(?)。

有限的游戏在边界内玩,无限的游戏玩的就是边界。

DeepWIKI 是如何工作的

2025年5月24日 12:50

DeepWIKI 是一个从源代码仓库生成详细文档的 AI Agent 项目,由 Devin.ai 提供。自从它火了以后,我就一直非常好奇它是怎么工作的。

我梳理了网上的相关资料和一些开源项目,得到了相对清晰的工作流程。对于其中难点的部分,我会在后续文章中跟进我的发现。

生成代码结构地图

首先 DeepWIKI 本质是一个 RAG 系统,它读取源代码仓库作为输入,将代码进行语法分析之后转换成代表语法结构和文件结构的元数据代表代码描述和片段的向量数据两部分,元数据存到关系数据库中,同时将对应的代码片段存储到向量数据库中以便后续 LLM 检索。

生成 WIKI 页面

生成 WIKI 页面的过程,就是 RAG 系统 query 的过程:

  1. 程序递归读取项目结构。
  2. 从元数据库中查询当前文件的元数据,再从向量数据库中查找相关性最强的代码和描述信息的 id。
  3. 用这些 id 再去元数据库里查询到描述信息,从工程文件中查询对应代码片段。
  4. 将上面的所有内容作为 context,根据元数据类型(架构、组件等)组合适当的 prompt,输入给 LLM。
  5. 最后由一个前端渲染引擎把 LLM 的输出渲染成文档页面。
  6. 重复步骤 1。

图片来自https://www.gptsecurity.info/2024/05/26/RAG/

难点 1:分块策略

上述过程中,如何在嵌入(embedding)前给代码分块,是个比较值得研究的话题。一般自然语言的分块是基于段落、句子、标点符号等方式,拆分出来的 chunk 包含完整的句子或者段落上下文。

但是代码的拆分不同,比如一个函数体由{ }包裹起来,如果使用自然语言的分词器分词,会导致上下文被拆分到不同 chunk 中,后续检索向量时准确度就会下降。

目前的解决办法有两种,一种是基于整个文件的分块,这种情况文件大小不能超过分块大小的上限,而且分块数据缺少真实的调用关系上下文。我们知道,代码的组织单元并不是文件(文件树只是方便人类阅读的组织形式),而是以类和函数为单元的网状依赖关系图。

第二种方式就是先用语法工具对代码文件做静态分析,再根据分析结果将代码以语法结构进行拆分。这种方式实现复杂,网上并没有找到相关的资料,幸而读到这篇RAG for a Codebase with 10k Repos,它介绍了如何利用语法静态分析来给代码分块,构建高效的代码仓库 RAG 系统。 但是文章也没有提供开源实现,考虑到作为商业项目的核心技术,这部分内容非常值得深入。我会持续跟进这部分内容的研究。

难点 2: 解析语法结构

元数据的语法解析要比向量数据简单一些,我从另一个开源项目Repo Graph中找到一些线索。

这个项目使用了 tree-sitter 来分析项目语法结构,从而得到三类元数据文件:

  • tag.json:代表一个文件、函数、类的路径、行号、描述等基础信息。
  • tree_structure.json: 项目的文件树结构信息。
  • *.pkl: 对象依赖关系图。

*.pkl是语法分析器扫描项目文件之后得到的一个网状的对象关系图,它使用 python 的 pickle 库把 python 网状对象序列化成文件。

从这个项目的实现来看,难点 1 中嵌入向量的过程似乎也可以用 tree-sitter 生成的代码元信息对代码按行分块。

提示词工程

在 RAG 查询阶段,要根据当前元信息的类型,组装不同的提示词。

这个项目Agent as a Judge 里有不少提示词可供参考:

生成概述的提示词

Provide a concise overview of this repository focused primarily on:
* Purpose and Scope: What is this project's main purpose?
* Core Features: What are the key features and capabilities?
* Target audience/users
* Main technologies or frameworks used

生成架构文档的提示词

Create a comprehensive architecture overview for this repository. Include:
* A high-level description of the system architecture
* Main components and their roles
* Data flow between components
* External dependencies and integrations

生成组件文档的提示词

Provide a comprehensive analysis of all key components in this codebase. For each component:
* Name of the component
* Purpose and main responsibility
* How it interacts with other components
* Design patterns or techniques used
* Key characteristics
* File paths that implement this component

其余请参考项目文件,就不一一列举了。

总结

DeepWIKI 是一个基于 RAG 系统的代码文档生成工具,它通过以下步骤工作:

  1. 对代码仓库进行语法分析,生成元数据和向量数据
  2. 然后通过 RAG 系统查询这些数据来生成文档
  3. 最后用前端引擎渲染成可读的文档页面

实现过程中有两个主要难点:

  • 代码分块策略:需要考虑代码的语法结构,不能像自然语言那样简单分割
  • 语法结构解析:可以使用 tree-sitter 等工具来解析代码结构

虽然目前有一些开源项目可以参考,但核心的分块策略实现仍然需要深入研究。

参考项目

读完这本书,沟通将是一个有迹可循的过程

2025年5月13日 13:49

沟通能力能够决定了一个人是否幸福。在与周围人相处融洽的环境下,能让我们觉得被包容,被理解。反之会让我们苦恼:明明抱有善意的交流,为什么总是导致冲突和分歧。到底问题出在哪里?

《超级沟通者》这本书最近给我很多启发,我希望和你分享一些沟通技巧。这和那些强调同理心,强调谈判方法的书籍不同,它提出了一个更深层次的观点:对话的目的,是了解身边人如何看待世界的同时,帮助他们理解我们的想法

当对话进入到个人感受层面的交流时,人与人才能建立起深度的连接

哈佛大学进行过一项调查,为什么有些会议能够缓和参与各方的冲突并达成共识,为什么有些谈话的参与者能够建立起深层次的连接。研究者发现,这类沟通中往往存在一个高中心度参与者,也就是标题中的「超级沟通者」,他具有这些特点:

  • 能够仔细倾听对方的表达和言外之意
  • 能够识别并回应对方的情绪
  • 能够提出问题并引导对方交流内心的感受

进一步地,研究发现人与人的不信任和不理解,往往出于以下一些原因:

  • 沟通浮于表面,谈话的目的模糊,或者内心真实想法没有表露
  • 情绪没有得到回应
  • 身份没有获得认同

因此,为了进行深入的沟通,人们通常要从一些表面的事实性问题,进入到更关注内心感受、观点、人生经历、价值观、信仰等层面的交流。也就是作者认为的,学习型对话。

学习型对话

作者提出学习型对话这个概念,认为一个深度的沟通过程,围绕着三种类型的对话展开:

务实对话、情感对话,以及社交对话。

务实对话

务实对话是指那些为了协商做出决策,或者各方为了达成共识而参与的对话。

这一类对话通常遵循理性的态度,按照「成本-收益」的逻辑进行。这是很多职场会议、谈判、家庭决策场景最常见的对话。这一类对话有个难点:虽然大家都知道要理性客观,但是往往分歧出现在一些价值观问题上。

比如你明知道父母是为你好,但是有些他们的期望就是无法满足。再比如工作中,你给同事提了一个建议,但是对方因为过往工作经历的原因不愿意采纳,即使没有正当的理由。

这种时候,谈话的思维方式就要从「成本-收益逻辑」,切换到「相似性逻辑」。即我们要从生活经验和价值观出发,找到双方具有的相似性,从而跨越鸿沟。

改变一个人的想法,要触及他的自我认知,让他明白改变观点带来的价值,感受到因做出正确决定而带来的自尊心

价值观是从长期的个人经历中建立起来的东西,很难被改变。人们会觉得信仰崩塌、自尊心受挫。沟通的关键是让从对方的底层价值观出发,让他认可改变带来的价值,并把做决定的权力交给对方。

下面是一些作者分享的,谈话中可以参考的方法:

  • 可以提前准备一个话题库,在聊天陷入沉默时抛出一些话题,增强你谈话的自信心。
  • 当识别出谈话是一个务实谈话时,先通过开放性问题让对方谈谈自己的感受、价值观和信念。
  • 邀请对方做出判断,询问对方的经历。
  • 如果对方转移话题,这代表对方希望扩展谈话的范围,你可以鼓励并留心他的言外之意。

想象关于家庭决策的冲突,当父母的价值观无法被你接受,你可以询问他们的经历,信念(过去遇到类似的事情会怎么办),分享我们的经历和感受,找到相似的部分(比如初衷都希望让家人过得更好)。然后突破屏障(如果父母处在我们的经历中,他们会怎么做)。

退一步讲,即使无法一次性彻底解决分歧,也可以在这次深度交流中获得更多理解,加深双方的感情。

情感对话

情感类型的对话,通常源于谈话的一方希望自己的情绪得到回应。

生活中经常发生的一类思维错位:女朋友跟你表达情绪,你跟她分析问题。

情感对话不是为了找到解决方案或者达成一致。情感对话的目的是触发情绪传染。一方感受到对方的脆弱,并以自身的脆弱作为回应,从而建立起信任、理解以及情感连接。

情感对话的难点是通过对方点滴的情绪流动,识别出这是一场情感对话,并给予恰当的回应

我们总听到别人谈共情和换位思考。其实这很难做到,当我们和对方有不同的人生经历、处于不同的情境时,很难真正地与对方换位思考。所以,进入情感对话第一步,是要询问对方,用问题打开对方的心扉,先试探性地提出表面的、安全的事实性问题,逐步询问到对方的信仰、价值观、意义、体验等等

当对方愿意分享内心真实想法时,就到了第二步,给予足够的回应。什么叫足够呢?回应的情绪能量要和对方的情绪能量匹配,不要太过也不要太少。让双方进入到一场情绪表达的共振中。要避免单向输出,尽量轮流表达,互相回应。有一个经典的错误是,当对方讲述了一个过往悲伤的经历后,我们为了表达理解,滔滔不绝讲述自己的个人经历并把谈话的注意力引向自己。这就会让对方的情绪无法着陆。一定要持续的、双向的、深入的个性化表达

最后一步,建立「理解循环」。在倾听之后,询问对方自己理解的是否正确。不要试图掌控对话的走向和论调,不要试图控制对方。

在亲密关系中,关于控制权的争夺屡见不鲜。一方意识到自己无法掌控某件对自己重要的事情时,就会发怒甚至责难对方。而往往这种内心对控制欲的渴望存在于生活各个细节中,处理不好就会令双方互不相让,引发的连锁反应被称为「数怨并诉」(kitchen-sinking),演变成破坏性争吵。

书中分享了一个调查,那些幸福的家庭和不幸福的家庭一样,都经常发生关于内部控制权的争吵,但是幸福家庭总能把冲突控制在一个合理的范围内。这些家庭成员不会试图控制对方,而是控制自己,控制自己所处的环境或是控制冲突的界限。然后通过对话,让双方找到可以共同控制的事情。

比如在一个关于「谁该做家务」的争吵中,我们可以控制自己(不要发怒或者指责对方),控制环境(在一个双方情绪愉悦的情况下讨论这个话题),控制冲突的界限(不去指责对方的生活习惯等),再通过协商找到一些双方可以共同努力控制的事情。

作者还有一些小建议:

  • 注重礼貌
  • 警惕讽刺性语言
  • 多表达感谢、问候、歉意
  • 避免在公开场合提出批评

社交对话

社交类型对话,是在对话过程中寻找身份认同,关注社会影响的一类对话。当一名医生用专业知识向患者解释治疗的必要性时,往往效果不如将自己带入到父亲、家人、朋友的角色时效果更好。因为患者担忧的问题往往是对家人的影响。所以如果能在这类谈话中,找到与对方深层次顾虑相匹配的角色,从而进行交流,就能事半功倍。

社交对话的难点是找到对方内心深处的恐惧、疑惑、顾虑等。我们每个人都有多重身份,当与他人对话时,要时刻意识到身份的多样性和复杂性,从而识别出什么情况要切换身份与对方在同一个频道交流。

在多元化的社会中,人们习惯把他人按出身、地域、性别等特征笼统归类,冲破隔阂的关键就是察觉身份的多样性,认识到我们与他们同为父亲、儿子等社会和家庭角色,避免对他人进行模糊的分类,泛泛地夸大某个身份特征的影响。

对于不同主张、不同信仰的人,交流的目的更多是互相理解,分享经历和观点,而不是说服他人改变想法。

总结

我最近在与朋友闲聊时有个体会,一个成熟的人,不能止步于「尊重但不理解」或是「谁好谁坏」的讨论中,即使我们不能解决大部分矛盾和误解,仍然可以做到「我不完全认同,但是我能理解你为什么这样做」。理解观点不同的人,也会让我们自己避免内耗,更加开朗。

回顾上面三种类型的对话,日常交流我们可以通过区分对话类型,切换不同的思维模式,从开放性话题渐进地进入到感受层面的交流,和他人建立更深层次的连接。善加实践,我们的生活和工作都必将获益。

Go服务端性能的一般解决思路

2025年5月6日 10:35

最近遇到一个性能问题,客户反馈,在他们的 IPC 设备后台有两个 Go 语言编写的服务进程占用内存一直在上涨,最大时候达到了总内存的 40% 。其中一个进程就是我们日志采集 Agent。

我首先怀疑是内存泄漏,因为过去发生过 goroutine 阻塞造成的内存泄漏(我在Go 内存泄漏常见模式中讨论过),所以我先针对所有创建和释放 goroutine 的地方进行排查。

在上一次教训之后,我们对代码单元测试层面做了 goruntine 内存泄漏的检测——使用go.uber.org/goleak。只需要在单元测试开头加上一句:

func TestXXX(t *testing.T) {
 defer goleak.VerifyNone(t)
 // ...
}

它就会在测试结束后自动检查是否有残留的 goroutine 协程。对于一些延迟执行的后台 goroutine 可以在单元测试里用 wait 或者 sleep 等待后台释放再结束测试用例。

经过第一轮排查可以排除代码本身 goroutine 造成的问题。于是我把注意力转向了另一个地方:定时任务。

根据客户反馈,在无任何前台操作的情况下,内存也会缓慢上升。

在我们代码里,使用了github.com/robfig/cron/v3这个第三方包,它的作用是编排定时任务。用法是

c = cron.New()
c.AddFunc("@every 10s", callbackFunc)

这种结构定义一个定时任务。它的实现也基于 goroutine,所以我把 go 自带的 pprof 加入到 main.go 的依赖中,重新编译了项目二进制文件并部署到测试环境上(使用跟用户相同的硬件配置)。这样启动项目后就可以在特定端口获取内存信息。(关于 pprof,你可以参考 Profiling Go Programs

我使用 pprof 的接口获取了不同时间间隔的 heap 数据

curl -o heap.1.out http://127.0.0.1:6060/debug/pprof/heap

然后使用

go tool pprof -http=:8099 -base heap.1.out heap.2.out

比较两次结果的差异,在 Web UI 上选择 In Use Space 选项,可以查看到哪些内存没有释放。

虽然经过第二轮排查,依然没有发现内存泄漏。但这一次我注意到服务中的一个定时任务会每隔 10 秒执行一次,执行过程中 CPU 占用率明显上升。在这个任务的代码里,它使用了github.com/shirou/gopsutil/process这个第三方库来查询系统进程 ID 和进程名等信息。

我查看它的源码后发现,这个库查询进程 ID 的方式,是把系统中所有的进程信息加载到内存中,然后匹配 ID 或者名称。因此,如果用户设备上的进程过多,就会每次查询时占用大量内存。

在一个 10 秒执行一次的定时任务中调用这个库,显然是非常低效的。

经过与客户进一步沟通,我们发现出现内存过高的两个进程中,另一个进程也有 CPU 占用过高的现象。于是我们让客户把 top 命令的截图发给我们。在看到截图的一瞬间,问题的真相就浮出水面了:

客户使用的 IPC 设备是性能比较低的版本,虽然内存较大,但 CPU 性能捉急。如果有多个进程同时执行后台任务,CPU 就会周期性打满,造成任务阻塞。而我们使用的第三方库基于 goroutine 来实现定时任务。在上一个任务被阻塞时,下一个任务依然会继续创建新的后台 goroutine,导致内存中的 goroutine 协程堆积地越来越多。

这是一个定时任务的 CPU 占用过高,间隔过短,造成的 goroutine 阻塞问题。

知道了原因,剩下的工作就是优化代码逻辑、更新版本、跟客户解释原因……

以上就是这次排查 Go 服务性能问题的过程,如果你也遇到类似情况,希望对你有所帮助。

为什么不应该让AI生成单元测试

2025年5月1日 09:27

最近听到 Gru.ai 创始人张海龙老师在一档播客节目中提到自动生成 Unit Testing 是他们在做 AI Coding 的主要方向。

Gru.ai 官网上有这么两句话:

Forget about unit testing – get covered automatically (忘记单元测试 - 自动覆盖) Harness the expertise of AI engineers to boost your team’s testing efficiency while reducing costs and ensuring top-notch quality. (利用 AI 工程师的专业知识来提高团队的测试效率,同时降低成本并确保一流的质量。)

张海龙老师在 AI Coding 方向的洞见让我很有启发。我只是对用 AI 写测试降本增效这种说法,持怀疑态度。我想他们在写第二句话时还有点不自信,最后还要画蛇添足补充一句 ensuring top-notch quality(确保一流质量)。

单元测试是需求的具象化。是整个测试体系中最小粒度、最贴近代码实现的约束工具。单元测试不仅被用来检查代码是否满足需求,更多时候,被用来检测边界条件(Corner Case),因为一段程序是否可靠,最重要的是在边界条件下它不会出错。这也是有经验的人类工程师区别于初级工程师的特点。

但是 Gru.ai 在做的,是用AI 提高单元测试覆盖率, 众所周知,覆盖率提高不等价于测试效率提高,更不等于质量提高

用一句提示词让 AI 自动帮你写出可以运行的单元测试。这对初级程序员来说非常具有诱惑力。好比一个射击运动员为了提高射击准确度,每次先开枪,然后在子弹坑附近画上靶子。

提升测试覆盖率的目的,是让人类工程师充分考虑边界条件。AI 辅助人类生成测试是一种节省时间的做法,这无可厚非,而 Gru.ai 却让我们「忘记单元测试,自动覆盖」。但 AI 大多时候不清楚边界条件,除非人类显式地告诉它。那么 AI 如何自动推断边界条件?我们又如何确信 AI 推断的边界条件是正确的?AI 测试了代码,谁来测试 AI ?

如果说 Cursor 这类 AI Coding 产品凝聚了硅谷程序员们对 Vibe Coding 的想象,那么 Gru.ai 就是中国程序员们对 Vibe Testing 的「美好期望」。

与AI协作编程──痛点篇

2025年3月23日 00:00

在与 AI 协作编程中,经常遇到一些大模型无法正确执行的情况。最常见的有:

  • 任务死循环
  • 模型无法修复环境问题
  • 模型执行长任务后半段忘记上下文

一些使用经验

以我自己为例,我经常使用 Cline + Github Copilot 的组合。我很喜欢 Cline 的功能是 Checkpoint restore,它可以在执行错误的位置重新编辑提示词执行。这让我可以在相同的任务中调用不同的模型,观察他们处理问题的能力。

用作规划(Plan)的模型通常用 Deepseek-R1,Gemini 2.0 Flash Thinking,Claude 3.7。这里除了 Claude 3.7 能够比较准确给出计划外,其他模型多少都容易走「歪路」, 比如 Deepseek-R1 喜欢做一些多余的事情,让它翻译中文,它会调用 MCP 的翻译服务而不是自己翻译。

从经济角度考虑,解决简单问题 Gemini 2.0 Flash Thinking 是比较快速、经济的模型。复杂问题直接上 Claude 3.7 可能更容易控制成本。

用作执行任务(Act)的模型里,Deepseek-V3 表现非常不稳定,经常死循环或丢失上下文。Claude 太贵,而 Gemini 2.0 Flash 是相对准确且划算的模型。置于国产的 Qwen 系列模型不完全支持 Function Calling,Cline 也没有适配,所以暂时无法测试。

AI 编程疑难杂症的应对方法

最近读到AI Blindspots这篇文章,作者系统性整理了 AI 编程中遇到的问题和他的思路。对我非常有启发。我用 Agent 把它翻译成了中文并人工做了润色,你可以在这里读到:AI 编程的盲点

概括起来,解决 AI 问题的核心要领还是三点:更准确的提示词、更完整的上下文、缩小问题规模。

相信随着技术的发展,编程范式会发生翻天覆地的变化。如果重构变得如此容易,那么马丁福勒的《重构》是否应该出一套 AI 时代下的新范式。如果文档不再是被人读,而是喂给模型当作上下文,那么文档的形态应该是什么样?是否提供一个向量化的文档接口供大模型调用,将是未来编程框架的新常态?

我对未来充满期待。

Python yield关键字的底层实现

2016年12月29日 02:00

这几天面试被问到类似的问题,顺便看了看 Python 的源码,参考网上的教程,总结一下 yield 关键字在 C 层面是如何实现的。

举个栗子

我们先看一个 python 生成器函数的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from dis import dis

def func():
 i = 4
 yield i
 print i

dis(func)
a =func()
a.next()
a.next()

使用 python 的库 dis 可以直接查看 python 虚拟机运行的字节码。dis(func)的打印如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 6 0 LOAD_CONST 1 (4)
 3 STORE_FAST 0 (i)

 7 6 LOAD_FAST 0 (i)
 9 YIELD_VALUE
 10 POP_TOP

 8 11 LOAD_FAST 0 (i)
 14 PRINT_ITEM
 15 PRINT_NEWLINE
 16 LOAD_CONST 0 (None)
 19 RETURN_VALUE

我们猜测其中第二列(代表字节码偏移量)为 9 的指令YIELD_VALUE就是 yield 关键字的执行代码,进入 Python2.7.12 源码目录,在解释器执行字节码的主函数PyEval_EvalFrameEx中找到了下面一段:

1
2
3
4
5
6
7
8
 TARGET_NOARG(YIELD_VALUE)
 {
 retval = POP();
 f->f_stacktop = stack_pointer;
 why = WHY_YIELD;
 // 跳转到fast_yield处。fast_yield里处理了一下状态位然后返回结果
 goto fast_yield;
 }

其中TARGET_NOARG为封装了case语句的宏,这句话的意思是,如果字节码是YIELD_VALUE,就把栈顶元素赋值给retval,然后跳转到fast_yield处,fast_yield处代码进行了一些状态判断后直接返回了retval

生成器是如何记录代码返回位置的

显然,如果这时候调用代码a.next()就会直接返回 yield 后边的表达式结果。这对应了上面 C 代码的fast_yield部分,那生成器怎么记录上次执行的位置并在下一次调用a.next()的时候从上次的位置继续执行的呢?

Python 在解释代码时,是将代码块加载为一个叫 PyFrameObject 的对象,这个对象代表了当前运行的栈帧。PyFrameObject 里有个f_lasti变量用于保存代码当前执行到了字节码的哪个位置。在第二次执行a.next()时,生成器对象把之前携带了f_lasti的 PyFrameObject 当参数传给PyEval_EvalFrameEx,在PyEval_EvalFrameEx里的执行一个 JUMPTO 就直接跳转到了上一次结束生成器时的字节码位置:

 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
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
...
#define FAST_DISPATCH() \
 { \
 if (!lltrace && !_Py_TracingPossible) { \
 f->f_lasti = INSTR_OFFSET(); \
 goto *opcode_targets[*next_instr++]; \
 } \
 // 跳转到fast_next_opcode处
 goto fast_next_opcode; \
 }
...
fast_next_opcode:
 f->f_lasti = INSTR_OFFSET();

 /* line-by-line tracing support */

 if (_Py_TracingPossible &&
 tstate->c_tracefunc != NULL && !tstate->tracing) {
 ...
 /* Reload possibly changed frame fields */
 // 按照f->f_lasti中的偏移量跳转字节码
 JUMPTO(f->f_lasti);
}

其中INSTR_OFFSET宏正是字节码的偏移量。

1
2
3
4
5
#define INSTR_OFFSET() ((int)(next_instr - first_instr))

// co->co_code里保存的是字节码
first_instr = (unsigned char*) PyString_AS_STRING(co->co_code);
next_instr = first_instr + f->f_lasti + 1;

所以生成器对象每次执行结束都把字节码的偏移量记录下来,并把运行状态保存在 PyFrameObject 里,下一次运行时生成器时,python 解释器直接按照偏移量寻找下一个字节码指令。

ZooKeeper 帮助手册

2014年10月14日 08:00

原文地址: http://zookeeper.apache.org/doc/r3.4.6/zookeeperProgrammers.html

本文假设你已经具有一定分布式计算的基础知识。你将在第一部分看到以下内容:

  • ZooKeeper数据模型
  • ZooKeeper Sessions
  • ZooKeeper Watches
  • 一致性保证(Consistency Guarantees)

接下来的4小节讲述了程序开发的实际应用:

  • 创建模块——ZooKeeper操作指引
  • 编程语言接口
  • 简单示例演示程序的结构
  • 常见问题和故障

本文的附录中包含和ZooKeeper相关的有用信息。

ZooKeeper的数据模型

ZooKeeper有一个类似分布式文件系统的命名体系。区别在于Zookeeper每个一个节点或子节点都可以拥有数据。节点路径是一个由斜线分开的绝对路径,注意没有相对路径。只要满足下面要求的unicode字符都可以作为节点路径:

  • 空字符不能出现在路径名
  • 不能出现以下字符: \u0001 - \u0019 and \u007F - \u009F
  • 以下字符不允许使用: \ud800 -uF8FFF, \uFFF0-uFFFF, \uXFFFE - \uXFFFF (where X is a digit 1 - E), \uF0000 - \uFFFFF
  • 字符".“可以作为一个名字的一部分, 但是”.“和”..“不能单独作为相对路径使用, 以下用法都是无效的: “/a/b/./c"或者”/a/b/../c”
  • “zookeeper"为保留字符

ZNodes

ZooKeeper树结构中的节点被称为znode。各个znode维护着一组用来标记数据和访问权限发生变化的版本号。这些版本号组成的状态结构具有时间戳。Zookeeper使用版本号和时间戳来验证缓存状态,调整更新。 每次znode中的数据发生变化,znode的版本号增加。例如,每当一个客户端恢复数据时,它就接收这个版本的数据,而当一个客户端提交了更新或删除记录,它必须同时提供这个znode当前正在发生变化的数据的版本。如果这个版本和目前真实的版本不匹配,则提交无效。 __提示,在分布式程序中,一个字节点可以代表一个通用的主机,服务器,集群中的一员,客户端程序等。但是在Zookeeper中,znode代表数据节点,Servers代表组成了Zookeeper服务的机器; quorum peers refer to the servers that make up an ensemble; 客户端代表任何使用ZooKeeper服务的主机或程序。

znode作为对程序开发来说最重要的信息,有几个特性需要特别关注下:

Watches 客户端可以在znode上设置Watch。znode发生的变化会触发watch然后清除watch。当一个watch被触发,Zookeeper给客户端发送一个通知。更多关于watch的内容请查看ZooKeeper Watches一节。

数据存取 命名空间中每个znode中的数据读写是原子操作。读操作读取znode中的所有数据位,写操作则替换所有数据。每个节点都有一个访问权限控制表(ACL)来标记谁可以做什么。 zookeeper不是设计成普通的数据库或大型对象存储的。它是用来管理coordination data。coordination data包括配置文件、状态信息、rendezvous等。这些数据结构的一个共同特点就是相对较小——以千字节为准。Zookeeper的客户端和服务会检查确保每个znode上的数据小于1M,实际平均数据要远远小于1M。 大规模数据的操作会引发一些潜在的问题并且延长在网络和介质之间传输的时间。如果确实需要大型数据的存储,那么可以采用如NFS或HDFS之类的大型数据存储系统,亦或是在zookeeper中存储指向存储位置的指针。

临时节点(Ephemeral Nodes) zookeeper还有临时节点的概念,这些节点的生命周期依赖于创建它们的session是否活跃。session结束时节点即被销毁。也由于这种特性,临时节点不允许有子节点。

序列节点——命名不唯一 当你创建节点的时候,你会需要zookeeper提供一组单调递增的计数来作为路径结尾。这个计数对父znode是唯一的。用%010d的格式——用0来填充的10位数(计数如此命名是为了简单排序)。例如”0000000001",注意计数器是有符号整型,超过表示范围会溢出。

ZooKeeper中的时间

zookeeper有很多记录时间的方式:

  • Zxid(ZooKeeper Transaction Id): zookeeper每次发生改动都会增加zxid,zxid越大,发生的时间越靠后。
  • Version numbers: 对znode的改动会增加版本号。版本号包括version (znode上数据的修改数), cversion (znode的子节点的修改数), aversion (znode上ACL(权限)的修改数)。
  • Ticks : 多个server构成zookeeper服务时,各个server用ticks来标记如状态上报、连接超时等事件。ticks time还间接反映了session超时的最小值(两次tick time);如果客户端请求的最小session timeout低于这个最小值,服务端会通知客户端最小超时置为这个最小值。
  • Real time : 除了每次znode创建或改动时候将时间戳记录到状态结构中外,zookeeper不使用时钟时间。

ZooKeeper状态结构(Stat Structure)

存在于znode中的状态结构,由以下各个部分组成:

  • czxid - znode创建产生的zxid
  • mzxid - znode最后一次修改的zxid
  • ctime - znode创建的时间的绝对毫秒数
  • mtime - znode最后一次修改的绝对毫秒数
  • version - znode上数据的修改数
  • cversion - 子节点修改数
  • aversion - znode的ACL修改数
  • ephemeralOwner - 临时节点的所有者的session id。如果此节点非临时节点,该值为0
  • dataLength - znode的数据长度
  • numChildren - znode子节点数

ZooKeeper Sessions

客户端通过创建一个handle和服务端建立session连接。一旦创建完成,handle就进入了CONNECTING状态,客户端库尝试连接一台构成zookeeper的server,届时进入CONNECTED状态。通常情况下操作会介于这两种状态之间。 一旦出现了不可恢复的错误:如session中止,鉴权失败或者应用直接结束handle,则handle会进入到CLOSED状态。下图是客户端的状态转换图:

状态转换图

应用在创建客户端session时必须提供一串逗号分隔的主机号:端口号,每对主机端口号对应一个ZooKeeper的server(如:“127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002”),客户端库会尝试连接任意一台服务器,如果连接失败或是客户端主动断开连接,客户端会自动继续与下一台服务器连接,直到连接成功。

3.2.0版本新增内容: 一个新的操作“chroot”可以添加在连接字符串的尾部,用来指明客户端命令运行的根目录地址。类似unix的chroot命令,例如: “127.0.0.1:4545/app/a” or “127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002/app/a”,说明客户端会以"/app/a"为根目录,所有路径都相对于根目录来设置,如"/foo/bar"的操作会运行在"/app/a/foo/bar"。 这一特性在多用户环境下非常好用,每个使用zookeeper服务的用户可以设置不同的根目录。

当客户端获得和zookeeper服务连接的handle时,zookeeper会创建一个Zookeeper session分配给客户端,用一个64-bit数字表示。一旦客户端连接了其他服务器,客户端必须把这个session id也作为连接握手的一部分发送。出于安全目的,zookeeper给session id创建一个密码,任何zookeeper服务器都可以验证密码。 当客户端创建session时密码和session id一起发送到客户端来,当客户端重新连接其他服务器时,同时要发送密码和session id。

zookeeper客户端库里有一个创建zookeeper session的参数,叫做session timeout(超时),用毫秒表示。客户端发送请求超时,服务端在超时范围内响应客户端。session超时最小为2个ticktime,最大为20个ticktime。zookeeper客户端API可以协调超时时间。 当客户端和zookeeper服务器集群断开时,它会搜索session创建时的服务器列表。最后,当至少一个服务器和客户端重新建立连接,session或被重新置为"connected"状态(超时时间内重新连接),或被置为"expired(过期)“状态(超出超时时间)。不建议在断开连接后重新创建session。ZK客户端库会帮你重新连接。特别地,我们将启发式学习模式植入客户的库中来处理类似“羊群效应”等问题。只有当你的session过期时才重新创建(托管的)。 session过期的状态转换图示例同过期session的watcher:

  1. ‘connected’ : session正确创建,客户端和服务集群正常连接
  2. …. 客户端从服务器集群断开
  3. ‘disconnected’ : 客户端失去和服务器集群的连接
  4. …. 过了一段时间, 超过了集群判定session过期的超时时间, 客户端并没有发觉自己和服务集群断开了连接
  5. …. 又过一段时间, 客户端恢复了同集群的网络连接
  6. ’expired’ : 最终客户端重新连上集群,然后被通知已经到期

另一个session建立时zookeeper需要的参数是默认watcher(监视者)。在客户端发生任何变化时,watcher都会发出通知。例如客户端失去和服务器的连接、客户端session到期等。watcher默认的初始状态是disconnected。(也就是说任何状态改变事件都由客户端库发送到watcher)。当新建一个连接时,第一个发送给watcher的事件通常就是session连接事件。

客户端发送请求会使session保持活动状态。客户端会发送ping包(译者注:心跳包)以保持session不会超时。Ping包不仅让服务端知道客户端仍然活动,而且让客户端也知道和服务端的连接没有中断。Ping包发送时间正好可以判断是否连接中断或是重新启动一个新的服务器连接。

和服务器的连接建立成功,当一个同步或异步操作执行后,有两种情况会让客户端库判断失去连接:

  1. 应用在已经失效的session上请求了一个操作时
  2. zookeeper服务器有一个等待中的操作时,客户端会从那台服务器断开连接。即服务器有等待的异步调用。

3.2.0版本新增内容 —— SessionMovedException 一个客户端无法查看的内部异常SessionMovedException。这个异常发生在服务端收到一个请求,这个请求的session已经在另一个服务器上重新连接。发生这种情况的原因通常是客户端发送完请求后,由于网络延时,客户端超时重新和其他服务器建立连接,当请求包到达第一台服务器时,服务器发现session已经移除并关闭了和客户端的连接。客户端一般不用理会这个问题,但是有一种情况值得注意,当两台客户端使用事先存储的session id和密码试图创建同一个连接时,第一台客户端重建连接,第二台则会被中断。

ZooKeeper Watches

所有zookeeper的读操作——getData(), getChildren(), exists()——都可以设置一个watch。Zookeeper的watch的定义是:watch事件是一次性触发的,发送到客户端的。在监视的数据发生变化时产生watch事件。以下三点是watch(事件)定义的关键点:

  • 一次性触发: 当数据发生变化时,一个watch事件被发送给客户端。例如,如果一个客户端做了一次getData("/znode1", true)然后节点/znode1发生数据变化或删除,这个客户端将收到/znode1的watch事件。如果/znode1继续发生改变,不会再有watch发送,除非客户端又做了其他读操作产生了新的watch。
  • 发送给客户端: 这就意味着,事件在发往客户端的过程中,可能无法在修改操作成功的返回值到达客户端之前到达客户端。watch是异步发送给watchers的。zookeeper提供一种保证顺序的方法:客户端在第一次看到某个watch事件之前不可能看到产生watch的修改的返回值。网络延时或其他因素可能导致不同客户端看到watch并返回不同时间更新的返回值。关键的一点是,不同的客户端看到发生的一切都必须是按照相同顺序的。
  • watch依附的数据: 这是说改变一个节点有不通方式。用好理解的话说,zookeeper维护两组watch:data watch和child watch。getData()和exists()产生data watch。getChildren()引起child watch。watch根据数据返回的种类不同而不同。getData()和exists()返回关于节点的数据信息,而getChildren()返回子节点列表。因此setData()触发某个znode的data watch(假设事件成功)。create()成功会触发被创建的znode上的data watch和在它父节点上的child watch。delete()成功会触发data watch和child watch(因为没有了子节点)。

watch在客户端已连接上的服务器里维护,这样可以保证watch轻量便于设置,维护和分发。当客户端连接了一台新的服务器,watch会在任何session事件时触发。当断开和服务器的连接时,watch不会触发。当客户端重新连接上时,任何之前注册过的watch都会重新注册并在需要的时候被触发。一般来说这一切都是透明的。只有一种可能会丢失watch:当一个znode在断开和服务器连接时被创建或删除,那么判断这个znode存在的watch因未创建而找不到。

ZooKeeper如何保证watch可靠性

zookeeper有如下方式:

  • watch与其他事件、watch、异步回复保持有序,Zookeeper客户端库确保任何分发都是有序的。
  • 客户端会在某个监视的znode数据更新之前看到这个znode的watch事件。
  • watch事件的顺序由Zookeeper服务端观察到的更新顺序决定。

watch注意事项

  • watch是一次性触发的;如果你收到watch事件后还想继续得到后续更改的通知,你需要再生成(设置)一个watch。
  • 由于watch是一次性触发,你在获取某事件和发送新的请求来得到watch这个操作之间,无法确保观察到Zookeeper中那个节点在这期间的所有修改。你要准备好应付这种情况出现:znode会在收到事件和再次设置新事件(译者注:对节点的操作)之间发生了多次修改。(你可能并不关心,但是必须了解这可能发生)
  • watch对象,或是function/context对,只会在得到通知时触发一次。例如,如果一个watch对象同时用来监控某个目标文件是否存在和监听getData(),之后那个文件被删除了。那么这个watch对象只会触发一次文件删除事件通知。
  • 如果你断开了同服务器的连接(例如服务器挂了),你在重新连上之前得不到任何watch。出于这种原因,session event会被发送给所有重要的watch handler。可以使用session事件进入安全模式:当断开连接时你收不到任何事件,这样你的进程可以在那种模式下稳健地执行。(译者注:可以通过发送session event使客户端进入安全模式(伪断开连接状态),在安全模式你可以修改代码而不用担心程序收到事件通知)

使用ACL控制ZooKeeper访问权限

zookeeper使用ACL来控制对znode(zookeeper的数据节点)的访问权限。ACL的实现方式和unix的文件权限类似:用不同位来代表不同的操作限制和组限制。与标准unix权限不同的是,zookeeper的节点没有三种域——用户,组,其他。zookeeper里没有节点的所有者的概念。取而代之的是,一个由ACL指定的id集合和其相关联的权限。 注意,一个ACL只从属于一个特定的znode。对这个znode子节点也是无效的。例如,如果/app只有被ip172.16.16.1的读权限,/app/status有被所有人读的权限,那么/app/status可以被所有人读,ACL权限不具有递归性。 zookeeper支持插件式认证方式,id使用scheme:id的形式。scheme是id对应的类型方式,例如ip:172.16.16.1就是一个地址为172.16.16.1的主机id。 当客户端连接zookeeper并且认证自己,zookeeper就在这个与客户端的连接中关联所有与客户端一致的id。当客户端访问某个znode时,znode的ACL会重新检查这些id。ACL的表达式为(scheme:expression,perms)expression就是特殊的scheme,例如,(ip:19.22.0.0/16, READ)就是把任何以19.22开头的ip地址的客户端赋予读权限。

ACL权限

ZooKeeper支持下列权限:

  • CREATE:允许创建子节点
  • READ:允许获得节点数据并列出所有子节点
  • WRITE:允许设置节点上的数据
  • DELETE:允许删除子节点
  • ADMIN:允许设置权限

CREATE和DELETE操作是更细的粒度上的WRITE操作。有一种特殊的情况:

  • 你想要A获得操作zookeeper上某个znode的权限,但是不可以对其子节点进行CREATE和DELETE。
  • 只CREATE不DELETE:某个客户端在上一级目录上通过发送创建请求创建了一个zookeeper节点。你希望所有客户端都可以在这个节点上添加,但是只有创建者可以删除。(这就类似于文件的APPEND权限)

zookeeper没有文件所有者的概念,但有ADMIN权限。在某种意义上说,ADMIN权限指定了所谓的所有者。zookeeper虽然不支持查找权限(在目录上的执行权限虽然不能列出目录内容,却可以查找),但每个客户端都隐含着拥有查找权限。这样你可以查看节点状态,但仅此而已。(这有个问题,如果你在不存在的节点上调用了zoo_exists(),你将无权查看)

内建ACL模式

ZooKeeper有下列内建模式:

  • world 有独立id,anyone,代表任何用户。
  • auth 不使用任何id,代表任何已经认证过的用户
  • digest 之前使用了格式为username:pathasowrd的字符串来生成一个MD5哈希表作为ACL ID标识。在空文档中发送username:password来完成认证。现在的ACL表达式格式为username:base64, 用SHA1编码密码。
  • ip 用客户端的ip作为ACL ID标识。ACL表达式的格式为addr/bits,addr中最有效的位匹配上主机ip最有效的位。

ZooKeeper C client API

插件式ZooKeeper认证

zookeeper运行于复杂的环境下,有各种不同的认证方式。因此zookeeper拥有一套插件式的认证框架。内建认证scheme也是使用这套框架。 为了便于理解认证框架的工作方式,你首先要了解两种主要的认证操作。框架首先必须认证客户端。这步操作通常在客户端连接服务器的同时完成并且将从客户端发过来的(或从客户端收集来的)认证信息关联此次连接。认证框架的第二步操作是在ACL中寻找关联的客户端的条目。ACL条目是<idspec, permissions>格式。idspec可能是一个关联了连接的,和认证信息匹配的简单字符串,也可能是评估认证信息的表达式。这取决于认证插件如何实现匹配。下面是一个认证插件必须实现的接口:

public interface AuthenticationProvider {
String getScheme();
KeeperException.Code handleAuthentication(ServerCnxn cnxn, byte authData[]);
boolean isValid(String id);
boolean matches(String id, String aclExpr);
boolean isAuthenticated();
}

第一个方法getScheme返回一个标识该插件的字符串。由于我们支持多种认证方式,认证证书或者idspec必须一直加上scheme:作为前缀。zookeeper服务器使用认证插件返回的scheme判断哪个id适用于该scheme。 当客户端发送与连接关联的认证信息时,handleAuthentication被调用。客户端指定和认证信息相应的模式。zookeeper把信息传给认证插件,认证插件的getScheme匹配scheme。实现handleAuthentication的方法通常在判断信息错误后返回一个error,或者在确认连接后使用cnxn.getAuthInfo().add(new Id(getScheme(), data))

认证插件在设置和ACL中都有涉及。当对某个节点设置ACL时,zookeeper服务器会传那个条目的id给isValid(String id)方法。插件需要判断id的连接来源。例如,ip:172.16.0.0/16是有效id,ip:host.com是无效id。如果新的ACL包括一个"auth"条目,就用isAuthenticated判断该scheme的认证信息是否关联了连接,是否可以被添加到ACL中。一些scheme不会被包含到auth中。例如,如果auth已经指定,客户端的ip地址就不作为id添加到ACL中。 在检查ACL时zookeeper有一个matches(String id, String aclExpr)方法。ACL的条目需要和认证信息相匹配。为了找到和客户端对应的条目,zookeeper服务器寻找每个条目的scheme,如果对某个scheme有那个客户端的认证信息,matches(String id, String aclExpr)会被调用并传入两个值,分别是事先由handleAuthentication 加入连接信息中认证信息的id,和设置到ACL条目id的aclExpr。认证插件用自己的逻辑匹配scheme来判断id是否在aclExpr中。

有两个内置认证插件:ip和digest。附加插件可以使用系统属性添加。在zookeeper启动过程中,会扫描所有以"zookeeper.authProvider"开头的系统属性。并且把那些属性值解释为认证插件的类名。这些属性可以使用-Dzookeeeper.authProvider.X=com.f.MyAuth或在服务器设置文件中添加条目来创建:

authProvider.1=com.f.MyAuth
authProvider.2=com.f.MyAuth2

注意属性的后缀是唯一的。如果出现重复的情况-Dzookeeeper.authProvider.X=com.f.MyAuth -Dzookeeper.authProvider.X=com.f.MyAuth2,只有一个会被使用。同样,所有服务器都必须统一插件定义,否则客户端用插件提供的认证schemes连接服务器时会出错。

一致性保证

ZooKeeper是一个高性能,可扩展的服务。读和写操作都非常快速。之所以如此,全因为zookeeper有数据一致性的保证:

顺序一致性 客户端的更新会按照它们发送的次序排序。

原子性 更新的失败或成功,都不会出现半个结果。

单独系统镜像 不管客户端连哪个服务器,它看来都是同一个。

可靠性 一旦更新生效,它就会一直保存到下一次客户端更新。这就有两个推论:

  1. 如果客户端得到成功的返回值,说明更新生效了。在一些错误情况下(连接错误,超时等)客户端不会知道更新是否生效。虽然我们使失败的几率最小化,但是也只能保证成功的返回值情况。(这就叫Paxos算法的单调性条件)
  2. 客户端能看到的更新,即使是渡请求或成功更新,在服务器失败时也不会回滚。

时效性 客户端看到的系统状态在某个时间范围内是最新的(几十秒内),任何系统更改都会在该时间范围内被客户端发现。否则客户端会检测到断开服务。

用这些一致性保证可以在客户端中构造出更高级的程序如 leader election, barriers, queues, read/write revocable locks(无须在zookeeper中附加任何东西)。更多信息Recipes and Solutions

zookeeper不存在的一致性保证: 多客户端同一时刻看到的内容相同 zookeeper不可能保证两台客户端在同一时间看到的内容总是一样,由于网络延迟等原因。假设这样一个场景,A和B是两个客户端,A设置节点/a下的值从0变为1,然后让B读/a,B可能读到旧的数据0。如果想让A和B读到同样的内容,B必须在读之前调用zookeeper接口中的sync()方法。

编程接口

常见问题和故障

下面是一些常见的陷阱:

  1. 如果你使用watch,你必须监控好已经连接的watch事件。当ZooKeeper客户端断开和服务器的连接,直到重新连接上这段时间你都收不到任何通知。如果你正在监视znode是否存在,那么你在断开连接期间收不到它创建和销毁的通知。
  2. 你必须测试ZooKeeper故障的情况。在大多数服务器都可用的情况下,ZooKeeper是可以维持工作的。关键问题是你的客户端程序是否能察觉到。在实际情况下,客户端与ZooKeeper的连接有可能中断(多数时候是因为Zookeeper故障或网络中断)。Zookeeper的客户端库关注于如何让你重新连接并且知道发生了什么。但是同时你也必须确保能够恢复你的状态和发送失败的请求。努力在测试库里测出这些问题,而不是在产品里——用几台服务器组成的zookeeper集群测试这个问题,尝试让它们重启。
  3. 客户端维护的服务器列表必须和现有的服务器列表一致。如果客户端的列表是现有服务器列表的子集,还可以在非最佳状态工作,但是如果客户端列表里的服务器不在现有集群里你就悲剧了。
  4. 注意存放事务日志的位置。性能评测最重要的部分就是日志,ZooKeeper会在回复响应之前先把日志同步到磁盘上。为了达到最佳性能,首选专用的磁盘来存日志。把日志放在繁忙的磁盘上会降低效率。如果你只有一个磁盘,就把记录文件放在NFS上然后增加SnapshotCount。这样虽然无法完全解决问题,但能缓解一些。
  5. 正确地设置你java的堆空间大小。这是避免频繁交换的有效措施。无用的访问磁盘会让你的效率大打折扣。记住,在ZooKeeper中,一切都是有序的,如果一个服务器访问了磁盘,所有的服务器都会同步这个操作。

其他资料链接请自行官网查看。

macbook安装ubuntu

2013年1月4日 08:00

注意,ubuntu 和 xubuntu 安装上有一定差别,请严格按照你选择的系统版本流程安装

ubuntu

准备工作

分区

这一步可以用命令行实现,也可以在 mac 下直接用磁盘工具分区,初学者建议后者。有经验的朋友可以在网上查询 diskutils 的用法,这是 mac 提供的分区工具

使用磁盘工具,打开左侧最上边磁盘位置(不是 macosx,而是整个硬盘)的选项,右边会出现分区标签。选择分区,你可以用鼠标拖动轻松将 macos 的一部分划分给 ubuntu 使用。分区格式可以选择 mac 日志文件。其实选什么都一样,等会还要重新格式化。

把 iso 镜像转化为苹果电脑识别的 img 镜像

hdiutil convert /path/to/ubuntu.iso -format UDRW -o /path/to/target.img

命令中path/to/ubuntu.iso是你下载的 iso 路径,path/to/target.img自然是你要保存成 img 的路径

插入 U 盘,刻录镜像

先运行如下命令查询你 U 盘的设备名

diskutil list

这里假设 U 盘是 disk1,执行

diskutil unmountDisk /dev/disk1

接下来执行命令刻录,

sudo dd if=/path/to/downloaded.img of=/dev/disk1 bs=1m

ubuntu 官网针对上一条命令可能出现的两种错误提示给出了解决办法:

  1. If you see the error dd: Invalid number ‘1m’, you are using GNU dd. Use the same command but replace bs=1m with bs=1M.

  2. If you see the error dd: /dev/disk1: Resource busy, make sure the disk is not in use. Start the ‘Disk Utility.app’ and unmount (don’t eject) the drive.

好了,刻完就可以重启安装了。重启后按住option会看到一个 refit 命名的移动设备图标,点进去就开始安装了。

安装过程

安装过程可以参考网上其他人的帖子。一般有两种方式

  1. 选择ubuntu和mac os x共存,系统会自动被安装到空闲分区。
  2. 选择其他选项,可以自己手动分区,我一般是选这个来手动分区。

假设你选了其他选项就会进入分区的窗口,这时你就会看到所有磁盘分区的情况,刚才在 mac 下给 ubuntu 预先分好一块空闲分区也在其中,名字可能叫 disk02,或者 disk03 什么的。 接下来要进行四次分区。这一段分区方法参考了百度经验上一个网友的教程,我进行了 2 处修改,原帖地址http://jingyan.baidu.com/article/60ccbceb18624464cab197ea.html

第一次分区:

点你刚才留出来的“空闲”分区,点“+”,进行如下设置:
挂载点:“/”
大小:22000MB
新分区的类型:主分区
新分区的位置:空间起始位置
用于:EXT4日志文件系统

第二次分区:

“空闲”处,继续点“+”,如下设置,
挂载点:(不设置)
大小:4096MB
新分区的类型:逻辑分区
新分区的位置:空间起始位置
用于:交换空间

第三次分区:

“空闲”处,继续点“+”,如下设置,
挂载点:/boot
大小:200MB
新分区的类型:逻辑分区
新分区的位置:空间起始位置
用于:EXT4日志文件系统

第四次分区:

“空闲”处,继续点“+”,如下设置,
挂载点:/home
大小:(剩余全部空间,剩下显示多少,就多少)
新分区的类型:逻辑分区
新分区的位置:空间起始位置
用于:EXT4日志文件系统

分区设置完毕后,下方还有一项“安装启动引导器的设备”,macbookpro 用户需要选择/boot 这个分区所在磁盘位置。

开机引导程序 rEFIT

安装完 ubuntu,重启在 mac 下下载安装 rEFIt。安装好后在终端里输入/efi/refit/. enable-always.sh启动 rEFIt。重启,你就会看到 ubuntu 的选项。

Xubuntu(估计 Lubuntu 和 Kubuntu 也应该适用,仅是猜想。)

准备工作

如何刻录镜像到移动设备

这个问题很重要,如果你下载了 iso 格式的 Xubuntu 系统镜像, 你需要将该镜像不经过任何转换的完整刻录到移动设备或光盘上 (我使用的是 unetbootin 这个软件,开源,跨平台,操作简便)。 注意,绝对不可以将 iso 格式转换成 img(mac 镜像)后刻录。 这样会导致 ubuntu 部分版本无法安装 grub 引导器。

开机引导程序 rEFIt

安装完的系统无法被 mac 直接引导,所以需要安装 rEFIt 引导。如果不想要安装它, 可以参考下边附录 1 里的安装方法。重新修改引导文件。

安装过程

  1. 在官网下载 xubuntu 镜像,使用 uneetbootin 刻录到设备 上(mac 版的 unetbootin 貌似刻录 iso 有问题,可以在 windows 上下载该软件使用)。
  2. 重启,开机界面按住option键,有个 windows 命名的移动设备图标,选中进入。
  3. 安装过程不敷述, 装后重启
  4. 从 mac 进入,安装 rEFIt,在 shell 里运行/efi/refit/. enable-always.sh启动 rEFIt
  5. 重启,出现两个图标,苹果代表 mac,企鹅代表 linux。至此,完成安装过程。

ubuntu 或 Xubuntu 安装后的一些配置

打开系统配置文件,

sudo gedit /etc/rc.local

在 exit 0 前边加入下边对应的语句, 默认关闭功能键 Fn:

echo 2 > /sys/module/hid_apple/parameters/fnmode

设置默认亮度(数字 2565 可以修改任意亮度)(xubuntu 下不起作用,原因未知)

echo 2565 > /sys/class/backlight/intel_backlight/brightness

设置键盘灯亮度(数字 1 代表亮度):

echo 1 > /sys/class/leds/smc::kbd_backlight/brightness

附录 1

https://help.ubuntu.com/community/MacBookPro11-1/Saucy

Go 语言的依赖倒置

2024年11月21日 11:26

这篇文章比较基础,是我在给 Java 程序员做 go 语言培训时用到的。

为什么要做依赖倒置(DIP)?

依赖倒置,或叫依赖反转、DIP,是软件开发非常重要的设计原则。很多程序员没有了解过相关知识,或者只从 Java Spring 知道大致思想。我今天想用一篇简短的文章,用 Go 语言做一个简单的例子,讲解一下怎么最简单地实现依赖倒置。

如果你还不知道它是什么,可以参考 wiki 中的描述,或者阅读马丁福勒关于 DIP 的文章

依赖倒置原则要解决一个软件开发中常见的风险:依赖。

尝试回忆一下:

  1. 当你尝试通过 Mock 方式屏蔽底层细节做测试时,你发现你要测试的类引用了大量框架提供的接口,导致你需要 mock 大量底层的实现。
  2. 当你尝试修改一个旧的底层类,但是依赖该类的上层服务类太多,你一边担心造成副作用,一边在所有依赖的位置重构上层代码。

我们分析一下这两个场景:

场景 1 里,应用类依赖于框架提供的实现,导致应用类很难从框架上剥离出来,业内处理这种问题的方法叫控制反转(IoC, Inversion of Control)。即应用类不应该依赖框架,而是框架提供插槽一样,把应用类注册给框架,由框架统一调度应用,执行对应的方法。

场景 2 里,服务类依赖底层类,导致底层修改难度越来越大。解决办法是依赖注入(DI, Dependency Injection)。即上层类不直接引用底层类,而是在使用的地方把上层类依赖的底层类注入进来。

把这两个场景结合起来,就是依赖倒置原则的核心:

  • 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
  • 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。

这两个原则保证了代码中模块的高内聚、低耦合,同时给 Mock、迭代更新模块创造了条件。

用 Go 语言实现它

假设现在要从一个用户的服务中查询用户的信息。有两个接口,UserRepository 作为数据层负责查询数据库, UserService 负责业务逻辑,它依赖 UserRepository。同时为了方便测试,我们还要写一个 Mock 的数据层实现。 整个结构如下图。

Go example

接下来非常轻松地,我们实现两个接口,并写了他们的实现类。同时我们还在 UserService 的实现类里写了一个 NewUserService,来把它依赖的 UserRepository 实现注入进来。

// 在 user_repository.go 中实现具体的接口
type UserRepository interface {
 GetByID(id int) (*User, error)
 Save(user *User) error
}

// ... 具体实现 UserRepository,略

// user_service.go 中实现
type UserService interface {
 GetUser(id int) (*User, error)
 CreateUser(name string, age int) error
}

// ... 具体实现 UserService,略

func NewUserService(repo UserRepository) UserService {
 return &UserServiceImpl{
 repo: repo,
 }
}

那么问题来了,可不可以直接在 user_service.go 中直接把 repository 引用进来呢?显然不行,因为这样,两个模块就形成了依赖关系。

这一点是依赖反转的核心,上层模块不直接引用下层模块,而是由执行的类来初始化 Service 并将依赖的下层服务注入进来。

// 在main.go 中
func main() {
 repo := &MySQLUserRepository{}
 userService := NewUserService(repo)
}

这样,当编写测试 Mock 代码时,不需要修改任何代码逻辑,直接在测试中将NewUserService 的参数替换成测试的假实例即可。

// 在 user_service_test.go 中
func TestUserService() {
 repo := &MockTestUserRepository{}
 userService := NewUserService(repo)
}

另外,如果数据层修改了实现,或者迁移到另外的数据库,你只需要修改两个地方:数据层的实现者和依赖注入者。对于调用者 UserService 则完全不受到影响。整个项目也不会形成依赖陷阱。

总结

依赖倒置原则的两个核心原则:

  • 模块不依赖于其他模块,而是都依赖于抽象接口
  • 抽象接口不依赖于实现,而实现依赖于抽象接口

在 Go 语言中实现这两条原则并不麻烦,只要将原本的调用方-实现方,转换成注册方-调用方-实现方。在 Go 中也有一些库和框架实现依赖反转,其实核心思想并没有差异。

监控系统项目复盘

2024年10月24日 15:52

这篇文章对我过去 3 年的一大块工作内容进行复盘。我作为项目组的架构师,在下文中也对项目早期遗留的一些问题进行反思,并分享我个人的解决思路。

项目核心依赖开源组件,定制化过度

我们的项目是一个运行在边缘设备上的日志、软硬件性能指标的采集/监控系统。考虑到边缘计算设备(IPC)的性能,选择开源组件时就侧重于轻量化、支持丰富的输出标准。早期部门架构师采用了 Fluent-Bit 作为项目的核心组件。Fluent-Bit 是 C 编写的开源、轻量、可轻度扩展的数据收集器。它最开始用作日志收集,后来逐渐发展成全功能的 Agent。对比流行的OpenTelemetry,Fluent-Bit 更开箱即用、更轻量,但是不易于修改和扩展。

最开始整个团队都没接触过监控系统,所以在设计系统时挖了不少坑。首先,用户在 UI 上操作过度繁琐,需要依次配置输出的目标(地址、端口、协议、格式、加密方式等等)、采集的指标类型,最后还要手动点击 Apply (应用)一下。

经过几轮迭代,适当简化了操作逻辑。但是像大部分工业 PC 上运行的程序一样,用户在初始化配置过后,通常不会主动去 UI 上修改配置。终端用户更关心占用系统资源多少、稳定性如何。所以最开始团队把这个项目做成了一个重交互的 C 端产品,这是个教训。

第二,后端开发为了满足 UI 设计的流程(比如,用户可以创建多份不同的配置项到不同的目标地址),做了复杂的 Work-around。因为 Fluent-Bit 是单进程事件驱动模型,只有单一配置文件,每次修改配置文件都要重启 Fluent-bit 进程。这就造成了 UI 上用户添加一个配置项,后台就要重新生成整个配置文件并重启 Fluent-Bit。这对于一个稳定运行的监控系统来说,无疑增加了重启过程中数据丢失的风险。另外,如果新增的配置项出错,就会让整个生成的配置文件报错,导致 Fluent-Bit 进程假死等问题。

为了解决这些问题,后端工程师又对 Fluent-Bit 的各项参数玩出各种花活儿。比如利用不同 tag 来分流不同用户配置项,为每个配置项单独配置参数和过滤规则。再比如设定缓存数据包大小和缓存 timeout 时间为 0,这样 Fluent-Bit 重启之后会首先尝试重发缓存在文件系统里的数据,这样间接防止用户数据丢失。

这些花活儿不但提高了维护难度,从用户角度看,也并没有带来任何真正的价值提升。

回顾来看,如果早期的 UI 设计改成单独的配置页面,不但简化的操作流程,还给业务代码降低的复杂度。

第三,核心项目依赖 Fluent-Bit 造成项目迁移到其他开源组件非常困难。加上 Fluent-Bit 更新频率高,公司对安全性合规要求使得我们团队每隔一段时间要对 Fluent-Bit 进行升级,同时对所有配置选项做回归测试。加上 Fluent-Bit 订制性很差,它虽然支持使用 Go 语言实现 Output 插件,但是只能用 C 语言编写 Input 插件。导致我们采集内部应用的数据,不得不用到它的 TCP 和 HTTP 插件来中转。部署多个 Agent 采集不同的内部服务。这让后期集成测试更添难度。

总体来说,Fluent-bit 的性能基本达到了预期,但是各种小 bug(比如 pgsql 插件在目标不可达时直接 Block 整个进程),开源社区维护者并没有引起重视,我们提交给开源社区的代码也被以各种理由驳回。如果让我重新选择,我更倾向于使用其他扩展性更强的开源组件。

对 Go 语言不熟悉,项目结构混乱

团队遇到的第二个挑战是对 Go 语言不熟悉。大部分开发成员只有 Java 开发经验,所以顺理成章把 Go 写成了 Java。因为框架(Go-Gin)的限制,导致开发中问题频出。

第一个问题来自面向对象和依赖反转。依赖反转对于使用 Java Spring 的人来说不会陌生,但是用 Go 实现依赖反转,需要利用 Interface 封装,并结合 Go-Mock 库做单元测试。团队成员早期不熟悉语言特性,经常错误封装抽象,或者干脆直接函数套函数,写成面条型代码。这充分暴露了大部分国内 Java 程序员其实没有受过良好的 OOP 训练。对于单元测试、集成测试这些工程实践也是流于形式。软件质量在大部分企业里仍然靠测试人员手动验证。

第二个问题是 Go 语言不鼓励过度抽象。如泛型、异常处理,都要一步步重复琐碎的代码片段,这让 Sonar 静态检查经常 failed。没经验的同事就会用各种奇技淫巧逃避静态检查。这也说明开发团队定期 code review 的必要性。

第四,Go 语言其实是一个社区不那么完善的编程语言,它的很多框架(如最热门的 gorm 居然是个人开发项目),像 Flyway 这种 Java 工具链中很成熟的迁移工具,在 Go 里竟然需要组合多个开源项目来替代。所以 Go 只适合来开发中等以下规模的项目,或者对性能要求较高的平台核心组件。(在国内)不适合做复杂的业务场景。

API 接口粒度过细,没有对资源对象做好抽象

团队早期由于管理混乱:架构上,没有对业务模型做好抽象,资源对象拆分太碎;管理上,任务拆解太简单粗暴,给每个同事单独负责一个模块,导致每个业务流程都设计了专门的 API,维护压力大。好在业务场景少,用自动化测试能一定程度上保证了接口可靠性。

最开始做自动化集成测试时,我们仍然使用 BDD 的形式,以业务操作为基础编写,后来逐渐发现这种监控系统,其实真正的用户操作逻辑非常简单,复杂的部分是不同类型的数据、不同的 Input、Output 配置可能引起的异常。所以我们改成了数据驱动测试,用配置文件对不同类型的 Fluent-Bit 配置做全面的测试。

总结起来,Fluent-Bit 配置的修改,其实完全可以用 3~4 个宽泛的 API 来实现,除了前文提到流程过度设计原因,项目初期的不确定性,导致开发人员过度关注松耦合,而忽略了维护性。

错误的流水线设计

最开始项目沿用的部门其他团队的集成测试、部署模式,把 Python 写的测试用例和项目部署脚本放在单独的 Gitlab Repo 里。结果是每次项目部署时,要人工去网页上修改版本号触发流水线。从持续集成的角度看,业务代码和测试用例分开,造成了每次 commit 都要到不同 repo 里去提交,且一旦冲突又要分别执行多次集成测试(时间长,反馈慢)。

后期我们做了一些调整,把多个小模块合并成一个Monorepo,同时把部分 API 相关的集成测试放在后端代码里,减少提交次数,也让原子提交更容易。

不过部署问题依然没有被解决,原因是边缘平台上的模块太多,系统集成需要多个团队合作,部署、发布版本时间长,出错的环节太多。对于这种情况,部门技术负责人设定了严格的代码提交、测试、review、文档更新流程,但是根本问题还在于团队责任模糊、部门团队跨多个国家和时区,缺少统一的调度和沟通机制。这些问题只能留给管理层逐渐缓解,或者随着业务收敛,减少、分流项目组。

小结

整体来看,我们团队遇到的很多问题出自项目早期,缺少项目和技术团队管理经验。对业务的愿景不了解,把做 C 端 Saas 产品的经验带到工业领域,用熟悉的开发范式套用到制造业。当然,不回避地说,在业务上,部门多流程长,业务负责人只能盲人摸象,用户反馈要先到达 Support 团队,再反馈给上层,最后才到开发团队。这让我们开发出来的产品要经过至少 3-6 个月才能得到有效的反馈。迭代周期太长,研发闭门造车。

乐山游记

2024年6月23日 18:50

最近工作节奏放缓,业余时间也多了。我决定去乐山看看。久闻小吃之都的美名,但是来成都之后一直在全国出差,对这座高铁只要 50 分钟就能到达的城市,一次面都没见过,实在说不过去。

作为时间管理小能手,我星期六早上 6 点就从家出发,8 点多到了乐山站。先找个早餐店弄一碗乐山豆腐脑吧,这是我在成都最爱的小吃之一,很期待原产地的风味。很多店铺都还没开门,一直走到了火车站对面的美食街才找到一家。坐下,一碗酥肉豆腐脑,一个牛肉咔饼。

乐山豆腐脑和北方豆腐脑不太一样。它的主食是豆花和红薯粉丝。在粘稠的豆花粉丝汤里,加上炸黄豆、花生、牛肉(或者酥肉、鸡丝)、香菜。洒上辣椒油。有时候再放点薄脆。吃到嘴里是滑滑的咸辣汤汁裹着肉和黄豆的酥脆口感。乐山本地的豆腐脑用的豆花似乎和成都小店里有区别,是比嫩豆腐要稍微老一点的豆腐,汤也更入味。

牛肉咔饼其实就是肉夹馍的四川版。把牛肉剁碎,再用葱姜蒜等调料用粉蒸肉的手法蒸熟,夹在一个软软的面饼里。一大早肉刚出锅,眼看老板夹着带热气的蒸肉放到饼里,香得很!

吃饱喝足之后,第一个要去的地方是乐山大佛。

乐山大佛在地图上很容易找到。乐山市面积不大,整个城市位于三江交汇处。乐山大佛就坐在三江口,眼观峨眉山。

从地图上就能看出,大渡河泥沙更多,河水偏黄色。而岷江水是青绿色。在大佛前的交汇点,形成一条非常明显的分界线。

打车 40 分钟到了景区,门票分两种:坐船和爬山。小小一座凌云山,被当地开辟出山和水两条收费路线,颇有四川小吃的特色,一菜多吃。

我选择了更轻松的坐船。船从岷江的嘉州渡开船,行至大渡河交汇处,江水已经开始由青泛黄。

复行十几分钟,就到了大佛脚下。大佛的左脚先映入眼帘,大佛身边左右护法已经被千年的江风磨平了棱角。

乐山大佛项目立项于唐代,经过了三代项目经理才完工。到现代也在不断修缮。

大佛周围的山崖有很多方孔,是用来搭建脚手架的。据说大佛外层原本有一座叫“大像阁”的七层建筑罩住大佛本体。但在历史上被毁掉了。在乐山博物馆还能看到很多相关资料。博物馆里还有一些有趣的豆知识:大佛头上的每一个圆圈都是一根长条状的石头,工匠先于大佛头部开凿出一个个插槽,再把这些长条状的“慧根”嵌进去。这可能是最早期的模块化设计。

继续前行,绕过大佛,就能看到凌云山的栈道和像蚂蚁穴一样的山路,如果选了登山门票,你就会和其他游客像小蚂蚁一样出现在这里。凌云山其实只是江上的一座小山丘,能在这样一个光滑无附着的地方凿出道路,很佩服古人的毅力。

船往回开时,站在船尾能看到凌云山的远影形成一尊向右躺平的卧佛形象。这个躺平的形象,就是乐山的精神图腾。

离开乐山大佛景区,继续探索乐山的美食吧!坐公交车前往上中顺特色街区。

上中顺有很多小吃,比如叶婆婆钵钵鸡,但是因为排队人太多,一个人吃钵钵鸡又浪费了一大锅锅底,所以我这次就放弃了。直奔海汇源纸皮烧麦。

海汇源的烧麦有鲜肉、牛肉、羊肉三种馅,还可以加钱把烧麦做成锅贴。我这次买的就是锅贴烧麦。

乐山纸皮烧麦跟成都的烧麦、北方的烧麦都不一样。成都烧麦的馅是肉粒和糯米混合的,味道像广式粽子;北方是牛羊肉馅。乐山烧麦肉馅更像北方,皮非常薄,用油煎一下口感脆香。我过去吃烧麦最不喜欢吃它顶端的褶皱。因为蒸烧麦时那里经常蒸不熟,会有生面渣的口感。但是海汇源的烧麦完全没有这个问题,它把服务态度以外的卖点都做足了(服务态度不好也是四川小吃的特色)。海汇源的烧麦不但皮薄馅大,小料(四川叫蘸水)也很有特色,辣椒油里似乎还能吃出一点点柠檬汁的味道。

从海汇源出来,我又在街边买了三串糖醋酱裹着的炸豆腐,算是乐山近年和绵绵冰(中式 Gelato)一样流行的街边小吃,不过味道一般,不作推荐。

从上中顺街区往东南方向走,就能看到大渡河。沿着大渡河有几座渡口,可以眺望对面的凌云山。

沿江有很多小亭子,当地老年人会聚到这里打一种叫“贰柒拾”的四川桥牌。这种牌有 1~10 十种数字和红黑两类花色。像打麻将一样可以吃碰胡,本质是个凑牌型算番数的概率计分游戏。

据说之所以用这种长条形的纸牌,是因为过去物质匮乏,人们用工厂里填物料用的废纸来制作纸牌。这种乐山“贰柒拾”应该是四川长牌的一个变种,四川长牌在湖广地区也曾经流行,很多老年人都会打。但是现如今当地的年轻人也都沉迷王者荣耀了,再过几年只能在非遗博物馆听讲解员讲解玩法。

我很欣赏乐山街头这种随处可见的景象:茶馆、打牌的小桌子。根本不需要特别的场地,一棵树周围的三米圈出一个阴凉,就是当地老年人的天然棋牌馆。随着年纪增长,我也越来越享受这种不容易变质的快乐,像下班回家看看体育运动,或者打开斗鱼看魔兽争霸比赛。这些赛事最大的特点是规则几乎不会变化,它们不像王者荣耀,几天不玩就陌生了,不会时不时给你更新个玩法,增加个人物。人不是计算机,更不是所谓“用户”,我不需要经常更新。年纪越大,多巴胺越是依赖那些简单、不变的东西。

沿着大渡河:远处是镇坐千年的大佛,脚下三江汇聚,水花在岷江与大渡河之间,像用粉笔划出的分界线。近处是急流中游泳的老人。

结束了乐山之行,赶回成都已经困得睁不开眼了,一觉睡到第二天下午两点。发现上午朋友在微信群里发了张图片。

从乐山回来我突然领悟了那句话:如果浪费时间能让你感到快乐,那时间就没有浪费。

《Python源码剖析》第二部分——Python虚拟机基础

2017年7月13日 08:00

Python 执行环境

在编译过程中,这些包含在 Python 源代码中的静态信息都会被 Python 编译器收集起来,编译的结果中包含了字符串,常量值,字节码等在源代码中出现的一切有用的静态信息。在 Python 运行期间,这些源文件中提供的静态信息最终会被存储在一个运行时的对象中,当 Python 运行结束后,这个运行时对象中所包含的信息甚至还会被存储在一种文件中。这个对象和文件就是我们这章探索的重点:PyCodeObject 对象和 pyc 文件。

在程序运行期间,编译结果存在于内存的 PyCodeObject 对象中;而 Python 结束运行后,编译结果又被保存到了 pyc 文件中。当下一次运行相同的程序时,Python 会根据 pyc 文件中记录的编译结果直接建立内存中的 PyCodeObject 对象,而不用再次对源文件进行编译了。

从文章摘录可见,python 生成的不是编译后的文件,而是.py文件对应的静态信息——PyCodeObject,这里包括了字节码指令序列、字符串、常量。每个名字空间(类、模块、函数)都对应一个独立的 PyCodeObject。(python 连编译后的文件里存的都是个对象!)

不被 import 的 py 文件不会生成 pyc。标准库里有 py_compile 等方法也可以生成 pyc。

import 机制 导入某个模块时,先查找对应的 pyc,如果没有 pyc 就生成然后 import 这个 pyc。(所以实际导入的并不是 py 文件,而是 py 文件编译后的 PyCodeObject)。

PyFrameObject Python 程序运行时的「执行环境」。参考操作系统执行可执行文件的过程。Python 也是将函数对应的执行环境封装成栈帧的形式加载进内存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
typedef struct _frame {
 PyObject_VAR_HEAD
 struct _frame *f_back; //执行环境链上的前一个frame
 PyCodeObject *f_code; //PyCodeObject对象
 PyObject *f_builtins; //builtin名字空间
 PyObject *f_globals; //global名字空间
 PyObject *f_locals; //local名字空间
 PyObject **f_valuestack; //运行时栈的栈底位置
 PyObject **f_stacktop; //运行时栈的栈顶位置
 ……
 int f_lasti; //上一条字节码指令在f_code中的偏移位置
 int f_lineno; //当前字节码对应的源代码行
 ……
 //动态内存,维护(局部变量+cell对象集合+free对象集合+运行时栈)所需要的空间
 PyObject *f_localsplus[1];
} PyFrameObject;

Python 标准库的sys._getframe()可以动态的在程序执行时获取当前内存中活跃的 PyFrameObject 信息。

LEGB 规则

即 python 作用域的查找顺序是local-enclosing-global-buildin。看下面代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
a = 1

def g():
 print a

def f():
 print a //[1]
 a = 2 //[2]
 print a

g()

代码在[1]处会抛出异常,原因是 python 在编译阶段就把静态数据(局部变量、全局变量、字节码)放入 pyc 里,执行到f()里时,查找到a是在 local 作用域里定义的而不是 global 里,但是此时 local 的 a 还没赋值,所以就会抛出异常。由此可见,python 作用域信息是在静态编译时就处理好了的

Python 虚拟机运行框架

运行时环境是一个全局的概念,而执行环境实际就是一个栈帧,是一个与某个 Code Block 对应的概念。

在 PyCodeObject 对象的 co_code 域中保存着字节码指令和字节码指令的参数,Python 虚拟机执行字节码指令序列的过程就是从头到尾遍历整个 co_code、依次执行字节码指令的过程。

由上文引用可见,python 在编译阶段将代码块的字节码保存在 PyCodeObject 的 co_code 属性里,然后在执行阶段从头到尾遍历这个 co_code 属性解读字节码。

Python 运行时环境 Python 在运行时用 PyInterpreterState 结构维护进程运行环境,PyThreadState 维护线程运行环境,PyFrameObject 维护栈帧运行环境,三者是依次包含关系,如下图所示:

Python 虚拟机就是一个「软 CPU」,动态加载上述三种结构进内存,并模拟操作系统执行过程。程序执行后,先创建各个运行时环境,再将栈帧中的字节码载入,循环遍历解释执行。

Python 字节码

1
2
3
i = 1
0 LOAD_CONST 0 (1)
3 STORE_NAME 0 (i)

例如 python 的一条语句i=1可以解释为下面两行字节码,最左边的第 1 列数字代表这行字节码在内存中的偏移位置,第 2 列是字节码的名字(CPU 并不关心名字,它只是根据偏移量读出字节码,所以这个名字是方便阅读用的),第 3 列是字节码的参数,如LOAD_CONST对应的数据在变量f->f_code->co_consts里,0 就是这个参数位于f->f_code->co_consts的偏移量。最后一列的括号里是从参数里取到的 value。

Python 的异常抛出机制

异常处理的操作都在Python/traceback.c文件里,python 每次调用一层函数,就创建改函数对应的 PyFrameObject 对象来保存函数运行时信息,PythonFrameObject 里调用 PyEval_EvalFrameEx 循环解释字节码,如果抛出异常就创建 PyTraceBackObject 对象,将对象交给上一层 PyFrameObject 里的 PyTracebackObject 组成链表,最后返回最上层 PyRun_SimpleFileExFlags 函数,该函数调用 PyErr_Print 遍历 PyTraceBackObject 链表打印出异常信息。

函数对象的实现

PyFunctionObject 是函数对象。在 python 调用函数时,生成 PyFunctionObject 对象,该对象的 f_global 指针用来将外层的全局变量传递给函数内部,然后在ceval.c文件的fast_function里解出 PyFunctionObject 对象里携带的信息,创建新的 PyFrameObject 对象(上文说过这个对象是维护运行时环境的),最后调用执行字节码的函数PyEval_EvalFrameEx执行真正函数字节码。

Python 执行一段代码需要什么? 从书中描述可见,python 执行一段代码需要做几件事:

  • 从源码编译出 PyCodeObject 保存变量和字节码
  • 执行阶段,从 PyCodeObject 里取出信息交给 PyFrameObject,执行 PyEval_EvalFrameEx 解释字节码
  • 如果遇到函数调用,就把函数对应的代码段从 PyCodeObject 存入 PyFunctionObject 对象,然后把这个函数对象通过参数传给新创建的 PyFrameObject ,在内层空间执行 PyEval_EvalFrameEx 解释字节码
  • 将结果或异常存入 PyFrameObject 的变量( 异常是存入 f_blockstack 里,外层判断 f_blockstack 里的数据是被 except 捕获还是没有捕获而继续下一步操作) 抛给外层

值得注意的是,python 在执行阶段,将对函数参数的键值查找,转换为索引查找,即在转换 PyCodeObject 为 PyFrameObject 时,将参数信息按位置参数、键参数按照一定顺序存储在 f_localsplus 变量中,再用索引来查找对应参数,而需要查找键值。这样提高了运行时效率。下图是foo('Rboert', age=5)在内存中的存储形式。

闭包的实现

Python 在编译阶段就把函数闭包内层和闭包外层使用的变量存入 PyCodeObject 中:

  • co_cellvars:通常是一个 tuple,保存嵌套的作用域中使用的变量名集合;
  • co_freevars:通常也是一个 tuple,保存使用了的外层作用域中的变量名集合。

在执行阶段,PyFrameObject 的 f_localsplus 中也为闭包的变量划分的内存区域,如下图所示:

元类

元类<type type>和其他类的关系如下图:

可调用性(callable) ,只要一个对象对应的 class 对象中实现了“call”操作(更确切地说,在 Python 内部的 PyTypeObject 中,tp_call 不为空)那么这个对象就是一个可调用的对象,换句话说,在 Python 中,所谓“调用”,就是执行对象的 type 所对应的 class 对象的 tp_call 操作。

Descriptor

在 PyType_Ready 中,Python 虚拟机会填充 tp_dict,其中与操作名对应的是一个个 descriptor 对于一个 Python 中的对象 obj,如果 obj.class对应的 class 对象中存在getsetdelete三种操作,那么 obj 就可称为 Python 一个 descriptor。

如果细分,那么 descriptor 还可分为如下两种:

  1. data descriptor : type 中定义了getset的 descriptor;
  2. non data descriptor : type 中只定义了get的 descriptor。 在 Python 虚拟机访问 instance 对象的属性时,descriptor 的一个作用是影响 Python 虚拟机对属性的选择。从 PyObject_GenericGetAttr 的伪代码可以看出,Python 虚拟机会在 instance 对象自身的dict中寻找属性,也会在 instance 对象对应的 class 对象的 mro 列表中寻找
  1. Python 虚拟机按照 instance 属性、class 属性的顺序选择属性,即 instance 属性优先于 class 属性;
  2. 如果在 class 属性中发现同名的 data descriptor,那么该 descriptor 会优先于 instance 属性被 Python 虚拟机选择

引申:Python 黑魔法 Descriptor (描述器)

Bound Method 和 Unbound Method

假设有下面两种对类方法的调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class A(object):
 def f(self):
 pass

a = A()
# [1]
a.f()

# [2]
A.f(a)

# [3]
func = a.f
func()

在代码[1]里,实例 a 调用类方法 f,python 底层会自动完成实例 a 和类方法 f 之间的绑定动作(调用func_ descr_get(A.f, a, A),将实例地址和函数对象 PyFunctionObject 封装到一个 PyMethodObject),而代码[2]里直接通过 A 调用,则 f 为非绑定的 PyMethodObject,里面没有实例信息,需要传入 a。

比较绑定方法与非绑定方法可知,通过[1]的方式每次都要绑定一次实例,开销非常大,下图比较的是[1]和[3]两种方式,绑定操作的执行次数。

结论: 调用类实例绑定的方法时,如果方法执行次数非常多,最好将方法赋值给一个变量,防止重复绑定增加开销

怎样的建议才有价值

2016年8月25日 13:13

我想很多时候我们下决心做一件事,都要征求周围人的建议。这些建议有好有坏。坏的建议不但帮不了你,还会歪曲你对事实的认识。最近我在研究系统学习英文的方法,偶然在豆瓣小站里看到了开头那段话,对此深表认同。以我个人健身的经历,在报名私教课之前也寻求过很多人的建议。大部分会告诉你,健身(减肥)最重要的就是坚持。他们还会煞有介事的告诉你要「管住嘴,迈开腿」。在这一年里,我减掉了大概 25KG 体重,最大的感受——如果没有教练指导,我自己是断然不会坚持下来的。大部分的建议都像开头引用的那段话一样,是假大空的虚话,废话。

「任何一个通过自我磨练掌握了某一项具体技能或者在某领域内有一定造诣的人,都会对该领域有着起码的基本认知和独特的个人总结,如果你向其请教,得到的断然不会是“努力去做或者坚持最重要”之类的假大空的虚话」
—— 豆瓣英语学习大神 恶魔奶爸 Sam

怎样征求有用的建议?结合我这些年减肥失败的教训,笼统的概括为:如果你要做一件长期投入的事儿,那么请咨询跟你情况类似,且做成了这件事的人。

首先「情况类似」很重要。因为很多人是科班出身,比如健身教练,他们大部分从上学起就被动养成了科学饮食、运动的好习惯。这些习惯并不是主动思考之后产生的,所以这些人在传授你知识时往往模糊不清的告诉你应该如何做,却说不出所以然。再比如英语系的学生就很难告诉你行之有效的英语学习方法,他们会告诉你大量看美剧英剧,背单词。但是你根本没有他们的学习环境,几乎不可能坚持下来。在语言学习中有「可理解输入」的概念,简单解释就是——你必须接受那些「你能够理解」的知识,否则你就吸收不了(记不住)。同理,知识在灌输给我们时候,如果不解释清楚来由,我们就很难有深刻认识。所以征求建议的对象如果跟你情况类似,你往往能听到更加切实可行的建议,比如在某个阶段应该注意什么,可能片面,但是有效。比如我在健身初期,咨询同学大伟很多健身的入门知识,关于人体代谢率,关于自重训练。这些东西,科班出身的教练通常不会细致地讲解给我。

其次「要征求做成了这件事的人」,这就是开头那段话的意义,那些没做成这件事儿的人,大都输在了「坚持」这两个字上。在把这一信息转告别人时,这些人就会主观放大坚持的重要性。你去征求这些自己都没成事儿的人建议,得到的都是些「管住嘴,迈开腿」,「坚持不懈」之类的屁话。我这一年锻炼和饮食的心得就是不断学习,反复修订计划和总结经验。另外还有一种傻逼理论「每个人都有各自的特点,要针对性制定计划,没有一种通用的解决办法」。倘若真是这样,还上学干什么?搞科研做什么?管理学、经济学都要歇菜了。别用个体差异给自己找借口了。

我猜很多有主见,独立思考的人都会认同一句话。这句话是罗永浩在一次鲁豫有约采访时候说的——「我们周围大部分人都是笨蛋」。虽然老罗最近在做手机上栽了大跟头,但是大多数人一辈子连栽这样跟头的机会都没有就入土了。可是骂他的成千上万人里有多少是经历过这过程的呢?传播那些人云亦云的虚话,不过是懒于思考,不求上进的人找好的借口。他们自己做不到的,就理所当然认为你也做不到。抱着「周围大部分人都是笨蛋」的理念,积极地去跟「对的人」征求「有价值」的建议,我相信一定能有所收获。

Ricoh GR 2 使用心得

2024年5月17日 23:07

买 GR2 已经很多年了,几年前写过一篇Ricol GR 2 参数说明,作为初学者分享了参数的说明。今天这篇分享一下这几年的使用心得。

机内裁剪

GR2 作为 28mm 相机,对于普通人扫街来说,最大的痛点就是经常抓不到合适的构图。28mm 的优点是取景范围更大,可以捕捉更多环境信息,但是相对地,用它拍摄时经常混入太多杂乱的场景元素。

但是好在 GR2 支持机内裁剪,可以把 28mm 裁切成 35mm 或 47mm 镜头。这让街拍时的构图更加灵活(当然以牺牲画质为代价)。

不同光圈的适合场景

  • F2.8 室内的人物和场景
  • F4 静物
  • F5.6 均衡
  • F8 图像最锐利的光圈设定
  • F11 漂亮的星芒效果

自定义参数

FUJI 胶片风格

  • 滤镜模式:正片。 饱和度:4、对比度:5、鲜明度:6
  • 白平衡:复合 AWB
  • 白平衡补偿:A:3、G:4
  • 曝光补偿:-1.0 ~ -1.0

上面这套参数模仿富士相机的胶片效果。对比鲜明度较高,有淡淡的绿色调。

日常蓝色调

  • 滤镜模式:正片。 饱和度:7、对比度:3、鲜明度:7
  • 白平衡:复合 AWB
  • 白平衡补偿:B:7、M:0
  • 曝光补偿:-1.0 ~ -1.0

这套参数画面干净,呈现静谧的蓝色。适合拍摄日常冷色调题材的画面。

自然风小清新

  • 滤镜模式:正片。 饱和度:2、对比度:4、鲜明度:3
  • 白平衡:复合 AWB
  • 白平衡补偿:B:8、M:0
  • 曝光补偿:-1.0 ~ -1.0

适合旅行拍摄低对比度的小清新画面。

性冷淡

  • 滤镜模式:轻微。 饱和度:9、对比度:5、鲜明度:9
  • 白平衡:CT:4200K
  • 白平衡补偿:B:8、M:2
  • 曝光补偿:-0.3 ~ -1.0

这套参数适合拍摄山脉、沙漠、城市人文等低静默、低色调的景物。

上面两套参数来自霜绝的分享,他还有其他几套参数。

快拍模式(Snap Mode)

快拍模式就是当你快速全按快门时,GR 按照你预先设置的快拍距离来快速捕捉画面的模式。

快拍模式适合用 F5.6 ~ F6.0 的光圈,因为在这个光圈范围内,快拍对焦距离为 15 米甚至更远,可以清晰地拍摄到 15 米范围的物体。

关于这个模式的更多介绍推荐阿默的视频: 【GR浪漫】 GR III GR IIIx 感性で描く日常スナップ

Tav 模式和 P 模式

Tav 和 P 模式很相似,是 GR2 特有的一种模式。在 Tav 模式下,你可以手动调节 F 和 快门速度,让相机自动调节 ISO,这个好处就是你可以用一个相对较快的快门速度(比如 1/125)来保证抓拍街道上移动的人物的同时,很轻松地调节 F 来获得不同的取景深度。但是要切忌让 ISO 高于 800,噪点会明显增加。

P 模式是我街拍用的比较多的模式,它固定了 ISO,你可以在 F 和快门二选一,另一个交给相机自动设置。这种适合在光线环境不好的情况下抓拍,尤其适合成都这种常年阴暗的城市 🤣

❌
❌