阅读视图

发现新文章,点击刷新页面。
☑️ ☆

浏览器从 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.devinngestRestate

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 吧!推送代码秒部署,最重要的是,还有国内的服务器节点,真的很好用

☑️ ☆

使用 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。

使用

  1. 使用此模板创建一个仓库
  2. 克隆你创建的仓库
  3. 运行 pnpm install 安装依赖项
  4. 运行 pnpm build 构建脚本

脚本源码放在 src 目录中,编译结果(可被 OmniFocus Scripts 使用的)放在 dist 目录中。

撰写脚本

src 目录内的任何不以 _ 开头的 TypeScript 文件都将被视为 OmniFocus 脚本并编译(_ 开头的脚本文件被保留用于工具函数)。

任何脚本都必须遵循以下模式:

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",
};

其中:

  1. actionmeta 是必需的,action.validate 是可选的
  2. meta 必须是脚本的最后一部分。它之后不可以有任何内容。

构建与使用

运行 pnpm build,构建后的脚本(以 .omnifocusjs 结尾)将被放置在 dist 目录下。

你可以直接将 dist 目录中的脚本拷贝到 OmniFocus 的脚本目录,也可以利用脚本进行同步。

如果你使用 iCloud 保存 OmniFocus 脚本,可以直接使用 pnpm sync 自动将构建好的脚本同步到 iCloud 中的 OmniFocus 脚本目录;如果你不使用 iCloud 而是使用了自定义路径,可修改 sync.sh 文件改变目标路径。

End

此方案我个人已用一年有余,但一方面开源版本可能有些错误,另一方面可能有更多的定制化需求。

欢迎进入 仓库 页面提交 Issue 和 PR!

☑️ ⭐

[备忘] Go init 行为

总结

基础规则:

  1. 所有的 init 函数都在一个 Goroutine 中执行(但请参见下面的特殊注意)
  2. 如果 package a 引用了 package b,那么 a 的 init 一定在 b 的 init 运行完成后运行
  3. main package 的 main 函数一定在其他 init 函数均运行完成后再运行(即运行顺序为 package 的 init -> main 的 init -> main 的 main)
  4. 同一 package 中的多个文件中的 init 执行顺序未定义,同一文件中的 init 自上而下运行
  5. 如果 package a 同时引用了 package b 和 c,那么 b 与 c 的 init 顺序在 Go1.21 及之后定义

在 Go1.20 及之前:

  1. 如果 package a 引用了 package b,那么 b 的 init 一定在 a 之前运行
  2. 但是,如果 package a 同时引用了 package b 和 c,只要 b c 之间没有引用关系,b c 的执行顺序是不定的

在 Go1.21 及之后:

  1. 对于无引用关系的包(即 Go1.20 及之前的中的第 2 点),按照其包名字母序决定引用顺序(例如 a 一定在 b 之前执行,github.com/xxx/xxx 一定在 gitlab.com/xxx/xxx 之前执行)

特殊注意:

  1. 如果 init 存在阻塞,那么用于运行 init 的 goroutine 可能创建新的 goroutine,这会导致某些 init 代码并发运行
  2. 存在阻塞的情况下,不会保证无引用关系的 package 的 init 完成先后顺序(参考示例 c)
  3. 存在阻塞的情况下,如果 package a 依赖了 package b,那么 a 的 init 一定在 b 的 init 运行完成后开始运行(参考示例 d)

示例项目

参考

The Go Memory Model
Go 1.21 Release Notes

☑️ ☆

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

前言

在构建 Linux/Unix 系安装包时,除了打包成标准的适用于各种发行版的软件包以外,我们更多的可能希望可以提供一个 shell 脚本进行程序的安装,将安装步骤简单收敛为两步:下载脚本 + 运行脚本。

通常,这种大多数的安装脚本都是再次从互联网上下载所需资源的,这样可以最小化脚本的体积并保证安装的始终是最新版本,但是这同样导致了下载到的「安装包」本质上是个「安装器」,无法离线安装。

本文将介绍一种已经在生产环境验证过的方案,来动态在安装包中嵌入网址。

受限于一些原因,本文更多的从原理层面进行讲解,暂无法提供完整的代码解决方案,敬请谅解

另外,以下代码均为根据原理为本文撰写,虽然原理已经经过生产验证但所使用的代码并未经过严格的生产验证,如有 bug 烦请告知

脚本构成

整个脚本由 head + embed-bin 两部份构成;embed-bin 是不加改动的将我们的程序进行嵌入的,而 head 是一个动态生成的脚本,用于从当前脚本中提取 embed-bin 并执行。

head 脚本随是动态生成,但为了维护的简单,这里采用模板的形式

#!/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 的内容不止压缩包甚至不止一个文件,另一方面就是为了给出更好的错误提示)。

这个脚本也提供了参数传递的能力和部分默认值的指定,这是因为在某些情况下相关步骤可能异常而全量执行所有步骤较为耗时,在实际使用中你可根据实际需要删改脚本参数。

脚本的参数由模板渲染引擎给出,这主要是为了可维护性,如果你更希望在脚本中撰写相关的内容则可以修改相关部分

渲染脚本

话不多说,直接上代码

//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
}

使用则是

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 等手段来定位二进制内容更加的高效、易维护。

在此基础上,其实还可以实现更多的事情(依赖验证、安装多个文件等),欢迎尝试

☑️ ⭐

谈谈时区

通常在本地化时往往会涉及到时区转换的问题,而通常在真正关注到时区之前我们所「默认」使用的时区为 UTC 或 “本地”。

本文以 Go 为例,分析下 Go 中的时区使用。

读取时区

在 Go 中,读取时区使用的是 LoadLocation 函数。

