再看看 SQL 中的 null

NULL 与任何值的运算结果都是 NULL NULL 有一个很重要的特性:NULL 与任何值的运算结果都是 NULL 1 2 3 4 5 SELECT 1 = 1; -- true SELECT 1 = NULL; -- null SELECT NULL = NULL; -- null SELECT 1 != NULL; -- null SELECT NULL != NULL; -- null 这也就意味着,对于存在列 col BOOLEAN NULLABLE 的表,如果一行中 col 列的值为 NULL,则下面的四条查询都不会包含这行 1 2 3 4 5 6 7 -- 等于自不必说 SELECT * FROM "table" WHERE col = true; SELECT * FROM "table" WHERE col = false; -- 但不等于也不会包括值为 null 的行 SELECT * FROM "table" WHERE col != true; SELECT * FROM "table" WHERE col != false; IS NULL 我们其实基本都知道这点,所以我们在进行不等运算时通常会用 IS NULL / IS NOT NULL 特殊处理 NULL 列 1 2 SELECT * FROM "table" WHERE col is null OR col != true; -- 筛选 col = null 或 false SELECT * FROM "table" WHERE col is null OR col != false; -- 筛选 col = null 或 true IS TRUE / IS FALSE 但其实,除了 IS NULL, IS 操作符后面还可以跟随 BOOL 值,那么,用 IS NOT 其实是一个更加精准的不等于操作 1 2 SELECT * FROM "table" WHERE col IS NOT true; -- 筛选 col = null 或 false SELECT * FROM "table" WHERE col IS NOT false; -- 筛选 col = null 或 true 而对于不是 bool 类型的 nullable 列,我们则可以搭配使用 = != 与 IS 1 2 3 -- 筛选 col = null 或任何不为 1 的值 SELECT * FROM "table" WHERE col is null OR col != 1; SELECT * FROM "table" WHERE col = 1 IS NOT TRUE; concat NULL 与任何值的运算结果都是 NULL,但在函数中则不一定(取决于具体实现) 以连接字符串为例,如果中间存在 NULL,用 || 连接属于运算符,中间任何一项为 NULL 则结果为 NULL,而用 concat 连接则是函数,中间出现的 NULL 会被忽略 1 2 SELECT 'hello-' || NULL || 'world'; -- null SELECT concat('hello-', NULL, 'world'); -- hello-world 布尔逻辑 NULL 与任何值的运算结果都是 NULL,但在布尔逻辑中则不一样…… 试试看能不能说出下面的返回值 1 2 3 4 5 6 7 8 9 10 11 12 13 select not NULL; select null or null; select null and null; select null OR true; select true OR null; select null OR false; select false OR null; select null AND true; select true AND null; select null AND false; select false AND null; 答案 1 2 3 4 5 6 7 8 9 10 11 12 13 14 select not NULL; -- NULL select null or null; -- NULL select null and null; -- NULL select null OR true; -- TRUE select true OR null; -- TRUE select null OR false; -- NULL select false OR null; -- NULL select null AND true; -- NULL select true AND null; -- NULL select null AND false; -- FALSE select false AND null; -- FALSE 其实逻辑在于理解 NULL 代表着「未知」,与「真」「假」一起构成了一个三值集合 那么自运算,无论「未知的反面」还是「未知和未知」「未知或未知」都是未知,也因此都是 NULL OR 的逻辑则是「有任何一个值为 TRUE 就是 TRUE」,因此,在 NULL 参与的运算中,存在 TRUE 则为 TRUE,不存在 TRUE 则为 NULL(继续未知) AND 的逻辑则是「有任何一个值为 FALSE 就是 FALSE」,因此,在 NULL 参与的运算中,存在 FALSE 则为 FALSE,不存在 FALSE 则为 NULL(继续未知) 在聚合函数中 在聚合时,如果对 NULL 值进行聚合,它的数值是被完全忽略的 —— 一个例子是 avg,如果对 1, 2, 3, 4, NULL 做 avg,结果是 (1+2+3+4)/4 如果期望给 NULL 在聚合时一个默认值,可以用 coalesce 函数为它赋一个「默认值」 COALESCE 上面已经提过 coalesce 函数了,再说一句就是,coalesce 函数可以接受多个参数(而不只是两个),会返回第一个非 NULL 值 例如用 coalesce(first_name, nickname, email) 取用户「昵称」 NULLIF pg 中还存在一个 nullif 函数,接收两个值,如果两个值相等则返回 NULL 否则返回第一个值,等价于 CASE WHEN a = b THEN NULL ELSE a END 这个函数主要用来「将零值转换为空值」;举个例子就是 1 2 3 4 SELECT NULLIF(TRIM(name), '') AS name, NULLIF(status, 'N/A') AS status FROM t; 还有就是搭配 NULL 的「可转换为其他类型」的特性 1 2 3 4 SELECT -- 将 '' 直接转换成 date 是会报错的,但是先转换为 NULL 再转换为 date 就可以了 NULLIF(col_date, '')::date AS date FROM t; NULL 值在 PostgresQL 协议中的表示 虽然大概率不会引起混淆,但还是说一下,虽然我们用 psql 规则看到 NULL 值就像是文本一样,但它的底层传输是二进制,PG 规定用 len = -1 代表 NULL 值,所以不存在它与空字符串、null 字符串等混淆的情况 另外,特殊的,在 COPY 中,如果用的 text format(PG 的消息协议中,对于一个值存在 text 和 binary 两种 format),那么会用 \N 代表 NULL 值 参考:https://www.postgresql.org/docs/current/protocol-message-formats.html 中的 Query, Parse, Bind, RowDescription, DataRow NULL 值在文件系统中的存储 当行中有任一列值为 NULL 时,「行头」HeapTupleHeaderData 中的 t_infomask 字段内的 HEAP_HASNULL flag 会被标记为 1,此时在行头后面、其余数据前面会增加一个 null bitmap,用位图的形式存储所有列的 NULL 情况,且如果某一列的值为 null,后面的 data 中将不会出现这一列的信息 另外,null bitmap 除了用来处理 NULL 值,还会用来处理 drop column —— 当一列被删除时其实际上依然存在着,只是后面所有的行都会带有 null bitmap 将这一列标记为 NULL(更事实上,删除列是懒删除,行内的数据都还在,只有下次更新行时才会清理这些数据 —— 当然,清理的方式也是将它标记为 NULL); 参考:https://www.postgresql.org/docs/current/storage-page-layout.html

2025/10/16
articleCard.readMore

迁移 Zeabur 集群

