最近又在好几个技术群里看到有人问“FeignClient 参数传 body 应该怎么写”,一问就是报错截图:不是required request body is missing,就是failed to deserialize the json body into the target type,还有人干脆收到一整个 HTML 错误页。说实话,这个问题看起来简单,实际上坑挺多的,尤其是刚接触 Spring Cloud 的同事,很容易在注解、参数类型、请求格式上绕晕。
这篇就把 FeignClient 传 body 这件事彻底讲清楚。我会从 Feign 处理请求体的底层逻辑讲起,再给几种实际可用的写法,然后带一个完整的前后端调用示例,最后把常见报错的排查思路整理出来。适合正在做微服务接口对接、或者被 Feign 各种参数问题折磨过的 Java 开发朋友,看完基本能直接抄作业。
1. 先理清 Feign 传 body 的底层逻辑
1.1 一个接口为什么只能有一个 @RequestBody
先说很多人报错的根源:Spring MVC 和 Feign 对方法参数的处理方式,其实和普通 Java 方法完全不一样。普通方法里你爱写几个参数写几个,但 HTTP 请求只有一个 body。Spring 解析 Controller 或者 Feign Client 方法的时候,遇到@RequestBody注解的参数,就会把请求体反序列化绑到这个参数上。
如果你在同一个方法里写了两个@RequestBody,Spring 启动时就会直接报错,提示 body 只能被绑定一次。就算你只写了一个,但调用方那边没有把参数序列化进 body,服务端解析时拿不到内容,就会出现required request body is missing。很多刚接触 Feign 的同事以为“我传了个对象过去,Spring 会自动帮我处理”,但实际上注解没写对、Content-Type 不对、参数类型不匹配,都会让 body 传不过去。
1.2 Feign 是怎么把对象变成请求体的
Feign 本身是一个声明式 HTTP 客户端,核心逻辑就是根据接口方法上的注解,把方法调用转换成一个 HTTP 请求。在 Spring Cloud OpenFeign 里,默认使用的编码器是SpringEncoder。它会检查方法参数上有没有@RequestBody,如果有,就会用 Spring 容器里的HttpMessageConverter把参数对象序列化成 JSON,写入请求体。
这里有个容易被忽略的点:Feign 对@RequestBody的处理依赖 Content-Type。正常走默认配置时,SpringEncoder会自动设置Content-Type: application/json,服务端@RequestBody也按 JSON 解析,两边就通了。但如果有人手动塞了 Header,或者自定义了 Encoder,把 Content-Type 改成了别的,服务端就可能解析失败或者收到空 body。
另外还有一点:Feign 方法参数上如果没有加任何注解,比如直接写void create(User user),那SpringEncoder不会把它当 body 处理,而是把它当作 query 参数拼到 URL 上。这也是很多人明明传了对象、服务端却收不到 body 的原因之一。
1.3 不是所有请求都能带 body:GET 的边界
HTTP 协议本身没有禁止 GET 请求带 body,但实际用起来非常尴尬:很多框架、网关、浏览器实现默认不会读取 GET 的 body,或者干脆忽略掉。Spring MVC 虽然某些版本允许 GET +@RequestBody,但 Feign 在生成 GET 请求时,默认不会把请求体写入,配置复杂不说,还会有各种兼容问题。
所以实践中建议:只要是需要传 body 的接口,一律用 POST、PUT、PATCH,别去挑战 GET 传 body。这不是 Feign 的锅,是 HTTP 语义和中间件生态决定的。真要传少量参数,就用@RequestParam拼 query;要传结构化数据,就老老实实 POST +@RequestBody。
2. 传 body 的几种主流写法
2.1 最推荐:@RequestBody + DTO 对象
先给最常见的写法。比如服务端有个创建用户的接口,接收 JSON body,Feign Client 这边可以直接定义一个 DTO,然后用@RequestBody标注:
@FeignClient(name = "user-service", url = "${user.service.url}") public interface UserClient { @PostMapping("/user/create") Result<User> createUser(@RequestBody UserCreateDTO dto); }public class UserCreateDTO { private String name; private Integer age; private String email; // 省略 getter / setter }关键是 DTO 的字段名要和服务端接收的 JSON 字段名保持一致。比如服务端 JSON 用的name,你 DTO 里写userName,那序列化出来的 JSON 就是{"userName":"xx"},服务端按name取就是null。
这种方式的好处是类型安全、可读性好、方便加校验,也是我平时用得最多的。字段变多了直接在 DTO 上加属性就行,不会像 Map 那样越写越乱。
2.2 图省事直接传 Map:能行但别长期用
有的同事喜欢这么写:
@PostMapping("/user/create") Result<User> createUser(@RequestBody Map<String, Object> params);调用的时候往里塞几个 key,确实灵活。比如字段是动态的、接口参数不固定的时候,Map 可以少建几个类。但我不建议把它作为默认方案,原因有三个:
第一,Map 丢掉了类型信息。value 是 Integer 还是 String,Jackson 序列化时可能会猜错,比如数字被序列化成字符串,服务端强转就报错。
第二,Map 没法做参数校验和文档化。别人看接口签名只知道你传了个 Map,不知道具体要哪些字段,维护成本很高。
第三,Map 里如果塞了null,默认情况下 Jackson 也会把null序列化进去,服务端如果没做容错,可能触发 NPE 或者反序列化异常。
所以 Map 只适合快速验证、临时调试、或者对接非常不规范的第三方接口。正式业务,还是建议建 DTO。
2.3 传 JSON 字符串的特殊情况和坑
还有一种写法是直接传 JSON 字符串:
@PostMapping("/user/create") Result<User> createUser(@RequestBody String jsonBody);这里的坑比想象中大。@RequestBody String在 Spring MVC 里是直接用StringHttpMessageConverter读取原始请求体,也就是说调用方传入的字符串会被“原样发送”。但在 Feign 的SpringEncoder里,事情就没那么简单了:它拿到参数是 String 之后,还是会走对象序列化的逻辑,把字符串再当作 JSON 序列化一次。
结果就是,你本来想发的是:
{"name":"zhang","age":18}实际发出去却变成了:
"{\"name\":\"zhang\",\"age\":18}"服务端拿到手的是一个带转义的 JSON 字符串,而不是真正的 JSON 对象,解析自然就挂了。
那什么时候适合用@RequestBody String?只有当你配置了自定义 Encoder,让 Feign 直接把 String 写入 body 的时候,才建议这么用。后面 2.4 会说到。
2.4 需要原生 body 时怎么自定义 Encoder
有些场景是真的需要发送原始字符串 body,比如对接某些硬件接口、第三方平台,它们的签名逻辑是直接把参数拼成 JSON 字符串做加密,body 不是标准 DTO 能表达的。这时候可以自定义一个简单的 Encoder:
public class RawStringEncoder implements Encoder { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { if (object instanceof String) { template.body((String) object, StandardCharsets.UTF_8); } else { throw new EncodeException("RawStringEncoder only supports String body"); } } }然后在 Feign 配置类里把 Encoder 换掉:
@Configuration public class FeignConfig { @Bean public Encoder feignEncoder() { return new RawStringEncoder(); } }这样接口方法就能放心写:
@PostMapping("/third-party/api") String callThirdParty(@RequestBody String rawJsonBody);但要注意,换了 Encoder 之后,这个 Feign Client 下的所有接口都会走新的序列化逻辑,如果其它接口还在用 DTO 对象,就会报错。所以一般建议单独建一个 Feign Client 接口给特殊对接用,别和普通业务接口混在一起。
3. 一个完整的 Feign 调用究竟长什么样
3.1 服务提供方的 Controller 怎么接
很多问题其实是服务端和客户端两边约定不一致导致的。服务端先定义一个标准的 POST 接口:
@RestController @RequestMapping("/user") public class UserController { @PostMapping("/create") public Result<User> createUser(@RequestBody UserCreateDTO dto) { User user = userService.create(dto); return Result.ok(user); } }注意,@RequestBody注解一定要写在参数前面,这样 Spring 才会从请求体里反序列化。如果服务端这里忘了加@RequestBody,那 Spring 会尝试把dto当作 form 表单参数去绑定,客户端发来的 JSON body 就可能解析失败或者全是 null。
3.2 调用方 Feign Client 怎么定义
再看调用方。定义一个 Feign Client 接口,方法签名尽量和服务端保持对称:
@FeignClient(name = "userService", url = "${user.service.url}", configuration = FeignConfig.class) public interface UserClient { @PostMapping("/user/create") Result<User> createUser(@RequestBody UserCreateDTO dto); }调用的时候注入接口,直接当普通 Service 用:
@Service public class UserServiceImpl { @Resource private UserClient userClient; public void register(UserCreateDTO dto) { Result<User> result = userClient.createUser(dto); if (result.getCode() != 0) { throw new BizException(result.getMessage()); } } }从客户端到服务端,整个链路就是:调用方方法参数被 Feign 序列化成 JSON body,POST 到服务端,服务端 Spring MVC 把 body 反序列化成UserCreateDTO,然后执行业务逻辑。
3.3 实操中的请求体设计建议
传 body 不只是写个注解那么简单,请求体设计直接影响后期维护体验。我的习惯是:
- 每个接口单独建 DTO,不要直接拿数据库实体类当请求体,避免暴露多余字段,也防止循环引用导致序列化死循环。
- DTO 字段根据接口语义命名,比如创建用户用
UserCreateDTO,更新用UserUpdateDTO,字段尽量精简。 - 有日期字段时,在 DTO 上标注
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),否则序列化出来可能是时间戳,服务端解析可能对不上。 - 如果个别字段可能为 null,建议用包装类型如
Integer、Long,不要用int、long,防止反序列化时空指针。
传 body 最怕的就是“不知道服务端要什么字段”。所以我一般先去抓包,看看浏览器里真实发的请求体长什么样,再照着写 DTO。
3.4 调试技巧:用浏览器 Network 面板构造请求
这里分享一个很实用的调试方法。比如你是在做页面某个功能对接,可以先把浏览器开发者工具打开 Network 面板,选中那个 ajax 请求,找到Request Payload或者Request Body里的 JSON 内容。这就是服务端实际接收到的 body 格式。
然后打开 Postman 或者 Apifox,新建一个 POST 请求,把 URL、Header、Body 原样复制进去,先跑通一遍。如果 Postman 能通,Feign 大概率也能通;如果 Postman 都通不了,就先别折腾代码,说明接口本身就是这个入参格式,照着改 DTO 就行。
我经常遇到同事说“Feign 调不通”,结果我在浏览器 Network 面板里一对比,发现 URL 路径少了个前缀,或者 Content-Type 不对。这类问题,工具一抓就出来了。
4. 经典报错与排查实录
4.1 required request body is missing 到底是什么意思
这个报错完整信息一般是:
required request body is missing: public org.jeecg.common.api.vo.Result com.example.controller.UserController.createUser(com.example.dto.UserCreateDTO)翻译过来就是:服务端接口要读 body,但是请求打过来的时候 body 是空的。常见原因有三个:
一是 Feign 接口方法参数上根本没有加@RequestBody,对象被当成了 query 参数拼进 URL。服务端当然读不到 body。
二是 Feign 接口写的是 GET 请求,比如@GetMapping,然后还想传 body。前面说了,Feign 对 GET 传 body 支持很差,大概率发不出 body。
三是自定义了 Encoder 或者拦截器,把请求体干掉了。这种情况先检查 Feign 配置类里有没有奇怪的 Encoder、RequestInterceptor,把 body 里的内容替换或者清空。
排查思路很简单:先抓包看实际 HTTP 请求有没有 body,没有就往前查 Feign 配置;如果有 body 但服务端还是报缺 body,再对比 Content-Type 是否匹配。
4.2 failed to deserialize the json body into the target type
这个报错的意思是:服务端收到了 body,但反序列化到目标类型时失败了。它的完整提示经常带一段 JSON 内容,比如:
failed to deserialize the json body into the target type: input: missing field `age`说明服务端在把 JSON 转成 DTO 时,发现缺少age字段,或者字段类型对不上。这种情况十有八九是调用方传的 JSON 和服务端 DTO 定义不一致。
举个例子:服务端 DTO 有age属性,调用方 DTO 写成了yearsOld,序列化出来的 JSON 就没有age这个 key。还有一种是类型不匹配,比如服务端定义Long id,调用方传了字符串"abc",Jackson 转换失败。
再补充一个隐蔽的坑:如果服务端 DTO 里加了一个新字段,但又没配@JsonIgnoreProperties(ignoreUnknown = true),旧版调用方发来的 JSON 完全能通过,但新版调用方多传了新字段,服务端可能因为无法识别该字段而反序列化失败。Spring Boot 2.x 默认是忽略未知字段的,但如果你改过 Jackson 配置或者用的是旧版本 Spring,就可能会踩到。遇到这类问题,把错误信息里的字段名和 DTO 对比一下,基本能定位。
4.3 收到一页 HTML:请求打到哪去了
这个特别经典。Feign 接口方法返回类型可能定义成了Result<User>,但实际返回的是一段:
<!doctype html><body style="background:#e8f4ff;text-align:center;margin-...看到 HTML,基本可以确定请求没有打到预期的 API 接口上。要么是 URL 配置错了,比如url少了个/api,请求打到网关默认页面;要么是服务端有 Spring Security、登录拦截器,把没有鉴权的请求重定向到了登录页;还有一种可能是服务端全局异常处理兜底,返回了一个错误页。
排查步骤:
- 用 Postman 或 Apifox 直接请求 Feign 里配的完整 URL,看返回是不是也是 HTML。
- 如果是,把服务端日志翻出来,看请求到底被谁拦截了。
- 检查 Feign 的
url配置是否带了正确的网关前缀、服务端口。 - 看看是不是需要额外传递 Token 之类的 Header,被拦截器挡了。
我也遇到过一种情况:服务端接口本身返回的是 JSON,但 Feign 调用时没有加Accept: application/jsonHeader,被网关降级处理,返回了一个未授权页面。这种情况在浏览器的Request Headers里抄一下默认 Header,加到 Feign 配置里就好了。
4.4 传不同参数结果一样:参数名对不上
还有个很诡异的现象:同时访问一个页面,传不同参数,但结果都一样。这种问题经常出在 Feign 传 body 时字段名对不上。比如服务端接口要的是userId,调用方 DTO 里写的是id,结果服务端每次拿到的都是 null,默认走了一个兜底逻辑,所以结果一样。
另一个可能是服务端接口并没有用@RequestBody接参数,而是从 query 里取。传不同参数当然没反应。这种情况把 Postman 抓到的请求对比一下,看参数是放在 query 还是 body 里,再决定服务端和 Feign 怎么改。
5. 进阶:超时、重试与表单类型的“body”
5.1 别忽略超时和线程配置
传 body 传对了,也不代表接口就一定能稳定调通。Feign 默认的超时时间很短,如果服务端处理慢一点,就可能超时。尤其是一些大报文 body 的接口,序列化、传输、反序列化都需要时间。
可以在配置里显式指定超时时间:
feign: client: config: default: connectTimeout: 5000 readTimeout: 15000connectTimeout是建立连接的超时,readTimeout是从服务端读取数据的超时。如果接口经常处理大 JSON,readTimeout可以适当调大,但也不要无脑调到几十秒,最好配合慢 SQL、大文件上传等场景做压测。
另外,如果 Feign 调用量很大,建议为 Feign 配置独立的线程池,避免“线程饥饿”。不过这属于性能调优范畴了,刚入门的朋友先保证功能通就行,后面再优化。
5.2 表单格式的 body:content-type 是 urlencoded
最后再说一种容易被忽略的“body 形式”。Feign 默认传 JSON,但有些老接口,或者某些第三方服务,要求的是application/x-www-form-urlencoded。虽然它也是 body,但编码方式和 JSON 不一样,需要借助feign-form库的SpringFormEncoder。
引入依赖后:
@Configuration public class FeignFormConfig { @Bean public Encoder feignFormEncoder() { return new SpringFormEncoder(); } }Feign Client 方法就可以这么写:
@FeignClient(name = "formService", url = "${form.service.url}", configuration = FeignFormConfig.class) public interface FormClient { @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) TokenResponse getToken(@RequestParam("grant_type") String grantType, @RequestParam("code") String code); }这种情况下,参数以表单形式编码在 body 里,服务端用@RequestParam接收。它和 JSON body 的区别在于 Content-Type 和编码方式,理解了这一点,以后遇到 XML、二进制等其它 body 格式,思路也是一样的:选择合适的 Encoder,设置正确的 Content-Type,两边对齐就行。
Feign 传 body 这块,真的不算难,但特别考验细心。注解、Content-Type、参数类型、服务端 DTO,一个环节没对上就报错。我个人这几年踩坑下来,最深刻的体会就是:遇到问题先别急着改代码,用抓包工具把真实的 HTTP 请求看清楚,比瞎猜有效得多。像required request body is missing、反序列化失败、返回 HTML 这类问题,只要对照实际请求一分就能定位。希望这篇能帮你少走点弯路,有不同看法欢迎讨论。