// 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 可以读取相关文件并解析。

  1. 如果存在 ZONEINFO 环境变量,利用该变量指向的目录 / 压缩文件进行读取
  2. 在 Unix 系统上,使用系统标准位置
  3. (主要用于编译 Go 时)从 $GOROOT/lib/time/zoneinfo.zip 进行读取
  4. (如果 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 环境中,是没有时区定义文件的,因此我们需要特别关注进行处理

  1. 可以在程序中使用 import _ "time/tzdata" 在编译期将时区文件编入程序中,这样在无法找到系统中的时区定义时也可以查找到标准的 IANA 时区定义
  2. 如果我们不需要特别动态的时区,我们可以避免使用 LoadLocation 而是使用 FixedZone 由我们自己提供时区名称和偏移,例如对于中国 UTF+8 可以使用 time.FixedZone("Asia/Shanghai", 8*60*60)

本地时区

通常在我们真正考虑到时区问题之前我们所「默认」使用的时区均为所谓的「本地时区」。

time.Now 为例,

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 就是本地时区,即运行这个程序所在的机器的时区。

// 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 又是怎么初始化的呢?

// 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 内容以 : 开头,则会忽略该冒号

  1. 如果没有指定 TZ 环境变量,阅读 /etc/localtime(通常就是指向了真正时区文件的软链接)
  2. 如果指定的 TZ 环境变量为绝对路径,阅读该文件
  3. 否则按照上文所分析的 LoadLocation 流程进行时区文件的读取

另外,上述 3 步骤如果失败,会 fallback 到使用 UTC 时间

加餐:tzdata

tzdata 详细定义了历史时区的变更情况,包括夏令时、闰秒等,因此 Asia/Shanghai 相比于简单的 GMT+8 更具有通用性、且可正确处理历史数据。

如果你感兴趣,可以利用 zdump Asia/Shanghai -i 查看上海的时区变化,并和使用夏令时的时间 zdump America/Chicago -i 进行对比。

☑️ ⭐

利用 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=trueauto_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 即可。

☑️ ☆

强制关闭 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 博客中执行来获取他的主题色

['--auto-hover-color', '--auto-theme-color', '--auto-banner-bg-color'].forEach(name => console.log(`${name}: ${getComputedStyle(document.documentElement).getPropertyValue(name)} !important;`))

使用获取到的值来替换下面的第一个 .dark 中的变量

.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 协议开源)。

☑️ ⭐

你真的了解 time.Now() 吗?

本文基于 Go1.20.4 源码进行分析,更高或更低版本可能有所差异

概览:time.Time

话不多说,先上源码

// 为了减少文章长度突出重点,注释部分有所删改

// A Time represents an instant in time with nanosecond precision.
// 
// The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
//
// In addition to the required “wall clock” reading, a Time may contain an optional
// reading of the current process's monotonic clock, to provide additional precision
// for comparison or subtraction.
type Time struct {
	// wall and ext encode the wall time seconds, wall time nanoseconds,
	// and optional monotonic clock reading in nanoseconds.
	//
	// From high to low bit position, wall encodes a 1-bit flag (hasMonotonic),
	// a 33-bit seconds field, and a 30-bit wall time nanoseconds field.
	// The nanoseconds field is in the range [0, 999999999].
	// If the hasMonotonic bit is 0, then the 33-bit field must be zero
	// and the full signed 64-bit wall seconds since Jan 1 year 1 is stored in ext.
	// If the hasMonotonic bit is 1, then the 33-bit field holds a 33-bit
	// unsigned wall seconds since Jan 1 year 1885, and ext holds a
	// signed 64-bit monotonic clock reading, nanoseconds since process start.
	wall uint64
	ext  int64

	// 本文重点不在时区,或另写一篇文章讨论相关话题
	loc *Location
}

当讨论「时间」这一概念时,或者更精确的说,是时点(instant),我们通常不会有什么疑惑。但与我们生活中时间点是唯一的不同,在现代计算机中,实际上存在着两种时钟:日历时钟(time-of-day clock /wall clock)单调钟(monotonic clock)

我们通常所看到的时间(包括时间戳、年月日时分秒的展示等)一般都是日历时钟,但是,当我们想要去计算时间间隔时,日历时钟之差可能是负数 —— 在两次计算之间,是可以「调时间」的。因此,我们需要一种方法来稳定的获取两个时间点之间的经过时间(elapsed time),这也就是单调钟的来历。单调钟的名字便来源于其单调递增的特性,它通常不是真实的时间值,且考虑单调钟的值是无意义的其唯一的目的便是用于稳定计算时间差。

进一步的讨论日历时钟和单调钟超过了本文的范围,我推荐你阅读《数据密集型应用系统设计》第八章中不可靠的时钟部分。但我们回过头来看开篇 Time 的源码 —— 其同时包括了日历时钟和单调钟。

wall 的 64 位被分成了 1+33+30 三个部分,其中第一个部分(最高位)名称为 hasMonotonic,它用来决定日历钟和单调钟怎么存储

  • 当 hasMonotonic = 1
    • wall 的第二部分(33 位)存储了自 1885.1.1 起的秒数(无符号)
    • wall 的第三部分(30 位)存储了纳秒部分(范围 [0, 10^9-1])
    • ext 存储了自进程启动起的纳秒数(有符号)
  • 当 hasMonotonic = 0
    • wall 的第二部分(33 位)为全 0
    • wall 的第三部分(30 位)存储了纳秒部分(范围 [0, 10^9-1])
    • ext 存储了自 1 年 1 月 1 日起的秒数(有符号)

