从 v1 到 v2,我重新拆了头像生成的整条链路—— gdenticon 重构日志(二)

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 的问题并不是函数太多或者目录不好看,而是三件不同的事挤在了一条固定路径里:

  1. 根据输入生成头像;
  2. 把头像编码成某种图片格式;
  3. 决定图片保存在哪里。

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 中到底产生了什么结果。没有数据之前,我不会先把结构变化写成性能结论。

项目地址:

下一篇

下一篇会进入头像生成核心,从摘要比特怎样落到镜像网格开始,继续看 balanced 模式怎样筛选候选,以及哪些地方还有优化空间。

相关推荐
FungLeo4 小时前
成为全栈·Go 后端篇·分类树与标签关系:目录变更为何需要协调
golang·go·gorm·数据库事务·分类树·成为全栈
FungLeo4 小时前
成为全栈·Go 后端篇·文章查询与展示投影:安全分页、排序和软删除
golang·go·gorm·分页查询·成为全栈·sql安全
KevinAshen671 天前
用 Go 修复旧下载工具:从接口 403 到凭证截断的完整复盘
go·github
FungLeo2 天前
成为全栈·Go 后端篇·跨语言认证兼容:Node 与 Go 如何互认 JWT 和密码
golang·go·jwt·身份认证·成为全栈·bcrypt
FungLeo2 天前
成为全栈·Go 后端篇·重写之前先锁基线:契约、Node 版本与行为清单
golang·node.js·go·openapi·契约测试·成为全栈
小满zs3 天前
Go语言第十七章(反射)
后端·go
小满zs3 天前
Go语言第十五章(JSON序列化)
后端·go
小满zs3 天前
Go语言第十六章(自定义类型)
后端·go
w-w0w-w3 天前
Go语言学习day1
go