建议至少有使用过Netty的经验、了解HTTP协议、了解Servlet用法的读者阅读,否则可能会有一点吃力
文章中的示例demo已上传至本人Github,戳此访问?
其中html、js文件均为网络web上的源码copy而来,如有侵犯版权,请告知作者
在tomcat中,启动tomcat应用首先会读取web.xml文件,去读取servlet配置,在最早的时候我们就是在web.xml中配置servlet映射关系,然后在具体的servlet类中编写我们的业务逻辑,但是这样做相当冗余,表现在多一个uri请求我就要加一个servlet类,管理起来十分繁琐且复杂,所以这时候MVC思想就来了,使用仅一个DispatcherServlet接受所有请求,我们就不需要在web.xml配置Servlet映射了,正好Spring中有一个MVC模块,我们只需要将具体业务逻辑变成一个个method,变成Bean交给Spring管理,此时Spring就会让这个DispatcherServlet去根据uri执行它管理的Bean —>执行对应的method。
有点扯远了,但我想说的是,将每个uri映射到某个Servlet上这个过程十分繁琐,但项目中为了还原tomcat的原始度,构建最底层最古老的写法,包括对http协议的构建,io的处理,servlet的映射和业务逻辑处理,关注更多的也更值得我们学习的是如何实现服务端的io高性能处理,如何仿tomcat去接受请求,仿造一个Servlet容器的网络请求做法,和如何构建http协议,所以暂且不要关注它的可用性。
既然仿造的是tomcat,那么协议一定是HTTP协议,这里客户端指的是浏览器,当在浏览器输入一个url后,将会向服务端发起一个HttpRequest,然后服务端需要根据request的不同,构造不同的response返回出去。
这里我没有照搬tomcat的web.xml配置,而是使用properties放置配置,怎么方便怎么来,大致意思有了就行
servlet.chatServlet.url=/
servlet.chatServlet.className=com.push.server.servlet.ChatIndexServlet
servlet.RestfulServlet.url=/payOrder
servlet.RestfulServlet.className=com.push.server.servlet.RestfulServlet
servlet.WaitPayServlet.url=/pay
servlet.WaitPayServlet.className=com.push.server.servlet.WaitPayServlet
这里是我demo中的一个配置,你需要按照以上规则去配置url与Servlet的映射关系,这样,我在浏览器访问/pay就会交给WaitPayServlet这个Servlet去处理请求了
具体初始化Servlet的代码如下:
public class ServletUtils {
private static Properties webProperties = new Properties();
// 存放url与Servlet关系的Map
private static Map<String, IServlet> servletMapping = new HashMap<String, IServlet>();
public static final String WEB_INF = "/WEB-INF";
// 初始化url与Servlet关系
public static synchronized void init() throws IOException {
if (AbstractServlet.successInit) {
log.info("已经初始化过一次了,不要重复初始化servlet");
}
InputStream in = null;
try {
// 获取 resources/web.properties 下的文件流
log.info("开启文件流");
in = ServletUtils.class.getResourceAsStream("/web.properties");
log.info("读取文件流");
// 读取properties的内容
webProperties.load(in);
for (Object k : webProperties.keySet()) {
log.info("读取内容");
String key = k.toString();
// 按照我们的规则去解析它
if (key.endsWith(".url")) {
String servletName = key.replaceAll("\\.url$", "");
String url = webProperties.getProperty(key);
String className = webProperties.getProperty(servletName + ".className");
// 单实例,多线程
IServlet obj = (IServlet) Class.forName(className).newInstance();
servletMapping.put(url, obj);
}
}
if (servletMapping.size() == 0) {
log.info("没有读取到servlet映射配置");
throw new RuntimeException("没有读取到servlet映射配置");
} else {
AbstractServlet.successInit = true;
}
} catch (Exception e) {
log.info("读取servlet配置文件失败: [{}]", e.getMessage());
throw new RuntimeException("读取servlet配置文件失败");
} finally {
if (in != null) {
in.close();
}
}
}
主要做的就是读取指定的文件名,然后按照我们的规则去解析映射关系,并且使用反射去创建一个Servlet实例,并保存在Map中,以便后续使用。值得一提的是,这里和tomcat的思想一致,Servlet是单实例,并多线程访问的。
之后我们只需要根据url拿到对应的Servlet去执行service方法即可:
public static IServlet findUriMapping(String uri) {
return servletMapping.get(uri);
}
解码器作用:在网络io的过程中,数据的传输一般都是使用字节来传输,同样,在浏览器对服务端发起一个http请求的时候也是发送一串字节,此时我们需要根据字节解析出可以看得懂的HttpRequest对象,这时候我们就需要一个解码器。
编码器作用:当我们根据请求构造相应的响应对象HttpResponse时,同样需要将其变成字节,利用网络传输出去给浏览器,这时候我们就需要一个编码器。
编解码过程相对繁琐,但Netty帮我们实现了大部分的公有协议,例如HTTP协议、WebSocket协议等等,所以编解码这块不需要我们关心,如果想自己实现一个轻量的协议,Netty提供了一些基类,所以实现起来也是很方便的,在后面的聊天室服务器的实现中我就自定义了一个通信规则,感兴趣的读者可以在下面了解自定义协议的实现。
private static final int DEFAULT_PORT = 8888;
public static void start(int port) {
EventLoopGroup bossEventLoop = new NioEventLoopGroup(1);
EventLoopGroup workerEventLoop = new NioEventLoopGroup();
try {
// 初始化servlet的映射关系
ServletUtils.init();
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossEventLoop, workerEventLoop)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(Channel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// http协议编解码器
pipeline.addLast(new HttpServerCodec());
// 聚合http请求对象 -> FullHttpRequest
pipeline.addLast(new HttpObjectAggregator(64 * 1024, true));
// 自定义的Http处理
pipeline.addLast(new HttpRequestHandler());
...
}
});
// 将服务端实现绑定到一个端口上,暴露出来
ChannelFuture channelFuture = bootstrap.bind(port);
log.info("服务已启动,监听端口: " + port);
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.info("线程被中断: [{}]", e.getMessage());
} catch (Exception e) {
log.info("服务器异常");
e.printStackTrace();
} finally {
bossEventLoop.shutdownGracefully();
workerEventLoop.shutdownGracefully();
}
}
在上面讲到,一个请求过来后,将会调用HttpRequestHandler的channelRead方法来响应请求:
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
// 400-错误的客户端请求
if (request.decoderResult().isFailure()) {
// 写一个400的错误响应出去给浏览器
sendErrorResponse(ctx, BAD_REQUEST);
return;
}
// 只支持GET方法
if (!request.method().equals(GET)) {
// 若不是GET方法的HTTP请求,写一个错误的响应出去
sendErrorResponse(ctx, METHOD_NOT_ALLOWED);
return;
}
String requestUri = request.uri();
log.info("http请求的uri为: [{}]", requestUri);
// 这里因为考虑到请求参数的问题,若带?的url将解析为参数处理
String newUri = requestUri;
if (requestUri.contains("?")) {
newUri = requestUri.substring(0, requestUri.indexOf("?"));
}
// 构建响应对象
HttpResponse response = new DefaultHttpResponse(request.protocolVersion(), HttpResponseStatus.OK);
// 若是静态资源的请求,交给StaticServlet去做
if (!handleStaticResourceRequest(newUri, request, response, ctx)) {
IServlet servlet = ServletUtils.findUriMapping(newUri);
// 404-NOT FOUND
if (servlet == null) {
sendErrorResponse(ctx, NOT_FOUND);
return;
}
// servlet负责构建响应对象
servlet.service(request, response, ctx);
}
// 业务逻辑处理时出现了异常-500
if (response.status().equals(HttpResponseStatus.INTERNAL_SERVER_ERROR)) {
sendErrorResponse(ctx, INTERNAL_SERVER_ERROR);
}
}
由于我们前面配置了解码器,当浏览器发起一个请求后,解码器将自动把字节转换为FullHttpRequest这个请求对象,我们可以从它看出HTTP请求方法是什么(GET、POST)、是否是一个错误的响应、请求的uri是什么等等。
所以这里我们根据请求的uri拿出对应的Servlet,并执行servlet的service方法。符合Servlet规范和tomcat中的实现思想。
在Servlet中,有些是响应一个html出去,有些是响应一个简单文本或是json出去(称为RESTful API),具体响应什么格式,在Servlet中体现。在Servlet中service方法又调用了doGet方法或是doPost方法根据请求执行对应方法,这里为了方便只开放了GET方法的实现。下面来看几个我自己定义的Servlet做了什么。
可以看出来,上面写的代码都是处理io请求,真正的业务逻辑是封装在Servlet中去做的。
public class ChatIndexServlet extends AbstractServlet {
private static final String CHAT_INDEX_PATH = "/chatIndex.html";
@Override
protected void doGet(FullHttpRequest request, HttpResponse response, ChannelHandlerContext ctx) throws Exception {
// 将html文件中的html内容写入response
RandomAccessFile file = new RandomAccessFile(ServletUtils.getResource(path, ServletUtils.WEB_INF), "r");
// 设置响应头长度为文件的长度
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, file.length());
// 写出之前构造好的response
ctx.write(response);
// 将文件写出
ctx.write(new DefaultFileRegion(file.getChannel(), 0, file.length()));
// 写出一个EMPTY_LAST_CONTENT表示响应完成
ChannelFuture future = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
// 请求头的 keep-alive 信息
boolean isKeepAlive = HttpUtil.isKeepAlive(request);
if (!isKeepAlive) {
// 如果请求头isKeepAlive=true的话,TCP连接是不会断开的
// 主要是希望一个TCP连接可以交互多个http请求,提高网络利用率
future.addListener(ChannelFutureListener.CLOSE);
}
}
}
可以看到,其实很简单,Servlet只是读取固定文件名的文件,将文件内容写出去给浏览器,并构造一些浏览器看的懂的HttpResponse,这样,浏览器就会将我们的文件内容变成一个html渲染展现出来。在日常开发中,SpringMVC封装了Servlet这一层,MVC—Model、Viewer、Controller,首先model是根据客户端请求可以构造一个动态的模型数据,比如某个列表的信息,viewer是具体视图,也就是上面所说的访问一个文件,将文件内容变成html响应出去就是视图做的事情,Controller就是具体业务逻辑的处理,具体负责Model需要长啥样的负责人,然后model结合视图一起渲染,变成一个html交给浏览器,浏览器就呈现出我们想要的视图出来。在这个过程中JSP就处于一个视图的位置,Controller将model交给视图解析器,然后视图解析器将model与JSP杂糅在一起,变成html响应出去。
大致就是这么一个流程。所以以上逻辑还是很符合最底层的servlet实现思想的。
在SpringMVC中,只要打上@ResponseBody注解的方法,都会被Spring识别,并且其并不返回视图,而是进行内容协商,构造一个协商后的响应头例如content-type=“application/json;”
,表示并不会响应一个html,基于此,下面这个Servlet即为类似RESTful API的实现:
@Override
protected void doGet(FullHttpRequest request, HttpResponse response, ChannelHandlerContext ctx) {
// 重新构造一个响应对象
FullHttpResponse restFulResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
// 响应一个简单文本content-type=text/plain
restFulResponse.headers().set(CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN);
byte[] bytes = result.getBytes();
// 响应一个字符串,变成字节写到response中
restFulResponse.content().writeBytes("success");
// 在响应体中表明内容有多长
restFulResponse.headers().set(CONTENT_LENGTH, bytes.length);
// 将响应写回去给客户端浏览器
ctx.writeAndFlush(restFulResponse);
}
很简单,只是构造了一个HttpResponse然后发送给浏览器,为了体现这个Servlet有用,在后面的仿支付的页面中,它将起到处理业务逻辑的作用。
再回顾一下我们的Servlet配置:
servlet.chatServlet.url=/
servlet.chatServlet.className=com.push.server.servlet.ChatIndexServlet
servlet.RestfulServlet.url=/payOrder
servlet.RestfulServlet.className=com.push.server.servlet.RestfulServlet
servlet.WaitPayServlet.url=/pay
servlet.WaitPayServlet.className=com.push.server.servlet.WaitPayServlet
这里我们启动上面的服务端,将它把端口绑定到8888上,然后访问localhost:8888
可以看到,这里成功响应了一个html页面,看起来有点挫。。。但服务端的功能还是体现出来了
接下来访问/payOrder这个uri,看看结果:
这里也成功返回了简单文本作为内容,可以看到此时uri其实带了一个参数,这个参数是下面访支付的逻辑用到的,在下面会介绍到。
在前段时间正好需要写一个webSocket即时消息推送通知的功能,业务大致如下:
在以前使用的是长轮询技术,后端阻塞得去等待支付结果的通知,后来需要改成webSocket方式去做,主要是将客户端和服务端保持一个长连接,在支付成功之后,服务端会根据之前的连接发送支付通知,这样就不必一直来轮询状态,节约了资源。
当时使用了Spring封装的webSocket粗略的实现了这个功能,由于考虑是否有性能问题,因为开启一个支付二维码页面就相当于要保存一个长连接在服务端。在学习Netty之后,遂想要使用Netty去实现这一功能,在保持连接上单台Netty应该是可以保持几十上百万的空闲连接(因为用户一般不会马上支付),并且只有少部分的连接在活动(少部分用户此时开始支付,此时就要活动连接发送通知),所以Netty实现的webSocket服务端是可以轻松应对这个业务场景和并发量的。
这里服务端的启动和上面的没什么差别,唯一的差别就是添加了两行代码:
// 这个handler在新连接接入时,pipeline添加handler后添加一个握手handler
// 若uri为/websocket,自动升级协议为websocket
// 并且在握手成功后会新增WebSocket的编解码器,并移除HttpObjectAggregator这个handler
pipeline.addLast(new WebSocketServerProtocolHandler("/Websocket"));
// 自定义的订单类型的WebSocket处理
pipeline.addLast(new WebSocketOrderHandler());
一个是webSocket的编解码器,一个是自定义处理webSocket内容的handler
在我们打开一个二维码支付页面的时候,页面后台js将发起一个webSocket连接,并且发送一些信息给服务端:
// 向指定url发起webSocket连接
var websocket = new WebSocket("ws://localhost:8888/Websocket");
// 连接发生错误的回调方法
websocket.onerror = function () {
console.log("WebSocket连接发生错误");
};
// 连接成功建立的回调方法
websocket.onopen = function () {
console.log("WebSocket连接成功");
// 连接成功后,向服务端发送generateOrderNo字符串
websocket.send("generateOrderNo");
};
// 接收到消息的回调方法
websocket.onmessage = function (event) {
//... 省略,这里主要是接收到支付成功消息后进行页面跳转到支付成功
};
//...
可以看到,这里主要是在连接成功后发送一串字符串,来看看服务端是如何处理这个字符串的:
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 如果是generateOrderNo这个字符串,进行处理
if (GENERATE_ORDER_NO.equals(msg.text())) {
log.info("客户端请求生成一个orderNo");
StringBuilder orderNo = new StringBuilder("CN");
orderNo.append(System.currentTimeMillis())
.append(ORDER_COUNT.addAndGet(1));
// 将orderNo这条channel保存起来
ctx.channel().attr(ORDER_NO).set(orderNo.toString());
orderGroup.add(ctx.channel());
ctx.writeAndFlush(new TextWebSocketFrame("orderNo:" + orderNo.toString()));
}else {
ctx.fireChannelRead(msg.retain());
}
}
这里根据一定规则生成一个订单编号,然后将此编号发送给浏览器,让浏览器知道自己的订单编号,然后在服务端,将订单编号与此条客户端TCP连接(Channel)保存起来,以便之后支付成功后拿出Channel对客户端发起通知。
这里,我们上面的那个Servlet就起到作用了,这里我们配置一个Servlet 映射关系:
# 访问此uri,即可模拟支付成功的通知,带上参数相当于通知某个特定的orderNo
# 例如我现在要CN001这笔订单支付成功,访问/payOrder?orderNo=CN001即可
servlet.RestfulServlet.url=/payOrder
servlet.RestfulServlet.className=com.push.server.servlet.RestfulServlet
# 支付二维码页面,使用到了上面Servlet容器技术
servlet.WaitPayServlet.url=/pay
servlet.WaitPayServlet.className=com.push.server.servlet.WaitPayServlet
上面注释说明,需要模拟支付成功场景,需要在uri后面带上参数?orderNo=CN001,这里看看这个Servlet是如何处理的吧:
@Override
protected void doGet(FullHttpRequest request, HttpResponse response, ChannelHandlerContext ctx) {
// 没带参数,返回一个错误的响应
if (!request.uri().contains("?")) {
//sendError
sendErrorResponse(ctx, BAD_REQUEST);
return;
}
// 拿到参数里的orderNo
String orderNo = request.uri()
.substring(request.uri().indexOf("?") + 1)
.split("=")[1];
if (orderNo == null || orderNo.length() == 0) {
//sendError
sendErrorResponse(ctx, BAD_REQUEST);
return;
}
String channelOrderNo;
// 在前面我们保存Channel的Map中取出对应的Channel
for (Channel channel : WebSocketOrderHandler.orderGroup) {
channelOrderNo = channel.attr(WebSocketOrderHandler.ORDER_NO).get();
if (channelOrderNo == null || channelOrderNo.length() == 0) {
continue;
}
// 对此Channel发送消息->"success"
if (orderNo.equals(channelOrderNo)) {
// 发送成功之后,将channel从Map移除,这里省略
channel.writeAndFlush(new TextWebSocketFrame("success"));
// 发送数据表示发送成功了
ctx.writeAndFlush(buildResponse("success"));
return;
}
}
// 发送数据表示没找到Channel,处理失败了
ctx.writeAndFlush(buildResponse("fail"));
}
当访问/pay这个uri之后,只是简单的跳转到了一个html页面上,不多赘述
首先访问/pay这个uri,模拟一个二维码等待支付的场景:
然后另外开启一个新页面,访问如图上的订单编号对应的url:
localhost:8888/payOrder?orderNo=CN15620807753762
这里返回一个success,表示处理成功,接着我们看看刚才的支付页面怎么样了:
此时支付页面已经成功跳转到支付成功的页面了。
在以前接触webSocket的时候,就好奇一个问题,微信QQ之类的聊天软件是如何做的通信,能承载那么高并发,或许它并不是使用webSocket协议来做的,但它也一定是一种长连接技术,双工的协议,并且此通信协议比webSocket更加轻量,才能做到可以同时承受那么多连接在互发数据。
带着好奇心,就自己整一个聊天室实现。这个项目的大致功能就是可以进入一个多人公共的聊天室,在这个聊天室可以互发消息,需求看起来是十分简单的。
这里我为了方便,使用了webSocket协议来完成双工的通信收发,这是外层协议,在其内层还内置了一个自定义的协议。为什么要自定义一个内置的协议呢?在聊天的过程中,有很多信息需要传递,
此时我们就需要制定一个规则,来传递我们需要的信息,就假设我们按照以上规则来收发消息,使用某个分隔符来分隔各个消息部位,例如我现在需要发送一个系统的消息,提示有一个用户进入聊天室了(为空部分为none):
服务端和客户端均使用","来分隔协议内容,固定数组下标即为固定内容
大致的一个流程如上所示,不是特别完美,但有表现出大致的意思即可。
在编解码的部分还是使用之前的WebSocketServerProtocolHandler来帮我们完成,在上面已经介绍过,这里不多赘述。
由于聊天室是带有html页面的,所以这里服务端的启动还是会包含开头我们说的Servlet,由它来帮我们映射uri和显示出对应的html页面。
这里我们需要用到js和css文件,在处理静态文件时,是这样做的:
private boolean handleStaticResourceRequest(String uri, FullHttpRequest request, HttpResponse response, ChannelHandlerContext ctx) {
// 默认响应的类型
String contextType = "text/html;";
boolean isStaticResource = false;
if (uri.endsWith(".css")) {
contextType = "text/css;";
isStaticResource = true;
} else if (uri.endsWith(".js")) {
contextType = "text/javascript;";
isStaticResource = true;
} else if (uri.toLowerCase().matches(".*\\.(jpg|png|gif)$")) {
String ext = uri.substring(uri.lastIndexOf("."));
contextType = "image/" + ext;
isStaticResource = true;
}
// 修改content-type 为请求uri中对应的type
response.headers().set(CONTENT_TYPE, contextType + "charset=utf-8;");
if (!isStaticResource) {
return false;
}
// 交给专门负责静态资源的servlet
AbstractServlet staticServlet = new StaticServlet();
staticServlet.service(request, response, ctx);
return true;
}
这里关键就是判断uri是否是静态资源结尾的,若是即视为静态资源,我们需要将HttpResponse的content-type改成静态资源对应的type,例如js就是text/javascript;
和上面的服务端相比基本没有改动,只是多添加了一个Handler而已
// 自定义的聊天类型的WebSocket处理
pipeline.addLast(new WebSocketChatHandler());
在下面我们具体看看这个Handler做了什么吧
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 将字符串解析成一个Java对象,规则就是上面协议所介绍的
// 解析成对象,方便下面我们处理
IMMessage request = decoder.decode(msg.text());
if (null == request) {
log.error("解析请求失败");
ctx.fireChannelRead(msg);
return;
}
Channel client = ctx.channel();
String addr = getAddress(client);
// 如果请求是Login,登陆请求的话
if (request.getCmd().equals(LOGIN.getName())) {
// 给这个客户端channel设置属性
client.attr(NICK_NAME).set(request.getSender());
client.attr(IP_ADDR).set(addr);
client.attr(FROM).set(request.getTerminal());
// 将这个客户端channel保存起来,以便后续拿出来发消息
onlineUsers.add(client);
// 遍历之前所有的channel
for (Channel channel : onlineUsers) {
// 判断请求是否来自自己
boolean fromSelf = (channel == client);
// 如果不是来自自己,就给所有的客户端发送系统消息
// 内容就是张三加入
if (!fromSelf) {
request = new IMMessage(SYSTEM.getName(), sysTime(), onlineUsers.size(), getNickName(client) + "加入");
} else {
// 如果是来自自己,就给自己这个客户端发送系统消息,提示已经和服务器建立了连接
request = new IMMessage(SYSTEM.getName(), sysTime(), onlineUsers.size(), "已与服务器建立连接!");
}
// 将我们上面构造的响应信息,编码成一个字符串
String content = encoder.encode(request);
// 将字符串构造成一个webSocket的帧,可以被webSocket编码器进行编码,发送给浏览器
// 然后发送出去这个帧
channel.writeAndFlush(new TextWebSocketFrame(content));
}
} else if (request.getCmd().equals(CHAT.getName())) {
//如果是聊天请求,也是遍历所有客户端
for (Channel channel : onlineUsers) {
boolean fromSelf = (channel == client);
if (fromSelf) {
// 如果是自己,聊天气泡会在右侧,并显示昵称为自己
request.setSender("自己");
} else {
// 如果是别人,聊天气泡在左侧,显示它的昵称
// 这里昵称这个属性在上面登陆的时候都已经保存好了,这里拿出来就知道是哪个昵称
request.setSender(getNickName(client));
}
request.setTime(sysTime());
// 一样,将字符串变成帧发送出去
String content = encoder.encode(request);
channel.writeAndFlush(new TextWebSocketFrame(content));
}
}
//...省略部分逻辑
}
这里聊天的逻辑也不算复杂,主要就是按照规则解析数据,做相应判断处理,然后将响应字符串变成帧发送给所有客户端channel。
servlet.chatServlet.url=/
servlet.chatServlet.className=com.push.server.servlet.ChatIndexServlet
由于这里我们配置了/这个uri即可访问聊天室的html,所以这里在浏览器直接输入localhost:8888
聊天室首页,输入昵称进入聊天室。(好像有点丑。。没办法不会弄前端)
可以看到,这里进入了聊天室…然后我们再开一个连接,测试双人互聊
可以看到,一个新用户进来,就会给其他用户提示。那么收发消息呢
也可以正常进行,到这里我们的聊天室就结束了,可能看起来有点low,但我们注重点在服务端的Netty实现。