在这里,我们进行一些极限分析

  1. 纳秒部分,十进制的最大值 10^9-1 对应的二进制为 30 位,保证了 wall 的第三部分不会越界
  2. wall 的第二部分(33 位)对应秒,最大值为 8589934591 秒,约 272.4 年,自 1885.1.1 起可用到 2157 年
  3. 64 位有符号纳秒的最大值约 292.47 年(应该不至于有程序一次性运行那么久吧)
    • 实际上,根据 Golang 内部实现,最大界限受限于系统返回的 monotonic,对 Linux 而言是整个系统 uptime 最大达 292.47 年
  4. 64 位有符号自 1.1.1 起的秒数最大值达 2924.7 亿年,我们有生之年是见不到溢出了

获取时间

time.Now()

// 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}
}

// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)

在对 time.Time 有了足够的了解以后,我们就很容易能读懂这段代码设计的逻辑了 —— 尽可能保留 monotonic

now 方法是用来返回当前的时间的,具体实现和系统有关,其三个返回值分别是

  • sec - Unix 时间戳(自 1970.1.1 起的秒数)
  • nsec - 纳秒偏移量 [0, 10^9-1]
  • mono - 系统级单调钟的值

首先是两个调整

  • mono -= startNano

    • 前面说了,存储在 Time 中的单调钟的值并非系统返回的,而是自进程启动起的纳秒数,因此这里进行了一次减法操作(与系统启动时的系统单调钟的值做减法)
    // Monotonic times are reported as offsets from startNano.
    // We initialize startNano to runtimeNano() - 1 so that on systems where
    // monotonic time resolution is fairly low (e.g. Windows 2008
    // which appears to have a default resolution of 15ms),
    // we avoid ever reporting a monotonic time of 0.
    // (Callers may want to use 0 as "time not set".)
    var startNano int64 = runtimeNano() - 1
    
  • sec += unixToInternal - minWall

    • 在 hasMonotonic=1 的情况下,Time 中存储的是自 1885.1.1 的秒数,而系统返回的是自 1970.1.1 的秒数,因此这里要将 sec 减去这 85 年的差
    const (
    	unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
    )
    
    const (
    	wallToInternal int64 = (1884*365 + 1884/4 - 1884/100 + 1884/400) * secondsPerDay
    	minWall      = wallToInternal               // year 1885
    )
    

后面的判断逻辑就很简单了:如果可能就采用 hasMonotonic=1 进行存储,否则(当时间不满足 1885-2157 年的区间中)则采用 hasMonotonic=0 存储。其中后面的判断分支…… 能看到这篇文章的人估计都遇不到了。

time.Unix()

// Unix returns the local Time corresponding to the given Unix time,
// sec seconds and nsec nanoseconds since January 1, 1970 UTC.
// It is valid to pass nsec outside the range [0, 999999999].
// Not all sec values have a corresponding time value. One such
// value is 1<<63-1 (the largest int64 value).
func Unix(sec int64, nsec int64) Time {
	if nsec < 0 || nsec >= 1e9 {
		n := nsec / 1e9
		sec += n
		nsec -= n * 1e9
		if nsec < 0 {
			nsec += 1e9
			sec--
		}
	}
	return unixTime(sec, int32(nsec))
}

func unixTime(sec int64, nsec int32) Time {
	return Time{uint64(nsec), sec + unixToInternal, Local}
}

const (
	unixToInternal int64 = (1969*365 + 1969/4 - 1969/100 + 1969/400) * secondsPerDay
)

Unix 主要做的一件事情就是利用 Unix 时间戳(epoch)来生成 hasMonotonic=0 的时间

unixTime 调用前的 if 逻辑是为了简化业务代码的编写使用的 —— 如果 nsec 部分不在 [0, 10^9-1] 的限制区间内,则替我们修正它。

哦对,除了最常用的秒级时间戳,我们还可能遇到毫秒级时间戳和微秒级时间戳,Go 也为我们提供了简化的调用:

// UnixMilli returns the local Time corresponding to the given Unix time,
// msec milliseconds since January 1, 1970 UTC.
func UnixMilli(msec int64) Time {
	return Unix(msec/1e3, (msec%1e3)*1e6)
}

// UnixMicro returns the local Time corresponding to the given Unix time,
// usec microseconds since January 1, 1970 UTC.
func UnixMicro(usec int64) Time {
	return Unix(usec/1e6, (usec%1e6)*1e3)
}

嗯…… 如果你要是问我纳秒级时间戳呢…… 直接调 time.Unix(0, nanoseconds) 就好了呀😂

time.Date()

// 为了减少文章长度突出重点,代码和注释部分有所删改

// Date returns the Time corresponding to
//
//	yyyy-mm-dd hh:mm:ss + nsec nanoseconds
//
// The month, day, hour, min, sec, and nsec values may be outside
// their usual ranges and will be normalized during the conversion.
// For example, October 32 converts to November 1.
func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time {
	...

	// Normalize overflow
	...

	// Compute days since the absolute epoch.
	d := daysSinceEpoch(year)

	// Add in days before this month.
	d += uint64(daysBefore[month-1])
	if isLeap(year) && month >= March {
		d++ // February 29
	}

	// Add in days before today.
	d += uint64(day - 1)

	// Add in time elapsed today.
	abs := d * secondsPerDay
	abs += uint64(hour*secondsPerHour + min*secondsPerMinute + sec)

	unix := int64(abs) + (absoluteToInternal + internalToUnix)

	// Look for zone offset for expected time, so we can adjust to UTC.
	...

	t := unixTime(unix, int32(nsec))
	t.setLoc(loc)
	return t
}

大部分的逻辑都被我直接省略掉了,简单来说,Date 做了以下几件事情

  1. 处理「溢出」:我们可以提供如 10 月 32 日这种日期,Golang 会帮我们进行正确的修正(为 11 月 1 日)—— 你可以看看 AddDate 的源码,其就是简单的直接操作了相关值
  2. 处理闰年
  3. 处理时区
  4. 利用 Unix 时间戳来生成 hasMonotonic=0 的时间

