WebSocket协议开发

由于HTTP协议的开销,导致它们不适用于低延迟应用。为了解决这些问题,WebSocket将网络套接字引入到客户端和服务端,浏览器和服务器之间可以通过套接字建立持久的连接,双方随时都可以互发数据给对方,而不是之前由客户端控制的一请一求模式。

HTTP协议介绍

HTTP协议的主要特点如下:

  • 支持Client/Server模式
  • 简单————客户向服务端请求服务时,只需指定服务URL,携带必须的请求参数或者消息体;
  • 灵活————HTTP允许传输任意类型的数据对象,传输的内容类型由HTTP消息头中的Content-Type加以标记
  • 无状态————HTTP协议是无状态协议,无状态是指协议对事务处理没有记忆能力。缺少状态意味着后续处理需要之前的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务端不需要先前信息时它的应答就较快,负载较轻。

HTTP协议的URL

HTTP URL(URL是一种特殊类型的URL,包含了某个资源的足够的信息)的格式如下。
http://host[":"port][abs_path]
http:表示要通过HTTP协议来定位网络资源;
host:表示合法的Internet主机域名或者IP地址;
port:指定一个端口号,为空则使用默认80端口;
abs_path:指定请求资源的URI,如果URL中没有给出abs_path,那么当它作为请求URI时,必须以“/”的形式给出,通常这点工作浏览器会自动帮我们完成。

HTTP请求消息(HttpRequest)

HTTP请求由三部分组成,具体如下。

  • HTTP请求行;
  • HTTP请求头;
  • HTTP请求正文。

请求行以一个方法符开头、以空格分开,后面跟着请求的URI和协议版本,格式为:
Method Request-URI HTTP-version CRLF
其中Method表示请求方法,Request-URI是一个统一资源标识符,HTTP-Version表示请求的HTTP协议版本,CRLF表示回车和换行(除了作为结尾的CRLF外,不允许出现单独的CRLF字符)。
请求方法有多种,各方法作用如下:

  • GET:请求获取Request-URI所标识的资源
  • POST:在Request-URI所标识的资源后附加新的提交数据;
  • HEAD:请求获取由Request-URL所标识的资源的响应消息报头;
  • PUT:请求服务器存储一个资源,并用Request-URI作为其标识;
  • DELETE:请求服务器删除Request-URI作为其标识;
  • TRACE:请求服务器回送收到的请求消息,主要用于测试或者诊断;
  • CONNECT:保留将来使用;
  • OPTIONS:请求查询服务器的性能,或者查询与资源相关的选项和需求。

GET方法:在浏览器的地址栏中输入网址的方式访问网页时,浏览器采用GET方法向服务器获取资源。如下图1所示。



图1 通过浏览器访问Netty HTTP服务端

通过服务端抓包,打印HTTP请求消息头,内容如下。


POST方法要求被请求服务端解释器附在请求后面的数据,常用于提交表单,GET一般用于获取/查询资源信息,而POST一般用于更新资源信息,GET和POST的主要区别如下。

  1. 根据HTTP规范,GET用于信息获取,而且应该是安全的和幂等的;POST则表示可能改变服务器上资源的请求。
  2. GET提交,请求的数据会附在URL之后,就是把数据放置在请求行(request line)中,以“?”分隔URL和传输数据,多个参数用“&”连接;而POST提交会把数据放置在HTTP消息的包体中,数据不会再地址栏中显示出来。
  3. 传输数据的大小不同。特定浏览器和服务器对URL长度有限制,例如IE对URL的长度的限制是2038字节(2KB+35B),因此GET携带的参数的长度会受到浏览器的限制;而POST由于不是通过URL传值,理论上数据长度不会受限。
  4. 安全性。POST的安全性要比GET的安全性高。比如通过GET提交数据,用户名和密码将明文出现在URL上。因为登录页面有可能被浏览器缓存和其他人查看浏览器的历史记录,那么别人就可以拿到你的账号和密码了。除此之外,使用GET提交数据还可能会造成Cross-site request forgery攻击。POST提交的内容由于在消息体中传输,因此不存在上述安全问题。
    请求报头允许客户端向服务端传递请求的附加信息以及客户端自身的信息。常用的请求报头如表1所示。
    表1 HTTP的部分请求消息头列表

