Go 标准库至今不提供通用的 map/filter 集合操作。Go 1.18 泛型落地后,samber 系列是填补这一缺口的主要选择:lo 负责集合变换,mo 负责 Option/Result 类型,hot 负责进程内缓存,ro 负责异步事件流。四个库解决的问题互不重叠,共同特点是零依赖、泛型优先、类型安全,可单独引入,也可组合使用。

库的选型应与问题类型匹配:对有限的切片数据引入响应式流属于过度设计;多步错误处理流程手写嵌套 if 会显著增加维护成本。因此下文各节均按"适用场景"与"不适用场景"组织,规则尽量写成可直接进入 code review 清单的形式。同作者的 oops 与 slog 系列不在本文展开,分别参见错误处理规范和第 19 篇(可观测性)。

samber/lo:先检查标准库

Go 1.21+ 的 slices 包已提供 Contains、Sort、Compact;Go 1.23+ 起,一行 slices.Collect(maps.Keys(m)) 即可获得键切片。这些场景不应再引入 lo,以避免无谓的依赖;切片与 map 的实现细节见数据结构内幕。lo 的适用范围是标准库尚未覆盖的操作:Map、Filter、Reduce、GroupBy、Chunk、Flatten、Uniq——在原生 Go 中,每一个都对应一段手写循环。

lo 是 Lodash 风格的泛型工具库,提供数百个类型安全函数,零依赖,默认不可变:所有函数返回新集合,不修改输入。核心包之外还有三个按需引入的子包:parallel(惯用别名 lop,并行)、mutable(lom,原地修改)、it(loi,Go 1.23+ 惰性迭代器)。

// ❌ 手写循环:三行样板,只为提取一个字段
names := make([]string, 0, len(users))
for _, u := range users {
    names = append(names, u.Name)
}

// ✅ lo.Map:声明式,编译期类型检查,无 interface 装箱
names := lo.Map(users, func(u User, _ int) string {
    return u.Name
})

批量变换可能失败时,应使用 Err 变体,避免手工收集错误。MapErr、FilterErr、ReduceErr 等版本的回调返回 (T, error),遇到第一个错误立即短路:其机制相当于把循环内的 if err != nil { return err } 提升到函数签名中,结果保序,且无需自行维护错误收集状态。

// ✅ 任一 URL 失败,立即短路返回
results, err := lo.MapErr(urls, func(url string, _ int) (Resp, error) {
    return fetch(url)
})

并行 Map 仅适用于 CPU 密集的大数据集。 lop.Map 为每个元素启动一个 goroutine(内部池化),结果保序;数据量在千条以下时收益为负,goroutine 开销会超过变换本身的成本。判断标准:变换昂贵(解析、哈希、计算)且数据量大。I/O 并发(HTTP 调用、数据库查询)不应使用 lop.Map,应使用 errgroup 配合 context 取消,见并发编程规范。

// ✅ 1 万条 JSON 解析,CPU 密集,并行处理才有收益
docs := lop.Map(raw, func(b []byte, _ int) *Doc { return parseDoc(b) })

lom 与 loi 必须有 profiler 数据支持,不应凭经验引入。lom 原地修改输入切片,减少分配,但失去并发读安全,切换条件是 pprof -alloc_objects 证实分配压力;loi 把急切求值换成惰性求值(range-over-func),Map → Filter → Take 一遍执行、不生成中间切片,前提是组合链在三步以上且使用 Go 1.23+。单步变换推荐 lo.Map:更清晰,开销也可忽略。

还应保持克制:三五行即可完成、一眼可读的循环,手写更直白;lo 适合"同一变换重复出现"的场景,对应 Go 谚语 clear is better than clever。另一条硬性规则:lo.Must 只允许出现在测试和 init 中——它在出错时直接 panic,进入请求路径会导致服务崩溃。

samber/mo:Option/Result 与错误管道

mo 提供一组 Rust/Scala 风格的单子类型:OptionT、ResultT、EitherL, R,以及 Future、IO、Task 等进阶类型。共同目标是让非法状态不可表示:nil 检查变成类型约束,错误处理变成可组合的管道。

