本文并非纯理论或纯技术类文章,而是结合理论进而实践(虽然没有特别深入的实践),浅析 netty 的 HTTP 协议栈,并着重聊聊实践中遇到的问题及解决方案。越往后越精彩哦!
如果你也打算使用 netty 来实现 HTTP 服务器,相信这个项目和本文对你是有较大帮助的!
<method> <request-URL> <version>
<headers>
<entity-body>
POST http://www.example.com HTTP/1.1
Content-Type: application/x-www-form-urlencoded;charset=utf-8
title=test&sub%5B%5D=1&sub%5B%5D=2&sub%5B%5D=3
POST http://www.example.com HTTP/1.1
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"
title
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png
PNG ... content of chrome.png ...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--
var data = {
'title'
:
'test'
,
'sub'
: [
1
,
2
,
3
]};
$http.post(url, data).success(function(result) {
...
});
POST http:
//www.example.com HTTP/1.1
Content-Type: application/json;charset=utf-
8
{
"title"
:
"test"
,
"sub"
:[
1
,
2
,
3
]}
Decodes ByteBuf into HttpRequest and HttpContent.
Encodes an HttpResponse or an HttpContent into a ByteBuf.
A combination of HttpRequestDecoder and HttpResponseEncoder which enables easier server side HTTP implementation.
ch.pipeline().addLast(
"codec"
,
new
HttpServerCodec())
ch.pipeline().addLast(
"decoder"
,
new
HttpRequestDecoder())
.addLast(
"encoder"
,
new
HttpResponseEncoder())
A ChannelHandler that aggregates an HttpMessage and its following HttpContent into a single FullHttpRequest or FullHttpResponse
(depending on
if
it used to handle requests or responses) with no following HttpContent.
It is useful when you don
't want to take care of HTTP messages whose transfer encoding is '
chunked'.
当然,netty 还提供了其他 HTTP 编解码器,有些涉及到高级应用(较复杂的应用),在此就不一一解释了,以上只是介绍netty HTTP 协议栈最基本的编解码器(切合文章主题——浅析)。
HttpRequest request = (HttpRequest) msg;
String uri = request.uri();
if
(uri.equals(FAVICON_ICO)){
return
;
}
Splits an HTTP query string into a path string and key-value parameter pairs.
This decoder is
for
one time use only. Create a
new
instance
for
each URI:
QueryStringDecoder decoder =
new
QueryStringDecoder(
"/hello?recipient=world&x=1;y=2"
);
assert
decoder.getPath().equals(
"/hello"
);
assert
decoder.getParameters().get(
"recipient"
).get(
0
).equals(
"world"
);
assert
decoder.getParameters().get(
"x"
).get(
0
).equals(
"1"
);
assert
decoder.getParameters().get(
"y"
).get(
0
).equals(
"2"
);
This decoder can also decode the content of an HTTP POST request whose
content type is application/x-www-form-urlencoded:
QueryStringDecoder decoder =
new
QueryStringDecoder(
"recipient=world&x=1;y=2"
,
false
);
...
从上面的描述可以看出,QueryStringDecoder 的作用就是把 HTTP uri 分割成 path 和 key-value 参数对,也可以用来解码 Content-Type = "application/x-www-form-urlencoded" 的 HTTP POST。特别注意的是,该 decoder 仅能使用一次。
String uri = request.uri();
HttpMethod method = request.method();
if
(method.equals(HttpMethod.GET)){
QueryStringDecoder queryDecoder =
new
QueryStringDecoder(uri, Charsets.toCharset(CharEncoding.UTF_8));
Map<String, List<String>> uriAttributes = queryDecoder.parameters();
//此处仅打印请求参数(你可以根据业务需求自定义处理)
for
(Map.Entry<String, List<String>> attr : uriAttributes.entrySet()) {
for
(String attrVal : attr.getValue()) {
System.out.println(attr.getKey() +
"="
+ attrVal);
}
}
}3.3 HTTP POST 解析实践
Combinate the HttpRequest and FullHttpMessage, so the request is a complete HTTP request.
FullHttpRequest fullRequest = (FullHttpRequest) msg;
FullHttpRequest fullRequest = (FullHttpRequest) msg;
String jsonStr = fullRequest.content().toString(Charsets.toCharset(CharEncoding.UTF_8));
JSONObject obj = JSON.parseObject(jsonStr);
for
(Entry<String, Object> item : obj.entrySet()){
System.out.println(item.getKey()+
"="
+item.getValue().toString());
}3.3.2 解析 application/x-www-form-urlencoded
解析此类型有两种方法,一种是使用 QueryStringDecoder,另外一种就是使用 HttpPostRequestDecoder。
FullHttpRequest fullRequest = (FullHttpRequest) msg;
String jsonStr = fullRequest.content().toString(Charsets.toCharset(CharEncoding.UTF_8));
QueryStringDecoder queryDecoder =
new
QueryStringDecoder(jsonStr,
false
);
Map<String, List<String>> uriAttributes = queryDecoder.parameters();
for
(Map.Entry<String, List<String>> attr : uriAttributes.entrySet()) {
for
(String attrVal : attr.getValue()) {
System.out.println(attr.getKey()+
"="
+attrVal);
}
}
public
HttpPostRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset) {
if
(factory ==
null
) {
throw
new
NullPointerException(
"factory"
);
}
if
(request ==
null
) {
throw
new
NullPointerException(
"request"
);
}
if
(charset ==
null
) {
throw
new
NullPointerException(
"charset"
);
}
// Fill default values
if
(isMultipart(request)) {
decoder =
new
HttpPostMultipartRequestDecoder(factory, request, charset);
}
else
{
decoder =
new
HttpPostStandardRequestDecoder(factory, request, charset);
}
}
if
(request
instanceof
HttpContent) {
// Offer automatically if the given request is als type of HttpContent
offer((HttpContent) request);
}
else
{
undecodedChunk = buffer();
parseBody();
}
由于我们使用过 HttpObjectAggregator, request 都是 HttpContent 类型,因此会 Offer automatically,我们就不必自己手动去 offer 了,也不用处理 Chunk,所以使用 HttpObjectAggregator 确实是带来了很多简便的。
HttpRequest request = (HttpRequest) msg;
HttpPostRequestDecoder decoder =
new
HttpPostRequestDecoder(factory, request, Charsets.toCharset(CharEncoding.UTF_8));
List<InterfaceHttpData> datas = decoder.getBodyHttpDatas();
for
(InterfaceHttpData data : datas) {
if
(data.getHttpDataType() == HttpDataType.Attribute) {
Attribute attribute = (Attribute) data;
System.out.println(attribute.getName() +
"="
+ attribute.getValue());
}
}
enum
HttpDataType {
Attribute, FileUpload, InternalAttribute
}
DiskFileUpload.baseDirectory =
"/data/fileupload/"
;
HttpRequest request = (HttpRequest) msg;
HttpPostRequestDecoder decoder =
new
HttpPostRequestDecoder(factory, request, Charsets.toCharset(CharEncoding.UTF_8));
List<InterfaceHttpData> datas = decoder.getBodyHttpDatas();
for
(InterfaceHttpData data : datas) {
if
(data.getHttpDataType() == HttpDataType.FileUpload) {
FileUpload fileUpload = (FileUpload) data;
String fileName = fileUpload.getFilename();
if
(fileUpload.isCompleted()) {
//保存到磁盘
StringBuffer fileNameBuf =
new
StringBuffer();
fileNameBuf.append(DiskFileUpload.baseDirectory).append(fileName);
fileUpload.renameTo(
new
File(fileNameBuf.toString()));
}
}
}
<form action=
"http://localhost:8080"
method=
"post"
enctype =
"multipart/form-data"
>
<input id=
"File1"
runat=
"server"
name=
"UpLoadFile"
type=
"file"
/>
<input type=
"submit"
name=
"Button"
value=
"上传"
id=
"Button"
/>
</form>
MessageToMessageDecoder 继承了 ChannelHandlerAdapter,也就是说解码器其实就是一个 handler,只不过是专门用来做解码的事情。
@Override
public
void
channelRead(ChannelHandlerContext ctx, Object msg)
throws
Exception {
RecyclableArrayList out = RecyclableArrayList.newInstance();
try
{
if
(acceptInboundMessage(msg)) {
@SuppressWarnings
(
"unchecked"
)
I cast = (I) msg;
try
{
decode(ctx, cast, out);
}
finally
{
ReferenceCountUtil.release(cast);
}
}
else
{
out.add(msg);
}
}
catch
(DecoderException e) {
throw
e;
}
catch
(Exception e) {
throw
new
DecoderException(e);
}
finally
{
int
size = out.size();
for
(
int
i =
0
; i < size; i ++) {
ctx.fireChannelRead(out.get(i));
}
out.recycle();
}
}
@Override
protected
void
decode(ChannelHandlerContext ctx, HttpRequest msg, List<Object> out)
throws
Exception {
FullHttpRequest fullRequest = (FullHttpRequest) msg;
ByteBuf content = fullRequest.content();
int
length = content.readableBytes();
byte
bytes =
new
byte
[length];
for
(
int
i=
0
; i<length; i++){
bytes[i] = content.getByte(i);[/i]
[i] }[/i]
[i] [/i]
[i]try[/i]
[i]{[/i]
[i] JSONObject obj = JSON.parseObject([/i]
[i]new[/i]
[i]String(bytes));[/i]
[i] out.add(obj);[/i]
[i] }[/i]
[i]catch[/i]
[i](ClassCastException e){[/i]
[i] [/i]
[i]throw[/i]
[i]new[/i]
[i]CodecException([/i]
[i]"HTTP message body is not a JSONObject"[/i]
[i]);[/i]
[i] }[/i]
[i]}[/i]
[i].addLast([/i]
[i]"jsonDecoder"[/i]
[i], [/i]
[i]new[/i]
[i]HttpJsonDecoder())[/i]
[i]if[/i]
[i](msg [/i]
[i]instanceof[/i]
[i]JSONObject){[/i]
[i] JSONObject obj = (JSONObject) msg;[/i]
[i] ......[/i]
[i] [/i]
[i]}[/i]4.2 HttpProtobufDecoder
[i]private[/i]
[i]final[/i]
[i]MessageLite prototype;[/i]
[i] [/i]
[i]public[/i]
[i]HttpProtobufDecoder(MessageLite prototype){[/i]
[i] [/i]
[i]if[/i]
[i](prototype == [/i]
[i]null[/i]
[i]) {[/i]
[i] [/i]
[i]throw[/i]
[i]new[/i]
[i]NullPointerException([/i]
[i]"prototype"[/i]
[i]);[/i]
[i] }[/i]
[i] [/i]
[i]this[/i]
[i].prototype = prototype.getDefaultInstanceForType();[/i]
[i]}[/i]
[i] [/i]
[i]@Override[/i]
[i]protected[/i]
[i]void[/i]
[i]decode(ChannelHandlerContext ctx, HttpRequest msg, List<Object> out) {[/i]
[i] FullHttpRequest fullRequest = (FullHttpRequest) msg;[/i]
[i] ByteBuf content = fullRequest.content();[/i]
[i] [/i]
[i]int[/i]
[i]length = content.readableBytes();[/i]
[i] [/i]
[i]byte[/i]
[i] bytes = [/i]
[i]new[/i]
[i]byte[/i]
[i][length];[/i]
[i] [/i]
[i]for[/i]
[i]([/i]
[i]int[/i]
[i]i=[/i]
[i]0[/i]
[i]; i<length; i++){[/i]
[i] bytes[i] = content.getByte(i);[/i][/i]
[i][i] }[/i][/i]
[i][i] [/i][/i]
[i][i]try[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] out.add(prototype.getParserForType().parseFrom(bytes, [/i][/i]
[i][i]0[/i][/i]
[i][i], length));[/i][/i]
[i][i] } [/i][/i]
[i][i]catch[/i][/i][i] [/i]
[i][i](InvalidProtocolBufferException e) {[/i][/i]
[i][i] [/i][/i]
[i][i]throw[/i][/i][i] [/i]
[i][i]new[/i][/i][i] [/i]
[i][i]CodecException([/i][/i]
[i][i]"HTTP message body is not "[/i][/i][i] [/i]
[i][i]+ prototype + [/i][/i]
[i][i]"type"[/i][/i]
[i][i]);[/i][/i]
[i][i] }[/i][/i]
[i][i]}[/i][/i]
[i][i].addLast([/i][/i]
[i][i]"protobufDecoder"[/i][/i]
[i][i], [/i][/i]
[i][i]new[/i][/i][i] [/i]
[i][i]HttpProtobufDecoder(UserProbuf.User.getDefaultInstance()))[/i][/i]
[i][i]if[/i][/i]
[i][i](msg [/i][/i]
[i][i]instanceof[/i][/i][i] [/i]
[i][i]UserProbuf.User){[/i][/i]
[i][i] UserProbuf.User user = (UserProbuf.User) msg;[/i][/i]
[i][i] ......[/i][/i]
[i][i]}[/i][/i][i] 五、聊聊开发中遇到的问题【推荐】
[i][i][ERROR] 2016-07-24 15:25:46 [io.netty.util.internal.logging.Slf4JLogger:176] - LEAK: ByteBuf.release() was not called before it's garbage-collected. Enable advanced leak reporting to find out where the leak occurred. To enable advanced leak reporting, specify the JVM option '-Dio.netty.leakDetectionLevel=advanced' or call ResourceLeakDetector.setLevel() See http://netty.io/wiki/reference ... .html for more information.[/i][/i]
[i][i]public[/i][/i][i] [/i]
[i][i]void[/i][/i][i] [/i]
[i][i]channelRead(ChannelHandlerContext ctx, Object msg) {[/i][/i]
[i][i] [/i][/i]
[i][i]ByteBuf buf = (ByteBuf) msg;[/i][/i]
[i][i] [/i][/i]
[i][i]try[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] [/i][/i]
[i][i]...[/i][/i]
[i][i] [/i][/i]
[i][i]} [/i][/i]
[i][i]finally[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] [/i][/i]
[i][i]buf.release();[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i]}[/i][/i]
[i]而有时候,ByteBuf 会被一个 buffer holder 持有,它们都扩展了一个公共接口 ByteBufHolder。正因如此, ByteBuf 并不是 netty 中唯一一种引用计数对象。由 decoder 生成的消息对象很可能也是引用计数对象,比如 HTTP 协议栈中的 HttpContent,因为它也扩展了 ByteBufHolder。[/i]
[i][i]public[/i][/i][i] [/i]
[i][i]void[/i][/i][i] [/i]
[i][i]channelRead(ChannelHandlerContext ctx, Object msg) {[/i][/i]
[i][i] [/i][/i]
[i][i]if[/i][/i][i] [/i]
[i][i](msg [/i][/i]
[i][i]instanceof[/i][/i][i] [/i]
[i][i]HttpRequest) {[/i][/i]
[i][i] [/i][/i]
[i][i]HttpRequest req = (HttpRequest) msg;[/i][/i]
[i][i] [/i][/i]
[i][i]...[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i] [/i][/i]
[i][i]if[/i][/i][i] [/i]
[i][i](msg [/i][/i]
[i][i]instanceof[/i][/i][i] [/i]
[i][i]HttpContent) {[/i][/i]
[i][i] [/i][/i]
[i][i]HttpContent content = (HttpContent) msg;[/i][/i]
[i][i] [/i][/i]
[i][i]try[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] [/i][/i]
[i][i]...[/i][/i]
[i][i] [/i][/i]
[i][i]} [/i][/i]
[i][i]finally[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] [/i][/i]
[i][i]content.release();[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i]}[/i][/i]
[i]如果你抱有疑问,或者你想简化这些释放消息的工作,你可以使用 ReferenceCountUtil.release():[/i]
[i][i]public[/i][/i][i] [/i]
[i][i]void[/i][/i][i] [/i]
[i][i]channelRead(ChannelHandlerContext ctx, Object msg) {[/i][/i]
[i][i] [/i][/i]
[i][i]try[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] [/i][/i]
[i][i]...[/i][/i]
[i][i] [/i][/i]
[i][i]} [/i][/i]
[i][i]finally[/i][/i][i] [/i]
[i][i]{[/i][/i]
[i][i] [/i][/i]
[i][i]ReferenceCountUtil.release(msg);[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i]}[/i][/i]
[i]或者可以考虑继承 SimpleChannelHandler,它在所有接收消息的地方都调用了 ReferenceCountUtil.release(msg)。[/i]
[i][i]-Dio.netty.leakDetectionLevel=advanced[/i][/i]
[i][i]upstream xxx.com{[/i][/i]
[i][i] [/i][/i]
[i][i]keepalive [/i][/i]
[i][i]32[/i][/i]
[i][i];[/i][/i]
[i][i] [/i][/i]
[i][i]server xxxx.xx.xx.xx:[/i][/i]
[i][i]8080[/i][/i]
[i][i];[/i][/i]
[i][i]}[/i][/i]
[i][i]server{[/i][/i]
[i][i] [/i][/i]
[i][i]listen [/i][/i]
[i][i]80[/i][/i]
[i][i];[/i][/i]
[i][i] [/i][/i]
[i][i]server_name xxx.com;[/i][/i]
[i][i] [/i][/i]
[i][i]location / {[/i][/i]
[i][i] [/i][/i]
[i][i]proxy_next_upstream http_502 http_504 error timeout invalid_header;[/i][/i]
[i][i] [/i][/i]
[i][i]proxy_pass xxx.com;[/i][/i]
[i][i] [/i][/i]
[i][i]proxy_http_version [/i][/i]
[i][i]1.1[/i][/i]
[i][i];[/i][/i]
[i][i] [/i][/i]
[i][i]proxy_set_header Connection [/i][/i]
[i][i]""[/i][/i]
[i][i];[/i][/i]
[i][i] [/i][/i]
[i][i]#proxy_set_header Host $host;[/i][/i]
[i][i] [/i][/i]
[i][i]#proxy_set_header X-Forwarded-For $remote_addr;[/i][/i]
[i][i] [/i][/i]
[i][i]#proxy_set_header REMOTE_ADDR $remote_addr;[/i][/i]
[i][i] [/i][/i]
[i][i]#proxy_set_header X-Real-IP $remote_addr;[/i][/i]
[i][i] [/i][/i]
[i][i]proxy_read_timeout 60s;[/i][/i]
[i][i] [/i][/i]
[i][i]client_max_body_size 1m;[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i] [/i][/i]
[i][i]error_page [/i][/i]
[i][i]500[/i][/i][i] [/i]
[i][i]502[/i][/i][i] [/i]
[i][i]503[/i][/i][i] [/i]
[i][i]504[/i][/i][i] [/i]
[i][i]/50x.html;[/i][/i]
[i][i] [/i][/i]
[i][i]location = /50x.html{[/i][/i]
[i][i] [/i][/i]
[i][i]root html;[/i][/i]
[i][i] [/i][/i]
[i][i]}[/i][/i]
[i][i]}[/i][/i]
[i][i]OutputStream outStream = conn.getOutputStream();[/i][/i]
[i][i]outStream.write(data);[/i][/i]
[i][i]outStream.flush();[/i][/i]
[i][i]outStream.close();[/i][/i]
[i][i] [/i][/i][i] [/i]
[i][i]if[/i][/i][i] [/i]
[i][i](conn.getResponseCode() == [/i][/i]
[i][i]200[/i][/i]
[i][i]) {[/i][/i]
[i][i] BufferedReader in = [/i][/i]
[i][i]new[/i][/i][i] [/i]
[i][i]BufferedReader([/i][/i]
[i][i]new[/i][/i][i] [/i]
[i][i]InputStreamReader((InputStream) conn.getInputStream(), [/i][/i]
[i][i]"UTF-8"[/i][/i]
[i][i]));[/i][/i]
[i][i] String msg = in.readLine();[/i][/i]
[i][i] System.out.println([/i][/i]
[i][i]"msg = "[/i][/i][i] [/i]
[i][i]+ msg);[/i][/i]
[i][i] in.close();[/i][/i]
[i][i]}[/i][/i]
[i][i]conn.disconnect();[/i][/i]
[i][i]ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);[/i][/i]
[i][i]/**[/i][/i]
[i][i] [/i][/i]
[i][i]* 响应报文处理[/i][/i]
[i][i] [/i][/i]
[i][i]* @param channel 当前上下文Channel[/i][/i]
[i][i] [/i][/i]
[i][i]* @param status 响应码[/i][/i]
[i][i] [/i][/i]
[i][i]* @param msg 响应消息[/i][/i]
[i][i] [/i][/i]
[i][i]* @param forceClose 是否强制关闭[/i][/i]
[i][i] [/i][/i]
[i][i]*/[/i][/i]
[i][i]private[/i][/i][i] [/i]
[i][i]void[/i][/i][i] [/i]
[i][i]writeResponse(Channel channel, HttpResponseStatus status, String msg, [/i][/i]
[i][i]boolean[/i][/i][i] [/i]
[i][i]forceClose){[/i][/i]
[i][i] ByteBuf byteBuf = Unpooled.wrappedBuffer(msg.getBytes());[/i][/i]
[i][i] response = [/i][/i]
[i][i]new[/i][/i][i] [/i]
[i][i]DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, byteBuf);[/i][/i]
[i][i] [/i][/i]
[i][i]boolean[/i][/i][i] [/i]
[i][i]close = isClose();[/i][/i]
[i][i] [/i][/i]
[i][i]if[/i][/i]
[i][i](!close && !forceClose){[/i][/i]
[i][i] response.headers().add(org.apache.http.HttpHeaders.CONTENT_LENGTH, String.valueOf(byteBuf.readableBytes()));[/i][/i]
[i][i] }[/i][/i]
[i][i] ChannelFuture future = channel.write(response);[/i][/i]
[i][i] [/i][/i]
[i][i]if[/i][/i]
[i][i](close || forceClose){[/i][/i]
[i][i] future.addListener(ChannelFutureListener.CLOSE);[/i][/i]
[i][i] }[/i][/i]
[i][i] }[/i][/i]
[i][i]private[/i][/i][i] [/i]
[i][i]boolean[/i][/i][i] [/i]
[i][i]isClose(){[/i][/i]
[i][i] [/i][/i]
[i][i]if[/i][/i]
[i][i](request.headers().contains(org.apache.http.HttpHeaders.CONNECTION, CONNECTION_CLOSE, [/i][/i]
[i][i]true[/i][/i]
[i][i]) ||[/i][/i]
[i][i] (request.protocolVersion().equals(HttpVersion.HTTP_1_0) &&[/i][/i]
[i][i] !request.headers().contains(org.apache.http.HttpHeaders.CONNECTION, CONNECTION_KEEP_ALIVE, [/i][/i]
[i][i]true[/i][/i]
[i][i])))[/i][/i]
[i][i] [/i][/i]
[i][i]return[/i][/i][i] [/i]
[i][i]true[/i][/i]
[i][i];[/i][/i]
[i][i] [/i][/i]
[i][i]return[/i][/i][i] [/i]
[i][i]false[/i][/i]
[i][i];[/i][/i]
[i][i]}[/i][/i]
[i]首先,TCP 的 KeepAlive 是 TCP 连接的探测机制,用来检测当前 TCP 连接是否活着。[/i]
[i]而对于 HTTP 的 KeepAlive,则是让 TCP 连接活长一点,在一次 TCP 连接中可以持续发送多份数据而不会断开连接。[/i]
本文为 @ 21CTO 创作并授权 21CTO 发布,未经许可,请勿转载。
内容授权事宜请您联系 webmaster@21cto.com或关注 21CTO 公众号。
该文观点仅代表作者本人,21CTO 平台仅提供信息存储空间服务。