time.Parse()

// 为了减少文章长度突出重点,代码和注释部分有所删改

func Parse(layout, value string) (Time, error) {
	return parse(layout, value, UTC, Local)
}

func parse(layout, value string, defaultLocation, local *Location) (Time, error) {
	...
	return Date(year, Month(month), day, hour, min, sec, nsec, defaultLocation), nil
}

这个删的更彻底了,其本质就是解析完格式后进行了 Date 调用,所以其生成的也是 hasMonotonic=0 的时间

小结

可以发现,我们最常用的四种构造时间的方式(Now、Date、Unix、Parse)中,只有 Now 存储了单调钟的信息(hasMonotonic=1)

这种存储,最重要的优势就是我们可以利用 time.Now() - time.Now() 来计算两次执行中的经过时间而不需要考虑出现时光倒流的

时间差

时间的减法

其实一切难点在我们了解了 time.Time 的结构体之后都解决了,设计好结构体后,让你自己去写 Sub 你也会这么写。

话不多说,让我们直接来看 Sub 的代码

// Sub returns the duration t-u. If the result exceeds the maximum (or minimum)
// value that can be stored in a Duration, the maximum (or minimum) duration
// will be returned.
// To compute t-d for a duration d, use t.Add(-d).
func (t Time) Sub(u Time) Duration {
	if t.wall&u.wall&hasMonotonic != 0 {
		te := t.ext
		ue := u.ext
		d := Duration(te - ue)
		if d < 0 && te > ue {
			return maxDuration // t - u is positive out of range
		}
		if d > 0 && te < ue {
			return minDuration // t - u is negative out of range
		}
		return d
	}
	d := Duration(t.sec()-u.sec())*Second + Duration(t.nsec()-u.nsec())
	// Check for overflow or underflow.
	switch {
	case u.Add(d).Equal(t):
		return d // d is correct
	case t.Before(u):
		return minDuration // t - u is negative out of range
	default:
		return maxDuration // t - u is positive out of range
	}
}

核心逻辑想必和你想的一样:如果两个时间都是 hasMonotonic=1 的,就计算两个时间的单调钟差值即可 —— 两个 ext 的减法;否则,计算日历时钟的差值。

还记得我们之前的极限分析吗?在极端情况下,无论走到了 hasMonotonic 的哪个分支,都会有一个溢出的可能性 —— 两个时间间隔太大了,以至于超出了我们 int64 纳秒最大可容纳的 292.47 年。

我推荐你好好阅读下上面的代码,特别是思考下为什么两种情况检测溢出的方法不一样

比较

零值 —— IsZero

当我们直接初始化一个 Time 时,其是 0 值

var t time.Time

Time 的初始化没有魔法,和 Go 中其他结构体的初始化 0 值相同 —— 其所有字段都被赋予了 0。那么,根据规则,其 hasMonotonic=0,因此使用 ext 存储秒、wall 的第三部分存储纳秒,这俩也是 0,所以,0 值的时间就是 January 1, year 1, 00:00:00.000000000 UTC

因为这个时间在实际情况下是不常见的,所以零值通常被认为是「未初始化的时间」。Go 提供了 IsZero 方法来检测

// 为了减少文章长度突出重点,增加了部分注释

// IsZero reports whether t represents the zero time instant,
// January 1, year 1, 00:00:00 UTC.
func (t Time) IsZero() bool {
	return t.sec() == 0 && t.nsec() == 0
}

// sec returns the time's seconds since Jan 1 year 1.
func (t *Time) sec() int64 {
	if t.wall&hasMonotonic != 0 { // 0 值时不会走到这个分支
		return wallToInternal + int64(t.wall<<1>>(nsecShift+1))
	}
	return t.ext
}

// nsec returns the time's nanoseconds.
// 就是简单的返回 wall 的第三部分(30 位纳秒时间偏移)
func (t *Time) nsec() int32 {
	return int32(t.wall & nsecMask)
}

const nsecMask = 1<<30 - 1

它的实现看上去稍微复杂了点,但实际上,考虑到 hasMonotonic=1 时值不可能为 0,因此只要这个 Time 不是我们利用指针进行了一些强行破坏,其 wall 的第一第三部分为 0 + ext 为 0,就是整体为 0。

相等 —— Equal

首先明确一点:如果直接利用结构体进行比较,那么结构体相等一定时间相等、结构体不等却并不一定时间不等。因此,在 Go 种进行时间比较时,我们应尽量避免利用 == 来比较,而是使用 Equal 方法。

造成结构体不等而时间相等的原因包括:可能有两种表示方法(hasMonotonic=0/1)对应着相同的值、可能有两个时区对应着相同的值。

本文重点不在时区,时区不会影响 Time 中的 wall 和 ext(其都是用 UTC 值存储的),只会影响其中的 loc 字段

// Equal reports whether t and u represent the same time instant.
// Two times can be equal even if they are in different locations.
// For example, 6:00 +0200 and 4:00 UTC are Equal.
// See the documentation on the Time type for the pitfalls of using == with
// Time values; most code should use Equal instead.
func (t Time) Equal(u Time) bool {
	if t.wall&u.wall&hasMonotonic != 0 {
		return t.ext == u.ext
	}
	return t.sec() == u.sec() && t.nsec() == u.nsec()
}

可以发现,Go 中 Equal 的实现也是本着「单调钟优先的原则」。

加餐

上面其实已经把本文的核心 —— time.Time 都说明白了(嗯,除了时区),这一节我想写点使用的时候用不到但是追求技术的话可以了解的

