Netty学习笔记(一):接收nodejs模拟表单上传的文件
好久不写博客了,也好久不写代码了,这两天临时遇上一个事情,觉得不难,加上觉得手有些生,就动手做了一下,结果遇上了不少坑,有新坑,有老坑,痛苦无比,现在总算差不多了,赶紧记录下来,希望以后不再重复这种痛苦。
事情很简单,用nodejs模拟表单提交,上传文件到netty服务器。
1、netty的参考资料很多,目前有netty3,netty4两个版本,netty5出到alpha 2版本,不知道怎么的,就不更新了,官网也注明不支持了,所以我采用的是netty4.1.19版,目前最新的。
参考的资料大致如下
1)http://netty.io/wiki/index.html,官方的文档,都写的很经典,值得学习,里面的例子snoop对我帮助很大
2)https://www.programcreek.com/,一个示例代码的网站。
netty的代码基本都是照抄第二个网站的内容,具体地址是https://www.programcreek.com/java-api-examples/index.php?source_dir=netty4.0.27Learn-master/example/src/main/java/io/netty/example/http/upload/HttpUploadServer.java。共有三个文件,HttpUploadServer.java,HttpUploadServerHandler.java,HttpUploadServerInitializer.java
2、nodejs本身比较简单,但也花了不少时间研究。上传文件可选的组件也很多,有form-data,request甚至官方的API,使用起来都不复杂,本来选择的是form-data,但是用起来也遇到了不少问题,最终使用的还是request,request使用起来非常简单,我主要参考了如下内容。
1)http://www.open-open.com/lib/view/open1435301679966.html 中文的,介绍的比较详细。
2)https://github.com/request/request 这是官方网站,内容最全,最权威。
3、详细环境
1)Windows 10专业版
2)Spring Tool Suite 3.9.1,其实用eclipse也可以
3)Netty 4.1.19
4)Nodejs 8.9.3
4、目标
1)Netty程序
a)同时支持post、get方法。
b)将cookie、get参数和post参数保存到map里,如果是文件上传,则将其保存到临时目录,返回web地址,供客户访问。
2)nodejs
a)同时支持get、post方法。
b)可以设置cookie,因为上传文件肯定是需要登录的,sessionID一般是保存在cookie里面。
5、预期思路
1)先解决netty的服务端问题,客户端先用浏览器测试。
2)再解决nodejs的问题。
6、解决过程和踩的坑
1)Netty
a)Netty编程本身不难,但是相对来说要底层一些,如果经常做web开发的人,可能容易困惑,但熟悉一下就好了。
一般来说,netty服务端程序分为三个程序,如下
Server:启动线程,保定端口。
Initializer:初始化流处理器,即将接收到的字节流先进行编码,形成对象,供后续解码器处理,我们需要关注的东西不多,在这个程序里,我们拿到手的已经是解析好的http对象了,只要按照我们的思路处理就可以了。
Handler:是我们自己的逻辑,在这个例子里就是解析对象,形成map,将文件保存到磁盘上而已。
b)首先是pom文件,如下
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io-netty</groupId>
<artifactId>io-netty-example</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>io-netty-example</name>
<url>http://maven.apache.org</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/io.netty/netty-codec-http -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.19.Final</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.44</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>3.8.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
c)Server非常简单,代码也不多,如下
package io.netty.example.http.upload;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.util.SelfSignedCertificate;
/**
* A HTTP server showing how to use the HTTP multipart package for file uploads and decoding post data.
*/
public final class HttpUploadServer {
static final boolean SSL = System.getProperty("ssl") != null;
static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8443" : "8090"));
public static void main(String[] args) throws Exception {
// Configure SSL.
final SslContext sslCtx;
if (SSL) {
SelfSignedCertificate ssc = new SelfSignedCertificate();
sslCtx = SslContext.newServerContext(ssc.certificate(), ssc.privateKey());
} else {
sslCtx = null;
}
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup);
b.channel(NioServerSocketChannel.class);
b.handler(new LoggingHandler(LogLevel.INFO));
b.childHandler(new HttpUploadServerInitializer(sslCtx)); //调用Initializer
Channel ch = b.bind(PORT).sync().channel();
System.err.println("Open your web browser and navigate to " +
(SSL? "https" : "http") + "://127.0.0.1:" + PORT + '/');
ch.closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
d)Initializer代码,需要注意的是流处理器,
/*
* Copyright 2012 The Netty Project
*
* The Netty Project licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.netty.example.http.upload;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpContentCompressor;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpRequestDecoder;
import io.netty.handler.codec.http.HttpResponseEncoder;
import io.netty.handler.ssl.SslContext;
public class HttpUploadServerInitializer extends ChannelInitializer<SocketChannel> {
private final SslContext sslCtx;
public HttpUploadServerInitializer(SslContext sslCtx) {
this.sslCtx = sslCtx;
}
@Override
public void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
if (sslCtx != null) {
pipeline.addLast(sslCtx.newHandler(ch.alloc()));
}
pipeline.addLast(new HttpRequestDecoder()); //处理Request
// Uncomment the following line if you don't want to handle HttpChunks.
//pipeline.addLast(new HttpObjectAggregator(1048576)); //将对象组装为FullHttpRequest
pipeline.addLast(new HttpResponseEncoder()); //处理Response
// Remove the following line if you don't want automatic content compression.
pipeline.addLast(new HttpContentCompressor()); //压缩
pipeline.addLast(new HttpUploadServerHandler());
}
}
这里需要注意一点,采用HttpRequestDecoder处理器,会将一个Request对象解析成三个对象HttpRequest、HttpCotent、LastHttpContent,这三个对象大致是这样的,HttpRequest是地址信息和头部信息,其中包括get方式传送的参数和cookie信息;HttpContent是消息体,即Body部分,即post方式form提交的内容;LastHttpContent则是消息体的末尾,即提示消息体结束,也就是整个请求结束。
但是需要注意的是,使用HttpObjectAggregator处理器,可以将Request对象处理为FullRequest,但我测试了一下,不知道为什么,竟然卡死了,所以只好用这种笨办法,以后研究一下,这次先这样吧。
e)Handler的代码有些长,不过还是贴出来吧。
/*
* Copyright 2012 The Netty Project
*
* The Netty Project licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.netty.example.http.upload;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.Cookie;
import io.netty.handler.codec.http.CookieDecoder;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpObject;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.codec.http.ServerCookieEncoder;
import io.netty.handler.codec.http.multipart.Attribute;
import io.netty.handler.codec.http.multipart.DefaultHttpDataFactory;
import io.netty.handler.codec.http.multipart.DiskAttribute;
import io.netty.handler.codec.http.multipart.DiskFileUpload;
import io.netty.handler.codec.http.multipart.FileUpload;
import io.netty.handler.codec.http.multipart.HttpDataFactory;
import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder;
import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.EndOfDataDecoderException;
import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder.ErrorDataDecoderException;
import io.netty.handler.codec.http.multipart.InterfaceHttpData;
import io.netty.handler.codec.http.multipart.InterfaceHttpData.HttpDataType;
import io.netty.util.CharsetUtil;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.alibaba.fastjson.JSON;
import static io.netty.buffer.Unpooled.*;
import static io.netty.handler.codec.http.HttpHeaders.Names.*;
public class HttpUploadServerHandler extends SimpleChannelInboundHandler<HttpObject> {
private static final Logger logger = Logger.getLogger(HttpUploadServerHandler.class.getName());
private HttpRequest request;
private boolean readingChunks;
private final StringBuilder responseContent = new StringBuilder();
private static final HttpDataFactory factory = new DefaultHttpDataFactory(DefaultHttpDataFactory.MINSIZE); // Disk
// if
// size
// exceed
private HttpPostRequestDecoder decoder;
//////
private String tempPath = "d:/upload/"; //文件保存目录
private String url_path = "http://localhost/upload/"; //文件临时web目录
private String errorJson;
private Map<String, Object> mparams = new HashMap<>(); //将参数保存到map里面
static {
DiskFileUpload.deleteOnExitTemporaryFile = true; // should delete file
// on exit (in normal
// exit)
DiskFileUpload.baseDirectory = null; // system temp directory
DiskAttribute.deleteOnExitTemporaryFile = true; // should delete file on
// exit (in normal exit)
DiskAttribute.baseDirectory = null; // system temp directory
}
@Override
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
if (decoder != null) {
decoder.cleanFiles();
}
}
//处理输入对象,会执行三次,分别是HttpRequest、HttpContent、LastHttpContent
@Override
public void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {
if (msg instanceof HttpRequest) {
HttpRequest request = this.request = (HttpRequest) msg;
URI uri = new URI(request.getUri());
if (!uri.getPath().equals("/formpostmultipart")) {
errorJson = "{code:-1}";
writeError(ctx, errorJson);
return;
}
// new getMethod
// for (Entry<String, String> entry : request.headers()) {
// responseContent.append("HEADER: " + entry.getKey() + '=' + entry.getValue() +
// "\r\n");
// }
// new getMethod
Set<Cookie> cookies;
String value = request.headers().get(COOKIE);
if (value == null) {
cookies = Collections.emptySet();
} else {
cookies = CookieDecoder.decode(value);
}
for (Cookie cookie : cookies) {
mparams.put(cookie.getName(), cookie.getValue());
}
// add
System.out.println(JSON.toJSONString(mparams));
QueryStringDecoder decoderQuery = new QueryStringDecoder(request.getUri());
Map<String, List<String>> uriAttributes = decoderQuery.parameters();
// add
mparams.putAll(uriAttributes);
System.out.println(JSON.toJSONString(mparams));
// for (Entry<String, List<String>> attr: uriAttributes.entrySet()) {
// for (String attrVal: attr.getValue()) {
// responseContent.append("URI: " + attr.getKey() + '=' + attrVal + "\r\n");
// }
// }
// responseContent.append("\r\n\r\n");
if (request.getMethod().equals(HttpMethod.GET)) {
// GET Method: should not try to create a HttpPostRequestDecoder
// So stop here
// responseContent.append("\r\n\r\nEND OF GET CONTENT\r\n");
// Not now: LastHttpContent will be sent writeResponse(ctx.channel());
return;
}
try {
decoder = new HttpPostRequestDecoder(factory, request);
} catch (ErrorDataDecoderException e1) {
e1.printStackTrace();
responseContent.append(e1.getMessage());
writeResponse(ctx.channel());
ctx.channel().close();
errorJson = "{code:-2}";
writeError(ctx, errorJson);
return;
}
readingChunks = HttpHeaders.isTransferEncodingChunked(request);
// responseContent.append("Is Chunked: " + readingChunks + "\r\n");
// responseContent.append("IsMultipart: " + decoder.isMultipart() + "\r\n");
if (readingChunks) {
// Chunk version
// responseContent.append("Chunks: ");
readingChunks = true;
}
}
// check if the decoder was constructed before
// if not it handles the form get
if (decoder != null) {
if (msg instanceof HttpContent) {
// New chunk is received
HttpContent chunk = (HttpContent) msg;
try {
decoder.offer(chunk);
} catch (ErrorDataDecoderException e1) {
e1.printStackTrace();
// responseContent.append(e1.getMessage());
writeResponse(ctx.channel());
ctx.channel().close();
errorJson = "{code:-3}";
writeError(ctx, errorJson);
return;
}
// responseContent.append('o');
// example of reading chunk by chunk (minimize memory usage due to
// Factory)
readHttpDataChunkByChunk(ctx);
// example of reading only if at the end
if (chunk instanceof LastHttpContent) {
writeResponse(ctx.channel());
readingChunks = false;
reset();
}
}
} else {
writeResponse(ctx.channel());
}
}
private void reset() {
request = null;
// destroy the decoder to release all resources
decoder.destroy();
decoder = null;
}
/**
* Example of reading request by chunk and getting values from chunk to chunk
*
* @throws IOException
*/
//处理post数据
private void readHttpDataChunkByChunk(ChannelHandlerContext ctx) throws IOException {
try {
while (decoder.hasNext()) {
InterfaceHttpData data = decoder.next();
if (data != null) {
try {
// new value
writeHttpData(ctx, data);
} finally {
data.release();
}
}
}
} catch (EndOfDataDecoderException e1) {
// end
// responseContent.append("\r\n\r\nEND OF CONTENT CHUNK BY CHUNK\r\n\r\n");
mparams.put("code", "-2");
}
}
//解析post属性,保存文件,写入map
private void writeHttpData(ChannelHandlerContext ctx, InterfaceHttpData data) throws IOException {
if (data.getHttpDataType() == HttpDataType.Attribute) {
Attribute attribute = (Attribute) data;
String value;
try {
value = attribute.getValue();
} catch (IOException e1) {
// Error while reading data from File, only print name and error
e1.printStackTrace();
// responseContent.append("\r\nBODY Attribute: " +
// attribute.getHttpDataType().name() + ": "
// + attribute.getName() + " Error while reading value: " + e1.getMessage() +
// "\r\n");
errorJson = "{code:-4}";
writeError(ctx, errorJson);
return;
}
mparams.put(attribute.getName(), attribute.getValue());
System.out.println(JSON.toJSONString(mparams));
} else {
if (data.getHttpDataType() == HttpDataType.FileUpload) {
FileUpload fileUpload = (FileUpload) data;
if (fileUpload.isCompleted()) {
System.out.println(fileUpload.length());
if (fileUpload.length() > 0) {
String orign_name = fileUpload.getFilename();
String file_name = UUID.randomUUID() + "."
+ orign_name.substring(orign_name.lastIndexOf(".") + 1);
fileUpload.renameTo(new File(tempPath + file_name));
mparams.put(data.getName(), url_path + file_name);
System.out.println(JSON.toJSONString(mparams));
}
} else {
errorJson = "{code:-5}";
writeError(ctx, errorJson);
}
}
}
}
//写入response,返回给客户
private void writeResponse(Channel channel) {
// Convert the response content to a ChannelBuffer.
ByteBuf buf = copiedBuffer(JSON.toJSONString(mparams), CharsetUtil.UTF_8);
responseContent.setLength(0);
// Decide whether to close the connection or not.
boolean close = HttpHeaders.Values.CLOSE.equalsIgnoreCase(request.headers().get(CONNECTION))
|| request.getProtocolVersion().equals(HttpVersion.HTTP_1_0)
&& !HttpHeaders.Values.KEEP_ALIVE.equalsIgnoreCase(request.headers().get(CONNECTION));
// Build the response object.
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, buf);
response.headers().set(CONTENT_TYPE, "text/plain; charset=UTF-8");
if (!close) {
// There's no need to add 'Content-Length' header
// if this is the last response.
response.headers().set(CONTENT_LENGTH, buf.readableBytes());
}
Set<Cookie> cookies;
String value = request.headers().get(COOKIE);
if (value == null) {
cookies = Collections.emptySet();
} else {
cookies = CookieDecoder.decode(value);
}
if (!cookies.isEmpty()) {
// Reset the cookies if necessary.
for (Cookie cookie : cookies) {
response.headers().add(SET_COOKIE, ServerCookieEncoder.encode(cookie));
}
}
// Write the response.
ChannelFuture future = channel.writeAndFlush(response);
// Close the connection after the write operation is done if necessary.
if (close) {
future.addListener(ChannelFutureListener.CLOSE);
}
}
//返回错误信息,也是写入response
private void writeError(ChannelHandlerContext ctx, String errorJson) {
ByteBuf buf = copiedBuffer(errorJson, CharsetUtil.UTF_8);
// Build the response object.
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, buf);
response.headers().set(CONTENT_TYPE, "text/html; charset=UTF-8");
response.headers().set(CONTENT_LENGTH, buf.readableBytes());
// Write the response.
ctx.channel().writeAndFlush(response);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
logger.log(Level.WARNING, responseContent.toString(), cause);
ctx.channel().close();
}
}
虽然代码很多,但是最需要注意的只有四个方法:
channelRead0(ChannelHandlerContext ctx, HttpObject msg):处理输入内容,会执行三次,分别是HttpRequest、HttpContent、LastHttpContent,依次处理。
readHttpDataChunkByChunk(ChannelHandlerContext ctx):解析HttpContent时调用,即消息体时,具体执行过程在函数writeHttpData中
writeResponse(Channel channel):写入response,这里调用了fastjson将map转换为json字符串。
f)上传的html文件
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>HTML5的标题</title>
</head>
<body>
<form action="http://127.0.0.1:8090/formpostmultipart?a=张三&b=李四" method="post" enctype="multipart/form-data">
<input type="text" name="name" value="shiyq"/>
<br>
<input type="text" name="name" value="历史地理"/>
<br>
<input type=file name="file"/>
<br>
<input type="submit" value="上传"/>
</form>
</body>
</html>
g)启动HttpUploadServer,然后在浏览器里访问upload.html,返回结果如下
{
"name": "历史地理",
"a": [
"张三"
],
"b": [
"李四"
],
"file": "http://localhost/upload/13d45df8-d6c7-4a7a-8f21-0251efeca240.png"
}
这里要注意的是,地址栏传递的参数是个数组,即参数名可以重复,form里面的值不可以,只能是一个。
2)NodeJS
nodejs相对要简单一些,但是也更让人困惑,主要遇到了两个问题。
a)请求地址包含中文的情况,这个其实是个老问题,很容易解决,但是却卡住了半天,看来很久不写程序就是不行啊。最后的解决办法就是进行url编码。
b)cookie设置的问题,form-data模块没有说明cookie设置的问题,官方API的request也言之不详,幸好request写的比较明白,但是默认还不开启,需要设置,还是老话,三天不写程序手就生了。
c)环境非常简单,只需要安装request模块就可以了,命令为npm install request,尽量不要装在全局,在我的Windows 10上出现找不到模块的现象,最后安装到当前目录才解决,最后的代码如下
var fs = require('fs');
var request = require('request').defaults({jar:true}); //不要忘记npm install request,不要忘记设置jar:true,否则无法设置cookied
var file_path="D:/Documents/IMG_20170427_121431.jpg"
var formData = {
name:"路送双",
code:"tom",
my_file:fs.createReadStream(file_path)
}
var url = encodeURI("http://localhost:8090/formpostmultipart?a=王二&a=张三&b=李四");//对中文编码
var j = request.jar();
var cookie = request.cookie('key1=value1');
var cookie1 = request.cookie('key2=value2');
j.setCookie(cookie, url);
j.setCookie(cookie1, url);
request.post({url:url, jar:j, formData: formData}, function optionalCallback(err, httpResponse, body) {
if (err) {
return console.error('upload failed:', err);
}
console.log( body);
});
需要注意cookie的设置,不仅需要设置jar属性为true,还需要调用多次setCookie,还需要在request.post中指定参数,挺麻烦的。
d)返回结果如下
{
"key1": "value1",
"key2": "value2",
"a": [
"王二",
"张三"
],
"b": [
"李四"
],
"code": "tom",
"my_file": "http://localhost/upload/8d8e2f9f-7513-4844-9614-0d7fb7a33a6e.jpg",
"name": "路送双"
}
7、结论
其实是个很简单的问题,不过研究过程有些长,而且很笨拙,如果用FullHttpRequest,代码会少很多,以后再研究吧。