我之前的 Zeabur 集群是独立跑在一台物理机上的,物理机相比云服务器的劣势之一就是底层存储的数据安全性 —— 云服务器的硬盘通常是由云服务商保证了安全性不同,物理硬盘坏了就是坏了 而不幸的是,不久前我就遇到了…… 所有数据差点消失,幸亏坏的盘并没有完全坏,以只读方式还能读(只是不能写)算是挽留了我的数据 但那以后我就一直在思考怎么保证数据安全,经过数个不同的方案研究,我最终的选择是 —— 将整机移动到 PVE 中 然后在 PVE 的底层使用 RAID 1 硬盘 方案敲定,行动开始 —— 我整个的迁移不太具有可复制性,因此写一篇完整的「迁移指南」确实不太有意义,但中间确确实实遇到了一些小问题,我觉得记录下来还是比较有价值的 💡 我两台机器(原 Zeabur 物理机和新的在 PVE 里面装的虚拟机): 在同一个机房 都是用的 RHEL 系的系统 内部文件系统都是 LVM + xfs 将 PVE 的硬盘改成 RAID1 我的 PVE 并不是在安装时就直接做好 RAID1 的,因此保证安全性的第一点是将 PVE 的硬盘改成 RAID1 ⚠️ 需要注意的是,RAID 只是冗余,不是备份也无法替代备份 这次我的迁移也顺便把备份加上了,但备份又是一个较大的话题,因此本文不会写备份相关的内容,如果未来有机会我会单独写一篇文章,不过可以顺便说一嘴的是,我选用的备份方案是 Velero 我没有阵列卡,那么硬盘改 RAID 就只能是通过软 RAID 来组,在 Linux 下组软 RAID 有两种办法,一是利用 mdadm 组 RAID,另一种则是用 lvm 组 RAID;二者各有优劣,我为了灵活性选择了 lvm 的方案 —— 我现在是 2 块 1TB 的硬盘,使用 lvm 的话后面扩容可以很方便的通过加 1 块 2TB 的硬盘来得到完整的 2TB 可用 RAID1 空间,而如果不用 lvm 虽然也能实现但相对来说更加麻烦,且有难以避免的 degrade 时段 哦对,还有,做冗余不能只将数据做冗余,也要考虑引导,都是老步骤,复制下 ELF 分区、重做下 grub 引导,没什么值得说的,有什么问题问问 ChatGPT 它应该能相当完美的回答。 假设我们现在的环境是:原有的数据卷 /dev/sda3、原有的 VG pve、原有的 LV pve/root /pve/swap pve/data、新的数据卷 /dev/sdb3 首先将新的数据卷转换为 PV pvcreate /dev/sdb3 然后将它加入至 VG vgextend pve /dev/sdb3 将 pve/root 转换为 RAID1 十分简单:一行命令 lvconvert --type raid1 -m1 pve/root /dev/sda3 /dev/sdb3 搞定 pve/swap 是个 swap 分区,没有做 RAID1 的必要,我们跳过 难点来到了 pve/data 这个逻辑卷 —— 它不是一个普通的 LV 而是一个 Thin Pool,如果我们直接执行 lvconvert --type raid1 -m1 pve/data /dev/sda3 /dev/sdb3 LVM 会报错 Operation not permitted on LV pve/data type thinpool. 可以认为 Thin Pool 是一个「逻辑逻辑卷」,它事实上分成了 meta 和 data 两部分(通过 lvs -a 命令可以看到,它是用 pve/data_tmeta 和 pve/data_tdata 组合而成的),所以我们说是对它做 RAID1 但事实上想做的是对它底层依赖的 meta 和 data 做 RAID1 —— 这样,Thin Pool 本身和从这个 Thin Pool 所分配出去的子 LV 也都是 RAID1 了。 所以我们要做的是,将它的底层数据卷转换为 RAID1 即可: 1 2 lvconvert --type raid1 -m1 pve/data_tmeta /dev/sda3 /dev/sdb3 lvconvert --type raid1 -m1 pve/data_tdata /dev/sda3 /dev/sdb3 然后等待 lvs -a 的输出中 Cpy%Sync 列变为 100% 即为 RAID 转换完成。 哦对,还有一件事情,虽然示例中我用的另一块盘也是 SATA 盘做例子,但事实上我的另一块盘是 NVME 的,因此我上面说的「pve/swap 不用做 RAID1」之外,我其实还将 pve/swap 给移动到了新的盘上 将 LV 从一个 PV 移动到另一个 PV 的命令:pvmove -n pve/swap /dev/sda3 /dev/nvme0n1p3 迁移方案的选择 物理机迁移到 PVE 最直接的方法就是直接做整盘磁盘镜像然后导入,这种方案没什么可说的,也是最简单的 但是基于下面几个原因 我想换个系统,原来的系统是 CentOS Stream,嗯…… 不太适合一个稳定的环境使用 原来的系统在装 Zeabur 之前还装了一些别的东西,现在已经不需要了 我所有有价值的数据全都在 k3s 里面了,其实只要迁移 k3s 数据就行,而且我也想试试到底怎么迁移下集群,都要迁移哪些数据 我讨厌 Live CD 带的 Bash 的体验,特别是我可能还得用 IPMI 的 Remote Console 而不是自己的本地终端 SSH 过去 我原来其实也不是一块硬盘,而是两块硬盘组的 LVM 卷,两块盘都有数据,整盘迁移还得在 live cd 版系统里面折腾下加载 LVM 我原来物理机的启动用的 UEFI,但新的跑在虚拟机里面的引导用的 BIOS 整机迁移我也得迁移完去 PVE 里面改各种网卡、硬盘的映射什么的,也不容易 嗯…… 我没有一块多余的足够大的盘让我存这么大的磁盘镜像了 = = 我最后放弃了整盘迁移的方案,而是选择 rsync 拷贝 k3s 相关的数据: 在 Zeabur 中添加一个新的 Dedicated Server 让它为我们安装 k3s 的相关内容 安装好以后,执行 /usr/local/bin/k3s-killall.sh 停止整个集群、删除给 zeabur 用的 ssh key、在 zeabur 中删除这个服务器 利用 rsync 拷贝所有集群需要的数据 启动 k3s 服务,耐心等待它把原来所有的镜像下好 登录 zeabur 面板,执行一次 Reinstall Zeabur Service 将 ufw 换成 firewalld 我之前使用的防火墙时 ufw,它简单易用,但有一个非常重要的缺陷:只要 k8s 暴露的端口,它没办法阻止 因此,趁着这次,换回 firewalld 了;根据 Zeabur 的说明和 k3s 文档,需要执行下面的内容放行相关流量 1 2 3 4 5 6 7 8 9 10 11 12 13 firewall-cmd --permanent --zone=public --add-service=ssh # 22 firewall-cmd --permanent --zone=public --add-service=http # 80 firewall-cmd --permanent --zone=public --add-service=https # 443 firewall-cmd --permanent --zone=public --add-port=4222/tcp firewall-cmd --permanent --zone=public --add-port=6443/tcp firewall-cmd --permanent --zone=public --add-port=30000-32767/tcp firewall-cmd --permanent --zone=public --add-port=30000-32767/udp firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16 firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16 firewall-cmd --reload 注:我个人不建议放开 30000-32767 —— 我询问过 Zeabur 支持人员,不放开这些端口不会影响 Zeabur 本身的能力,只是非 http 类型的端口映射无法被访问。Zeabur 会默认将所有端口都暴露到公网,部分端口所对应的服务可能有安全问题,所以我的建议是不要添加 30000-32767 两台规则,而仅在确实需要访问映射的端口时再添加 注 2:如果你是基于你自己需要的目的去访问内部服务(如数据库、Redis),我非常不建议你将这些服务暴露到公网,而是应当使用 zproxy 通过代理访问这些内部服务(当然,你需要将 zproxy 本身使用的端口放开) 不停机更换 IP zeabur 和 k3s 本身对于「本机 IP 」有一定的偏好,如果要改的话比较麻烦,所以简单起见,我的选择是: 假设原机器 IP 1.1.1.1,现在新的机器 IP 2.2.2.2,我将两个机器的 IP 对调下就好了 云主机换个 IP 轻轻松松,物理机换个 IP 就很麻烦,特别是远程物理机换 IP(好吧,其实可以直接登录 IPMI 在 Remote console 里面改 IP 的,但我们当这个不存在) 不过挺好的一点是,我可以通过引入一个新的临时 IP 让这两台机器都不离线的情况下对调下 IP 💡 我这两台机器在同一个内网、网关相同、DNS 相同,都是手动指定的 IP 没有使用 DHCP 假设两台机器的网卡都是 eno1、临时 IP 是 3.3.3.3 流程是: 虽然和换 IP 的流程没关系,但是建议先执行 /usr/local/bin/k3s-killall.sh 把整个机器的 k3s 停掉 在原机器(1.1.1.1)执行 nmcli con mod eno1 +ipv4.addresses "3.3.3.3/24" && nmcli dev reapply eno1 让这个机器有 1.1.1.1 和 3.3.3.3 两个 IP 通过 3.3.3.3 登录原机器(1.1.1.1 + 3.3.3.3),执行 nmcli con mod eno1 -ipv4.addresses "1.1.1.1/24" && nmcli dev reapply eno1 让这台机器只剩下 3.3.3.3 一个 IP 登录新机器(2.2.2.2)执行 nmcli con mod eno1 +ipv4.addresses "1.1.1.1/24" && nmcli dev reapply eno1 让这个机器有 1.1.1.1 和 2.2.2.2 两个 IP 通过 1.1.1.1 登录新机器(2.2.2.2 + 1.1.1.1)执行 nmcli con mod eno1 -ipv4.addresses "2.2.2.2/24" && nmcli dev reapply eno1 让这台机器只剩下 1.1.1.1 一个 IP 通过 3.3.3.3 登录原机器,执行 nmcli con mod eno1 +ipv4.addresses "2.2.2.2/24" && nmcli dev reapply eno1 让这个机器有 2.2.2.2 和 3.3.3.3 两个 IP 通过 2.2.2.2 登录原机器(2.2.2.2 + 3.3.3.3)执行 nmcli con mod eno1 -ipv4.addresses "3.3.3.3/24" && nmcli dev reapply eno1 让这台机器只剩下 2.2.2.2 一个 IP 好了,现在原机器 IP 变成了 2.2.2.2、新机器 IP 则变成了 1.1.1.1 —— 对调完成 🤷 显得繁琐了点,但其实不难 利用 rsync 迁移数据 主要有两部分的信息我们需要迁移 Zeabur 连接集群所用的 SSH 凭据 集群本身的各种配置、数据 我们需要用 rsync 同步下面的文件/目录到新的集群: /etc/rancher/k3s k3s 配置及连接集群的凭证 /var/lib/rancher/k3s/server/db/ k3s 数据库 /var/lib/rancher/k3s/server/token k3s 内部授权 token /var/lib/rancher/k3s/storage 使用的 local volume 的存储路径 同步时使用的命令为(在原机器上执行) 1 2 3 4 5 6 7 8 9 10 11 P=/etc/rancher/k3s # 依次使用上面所需要同步的路径 NEW=2.2.2.2 # 新机器的 IP 地址 # 同步数据 rsync -aHAX --numeric-ids --info=progress2 \ --delete --delete-delay \ $P \ root@$NEW:$P # 恢复 SELinux 属性(如果需要的话) ssh root@$NEW "restorecon -RF $P || true" 在启动前,删掉下面的目录(k3s 启动时会自动重新创建) /var/lib/rancher/k3s/server/cred /var/lib/rancher/k3s/server/tls 合并卷容量 我为虚拟机分配了 512GB 的硬盘,但我没想到的是我使用的 Automatic Disk Partition 竟然将绝大多数空间给了 /home 导致我迁移数据一半告诉我没空间了 = = 幸好,虽然这个奇怪的硬盘分区有点烦人,但 RHEL 系一律使用 LVM —— 它也只是个 LV 而已!简简单单,删掉这个 LV 把空间匀给 root 就好了 对了,删除 /home 挂载点要记得改 /etc/fstab,不然下次系统可能启动不起来 更新 node 因为 Zeabur 不允许同一个 IP 有两个 Dedicated Server 出现,所以安装的过程中是使用了一个新的 IP 装的 在迁移完,如果想用回原来的 IP,需要修改 /etc/systemd/system/k3s.service.env 文件里面的 K3S_NODE_NAME 然后启动起来看看 kubectl get nodes,如果还有原来的 IP 的 Node,删了就好 Pod 扩容 我数据迁移完一启动各种飙红,仔细一看原来是 Pod 数量超限(默认 110),需要修改 kubelet 配置 在 /var/lib/rancher/k3s/agent/etc/kubelet.conf.d 目录下,创建一个 01-max-pods.conf ,里面写 1 2 3 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration maxPods: 1000 然后执行 systemctl restart k3s 就好 哦对,这个其实我之前就改过,但是迁移的时候漏了,你可以看看原来机器的 /var/lib/rancher/k3s/agent/etc/kubelet.conf.d 目录下有没有除了 00-k3s-defaults.conf 以外的文件,有的话最好前面 rsync 的时候直接一起迁移了,省事

2025/10/6
articleCard.readMore

2025Q3 订阅 Recap

现存订阅 ChatGPT Plus ChatGPT 目前已经成了我的长期订阅选择 一方面,GPT-5(和原来的 o3)体验确实好,可以在思考的过程去搜索、执行代码简直是王炸 另一方面,Codex 写代码是真的强,绝大多数能力已经能超过 Claude 了,只有 Codex 软件本身的用户体验差了一点 还有一点,Codex 自带 PR 代码审查!而且是完全免费的,完全可以替代掉 Code Rabbit / Cursor 之类的产品了 这一切只要 $20 真的很值 Proton Visionary Proton Visionary 依然在我的订阅列表中 但我已经越来越少用它了,目前没有退订一方面这东西退订需要 Deactivate 邮件地址。。另一方面则是我的上古版 Visionary 价格太香了(现在退了再买差不多涨价了得有 70%) 目前我已经停用了绝大多数 Proton 的组件,只在使用 SimpleLogin 和 Mail。 btw,如果你也在用 Proton Mail 但是不想用它的官方客户端(官方客户端实在太难用了,改版前难用,改版后不但难用 bug 还多)可以考虑各种第三方邮件客户端 + protonmail-bridge-docker 方案 AD:Proton Visionary 可以分享给他人加入,如果你想加入我的家庭组请发邮件给我 singee@proton.me,价格为 92 天 ¥109 Cursor Pro 没错,我依然订阅着 Cursor 🤔 Cursor 在折腾它的 plan 把自己折腾的残废的情况下,我依然在订阅着它😂 首先一点,Cursor Tab 是真的强,市面上没有对手。我最初就是完全因为 Tab 而订阅的 Cursor,而现在也经常用它来修一些 AI 搞不定的复杂逻辑。 另外,看似 Cursor 涨价了,但作为老用户还能继续享受一个月 500 次(Opt out of new pricing)。而现在的 500 次是不再限制 20 个工具调用的 500 次,比 max 只差在了 context 长度上(但我基本都会拆分好任务再给 AI,达到 200k 的次数屈指可数 —— 而且就算达到了 Cursor 也会自动 compact),所以整体依然很香(至少在强制 usage-based billing 之前很香😂) lisa host 在 AI 时代,一个独享的家宽 IP 可能已经是想流畅使用各种服务的必备品了;之前的 IP Royal 虽然便宜但是质量真的不敢说好,目前换成了 lisa,相当棒 AD:要购买欢迎使用我的推广链接 https://lisahost.com/aff.php?aff=3372 Dler Cloud Diamond Dler 的服务我觉得已经回到了原来的水平,所以我目前也回到了 Diamond Plan,我觉得目前它又一次成为了我心目中的第一(至少比某 N 家强 emm) iximiuz labs Lifetime 曾经我是 Premium 订阅,在今年中升级到了 Lifetime 对于想学习 k8s、网络、Linux 的,很推荐这个平台,Learn by doing 的形式,边学习边实践还有对应的测试,绝对比单纯看某些文档/博客更适合学习 而且,在过去一年的更新中,iximiuz labs 增加了不少新功能,对我最重要的就是自定义 Playground —— 想学习/体验下什么产品,可以快速创建一个去测试,而且多机器的设计也可以自由去测试集群相关功能。 其他 还有一些仍然在订阅的,但与《2024Q3 订阅 Recap》相比没什么变化或没什么想写的,就不再赘述了,在这里列个清单,感兴趣的可以回看我之前的 Recap Zero to Mastery iCloud 2TB 山姆卓越 Setapp GLaDOS Exercism IFTTT labuladong 算法笔记 国内、国外两台独立服务器 杂七杂八的各种国内会员 其他这一年订阅过又退订了的 Claude Max 首当其冲的自然是 Claude 了;其实到现在 Claude 都是桌面 MCP 做的最好的、Claude Code 也是综合体验最好的,奈何 Opus 持续降智的同时 Codex GPT-5 太强了而且性价比真的高…… 相比 2024Q3 的变化 传送门:《2024Q3 订阅 Recap》 退订 Monica Unlimited 我曾经对于 Monica 十分满意(可以回看我上次的 Recap) 奈何,它家就是出了 Manus 的那家,然后重心转移了之后 Monica 就再也没什么新功能了,对于新模型的支持也不积极,高级模型还出了个积分制额外收费…… 不过最重要的还是,ChatGPT Plus 已经成为了我的常订,而我对于 Claude、Gemini 等模型也没那么高的需求了 当然,如果你想要一个大而全又没那么贵的解决方案,Monica 还是一个很棒的选择(其实我现在依然用者它的浏览器插件,简单的问题随时划词问下体验也不错),如果你想订阅,欢迎使用我的邀请链接 https://monica.im/?ref=bryan 退订 GitHub Copilot 感觉 GitHub Copilot 已经跟不上现在这个时代了…… 作为 Cursor + JetBrains 双持的我,前者自不用说,Copilot 对我而言毫无吸引力,而后者嘛 JetBrains 的 AI Assistant 已经自带了 AI 补全,将模式改成 Creative 后(我其实不太理解为啥它不是默认 - 可能是这个模式更耗费服务器资源?)体验十分棒。美中不足的是 NES(Next Edit Suggestions)还在 beta 且仅支持特定语言(没一个我在写的 = =),但 Copilot 虽然支持但也不咋地。。还不如免费的 Trae 呢😂 综上,👋 Copilot —— 一个我从内测就开始用的插件,一个几乎重塑了我的编码习惯的插件 退订 Cloudflare Workers 我曾经是一个 Serverless 的「爱好者」,我觉得它是未来 奈何,现在越来越感觉到对于一个标准的应用来说,数据库是不可或缺的,而在 Serverless 的环境下,用户-边缘计算服务-数据库的延迟会变得十分明显,造成极差的用户体验 我目前几乎所有的服务都已迁移到 fly.io 和 zeabur —— 与之对应,Cloudflare Workers 也退订了 退订 Inoreader 目前我已将 RSS 完全切换到了我自己写的 1Space,也因此退订了 Inoreader 不过,虽然退订了,但我没想到的是,过去的一年多 Inoreader 竟然获得了若干更新 —— 很神奇的事情,获取是换了个激进的产品经理吧,一个停止了七八年没大更新的服务突然更新/优化了不少 退订 WeRSS 这不是我想退订,是官网登录系统直接挂了,给开发者发邮件/微信都不回! 哎,再没有通过 RSS 稳定订阅微信公众号的方式了 基于搜狗、即刻抓取的方案,十分不靠谱(有时候丢,不丢的时候有时候能延迟个一周) 而基于微信读书的 WeWe RSS、WeChat2RSS 等确实靠谱,但我珍爱我的微信读书账号不想让它被封啊…… 为啥没有个 SaaS 啊…… 哦,(写这篇文章的时候)刚搜到了个 WeRSS - 微信公众号订阅助手,看上去是基于微信公众平台?我感觉有整个微信被封号的风险 🤷 现在只能,已经不看微信公众号了 其他已退订的服务 X Premium(优惠到期,没啥续费的动力,后面需要的话可能开个 Basic) IPRoyal(使用 lisa 替代)