对外 API 保持 (T, error),Result 只用于领域层。 error 是 Go 的标准错误处理机制,跨包接口与简单分支应继续使用,约定见错误处理规范。Result 的收益在领域逻辑内部:多步操作、每步都可能失败、需要自动短路时,Map/FlatMap 把嵌套 if 压成一条从左到右的组合链。惯例是在边界处用 TupleToResult 把 (T, error) 包装成 Result,管道结束再用 Get 还原。

func parseAge(s string) mo.Result[int] {
    v, err := strconv.Atoi(s)
    return mo.TupleToResult(v, err)
}

// ✅ 解析、校验,任一步失败,管道自动短路
result := parseAge("25").FlatMap(validateAge)

可空字段应使用 Option,避免在零值与指针之间做含糊判断。Option[string] 能区分"未填写"与"空字符串";它实现了 json.Marshaler/Unmarshaler 和 sql.Scanner/driver.Valuer,None 序列化为 null,Scan 读到 NULL 列也不会出错,JSON 结构体和数据库模型可以直接使用:

type User struct {
    Name  string            `json:"name"`
    Phone mo.Option[string] `json:"phone"` // None -> null,与 "" 明确区分
}

一个语言层面的限制需要提前了解:Go 的方法不能引入新的类型参数,Result[T].Map 永远返回 Result[T]。要把 Result[[]byte] 变成 Result[Config],必须使用子包函数,例如用 result.Map 配合 Pipe2 组合:

// []byte -> Config -> ValidConfig
cfg := result.Pipe2(
    mo.TupleToResult(os.ReadFile("config.yaml")),
    result.Map(parseConfig),
    result.FlatMap(validate),
)

应优先使用 OrElse 而非 MustGet。MustGet 在 None/Err 上 panic,仅限两种情况:mo.Do 块内(panic 会被捕获并转成 Err),以及百分之百确定值存在时。

Result 表示成败,Either 表示两种合法结果。"缓存命中还是回源查询"不是成功与失败,而是两个都有效的分支,这种语义应使用 Either[L, R],不应使用 error 表达。Result 可用 ToEither 转换;mo.Fold 对 Option、Result、Either 提供统一的提取接口。

samber/hot:进程内缓存

hot 这个名字容易让人联想到热点代码优化,实际与模板、JIT 无关——它是泛型内存缓存库(Hot Object Tracker):9 种淘汰算法、TTL、读穿透 loader(内置 singleflight)、分片、stale-while-revalidate 和 Prometheus 指标。适用信号明确:同一批中低基数资源(用户、配置、热门商品)被高频重复加载,既要降低延迟,也要减轻后端负载。

淘汰算法应从 W-TinyLFU 起步,miss rate 超出 SLO 再更换。9 种算法各有适用场景:LRU 适合新近性主导的场景(如会话),全表扫描会导致缓存污染;LFU 能保留热门条目,但访问模式迁移后,旧热点会长期占据缓存;S3FIFO 高吞吐、扫描抗性好,小容量场景下表现不佳。不应为了"看起来专业"而选用冷门算法。

TTL、janitor、容量预算三项配置缺一不可。 未设置 TTL 的缓存会无限期提供陈旧数据,下游可能长期把旧配置当作最新值使用;未配置 WithJanitor 时,过期条目会持续占用内存等待淘汰;容量应按"内存预算 ÷ 单条大小"估算,单条大小需计入键、堆上的切片和 map 字段,以及约 100 字节的簿记开销;无法确定时应编写测试,用 runtime.ReadMemStats 实测后向下取整留出余量。

cache := hot.NewHotCache[string, *User](hot.WTinyLFU, 10_000).
    WithTTL(5 * time.Minute).
    WithLoaders(func(ids []int) (map[int]*User, error) {
        return db.GetUsersByIDs(ctx, ids) // 未命中自动回源
    }).
    WithJanitor().
    Build()
defer cache.StopJanitor()

