Conversation
…body" field tag
## Problem
A strict route can only receive a JSON object request body. A top-level
JSON array is either rejected with CodeInvalidParameter (under the JSON
content type) or, under the other content types, silently ignored with
all the fields left as zero values and even some bogus parameters parsed
out of the body by the form parameters decoding. There is also no way to
describe such a request body in the generated OpenAPI document.
## Change
The whole request body can now be received by a field tagged with
`in:"body"`, which reuses the existing `in` tag that already declares the
parameter location for path/query/header/cookie:
type BatchChatReq struct {
g.Meta `mime:"application/json" method:"post" path:"/batch/chat"`
Messages []ChatMessage `json:"messages" in:"body"`
}
The client then posts `[{"role":"user"}]` as the request body. Only the
slice fields are supported (a pointer to slice included), which is
validated at the route registering time; a fixed-size array can use a
slice with a length validation rule instead. A request parameter named
exactly after the field tag is ignored for that field, so the request
body always takes precedence over the parameters of the same name. The
tag works as the opt-in signal that keeps every other endpoint behaving
exactly as before, and the request body of the generated document is the
schema of that field instead of a wrapping object.
Member
|
数组体绑定这条路径看起来是对的,生成出来的 OpenAPI 和运行时还对不上。 POST/PUT/PATCH 上,没写 切片字段上的 |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
对 #4618 所要解决的问题的另一种实现方式
规范路由(标准路由)的请求结构体只能接收 JSON 对象体:
parseBody把 body 塞进map[string]any,顶层数组必然进不来。想批量提交[{"role":"user"},{"role":"assistant"}]这种数组体,今天两条路都不通:Content-Type: application/json+ 数组体CodeInvalidParametergstr.Parse兜底:字段全零,而且会切出脏参数第二条尤其坑:
gstr.Parse按&和=切分,所以[{"url":"https://x.com?a=1&b=2"}]会产出一个叫b的假请求参数——请求成功了,但b从哪来的没人说得清。文档侧同样表达不了:
goai只能生成对象形态的请求体,生成不出type: array+items: $ref。为什么不走
type:"array"+ 「第一个切片字段」社区 PR #4618 用
g.Meta的type:"array"标签解决,把数组绑到请求结构体的第一个切片字段。这个做法有三个问题,最终没有采用:一、靠字段声明顺序猜接收方,会静默走错。 「取第一个切片字段」成立,仅仅因为
Items恰好写在Tags前面。把Tags往上一行挪,行为就变了;用户往前面插一个切片字段,接收方就悄悄换了——不报错、不打日志。二、
type这个 tag 名已经有主。 字段级的type:"file"是既有的文件上传约定(Files []*ghttp.UploadFile配type:"file")。type:"array"表示「请求体形状」,同一个词两套语义;而且上传字段本身也是切片,照样会被「第一个切片字段」的规则选中。三、带了一个未声明的破坏性变更。 #4618 让非 JSON content-type 下的数组体,从「200 + 字段全零」变成参数错误。方向是对的(静默给零值更坑),但 PR 描述里没提。
改动
in:"body"—— 复用 GF 已有的「参数来源」标签in,让字段自己声明它接收整个请求体:客户端直接提交
[{"role":"user"},{"role":"assistant"}],绑定到Messages;结构体里的其它字段(路径参数、query 等)照旧合并,互不干扰。in不是新标签:gtag.In本来就存在,goai里已经实现path/query/header/cookie四个取值,文档里教的写法就是in:"header"指定参数位置。这里只是把body补进同一套语义。范围:
in:"body"只能写在切片字段上,指向切片的指针也算;其它类型注册期直接报错,并且按类型给出路——定长数组提示换成切片 + 长度校验,结构体 /map/interface{}提示「对象体由请求结构体的普通字段接收,不需要这个标签」。另外,这个标签只对严格路由(
func(ctx, req) (res, err))有意义——func(*ghttp.Request)那种 handler 没有请求结构体,标签无从生效。两条边界是有意划的:
[N]T)不收。 实践中定长都用切片加长度校验表达(v:"length:N"),框架不必为此再支持一种形态;而且收数组就得连带修gconv的数组转换——切片转定长数组这条路本来就不通(实测请求会以reflect.MakeSlice of non-slice type失败),那是与这个特性无关的既有缺陷,不宜顺路拖进来。{"a":1},里面的键会当请求参数用、绑到同名字段上,「整个对象体」本来就有常规做法。所以这不是「对象体还没做」,是不需要;真要做的其实是另一类需求——对象体原样透传(比如存原始 JSON、做签名校验),那个留给后面。为什么叫
body,而不是array_json_body之类最容易想到的质疑是:只支持 JSON 数组,叫
body是不是太泛、会误导?不泛,四个理由:in是「参数位置」枚举,取值都应该是位置词。 现有path/query/header/cookie全是「这个参数从哪来」,不带形状和格式;body正是这一列里缺的第五个位置。叫array_json_body就把「位置 + 形状 + 格式」三样塞进同一个值,以后支持对象透传、表单体,难道叫object_json_body、form_body?名字只会越起越多。Messages []ChatMessage一眼就知道是数组、元素是什么;标签再说一遍是重复,而且类型不匹配时注册期就会报错,不靠名字提醒。body是行业原词。 Swagger/OpenAPI 2.0 的参数就写in: body,表示「整个请求体就是这个参数」,它收对象也收数组,名字里从来没有形状。GF 现有四个取值也是照这套词汇来的。in取值都是一个全小写单词;array_json_body是 snake_case,array-body的连字符又和valid规则名(max-length)那套风格撞车,读起来像规则而不是位置。真要做「对象体原样透传」那天,
in:"body"配上 struct /map字段就够,一个词不加、一行不删;直译名则要再起名或废弃老的。至于「会不会让人误解成什么都收」:写错类型是注册期报错、服务起不来,请求体和标签不匹配(对象体 / 表单体 / multipart)是明确报CodeInvalidParameter,都不会静默。实现
net/ghttp:注册期识别、校验、把名字准备好handlerFuncInfo加两个字段:ReqBodyFieldName(字段名,请求期绑数组用)和ReqBodyFieldTagName(字段的 tag 名,请求期删同名参数用)。checkAndCreateFuncInfo在已有的ReqStructFields之后扫一遍:map会带上对应的改法提示,见上面的「范围」)。错误在注册 handler 的时候就会报出来:
checkAndCreateFuncInfo的错误在BindHandler/group.Bind/ 对象绑定三条路上都是Logger().Fatal(...),服务直接起不来,而不是等到请求期才发现绑不上。多写一个in:"body"、写到string字段上、写到定长数组上,都会在启动时明确告知。两个名字在注册期解析一次,请求期只读:绑数组体用字段名(数组体就写在字段名这个键上),删同名参数用 tag 名。这样既省掉每请求一次字段遍历,也不会出现「绑定用注册期的结果、删除按请求期重算」两套结果(标签里写了变量时,两边会不一样)。
存的是字段名而不是
reflect.StructField.Index:ReqStructFields由RecursiveOptionEmbedded产出,内嵌结构体里的字段,它的Index是相对那个内嵌结构体算的,FieldByIndex会取错;存名字交给gconv按名字匹配,内嵌字段的「提升」天然就是对的。net/ghttp:解析(parseBody)parseBody加一段数组检测,位置在原有 JSON 处理之前:「是不是数组接口」由
isArrayRequestBodyExpected()判断,读的是当前路由 handler 上注册期缓存的字段名——这就是由声明决定的开关:没写标签的接口一行代码都不受影响,不用把全框架的解析一起放松。检测不看 content-type(和对象体在非 JSON content-type 下的宽松处理一致):只要以
[开头、]结尾就试着解析,所以text/plain、表单类型提交过来的数组体都能收。解析失败时再看 content-type:JSON 体报文法错误,其它体回落到原来的参数解码。两个容易忽略的点:数组分支必须判在
bodyMap默认解码之前(否则数组体先被gstr.Parse变成一堆脏参数);parseBody开头要重置bodyMap/bodyArray,否则中间件改 body 后调ReloadParam会拿到上一次的陈旧结果(数组体和对象体互相残留)。表单解码之前也要先认数组。
GetRequestMap()的顺序是parseForm()→parseBody(),表单类型的请求体会先被切成参数,等parseBody认出数组已经晚了。实测:application/x-www-form-urlencoded提交[{"name":"https://x.com?a=1&id=777&b=2"}],数组绑对了,但 URL 里的&id=777也变成了参数id,把结构体里的同名字段顶成了 777。修法是在
parseForm()的非 multipart 分支开头先问一句「这个接口是不是数组接口」(isArrayRequestBodyExpected()),是的话直接试解析数组、命中就清掉formMap返回,不再走标准库的表单解码。好处有两个:数组体不会被切碎;ReloadParam之后残留的formMap也顺手清掉。text/plain那种本来就不走表单分支的请求一直是干净的,可以当对照。net/ghttp:绑定(doGetRequestStruct)三件事:
一、先删掉跟字段 tag 同名的请求参数。 数组体写进
data[字段名],而gconv找字段的顺序是「tag 名精确匹配 → 字段名精确匹配 → 模糊匹配」——tag 名排在字段名前面,所以只要请求参数里有一个正好等于 tag 名的键(json:"messages"对应?messages=...),它就会先被绑上去,把数组体顶掉。没有这一句时实测:?ids=99加数组体[1,2,3],拿到的是[99];参数是标量、字段是结构体切片时更糟,gconv转换直接 panic(500)。删的范围只限「正好等于 tag 名」这一个键,不做模糊匹配。最初写的是「忽略大小写和
-_[]之类的符号」的模糊比较,会误伤兄弟字段:结构体里同时有Items []int(配json:"items" in:"body")和ItemS string(配json:"item_s")时,?item_s=hello的hello会被删掉——item_s去掉符号正好等于Items。而且模糊匹配根本抢不走数组体:数组体写在字段名的键上,精确匹配到它,就轮不到模糊匹配了。所以只删 tag 名这一个键,够用,也不误伤。二、分别处理数组体、非数组体、没有 body 三种情况。
data[字段名],数组体优先;CodeInvalidParameter(不静默留空切片)。multipart 的解析结果在MultipartForm里、不在bodyMap里,所以要单独判一下——否则它会被当成「没有 body」放过去,字段是 nil、multipart 里的字段却当参数合了进来。这样「普通字段的」「只有文件的」「空表单」三种 multipart 形态都会被拒,真正没有 body 的请求照旧放行;data[字段名] = nil。一句nil就能挡住所有想填这个字段的参数:gconv匹配字段的顺序是「tag 名 → 字段名 → 模糊」,nil 在字段名这一档被精确命中,之后的模糊匹配根本不会跑;字段因此稳定是零值,[]struct字段收到标量参数时原来会 panic(500)也不会再发生。[]与无 body 的区别由此保留:前者是空切片,后者是 nil。三、
gconv.Struct照常转换,其它字段(路径参数、query、header/cookie、默认值)完全不受影响。net/goai:文档侧五处改动,缺一处都不行:
一、
ParameterInBody常量 + 参数循环放行。parameter.In是从 tag 映射进去的,newParameterRefWithStructMethod的白名单里没有body,会落到invalid tag value "body" for In的报错;而oai.Add的错误在initOpenApi里是Logger().Fatalf(...)。不加这个 case,不是文档难看,是服务起不来。二、请求体 schema 改用该字段的 schema。 必须绕开原来的
getRequestSchemaRef:它返回指向请求结构体组件的$ref,那个组件是{type: object, properties: {messages: [...]}}——Swagger 上会显示成对象{"messages":[...]},正是要避免的包装形态。newSchemaRefWithGolangType对切片直接产出{type: array, items: {$ref: ...}},并把元素类型注册进Components.Schemas,不需要另写数组 schema 生成器。字段上的dc顺手拿来做requestBody.description(否则它会凭空消失:该字段既不产出 parameter,也不在请求体 schema 里)。三、生成数组 schema 前先解引用指针。
*[]ChatMessage这种字段,newSchemaRefWithGolangType原来会先剥出array,再对Elem()递归——于是文档里变成数组套数组,而运行期是正常的一维数组。加一次解引用后,*[]T和[]T的文档一致。四、
removeOperationDuplicatedProperties补 nil 守卫。 这个函数在addPath里无条件调用,会直接解引用Schema.Value.Properties;而数组 schema 的Properties就是 nil(属性表是对象才有的)。不补守卫,带路径参数的数组接口一生成文档就 panic。顺带也挡住了Schema.Value == nil——原来的Value != nil守卫在它后面才出现,是既有隐患。五、body 字段和 HTTP 注册用同一套扫描范围。 HTTP 注册期扫
ReqStructFields用的是RecursiveOptionEmbedded,而文档侧原来是在参数列表(RecursiveOptionEmbeddedNoTag)里顺手找 body 字段——两者对带标签的内嵌结构体结论不同:把 body 字段放在基类里、用带标签的方式内嵌进来(比如基类字段上写json:"base")时,运行期能正常收到数组(实测通过),文档却给出指向请求结构体组件的$ref,又变回要避免的包裹对象。修法是给 body 字段单独扫一份RecursiveOptionEmbedded的字段列表,参数列表的扫描方式保持不变(否则会顺带改掉其它参数的文档行为)。行为等价性
下面的「普通接口」指没声明
in:"body"的接口。CodeInvalidParametergstr.Parse兜底in取值(path/query/header/cookie)in:"body"字段[]空数组CodeInvalidParameter,不静默留空切片map/interface{}字段失败模式都是显式的,没有静默降级:
in:"body"写多了、写到非切片字段上 → 注册期报错,进程起不来;CodeInvalidParameter;Parse JSON body failed。另外提醒一句:畸形 multipart 体(boundary 不对)仍然是框架既有的 panic → 500,任何 multipart 接口都一样,与本次改动无关。
钉住这些行为的用例:
Test_Request_JsonArrayBody(正常路径;另含非 JSON content-type 也能收)Test_Request_JsonArrayBody_BodyTakesPrecedenceOverQuery(同名参数顶不掉数组体)Test_Request_JsonArrayBody_Pointer(指向切片的指针字段)Test_Request_JsonArrayBody_WithPathParam(路径参数与数组体共存)Test_Request_JsonArrayBody_EmptyAndAbsent([]是空切片、无 body 是 nil;无 body 时同名及大小写/符号变体参数都填不进来,标量参数也不再 panic)Test_Request_JsonArrayBody_FormContentTypeNoPollution(表单类型的数组体照收且不切出脏参数;普通表单体仍报参数错误)Test_Request_JsonArrayBody_MultipartRejected(普通字段、只有文件、空表单三种 multipart 形态都报参数错误)Test_Request_JsonArrayBody_Nested(元素含嵌套结构体与嵌套切片)Test_Request_JsonArrayBody_ObjectBodyRejected(对象体必须报参数错误)Test_Request_JsonArrayBody_PlainEndpointUnchanged(普通接口的数组体仍是原错误、普通对象体照常工作)Test_HandlerFuncInfo_CheckAndCreateReqBodyField(注册期校验:无标签 / 切片 / 指向切片的指针 / 没有 tag 名时回落字段名 / 非切片 / 定长数组 / 结构体字段 / 多标签;定长数组与结构体两种报错文案里的出路提示也一并钉住)TestOpenApiV3_ArrayRequestBody(数组 schema、元素注册、字段不产出 parameter、请求体带 path 参数时不被删、dc进 description、*[]T是一维数组、带标签/无标签内嵌结构体里的 body 字段都是数组体)验证方式:Linux 容器(
golang:1.25)里跑net/ghttp里请求与路由相关的那批用例(-run "Test_Request|Test_Params|Test_Router|Test_RouteServe|Test_Middleware|Test_Hook|Test_Group|Test_OpenApi")、net/goai、util/gconv、encoding/gjson,全绿。同名参数、兄弟字段、无 tag 名、定长数组、无 body 时的各种同名变体、表单类型的数组体、multipart 三种形态,另用临时写的对照测试(未提交)比较过改动前后的实际响应。兼容性
无破坏性变更。
in:"body"是此前不可用的取值:goai的白名单不认它,配了 OpenAPI 文档的接口在启动生成文档时会报invalid tag value "body" for In并退出;没配文档的接口,这个标签被完全忽略。没有任何现存用法依赖它。唯一新增的语义:声明了该标签的字段,取值只来自请求体——与该字段同名(含大小写、符号变体)的请求参数会被忽略掉。这正是这个特性的契约。
性能与内存影响
一句话:没写
in:"body"的接口,实测没有变慢、也没有多分配内存;写了的接口,多出来的大头是「解析数组本身」(跟解析对象体是同一套代码),我们自己加的只有几十纳秒;唯一多出来的一次内存分配,出现在「声明了数组字段、但请求没带 body」时把字段置零那一步。先看普通接口(改动前
d924ae001→ 改动后)每个样本跑 30000 次,改动前后两份代码在同一个容器里轮流跑、各跑 3 轮,取中位数:
时间那列改动后全部略快一点,但这属于测量抖动(两份代码编译出来的程序布局不同、机器也有噪声,±5~15% 很正常),不能当成「变快了」。真正说明问题的是分配次数和内存字节数:一项一项完全相同——普通接口的请求路径上,我们没有多分配任何内存。
再看用上这个特性的接口(改动后)
[]any,3 个元素就要建 3 个 map),不是这次新加的代码。parseForm提前返回,整套表单解码都不做了(原来那套要把 body 切碎、再拼成参数 map)。原因很具体:置零要往已经拼好的参数 map 里写一个
nil,map 就得新开一个桶(bucket)。这里有个可以优化、这次没做的办法:注册路由的时候就把「怎么把这个字段置零」算好、存成一个函数(gconv内部就是这么干的),请求时直接调用、不碰 map,这一次分配就省了。量级是 0.5µs / 1 次分配,真要抠再抠。这些开销分别落在哪
handlerFuncInfo多两个字符串(每条路由 32 字节);开了 OpenAPI 的进程在生成文档时,每条路由多扫一遍字段(只在启动时)。parseBody、parseForm、doGetRequestStruct各一处),只有几纳秒,实测淹没在噪声里。Request每个请求对象多 24 字节(bodyArray的切片头);因为落在同一个内存档位,实测每请求的内存字节数没变化。量这些数的方法:Linux 容器(
golang:1.25,12 核),改动前后的代码各放一份、轮流跑多轮取中位数,每个样本 30000~50000 次;基准用的是仓库里现成的标准 handler 解析基准(net/ghttp/ghttp_z_bench_parse_test.go,来自fix/parameter-parsing分支),另补了「数组体」「没带 body」两个场景,量完就删了、没有提交。改动文件
net/ghttp/ghttp.gohandlerFuncInfo加ReqBodyFieldName/ReqBodyFieldTagNamenet/ghttp/ghttp_server_service_handler.gocheckAndCreateReqBodyField:注册期扫描、校验(按类型给改法提示)、解析两个名字net/ghttp/ghttp_request.goRequest加bodyArraynet/ghttp/ghttp_request_param.goparseBody数组分支;isArrayRequestBodyExpected;parseForm的表单解码前数组检测net/ghttp/ghttp_request_param_request.godoGetRequestStruct:删同名参数、绑数组体、拦对象体与 multipart、无 body 时把字段归零net/goai/goai.goParameterInBody常量net/goai/goai_parameter_ref.goin:"body"字段不产出 parameternet/goai/goai_path.gofindBodyField(body 字段按与 HTTP 注册相同的范围单独扫一份);Propertiesnil 守卫net/goai/goai_shema_ref.go*[]T不再出数组套数组net/ghttp/ghttp_z_unit_feature_request_array_body_test.gonet/ghttp/ghttp_z_unit_internal_test.gonet/goai/goai_z_unit_test.goTestOpenApiV3_ArrayRequestBody