在工业边缘程序里,错误不只是"打印一行日志"。一次设备读取失败,可能来自连接超时、协议响应异常、数据校验失败、权限不足,也可能是下游数据库暂时不可用。如果错误只被转成字符串,后续就很难判断该重试、报警、降级,还是直接返回给上层业务。
Go 标准库 errors 包和 fmt.Errorf 的 %w 动词,是构建错误链的核心工具。本文从 Zenova EdgeOS 在工业边缘工程视角梳理错误创建、包装、识别、分类、分层传递、日志与接口返回的实践。
一、为什么需要错误链
一个可维护的错误处理体系至少要满足四个目标:
| 目标 | 典型问题 | 错误链价值 |
|---|---|---|
| 保留上下文 | 只知道 failed,不知道哪个站点、设备、操作失败 | 每层包装补充当前语义 |
| 支持识别 | 上层只能比较错误字符串 | 使用 errors.Is / errors.As |
| 支持决策 | 无法区分超时、权限、不存在、冲突 | 用哨兵错误或错误类型分类 |
| 控制暴露面 | SQL、固件路径、内部 IP 泄漏到 API | 内层保留细节,边界只返回安全信息 |
简单说,错误链让错误既能"带细节往上传",又能让上层按语义处理,而不是按文案猜。
二、基础:创建、包装与解包
1. 创建错误
go
var ErrNotFound = errors.New("device not found")
func findDevice(id string) error {
// 真实项目中这里通常查缓存、本地表或设备服务。
return ErrNotFound
}
2. 包装错误
%w 会保留被包装错误,后续 errors.Is、errors.As 和 errors.Unwrap 才能继续工作。
go
func getDevice(id string) (Device, error) {
if err := findDevice(id); err != nil {
return Device{}, fmt.Errorf("get device %s: %w", id, err)
}
// 省略成功路径。
return Device{ID: id}, nil
}
3. 判断错误
go
err := getDevice("dev-001")
if errors.Is(err, ErrNotFound) {
log.Printf("device not found: %v", err)
}
注意区别:
errors.Is判断错误或错误链中是否包含目标值,适合哨兵错误;errors.As判断错误链中是否存在某种类型,并提取字段,适合业务错误类型;err == ErrNotFound只能比较最外层对象,包装后会失效;strings.Contains(err.Error(), "not found")依赖文案,不应作为程序逻辑。
三、哨兵错误:定义稳定语义
哨兵错误适合表达跨层传递、稳定且数量有限的业务语义。
go
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
ErrTimeout = errors.New("timeout")
ErrUnavailable = errors.New("unavailable")
ErrDeviceBusy = errors.New("device busy")
)
边界层可以按语义转换:
go
func statusCode(err error) int {
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrPermission):
return http.StatusForbidden
case errors.Is(err, ErrTimeout):
return http.StatusGatewayTimeout
case errors.Is(err, ErrUnavailable) || errors.Is(err, ErrDeviceBusy):
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
不要为每种底层故障都创建哨兵错误。语义不稳定、需要携带字段、只有局部模块使用的错误,更适合自定义类型。
四、自定义错误类型:保留业务字段
自定义错误适合表达"同一类失败,但上下文不同"的场景,例如设备读取失败、协议解析失败、采集任务失败。
go
type DeviceError struct {
DeviceID string
Op string
Retryable bool
Err error
}
func (e *DeviceError) Error() string {
if e == nil {
return "<nil>"
}
return fmt.Sprintf("device %s %s: %v", e.DeviceID, e.Op, e.Err)
}
func (e *DeviceError) Unwrap() error {
return e.Err
}
使用 errors.As 提取结构化字段:
go
err := &DeviceError{
DeviceID: "dev-001",
Op: "read register",
Retryable: true,
Err: ErrTimeout,
}
var de *DeviceError
if errors.As(err, &de) {
log.Printf(
"device_id=%s op=%s retryable=%t root=%v",
de.DeviceID, de.Op, de.Retryable, errors.Unwrap(de),
)
}
实现建议:
Unwrap返回Err,不要在Error()里丢失底层错误;- 字段使用稳定命名,便于结构化日志和指标统计;
- 敏感值、内部地址、凭据不要直接放入错误字段;
- 如果错误类型实现
Is方法,必须保证语义稳定、可测试。
五、多错误:fan-out 与批量任务
工业边缘常见一次任务操作多台设备,例如批量读取、批量下发、批量校验。Go 1.20 之后可以使用 errors.Join 聚合多个错误。
go
func collectDevices(ctx context.Context, ids []string) error {
var results []error
for _, id := range ids {
if err := readDevice(ctx, id); err != nil {
results = append(results, fmt.Errorf("read device %s: %w", id, err))
}
}
return errors.Join(results...)
}
判断聚合错误:
go
err := collectDevices(ctx, deviceIDs)
if errors.Is(err, ErrTimeout) {
// 只要错误链中存在超时,就按超时策略处理。
}
如果成功读取了 90 台设备、失败 10 台,单纯返回一个总错误会丢掉成功结果。工程上可以返回"结果 + 失败列表",让上层决定是否部分成功、继续处理或整体重试。
六、分层错误边界
1. Repository / Driver 层
驱动层的职责是把数据库、协议栈、文件系统错误转换成领域语义,同时保留技术根因。
go
func (r *DeviceRepo) Get(ctx context.Context, id string) (Device, error) {
var d Device
err := r.db.GetContext(ctx, &d, "SELECT id, name FROM devices WHERE id = ?", id)
if errors.Is(err, sql.ErrNoRows) {
return Device{}, fmt.Errorf("device %s: %w", id, ErrNotFound)
}
if err != nil {
return Device{}, fmt.Errorf("query device %s: %w", id, err)
}
return d, nil
}
2. Service 层
服务层补充业务动作,不重复堆叠无意义的上下文。
go
func (s *GatewayService) GetDevice(ctx context.Context, id string) (Device, error) {
d, err := s.repo.Get(ctx, id)
if err != nil {
return Device{}, fmt.Errorf("get gateway device: %w", err)
}
return d, nil
}
3. HTTP Handler 层
接口边界要做两件事:按语义返回状态码,避免把内部细节暴露给外部。
go
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (h *Handler) GetDevice(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
device, err := h.service.GetDevice(r.Context(), id)
if err != nil {
code := statusCode(err)
if code >= http.StatusInternalServerError {
slog.Error("get device failed",
"device_id", id,
"error", err,
)
writeError(w, code, "internal error")
return
}
writeError(w, code, userMessage(err))
return
}
writeJSON(w, http.StatusOK, device)
}
错误文案建议:
- 对外返回稳定 code 和可行动的 message;
- SQL、驱动细节、堆栈、内部路径只进入日志;
- 请求 ID、设备 ID、站点 ID 等上下文用于排障;
- 不要把原始错误直接返回给浏览器、设备端或第三方调用方。
七、错误与重试策略
错误分类要服务于动作。可以按"是否可重试"和"影响范围"建模:
| 错误语义 | 常见来源 | 建议动作 |
|---|---|---|
| 超时 | 连接超时、请求截止 | 有限重试,检查退避 |
| 暂不可用 | 下游服务重启、连接池耗尽 | 重试或降级缓存 |
| 设备忙 | 采集冲突、指令占用 | 延迟重试 |
| 不存在 | ID 错误、设备未注册 | 不重试,返回业务错误 |
| 权限不足 | 认证、授权、密钥失效 | 不重试,通知运维或用户 |
| 数据校验失败 | 非法寄存器值、CRC 错误 | 视协议决定重读或告警 |
判断上下文超时时,应使用:
go
if errors.Is(err, context.DeadlineExceeded) {
// 请求级超时。
}
如果错误来自 net 包,也可以按接口判断:
go
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
// 网络层超时,可进入有限重试。
}
重试必须有限制,包括最大次数、总截止时间、退避间隔和抖动,避免设备掉线时把任务循环打成高频扫描。
八、堆栈、日志与可观测性
Go 标准库错误默认不携带调用堆栈。runtime.Caller 只能记录创建错误的位置,不等同于完整调用栈。工业边缘系统更稳定的做法是把错误链与结构化日志、trace、指标结合。
go
slog.Error("collect device failed",
"site", site,
"device", deviceID,
"op", "read_registers",
"error", err,
)
如果确实需要堆栈,可以选择第三方库,但要评估维护状态和依赖成本:
github.com/pkg/errors提供过Wrap/WithStack,但项目已进入冻结状态;github.com/cockroachdb/errors兼容标准错误链,并提供堆栈与报告能力;- 也可以在关键边界自行记录
runtime.Caller,只保留必要位置。
比"每个错误都带堆栈"更重要的是:
- 日志中有站点、设备、操作、请求 ID;
- 指标能按错误类型聚合,例如
timeout、permission、not_found; - 每个错误只在一个明确边界打印,避免重复刷屏;
- 失败路径有测试覆盖,而不是只测试成功路径。
九、常见错误处理模式
1. 只在需要决策时处理错误
go
if err := syncDevice(ctx, id); err != nil {
if errors.Is(err, ErrDeviceBusy) {
scheduleLater(id)
return nil
}
return fmt.Errorf("sync device %s: %w", id, err)
}
能处理就处理,不能处理就带上下文继续上抛。不要到处 if err != nil 却什么也不做。
2. 避免吞错
不推荐:
go
_ = closeConn()
如果关闭失败不影响主流程,至少记录 debug 日志;如果可能造成资源泄漏或数据不完整,应进入返回错误或指标。
3. 避免过度包装
go
return fmt.Errorf("handle: %w",
fmt.Errorf("service: %w",
fmt.Errorf("repo: %w", err),
),
)
这种链路会变成 handle: service: repo: ...,可读性差。每层只补充自己拥有的信息即可。
4. goroutine 错误要显式传递
go
func worker(ctx context.Context, id string, out chan<- error) {
result := process(ctx, id)
if result != nil {
result = fmt.Errorf("process %s: %w", id, result)
}
select {
case out <- result:
case <-ctx.Done():
}
}
不要在 goroutine 里直接 panic 或丢弃失败结果。批处理场景应确保每个任务的错误都有归属和结束状态。
十、常见坑与应对
| 常见坑 | 现象 | 应对 |
|---|---|---|
| 吞掉错误 | 任务显示成功但状态异常 | 返回、记录或进入指标,至少显式说明可忽略 |
| 字符串比较 | 文案变化后逻辑失效 | 使用 errors.Is / errors.As |
忘记 %w |
错误链断裂,无法识别根因 | 包装时使用 %w |
| 包装过深 | 错误信息重复、排障困难 | 每层只补充本层语义 |
| 暴露内部错误 | SQL、路径、地址泄漏 | 边界转换安全响应,细节进日志 |
| 把所有错误当 500 | 上层无法降级或重试 | 定义领域错误语义 |
| 无限重试 | 放大故障、拖垮设备 | 限制次数、退避、总超时 |
| 日志无上下文 | 无法定位站点和设备 | 携带 request、site、device、op |
| 只看成功路径测试 | 边界行为不可控 | 覆盖超时、不存在、权限、暂不可用 |
十一、工程实践清单
- 为跨层错误定义少量稳定哨兵错误;
- 为携带上下文的业务失败定义自定义错误类型;
- 所有包装使用
%w,避免错误链断裂; - HTTP、gRPC、MQTT 等边界统一做错误映射;
- 重试策略与错误语义绑定;
- 结构化日志携带设备、站点、操作和请求 ID;
- 批量任务保留部分成功结果和逐项失败原因;
- 对外错误只暴露稳定 code 与安全 message;
- 为超时、不存在、权限不足、暂不可用等路径补测试;
- 将错误类型接入指标、告警和链路追踪。
十二、TL;DR
errors.New创建错误,fmt.Errorf+%w包装错误;errors.Is识别哨兵错误,errors.As提取错误类型;- 自定义错误要实现
Error()和Unwrap(); errors.Join适合聚合多任务失败;- 分层包装要克制,每层只补充本层语义;
- API 边界返回安全信息,内部细节进入日志;
- 重试、告警、降级都应基于错误语义,而不是错误文案。
下一步建议
- 盘点当前项目中字符串比较和吞错的位置;
- 定义领域级哨兵错误和错误类型;
- 统一 HTTP / gRPC / 消息入口的错误映射;
- 为超时、权限、不存在、暂不可用补充测试;
- 将错误分类接入日志、指标和告警体系。