回源应交给 Loader,不应在业务代码中手写"先查缓存再查库"。 WithLoaders 配置回源函数后,未命中自动加载;更重要的是内置 singleflight:并发请求同一个缺失 key 只触发一次回源,可防止缓存击穿。Get 返回 (value, found, err) 三个值,loader 失败时必须检查 err,不能只看 found。

过期时间应加 jitter,可变值应开启拷贝。一批同时写入的条目会同时过期,回源瞬间形成惊群效应(Thundering Herd),WithJitter 把过期时间打散;缓存中存指针时,调用方就地修改会破坏共享状态,应使用 WithCopyOnRead/WithCopyOnWrite 保护可变值。

命中率长期低于 80% 时,应先检查容量是否过小或算法是否合适。WithPrometheusMetrics 应在上线时启用;命中率持续偏低,应扩容或更换算法,而不是继续增加机器。

samber/ro:响应式事件流

数据已在切片中,用 lo;数据随时间到达,才适用 ro。ro 是 ReactiveX 的 Go 实现:150 多个类型安全操作符、5 种 Subject、40 多个插件,内置背压、错误传播与 context 集成。判断标准:有限、同步、急切 → lo;无限流(WebSocket、ticker、文件监听)、多源异步合并(CombineLatest/Zip)、需要时间语义(delay、throttle、timeout、retry)→ ro。channel + goroutine 理论上都能实现,但手工关闭 channel、在嵌套 select 中传递错误、管理订阅生命周期,在管道复杂后会成为负担——ro 把这些固化成声明式操作符,错误沿管道自动传播,无需在每个环节自行编写错误通道。

observable := ro.Pipe2(
    ro.RangeWithInterval(0, 5, 1*time.Second),
    ro.Filter(func(x int) bool { return x%2 == 0 }),
    ro.Map(func(x int) string { return fmt.Sprintf("even-%d", x) }),
)

observable.Subscribe(ro.NewObserver(
    func(s string) { fmt.Println(s) },    // onNext
    func(err error) { log.Println(err) }, // onError
    func() { fmt.Println("done") },       // onComplete
))

应使用类型化的 Pipe2…Pipe25,不使用 untyped Pipe——后者依赖 any 传递,编译期类型检查全部失效,类型错误要推迟到运行时才暴露。

onNext、onError、onComplete 三个回调必须全部提供。 只注册 onNext 的观察者会静默丢弃流上的错误,在生产环境中表现为无声的数据丢失。ro.NewObserver(onNext, onError, onComplete) 是唯一正确的写法。

无限流必须有界。 Take(n)、TakeUntil(signal)、Timeout、context 取消,四选一;否则订阅永不结束,导致 goroutine 泄漏。此外应区分冷热:Observable 默认是冷的,每次订阅独立执行一遍;多个消费者需要共享同一份事件流时,才使用 Share()(引用计数,最后一个退订时拆除)或 Subject(本身是热的,支持推送事件与终止信号)。

速查清单

库定位适用场景不适用场景
lo集合变换,不可变、零依赖相同变换逻辑重复出现(第三次起)标准库已覆盖的操作;千条以下并行
moOption/Result/Either 类型领域层多步错误组合、可空字段对外包 API;简单错误分支
hot进程内缓存:9 种淘汰算法 + loader中低基数资源高频重复加载工作集远超内存;跨进程共享
ro响应式事件流:管道操作符无限流、多源合并、时间语义有限切片变换(应使用 lo)

推荐规则:

  • 切片变换:应先检查标准库,缺失的操作使用 lo;CPU 密集的大数据集使用 lop;lom 与 loi 必须有 profiler 数据支持。
  • 错误组合:边界保持 (T, error),领域层用 TupleToResult 进入 Result 管道,回退默认值以 OrElse 作为最终防线。
  • 高频重复加载:使用 hot,配置 WTinyLFU + TTL + Loader 三项,并启用 janitor 与 Prometheus 指标。
  • 事件随时间到达:使用 ro;类型化 Pipe、三个回调、无限流设置边界,缺一不可。

四个库的共同规则:应先采用最简单、最标准的方案,待 profile 数据与真实需求明确后再更换工具。 函数式组合不是目的;代码意图可读、行为可测,才是衡量标准。