在 Go 语言的开发旅程中,错误处理是一个无法回避的重要环节。起初,Go 语言提供了一种看似简单灵活的错误处理方式,然而,随着项目的不断发展,代码库逐渐庞大,开发者数量增多,这种简单性却逐渐演变成了一场混乱。错误信息的不一致、处理方式的不统一以及难以追踪等问题接踵而至,给项目的维护和调试带来了巨大的挑战。今天,我们将深入探讨 Go 错误处理的演进过程,从最初的常见做法到面临的问题,再到最终构建的集中化、结构化错误处理框架,一同见证如何在错误的海洋中找到秩序。
一、Go 错误处理的常见做法
(一)错误即值的理念
Go 语言中,错误被视为普通的值,任何实现了error
接口(包含Error() string
方法)的类型都可以作为错误返回。这种设计理念与其他编程语言中通过异常机制处理错误有所不同,它给予开发者更大的灵活性,使得每个包都能够根据自身需求定义合适的错误处理策略。例如,在一个文件读取操作中,如果文件不存在,os.ReadFile
函数会返回一个error
值,开发者可以根据这个错误值进行相应的处理,如提示用户文件不存在或采取其他补救措施。
(二)常见的错误处理模式
-
标准包中的错误创建:开发者通常使用
errors.New()
或fmt.Errorf()
来创建简单的错误。errors.New()
用于创建一个基本的错误值,而fmt.Errorf()
则更加强大,它允许在创建错误时进行格式化操作,使错误信息更加详细和有意义。例如,fmt.Errorf("文件读取失败: %w", err)
可以在原错误err
的基础上添加更具体的上下文信息,方便后续的错误排查。 -
错误包装与传递:为了给错误添加更多的上下文信息,开发者会使用
fmt.Errorf()
结合%w
动词来包装错误。这使得错误在传递过程中能够携带更多的信息,有助于定位问题的根源。例如,在一个多层调用的函数链中,下层函数可以将自身的错误信息包装后传递给上层函数,上层函数可以通过检查包装后的错误来获取更详细的错误信息。 -
错误的组合与检查:Go 提供了
errors.Join()
函数来合并多个错误为一个错误,方便在某些场景下统一处理多个错误。同时,errors.Is()
用于检查一个错误是否与特定的值相等,errors.As()
用于将错误匹配为特定的类型,errors.Unwrap()
则可以获取包装在内部的底层错误。这些工具函数使得开发者能够更加灵活地处理和判断错误类型,从而采取合适的措施。例如,在处理多个文件读取操作时,如果其中一个文件读取失败,可以使用errors.Join()
将所有的错误合并,然后统一进行处理;使用errors.Is()
可以判断某个错误是否是特定的文件不存在错误,以便针对性地处理。
(三)早期项目中的错误处理实践
-
使用
pkg/errors
包含堆栈跟踪:在早期,为了更好地调试错误,许多开发者使用pkg/errors
包来包含堆栈跟踪信息。这在大型项目中特别有用,因为它能够帮助开发者快速定位错误发生的位置。例如,当一个函数在多层调用后发生错误时,通过堆栈跟踪可以清晰地看到函数的调用路径,从而更容易找出问题所在。 -
导出错误变量:每个包通常会定义自己的错误变量,以便在函数中返回特定的错误。然而,这些错误变量的定义往往缺乏统一的标准,导致风格不一致。例如,一个数据库操作包可能定义
ErrNotFound
表示记录未找到的错误,而另一个用户管理包可能也定义了类似的ErrUserNotFound
错误变量,这使得在整个项目中难以统一管理和理解错误信息。 -
使用
errors.Is()
检查错误并包装上下文:开发者会使用errors.Is()
来检查特定的错误,并通过fmt.Errorf()
等方式包装错误,添加更多的上下文信息。但这种方式在实际应用中往往会导致日志信息冗长、重复,且不够清晰。例如,在一个用户查询函数中,如果查询失败,可能会返回一个包含多层包装的错误信息,如"内部服务器错误: 查询用户失败: 用户未找到 (id=52a0a433 - 3922 - 48bd - a7ac - 35dd8972dfe5): 记录未找到: 未找到"
,这使得在查看日志时很难快速准确地理解问题的本质。 -
基于 Protobuf 定义外部错误类型和代码:对于对外暴露的 API,一些项目采用基于 Protobuf 的错误模型来结构化错误信息。通过定义
Error
消息类型,包含错误消息、类型、代码等字段,以及相应的ErrorType
和ErrorCode
枚举,使得外部 API 的错误处理更加规范。然而,随着时间的推移,由于缺乏清晰的规划,错误类型和代码的添加变得随意,导致了不一致和重复的问题。例如,不同的开发者可能会为相似的错误定义不同的错误代码,使得整个错误体系变得混乱。
二、问题的滋生与挑战
(一)错误声明的分散性
每个包都自行定义错误常量,缺乏集中管理,导致错误声明散布在整个代码库中。这使得开发者很难清楚地知道一个函数可能返回哪些错误,增加了理解和维护代码的难度。例如,在一个大型项目中,可能存在多个地方定义了类似的 “未找到” 错误,如gorm.ErrRecordNotFound
和user.ErrNotFound
,开发者需要花费大量时间来确定在特定情况下到底会返回哪个错误,以及如何正确处理。
(二)日志信息的混乱
-
随意的错误包装导致日志冗长冗余:许多函数在包装错误时使用了任意、不一致的消息,并且没有声明自己的错误类型。这使得日志变得冗长、重复,难以搜索和监控。例如,一个错误可能在多次包装后包含了大量的冗余信息,如
"意外的gorm错误: 查找业务渠道失败: 调用API时收到错误: 意外: 上下文已取消"
,这些信息不仅难以阅读,还掩盖了错误的核心原因。 -
错误消息缺乏解释性:错误消息往往过于通用,没有清楚地解释错误发生的原因和过程,而且容易受到不经意的更改影响,变得脆弱。例如,一个简单的
"错误"
消息并不能提供任何有用的信息,开发者需要花费更多的时间和精力来调试问题。
(三)错误处理的不规范
-
各包处理方式差异大:每个包对错误的处理方式各不相同,有些包可能会返回、包装或转换错误,这使得在跨包调用时很难确定一个函数到底如何处理错误。例如,一个函数可能在内部处理了错误并返回了一个自定义的错误类型,但调用者可能并不清楚这个错误类型的含义和处理方式,从而导致错误处理不当。
-
上下文丢失问题:随着错误在函数调用链中的传播,上下文信息往往会丢失。这使得上层函数在接收到错误时,很难了解错误发生的具体背景,只能得到一个模糊的
500 Internal Server Error
,而无法确定根本原因。例如,在一个 Web 应用中,一个数据库操作失败可能会导致一系列的错误传播,但最终在返回给用户时,用户只能看到一个笼统的服务器内部错误,无法得知是数据库连接问题、查询语句问题还是其他原因导致的错误。
(四)错误分类与监控的缺失
-
错误未分类导致重要问题被掩盖:错误没有按照严重程度或行为进行分类,这使得一些重要的问题可能被淹没在大量的噪声日志中,难以识别。例如,一个
context.Canceled
错误在某些情况下可能是正常的(如用户关闭浏览器标签),但在其他情况下可能表示请求取消是因为查询速度过慢等问题,由于没有分类,很难区分这些不同情况,从而影响对系统健康状况的判断。 -
无法有效监控错误:缺乏分类使得无法有效地监控错误的频率、严重程度或影响。开发者无法准确了解系统中各种错误的发生情况,难以针对性地进行优化和改进。例如,不知道哪些错误是经常发生的,哪些错误对系统性能影响较大,就无法确定优化的重点和优先级。
三、构建集中化、结构化错误处理框架
(一)设计决策的核心要点
-
集中化的错误声明:为了解决错误声明分散的问题,决定将所有错误代码集中在一个地方进行定义,并采用命名空间结构。这样可以提高错误的可组织性和可追踪性,使开发者能够清晰地了解整个项目中的错误体系。例如,定义
PRFL.USR.NOT_FOUND
表示用户未找到的错误,FLD.NOT_FOUND
表示流程文档未找到的错误,并且可以共享一个底层的基础代码DEPS.PG.NOT_FOUND
(表示在 PostgreSQL 中记录未找到),通过这种方式明确错误之间的关系,方便管理和维护。 -
结构化的错误代码格式:采用结构化的错误代码格式,确保错误信息清晰、一致。每个错误代码都有明确的含义,通过命名空间的划分,使得错误代码更具可读性和可扩展性。例如,在一个大型项目中,不同模块的错误可以通过命名空间进行区分,如
SVC.AUTH.ERROR
表示认证服务相关的错误,DB.QUERY.ERROR
表示数据库查询相关的错误,这样在查看错误信息时能够快速定位问题所在的模块。 -
标准化的错误创建与检查:定义了一个新的
Error
类型,所有错误都必须实现这个接口,同时提供了一套全面的辅助函数来创建和检查错误。这使得错误的创建和处理更加规范,避免了之前随意创建和处理错误的问题。例如,使用CODE.New()
来创建新的错误,使用IsErrorCode()
和IsErrorGroup()
等函数来检查错误类型,确保在整个项目中对错误的处理方式保持一致。 -
错误的分类与标记:通过为错误代码添加标签,对错误进行分类,以便通过日志和指标进行有效的监控。例如,可以为错误添加
SEVERITY_HIGH
(高严重度)、SEVERITY_MEDIUM
(中严重度)、SEVERITY_LOW
(低严重度)等标签,或者根据错误的类型添加DB_ERROR
(数据库错误)、NETWORK_ERROR
(网络错误)等标签,这样在监控系统中可以根据标签快速筛选和统计不同类型的错误,及时发现和解决重要问题。
(二)框架的核心组件与类型
-
核心包的功能划分:新的错误处理框架包含几个核心包,每个包都承担着特定的功能。
connectly.ai/go/pkgs/errors
包是主要的包,用于定义Error
类型和错误代码;errors/api
包用于将错误发送到前端或外部 API;errors/E
包是一个辅助包,通过点导入的方式提供方便的错误处理功能;testing
包则提供了用于测试命名空间错误的工具。这些包相互协作,共同构建了一个完整的错误处理体系。 -
Error
和Code
类型的定义与关系:Error
接口是对标准error
接口的扩展,新增了Code()
方法用于返回错误代码。Code
类型被实现为一个uint16
值,并具有相应的字符串表示。例如,DEPS.PG.NOT_FOUND
就是一个Code
类型的值,它通过New()
或Wrap()
方法可以创建或包装错误,并且可以携带上下文信息。Error
类型的实现确保了内部错误和第三方库错误之间的明确区分,同时也为迁移过程提供了便利,使得可以逐步将代码迁移到新的错误处理框架中。
(三)示例代码展示框架用法
-
创建和包装错误:首先,通过点导入
errors/E
包,可以方便地使用预定义的错误代码和类型。例如,使用PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
可以创建一个表示用户请求无效的错误。在包装错误时,可以使用CODE.Wrap()
方法,如dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found")
和usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
,这样可以将底层错误逐步包装成更具上下文信息的高层错误,方便在日志中查看错误的传播路径和详细信息。 -
添加错误上下文:可以使用
With()
方法为错误添加额外的键值对作为上下文信息。例如,usrErr := PRFL.USR.NOT_FOUND.With(l.UUID("user_id", req.UserID), l.String("flag", flag)).Wrap(ctx, dbErr, "user not found")
,其中l.UUID
和l.String
是来自logging/l
包的辅助函数,用于创建键值对。这些上下文信息在日志输出中可以提供更多的调试线索,帮助开发者快速定位问题。 -
不同类型的错误实现:框架目前定义了三种实现
Error
接口的类型:Error
、Error0
和VlError
(ApiError
用于迁移旧的 API 错误)。Error
是最基本的错误类型,包含了错误代码、消息、字段和堆栈跟踪等信息,并且通过私有方法_base()
确保了错误类型的安全性。Error0
是默认的错误类型,大多数错误代码会生成Error0
值,它包含一个base
结构和一个可选的子错误,用于提供常见的错误功能。VlError
用于处理验证错误,它可以包含多个子错误,并提供了方便的方法来处理验证相关的逻辑。例如,在用户注册功能中,如果用户名或密码验证失败,可以使用VlError
来收集和返回多个验证错误信息。
(四)声明新的错误代码
-
代码的声明方式与存储:错误代码通过定义结构体和常量的方式进行声明。例如,定义
var DEPS deps
,其中deps
结构体包含了与依赖相关的错误代码,如PG
结构体中的NOT_FOUND
、CONFLICT
等代码。这些代码被实现为uint16
值,并存储在一个CodeDesc
数组中,CodeDesc
结构体包含了代码的整数值、字符串表示、相关的 API 描述(如ErrorType
、ErrorCode
、HttpCode
等)。在声明新代码后,需要运行生成脚本来生成必要的辅助代码,确保代码的一致性和正确性。 -
标记外部可用代码:使用api-code标签来标记可用于外部 API 的错误代码。例如,PRFL.USR.NOT_FOUND被标记为外部代码,而PRFL.USR.REPO.NOT_FOUND为内部代码。只有标记为外部代码的错误才会在对外的 HTTP API 中以特定的格式返回,未标记的内部代码则会显示为通用的Internal Server Error,这样可以保护内部错误信息的安全性,同时提供合适的外部错误响应。
-
错误代码的映射关系:在 Protobuf 中通过枚举选项声明错误代码、错误类型与 gRPC/HTTP 代码之间的映射关系。例如,在error/type.proto和error/code.proto文件中,定义ERROR_TYPE_PERMISSION_DENIED和ERROR_CODE_DISABlED_ACCOUNT等错误类型和代码,并指定相应的 gRPC 代码、HTTP 代码、消息内容等。这样在处理错误时,可以根据映射关系将内部错误转换为合适的外部错误响应,确保客户端能够正确理解和处理错误信息。
(五)处理第三方错误与错误映射
-
将第三方错误转换为内部错误:在处理第三方包返回的错误时,需要根据具体情况将其转换为内部命名空间错误。例如,当处理数据库或外部 API 错误时,通过errors.Is()判断错误类型,然后使用相应的内部错误代码进行包装。如switch { case errors.Is(err, sql.ErrNoRows): return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found"); case errors.Is(err, context.DeadlineExceeded): return nil, PRFL.USR.TIMEOUT.Wrap(ctx, err, "query timeout"); default: return nil, PRFL.USR.UNKNOWN.Wrap(ctx, err, "unexpected error"); },这样可以将第三方错误统一纳入内部错误处理体系,提高错误处理的一致性。
-
使用辅助函数进行错误映射:框架提供了IsErrorCode()、IsErrorGroup()和MapError()等辅助函数来简化错误映射的代码编写。IsErrorCode()用于检查错误是否包含指定的代码,IsErrorGroup()用于判断错误是否属于特定的组,MapError()则可以方便地将内部错误代码映射为外部错误代码,并添加格式化参数和键值对。例如,user, err := queryUser(ctx, userReq); if err!= nil { return nil, MapError(ctx, err).Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user not found").KeepGroup(PRF.USR).Default(PRL.USR.UNKNOWN, "failed to query user"); },这段代码演示了如何使用MapError()函数根据不同的错误情况进行错误映射和处理,提高了代码的可读性和可维护性。
四、测试命名空间错误
框架提供了ΩxError等专门的测试助手,用于在测试中更轻松地编写和断言错误条件。例如ΩxError(err).Contains("not found")可以检查错误是否包含指定消息。
通过构建这个结构化和集中化的错误处理框架,我们解决了之前在 Go 语言错误处理中遇到的诸多问题,提高了代码的可维护性和错误处理的效率。希望这个框架的介绍和设计思路能为其他开发者提供一些参考和启发。
科技脉搏,每日跳动。
与敖行客 Allthinker一起,创造属于开发者的多彩世界。
- 智慧链接 思想协作 -