2025/9/19
articleCard.readMore

再见 xlog

2023 年 4 月我在 xlog 下写下了第一篇博客。xlog 真的是一个我很喜欢的博客平台,好看,对于 markdown 的第一方支持,不用考虑部署、图床等问题,自带 AI Summary、自带双语翻译,真的是一切只需要「写」即可,哦对,更重要的是,依赖于区块链技术,虽然你写的文章是在平台上的,但所有数据都依然属于你,且所有数据都是永久保存。 然而,两年过去了,xlog 这个平台虽然还在,但我认为它已经死了。GitHub 更新已基本停滞,没有人处理 issue、没有人审阅 pr,xlog 官网上充满了 spam,也没有人去管理社区。 其实这一切的原因都很好理解 —— xlog 的开发者 DIYGod 转去做 Folo 了。是啊,Folo 相比于 xlog 绝对是更有前景的项目,也更容易讲故事…… xlog 已被放弃,再加上 xlog 的母公司 RSS3 最近的动荡,我觉得是时候从 xlog 迁移走了。 我其实无比庆幸,两年前的时候我是打算把我所有博客迁移到 xlog 的。但是我的博客用的是 /yyyy/mm/dd/xxx 的 url 格式,而 xlog 并不支持这种格式,因而我一直是用 blog.singee.me 作为博客主域名 + articles.singee.me 作为 xlog 博客域名的,然后通过自动化将内容进行同步,且配置 xlog 博客的链接作为 Canonical URL。 这让我这次的迁移十分简单:将我原始博客的链接删掉、将 articles.singee.me 的原链接进行跳转即可! 嗯…… 唯一的副作用,通过 RSS 订阅我的博客的人应该会因为 id 变化了重新看一遍我的博客。其实这个是可以解决的,因为我之前 blog.singee.me 的 RSS 是通过 patch 了 hexo-generator-feed 实现的,完全可以特殊处理,但考虑到经历这个事件以后我应该不会再考虑这种「奇葩」的两处链接的形式了,所以我就把之前的 patch 回滚了,顺便增加一下我博客的曝光度 emm anyway,博客又回来了,我又回到了原来的工作流:Notion 写作 + 同步到博客。已经这样写了两个博客了,一切都挺好,和两年前相比仿佛什么都没变 XD

2025/8/1
articleCard.readMore

Python 的 pth 文件:在程序启动前自动执行命令

