Netty实现一个小应用服务器 +消息收发推送系统


项目内容

  • 构建一个Servlet容器(仿Tomcat)
  • 写一个仿支付的页面,其可以在接收到支付成功通知后自动跳转
  • 多人聊天室

建议至少有使用过Netty的经验、了解HTTP协议、了解Servlet用法的读者阅读,否则可能会有一点吃力
文章中的示例demo已上传至本人Github,戳此访问?
其中html、js文件均为网络web上的源码copy而来,如有侵犯版权,请告知作者

1. Servlet容器

1.1 介绍

在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协议,所以暂且不要关注它的可用性。

1.2 协议

既然仿造的是tomcat,那么协议一定是HTTP协议,这里客户端指的是浏览器,当在浏览器输入一个url后,将会向服务端发起一个HttpRequest,然后服务端需要根据request的不同,构造不同的response返回出去。

1.3 Servlet映射关系初始化

在这里插入图片描述
这里我没有照搬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);
}

1.4 HTTP协议的编解码

  • 解码器作用:在网络io的过程中,数据的传输一般都是使用字节来传输,同样,在浏览器对服务端发起一个http请求的时候也是发送一串字节,此时我们需要根据字节解析出可以看得懂的HttpRequest对象,这时候我们就需要一个解码器。

  • 编码器作用:当我们根据请求构造相应的响应对象HttpResponse时,同样需要将其变成字节,利用网络传输出去给浏览器,这时候我们就需要一个编码器。

编解码过程相对繁琐,但Netty帮我们实现了大部分的公有协议,例如HTTP协议、WebSocket协议等等,所以编解码这块不需要我们关心,如果想自己实现一个轻量的协议,Netty提供了一些基类,所以实现起来也是很方便的,在后面的聊天室服务器的实现中我就自定义了一个通信规则,感兴趣的读者可以在下面了解自定义协议的实现。

1.5 服务端的启动

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();
  }
}
  1. 首先,这里初始化了Servlet映射关系,在上面有介绍,这里不多赘述
  2. 然后添加了两个Handler,分别是HttpServerCodec和HttpObjectAggregator,这两个都是编解码的工具,可以看成是一个黑盒,知道它是用来将字节和具体HTTP对象互相转换即可。感兴趣的读者可以看我接下来的几篇源码分析,了解其实现机制。
  3. HttpRequestHandler这个就是实现的关键了,这个Handler只关注读事件,也就是当浏览器向服务端发起请求时它将会执行它的channelRead方法,处理逻辑。
  4. 最后,将服务端绑定到一个指定的端口上,暴露服务。至此,可以在浏览器上访问对应url即可访问到html页面了。

1.6 请求的业务逻辑处理

在上面讲到,一个请求过来后,将会调用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做了什么。

1.7 Servlet处理具体业务逻辑

可以看出来,上面写的代码都是处理io请求,真正的业务逻辑是封装在Servlet中去做的。

1.7.1 响应View(html页面)的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实现思想的。

1.7.2 响应RESTful API(JSON或简单文本)的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有用,在后面的仿支付的页面中,它将起到处理业务逻辑的作用。

1.8 测试使用情况

再回顾一下我们的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
  • 我们在ChatIndexServlet这个Servlet中,将其跳转了一个页面
  • 在RestfulServlet这个Servlet中,简单响应了一个success字符串。

这里我们启动上面的服务端,将它把端口绑定到8888上,然后访问localhost:8888
在这里插入图片描述
可以看到,这里成功响应了一个html页面,看起来有点挫。。。但服务端的功能还是体现出来了

接下来访问/payOrder这个uri,看看结果:
在这里插入图片描述
这里也成功返回了简单文本作为内容,可以看到此时uri其实带了一个参数,这个参数是下面访支付的逻辑用到的,在下面会介绍到。

2. 仿支付功能

1.1 介绍

在前段时间正好需要写一个webSocket即时消息推送通知的功能,业务大致如下:

  1. 下单后返回一个二维码页面,用户需要扫码支付
  2. 用户扫码支付成功后,服务端确认后,原二维码页面需要跳转到支付成功的页面

在以前使用的是长轮询技术,后端阻塞得去等待支付结果的通知,后来需要改成webSocket方式去做,主要是将客户端和服务端保持一个长连接,在支付成功之后,服务端会根据之前的连接发送支付通知,这样就不必一直来轮询状态,节约了资源。

当时使用了Spring封装的webSocket粗略的实现了这个功能,由于考虑是否有性能问题,因为开启一个支付二维码页面就相当于要保存一个长连接在服务端。在学习Netty之后,遂想要使用Netty去实现这一功能,在保持连接上单台Netty应该是可以保持几十上百万的空闲连接(因为用户一般不会马上支付),并且只有少部分的连接在活动(少部分用户此时开始支付,此时就要活动连接发送通知),所以Netty实现的webSocket服务端是可以轻松应对这个业务场景和并发量的。

