《通信协议》——Socket(5.2)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/hy_coming/article/details/88777151

一、前言

前面一章节已经知道了关于Socket的一些理论知识,下面来看一下现实中是怎样开发的。

二、实例

先来一个简单的demo

//服务端
public class SocketServer {
    @SuppressWarnings("CharsetObjectCanBeUsed")
    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(9999);
            ExecutorService executorService = Executors.newFixedThreadPool(100);
            while (true) {
                Socket socket = serverSocket.accept();
                Runnable runnable = () -> {
                    try {
                        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream(), "utf-8"));
                        String string;
                        while ((string = bufferedReader.readLine()) != null) {
                            System.out.println("客户端说:" + string);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                };
                executorService.submit(runnable);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}


//客户端1
public class SocketClient1 {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1", 9999);
            BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in, "UTF-8"));
            while (true) {
                String string = bufferedReader.readLine();
                bufferedWriter.write(string);
                bufferedWriter.write("\n");
                bufferedWriter.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

//客户端2
public class SocketClient2 {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1", 9999);
            BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in, "UTF-8"));
            while (true) {
                String string = bufferedReader.readLine();
                bufferedWriter.write(string);
                bufferedWriter.write("\n");
                bufferedWriter.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

上面使用的线程池优势:

  • 线程复用,创建线程耗时,回收线程慢
  • 防止短时间内高并发,指定线程池大小,超过数量将等待,方式短时间创建大量线程导致资源耗尽,服务挂掉

但是还有问题就是上面的服务端判断结束是通过换行符(读取到的一行为null)来判定的,这样的优缺点是:

  • 优点:不需要关闭流,当发送完一条命令(消息)后可以再次发送新的命令(消息)
  • 缺点:需要额外的约定结束标志,太简单的容易出现在要发送的消息中,误被结束,太复杂的不好处理,还占带宽

那么有没有一种好的解决方案呢,其实是有的,就是定长字符串,下面来介绍:

如果你了解一点class文件的结构,就知道这种思想了,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。

  现在首要的问题就是用几个字节指定长度呢,我们可以算一算:

  • 1个字节:最大256,表示256B
  • 2个字节:最大65536,表示64K
  • 3个字节:最大16777216,表示16M
  • 4个字节:最大4294967296,表示4G
  • 依次类推

  这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,其实如果你稍微了解一点UTF-8的编码方式,那么你就应该能想到为什么一定要固定表示长度字节的长度呢,我们可以使用变长方式来表示长度的表示,比如:

  • 第一个字节首位为0:即0XXXXXXX,表示长度就一个字节,最大128,表示128B
  • 第一个字节首位为110,那么附带后面一个字节表示长度:即110XXXXX 10XXXXXX,最大2048,表示2K
  • 第一个字节首位为1110,那么附带后面二个字节表示长度:即110XXXXX 10XXXXXX 10XXXXXX,最大131072,表示128K
  • 依次类推

  上面提到的这种用法适合高富帅的程序员使用,一般呢,如果用作命名发送,两个字节就够了,如果还不放心4个字节基本就能满足你的所有要求,下面的例子我们将采用2个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾:

//服务端
public class SocketServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = null;
        Socket socket = null;
        InputStream inputStream = null;
        try {
            serverSocket = new ServerSocket(9999);
            System.out.println("服务端将一直等待客户端的到来。。。。。。。");
            while (true) {
                socket = serverSocket.accept();
                inputStream = socket.getInputStream();
                byte[] bytes;
                //读取第一个字节,如果是-1说明已经结束了
                int first = inputStream.read();
                if (first == -1) {
                    break;
                }
                //读取第二个字节
                int second = inputStream.read();
                //按照双方约定的长度算法进行计算
                int length = (first << 8) + second;
                bytes = new byte[length];
                inputStream.read(bytes);
                System.out.println("接受的数据为:" + new String(bytes, "utf-8"));
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (serverSocket != null) {
                serverSocket.close();
            }
            if (socket != null) {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (inputStream != null) {
                inputStream.close();
            }


        }

    }
}

//客户端
public class SocketClient1 {
    public static void main(String[] args) {
        try {
            Socket socket = new Socket("127.0.0.1",9999);
            OutputStream outputStream = socket.getOutputStream();
            String msg = "你好,我是joy";
            byte[] len = msg.getBytes("utf-8");
            outputStream.write(len.length>>8);
            outputStream.write(len.length);
            outputStream.write(len);
            outputStream.flush();

            msg = "this is second";
            len = msg.getBytes("utf-8");
            outputStream.write(len.length>>8);
            outputStream.write(len.length);
            outputStream.write(len);
            outputStream.flush();

            msg="this didnd g erfgaorg sadglkwene fs是否v哦i是";
            len = msg.getBytes("utf-8");
            outputStream.write(len.length>>8);
            outputStream.write(len.length);
            outputStream.write(len);
            outputStream.flush();

            outputStream.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}

如果需要服务端返回结果也是采用这种方式,然后客户端进行读取,现在比较流行的就是长度+类型+数据模式的传输方式。

三、问题

那么是不是按照上面的方式来说就不会出现问题呢,那肯定不是,我们需要考虑拆包和黏包的问题,下面是产生原因

  • 拆包:当一次发送(Socket)的数据量过大,而底层(TCP/IP)不支持一次发送那么大的数据量,则会发生拆包现象。
  • 黏包:当在短时间内发送(Socket)很多数据量小的包时,底层(TCP/IP)会根据一定的算法(指Nagle)把一些包合作为一个包发送。

  首先可以明确的是,大部分情况下我们是不希望发生拆包和黏包的(如果希望发生,什么都去做即可),那么怎么去避免呢,下面进行详解?

黏包

  首先我们应该正确看待黏包,黏包实际上是对网络通信的一种优化,假如说上层只发送一个字节数据,而底层却发送了41个字节,其中20字节的I P首部、 20字节的T C P首部和1个字节的数据,而且发送完后还需要确认,这么做浪费了带宽,量大时还会造成网络拥堵。当然它还是有一定的缺点的,就是因为它会合并一些包会导致数据不能立即发送出去,会造成延迟,如果能接受(一般延迟为200ms),那么还是不建议关闭这种优化,如果因为黏包会造成业务上的错误,那么请改正你的服务端读取算法(协议),因为即便不发生黏包,在服务端缓存区也可能会合并起来一起提交给上层,推荐使用长度+类型+数据模式。

  如果不希望发生黏包,那么通过禁用TCP_NODELAY即可,Socket中也有相应的方法:

void setTcpNoDelay(boolean on) 

  通过设置为true即可防止在发送的时候黏包,但是当发送的速率大于读取的速率时,在服务端也会发生黏包,即因服务端读取过慢,导致它一次可能读取多个包。

拆包

  这个问题应该引起重视,在TCP/IP详解中说过:最大报文段长度(MSS)表示TCP传往另一端的最大块数据的长度。当一个连接建立时,连接的双方都要通告各自的 MSS。客户端会尽量满足服务端的要求且不能大于服务端的MSS值,当没有协商时,会使用值536字节。虽然看起来MSS值越大越好,但是考虑到一些其他情况,这个值还是不太好确定,具体详见《TCP/IP详解 卷1:协议》。

  如何应对拆包,其实在上面2.3节已经介绍过了,那就是如何表明发送完一条消息了,对于已知数据长度的模式,可以构造相同大小的数组,循环读取,示例代码如下:

int length = 1024;//这个是读取的到数据长度,现假定1024 
byte[] data = new byte[1024];
int readLength = 0; 
while(readLength<length){
    int read = inputStream.read(data, readLength, length - readLength);
    readLength += read;
}

  这样当循环结束后,就能读取到完整的一条数据,而不需要考虑拆包了。

四、其他参数

其实如果经常看有关网络编程的源码的话,就会发现Socket还是有很多设置的,可以学着用,但是还是要有一些基本的了解比较好。下面就对Socket的Java API中涉及到的进行简单讲解。首先呢Socket有哪些可以设置的选项,其实在SocketOptions接口中已经都列出来了:

  • int TCP_NODELAY = 0x0001:对此连接禁用 Nagle 算法。
  • int SO_BINDADDR = 0x000F:此选项为 TCP 或 UDP 套接字在 IP 地址头中设置服务类型或流量类字段。
  • int SO_REUSEADDR = 0x04:设置套接字的 SO_REUSEADDR。
  • int SO_BROADCAST = 0x0020:此选项启用和禁用发送广播消息的处理能力。
  • int IP_MULTICAST_IF = 0x10:设置用于发送多播包的传出接口。
  • int IP_MULTICAST_IF2 = 0x1f:设置用于发送多播包的传出接口。
  • int IP_MULTICAST_LOOP = 0x12:此选项启用或禁用多播数据报的本地回送。
  • int IP_TOS = 0x3:此选项为 TCP 或 UDP 套接字在 IP 地址头中设置服务类型或流量类字段。
  • int SO_LINGER = 0x0080:指定关闭时逗留的超时值。
  • int SO_TIMEOUT = 0x1006:设置阻塞 Socket 操作的超时值: ServerSocket.accept(); SocketInputStream.read(); DatagramSocket.receive(); 选项必须在进入阻塞操作前设置才能生效。
  • int SO_SNDBUF = 0x1001:设置传出网络 I/O 的平台所使用的基础缓冲区大小的提示。
  • int SO_RCVBUF = 0x1002:设置传入网络 I/O 的平台所使用基础缓冲区的大小的提示。
  • int SO_KEEPALIVE = 0x0008:为 TCP 套接字设置 keepalive 选项时
  • int SO_OOBINLINE = 0x1003:置 OOBINLINE 选项时,在套接字上接收的所有 TCP 紧急数据都将通过套接字输入流接收。

参考:

https://www.cnblogs.com/yiwangzhibujian/p/7107785.html

猜你喜欢

转载自blog.csdn.net/hy_coming/article/details/88777151