HTTP响应消息(HttpResponse)

处理完HTTP客户端的请求之后,HTTP服务端返回响应消息给客户端,HTTP响应由三部分组成,分别是:状态行、消息报头、响应正文。
状态行的格式为:HTTP-Version Status-Code Reason-Phrase CRLF,其中HTTP-Version表示服务器HTTP协议版本,Status-Code表示服务器返回的响应状态代码。
状态代码由三位数组成,第一个数字定义了响应的类型,它有5种可能的取值。

  • 1xx:指示消息。表示请求已接收,继续处理;
  • 2xx:成功。表示请求已被成功接收、理解、接受
  • 3xx:重定向。要完成请求必须进行更进一步的操作;
  • 4xx:客户端错误。请求有语法错误或请求无法实现。
  • 5xx:服务器端错误。服务端未能处理请求。

常见的状态代码、状态描述如表2所示。
表2 HTTP响应状态代码和描述信息


响应报头运行服务器传递不能放在状态行中的附加响应信息,以及关于服务器的信息和对Request-URI所标识的资源进行下一步访问的信息。常用的响应报头如表3所示。
表3 常用的响应报头


Netty HTTP服务端入门开发

我们通过文件服务器为例学习Netty的HTTP服务端入门开发。

HTTP服务端开发

代码清单1 HTTP文件服务器 业务逻辑处理HttpFileServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
public class HttpFileServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private final String url;
public HttpFileServerHandler(String url) {
this.url = url;
}
@Override
public void messageReceived(ChannelHandlerContext ctx,
FullHttpRequest request) throws Exception {
/*
* 对HTTP请求消息的解码结果进行判断,如果解码失败,直接构造HTTP400错误返回
*/
if (!request.getDecoderResult().isSuccess()) {
sendError(ctx, BAD_REQUEST);
return;
}
/*
* 对请求行中的方法进行判断,如果不是从浏览器或者表单设置为GET发起的请求(例如POST),
* 则构造HTTP405错误返回。
*/
if (request.getMethod() != GET) {
sendError(ctx, METHOD_NOT_ALLOWED);
return;
}
//对请求URL进行包装,然后sanitizeUri方法展开分析
final String uri = request.getUri();
final String path = sanitizeUri(uri);
//如果构建的URI不合法,则返回HTTP403错误
if (path == null) {
sendError(ctx, FORBIDDEN);
return;
}
//使用新数组装的URI路径构造File对象
File file = new File(path);
//如果文件不存在或者文件系统隐藏文件,则构造HTTP404异常返回。
//如果文件时目录,则发送目录的链接给客户端浏览器
if (file.isHidden() || !file.exists()) {
sendError(ctx, NOT_FOUND);
return;
}
if (file.isDirectory()) {
if (uri.endsWith("/")) {
sendListing(ctx, file);
} else {
sendRedirect(ctx, uri + '/');
}
return;
}
/*
* 如果用户在浏览器上点击超链接直接打开或者下载文件,对超链接的文件进行合理性判断,
* 如果不是合法文件,则返回403错误。
* 校验通过后,使用随机文件读写类以只读的方式打开文件,如果文件打开失败,则返回404错误
*/
if (!file.isFile()) {
sendError(ctx, FORBIDDEN);
return;
}
RandomAccessFile randomAccessFile = null;
try {
randomAccessFile = new RandomAccessFile(file, "r");// 以只读的方式打开文件
} catch (FileNotFoundException fnfe) {
sendError(ctx, NOT_FOUND);
return;
}
/*
* 获取文件的长度,构造成功的HTTP应答消息,然后在消息头中设置content length和contenttype,
* 判断是否Keep-Alive,如果是,则在应答消息头中设置为Keep-Alive
*/
long fileLength = randomAccessFile.length();
HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
setContentLength(response, fileLength);
setContentTypeHeader(response, file);
if (isKeepAlive(request)) {
response.headers().set(CONNECTION, HttpHeaders.Values.KEEP_ALIVE);
}
//响应消息
ctx.write(response);
ChannelFuture sendFileFuture;
//通过Netty的ChunkedFiled对象直接将文件写入到发送缓冲区中。
sendFileFuture = ctx.write(new ChunkedFile(randomAccessFile, 0,
fileLength, 8192), ctx.newProgressivePromise());
sendFileFuture.addListener(new ChannelProgressiveFutureListener() {
@Override
public void operationProgressed(ChannelProgressiveFuture future,
long progress, long total) {
if (total < 0) { // total unknown
System.err.println("Transfer progress: " + progress);
} else {
System.err.println("Transfer progress: " + progress + " / "
+ total);
}
}
//最后为sendFileFuture增加GenericFutureListener,如果发送完成,打印“Transfer complete"
@Override
public void operationComplete(ChannelProgressiveFuture future)
throws Exception {
System.out.println("Transfer complete.");
}
});
/*
* 如果使用chunked编码,最后需要发送一个编码结束的空消息体,将LastHttpContent
* 的EMPTY_LAST_CONTENT发送到缓冲区,标识所有的消息体已经发送完成,
* 同时调用flush方法将之前的发送缓冲区的消息刷新到SocketChannel中发送给对方
*/
ChannelFuture lastContentFuture = ctx
.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
//如果非Keep-Alive的,最后一包消息发送完成之后,服务端要主动关闭连接
if (!isKeepAlive(request)) {
lastContentFuture.addListener(ChannelFutureListener.CLOSE);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause)
throws Exception {
cause.printStackTrace();
if (ctx.channel().isActive()) {
sendError(ctx, INTERNAL_SERVER_ERROR);
}
}
private static final Pattern INSECURE_URI = Pattern.compile(".*[<>&\"].*");
private String sanitizeUri(String uri) {
/*
* 首先使用JDK的java.net.URLDecoder对URL进行解码,
* 使用UTF-8字符集,解码成功之后对URI进行合理性判断,
* 如果URI与允许访问的URI一致或者其子目录(文件),
* 则通过校验,否则返回空
*/
try {
uri = URLDecoder.decode(uri, "UTF-8");
} catch (UnsupportedEncodingException e) {
try {
uri = URLDecoder.decode(uri, "ISO-8859-1");
} catch (UnsupportedEncodingException e1) {
throw new Error();
}
}
if (!uri.startsWith(url)) {
return null;
}
if (!uri.startsWith("/")) {
return null;
}
//将硬编码的文件路径分隔符替换为本地操作系统的文件分隔符
uri = uri.replace('/', File.separatorChar);
if (uri.contains(File.separator + '.')
/*
* 对新的URI做二次合法性校验,如果校验失败则直接返回空。最后对文件进行拼接,
* 使用当前运行程序所在的工程目录+URI改造绝对路径返回。
*/
|| uri.contains('.' + File.separator) || uri.startsWith(".")
|| uri.endsWith(".") || INSECURE_URI.matcher(uri).matches()) {
return null;
}
return System.getProperty("user.dir") + File.separator + uri;
}
private static final Pattern ALLOWED_FILE_NAME = Pattern
.compile("[A-Za-z0-9][-_A-Za-z0-9\\.]*");
private static void sendListing(ChannelHandlerContext ctx, File dir) {
/*
* 首先创建成功HTTP响应消息,随后设置消息头的类型为“text/html;charset=UTF-8”
*/
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK);
response.headers().set(CONTENT_TYPE, "text/html; charset=UTF-8");
/*
* 用于构建响应消息体,由于需要将响应结果显示在浏览器上,所以采用了HTML格式。
*/
StringBuilder buf = new StringBuilder();
String dirPath = dir.getPath();
buf.append("<!DOCTYPE html>\r\n");
buf.append("<html><head><title>");
buf.append(dirPath);
buf.append(" 目录:");
buf.append("</title></head><body>\r\n");
buf.append("<h3>");
buf.append(dirPath).append(" 目录:");
buf.append("</h3>\r\n");
buf.append("<ul>");
//打印了一个..的链接
buf.append("<li>链接:<a href=\"../\">..</a></li>\r\n");
/*
* 用于展示根目录下的所有文件和文件夹,同时使用超链接来标识
*/
for (File f : dir.listFiles()) {
if (f.isHidden() || !f.canRead()) {
continue;
}
String name = f.getName();
if (!ALLOWED_FILE_NAME.matcher(name).matches()) {
continue;
}
buf.append("<li>链接:<a href=\"");
buf.append(name);
buf.append("\">");
buf.append(name);
buf.append("</a></li>\r\n");
}
buf.append("</ul></body></html>\r\n");
//分配对应消息的缓存区对象
ByteBuf buffer = Unpooled.copiedBuffer(buf, CharsetUtil.UTF_8);
/*
* 将缓冲区中响应消息存放到HTTP应答消息中,然后释放缓冲区,
* 最后调用writeAndFlush将响应消息发送到缓冲区并刷新到SocketChannel中。
*/
response.content().writeBytes(buffer);
buffer.release();
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void sendRedirect(ChannelHandlerContext ctx, String newUri) {
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, FOUND);
response.headers().set(LOCATION, newUri);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void sendError(ChannelHandlerContext ctx,
HttpResponseStatus status) {
FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1,
status, Unpooled.copiedBuffer("Failure: " + status.toString()
+ "\r\n", CharsetUtil.UTF_8));
response.headers().set(CONTENT_TYPE, "text/plain; charset=UTF-8");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void setContentTypeHeader(HttpResponse response, File file) {
MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
response.headers().set(CONTENT_TYPE,
mimeTypesMap.getContentType(file.getPath()));
}
}