1.2 协议与编解码

  • 协议:在上面也介绍了,协议部分使用的是webSocket,其刚开始也是一个Http连接,访问指定的webSocket的http的uri之后,后端会将此条连接升级为webSocket协议,保持长连接。
  • 编解码:而编解码的部分,Netty也帮我们封装好了,不需要我们关心,这里只需要添加一个WebSocketServerProtocolHandler即可,这个编解码会动态添加一个握手Handler到pipeline中,在访问到指定的uri之后开始握手,握手成功之后会向pipeline中添加一个编码器一个解码器,并且检测若存在http编解码器例如HttpServerCodec或是HttpObjectAggregator,将其删除或添加到其前面。可见WebSocketServerProtocolHandler这个handler杂糅了许多功能。

1.3 服务端的启动

这里服务端的启动和上面的没什么差别,唯一的差别就是添加了两行代码:

// 这个handler在新连接接入时,pipeline添加handler后添加一个握手handler
// 若uri为/websocket,自动升级协议为websocket
// 并且在握手成功后会新增WebSocket的编解码器,并移除HttpObjectAggregator这个handler
pipeline.addLast(new WebSocketServerProtocolHandler("/Websocket"));
// 自定义的订单类型的WebSocket处理
pipeline.addLast(new WebSocketOrderHandler());

一个是webSocket的编解码器,一个是自定义处理webSocket内容的handler

1.4 处理一个WebSocket逻辑

在我们打开一个二维码支付页面的时候,页面后台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对客户端发起通知。

1.5 RESTFul API的Servlet处理支付成功通知

这里,我们上面的那个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页面上,不多赘述

1.6 测试使用情况

首先访问/pay这个uri,模拟一个二维码等待支付的场景:
在这里插入图片描述
然后另外开启一个新页面,访问如图上的订单编号对应的url:

localhost:8888/payOrder?orderNo=CN15620807753762

在这里插入图片描述
这里返回一个success,表示处理成功,接着我们看看刚才的支付页面怎么样了:
在这里插入图片描述
此时支付页面已经成功跳转到支付成功的页面了。

3. 多人聊天室

3.1 介绍

在以前接触webSocket的时候,就好奇一个问题,微信QQ之类的聊天软件是如何做的通信,能承载那么高并发,或许它并不是使用webSocket协议来做的,但它也一定是一种长连接技术,双工的协议,并且此通信协议比webSocket更加轻量,才能做到可以同时承受那么多连接在互发数据。

带着好奇心,就自己整一个聊天室实现。这个项目的大致功能就是可以进入一个多人公共的聊天室,在这个聊天室可以互发消息,需求看起来是十分简单的。

3.2 协议

这里我为了方便,使用了webSocket协议来完成双工的通信收发,这是外层协议,在其内层还内置了一个自定义的协议。为什么要自定义一个内置的协议呢?在聊天的过程中,有很多信息需要传递,

  • 消息类型:系统消息、用户消息、登陆消息、退出消息等等
  • 消息发送方
  • 消息接收方
  • 消息内容
  • 消息发送时间
  • 当前在线人数

此时我们就需要制定一个规则,来传递我们需要的信息,就假设我们按照以上规则来收发消息,使用某个分隔符来分隔各个消息部位,例如我现在需要发送一个系统的消息,提示有一个用户进入聊天室了(为空部分为none):

  • 首先客户端会向服务端发起一个登陆请求
    • LOGIN,张三,ALL,none,20191024,1
  • 服务端接受到请求后,向所有人广播张三已经进来了的消息
    • SYSTEM,none,ALL,张三进入了聊天室,20191025,2
  • 接着张三发送了一个消息“helloWorld”,即为向服务端发送消息
    • CHAT,张三,ALL,helloWorld,20191026,2
  • 服务端接受到消息后,向所有人广播张三发送的消息,所有客户端收到消息解析后即可显示在客户端上
    • CHAT,张三,ALL,helloWorld,20191026,2

服务端和客户端均使用","来分隔协议内容,固定数组下标即为固定内容

大致的一个流程如上所示,不是特别完美,但有表现出大致的意思即可。

3.3 编解码

在编解码的部分还是使用之前的WebSocketServerProtocolHandler来帮我们完成,在上面已经介绍过,这里不多赘述。

3.4 静态资源的处理

由于聊天室是带有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;

3.5 服务端的启动

和上面的服务端相比基本没有改动,只是多添加了一个Handler而已

// 自定义的聊天类型的WebSocket处理
pipeline.addLast(new WebSocketChatHandler());

在下面我们具体看看这个Handler做了什么吧

3.6 处理聊天消息

@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。

3.7 测试使用情况

servlet.chatServlet.url=/
servlet.chatServlet.className=com.push.server.servlet.ChatIndexServlet

由于这里我们配置了/这个uri即可访问聊天室的html,所以这里在浏览器直接输入localhost:8888
在这里插入图片描述
聊天室首页,输入昵称进入聊天室。(好像有点丑。。没办法不会弄前端)
在这里插入图片描述
可以看到,这里进入了聊天室…然后我们再开一个连接,测试双人互聊
在这里插入图片描述
可以看到,一个新用户进来,就会给其他用户提示。那么收发消息呢
在这里插入图片描述
也可以正常进行,到这里我们的聊天室就结束了,可能看起来有点low,但我们注重点在服务端的Netty实现。

发布了84 篇原创文章 · 获赞 85 · 访问量 10万+

猜你喜欢

转载自blog.csdn.net/qq_41737716/article/details/94553782