ᕦ(・́~・̀) 加餐内容量其实比正文还多

time.Now 到底如何实现

上面已经说过了 time.Now 的实现,但是有没有发现……

// Provided by package runtime.
func now() (sec int64, nsec int32, mono int64)

这个 now 直接被省略了😂只留下了一行由 runtime 实现。

是的,它确实是由 runtime 实现的,真实的实现在 runtime 中的 time_now 函数,并且更进一步的,在 Linux Amd64 和其他系统的实现还不太一样

//go:build !faketime && (windows || (linux && amd64))

//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64)
//go:build !faketime && !windows && !(linux && amd64)

//go:linkname time_now time.now
func time_now() (sec int64, nsec int32, mono int64) {
	sec, nsec = walltime()
	return sec, nsec, nanotime()
}

在 Linux Amd64 上,整个 time_now 都是用汇编实现的,下面的代码是我加了注释的版本,你可从此链接阅读原始版本

// 为了减少文章长度突出重点,代码和注释部分有所删改

// func time.now() (sec int64, nsec int32, mono int64)
// $16-24 表示函数需要 16 字节的栈空间和 24 字节的返回值空间
TEXT time·now<ABIInternal>(SB),NOSPLIT,$16-24
	// 准备 vDSO
    ...

	// (如果不是 g0)切换到 g0
	...

