目录
一、简介
HTTP
(超文本传输协议
)协议是建立在TCP
传输协议之上的应用层协议,它的发展是万维网协会和Internet工作小组IETF合作的结果。HTTP是一个属于应用层的面向对象的协议。由于其简捷、快速的方式,适用于分布式超媒体信息系统。
由于HTTP协议是目前Web开发的主流协议,基于HTTP的应用非常广泛,因此,掌握HTTP的开发非常重要,本章主要介绍如何基于Netty的HTTP协议栈进行HTTP服务端和客户端开发。由于Netty的HTTP协议栈是基于Netty的NIO通信框架开发的,因此,Netty的HTTP协议也是异步非阻塞
的。
使用的netty版本:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>5.0.0.Alpha2</version>
</dependency>
二、HTTP协议介绍
HTTP协议的主要特点如下:
- 支持Client/Server模式;
- 简单——客户想服务器请求服务时,只需指定服务URL,携带必要的请求参数或者消息体;
- 灵活——HTTP允许传输任意类型的数据对象,传输的内容类型由
HTTP消息头
中的Content-Type
加以标记; - 无状态——HTTP协议是无状态协议,无状态是指协议对于事务处理没有记忆功能。缺少状态意味着如果后续处理又需要之前的信息,则它必须重传,这样可能会导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就比较快,负载较轻。
2.1 HTTP协议的URL
HTTP URL(URL是一种特殊类型的URI,包含了用于查找某个资源的足够信息)格式如下:
http://host[":"port][abs_path]
其中http
表示要通过HTTP协议来定位网络资源;
host
表示合法的Internet主机域名或者IP地址;
port
指定一个端口号,为空则使用默认端口80;
abs_path
指定请求资源的URI,如果URL中没有给出abs_path,那么当它作为请求URI时,必须以"/"的形式给出,通常这一点工作浏览器会自动帮我们完成。
2.2 HTTP请求消息(HttpRequest)
HTTP请求由三部分组成,具体如下:
- HTTP 请求头;
- HTTP 请求行;
- HTTP 请求正文;
请求头以一个方法符开头,以空格分开,后面跟着请求的URI和协议的版本,格式为:Method Request-URI HTTP-Version CRLF
其中Method
表示请求方法,请求方法有很多种:
- GET:获取或查询资源,是幂等的,请求的数据会附在URL后面,以
?
分割URL和传输数据,多个参数用&
拼接,传输的数据长度会有限制; - POST:提交数据,一般用于更新资源,提交的数据直接放在HTTP消息的包体中,传输的数据长度不会收到限制,POST的安全性要比GET高,
- PUT:请求服务器存储一个资源;
- DELETE:删除资源;
- HEAD:请求获取资源响应消息报头;
- TRACK:请求服务器回送收到的请求信息,主要用于测试或诊断;
- CONNECT:保留将来使用;
- OPTIONS:请求查询服务器性能,或者查询与资源相关的选项和需求;
常用的RESTFUL风格进行增删改查的就是前四种请求方法。
Request-URI
是一个统一资源标识符。
HTTP-Version
表示请求的HTTP协议的版本。
CRLF
表示回车和换行(除了作为结尾的CRLF之外,不允许出现单独的CR或者LF字符)。
HTTP的部分请求消息头列表:
名称(KEY) | 作用 |
---|---|
Accept | 用于指定客户端接收哪些类型的信息。例如:Accept:image/gif,表名客户端希望接收GIF图像格式的资源 |
Accept-Charset | 用于指定客户端接收的字符集 |
Accept-Encoding | 指定可接受的内容编码 |
Accept-Language | 指定一种自然语言 |
Authorization | 主要用于证明客户端是否有权查看某个资源,要求服务器对其进行认证 |
Host | 发送请求时,该报头域是必需的,用于指定被请求资源的Internet主机和端口号 |
User-Agent | 允许客户端将它的操作系统、浏览器和其他属性告诉服务器 |
Content-Length | 请求消息体的长度 |
Content-Type | 表示后面的文档属于什么MIME类型。Servlet默认为text/plain ,但通常需要显式的指定为text/html,由于经常要设置Content-Type,因此HTTPServletRequest提供了一个专用的方法setContentType |
Connectin | 连接类型 长连接/短连接 |
2.3 HTTP的响应消息(HttpResponse)
处理完HTTP客户端的请求之后,HTTP服务器返回响应消息给客户端,HTTP响应也是由三个部分组成,分别是:状态行、消息报文、响应正文。
状态行的格式为:HTTP-Version Status-Code Reason-Phrase CRLF
, 其中 Status-Code
表示服务器返回的响应状态码。状态码由三位数字组成,
第一个数组定义了响应的类别,它有五种可能的取值:
- 1xx:指示信息,表示请求已经接受,继续处理;
- 2xx:成功,表示请求已经被成功接收、理解、接受;
- 3xx:重定向,要完成请求必须进行更进一步的操作;
- 4xx:客户端错误,请求语法有错误或者请求无法实现;
- 5xx:服务器端错误,服务器未能处理请求;
常见的状态码:
状态码 | 描述 |
---|---|
200 | OK:客户端请求成功 |
400 | Bad Request:客户端请求语法有错误,不能被服务器所理解 |
401 | Unauthorized:请求未经授权 |
403 | Forbidden : 服务器收到请求,但是拒绝提供服务 |
404 | Not Found:请求资源不存在 |
500 | Internet Server Error:服务器发生不可预期错误 |
503 | Server Unavailable:服务器当前不能处理客户端的请求,一段时间后可能恢复正常。 |
响应报头允许服务器传递不能放在状态行中中的附加响应信息,以及关于服务器的信息和Request—URI所标识的资源进行下一步的访问信息,常用的响应报头如下:
名称 | 作用 |
---|---|
Location | 用于重定向接受者到一个新的位置,Location响应报文域常用于更换域名的时候。 |
Server | Bad Request:包含了服务器用来处理请求的软件信息,与User-Agent请求报文是相对应的。 |
WWW-Authenticate | 必须被包含在401(未授权的)响应信息中,客户端收到401消息,并发送Authorization报头域请求服务器对其进行校验,服务端响应报头就该包含该报头域。 |
三、Netty HTTP 服务端入门开发
简单介绍完HTTP协议,现在开始学习如何使用 Netty 的 HTTP协议栈开发HTTP服务端和客户端应用程序。由于Netty天生是异步事件驱动的结构,因此基于NIO TCP 协议栈开发的 HTTP 协议栈也是异步非阻塞的。
Netty 的 HTTP 协议栈无论在性能还是可靠性上,都表现优异,非常适合在非Web容器的场景下应用,相比于Tomcat、Jetty、等Web容器,它更加轻量和小巧。灵活性和定制性也更好。
3.1 HTTP服务端例程场景描述
我们使用文件服务器为例学习Netty的HTTP服务端入门开发,例程场景如下:
文件服务器使用HTTP协议对外提供服务,当客户端通过浏览器访问文件服务器时,对访问路径进行检查,检查失败时返回HTTP403错误,该网页无法访问;如果校验通过,以链接的方式打开当前文件目录,每个目录或者文件都是超链接,可以递归访问。
如果是目录,可以继续递归访问它下面的子目录或者文件,如果是文件且可读,则可以在浏览器端直接打开,或者通过「目标另存为」下载该文件。
3.2 HTTP文件服务器启动类
package com.lsh.netty.http;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
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.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
/**
* @author :LiuShihao
* @date :Created in 2021/6/2 9:10 上午
* @desc :HTTP 服务端开发
*/
public class HttpFileServer {
private String ipAddress = "127.0.0.1";
private static final String DEFAULT_URL = "/src/com/exp/netty/";
public void run(final int port,final String url) throws Exception{
NioEventLoopGroup bossGroup = new NioEventLoopGroup();
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup,workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//想ChannelPipeline中添加HTTP请求消息解码器
ch.pipeline().addLast("http-decoder",new HttpRequestDecoder());
//添加HttpObjectAggregator解码器,作用是将多个消息转换为单一的FullHttpRequest或者FullHttpResponse,
// 原因是HTTP解码器在每个HTTP消息中会生成多个消息对象。
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());
//最后添加HTTPFileServerHandler 用于文件服务器的业务逻辑处理
ch.pipeline().addLast("fileServerHandler",new HttpFileServerHandler(url));
}
});
ChannelFuture future = b.bind(ipAddress, port).sync();
System.out.println("HTTP 文 件 目 录 服 务 器 启 动 ,网 址 是 :http://"+ipAddress+":"+port+url);
future.channel().closeFuture().sync();
}catch (Exception e){
System.out.println(e.getMessage());
}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
int port = 8080;
String url = DEFAULT_URL;
new HttpFileServer().run(port,url);
}
}
3.3 HTTP文件服务器处理类
package com.lsh.netty.http;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.handler.stream.ChunkedFile;
import io.netty.util.CharsetUtil;
import javax.activation.MimetypesFileTypeMap;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.regex.Pattern;
/**
* @author :LiuShihao
* @date :Created in 2021/6/2 11:55 上午
* @desc :HttpFile文件服务器处理类
*/
public class HttpFileServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private final String url ;
public HttpFileServerHandler(String url) {
this.url = url;
}
/**
* 当服务器接收到消息时,会自动触发 messageReceived方法
*/
@Override
protected void messageReceived(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
// 5.0.0 request.getDecoderRequest() 已经被弃用
//对HTTP请求消息的解码结果进行判断,如果解码失败则返回400错误
if (!request.decoderResult().isSuccess()) {
System.out.println("解码失败返回400");
sendError(ctx, HttpResponseStatus.BAD_REQUEST);
return;
}
//对请求方法进行判断,如果不是从浏览器或者表单设置为GET发起的请求(例如POST),则返回405错误
if (request.method() != HttpMethod.GET) {
sendError(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED);
System.out.println("请求方式不是GET,返回405");
return;
}
//如果URI不合法 返回403错误
final String uri = request.uri();
System.out.println("request.uri : " + uri);
final String path = sanitizeUri(uri);
System.out.println("path : " + path);
if (path == null) {
sendError(ctx, HttpResponseStatus.FORBIDDEN);
System.out.println("URI不合法 返回403错误");
return;
}
//如果文件不存在或者是系统隐藏文件 则返回404错误
File file = new File(path);
if (file.isHidden() || !file.exists()) {
sendError(ctx, HttpResponseStatus.NOT_FOUND);
System.out.println("文件不存在或者是系统隐藏文件 则返回404错误");
return;
}
if (file.isDirectory()) {
if (uri.endsWith("/")) {
//返回
sendListing(ctx, file);
} else {
//重定向
sendRedirect(ctx, uri + "/");
}
return;
}
//判断文件合法性
if (!file.isFile()) {
sendError(ctx, HttpResponseStatus.FORBIDDEN);
return;
}
RandomAccessFile randomAccessFile = null;
try {
//以只读方式打开文件
randomAccessFile = new RandomAccessFile(file, "r");
} catch (FileNotFoundException e) {
sendError(ctx, HttpResponseStatus.NOT_FOUND);
return;
}
//获取文件的长度构造成功的HTTP应答消息
long fileLength = randomAccessFile.length();
DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
HttpHeaderUtil.setContentLength(response, fileLength);
setContentTypeHeader(response, file);
//判断是否是keepAlive,如果是就在响应头中设置CONNECTION为keepAlive
if (HttpHeaderUtil.isKeepAlive(request)) {
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
}
ctx.write(response);
ChannelFuture sendFileFuture;
//通过Netty的ChunkedFile对象直接将文件写入到发送缓冲区中
sendFileFuture = ctx.write(new ChunkedFile(randomAccessFile, 0, fileLength, 8192), ctx.newProgressivePromise());
//为sendFileFuture添加监听器,如果发送完成打印发送完成的日志
sendFileFuture.addListener(new ChannelProgressiveFutureListener() {
@Override
public void operationComplete(ChannelProgressiveFuture channelProgressiveFuture) throws Exception {
System.out.println("传输完成.");
}
@Override
public void operationProgressed(ChannelProgressiveFuture channelProgressiveFuture, long progress, long total) throws Exception {
if (total < 0) {
System.err.println("传输进度 : " + progress);
} else {
System.err.println("传输进度: " + progress + "/" + total);
}
}
});
//如果使用chunked编码,最后需要发送一个编码结束的空消息体,将LastHttpContent.EMPTY_LAST_CONTENT发送到缓冲区中,
//来标示所有的消息体已经发送完成,同时调用flush方法将发送缓冲区中的消息刷新到SocketChannel中发送
ChannelFuture lastContentFuture = ctx.
writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
//如果是非keepAlive的,最后一包消息发送完成后,服务端要主动断开连接
if (!HttpHeaderUtil.isKeepAlive(request)) {
lastContentFuture.addListener(ChannelFutureListener.CLOSE);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx,Throwable cause){
cause.printStackTrace();
if(ctx.channel().isActive()){
sendError(ctx,HttpResponseStatus.INTERNAL_SERVER_ERROR);
}
}
private static final Pattern INSECURE_URI=Pattern.compile(".*[<>&\"].*");
/**
*
*
* @param uri
* @return
*/
private String sanitizeUri(String uri){
//对URL进行解码 解码成功后对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();
}
}
//解码成功后对uri进行合法性判断,避免访问无权限的目录
if(!uri.startsWith("/src/com/exp/netty")){
System.out.println("uri不是以/src/com/exp/netty开头的,uri不合法!");
return null;
}
if(!uri.startsWith("/")){
System.out.println("uri不是以/开头的");
return null;
}
//将硬编码的文件路径分隔符替换为本地操作系统的文件路径分割符
uri = uri.replace('/',File.separatorChar);
//对新的URI做二次合法性校验,如果校验失败则直接返回空
if (uri.contains(File.separator+'.')
|| uri.contains('.'+File.separator) || uri.startsWith(".")
|| uri.endsWith(".") || INSECURE_URI.matcher(uri).matches()){
System.out.println("新的URI做二次合法性校验失败则直接返回空");
return null;
}
//最后对文件进行拼接,使用当前运行程序所在的工作目录 + URI 构造绝对路径返回
System.out.println("最后对文件进行拼接:"+System.getProperty("user.dir") + File.separator + uri);
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\\.]*");
/**
* 发送目录的链接到客户端浏览器
* @param ctx
* @param dir
*/
private static void sendListing(ChannelHandlerContext ctx,File dir){
//创建成功的http响应消息
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
//设置消息头的类型是html文件,不要设置为text/plain,客户端会当做文本解析
response.headers().set(HttpHeaderNames.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");
System.out.println("buf :" + buf);
//分配消息缓冲对象
ByteBuf buffer = Unpooled.copiedBuffer(buf, CharsetUtil.UTF_8);
//将缓冲区的内容写入响应对象,并释放缓冲区
response.content().writeBytes(buffer);
buffer.release();
//将响应消息发送到缓冲区并刷新到SocketChannel中
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void sendRedirect(ChannelHandlerContext ctx,String newUri){
FullHttpResponse response=new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.FOUND);
response.headers().set(HttpHeaderNames.LOCATION,newUri);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status){
FullHttpResponse response=new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,status,
Unpooled.copiedBuffer("Failure: "+status.toString()+"\r\n",CharsetUtil.UTF_8));
response.headers().set(HttpHeaderNames.CONTENT_TYPE,"text/html;charset=UTF-8");
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private static void setContentTypeHeader(HttpResponse response,File file){
MimetypesFileTypeMap mimetypesTypeMap=new MimetypesFileTypeMap();
response.headers().set(HttpHeaderNames.CONTENT_TYPE,mimetypesTypeMap.getContentType(file.getPath()));
}
}
3.4 文件服务器效果
右键下载:
至此,作为入门级的Netty HTTP 协议栈的应用——HTTP文件服务器已经开发完毕,下一章我们学习目前最流行的 HTTP+XML 开发。HTTP+XML应用非常广泛,一旦我们掌握了如何在Netty中实现通用的 HTTP + XML协议栈,后续相关的应用层开发和维护将变得非常简单。