gdenticon v1 已经可以作为 Go 包生成头像,也有镜像网格、图案筛选和自动配色。单看最终结果,它并不是一个不能用的版本。
只是 v1 的使用方式一直围绕 Go 代码和本地 PNG 文件。调用方需要自己组织参数,再通过 Save 把结果写到磁盘。后来我想把它做成一个可以直接运行的工具,同时让同一张头像能够输出成不同格式,或者直接写进内存、HTTP 响应和其他目标,原来的接口就开始显得不够用了。
顺着旧结构继续加也不是不行,但改动很难停在一个地方。WritePNG 不只负责 PNG 编码,它接收原始 id,内部还会调用 Generate;外面的 Save 又接着处理内容哈希、文件命名和落盘。增加 SVG 或 JPEG,要么复制一套相似的入口,要么让 Save 同时管理越来越多的格式和输出规则。
这也成了我开始做 v2 的原因。要继续增加功能,得先把原来的一条固定路径拆开:先生成一份可以复用的头像结果,再选择编码方式,最后由调用方决定结果写到哪里。
v1:作为 Go 包使用,所有事情最后都汇进 Save
v1 没有 CLI。调用方需要先在 Go 代码里组装一个 model.Image:
go
img := model.Image{
GridSize: 5,
ImageSize: 256,
Mirror: true,
Hash: gdenticon.SHA256,
}
这个结构同时放了两类信息:
- 网格、镜像、摘要和颜色,决定头像本身如何生成;
ImageSize和Format,决定头像怎样输出。
随后调用:
go
path, err := gdenticon.Save(id, img, "")
Save 再调用 WritePNG,而 WritePNG 内部才调用 Generate。完整路径大致是:
text
Go 调用方
→ model.Image
→ Save
→ WritePNG
→ Generate
→ PNG 编码到 bytes.Buffer
→ 根据内容计算文件名
→ 写入磁盘