noswitch: // 获取时间的逻辑(优先利用 vDSO)
	SUBQ	$32, SP		// Space for two time results
	ANDQ	$~15, SP	// Align for C code

	MOVL	$0, DI // CLOCK_REALTIME(获取日历时钟)
	LEAQ	16(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CMPQ	AX, $0
	JEQ	fallback // 失败跳转
	CALL	AX

	MOVL	$1, DI // CLOCK_MONOTONIC(获取单调钟)
	LEAQ	0(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CALL	AX

ret: // 获取成功,结果存在了栈中
	MOVQ	16(SP), AX	// realtime sec
	MOVQ	24(SP), DI	// realtime nsec (moved to BX below)
	MOVQ	0(SP), CX	// monotonic sec
	IMULQ	$1000000000, CX
	MOVQ	8(SP), DX	// monotonic nsec

	// 恢复现场
    ...

	// 返回结果
	// set result registers; AX is already correct
	MOVQ	DI, BX
	ADDQ	DX, CX // 计算出 monotonic nanoseconds
	RET

fallback: // CLOCK_REALTIME 获取失败时,证明 vdso 失败,利用系统调用获取
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	MOVL	$1, DI // CLOCK_MONOTONIC
	LEAQ	0(SP), SI
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	JMP	ret

简单来说,在 Linux Amd64 下,Go 会尽可能利用 vDSO 获取时间信息(包括日历时钟和单调钟的时间),如果出现错误才会 fallback 到系统调用。vDSO 的主要目的是为了降低系统调用的时间,具体你可阅读 Linux 手册获得更多信息。在 Go 中,实际上只有和时间有关的系统调用才用到了 vDSO:vdso_linux_amd64.go

而非 Linux Amd64 下,使用了 walltime()nanotime() 来分别获取日历时钟和单调钟。

以下代码以 Linux Arm64 为例

func walltime() (sec int64, nsec int32)

//go:nosplit
func nanotime() int64 {
	return nanotime1()
}

func nanotime1() int64

看这个没有 body 的样子就知道肯定是要走汇编了😂读懂了上面 Linux Amd64 的其实 Arm64 的大差不差(都是 vDSO + syscall fallback),只不过是没有将 walltime 和 nanotime 在一个函数中获取罢了,你可点此阅读相关源码。

如何获取系统单调钟?

time 包下没有提供获取系统单调钟的手段,但 runtime 中的 nanotime 其实就是系统单调钟。你可以将以下内容包装成一个帮助函数来使用

import (
	_ "unsafe"
)

//go:noescape
//go:linkname Monotonic runtime.nanotime
func Monotonic() int64

或是…… 使用我的 extime.Monotonic 👀

Round —— 四舍五入 + 去除单调钟信息

Round 是用来四舍五入的

// Round returns the result of rounding t to the nearest multiple of d (since the zero time).
// The rounding behavior for halfway values is to round up.
// If d <= 0, Round returns t stripped of any monotonic clock reading but otherwise unchanged.
//
// Round operates on the time as an absolute duration since the
// zero time; it does not operate on the presentation form of the
// time. Thus, Round(Hour) may return a time with a non-zero
// minute, depending on the time's Location.
func (t Time) Round(d Duration) Time {
	t.stripMono()
	if d <= 0 {
		return t
	}
	_, r := div(t, d)
	if lessThanHalf(r, d) {
		return t.Add(-r)
	}
	return t.Add(d - r)
}

// stripMono strips the monotonic clock reading in t.
func (t *Time) stripMono() {
	if t.wall&hasMonotonic != 0 {
		t.ext = t.sec()
		t.wall &= nsecMask
	}
}

你可以利用 Duration 来提供精度(例如 t.Round(time.Second))。

另外,它还有一个「副作用」,就是会将我们前面说过的 Time 中的单调钟的信息删除(第一行的 stripMono)。因此,除了四舍五入,如果你想让他删除单调钟的内容,可以使用 t.Round(0)

闰秒

如果你不知道闰秒,请先移步维基百科。闰秒是一个一直存在、造成了无数事故、并且即将取消的东东。因为前辈踩了坑我们可能已经不会再那么关注它,但是,你至少应该知道它。

闰秒在一定程度上超出了本文的讨论范畴 —— 因为 Go 不考虑闰秒。在 Go 中,你不会得到「23:59:60」这种时间,而计算时间差时因为现在已经引入了单调钟所以也不会再出事故。

等下,再?敬请移步 2017 年 Cloudflare 的事故,当时 Go 的 time.Now 还没有使用单调钟。

参考

go/time.go at go1.20.4 · golang/go

https://github.com/golang/go/issues/12914

☑️ ☆

同时安装多版本 pnpm & 发布自定义 homebrew 包

如果你只是想找一个利用 Homebrew 安装 pnpm v7 的方案,执行 brew install ImSingee/pnpm/pnpm@7 即可

pnpm v8 在一个月前发布了,作为大版本更新之一,它引入了 lock file V6,同时停止支持了 V5。然而在给一些使用老版本的项目提 pr 的情况下,如果需要引入新的依赖就势必需要更新 lock file —— 这是不可被接受的,不能直接期望所有协作者都升级其 pnpm。

这引入了一个 pnpm V7 和 V8 共存的问题。这本不是难事,有着 Corepack 或者 pnvm 等工具。然而其对我而言都太重了 —— 一个 Homebrew 似乎就够了。

Homebrew 一个问题是,不支持安装旧版本,曾经引入过的 homebrew/versions 也早已被弃用,官方唯一建议的方案是自行托管 —— 当然,目前 pnpm 是没有维护官方旧版本的 tap 的,因此,只能自己动手喽。

创建 tap

根据官方指引,执行下面的命令即可创建一个空的 tap 库

brew tap-new ImSingee/homebrew-pnpm

注:这里 tap-new 后面的参数格式必须为 <repo>/homebrew-<name> ,这样可以通过 brew tap <repo>/<name> 来启用这个 tag,后续可以通过 brew install <repo>/<name>/<formula> 来直接安装相关包【另外实际上,如果 repo 名称不以 homebrew- 开头,这个命令会自动帮你加上这个前缀的】

然后会打印出类似下面的信息

Initialized empty Git repository in /opt/homebrew/Library/Taps/imsingee/homebrew-pnpm/.git/
[main (root-commit) 1b89b92] Create imsingee/pnpm tap
 3 files changed, 90 insertions(+)
 create mode 100644 .github/workflows/publish.yml
 create mode 100644 .github/workflows/tests.yml
 create mode 100644 README.md
==> Created imsingee/pnpm
/opt/homebrew/Library/Taps/imsingee/homebrew-pnpm

When a pull request making changes to a formula (or formulae) becomes green
(all checks passed), then you can publish the built bottles.
To do so, label your PR as `pr-pull` and the workflow will be triggered.

大体意思就是,帮你在 /opt/homebrew/Library/Taps/imsingee/homebrew-pnpm 下创建了一个项目,并配置好了 GitHub Action 帮你测试、配置 bottle

增加一个 formula

我们新增一个 pnpm@7 的 Formula,这里是官方的 pnpm.rb

class Pnpm < Formula
  require "language/node"

  desc "📦🚀 Fast, disk space efficient package manager"
  homepage "https://pnpm.io/"
  url "https://registry.npmjs.org/pnpm/-/pnpm-8.3.1.tgz"
  sha256 "ce038ba2617f7a93d0b1f24b733b9d64258b15c97a14c6f37673c8d49e033d9a"
  license "MIT"

  livecheck do
    url "https://registry.npmjs.org/pnpm/latest"
    regex(/["']version["']:\s*?["']([^"']+)["']/i)
  end

  bottle do
    sha256 cellar: :any_skip_relocation, arm64_ventura:  "78ecd13f60c3baf6913933c8494ca17fc4e5b9f93c46bbc131312ffe41fe7f88"
    sha256 cellar: :any_skip_relocation, arm64_monterey: "78ecd13f60c3baf6913933c8494ca17fc4e5b9f93c46bbc131312ffe41fe7f88"
    sha256 cellar: :any_skip_relocation, arm64_big_sur:  "78ecd13f60c3baf6913933c8494ca17fc4e5b9f93c46bbc131312ffe41fe7f88"
    sha256 cellar: :any_skip_relocation, ventura:        "2f4f18876a3e2823f86f5500b7c47c173695e7f21eba007c2b7689dd12301145"
    sha256 cellar: :any_skip_relocation, monterey:       "2f4f18876a3e2823f86f5500b7c47c173695e7f21eba007c2b7689dd12301145"
    sha256 cellar: :any_skip_relocation, big_sur:        "4be656f6ff04e145810fb6e19f08fb01030798cec610c9d618b1fb01121d9f64"
    sha256 cellar: :any_skip_relocation, x86_64_linux:   "78ecd13f60c3baf6913933c8494ca17fc4e5b9f93c46bbc131312ffe41fe7f88"
  end

  depends_on "node" => :test

  conflicts_with "corepack", because: "both installs `pnpm` and `pnpx` binaries"

  def install
    libexec.install buildpath.glob("*")
    bin.install_symlink "#{libexec}/bin/pnpm.cjs" => "pnpm"
    bin.install_symlink "#{libexec}/bin/pnpx.cjs" => "pnpx"
  end

  def caveats
    <<~EOS
      pnpm requires a Node installation to function. You can install one with:
        brew install node
    EOS
  end

  test do
    system "#{bin}/pnpm", "init"
    assert_predicate testpath/"package.json", :exist?, "package.json must exist"
  end
end

我们要进行几个小的修改

  • 文件名官方为 pnpm.rb,我们这里要改名为 pnpm@7.rb 以示区分
  • class 名称 Pnpm 我们需要修改成 PnpmAT7
  • url 这里我们要修改成我们所需的版本,目前 7 最新的版本是 7.32.2
  • sha256 我们这里需要修改成对应版本的 hash,目前 7.32.2 对应的是 f4b40caa0c6368da2f50b8ef891f225c24f14e7d60e42a703c84d3a9db8efede
  • livecheck 节下的 url 我们修改成 https://registry.npmjs.org/pnpm、regex 为 /["']latest-7["']:\s*?"'["']/i
  • 移除 bottle

最终修改好的版本是

class PnpmAT7 < Formula
    require "language/node"
  
    desc "📦🚀 Fast, disk space efficient package manager"
    homepage "https://pnpm.io/"
    url "https://registry.npmjs.org/pnpm/-/pnpm-7.32.2.tgz"
    sha256 "f4b40caa0c6368da2f50b8ef891f225c24f14e7d60e42a703c84d3a9db8efede"
    license "MIT"
  
    livecheck do
      url "https://registry.npmjs.org/pnpm"
      regex(/["']latest-7["']:\s*?["']([^"']+)["']/i)
    end
  
    depends_on "node" => :test
  
    conflicts_with "corepack", because: "both installs `pnpm` and `pnpx` binaries"
  
    def install
      libexec.install buildpath.glob("*")
      bin.install_symlink "#{libexec}/bin/pnpm.cjs" => "pnpm"
      bin.install_symlink "#{libexec}/bin/pnpx.cjs" => "pnpx"
    end
  
    def caveats
      <<~EOS
        pnpm requires a Node installation to function. You can install one with:
          brew install node
      EOS
    end
  
    test do
      system "#{bin}/pnpm", "init"
      assert_predicate testpath/"package.json", :exist?, "package.json must exist"
    end
  end

修改点的一些小解释

命名(文件名、也是 formula 名字)方面,首先必须要和官方的 pnpm 做出区分,而选择 pnpm@{VERSION} 这种格式则是和官方的其他多版本包格式保持一致(例如 postgresql、llvm 等均采用这种命名)。class 的名称和文件名要保持对应,pnpm@7 所对应的是 PnpmAT7

另外,我们的 pnpm@7.rb 文件可以选择存储在前面 tap-new 命令帮我们创建好的 Formula 目录下,也可以直接放在根目录下(还可以放在 HomebrewFormula 下)

url 和 sha256 是对应的包下载地址和对应包的下载文件哈希,我们可以利用 NPM Registry API 来获取最新的版本,并下载该版本对应的包来获取哈希

# 获取当前的 V7 最新版本
curl -s https://registry.npmjs.org/pnpm | jq '."dist-tags"."latest-7"'

# 获取这一版本对应的哈希
curl -s https://registry.npmjs.org/pnpm/-/pnpm-7.32.2.tgz | sha256sum

livecheck 是用来检测当前是否是最新版本的,pnpm 官方的 formula 是取的 latest,我们这里修改成取 latest-7

移除 bottle,因为这是自动化构建的内容,新的 formula 不应该包括

发布

在发布前,我们需要先执行下 brew style --fix pnpm@7.rb 来确认下 style 符合规范。

我们需要利用 Github Action 来触发测试和 bottle 的构建。在提交代码后不要直接 push 到主分支,而是提交一个 PR ,在通过所有测试后给其添加 pr-pull 标签。

然后任何人就都可以直接安装我们的 pnpm@7 啦!

# case 1: enable tap
brew tap ImSingee/pnpm
brew install pnpm@7

# case 2: simple install single
brew install ImSingee/pnpm/pnpm@7 

发布后的仓库在 https://github.com/ImSingee/homebrew-pnpm,欢迎使用和 star :-)

安装后的一个小 tips

命名使用 <name>@{VERSION} 的一个好处在于,其不会污染我们目前在使用的环境,这可以在安装后有一个提示看出

pnpm@7 is keg-only, which means it was not symlinked into /opt/homebrew,
because this is an alternate version of another formula.

If you need to have pnpm@7 first in your PATH, run:
  echo 'export PATH="/opt/homebrew/opt/pnpm@7/bin:$PATH"' >> ~/.zshrc

与一般的 formula buts,这种会被检测到是另一个程序的不同版本,因此不会安装到系统的 PATH 下,而是独立的放在额外的路径。

根据提示,如果我们想要执行 7 版本的 pnpm,则需要使用全路径 /opt/homebrew/opt/pnpm@7/bin/pnpm ,如果想要让这个作为主版本的话,可以将 /opt/homebrew/opt/pnpm@7/bin 加入至 PATH 中。

当然,我的做法是做一个 symlink

ln -s /opt/homebrew/opt/pnpm@7/bin/pnpm /usr/local/bin/pnpm7
ln -s /opt/homebrew/opt/pnpm@7/bin/pnpx /usr/local/bin/pnpx7

参考

https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap

https://stackoverflow.com/questions/3987683/homebrew-install-specific-version-of-formula

☑️ ⭐

Go 中 error 序列化的坑

引言

请猜测:下面的输出是什么?

package main

import (
 "encoding/json"
 "fmt"
)

func main() {
 jsonStr, _ := json.Marshal(fmt.Errorf("This is an error"))
 fmt.Println(string(jsonStr))
}
答案 出乎意料——输出并非 "This is an error",而是一个 {} !

这个问题实际上早有讨论,然而并没有得到官方的答复(或许是因为需要序列化 error 的地方太少了?)

根源

Go 中 error 不过是一个接口,一个没有任何特殊点的接口。

而 Go 中 fmt.Errorf 所返回的 error 类型定义为:

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

// 或者

type wrapError struct {
	msg string
	err error
}

在 JSON 序列化时,遵循标准的 struct 序列化规则:保留所有的大写字母开头的字段而省略其余字段,并不会去调用底层的 Error 方法来获取错误的信息。因此,最终结果就是简单的 {}

解决

阅读 json 序列化相关的源码

// newTypeEncoder constructs an encoderFunc for a type.
// The returned encoder only checks CanAddr when allowAddr is true.
func newTypeEncoder(t reflect.Type, allowAddr bool) encoderFunc {
	// If we have a non-pointer value whose type implements
	// Marshaler with a value receiver, then we're better off taking
	// the address of the value - otherwise we end up with an
	// allocation as we cast the value to an interface.
	if t.Kind() != reflect.Pointer && allowAddr && reflect.PointerTo(t).Implements(marshalerType) {
		return newCondAddrEncoder(addrMarshalerEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(marshalerType) {
		return marshalerEncoder
	}
	if t.Kind() != reflect.Pointer && allowAddr && reflect.PointerTo(t).Implements(textMarshalerType) {
		return newCondAddrEncoder(addrTextMarshalerEncoder, newTypeEncoder(t, false))
	}
	if t.Implements(textMarshalerType) {
		return textMarshalerEncoder
	}

	switch t.Kind() {
	case reflect.Bool:
		return boolEncoder
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
		return intEncoder
	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
		return uintEncoder
	case reflect.Float32:
		return float32Encoder
	case reflect.Float64:
		return float64Encoder
	case reflect.String:
		return stringEncoder
	case reflect.Interface:
		return interfaceEncoder
	case reflect.Struct:
		return newStructEncoder(t)
	case reflect.Map:
		return newMapEncoder(t)
	case reflect.Slice:
		return newSliceEncoder(t)
	case reflect.Array:
		return newArrayEncoder(t)
	case reflect.Pointer:
		return newPtrEncoder(t)
	default:
		return unsupportedTypeEncoder
	}
}

可以发现,在进行类型判断之前,会依次判断类型是否实现了 json.Marshalerencoding.TextMarshaler 接口,如果实现了则使用其对应的实现。

因此,实现 encoding.TextMarshaler 接口即可解决问题:

func (e MyError) MarshalText() ([]byte, error) {
	return []byte(e.Error()), nil
}

并没有完事!

上面看似解决了问题,但是却给我们提供了一个隐性的要求:我们不可以使用任何 Go 标准库提供的错误类型,因为我们无法为其实现 TextMarshaler 接口。

最优的方案实际上是全局使用第三方库。这里推荐使用我自己的 ee 错误处理库(github.com/ImSingee/go-ex/ee),其修改自官方的 pkg/errors 库,但基于实际需求做了一定的优化:

  1. (相比标准库)为所有的错误都包装了调用栈信息。
  2. 对于已经存在调用栈信息的,不会覆盖(来保证永远可以拿到最深层的调用栈信息)。
  3. 支持在 WithStack 时指定 skip 来使用上层栈(用于编写工具函数)。
  4. 栈信息的 StackTraceFrame 可访问,以供外部工具(例如日志处理库)结构化利用。
  5. 增加 Panic 函数,调用时会自动生成 error 并记录 panic 位置信息。
  6. 所有 error 都实现了 TextMarshaler 接口,对序列化友好。

另外,即使包裹了自定义错误,总有一些漏网之鱼,因此一个建议是在 json 序列化之前将可能为 error 的字段进行判断来替换。这里提供一个示例函数,实际使用时可根据需要修改使用:

// Special Check
if err, ok := fields["error"].(error); ok {
	_, tmok := err.(encoding.TextMarshaler)
	_, jmok := err.(json.Marshaler)

	if !tmok && !jmok {
		fields["error"] = err.Error()
	}
}

总结

我所有文章最不会写的就是总结,因此这个总结由 AI 生成😂

本文主要讲解了在 Go 语言中如何序列化 error 类型。为了解决 fmt.Errorf () 所返回的 error 类型结构无法符合 JSON 序列化的标准的问题,我们需要实现 encoding.TextMarshaler 接口。同时,本文推荐使用第三方库 ee,它继承了官方库 pkg/errors 的优点,并且实现了所有错误类型都实现 TextMarshaler 接口的特性。最后,我们还提供了一个代码示例,可以帮助你在序列化之前避免判断是否为 error 类型的字段。

☑️ ⭐

部署 OpenCat Team Server 到 Fly.io

准备工作

创建项目

在本地新建一个空文件夹,使用命令行进入该文件夹,然后输入以下命令创建项目:

flyctl launch

运行过程中,你需要设置项目名称(或保留空白以自动生成名称)和选择地区(注意不要选择香港)。确认后,系统将生成 fly.toml 文件。

创建存储

为确保数据持久化,请创建一个新的 Volume 来存储相关数据。执行以下命令创建:

flyctl volumes create opencat_data --size 1

在执行过程中,选择与之前步骤相同的地区。该命令将创建一个名为 opencat_data、大小为 1GB 的存储桶,用于存储后续数据。

编辑 fly.toml 文件

[env] 段之前,插入以下内容:

[build]
  image = "bayedev/opencatd:latest"

[mounts]
  destination = "/opt/db"
  source = "opencat_data"

[[services]] 下的 internal_port 值修改为 80。

启动服务器

执行 flyctl deploy 启动服务器,并确保通过安全检查。

绑定自定义域名

如没有自定义域名,请跳过此步骤。

执行 flyctl certs add YOUR_DOMAIN 配置自定义域名(将 YOUR_DOMAIN 替换为你的域名),并按照要求配置相应的 CNAME 记录。

配置完成后,稍等片刻。访问 https://fly.io/apps/APPNAME/certificates (将 APPNAME 替换为你创建项目时指定的名称),确保所有检查项目都已成功。

激活

打开 OpenCat 的创建团队页面,将域名设置为 https://YOUR_DOMAIN (将 YOUR_DOMAIN 替换为你在上一步绑定的自定义域名;如果未绑定,请使用 https://APPNAME.fly.dev)。确认激活,即可正常使用。

❌