本文将以一个 RPC 接口IActivityBooth
作为示例,讲解如何通过 HTTP 请求调用到该接口。
public interface IActivityBooth {
// 标准 rpc 接口定义, 仅有一个入参,即请求结构体
String enrollGrades(EnrollGradesReq req);
}
public class EnrollGradesReq {
// 课程名称
private String course;
// 任课教师
private User teacher;
// 学生名单
private List<Student> students;
// 成绩指标: 依次为最高分、最低分、平均分
private List<Integer> gradeMetrics;
}
enrollGrades
方法:模拟学生成绩登记操作;EnrollGradesReq
请求对象包括接口响应所需的所有信息:课程名称、任课老师、班级中的学生名单、成绩指标。cource
:简单数据类型java.lang.String,相似的类型还有Integer、Double、Boolean等。teacher
:任课教师,DTO 对象User;students
:学生名单,DTO 对象Student集合;gradeMetrics
:班级成绩统计指标,简单类型(Integer)集合;为什么 rpc 接口统一只有一个入参?我认为好处有如下几点:
multipart/form-data
是一种用于HTTP请求的编码类型,它允许在单个请求中发送多种类型的数据(尤其是文件上传)。在这种编码类型下,消息体被分割成多个部分,每个部分可以包含不同类型的数据。
常用场景:
multipart/form-data
。例如,当用户需要上传图片、视频、文档等文件到服务器时。multipart/form-data
提供了灵活的数据组织方式。注:我选择 json 字符串传递对象类型,如 students 为 json 列表、teacher 则为单个 json 字符串。
为了更清晰的展示 form-data 类型的消息格式,我使用 Netty 编写了简易的 echo 服务器,它会回传原始的 HTTP 请求报文。使用 postman 发送请求后,收到的响应如下:
POST /echo/req HTTP/1.1
User-Agent: PostmanRuntime/7.36.0
Accept: */*
Postman-Token: d0e81eda-2dcd-447e-bc66-1a1cd7b66b0c
Host: localhost:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------062873639731964115386269
content-length: 635
----------------------------062873639731964115386269
Content-Disposition: form-data; name="teacher"
{"uid":"00001","age":55,"nickName":"罗辉"}
----------------------------062873639731964115386269
Content-Disposition: form-data; name="students"
[{"id": "1002","name":"wzz","totalGrades":80},{"id": "1001","name":"wy","totalGrades":100}]
----------------------------062873639731964115386269
Content-Disposition: form-data; name="gradeMetrics"
[100,80,90]
----------------------------062873639731964115386269
Content-Disposition: form-data; name="course"
math
----------------------------062873639731964115386269--
Content-Disposition
是 HTTP 请求或响应头的一部分,用于指示资源的处理方式,它用于 multipart/form-data类型的请求体中,表示表单数据的一部分。boundary
(边界标识符)来分隔。Content-Disposition: form-data
表明这部分内容是表单数据。紧接着的name="..."
属性指定了表单字段的名称。(teacher
、gradeMetrics
都是 Postman 请求时设置的表单属性)Content-Disposition
通常还会包含一个filename
属性,指示上传的文件名。application/json
表示消息体的内容是JSON(JavaScript Object Notation)格式,json 是一种轻量级的数据交换格式,易于人阅读和编写,同时也易于机器解析和生成。
常用场景:
application/json
非常方便。HTTP 完整请求:
POST /echo/req HTTP/1.1
Content-Type: application/json
User-Agent: PostmanRuntime/7.36.0
Accept: */*
Postman-Token: 264730d7-68fd-4a90-9bd8-fac08a14d2a4
Host: localhost:8080
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
content-length: 398
{
"course": "math",
"teacher": {
"uid":"00001",
"age":55,
"nickName":"罗辉"
},
"students": [
{
"id": "1002",
"name":"wzz",
"totalGrades":80
},
{
"id": "1001",
"name":"wy",
"totalGrades":100
}
],
"gradeMetrics": [100,80,90]
}
在介绍使用 Netty 转换 TCP 字节流为完整 HTTP 报文前,我先把ChannelInitializer
代码贴一下,initChannel
方法为每个新建立的连接注册 Inbound 和 Outbound 处理器。
public class GatewayChannelInitializer extends ChannelInitializer<SocketChannel> {
private final Configuration configuration;
public GatewayChannelInitializer(Configuration configuration) {
this.configuration = configuration;
}
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new HttpRequestDecoder());
pipeline.addLast(new HttpResponseEncoder());
pipeline.addLast(new HttpObjectAggregator(1024 * 1024));
/*添加自定义的业务处理器 */
pipeline.addLast();
}
}
我概括下这段代码的关键点:
HttpRequestDecoder
用于将 HTTP 请求的字节流解析为一个HttpRequest
和多个HttpContent
对象;HttpObjectAggregator
:聚合HttpRequest
和随后的HttpContents
为 FullHttpRequest
或FullHttpResponse
;HttpRequest
就是不含 Http Body 的 Http 请求;而HttpContent
就是保存 Http Body 分块数据的实例。HttpContent
实例?maxChunkSize
(默认 8192 字节),控制HttpContent
携带 Body 数据的最大长度,对于数据超出 8192 字节的 Http Body,解码器会将其分割为多个HttpContent
实例。本章之后的内容,我将详细讲解这一部分的源码,梳理完整的处理流程,不感兴趣的朋友可以直接跳到参数解析章节。
HTTP 协议规定报文必须有 header,但可以没有 body,而且在 header 之后必须要有一个“空行”,也就是“CRLF”,十六进制的“0D0A”。
HttpRequestDecoder
是ChannelInboundHandler
接口的实现类,作用是将ByteBuf
中的字节流数据解码为一个HttpMessage
实例(实际为 DefaultHttpRequest
实现类)和多个HttpContent
实例。
// io.netty.handler.codec.http.HttpRequestDecoder#createMessage
protected HttpMessage createMessage(String[] initialLine) throws Exception {
return new DefaultHttpRequest(
HttpVersion.valueOf(initialLine[2]),
HttpMethod.valueOf(initialLine[0]), initialLine[1], validateHeaders);
}
DefaultHttpRequest
对象就是 HTTP 请求行+ 头部数据,请求行包含 请求方法、请求 URI 和 HTTP 协议版本。
一个HttpContent
对象就是 HTTP Body 的一个分块,用于 HTTP 分块传输。
HttpObjectDecoder
会在生成HttpMessage
后生成HttpContent
,当出现如下情况时,解码器将生成多个HttpContent
实例。
maxChunkSize
:默认 8192 字节,控制 content 和单个分块的最大长度。当 content 超出该值,则会被分为多个 HttpContent。Transfer-Encoding: chunked
下面我们来看看源码中针对这两种场景的处理方式,方法io.netty.handler.codec.http.HttpObjectDecoder#decode
。
头字段包含 Content-Length 字段,状态机流转为READ_FIXED_LENGTH_CONTENT状态,chunkSize
设置为content长度。
long contentLength = contentLength(); // 读取头字段Content-Length的值
// ...
if (nextState == State.READ_FIXED_LENGTH_CONTENT) {
// chunkSize will be decreased as the READ_FIXED_LENGTH_CONTENT state reads data chunk by chunk.
chunkSize = contentLength;
}
源码截图中,readLimit
规定读取的字节数最大为 buffer 中可读字节数、maxChunkSize
二者中的最小值。
chunkSize
则是 HTTP Body 剩余数据的大小,因此本轮解码器解析的数据toRead
应该为chunkSize
、readLimit
二者的最小值,即单次读取的数据量不能超出maxChunkSize
、也不能将多个 HTTP 请求的数据混淆在一个HttpContent
中。
如果当前 HTTP body 没有剩余数据(chunkSize
等于0),则将消息体包装到DefaultLastHttpContent
对象中并添加到out
集合(out
集合会被传递给下一个InboundHandler)。此外,还会重置currentState
为 SKIP_CONTROL_CHARS,表示本轮 Http 请求/响应解析完成。
如果仍有剩余内容,则将内容包装为DefaultHttpContent
对象,并将其添加到out
集合方法返回,注意此时 currentState仍然为READ_FIXED_LENGTH_CONTENT,且out.size
大于 0,这意味 decode
方法会再次被调用,从而能解析剩余的 Http 消息体。
在阅读后续代码前,我先补充下 HTTP 分块编码规则:
Transfer-Encoding: chunked
时,状态机的状态会流转为READ_CHUNK_SIZE。lineParser
:LineParser
对象,LineParser
是ByteProcessor
实现类,用于将ByteBuf
中的字节流解析为以行(Line)为单位的字符串流。parse
方法在循环内调用LineParser#process
方法,逐个读取 buffer 中的字符,当读取到LF 换行符后,process
返回 false 退出循环,此时 lineParser 已经读取到了完整的一行(不含CRLF)。 public boolean process(byte value) throws Exception {
char nextByte = (char) (value & 0xFF);
if (nextByte == HttpConstants.LF) {
int len = seq.length();
// Drop CR if we had a CRLF pair
if (len >= 1 && seq.charAtUnsafe(len - 1) == HttpConstants.CR) {
-- size;
seq.setLength(len - 1);
}
// 读取到LF换行符, 返回false
return false;
}
increaseCount();
seq.append(nextByte);
return true;
}
getChunkSize
方法解析十六进制分块长度,并设置chunkSize字段为该值。若分块长度大于 0,状态流转为READ_CHUNKED_CONTENT。READ_CHUNKED_CONTENT状态:读取分块数据。
chunkSize
初始化为当前分块大小,表示分块剩余可读取大小;toRead
变量计算可读取的最大字节数,不得超过maxChunkSize
和 ByteBuf 中可读字节数。DefaultHttpContent
对象,content
字段设置为从 buffer 中读取的toRead
大小的数据。readRetainedSlice
方法获取ByteBuf buf
某个区间的子切片sub_buf
,这个子切片的引用计数独立于buf
。即使,buf
的引用计数降至0并且被释放,sub_buf
仍然是有效的。chunkSize
减少toRead
,并将DefaultHttpContent
对象添加至out
集合传递给下一个 handler。如果chunkSize
等于 0,说明当前分块读取完毕,状态流转为READ_CHUNK_DELIMITER:解码器会逐个读取 buffer 中的字节,直到遇到换行符LF(‘\n’)结束循环,状态流转为READ_CHUNK_SIZE重复分块解析过程。
如果READ_CHUNK_SIZE读取分块大小为 0,会流转到READ_CHUNK_FOOTER状态。该状态表示解码器正在处理 HTTP 消息的块传输编码的尾部(chunk footer)
readTrailingHeaders
方法:读取和解析块传输编码消息末尾的 trailing headers(尾头部)。使用 telnet 客户端调试 HTTP 分块数据解析流程:
上图中请求体分为 4 行:
Hello World
;chunkSize
等于11。
最后一行为空行,因此readTrailingHeaders
返回LastHttpContent.EMPTY_LAST_CONTENT
。
最后,将EMPTY_LAST_CONTENT
添加到out
集合,重置currentState
为SKIP_CONTROL_CHARS状态。
至此,解码器完成了这个分块传输 HTTP 请求的解析工作,等待处理新的 HTTP 请求。Telnet 客户端收到的 HTTP 响应数据:
缓存在 ByteBuf 中的字节流经过HttpRequestDecoder
处理,解码为包含 请求行 + 请求头信息的HttpRequest
对象和多个HttpContent
对象。
这些实例将被依次交给HttpObjectAggregator
处理,该ChannelHandler
用于将来自HTTP消息的多个部分组合成一个完整的HTTP消息,即FullHttpRequest
对象。
下面的示意图清晰反映了这一流程:
聚合消息的逻辑主要实现于io.netty.handler.codec.MessageAggregator#decode
方法。下面我将依次介绍 聚合器收到HttpMessage
和HttpContent
对象时的处理逻辑。
如果 Aggregator 接收到的消息为HttpMessage
实例,即 Http head + 版本号,则该消息为Http的首个消息,isStartMessage
返回 true,代码如下(我对关键代码进行了编号,可以对照着解说阅读):
protected void decode(final ChannelHandlerContext ctx, I msg, List<Object> out) throws Exception {
if (isStartMessage(msg)) { // (1)
// ...
// A streamed message - initialize the cumulative buffer, and wait for incoming chunks.
CompositeByteBuf content = ctx.alloc().compositeBuffer(maxCumulationBufferComponents); // (2)
if (m instanceof ByteBufHolder) { // (3)
appendPartialContent(content, ((ByteBufHolder) m).content());
}
currentMessage = beginAggregation(m, content); // (4)
}
}
isStartMessage
:msg 是否为一个 HTTP 请求/响应的开始消息。HttpObjectAggregator
中对该方法的实现如下:protected boolean isStartMessage(HttpObject msg) throws Exception {
return msg instanceof HttpMessage;
}
例如:DefaultHttpRequest
为HttpMessage
实例。
ctx.alloc()
获取当前 Channel 的 ByteBufAllocator,创建了一个CompositeByteBuf
实例。maxCumulationBufferComponents
是CompositeByteBuf
可以持有的最大 ByteBuf 组件数量。这是一个性能优化,用于限制累积缓冲区的大小。if (m instanceof ByteBufHolder)
检查m是否为ByteBufHolder
的实例,如果是,说明m包含可用的数据内容,需要追加到前面创建的CompositeByteBuf content
中。beginAggregation
:创建新的聚合消息,入参为开始消息start
和指定的ByteBuf
实例content用于存放 body 数据。HttpRequest
实例,则创建AggregatedFullHttpRequest
对象用于存放聚合后的完整 HTTP 请求。如果 Aggregator 接收到的消息为HttpContent
,即 Http Body 的分块数据,isContentMessage
将返回true,这部分代码如下:
else if (isContentMessage(msg)) {
if (currentMessage == null) {
// it is possible that a TooLongFrameException was already thrown but we can still discard data
// until the begging of the next request/response.
return;
}
// Merge the received chunk into the content of the current message.
CompositeByteBuf content = (CompositeByteBuf) currentMessage.content(); // (1)
@SuppressWarnings("unchecked")
final C m = (C) msg;
// Append the content of the chunk.
appendPartialContent(content, m.content()); // (2)
// Give the subtypes a chance to merge additional information such as trailing headers.
aggregate(currentMessage, m);
final boolean last;
if (m instanceof DecoderResultProvider) {
DecoderResult decoderResult = ((DecoderResultProvider) m).decoderResult();
if (!decoderResult.isSuccess()) {
if (currentMessage instanceof DecoderResultProvider) {
((DecoderResultProvider) currentMessage).setDecoderResult(
DecoderResult.failure(decoderResult.cause()));
}
last = true;
} else {
last = isLastContentMessage(m); // (3)
}
} else {
last = isLastContentMessage(m); // (3)
}
if (last) { // (4)
finishAggregation0(currentMessage);
// All done
out.add(currentMessage);
currentMessage = null;
}
}
// ...
beginAggregation
方法创建的AggregatedFullHttpRequest
实例;content 就是用于缓存合并后 Http Body 数据的ByteBuf
。appendPartialContent
方法将HttpContent
对象 msg 中的分块数据,追加到 content 中,即将 HttpRequestDecoder
解码生成的多个HttpContent
对象的数据合并到 currentMessage 中。isLastContentMessage
判断是否对象为LastContentMessage
实例,即一个完整 Http 请求/响应 的最后一个分块数据。LastContentMessage
实例,执行if(last)
代码块。
finishAggregation0
会针对头部不含Content-Length字段的请求,补上这一头字段。(注:Transfer-Encoding: chunked 和 Content-Length 是互斥的)out.add(currentMessage)
将聚合完成后的FullHttpRequest
实例加入 out 集合,从而传递给下一个 InboundHandler,通常这个处理器由用户定义,用于实现业务逻辑。注:即使 Http 请求没有 Body,HttpObjectDecoder#decode
方法也会在解码完成前,添加一个空的 LastHttpContent
对象。因此,任何合法的 Http 请求/响应,必然以一个LastHttpContent
实例标识该 Http 请求/响应完成解码。
在上一章节中,我介绍了使用 Netty 框架处理字节流,得到完整的 Http 报文的全过程。这一章,我将介绍如何从聚合的FullHttpRequest
中,解析出泛化调用入参。
前面我们介绍了 Http 传递 RPC 请求结构体的两种方式:multipart/form-data
、application/json
,下面我将分别介绍如何介绍这两种方式传递的请求结构体。
Netty 处理普通的 post 请求,典型的处理方式是使用io.netty.handler.codec.http.multipart.HttpPostRequestDecoder
解析 Post 请求:
FullHttpRequest requst = ... // HttpObjectAggregator聚合后的Http请求
HttpPostRequestDecoder decoder = new HttpPostRequestDecoder(request); // HttpRequest
decoder.offer(request); // HttpContent
HttpPostRequestDecoder
:Netty 中用于处理 HTTP POST 请求的解码器,该解码器能解析 POST 请求体。Post 协议又可以分为普通 post 请求和 Multipart 请求:
HttpPostRequestDecoder#offer
:用于提供HttpContent
对象给解码器。处理完成后,可以通过getBodyHttpDatas()
获取所有表单数据,或者通过getBodyHttpDatas(String name)
获取特定名字的表单字段。
随后,通过getBodyHttpDatas
方法获取所有表单数据并进行解析,完整代码如下:
case "multipart/form-data":
// ...
Map<String, Object> parameters = new HashMap<>();
// 返回 Http 消息体中所有数据的列表(先通过 offer 方法提供完整的 Http 消息体)
decoder.getBodyHttpDatas().forEach(data->{
Attribute attr = (Attribute) data;
try {
String val = attr.getValue();
if(isJsonObject(val)) { // (1)
Object paramVal = parameters.get(attr.getName());
Map<?, ?> newVal = JSON.parseObject(val);
if(paramVal == null) {
parameters.put(attr.getName(), newVal);
} else if(paramVal instanceof List) {
((List)paramVal).add(newVal);
} else {
List<Object> objList = new ArrayList<>();
objList.add(paramVal); // old
objList.add(newVal); // new
parameters.put(attr.getName(), objList);
}
} else if(isJsonArray(val)) { // (2)
List<?> jsonObjs = null;
try {
jsonObjs = JSON.parseArray(val, JSONObject.class); // (2.1)
} catch (Exception e) {
// 尝试解析为字符串列表
jsonObjs = JSON.parseArray(val, String.class); // (2.2)
}
parameters.put(attr.getName(), jsonObjs);
} else { // (3)
parameters.put(attr.getName(), val);
}
} catch (Exception e) {}
});
isJsonObject
判断 val 是否为 JSON 对象,例如{"uid":"00001","age":55,"nickName":"罗辉"}
。如果是,则使用 fastjson 工具包的JSON.parseObject
完成解析;private boolean isJsonObject(String str) {
if(str == null || str.isEmpty()) {
return false;
}
return str.startsWith("{") && str.endsWith("}");
}
表单中可以传递多个名称相同的value(见下图),所以代码中针对【使用多个相同 key 的方式发送对象数组】的方式进行了支持。
isJsonArray
判断 val 是否为 JSON 数组,JSON 数组又可以细分为对象数组 和 简单类型数组。使用JSON.parseArray
解析,第二个入参表示目标类型。
[{"uid":"00001","age":24,"nickName":"wzz"},{"uid":"00002","age":23,"nickName":"wy"}]
如果解析过程抛出异常,说明 val 并不是对象数组,尝试将其作为简单类型数组解析。
[\"hello\", \"world\"]
[2.45,5,64]
解析 application/json 类型的请求结构体非常方便,因为rpc 接口入参规定为对象,因此直接使用 fastjson 的JSON.parseObject
即可。
case "application/json":
ByteBuf content = request.content().copy();
if(content.isReadable()) {
String contentStr = content.toString(StandardCharsets.UTF_8);
// parseObject 将 json 字符串转换为 JSONObject, 该类型实现 Map 接口
// 如果 JSONObject 中某个条目的值也是 json 字符串, 该字符串也会被解析为 JSONObject
return JSON.parseObject(contentStr);
}
使用 Dubbo RPC 框架 + Zookeeper 注册中心发起泛化调用,详细的配置、使用教程以及入参处理本文不再赘述,笔者在另一篇文章已经给出了案例说明:一看就会!Dubbo 泛化调用简明教程——含不同类型入参处理
总而言之,API网关发起泛化调用,需要如下几个条件:
/gateway/generic/enrollGrades
;cn.wzz.gateway.rpc.IActivityBooth
。enrollGrades
。cn.wzz.gateway.rpc.req.EnrollGradesReq
;【rpc 接口全限定名】、【方法名】以及【方法入参全限定名】需要服务主动上报给 API 网关;请求 uri 唯一确定一个 RPC 方法;rpc 方法的入参值通过解析 HTTP 携带的载荷(HTTP Body)获取。
调用结果展示:
public String enrollGrades(EnrollGradesReq req) {
return String.format("[enrollGrades] teacher=%s; ", JSON.toJSONString(req.getTeacher())) +
String.format("students=%s; ", JSON.toJSONString(req.getStudents())) +
String.format("maxGrade=%d, minGrade=%d, averageGrade=%d",
req.getGradeMetrics().get(0),
req.getGradeMetrics().get(1),
req.getGradeMetrics().get(2));
}