代码清单2 HTTP文件服务器 启动类HttpFileServer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class HttpFileServer {
private static final String DEFAULT_URL = "/src/com/eric/netty/";
public void run(final int port, final String url) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
/*
* 先向ChannelPipeline中添加HTTP请求消息解码器,
* 随后又添加了HttpObjectAggregator解码器,
* 他的作用是将多个消息转换为单一的FullHttpRequest或者FullHttpResponse,
* 原因是http解码器在每个HTTP消息中会生成多个消息对象
* 1 HttpRequest/HttpResponse;
* 2 HttpContent;
* 3 LastHttpContent.
*/
ch.pipeline().addLast("http-decoder",
new HttpRequestDecoder());
ch.pipeline().addLast("http-aggregator",
new HttpObjectAggregator(65536));
//新增HTTP响应解码器,对HTTP响应消息进行编码
ch.pipeline().addLast("http-encoder",
new HttpResponseEncoder());
/*
*新增Chunked handler它的主要作用是支持异步发送大的码流(例如大的文件传输)
*但占用过多的内存,防止发生Java内存溢出错误。
*/
ch.pipeline().addLast("http-chunked",
new ChunkedWriteHandler());
ch.pipeline().addLast("fileServerHandler",
new HttpFileServerHandler(url));
}
});
ChannelFuture future = b.bind("127.0.0.1", port).sync();
System.out.println("HTTP文件目录服务器启动,网址是:" + "http://127.0.0.1:" + port + url);
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
int port = 8031;
if (args.length > 0) {
try {
port = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
String url = DEFAULT_URL;
if (args.length > 1)
url = args[1];
new HttpFileServer().run(port, url);
}
}

Netty Http文件服务器例程运行结果

运行结果如图1所示.



图2 运行结果

在浏览器打开网址:
http://127.0.0.1:8031/src/com/eric/netty/

看到浏览器上显示的是文件目录地址,如图2所示.



图3 文件目录地址

Netty权威指南第二版源代码

Adhere to the original technology to share, your support will encourage me to continue to create!