Python 的 pth 文件提供了在任何 Python 解释器执行前执行命令的能力,可以方便的执行一些初始化脚本 pth 文件的能力 Python 解释器在启动时会自动 import site 模块(除非启动时指定 -S flag),而 site 模块有一个行为就是会寻找 site-packages 目录(最常用的场景就是我们安装包的目录)下的所有 .pth 文件并依次「执行」它。 需要注意的是, pth 实质上并不是脚本,它的定义是「path configuration file」,格式是每行一个「additional items (one per line) to be added to sys.path」,实际上,它每一行可以是下面的值之一: 空行、注释行(以 # 开头):跳过 以 import 开头的字符串:会被 exec() 解释执行 其他字符串:会被追加进 sys.path 之中 因此,利用第二个能力,我们实质上可以任意执行我们需要的脚本! 🗒️ 注:如果脚本执行出现异常,只会打印出错误而不会阻止解释器的继续执行 可用性 & 执行时机 在 Python 3 的所有版本可用 相关 pth 文件会在 python 解释器启动时执行 —— 这包括执行脚本前、启动解释器前、执行任何用 python 写的程序前(如 pip) 当存在多个 pth 文件时,将采用字母序的方式依次执行 —— 一个值得强调的点是,python 的 virtualenv 就是通过 _virtualenv.pth 执行的 —— 这意味着,我们应当注意 pth 的命名以让其确保在 venv 之前或之后运行

2025/7/18
articleCard.readMore

浏览器从 A 到 Z

将 A-Z 逐一输入到 Google Chrome 的地址栏里,我的 Google Chrome 都会自动补全出哪些域名呢? 本文灵感来源于 2024: 浏览器从 A 到 Z,首发于少数派 A - https://axiom.co Axiom 是我最喜欢的网站日志收集分析工具,免费版拥有着高达 500GB / 月的免费额度,我的多款产品(包括我最新正在做的 1Space)都是使用的它作为日志收集。 如果你在找一款日志工具,强烈推荐它! B - https://baidu.com 无可争议…… 虽然我现在确实不怎么用百度了,但是网络连不上的时候还是第一时间用百度做测试的😂 C - https://chatgpt.com ChatGPT 作为 AI 时代的先驱,在目前似乎仍然保持着第一🤔 如果这篇文章是在 2024 年写的,那么我的 C 可能就不是它了;2024 年或许算是 ChatGPT 落后的一年,但是在 2025 年,O1-Pro 和 Deep Research 让它再次成为了模型领域的 No.1。我追随着最新的前沿技术,也订阅了高达 $200 / 月的 ChatGPT Pro,或许很贵,但我觉得它确确实实为我节省了很多的时间和精力。 D - https://discord.com 论「社区」,似乎已经有越来越多的服务选择在 Discord 这个平台上建立了。我在用的很多产品都用它作为产品发布和讨论的渠道。 E - edge://inspect 没想到竟然是个 Inspect 页面。这是 Chrome 自带的浏览器调试工具页面(不过因为我在用的是 Edge,所以不是 chrome://inspect 而是 edge://inspect)。我在做的 1Space,因为利用了 Shared Workers 作为多标签页的同步方案,因此我需要频繁访问它来查看同步日志。 F - https://fly.io Fly IO 是我用了数年的容器服务平台,曾经驱动了我绝大多数的产品(其实现在也有不少,还剩下 40% 吧),体验很不错。 Fly IO 给我印象最深刻的其实是他们的 招聘。他们的招聘与绝大多数的面试不同,采用的是「做 2-3 道实践题」+「与他们工作一天」的形式,只看能力,不看背景(甚至他们不要求简历),而且是全远程工作、薪资透明(仅与面试定级有关,与历史薪资、所在地域无关)。 G - https://gmail.com Gmail —— 一个不存在的邮箱平台 H - http://localhost:3333 好好好,竟然是 localhost —— 3333 这个端口是我的 1Space 的本地开发服务器所使用的端口。 I - https://inoreader.com 虽然 RSS 越来越没落,虽然 RSS 平台越来越多(嗯?这俩之间的因果关系有点反直觉),但我还是觉得 RSS 是最适合我的信息收集渠道,inoreader 也是最好用的 RSS 客户端。 多说一句(广告时间到),inoreader 单纯做 RSS 已经到了几乎极致了,但是我们用 RSS 的目的或许不是为了收集信息,而是为了学习信息,而在整理回顾上,inoreader 就没那么强了,甚至我觉得其他能作为它下游知识管理的产品(例如我目前在用 readwise,或许下面 R 能看到它的身影)也不够优秀。因此我做的 1Space 目的就是打通知识「收集 - 管理 - 回顾」的全流程。 J - https://www.jetbrains.com JetBrains 曾经是无可争议的 IDE 老大。 唉,曾经。JetBrains 的体验真的比 VS Code 好太多,哪怕在 VS Code 最擅长的前端领域,我也敢说 WebStorm 吊打它。奈何,现在是 AI 的时代,IDE 已经被 Cursor 为首的 AI IDE 重塑,而 JetBrains 真的在 AI 的潮流上落后了。打败你的,可能并不是你的竞争对手。 K - https://kb.singee.me 哦吼,我自己的 Knowledge Base!我的知识库一直是公开的,我的很多懒得不适合整理成博客的内容都写在了上面,主要记录我各种笔记、踩过的各种坑。 L - http://localhost:3333 嗯…… 和 H 一样,看出来我真的很努力去做 1Space 了。 M - https://monica.im 在我从浏览器中测试「M」之前,我就猜到了,我最常用的网站必有它。 Monica 可能宣传的不多,但是说它所属公司的另一个产品 Manus 估计大多数人都听过。如果说 Manus 是 AI Agent、完全替代人工方面的王炸,那么 Monica 就是你日常使用 AI 过程中的瑞士军刀 —— 你需要的 AI 能力,几乎总能在 Monica 中找到。 N - https://notion.so 竟然是 Notion。Notion 似乎我已经不用多余篇幅介绍了,估计能看到本文的受众都见过它。我曾经是 Notion 最早期的用户之一,但坦白说我已经挺长时间没怎么用过 Notion 了…… 其他 N 开头的产品还不够能打哇🤷 O - https://originui.com 又是一个程序员专属产品。这是一个 UI 组件库,算是 shadcn 的补充,同样以「复制-粘贴」的形式引入组件,需要的人值得一看! P - https://www.paypal.com 竟然是 Paypal,类似国内的支付宝的产品?又是一个实际上我没怎么用的产品,看起来 P 开头的服务也不够能打😮‍💨 Q - 不便透露 emm 企业内部平台,略过 R - https://read.readwise.io 哦!Readwise Reader!一个稍后读的阅读器 + RSS! 真的挺好用的,而且对于我一个早期的 Readwise 用户而言直接是加量不加价。但我其实对它的很多细节还是不怎么满意的。我的 1Space 去年才开始正式做,但其实早在数年前就有计划了。但当时,刚刚打算做,就遇到了 Readwise Reader 宣布立项,我被他们开始宣传的「Reader for Power Reader」吸引了,决定等他们的产品,奈何,等了这么久,虽然已经比其他阅读器做的好很多了,还是不足以达到我想要的地步😮‍💨 最后还是没逃开自己做的命运。 S - 不便透露 我的某产品的内测页面🤔 T - temporal-web.temporal.svc.cluster.local:8080 Temporal 的管理面板。Temporal 是一个 Workflow 管理与调度的工具。写后端的人应该都知道,如果一个逻辑直接使用很普通的代码编写,在项目发展的过程中,很容易就会遇到复杂度指数级提升。如果并发、重试等基础操作在每个接口、每个 RPC 调用都自己写一遍,实在是没有意义,而且很容易漏了哪里导致上线 bomb。Workflow 就是对这种场景的一个解决方案,你的一切逻辑都定义成 Workflow,而重试、日志、并发等等都由调度器管理,项目初期看起来可能稍显繁琐,但是随着项目复杂度的提升、随着项目对并发的要求的提升,你会感谢当年选型选择了使用 Workflow 进行组织的你的。 哦对,Temporal 被设计为可以支撑超大型项目。如果你的项目是个中小型项目,也可以看看其他解决方案,例如 Trigger.dev、inngest、Restate。 U - https://ui.shadcn.com 要不是这篇文章严格按照字母序组织,它应该是和前面的 Origin UI 放在一起的。Shadcn/ui 简直就是没有设计能力的开发者的福音,如果你想做一个自己的产品,又苦于不知如何让页面变得好看,不如试试它。 另外,shadcn/ui 的开发者目前已经加入了 Vercel,因此 Vercel 的 v0 对 shadcn/ui 有很强的支持,如果你不但没有设计能力、甚至不是开发者,那么,利用 v0,只动动嘴皮子也可以得到利用 shadcn/ui 组织的很好看的界面。 V - https://v2ex.com i2exv2ex 论坛可以说是中国最大的同性交友社区程序员论坛了,嗯,就是这样。 W - 不便透露 我自用的某产品页面 X - https://x.com 全球最大的社交平台,实话说,对不追星的人来说,比微博好玩多了 Y - https://www.youdao.com 各种词典软件层出不穷,但我觉得依然还是有道最好用。带有韦氏、柯林斯、牛津的资源,各种原版例句,而且完全免费。 Z - https://zeabur.com 如果你是一个开发者,又不想浪费过多的精力在自动部署、运维上,就选 Zeabur 吧!推送代码秒部署,最重要的是,还有国内的服务器节点,真的很好用

2025/3/21
articleCard.readMore

2024Q3 订阅 Recap

现存订阅 (按照实付月金额降序) Proton 我是 Proton Visionary 用户,Proton Visionary 的价格 $479.76 / 2y,是我所有订阅服务中最贵的了。 我依然在持续订阅中,且目前对于 Proton 依然很满意 Visionary 套餐持续「加量不加价」 相比之前,近年明显增加了 Visionary 套餐的「新品尝鲜特权」,所有新产品/新功能都会优先给 Visionary 用户使用 在可以预见的未来我将继续长期订阅下去 AD:Proton Visionary 可以分享给他人加入,如果你想加入我的家庭组请发邮件给我 singee@proton.me,价格为 92 天 ¥109 Monica 订阅了 Monica Unlimited,年付 $199 Monica 是一个我认为最全的 AI 工具,可以说覆盖了几乎所有需要 AI 的平台,有网页版的 chat、浏览器插件扩展(Chrome + iOS Safari)、桌面端、手机端,几乎可以说只要订阅 Monica 即可满足所有对于 AI 的需求 AD:如果想购买 Monica,请使用我的邀请链接 https://monica.im/?ref=bryan Zero to Mastery 订阅了 ZTM 年度订阅,价格 $195.3 / 年(使用了提供给中国用户的 30% PPP 折扣) 一个计算机相关知识的学习网站。我是从 Udemy 学习到的他的课程,质量很不错而且持续更新,并且有比较多我感兴趣的内容,所以就去网站订阅了。目前在这上主要用来学习算法、系统设计和设计。 如果你也对它感兴趣,单门课程可以看看 Udemy 搞促销的时候,大概七八十一节,如果想订阅的话记得取申请 PPP 折扣订阅 另外他家还有一点有意思的是,Lifetime 标价 $999,但只要你学习 30 个月(每个月学习至少一节课)就可以得到免费的 Lifetime Plan。这种不促销而是鼓励你持续学习的做法我很喜欢。 Duolingo 订阅了 Duolingo Family,价格 $195.3 / 年 和 GF 一起用,总觉得用多邻国学知识是次要的,玩玩才是主要的…… 我目前用它来学习日语和粤语,女票用来学习韩语 如果你也对多邻国感兴趣,去淘宝买 ¥40 / 年的家庭组拼车或许才是最划算的方案 GitHub Copilot 订阅了 Personal Plan,价格 $10 / 月 不知何故我三年的 Copilot 免费突然没了,或许和最近确实没怎么折腾开源有关吧。 一直用 Copilot 已经形成惯性了,写代码时候没有 Copilot 提示(特别是一些错误信息什么的)总觉得麻烦好多,价格也不贵,就付费订阅了。 而且现在 Copilot 已经不只是代码补全了,一方面支持了 CLI,一方面支持了 Chat。特别是 Chat,之前只能在 IDE 和手机版用,现在不但可以在 GitHub 主站用了,还可以索引仓库来 Ask Repo,甚至还有独立的 Chat 页面。 Copilot 我唯一不满意的应该还是在 JetBrains 下生成 Commit Message 吧(而 JetBrains 自家的 JetBrains AI 就支持),我其实挺期待一个可以学习我历史 commit message 习惯来自动生成一个 commit message 然后我只需要简单更改就可以提交的功能的。 iCloud 订阅了 iCloud+ 2TB,价格 ¥68 / 月 emmm 这可能是唯一一个我一直想退订但是没退吧,还每次扣款前五天提醒我下让我头疼。因为和家人共用,存储照片 + 备份,所以虽然感觉这是个性价比极低还不好用的东西我也只能忍着用了 Dler Cloud 翻*服务,Pass Gold 套餐,享受了 8 折,折后 ¥800 / 年 实话说已经不是很满意了,两年前我对 Dler 的评价是「综合体验最好的翻*服务」,但是现在我已经要打个问号了。 近年来持续的涨价 + 服务劣化,可能和大环境有关,但是我也实在不想为它付这么高昂的费用了。(事实上,之前我的套餐是最高等级 ¥1688/年 的 Diamond,现在也降级了) 山姆 卓越会员,¥680 / 年 有点乱入的感觉,毕竟山姆可能是这个列表里唯一不是数字产品的了 之前一直都是蹭别人的卡或者闲鱼,今年终于自己开了。在 ¥260 的普通卡和 ¥680 的卓越卡之间纠结来着,但是想了下差价每个月 ¥35,卓越+联名卡额外返利共 3%,平均每个月消费 1167 应该还是很容易的;而如果想完全赚回卡费,扣除掉送了 ¥50 开卡礼券、副卡 ¥100 闲鱼卖了,¥530 的价格在 4% 的返利下只需要每月消费 1104 就能回本相当于卡完全没付钱(这个金额我没算错哈,420/0.3 > 530/0.4),更何况还有副卡也会消费、加上卓越还有一些洗牙洗车券什么的,很容易回本,就开了。 Cloudflare Workers Paid Plan,$5.2 / 月(因为我超用量了……) 我有数十个小服务跑在 Worker 上,大大提升了我的效率,可以说这个 5 刀是我付的最值的 5 刀了。 iximiuz 前期通过 Pateron 赞助获得的 Premium,$5 / 月 一个用来学习服务端技能的 learn-by-doing 平台,提供 tutorial 和对应的实验环境 Inoreader RSS 服务商,活动时候开的 $89.99 / 18 月 Readwise $4.5/月(中国用户 PPP 优惠 50%) 我在同时用着它的回顾卡和 Reader 服务。但事实上我对它并不是非常满意,更多的是一种「市面上没有替代品」心态在用着它;我也在筹备我自己的类似产品,或许 2025 能见面。 X Premium HK$396 / 年(前几天活动 40% off 开的) X Premium 可以说是一个我有时候需要有时候不需要的东西,大多数情况 Basic 够了,但我又很烦广告。之前一直都是一阵开一阵关的,这次想了下入了年付。 X Premium 有一个非常坑的地方!如果你点了升级按钮,那么不会有任何二次确认而是会给你直接升级+扣费;而如果你点了降级按钮,那么会立刻把你降级而且没有任何退款。 月付下损失可控,但是想像下年付的情况下,还剩下 11 个月的时候,想看看 Premium+,点了下,立刻升级成功了,反应过来不对,我只是想看看啊,于是又点了下降级,然后立刻降级成功了,紧接着发现哎竟然扣款了,那我降级会退款吗,等了几天发现没退,然后想那应该差额成为 credit 了吧,再次支付,嘿,又扣了一次钱。。然后联系客服,等待一周得到了一个「no refund」的回复…… GLaDOS 同样是翻 * 服务,¥398 / 400 天 我用来做 Dler 的 backup 的,另外如果坚持每日签到的话价格要低于标价,因为差不多每签到 20 天能得 10 天,算下来 ¥398 能用两年多 IPRoyal 固定美国家宽出口 IP,$4 / 月 懂得都懂,应付风控 WeRSS 微信公众号转 RSS 订阅,VIP3 ¥292.5/年 Setapp 家庭组拼车,¥18/月 单单其中的 Timing 这个订阅 App 就能回本了,我额外在用的 Spark、Bartender、iStat Menus 等都算是白送的 Dropbox 土耳其订阅 TRY 809.99/年 我认为体验最好的网盘,没有之一 Exercism 一个编程学习平台,在出 Insider 之前赞助了 $2 / 月现在获得了免费的 Insider【但其实所有功能都能免费使用】 主要可以用来学习多种编程语言,让自己刻意走出舒适区,而且有问题可以找 mentor 帮忙,完全免费 IFTTT Pro+ Plan,很早期订阅的,$1.99 / 月 labuladong 算法笔记 算法学习网站,¥109 / 年 淘宝 88 VIP ¥88 / 年 各种域名 我名下目前有 13 个域名,差不多均价 $12 / 年,加起来约 $150 服务器 目前我的主要服务都跑在 fly.io 和 Digital Ocean 上,约 $100 / 月,但目前均在免费 credit 范围内 杂七杂八的国内会员 腾讯视频 爱奇艺 优酷 京东 plus 网易严选 京东 1 号店 QQ 绿钻 网易云 QQ 超级会员 QQ 阅读 知乎会员 各种联名卡活动开的,很难去算清楚具体花了多少钱,差不多每年 ¥400 吧 信用卡年费 建行大山白:¥1800/年,可用 40w 积分抵扣 中信 美国运通 Safari 白金卡:¥480/年,可用 6w 积分抵扣 银联 Plus 白金卡:¥480/年,可用 12w 积分抵扣 万豪精逸白金卡:¥980/年,刚性年费 相比于 2024Q2 的主要变化 取消了 Monica 的自动续费 之前订阅了 Monica 的年付($199 / 年),本来是打算长久用下去的,但是这个季度我决定取消了它的自动续费,等到过期了再重新考虑是否续费,主要原因: 降价,Monica 的 Unlimited 在我订阅期间有一两个月的 $99 / 年的循环折扣,现在应该也只需要 $149 / 年;而我的订阅费用却只能是 $199 / 年,不能享受折扣 劣化:GPT-4 在很多情况下比 GPT-4o 要强,但是 Monica 在 4o 出了以后下掉了 GPT-4,且无法恢复(在智能体 - Models 中仍然可以选择 4,但是无法使用任何非文字的功能) 劣化 + 涨价:我初用的时候 Monica 可以在长上下文的情况下获得比较不错的响应,但是两个月前我开始发现在长上下文的情况下 Monica 会丢失一些信息,猜测做了截断/压缩;在 o1 出了以后直接更新摊牌,不限上下文的要按量付费 申请了 Memex 的退款 我曾经入了 Memex 的 lifetime plan,但是用了不到一周就发现完全不足以达到我的期望,就申请了退款。作者很快回复了,了解我不满意的点并承诺会在 6 个月内改善(也就是到现在这个月,2024.10)如果到时候仍然未能改善,继续给我退款,我同意了。 但自那以后,我所有的询问邮件、产品反馈邮件都石沉大海,再无人回复过。而现在 6 个月到了,我更是发现整个产品已经 3 个月没有更新了。 我以 Chargeback 作为威胁给作者再次发了最后一封邮件并表达了我的愤怒之情,这次作者倒是很快恢复了,说会 asap 启动 退款流程,然而至此又消失了,没有退款、没有回复…… 计划停用 inoreader 其实早在两年前我就已经计划停用 inoreader 了,一个原因就是日益渐长的年费,现在已经丧心病狂到了$7.5 / 月(对比 miniflux 官方实例只要 $15/年),另一个原因就是最近的新 UI 让我卡到完全无法正常使用(事实上,在我现在的数据规模下,旧的 UI 也在某些地方会很卡,但至少不至于卡到不能用) 替代方案现在主要打算自己开发了,想做一个完整的 RSS + Workflow 的形式,来替代我目前 inoreader + IFTTT 的形式

2024/10/17
articleCard.readMore

使用 TypeScript 撰写 OmniFocus 脚本

OmniFocus 4 即将发布!在我多年管理我的待办的过程中,我尝试过 Todoist、滴答清单、Things、Sorted 等等几乎所有市面上的 TODO 软件,但最终,OmniFocus 终成我一直以来的最终选择。而谈及 OmniFocus 的强大性,不得不提的就是他强大的自动化能力 —— Omni Automation。 Omni Automation 实际上是基于 JS 脚本的,而编写纯 JS 脚本的过程…… 一言难尽。虽然 Omni Automation 官方提供了 TypeScript 的定义文件,但一方面难以做好类型检查,另一方面其详尽程度仍有待提升(长久不更新、大量使用 any 等),此外,由于缺乏打包工具,代码逻辑的复用也显得颇为困难(我甚至很长一段时间都是靠着 Mac 版本 OmniFocus 的一个 bug 实现的逻辑复用)。 为了庆祝 OmniFocus 4 的面世,我决定将我个人开发并使用的方案整理开源,包括打包脚本和类型定义,还有我使用的一些工具函数及脚本,希望可以让更多人能够愉快地编写 OmniFocus Script。 使用 使用此模板创建一个仓库 克隆你创建的仓库 运行 pnpm install 安装依赖项 运行 pnpm build 构建脚本 脚本源码放在 src 目录中,编译结果(可被 OmniFocus Scripts 使用的)放在 dist 目录中。 撰写脚本 src 目录内的任何不以 _ 开头的 TypeScript 文件都将被视为 OmniFocus 脚本并编译(_ 开头的脚本文件被保留用于工具函数)。 任何脚本都必须遵循以下模式: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export const action = new PlugIn.Action(function (selection) { // do anything you want }); action.validate = function (selection) { // do anything you want }; export const meta: Meta = { label: "...", description: "...", identifier: "...", author: "...", version: "0.1", }; 其中: action 和 meta 是必需的,action.validate 是可选的 meta 必须是脚本的最后一部分。它之后不可以有任何内容。 构建与使用 运行 pnpm build,构建后的脚本(以 .omnifocusjs 结尾)将被放置在 dist 目录下。 你可以直接将 dist 目录中的脚本拷贝到 OmniFocus 的脚本目录,也可以利用脚本进行同步。 如果你使用 iCloud 保存 OmniFocus 脚本,可以直接使用 pnpm sync 自动将构建好的脚本同步到 iCloud 中的 OmniFocus 脚本目录;如果你不使用 iCloud 而是使用了自定义路径,可修改 sync.sh 文件改变目标路径。 End 此方案我个人已用一年有余,但一方面开源版本可能有些错误,另一方面可能有更多的定制化需求。 欢迎进入 仓库 页面提交 Issue 和 PR!

2023/12/13
articleCard.readMore

[备忘] Go init 行为

总结 基础规则: 所有的 init 函数都在一个 Goroutine 中执行(但请参见下面的特殊注意) 如果 package a 引用了 package b,那么 a 的 init 一定在 b 的 init 运行完成后运行 main package 的 main 函数一定在其他 init 函数均运行完成后再运行(即运行顺序为 package 的 init -> main 的 init -> main 的 main) 同一 package 中的多个文件中的 init 执行顺序未定义,同一文件中的 init 自上而下运行 如果 package a 同时引用了 package b 和 c,那么 b 与 c 的 init 顺序在 Go1.21 及之后定义 在 Go1.20 及之前: 如果 package a 引用了 package b,那么 b 的 init 一定在 a 之前运行 但是,如果 package a 同时引用了 package b 和 c,只要 b c 之间没有引用关系,b c 的执行顺序是不定的 在 Go1.21 及之后: 对于无引用关系的包(即 Go1.20 及之前的中的第 2 点),按照其包名字母序决定引用顺序(例如 a 一定在 b 之前执行,github.com/xxx/xxx 一定在 gitlab.com/xxx/xxx 之前执行) 特殊注意: 如果 init 存在阻塞,那么用于运行 init 的 goroutine 可能创建新的 goroutine,这会导致某些 init 代码并发运行 存在阻塞的情况下,不会保证无引用关系的 package 的 init 完成先后顺序(参考示例 c) 存在阻塞的情况下,如果 package a 依赖了 package b,那么 a 的 init 一定在 b 的 init 运行完成后开始运行(参考示例 d) 示例项目 https://github.com/singee-study/go-init 参考 The Go Memory Model Go 1.21 Release Notes

2023/12/12
articleCard.readMore

[随笔] 一开始就公布定价

看到了 FeedbackTrace 这个项目,看起来不错,但是我不会去使用,一个主要的原因就是它没有公布定价。 如果去选择一个一个直接面向 C 端的第三方产品,我一定会选择已经有了明确定价的 —— 否则我将承担巨大的未来的风险。如果未来定价不满足我的预期,我需要去考虑怎么迁移数据、怎么抹平用户体验差异等,一方面可能带来不确定的时间成本,另一方面可能产生糟糕的终端用户体验。 面对公测期间的价格和上线后不一致的做法,各个产品有各个产品不同的思路,最统一的就是降价后下一期账单自动降价、涨价保持原有价格不变或至少保持原有价格一定时间。特别的,Rewind 在 Early Access 期间的策略是调低定价后为历史订单全额退款、调高价格保持用户的原有价格不变,而 ChatGPT 的策略则是调低定价后给予用户差额价格退款并给予额外补偿。 多说一句,公测免费并不是不预先提供价格点的「借口」,完全可以预先告诉哪些是付费点但是公测期间可以免费使用(例如 Omnivore 明确在文档中说明了它的哪些功能未来可能是收费的)。 因此,我的产品,也一定在最初就给出一个收费点定价方案 —— 哪怕不够完美。大体来说,我会提前将价格和收费点列明,公测期间可以免费使用收费功能(或部分收费功能),而预先开始订阅收费版本的用户(即早期支持者)承诺未来如果存在价格调整他们一定可以「使用最低且不高于现有的价格得到最多的服务」。

2023/11/25
articleCard.readMore

[随笔] Server Action

最近在一个自己的小 Side Project 中使用了一下 Server Action,感受大概是 更多的其实是替换原来的 /api 路由,可以把它想做一个 TypeSafe 的 RPC,原来需要自己去定义 API 然后生成代码,这些 Server Action 直接帮你做了 因此,感觉原来用了 Next.js 的 /api 路由的,迁移到 Server Action 会有较大的体验提升 Server Action 本质只能向前端发送一个 JSON Object,缺少了标准 HTTP/RPC 的 StatusCode 和 Headers 的能力,因此如果真的想去很好的用它可能 Server 和 Client 仍然需要包一层 目前 Server Action 实质上缺少触发前端状态更新的逻辑,如果很需要其实可以利用 WebSocket 或者利用「包的那层」来手搓,实现起来其实也不难 我遇到 Server Action 最主要的问题其实是它触发调用的那层做的事情实在是太多了,会触发很多 React 内部的状态转换逻辑,另外我发现它与 useReducer 一起用甚至还有 bug(哪怕是目前 Next.js 14 Stable 了) 综上,全栈项目玩玩可以,其余的…没啥必要

2023/10/27
articleCard.readMore

[随笔] Swift 异步:Task vs DispatchQueue

在 Swift 上,执行一个异步的函数大体上有两种办法 Task DispatchQueue 背景知识:线程和队列 Swift 同时支持多线程和异步,因此 存在主线程和多个后台线程 每个线程存在若干队列(若干个全局 global 队列(每个优先级一个)、自定义队列(人为创建)) 背景知识:队列优先级 在队列层面,存在优先级的概念(在 Task 中叫 priority,在 DispatchQueue 中叫 qos) userInteractive:最高优先级,适用于 UI 操作(例如动画等)(在 Task 中被弃用) userInitiated:较高优先级,适用于用户触发的操作 default:中优先级,默认(在 Task 中被弃用) high, medium, low:高中低优先级(仅在 Task 中存在) utility:较低优先级,适用于耗时的后台任务 background:最低优先级,适用于耗时的后台任务 unspecified:继承于 Thread.current.qualityOfService(在 Task 中被弃用) 背景知识:main main 同时隐含着两个概念:main thread 和 main queue,事实上无需特意区分(可以认为只有 main thread 才有 main queue,而 main thread 的不同 queue 之间并无特殊区别) main 的主要用处在于其是直接和用户交互的,只有在 main 才能修改 UI、如果 main 繁忙用户会感觉到 UI 刷新卡顿 对于 Task 而言,在 main 执行使用 Task { @MainActor 对于 DispatchQueue 而言,在 main 执行使用 DispatchQueue.main 注意,Task 的 @MainActor 实际上并不是让闭包代码在 main 执行,而是让其他执行它的 concurrency-aware 代码逻辑在 main 上执行(但是目前存在非 concurrency-aware 的代码,因此可能标记了 @MainActor 实际上仍然是在非 main 执行的,这种时候需要手动切换到 main;不过这种情况 XCode 会有警告,所以不必过于担心) DispatchQueue 相关 依赖的是 Grand Central Dispatch(GCD) libdispatch Dispatch.main 是在主线程执行的,其他均不保证实际执行线程 执行顺序先进先出 队列类型分为 serial 和 concurrent 两种,serial 在执行完一个任务后才会开始执行另一个、concurrent 会同时执行多个任务(故不保证任务的结束顺序);main 是 serial 类型的、默认也是 serial 类型的 执行时存在 sync 和 async 两种,sync 会等待这个任务完成后返回,async 会直接返回 因上述特性,在 main 线程执行 DispatchQueue.main.sync 会导致死锁 无法直接获得执行结果 Task 相关 Task 有 child 和 detached 两种类型,区别在于后者无法访问到调用方可见的变量 来自于 main 创建的 child Task 会始终在 main 执行,否则(除非标记 @MainActor)不保证执行的线程/队列 已提交的任务可取消、可获取(等待)结果(获取结果需要 await) 如果和 DispatchQueue 类比,可以认为 Task 与 DispatchQueue.async 执行上的行为一致 额外:RunLoop RunLoop.current 返回当前的线程循环、.main 返回主线程循环 通常情况下,无需特别注意 RunLoop.main 和 DispatchQueue.main 的区别,二者都是在 main 上执行逻辑 RunLoop.main 中执行的逻辑可以被外部用户操作暂停,而 DispatchQueue.main 不会,因此在处理滚动时可能更希望使用 RunLoop.main,而其他通常场景则一般使用 DispatchQueue.main

2023/9/21
articleCard.readMore

[随笔] RSS 与稍后阅读

尽可能降低 RSS 的「手动筛选」,包括内容量和频率 更多的关注你的稍后阅读,而不是追求新的信息 1 有两个层面,一是尽可能的用自动化,将关注的内容直接推送到稍后阅读工具中,而不是人工筛选,另一个是人工筛选的频率要尽可能降低,至少不应该每天都去筛选 2 是,尽管新闻很诱人,但是稍后阅读中堆积的内容也曾是你的兴趣,尽可能让你的稍后阅读呈现递减的趋势,反而新的内容没那么重要

2023/9/18
articleCard.readMore

在 Shell 脚本中嵌入二进制文件

前言 在构建 Linux/Unix 系安装包时,除了打包成标准的适用于各种发行版的软件包以外,我们更多的可能希望可以提供一个 shell 脚本进行程序的安装,将安装步骤简单收敛为两步:下载脚本 + 运行脚本。 通常,这种大多数的安装脚本都是再次从互联网上下载所需资源的,这样可以最小化脚本的体积并保证安装的始终是最新版本,但是这同样导致了下载到的「安装包」本质上是个「安装器」,无法离线安装。 本文将介绍一种已经在生产环境验证过的方案,来动态在安装包中嵌入网址。 受限于一些原因,本文更多的从原理层面进行讲解,暂无法提供完整的代码解决方案,敬请谅解 另外,以下代码均为根据原理为本文撰写,虽然原理已经经过生产验证但所使用的代码并未经过严格的生产验证,如有 bug 烦请告知 脚本构成 整个脚本由 head + embed-bin 两部份构成;embed-bin 是不加改动的将我们的程序进行嵌入的,而 head 是一个动态生成的脚本,用于从当前脚本中提取 embed-bin 并执行。 head 脚本随是动态生成,但为了维护的简单,这里采用模板的形式 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 #!/bin/sh # # NAME: {{ .Name }} # PLATFORM: { .Platform }} # DIGEST: {{ .MD5 }}, @LINES@ THIS_DIR=$(DIRNAME=$(dirname "$0"); cd "$DIRNAME"; pwd) THIS_FILE=$(basename "$0") THIS_PATH="$THIS_DIR/$THIS_FILE" EXTRACT={{ if .AutoExtract }}1{{ else }}0{{ end }} FORCE_EXTRACT=0 PREFIX={{ .DefaultPrefix }} EXECUTE={{ if .AutoExecute }}1{{ else }}0{{ end }} {{- end }} USAGE="{{ .Opts.Usage }}" while getopts ":h{{ .Opts.FlagNames }}" flag; do case "$flag" in h) printf "%s" "$USAGE" exit 2 ;; {{- range .Opts.All }} {{ .Name }}) {{ range .Action.DoIfSet }}{{ . }} {{ end }};;{{ end }} *) printf "ERROR: did not recognize option '%s', please try -h\\n" "$1" exit 1 ;; esac done # Verify MD5 printf "%s\\n" "Verifying file..." MD5=$(tail -n +@LINES@ "$THIS_PATH" | md5sum) if ! echo "$MD5" | grep {{ .MD5 }} >/dev/null; then printf "ERROR: md5sum mismatch of tar archive\\n" >&2 printf "expected: {{ .MD5 }}\\n" >&2 printf " got: %s\\n" "$MD5" >&2 exit 3 fi {{ if .Archive -}} if [ -z "$PREFIX" ]; then PREFIX=$(mktemp -d -p $(pwd)) fi if [ "$EXTRACT" = "1" ]; then if [ "$FORCE_EXTRACT" = "1" ] || [ ! -f "$PREFIX/.extract-done" ] || [ "$(cat "$PREFIX/.extract-done")" != "{{ .MD5}}" ]; then printf "Extracting archive to %s ...\\n" "$PREFIX" { dd if="$THIS_PATH" bs=1 skip=@ARCHIVE_FIRST_OFFSET@ count=@ARCHIVE_FIRST_BYTES@ 2>/dev/null dd if="$THIS_PATH" bs=@BLOCK_SIZE@ skip=@ARCHIVE_BLOCK_OFFSET@ count=@ARCHIVE_BLOCKS_COUNT@ 2>/dev/null dd if="$THIS_PATH" bs=1 skip=@ARCHIVE_LAST_OFFSET@ count=@ARCHIVE_LAST_BYTES@ 2>/dev/null } | tar zxf - -C "$PREFIX" echo -n {{ .MD5 }} > "$PREFIX/.extract-done" else printf "Archive has already been extracted to %s\\n" "$PREFIX" fi fi if [ "$EXECUTE" = "1" ]; then echo "Run Command:" {{ .Command }} cd "$PREFIX" && {{ .Command }} fi {{- end }} exit 0 ## --- DATA --- ## 这个脚本模板存在着两种类型的变量:{{ XX }} 与 %XX%,其中主要的区别在于整个模板渲染要分成两步:首先渲染所有的 {{ XX }} 变量,然后再渲染剩余的 %XX% 变量;渲染前者时无特殊要求,而渲染后者时需要保证变量的渲染前后文本的长度与行数不变。 这个脚本会将 embed-bin 作为压缩包进行解压,这主要是因为我们内部使用时相关数据可能很大(数百兆乃至上 GB),如果你只需要一个小的脚本可以移除压缩有关的代码。 另外,这个脚本会在执行前进行一次 MD5 校验,这主要是为了防止一些情况下脚本下载不完全导致的。但是因为本身 embed-bin 就是压缩包了,因此可以删除校验有关的代码来加快安装速度(我们内部保留的原因一方面是因为我们 embed 的内容不止压缩包甚至不止一个文件,另一方面就是为了给出更好的错误提示)。 这个脚本也提供了参数传递的能力和部分默认值的指定,这是因为在某些情况下相关步骤可能异常而全量执行所有步骤较为耗时,在实际使用中你可根据实际需要删改脚本参数。 脚本的参数由模板渲染引擎给出,这主要是为了可维护性,如果你更希望在脚本中撰写相关的内容则可以修改相关部分 渲染脚本 话不多说,直接上代码 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 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 //go:embed "header.sh.tmpl" var headerTemplate string type headerOptions struct { Name string MD5 string Opts *Opts *ArchiveOptions } type ArchiveOptions struct { DefaultPrefix string AutoExtract bool AutoExecute bool Command string // 使用 $PREFIX 引用 prefix Filename string // 供 builder 使用,不会打入最终文件 } func (o *ArchiveOptions) QuotedCommand() string { return shells.Quote(o.Command) } func renderHeaders(o *headerOptions) ([]byte, error) { t := template.New("") tt, err := t.Parse(headerTemplate) if err != nil { return nil, ee.Wrap(err, "invalid template") } b := bytes.Buffer{} err = tt.Execute(&b, o) if err != nil { return nil, err } return b.Bytes(), nil } func getHeaders(o *headerOptions) ([]byte, error) { tmpl, err := renderHeaders(o) if err != nil { return nil, err } lines := bytes.Count(tmpl, []byte("\n")) + 1 tmpl = bytes.ReplaceAll(tmpl, []byte("@LINES@"), []byte(strconv.Itoa(lines))) replaceAndFillSpace(tmpl, "@BLOCK_SIZE@", blockSize) return tmpl, nil } func replaceAndFillSpace(data []byte, old string, new int64) { oldBytes := []byte(old) newString := strconv.FormatInt(new, 10) newWithExtraSpace := append([]byte(newString), bytes.Repeat([]byte{' '}, len(old)-len(newString))...) // assert len(old) == len(newWithExtraSpace) // Apply replacements to buffer. start := 0 for { i := bytes.Index(data[start:], oldBytes) if i == -1 { return // stop } start += i start += copy(data[start:], newWithExtraSpace) } } type Opts struct { All []*Opt } func (opts *Opts) FlagNames() string { b := strings.Builder{} for _, opt := range opts.All { b.WriteString(opt.Name) if len(opt.Arg) != 0 { b.WriteString(":") } } return b.String() } func (opts *Opts) Usage() string { b := strings.Builder{} b.WriteString("Usage: $0 [options]\n\n") all := make([][2]string, 0, 1+len(opts.All)) nameLen := 2 all = append(all, [2]string{"-h", "Print this help message and exit"}) for _, opt := range opts.All { bb := strings.Builder{} bb.WriteString("-") bb.WriteString(opt.Name) if opt.Arg != "" { bb.WriteString(" [") bb.WriteString(opt.Arg) bb.WriteString("]") } name := bb.String() if len(name) > nameLen { nameLen = len(name) } all = append(all, [2]string{name, opt.Help}) } for _, a := range all { b.WriteString(a[0]) b.WriteString(strings.Repeat(" ", nameLen-len(a[0]))) b.WriteString("\t") b.WriteString(a[1]) b.WriteString("\n") } return b.String() } type Opt struct { Name string Arg string Help string Action OptAction } type OptAction interface { DoIfSet() []string } type DoAndExitAction struct { Do []string ExitCode int } func (a *DoAndExitAction) DoIfSet() []string { r := append([]string{}, a.Do...) r = append(r, "exit "+strconv.Itoa(a.ExitCode)) return r } type DoAndContinueAction struct { Do []string } func (a *DoAndContinueAction) DoIfSet() []string { return a.Do } func SimpleSetEnvAction(envName string, envValue interface{}) *DoAndContinueAction { return &DoAndContinueAction{ Do: []string{fmt.Sprintf("%s=%v", envName, envValue)}, } } type Builder struct { Name string ArchiveOptions *ArchiveOptions } func openAndWrite(filename string, w io.Writer) (int64, error) { f, err := os.Open(filename) if err != nil { return 0, err } defer f.Close() return io.Copy(w, f) } func fillAndSetHeader(prefix, filename string, f io.Writer, headers []byte, offset int64) (int64, error) { fileLength, err := openAndWrite(filename, f) if err != nil { return 0, ee.Wrap(err, "cannot append data for "+prefix) } firstOffset := offset firstBytes := blockSize - (firstOffset % blockSize) replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_OFFSET@", prefix), firstOffset) replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_BYTES@", prefix), firstBytes) copy2Start := firstOffset + firstBytes copy2Skip := copy2Start / blockSize copy2Blocks := (fileLength - copy2Start + firstOffset) / blockSize replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCK_OFFSET@", prefix), copy2Skip) replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCKS_COUNT@", prefix), copy2Blocks) copy3Start := (copy2Skip + copy2Blocks) * blockSize copy3Size := fileLength - firstBytes - (copy2Blocks * blockSize) replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_OFFSET@", prefix), copy3Start) replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_BYTES@", prefix), copy3Size) return fileLength, nil } func (b *Builder) Build(saveTo string) error { header := &headerOptions{ Name: b.Name, ArchiveOptions: b.ArchiveOptions, Opts: &Opts{}, } fileMD5 := md5.New() var dataSize int64 if header.ArchiveOptions != nil { if header.ArchiveOptions.AutoExtract { header.Opts.All = append(header.Opts.All, &Opt{ Name: "E", Help: "Do not extract archive", Action: SimpleSetEnvAction("EXTRACT", 0), }) } else { header.Opts.All = append(header.Opts.All, &Opt{ Name: "e", Help: "Also extract archive", Action: SimpleSetEnvAction("EXTRACT", 1), }) } header.Opts.All = append(header.Opts.All, &Opt{ Name: "f", Help: "Force extract archive", Action: SimpleSetEnvAction("FORCE_EXTRACT", 1), }) prefixOpt := &Opt{ Name: "d", Arg: "DIR", Help: "Extract to directory", Action: &DoAndContinueAction{ Do: []string{`PREFIX="${OPTARG}"`}, }, } if header.ArchiveOptions.DefaultPrefix != "" { prefixOpt.Help += fmt.Sprintf(" (default: %s)", header.ArchiveOptions.DefaultPrefix) } header.Opts.All = append(header.Opts.All, prefixOpt) if header.ArchiveOptions.Command != "" { if header.ArchiveOptions.AutoExecute { header.Opts.All = append(header.Opts.All, &Opt{ Name: "X", Help: "Do not execute command", Action: SimpleSetEnvAction("EXECUTE", 0), }) } else { header.Opts.All = append(header.Opts.All, &Opt{ Name: "x", Help: "Also execute the command", Action: SimpleSetEnvAction("EXECUTE", 1), }) } } n, err := openAndWrite(header.ArchiveOptions.Filename, fileMD5) if err != nil { return ee.Wrap(err, "failed to read archive file to get md5") } dataSize += n } _ = dataSize header.MD5 = hex.EncodeToString(fileMD5.Sum(nil)) headers, err := getHeaders(header) if err != nil { return ee.Wrap(err, "failed to get headers") } f, err := os.OpenFile(saveTo, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { return ee.Wrap(err, "failed to write file") } defer f.Close() // write header headersLen, err := f.Write(headers) if err != nil { return ee.Wrap(err, "failed to write headers") } currentOffset := int64(headersLen) // embed archive if header.ArchiveOptions != nil { n, err := fillAndSetHeader("ARCHIVE", header.ArchiveOptions.Filename, f, headers, currentOffset) if err != nil { return ee.Wrap(err, "failed to embed installer") } currentOffset += n } _ = currentOffset // rewrite headers _, err = f.Seek(0, 0) if err != nil { return ee.Wrap(err, "failed to seek file") } newHeadersLen, err := f.Write(headers) if err != nil { return ee.Wrap(err, "failed to rewrite headers") } if headersLen != newHeadersLen { return ee.New("headers unexpected change after rewrite") } return nil } 使用则是 1 2 3 4 5 6 7 8 9 10 11 b := &Builder{ Name: name, ArchiveOptions: &binbundler.ArchiveOptions{ DefaultPrefix: "/path/to/extract", AutoExtract: true, AutoExecute: true, Command: "bash $PREFIX/install.sh", # 安装命令,简单可直接执行,复杂可使用一个额外的脚本 Filename: "/path/to/embed", }, } err = b.Build("/path/to/script-save-to.sh") 在整个脚本中,动态插入了相关模板变量,并计算了相关 offset 后记 本文更多的只是提供一种思路(利用 dd 来解压、动态生成 opt 来控制执行过程),相比于网上更多的利用 grep 等手段来定位二进制内容更加的高效、易维护。 在此基础上,其实还可以实现更多的事情(依赖验证、安装多个文件等),欢迎尝试

2023/8/28
articleCard.readMore

谈谈时区

通常在本地化时往往会涉及到时区转换的问题,而通常在真正关注到时区之前我们所「默认」使用的时区为 UTC 或“本地”。 本文以 Go 为例,分析下 Go 中的时区使用。 读取时区 在 Go 中,读取时区使用的是 LoadLocation 函数。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // LoadLocation returns the Location with the given name. // // If the name is "" or "UTC", LoadLocation returns UTC. // If the name is "Local", LoadLocation returns Local. // // Otherwise, the name is taken to be a location name corresponding to a file // in the IANA Time Zone database, such as "America/New_York". // // LoadLocation looks for the IANA Time Zone database in the following // locations in order: // // - the directory or uncompressed zip file named by the ZONEINFO environment variable // - on a Unix system, the system standard installation location // - $GOROOT/lib/time/zoneinfo.zip // - the time/tzdata package, if it was imported func LoadLocation(name string) (*Location, error) 阅读注释可知,如果 name 为空 / UTC 则使用 UTC、为 Local 则使用本地时区(在后面进行讲解),否则,从特定位置进行读取。 所谓读取,是读取的 tzfile 时区文件,可阅读该文档查阅更多信息。简单来说,时区文件是一个以 TZif 开头的二进制文件,其中包含了时区的偏移量、闰秒、夏令时等信息,Go 可以读取相关文件并解析。 如果存在 ZONEINFO 环境变量,利用该变量指向的目录/压缩文件进行读取 在 Unix 系统上,使用系统标准位置 (主要用于编译 Go 时)从 $GOROOT/lib/time/zoneinfo.zip 进行读取 (如果 import 了 time/tzdata )从程序嵌入的数据读取 我们比较关注的是 2,即 Unix 的标准时区文件的存储位置。在 Unix 系系统中,时区文件通常存储在 /usr/share/zoneinfo/ 目录中(根据系统不同,还可能是 /usr/share/lib/zoneinfo/ 或 /usr/lib/locale/TZ/),例如,中国(Asia/Shanghai)的时区定义文件就是 /usr/share/zoneinfo/Asia/Shanghai。因此,通常程序可以直接从系统中获取到时区的信息。 注意,在 alpine 环境中,是没有时区定义文件的,因此我们需要特别关注进行处理 可以在程序中使用 import _ "time/tzdata" 在编译期将时区文件编入程序中,这样在无法找到系统中的时区定义时也可以查找到标准的 IANA 时区定义 如果我们不需要特别动态的时区,我们可以避免使用 LoadLocation 而是使用 FixedZone 由我们自己提供时区名称和偏移,例如对于中国 UTF+8 可以使用 time.FixedZone("Asia/Shanghai", 8*60*60) 本地时区 通常在我们真正考虑到时区问题之前我们所「默认」使用的时区均为所谓的「本地时区」。 以 time.Now 为例, 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 type Time struct { wall uint64 ext int64 loc *Location } // Now returns the current local time. func Now() Time { sec, nsec, mono := now() mono -= startNano sec += unixToInternal - minWall if uint64(sec)>>33 != 0 { // Seconds field overflowed the 33 bits available when // storing a monotonic time. This will be true after // March 16, 2157. return Time{uint64(nsec), sec + minWall, Local} } return Time{hasMonotonic | uint64(sec)<<nsecShift | uint64(nsec), mono, Local} } 可以看到,Time 结构体的最后一个字段 loc *Location 就是时区,而 time.Now 中使用的时区为 Local。 我们本文主要关注时区,如果你对这段代码中的其他因素感兴趣,欢迎阅读 你真的了解 time.Now() 吗?。 这里的 Local 就是本地时区,即运行这个程序所在的机器的时区。 1 2 3 4 5 6 7 // Local represents the system's local time zone. // On Unix systems, Local consults the TZ environment // variable to find the time zone to use. No TZ means // use the system default /etc/localtime. // TZ="" means use UTC. // TZ="foo" means use file foo in the system timezone directory. var Local *Location = &localLoc 阅读 Go 中关于 Local 的说明可知,Go 会优先尊重 TZ 环境变量所指定的时区,如果没有特殊指定,则使用 /etc/localtime 文件读取当前时区。 那么,Local 又是怎么初始化的呢? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 // localLoc is separate so that initLocal can initialize // it even if a client has changed Local. var localLoc Location var localOnce sync.Once func (l *Location) get() *Location { if l == nil { return &utcLoc } if l == &localLoc { localOnce.Do(initLocal) } return l } 从这段代码的逻辑中不难猜出,Local 并没有真的在程序启动时读取上述信息,而是在首次使用时才真正的通过执行 initLocal 函数来进行初始化。同时,这段代码也隐性的为使用 Location 提出了一个要求:必须调用 get 方法来获取「真正的 Location」。 initLocal 函数在 zoneinfo_*.go 中定义,在不同的机器上有着不同的实现,但本质上都是 如果 TZ 内容以 : 开头,则会忽略该冒号 如果没有指定 TZ 环境变量,阅读 /etc/localtime(通常就是指向了真正时区文件的软链接) 如果指定的 TZ 环境变量为绝对路径,阅读该文件 否则按照上文所分析的 LoadLocation 流程进行时区文件的读取 另外,上述 3 步骤如果失败,会 fallback 到使用 UTC 时间 加餐:tzdata tzdata 详细定义了历史时区的变更情况,包括夏令时、闰秒等,因此 Asia/Shanghai 相比于简单的 GMT+8 更具有通用性、且可正确处理历史数据。 如果你感兴趣,可以利用 zdump Asia/Shanghai -i 查看上海的时区变化,并和使用夏令时的时间 zdump America/Chicago -i 进行对比。

2023/8/20
articleCard.readMore

利用 Fly.io 部署 Windmill

Windmill 是一个开源可自部署的工作流引擎(甚至低代码平台),可访问 https://www.windmill.dev/docs/compared_to 查看其官方与 Zapier 等服务的对比。 本文将展示如何将其部署到 Fly.io 概览 本文介绍了两种方式来进行部署 Windmill,我分别称之为 minimal 和 full 模式。 在 minimal 模式下,所有 Windmill 组件运行在单一 app 中,这是最简单且经济的方案,但是不够灵活(例如,因为 Server 和 Worker 运行在同一个容器中,难以水平扩展); 在 full 模式下,Windmill 组件被拆分至两个不同的 app 中,其中一个运行 Server,另一个运行 Worker,相比于 minimal 方案较为复杂,但是可以让你更灵活的控制分布式的 Worker。 请克隆仓库 windmill-on-fly,然后继续下面的步骤。 LSP 组件 minimal 与 full 均需预先安装 LSP LSP 提供了 Windmill 前端页面上的代码补全功能,需要独立部署。 Launch & Deploy 如果想要修改区域,请修改 fly.toml 文件中的 primary_region 字段(默认为 sea)。如果修改区域,建议将所有 app 的区域保持一致以获得最佳体验。 进入 lsp 目录下,执行 fly launch 来启动项目;启动时,针对 Would you like to copy its configuration to the new app? (y/N) 问询选择 Y,并稍后为该 app 起个名字。 后续本文中如果出现 my-windmill-lsp 请替换成你在这里设置的 app 名称。 在接下来的问询中,不要创建 Postgresql 和 Redis(针对相关问询均选择 N)。 最后询问 Would you like to deploy now? (y/N) 时选择 Y 来进行部署。 分配 IP 部署完成后,执行 fly ips allocate-v6 --private 来为服务分配一个 Private IP(你也可以同时删除所有的 Public IP)。 Scale LSP 对于性能的要求比较高。基于我实际感受,想要正常使用建议至少为其分配 4GB 内存。可执行 fly scale vm performance-2x 进行 Scale。 考虑到价格因素,可开启 fly.toml 中的 auto_start_machines=true 和 auto_stop_machines=true 来节省成本 Minimal 模式部署 如果你选择 Full 模式,请跳过当前步骤 下面所有的操作均在 minimal 目录下进行。 Launch 执行 fly launch 来启动项目;启动时,针对 Would you like to copy its configuration to the new app? (y/N) 问询选择 Y,并稍后为该 app 起个名字。 后续本文中如果出现 my-windmill 请替换成你在这里设置的 app 名称 在接下来的问询中,不要创建 Postgresql 和 Redis(针对相关问询均选择 N)。 最后询问 Would you like to deploy now? (y/N) 时选择 N 来跳过 Deploy。 配置数据库 请跟随 Fly Postgres 教程 先创建好一个数据库集群。 请将下面命令中的 my-pg 修改为你的数据库集群的名称 执行 fly pg attach my-pg --superuser 来创建数据库与用户。 配置 LSP 编辑 Caddyfile,将其中的 xxx-windmill-lsp 修改成你自己的 windmill-lsp 的服务名。 (可选)绑定自定义域名 如果你没有或不想绑定自定义域名,请跳过此步骤。 执行 fly certs add YOUR_DOMAIN 配置自定义域名(将 YOUR_DOMAIN 替换为你的域名),并按照要求配置相应的 CNAME 记录。 配置环境变量 打开 fly.toml 文件,将 env.BASE_URL 修改为你的外部访问域名 —— 如果你没有使用自定义域名,那么这里输入 https://my-windmill.fly.dev,否则输入 https://YOUR_DOMAIN。 如果你想修改 worker 的数量,可修改 env.NUM_WORKERS 的变量值。 Deploy 输入 fly deploy 进行部署(部署过程中会自动构建所需镜像、创建需要的 volume)。等待部署成功后访问你的实例,测试利用账号 admin@windmill.dev 密码 changeme 登录。 Full 模式部署 如果你选择 Minimal 模式,请跳过当前步骤 full 目录下包括两个目录 - server 与 worker,分别存储了 Server 与 Worker 组件的配置。 Server - Launch 进入 full/server 目录下,执行 fly launch 来启动项目;启动时,针对 Would you like to copy its configuration to the new app? (y/N) 问询选择 Y,并稍后为该 app 起个名字。 后续本文中如果出现 my-windmill-server 请替换成你在这里设置的 app 名称 在接下来的问询中,不要创建 Postgresql 和 Redis(针对相关问询均选择 N)。 最后询问 Would you like to deploy now? (y/N) 时选择 N 来跳过 Deploy。 Server - 配置数据库 请跟随 Fly Postgres 教程 先创建好一个数据库集群。 请将下面命令中的 my-pg 修改为你的数据库集群的名称;下面命令中的 database-name 和 database-user 均使用 windmill,你也可以自行修改成你需要的名字。 进入 full/server 目录下,执行 fly pg attach my-pg --superuser --database-name windmill --database-user windmill 来创建数据库与用户。 记录上述命令执行时打印出来的 DATABASE_URL 备用 Server - 配置 LSP 进入 full/server 目录下,编辑 Caddyfile,将其中的 xxx-windmill-lsp 修改成你自己的 windmill-lsp 的服务名。 (可选)Server - 绑定自定义域名 如果你没有或不想绑定自定义域名,请跳过此步骤。 进入 full/server 目录下,执行 fly certs add YOUR_DOMAIN 配置自定义域名(将 YOUR_DOMAIN 替换为你的域名),并按照要求配置相应的 CNAME 记录。 Server - 配置环境变量 进入 full/server 目录下,打开 fly.toml 文件,将 env.BASE_URL 修改为你的外部访问域名 —— 如果你没有使用自定义域名,那么这里输入 https://my-windmill-server.fly.dev,否则输入 https://YOUR_DOMAIN。 Server - Deploy 进入 full/server 目录下,输入 fly deploy 进行部署(部署过程中会自动构建所需镜像、创建需要的 volume)。 新版无 volume 的 fly app 会默认部署两个实例,Server 使用多实例的意义通常不大,可执行 fly scale count 1 将多余的实例删除。 等待部署成功后访问你的实例,测试利用账号 admin@windmill.dev 密码 changeme 登录。 Worker - Launch 进入 full/worker 目录下,执行 fly launch 来启动项目;启动时,针对 Would you like to copy its configuration to the new app? (y/N) 问询选择 Y,并稍后为该 app 起个名字。 后续本文中如果出现 my-windmill-worker 请替换成你在这里设置的 app 名称 在接下来的问询中,不要创建 Postgresql 和 Redis(针对相关问询均选择 N)。 最后询问 Would you like to deploy now? (y/N) 时选择 N 来跳过 Deploy。 Worker - 配置数据库 进入 full/worker 目录下,执行 fly secrets set 'DATABASE_URL=xxx' --stage 来配置连接数据库的凭据。(将 xxx 替换成上面步骤中打印出来的 DATABASE_URL 的内容) Worker - 配置环境变量 进入 full/worker 目录下,打开 fly.toml 文件,将 env.BASE_URL 修改为你的 windmill-server 的外部访问域名 —— 如果你没有使用自定义域名,那么这里输入 https://my-windmill-server.fly.dev,否则输入 https://YOUR_DOMAIN。 如果你想修改 worker 的数量,可修改 env.NUM_WORKERS 的变量值。 Worker - Deploy 进入 full/worker 目录下,输入 fly deploy 进行部署。 访问你实例的 /workers 路由(例如 https://my-windmill-server.fly.dev/workers)查看是否有你的 Worker 展示。 常见问题 升级(或指定版本号) 当前所有版本号均使用 latest,这在生产上可能不适用(因为每次重新部署都会升级到最新版本)。 版本号存在于如下的文件中 lsp/fly.toml minimal/Dockerfile full/server/Dockerfile full/worker/fly.toml 你可将这些文件中的 "ghcr.io/windmill-labs/windmill:latest" 中的 latest 换成你希望的版本号,例如 1.133。 唯一需要着重注意的点:请始终保持所有版本号一致,否则可能出现未知问题。 升级:修改版本号后在各自相关目录下重新执行 fly deploy 即可。

2023/8/2
articleCard.readMore

强制关闭 xLog 的 Dark Mode

自从 xLog 引入了 Dark Mode 以后,因为大大降低了对比度造成文字灰色可读性差,我就暂停使用了 xLog (同时暂停了写作和阅读)而期待官方更新。然而,数月过去了,依然没有丝毫改善(难道开发团队都没人用 Dark Mode 吗)…… 行吧,自己动手,丰衣足食🌚 关闭自己博客的 Dark Mode 因为 xLog 不支持自定义 js,故无法直接修改 xLog 的 Dark Mode 判断逻辑。因此,只能强行将 Dark Mode 模式下的 CSS 覆盖成 Light Mode 的。 警告 使用该功能意味着需要时刻跟随 xLog 的官方样式更新,否则会造成博客效果变差。如果使用请自行承担相关风险。 下面的 CSS 尽可能覆盖了所有 Dark Mode 下的样式,并隐藏了 Theme Switcher(可查看当前站点来预览效果)。 另外,因为 xLog 使用了 Service Worker,修改站点样式后可能需要刷新两次才能预览到最新效果。 附:CSS 首先打开你的博客页面,切换成 Light Mode,然后在浏览器控制台执行下面的脚本来获取你的主题色 当然,你也可以从其他人的 xLog 博客中执行来获取他的主题色 1 ['--auto-hover-color', '--auto-theme-color', '--auto-banner-bg-color'].forEach(name => console.log(`${name}: ${getComputedStyle(document.documentElement).getPropertyValue(name)} !important;`)) 使用获取到的值来替换下面的第一个 .dark 中的变量 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 .dark { /* 将下面的三个变量值替换 */ --auto-hover-color: #e9e7e8 !important; --auto-theme-color: #bd80bd !important; --auto-banner-bg-color: #d3cfd2 !important; } .dark .xlog-banner { background-color: var(--banner-bg-color, #f9f9f9) !important; } html.dark { --border-color: #eee !important; color-scheme: light !important; } :root { --csb-ck-color-text: 46,46,46 !important; --csb-ck-color-text2: 17,17,17 !important; --csb-ck-color-text-subtle: 153,153,153 !important; --csb-ck-color-bg: 255,255,255 !important; --csb-ck-color-bg1: 247,247,247 !important; } :root { --tw-color-black: 0 0 0 !important; --tw-color-white: 255 255 255 !important; --tw-color-slate-50: 248 250 252 !important; --tw-color-slate-100: 241 245 249 !important; --tw-color-slate-200: 226 232 240 !important; --tw-color-gray-50: 249 250 251 !important; --tw-color-gray-100: 243 244 246 !important; --tw-color-gray-200: 229 231 235 !important; --tw-color-gray-300: 209 213 219 !important; --tw-color-gray-400: 156 163 175 !important; --tw-color-gray-500: 107 114 128 !important; --tw-color-gray-600: 75 85 99 !important; --tw-color-gray-700: 55 65 81 !important; --tw-color-gray-800: 31 41 55 !important; --tw-color-gray-900: 17 24 39 !important; --tw-color-zinc-50: 250 250 250 !important; --tw-color-zinc-100: 244 244 245 !important; --tw-color-zinc-200: 228 228 231 !important; --tw-color-zinc-300: 212 212 216 !important; --tw-color-zinc-400: 161 161 170 !important; --tw-color-zinc-500: 113 113 122 !important; --tw-color-zinc-600: 82 82 91 !important; --tw-color-zinc-700: 63 63 70 !important; --tw-color-zinc-800: 39 39 42 !important; --tw-color-zinc-900: 24 24 27 !important; --tw-color-stone-400: 168 162 158 !important; --tw-color-red-100: 254 226 226 !important; --tw-color-red-200: 254 202 202 !important; --tw-color-red-400: 248 113 113 !important; --tw-color-red-500: 239 68 68 !important; --tw-color-red-600: 220 38 38 !important; --tw-color-red-700: 185 28 28 !important; --tw-color-orange-50: 255 247 237 !important; --tw-color-orange-100: 255 237 213 !important; --tw-color-orange-200: 254 215 170 !important; --tw-color-orange-400: 251 146 60 !important; --tw-color-orange-500: 249 115 22 !important; --tw-color-orange-600: 234 88 12 !important; --tw-color-orange-700: 194 65 12 !important; --tw-color-yellow-200: 254 240 138 !important; --tw-color-yellow-400: 250 204 21 !important; --tw-color-yellow-500: 234 179 8 !important; --tw-color-green-100: 220 252 231 !important; --tw-color-green-200: 187 247 208 !important; --tw-color-green-400: 74 222 128 !important; --tw-color-green-500: 34 197 94 !important; --tw-color-green-600: 22 163 74 !important; --tw-color-green-700: 21 128 61 !important; --tw-color-teal-600: 13 148 136 !important; --tw-color-sky-500: 14 165 233 !important; --tw-color-sky-700: 3 105 161 !important; } footer>div>button[role=switch] { display: none !important; } 关闭他人博客的 Dark Mode 因为 xLog 支持自定义域名,而暗色模式的 Theme Switcher 仅在当前域名上生效,因此无法简单地为所有站点关闭 Dark Mode。只能…… 写一个脚本检测到是 xLog 后强制关闭深色模式。 脚本:https://greasyfork.org/zh-CN/scripts/472216-auto-disable-xlog-dark-mode 可自由取用(以 MIT 协议开源)。

2023/8/2
articleCard.readMore

解决 CentOS8 中 yum 源失效

先吐槽下,为什么 CentOS 会 breaking 正常运行的系统啊 如果之前没有配置过其他镜像的话 1 2 sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-* sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-* 如果配置过的话,将 baseurl 中的域名改成 http://vault.centos.org 就好(注意只支持 HTTP) 这个原因是,因为 CentOS 8 Deprecated 了于是就在 Mirror 中把仓库删了🙃

2023/6/7
articleCard.readMore

[随笔] 避免完美主义导致的看似时间不够

我经常会有想做一件事然后看了下时间可支配时间只有半小时了然后就会想「只有半小时了,等时间充裕的时候再做吧」然后一点点拖延下去 然而突然发现,以时间不够为为理由的拖延不但是完美主义的表现之一,更是一种可笑的借口 时间是最最宝贵的不可再生资源,一天可支配时间有 12 小时已经很了不起了,半小时已经是一天中的 1/24 了、半小时已经可以做很多事了。 而一周多来几次半小时的话,完成的事情可能已经抵得上正常半天甚至一天的任务量了。 更何况,借效率之名实拖延之事、让时间白白流逝,才是更可惜、又可笑的。 action >> thinking

2023/5/23
articleCard.readMore

[随笔] PKM 的通用格式

突然在想,现在的 PKM 都选择用类似 Markdown 的格式存储数据,然后标榜自己「使用通用格式,不会被锁定平台」是不是跑偏了?越来越觉得这种存储更多的只是一种营销术语。 使用 Markdown 意味着你需要向这种格式妥协。因此很多功能难以很完美的实现。例如 Notion / RoamResearch / Tana 这种基于 Block 的工具,很容易可以为 Block 添加元信息 —— 从修改时间到自定义属性,十分容易完成。然而,选择使用 Markdown 就意味着各种元信息存储时要向 Markdown 的可读性妥协,logseq 单单一个页面时间信息两年过去了依然不稳定。 那这些软件真的是真正的 Markdown 吗?并不。所有软件想要实现一定的特色功能都不得不考虑「元数据」的存在 —— 因此,虽然这种软件本身是 Markdown,但都重新发明了 Markdown。 那,在这种魔改的 Markdown 前,用户真的不被平台锁定吗?就以 Obsidian 和 Logseq 为例,二者都是 Markdown,但是想要无缝转移十分的困难。从文件名开始,到文件内容的自定义格式,都需要去了解两方的自定义规则,并且这些往往都是 undocumented 的,必须去深入阅读源码才能写出一个完美的转换(同时,解析 Markdown 特别是魔改过的 Markdown 难度相比于解析结构化数据是大大提升的)。 反而,虽然 Roam Research 是私有格式,但是本质上可以导出一个 json —— 并且 json 格式的含义往往非常「易猜」,很容易就可以写出一个它到其他平台的转换程序,当年我从 Roam 切换到其他笔记软件的流程十分丝滑 —— 笔记软件本身没有提供转换的我也很容易就可以写出一个来。同理,Notion 虽然没有一个全局的导出,但是其提供了完整的 API 可以获取结构化的数据,迁移成本(至少对我而言)要远远小于在两个所谓支持 Markdown 的软件之间进行交换的。 或许对于笔记软件而言,数据库 + 导出才是正道;非得要基于文件系统、非得要基于所谓通用文件格式,反而是一种没必要的限制。

2023/5/18
articleCard.readMore