自动化业务状态码设计
不管我们做的是什么业务的开发,迭代的服务有多底层,都会使用到业务状态码。它是何其简单,但是用的过程也会遇到麻烦,比如我们要定义一个状态码,要考虑它的值是要定义多少,以及是否唯一,还有我在开发过程用了这个数值,会不会跟其他开发用到同一个,导致合并时候才发现有冲突。
本文将介绍我们在使用业务状态码过程中,遇到了哪些问题,最后是如何设计一个自动化业务状态码工具,使得我们可以高效专注开发。
业务状态码是怎样的
业务状态码是在业务应用程序中使用的一种表示操作结果或状态的代码。它们提供了一种标准化的方式来指示请求的处理结果,以便应用程序和用户能够理解和处理这些结果。
从示例图是接口响应给调用端的内容。从图二/三可以看出,不同的错误信息会对应不同的状态码,还有辅助信息用于解决遇到的问题,这里主要是给开发看的。
业务状态码可以用于表示成功、错误、警告、信息等各种类型的状态。以 C 端账号登录场景为例,我们会将失败信息展示给玩家看。
从这两张图可以看出,同个接口下,玩家信息错误以及验证次数过多情况下,我们会提示不同的信息/状态码。在这里错误信息足够明显提示给玩家了,状态码显得不那么重要,那是不是说明没用了,其实不然,再看下下面的图。
从这两张图可以看出,提示的内容是一样的,但是对应了不同的状态码,而内容的信息是模糊的,没有指明错误原因,只是引导。为什么要这么做呢?对于登录场景来说,经常会受到黑产攻击,我们要与其对抗,自然就是制定许多的规则来防范,同时不能把规则暴露出来,总不能说是 xxx 原因导致的吧。那如果真的存在误封了我们要给玩家解决,我们就可以通过状态码快速定位到原因。
我们可以总结下,组成业务状态码的核心的两个就是:状态码和描述内容
状态码是唯一的;
描述内容不是唯一的,可以是模糊的,但是一定要具备引导作用
发展过程
我们已经知道好的业务状态码应该是什么样子了,那么落到实际开发要怎么设计呢?
先看下雏型是怎样的,有哪些问题,怎么发展
版本 v1:
问题:
业务状态码是定义在接口里的,单看一个接口,没啥问题。但我们项目是存在多个接口,如果每个接口都复制这么一套内容,既低效又影响接口维护。并且容易出现状态码定义的冲突,无法保障唯一性。
问题本质就是没统一维护,那么我们可以独立一个工具库,开发在里面定义状态码,就可以保障唯一
版本 v2:
独立库后,我们只需要在这里面去定义状态码就行了,代码规范是有序递增的,所以能清晰知道我们新增的码大小是多少,保障码是唯一的。
但是呢,这个引入其它的问题,那就是效率和体验变差
需要定义变量(2 次)、码(需要口算)、内容。使用的时候要写变量名(CommonConstant::COMMON_FAILED)和方法(CommonConstant::getMessage($code)),不够爽;
开发对业务状态码的变更,都只是在本地的分支,相当于 mysql 事务的读已提交级别。所以当有多个项目开发就很容易造成合并冲突,尤其是一些项目开发周期久就很容易出现;
对于码的使用缺少隔离,比如某个场景下,前端会同时调用 A 接口和 B 接口,这两接口同时使用了
`CommonConstant::
COMMON_FAILED
`
,那你看到提示`操作失败(10001)`
就不知道是哪个接口报的错误信息;
暂时无法在飞书文档外展示此内容
对于包依赖管理的项目,比如 GO,则需要作为包引入,需要频繁更改版本号,甚至忘记改回主版本号,而提交到 master;
暂时无法在飞书文档外展示此内容
使用代码作为存储介质,被局限在某个语言上,如果使用 Go 开发就无法使用,要独立维护多套业务状态码。
方案设计
开发的步骤可以分解为
定义错误内容,比如'参数错误';
编写业务状态码
确定状态码大小,比如 101;
写代码,将错误内容和码关联起来
使用状态码
先看看效果
暂时无法在飞书文档外展示此内容
定义错误内容
当我们开发过程,有个异常需要抛出时候,就是业务状态码出场的时候了。比如说签名验证不通过了,要返回一个签名错误 Error;触发 IP 风控了,也要返回 Error。
那这个信息要写在哪呢?肯定是我们当下开发的项目里去定义最方便了,如果像前面放到一个公共库就会带来诸多效率问题。
暂时无法在飞书文档外展示此内容
示例中,我们是通过定义参数变量内容。变量是实际发生的 Err,而内容是给到玩家看到的。前面我们也提到过,错误内容可以是明确的,也可以是模糊的,取决于你的应用场景和安全考虑。
编写业务状态码
确定状态码大小
内容已经有了,那就需要知道它对应的状态码是多少了。首先状态码一个明显的特征就是唯一性,这也是容易造成码冲突的原因。为什么会有这个问题呢,其实就是我们只能看到有限范围内的业务状态码,只能在一个小集合里做判断,所以这个判断应该交给一个可以看到所有业务状态码的角色来做,由它来决定码应该是多大,并且是一个提交状态,任何空间、任何时间下都是唯一的。那就需要一个中心服务来承载,提供新增码以及修改内容的能力。
暂时无法在飞书文档外展示此内容
在唯一性的基础上,我们还需要做到状态码具备辨识度。
怎么理解这个辨识度呢,就是我们看到这个码就能知道,它是属于哪个服务的,或者不属于哪个服务的。为什么需要这个呢,因为在一些复杂的业务下,你看到的可能只是一个简单的按钮,但是背后触发的可能是一堆的服务,维护服务的开发也属于不同的人,那么当看到一个状态码,如果能知道它属于哪个服务,就可以节省很多排查时间。
暂时无法在飞书文档外展示此内容
那怎么提高辨识度呢,我们可以划分号段(或者说空间 ID),然后状态码由两部分组成,号段+自增数。比如说 A 服务分配的号段是 10,一个服务可以有 1000 个状态码,也就是 A 服务的状态码范围是 10000~10999。
这样的话,我作为登录服务的维护者,自己项目的号段是 19xxxx,即使看到图二这样模糊的提示,通过状态码知道是依赖的其它服务返回的提示(放假可以愉快地在外面玩耍了)。当然记不住也没关系,我们的码已经存储在数据库里了,使用后台查下就知道是什么服务报的了,不用去翻代码了。
"写代码",将错误内容和码关联起来
我们知道,业务状态要能用,还得实实在在的被写在项目里,也就是硬编码。既然我们说要解决前面效率低的问题,那么硬编码的人肯定不能是我们自己了,要交给程序自己去执行,我们只要在需要的时候点下按钮执行就好了。
从前面视频可以看出,我们做的事情就是在需要编码`err`
的情况下,在code.go
里写下我们的错误信息,然后点下 IDE 自带的便捷执行按钮,就会启动程序进行代码分析,从服务器拿到对应的码,最后自动生成code.gen.go
,将码和错误信息关联起来,同时生成一个方法方便我们直接使用。
暂时无法在飞书文档外展示此内容
代码分析
这一步其实就是把`code.go`
文件的内容读取出来,识别出变量名以及其值。那既然要进行分析识别,就会有一个判断标准,在这里则是要求是常量定义,格式为`TypeMsgXxx="Abc"`
,这样程序就能提取出状态码标识`Xxx`
和值`Abc`
。
提交代码信息
这一步其实就是把从文件提取出的状态码信息打包成对应的格式,提交给服务换取对应的状态码大小。前面提到状态码任何空间、任何时间下都是唯一的,如果只是提交分析的状态码信息是不足以做到的,因为你没法保障其它项目就不会写一样的内容。那就需要有一个唯一的标识,在这里使用的是项目的名称。以 github 为例,一个项目的账号名+项目名称就是唯一的,比如`
https://github.com/wsqun/go-delay-queue
`
,我们可以用`wsqun + go-delay-queue`
作为唯一标识 ID 一起提交给服务,那么服务就能根据 ID 进行区段分配,找到 ID 下的所有状态码,判断该区段分配的下一个状态码是多大了,最后服务会返回所有关联信息(变量名:状态内容:状态码)。
生成代码
这一步就是把从服务拿到的关联信息,转换成代码文件。那么首先我们要决定要生成的代码长什么样子,也就是使用场景有哪些。这里考虑的主要两点:
可以拿到业务状态码的值,所以会生成变量值
少写代码,直接返回对应的 Err,所以会生成错误信息对应的方法
可以简单得到一个模板
那么接下来我们就是把服务拿到的关联信息(变量名:状态内容:状态码)进行循环生成对应的常量和方法。在这里介绍两种生成代码的方式:
抽象语法树
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
我们写的代码,需要经过编译才能跑,编译的过程就涉及(源代码 -> 词法分析 -> 语法分析 -> 语义分析 -> 机器代码生成/优化),词法分析可以简单看作我们前面做的代码分析,把代码内容拆成一个个 token,然后语法分析就会把 token 组成一颗语法树,语义分析就是检查这棵树的语法有没问题(例如类型不匹配,未声明的变量等等)。试想下如果我们可以直接构造语法树,是不是就可以反过来生成我们的代码了(语法树 -> [ 语义分析 -> 语法分析-> 词法分析] -> 源代码)。
而 Go 就提供了`ast`
包给我们使用,可以自己构建语法树。下面这个代码片段就是生成`T
ypeCodeCallClientFail
= 1000`
变量代码,然后将我们拿到的数据进行循环添加到语法树就可以了。
模板生成
当然并不是所有语言都会提供`ast`
工具让我们生成语法树来反向生成代码,我们可以使用相对通用的方法,就是直接生成符合语法的代码内容,然后写到一个文件里。
比如我们定义一个模板,然后代码循环数据得到一个字符串`
TypeCodeCallClientFail
= 1000 \n
TypeCodeCallSvcFail
= 1001`
,然后替换掉模板的?,生成一个文件即可。
这两种方式各有优缺点,AST 能够做语法检查,准确生成代码,但是学习成本高;模板生成则简单灵活,但生成代码可能有语法问题。选择哪种方法主要取决于具体需求,当然如果你觉得这两种还要写代码太麻烦了,不想写,也可以直接丢给打工人 GPT,让它直接生成也是可以😄。
使用状态码
至此我们已经生成了业务状态码,只要直接调用即可。但是这仅仅停留在用的地步,我们虽然把状态码返回给用户看了,用户反馈给了我们,是能快速定位到出问题的地方,但是为什么出问题不一定知道,因为有些问题只有触发某个边界条件才会暴露出来,也就是说 1 千个用户,可能就 1 个有问题。
这种情况怎么出现的呢,其实就是个别情况下用户的数据比较特殊,所以要快速解决问题,就得把用户的数据保留下来,也就是问题现场保留。因此示例中调用返回`kit.PrivateNewInstance()`
的方法,并不是简单的返回一个业务状态码错误信息,而是一个 Err 的工具对象,可以额外记录现场信息。
现场保留的内容
记录代码行数,以 Go 为例我们可以获取堆栈信息
`_, fileNd, lineNd, _ := runtime.Caller({n})`
,PHP 则可以使用`new Exception(xxx,xxx)`
,Ex 对象记录了比较详细堆栈;设置当前方法入参 / 触发异常的数据
设置异常信息
最后可以在接口统一返回的代码处,将 Err 的信息都记录到日志里。
如果业务比较复杂,不介意体验的话,还可以把当前请求的 ID 返回给玩家显示,这样就能快速查到现场保留的日志
监控
当错误发生时,我们是能知道的,并且是能监控到的。既然我们的业务状态是唯一的,那就可以做一个统一的、全局的业务状态码监控,这样就能监控到异常,通过趋势判断是否有问题,并且能定位到是什么服务报问题。
最后
本文主要介绍了业务状态码的设计和使用,提供了一个解决方案。当然这并不能完全适应所有场景,比如这里只有一种提示语言(中文),如果业务是应用在不同国家和地区的,就需要能够自动使用不同国家地区的语言(英文、韩语、日语等)。
最重要是解决本文提到的核心问题,其它的依据自己的使用场景再稍作改造即可。
版权声明: 本文为 InfoQ 作者【三七互娱后端技术团队】的原创文章。
原文链接:【http://xie.infoq.cn/article/951cbd2342f3b1bc15886446f】。文章转载请联系作者。
评论