这条链能完成任务,但它给后面的扩展留下了几个问题。
真正的问题不是 PNG,而是编码器无法单独扩展
v1 的 model.Image 里已经预留了 SVG、PNG、JPEG、GIF、BMP、TIFF 和 WEBP 等格式。这些常量只是当时为后续输出留下的位置,不代表所有格式已经实现,也不是这次重构的理由。
真正的问题在调用方向。Save 固定调用 WritePNG,而 WritePNG 又负责触发 Generate。如果沿用这条链继续增加格式,大致只有两种走法:不断增加 WriteJPEG(id, img)、WriteSVG(id, img) 这样的平行入口,或者让 Save 根据格式分支,并继续掌握生成和落盘。
两种写法都能工作,但编码器仍然不是一个可以独立替换的环节。每增加一种格式,都要重新碰到原始输入、生成配置和文件策略。对我来说,这才是需要重构的信号。
WritePNG 同时知道输入、生成和编码
v1 的 WritePNG 接收的不是已经生成好的头像,而是原始 id 和整份 model.Image:
go
func WritePNG(w io.Writer, id string, img model.Image) error {
icon, err := Generate(id, img)
if err != nil {
return err
}
return png.Encode(w, raster(icon, img.ImageSize))
}
这让 PNG 输出成为整条生成流程的入口之一。调用方如果已经拿到 Icon,没有统一的 Write 可以继续使用;以后增加其他编码器,也要重新决定谁负责生成、谁负责校验尺寸。
核心包顺便接管了文件系统
v1 的 Save 会选择临时目录、创建 gdenticon 文件夹、计算内容哈希并写入文件。这些行为对只想保存本地文件的调用方很方便,却不一定适合其他使用场景。
如果头像需要直接写入 HTTP 响应、上传对象存储,或者只保留在内存里,目录和文件名根本不应该出现。核心包把本地保存当成默认终点后,其他使用方式只能绕开 Save,自己重新拼接流程。
编码结果需要先完整放进内存
为了根据图片内容生成文件名,v1 会先把完整 PNG 写入 bytes.Buffer:
go
var buf bytes.Buffer
if err := WritePNG(&buf, id, img); err != nil {
return "", err
}
path := filepath.Join(dir, fileName(buf.Bytes()))
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return "", err
}
一张默认尺寸头像占用不算大,所以这不是一个会立刻暴露的故障。但保存图片必须等编码全部完成,再读取同一份字节计算哈希和落盘。单张图片尚可接受,后面如果考虑批量生成,这条路径就值得先整理。
这里我不打算先给出节省了多少内存。没有基准测试,结构上的减少不能直接写成性能提升。能确定的是,新链路不再要求核心保存函数持有完整的编码结果。
v2 的重构目标:把三件事拆开
v1 的问题并不是函数太多或者目录不好看,而是三件不同的事挤在了一条固定路径里:
- 根据输入生成头像;
- 把头像编码成某种图片格式;
- 决定图片保存在哪里。
v2 的主要调整,就是给这三件事各留一个清楚的边界。
text
GenerateOptions
→ Generate
→ Icon
→ Write(io.Writer)
→ 文件、内存、HTTP 响应或对象存储
算法仍然位于 Generate 内部,PNG、SVG 等格式归 Write 管,目录、缓存和替换文件则移动到单独的文件层。CLI 负责把它们组合起来,但不再把自己的保存策略塞回核心包。
第一处:拆开生成配置和输出配置
v2 删除了单独的 model 包,把公开类型收回 gdenticon 包,同时把原来的 model.Image 拆成两份配置。
GenerateOptions 只保留生成逻辑头像需要的信息:
go
type GenerateOptions struct {
GridSize int
Mirror bool
Style PatternStyle
Digest DigestFunc
Foreground ColorFunc
Background color.Color
Format Format
}
WriteOptions 保存编码阶段才关心的尺寸和 JPEG 质量:
go
type WriteOptions struct {
Size int
JPEGQuality int
}
这里把 Format 留在 GenerateOptions 和 Icon 中,是为了让生成结果携带后续要使用的编码格式;真正与像素尺寸有关的校验则推迟到 Write。SVG 不需要先满足栅格图片的像素限制,PNG、JPEG 等格式才需要检查图片尺寸是否能容纳网格。
这个拆分不是为了多造一个结构体,而是避免生成阶段提前背上所有编码规则。
第二处:让 Icon 成为真正的中间结果
v2 的 Generate 只负责从输入和选项得到 Icon:
go
icon, err := gdenticon.Generate(id, opts)
Icon 记录网格、填充位置、前景色、背景色和目标格式。它描述头像是什么样,但还不是一份图片文件。
go
type Icon struct {
GridSize int
Filled []bool
Background color.Color
Foreground color.Color
Format Format
}
v1 已经有 Icon,这次并不是发明了一个新概念。真正的变化是后续编码器开始直接接收它,不再从 id 和完整配置重新走一遍 Generate。
这也说明本轮重构的重点不在头像算法。v1 已经有摘要熵流、镜像网格、密度范围、四连通块筛选和配色逻辑;v2 的这一阶段主要保留它们,再把外围调用方式理顺。算法当然还有继续优化的空间,但它不应该和文件输出一起改,否则很难判断变化究竟来自哪一层。
第三处:用一个 Write 接住所有格式
现在的编码入口统一成了:
go
func Write(w io.Writer, icon Icon, opts WriteOptions) error
Write 根据 icon.Format 选择 PNG、SVG、JPEG、GIF、BMP 或 TIFF 编码器。调用方不需要记住六个公开函数,也不需要再次提供原始字符串。
这里最重要的仍然是 io.Writer。核心包只负责把编码后的字节写出去,不创建文件,也不关闭 Writer。于是同一份 Icon 可以输出到:
bytes.Buffer,留在内存;http.ResponseWriter,直接返回给浏览器;os.File,保存到本地;- 对象存储上传流;
io.MultiWriter,同时写入多个目标。
多格式支持因此不再是类型声明里的一张清单,而是真正接入了统一输出链路。CLI 也新增了 --format,用户的选择可以一路传到 Write。
第四处:文件保存移出核心生成包
v2 删除了核心包中的 Save,新增 pkg/gdenticon/file,专门处理目录、临时文件、安全替换和内容寻址缓存。
CLI 现在这样组合编码与缓存:
go
entry, err := iconfile.PutCacheEntry(
dir,
icon.Format.Extension(),
0o644,
func(w io.Writer) error {
return gdenticon.Write(w, icon, gdenticon.WriteOptions{Size: f.size})
},
)
文件层提供一个 Writer,Write 直接把编码结果写进去。PutCacheEntry 再通过 io.MultiWriter 同时写临时文件和 SHA-256,不必先在内存里保留完整图片。
缓存命中、并发写入和 Windows 文件替换也是这一层的问题。它们对 CLI 很重要,但不应该成为 Generate 的一部分。其他项目只想拿到内存中的图片时,可以完全不依赖这个文件包。
第五处:v2.0.0 开始加入 CLI
v1 主要作为 Go 包使用,到了 v2.0.0 才加入 CLI。CLI 并不是在库外面简单套一层参数解析,它需要把命令行中的字符串转换成 GenerateOptions 和 WriteOptions,再接入新的生成、编码和文件链路。
v2.0.0 同时提供直接生成和交互模式,因此 main 需要先判断运行方式:
go
func runProgram(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if interactiveRequested(args) {
return runInteractive(stdin, stdout, stderr)
}
return run(args, stdout, stderr)
}
交互模式没有复制参数解析和头像生成。它把 generate 后面的内容交给同一棵 Cobra 命令树,再通过回调记录生成结果。直接命令和交互命令从参数校验开始共用同一条链路。
错误处理也继续保留了两种退出语义:参数使用错误返回 2 并显示用法,运行错误返回 1 并保留实际原因。交互模式遇到错误不会退出整个会话,只改变展示方式。
这部分看起来离头像算法很远,却决定了新增的 CLI 能不能继续扩展,而不把核心包拖进终端状态和会话管理里。
v2 当前的一次请求
现在执行:
powershell
gdenticon alice@example.com --grid 7 --format svg
走过的是下面这条链:
text
main
→ 选择直接命令或交互模式
→ CLI 校验并转换参数
→ Generate 生成 Icon
→ Write 按指定格式编码到 io.Writer
→ 文件层写入或命中内容缓存
→ CLI 输出最终路径
与 v1 相比,头像算法没有被包装成更复杂的层级,反而是它周围的责任更清楚了:
Generate不负责图片尺寸和文件路径;Write不重新生成头像,也不决定保存位置;- 文件层不关心摘要和网格;
- CLI 只负责把用户输入与这些能力接起来。
这才是 v1 到 v2 最主要的重构。它没有直接证明程序快了多少,但给后续优化留下了可以单独测量和替换的位置。
还没有结束的部分
v2 把生成、编码和保存拆开以后,头像生成本身仍然有足够的深度继续往下做。
当前算法会把摘要转换成可持续读取的比特流,生成镜像网格,再根据填充密度和四连通块过滤太空、太满或过于零散的候选。这个核心思路在 v1 已经存在,v2 并没有把它全部推翻。
后面真正要看的,是这套实现有哪些计算可以减少,候选筛选是否值得调整,网格与渲染过程有没有多余分配,以及每次变化在 benchmark 中到底产生了什么结果。没有数据之前,我不会先把结构变化写成性能结论。
项目地址:
- GitHub:hourinX/gdenticon
- Gitee:hourinX/gdenticon
下一篇
下一篇会进入头像生成核心,从摘要比特怎样落到镜像网格开始,继续看 balanced 模式怎样筛选候选,以及哪些地方还有优化空间。