Java网络编程Socket

Socket主要用在进程间,网络间通信。

这种模式是基础,也是为以后学习NIO做铺垫。

1、基础用法,双向通信,发送消息并接受消息

Socket的底层是TCP,不需要考虑服务端是否已经接收到消息,如果没有发送到服务器端是会抛异常的。

Java的socket是一个全双工套接字,任何的输入流或输出流的close()都会造成Socket关闭。
解决办法:使用socket.shutdownOutput()方法关闭套接字的输出流,使服务器知道输出流关闭,可以得到流末尾标志(-1)。
同样,可以使用socket.shutdownInput()方法单独关闭套接字的输入流。
在客户端或者服务端通过socket.shutdownOutput()都是单向关闭的,即关闭客户端的输出流并不会关闭服务端的输出流,所以是一种单方向的关闭流;
通过socket.shutdownOutput()关闭输出流,但socket仍然是连接状态,连接并未关闭

如果直接关闭输入或者输出流,即:in.close()或者out.close(),会直接关闭socket

服务端

package com.study;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Socket服务端
 */
public class SocketServer {

    public static void main(String[] args) throws Exception {
        // 监听指定的端口
        int port = 55533; //一般使用49152到65535之间的端口
        ServerSocket server = new ServerSocket(port);
        // 使用线程池,防止过多线程耗尽资源
        ExecutorService threadPool = Executors.newFixedThreadPool(100);
        Socket socket;
        while (true) {
            socket = server.accept(); //会一直阻塞,直到有客户端连接进来
            // new Thread 只是创建一个类的对象实例而已。而真正创建线程的是start()方法。
            // 这里并没有直接调用start()方法,所以并没创建新线程,而是交给线程池去执行。
            threadPool.submit(new SocketThread(socket));
        }
        // socket.close();
        // server.close();
    }


    static class SocketThread implements Runnable {
        private Socket socket;

        public SocketThread(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            InputStream inputStream = null;
            OutputStream outputStream = null;
            try {
                // 建立好连接后,从socket中获取输入流
                inputStream = socket.getInputStream();
                // 建立好连接后,从socket中获取输出流
                outputStream = socket.getOutputStream();

                byte[] buf = new byte[1024];
                int len;
                StringBuilder sb = new StringBuilder();
                //只有当客户端关闭它的输出流的时候,服务端才能取得结尾的-1
                while ((len = inputStream.read(buf)) != -1) {
                    // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
                    sb.append(new String(buf, 0, len, StandardCharsets.UTF_8));
                }
                System.out.println("收到客户端消息:" + sb);

                outputStream.write("Hello Client".getBytes(StandardCharsets.UTF_8));

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    if (inputStream != null) {
                        inputStream.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                try {
                    if (outputStream != null) {
                        outputStream.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                try {
                    if (socket != null) {
                        socket.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

客户端

package com.study;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

/**
 * Socket客户端
 */
public class SocketClient {


    public static void main(String[] args) throws Exception {
        Socket socket = null;
        InputStream inputStream = null;
        OutputStream outputStream = null;
        try {
            // 要连接的服务端IP地址和端口
            String host = "127.0.0.1";
            int port = 55533;
            // 与服务端建立连接
            socket = new Socket(host, port);
            // 建立好连接后,从socket中获取输入流
            inputStream = socket.getInputStream();
            // 建立好连接后,从socket中获取输出流
            outputStream = socket.getOutputStream();

            outputStream.write("Hello Server".getBytes(StandardCharsets.UTF_8));
            // outputStream.close(); //虽然close()方法也可以发送-1终止符号,但是close()方法会导致socket关闭。
            socket.shutdownOutput();// 单向关闭输出流,发送流的终止符-1。

            byte[] buf = new byte[1024];
            int len;
            StringBuilder sb = new StringBuilder();
            //只有当客户端关闭它的输出流的时候,服务端才能取得结尾的-1
            while ((len = inputStream.read(buf)) != -1) {
                // 注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
                sb.append(new String(buf, 0, len, StandardCharsets.UTF_8));
            }
            System.out.println("收到服务端消息:" + sb);

        } finally {
            try {
                if (inputStream != null) {
                    inputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                if (outputStream != null) {
                    outputStream.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            try {
                if (socket != null) {
                    socket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

2、如何告知对方已发送完命令

其实这个问题还是比较重要的,正常来说,客户端打开一个输出流,如果不做约定,也不关闭它,那么服务端永远不知道客户端是否发送完消息,那么服务端会一直等待下去,直到读取超时。所以怎么告知服务端已经发送完消息就显得特别重要。
 

2.1 通过Socket关闭

当Socket关闭的时候,服务端就会收到响应的关闭信号,那么服务端也就知道流已经关闭了,这个时候读取操作完成,就可以继续后续工作。
但是这种方式有一些缺点,客户端Socket关闭后,将不能接受服务端发送的消息,也不能再次发送消息
如果客户端想再次发送消息,需要重现创建Socket连接

2.2 通过Socket关闭输出流的方式

这种方式调用的方法是:socket.shutdownOutput();
而不是outputStream.close();如果关闭了输出流,那么相应的Socket也将关闭,和直接关闭Socket一个性质。

调用Socket的shutdownOutput()方法,底层会告知服务端我这边已经写完了,那么服务端收到消息后,就能知道已经读取完消息,如果服务端有要返回给客户的消息那么就可以通过服务端的输出流发送给客户端,如果没有,直接关闭Socket。
这种方式通过关闭客户端的输出流,告知服务端已经写完了,虽然可以读到服务端发送的消息,但是还是有一点点缺点:
不能再次发送消息给服务端,如果再次发送,需要重新建立Socket连接
这个缺点,在访问频率比较高的情况下将是一个需要优化的地方。

2.3 通过约定符号

这种方式的用法,就是双方约定一个字符或者一个短语,来当做消息发送完成的标识,通常这么做就需要改造读取方法。
假如约定单端的一行为end,代表发送完成,例如下面的消息,end则代表消息发送完成:
那么服务端响应的读取操作需要进行如下改造:
while ((line = read.readLine()) != null && "end".equals(line)) {
  //注意指定编码格式,发送方和接收方一定要统一,建议使用UTF-8
  sb.append(line);
}
这么做的优缺点如下:

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

2.4 通过指定长度

如果你了解一点class文件的结构,那么你就会佩服这么设计方式,也就是说我们可以在此找灵感,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。

现在首要的问题就是用几个字节指定长度呢:

1个字节:最大256,表示256B
2个字节:最大65536,表示64K
3个字节:最大16777216,表示16M
4个字节:最大4294967296,表示4G
依次类推
这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,一般如果用作命名发送,两个字节就够了,如果还不放心4个字节基本就能满足你的所有要求,下面的例子我们将采用3个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾:
 

2.4.1 读超时 SO_TIMEOUT

读超时是在建立连接以后,读数据时使用的,一般指inputStream.read()方法的阻塞时间(超时时间)。

读超时这个属性还是比较重要的,当Socket优化到最后的时候,往往一个Socket连接会一直用下去,
那么当一端因为异常导致连接没有关闭,另一方是不应该持续等下去的,所以应该设置一个读取的超时时间,
当超过指定的时间后,还没有读到数据,就假定这个连接无用,然后抛异常,捕获异常后关闭连接就可以了,调用方法为:

setSoTimeout(int timeout)

timeout - 指定的以毫秒为单位的超时值。设置0为持续等待下去。
对下面3个方法都有影响:

ServerSocket.accept()
SocketInputStream.read() //简单来说,客户端或者服务端的read()方法,如果对方已经关闭,是不知道的,会一直阻塞下去。
DatagramSocket.receive()


2.4.2 设置连接超时

这个连接超时和上面说的读超时不一样,读超时是在建立连接以后,读数据时使用的,而连接超时是在进行连接的时候,等待的时间。

SocketAddress endpoint = new InetSocketAddress("127.0.0.1", 55533);
Socket socket = new Socket();
socket.connect(endpoint,3000);//设置超时3000毫秒

2.4.3 设置保持连接存活SO_KEEPALIVE

虽然说当设置连接连接的读超时为0,即无限等待时,Socket不会被主动关闭,但是总会有莫名其妙的软件来检测你的连接是否有数据发送,长时间没有数据传输的连接会被它们关闭掉。

因此通过设置这个选项为true,可以有如下效果:当2个小时内在任意方向上都没有跨越套接字交换数据,则 TCP 会自动发送一个保持存活的消息到对面。将会有以下三种响应:

返回期望的ACK。那么不通知应用程序(因为一切正常),2 小时的不活动时间过后,TCP 将发送另一个探头。
对面返回RST,表明对面挂了,但是又好了,Socket依然要关闭
没有响应,说明对面挂了,这时候关闭Socket
所以对于构建长时间连接的Socket还是配置上SO_KEEPALIVE比较好

下面的例子
模拟类似WebSocket的功能,接收消息,发送消息,是异步的。

服务端

package com.study;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Socket服务端
 */
public class SocketServer {
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) throws Exception {
        // 监听指定的端口
        int port = 55533; //一般使用49152到65535之间的端口
        ServerSocket server = new ServerSocket(port);
        // 使用线程池,防止过多线程耗尽资源
        ExecutorService threadPool = Executors.newFixedThreadPool(100);
        Socket socket;
        while (true) {
            socket = server.accept(); //会一直阻塞,直到有客户端连接进来
            // socket.setSoTimeout(0);// 读超时,默认0,表示inputStream.read()方法会一直等待下去
            socket.setKeepAlive(true); // 防止长时间没有数据传输,socket被关闭
            // socket.setTcpNoDelay(true);// 防止发生黏包
            // new Thread 只是创建一个类的对象实例而已。而真正创建线程的是start()方法。
            // 这里并没有直接调用start()方法,所以并没创建新线程,而是交给线程池去执行。
            threadPool.submit(new SocketThread(socket));
        }
        // socket.close();
        // server.close();
    }


    static class SocketThread implements Runnable {
        private Socket socket;
        private InputStream inputStream;
        private OutputStream outputStream;

        public SocketThread(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            try {
                // 建立好连接后,从socket中获取输入流
                inputStream = socket.getInputStream();
                // 建立好连接后,从socket中获取输出流
                outputStream = socket.getOutputStream();


                //这样处理,消息发送和消息接收是异步的。

                // 类似监听接收消息的功能,该方法会被阻塞
                new Thread(() -> {
                    try {
                        recvMsg();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }).start();
                // inputStream.close();
                // socket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }


        /**
         * 发送消息
         * @param msgType 消息类型
         * @param msg     消息内容
         */
        private void sendMsg(int msgType, String msg) {
            try {
                if (socket == null || socket.isClosed()) {
                    return;
                }
                // 首先需要计算得知消息的长度
                byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
                int length = bytes.length;

                // write(int b)方法,只会写入一个字节到输出流,1个字节只有8位,而int类型有32位,要写入的字节是低8位。24个高位被忽略。

                // 与对方约定前3个字节为消息内容长度,第4个字节为消息类型。
                // 3个字节:表示一条消息内容最大长度 2^24-1 = 16777215K ≈ 16M。如果不够可继续约定4个字节为消息长度。

                // 将消息的长度优先发送出去,消息长度约定占3个字节
                outputStream.write(length >> 16);
                outputStream.write(length >> 8);
                outputStream.write(length);
                // 发送消息类型
                outputStream.write(msgType);
                // 发送消息内容
                outputStream.write(bytes);
                // socket.shutdownOutput();// 单向关闭输出流,发送流的终止符-1。
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        private void recvMsg() throws Exception {
            try {
                // read()方法将阻塞,直到输入数据可用、检测到流的结尾-1或引发异常。
                // read()方法将返回为0到255范围内的整数,如果到达流的结尾,则返回-1。

                // 与对方约定前3个字节为消息内容长度,第4个字节为消息类型。
                // 3个字节:表示一条消息内容最大长度 2^24-1 = 16777215K ≈ 16M。如果不够可继续约定4个字节为消息长度。

                // 消息的长度
                int first = inputStream.read();
                // 如果读取的值为-1 说明到了流的末尾
                // if (first == -1) {
                //     return;
                // }
                int second = inputStream.read();
                int third = inputStream.read();
                // 消息内容长度
                int length = (first << 16) + (second << 8) + third;

                // 消息类型,比如可以定义0为心跳
                int msgType = inputStream.read();

                // 构造一个指定长的byte数组
                byte[] bytes = new byte[length];
                // 读取指定长度的消息内容
                inputStream.read(bytes);
                // socket.shutdownInput();// 单向关闭输入流,发送流的终止符-1。
                // 消息内容
                String msg = new String(bytes, StandardCharsets.UTF_8);

// 到这里就可以将接收到的消息内容msg放入 MQ 或者开启一个子线程来处理消息内容了。

                //心跳
                if (0 == msgType) {
                    System.out.println(LocalDateTime.now().format(formatter) + "=>" + msg);
                    sendMsg(0, "Pong");
                    return;
                }
            } finally {
                // 重复接收消息
                recvMsg();
            }
        }
    }
}

客户端

package com.study;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * Socket客户端
 */
public class SocketClient {
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static Socket socket;
    private static InputStream inputStream;
    private static OutputStream outputStream;

    public static void main(String[] args) throws Exception {
        // 要连接的服务端IP地址和端口
        String host = "127.0.0.1";
        int port = 55533;
        // 与服务端建立连接
        // SocketAddress endpoint = new InetSocketAddress(host, port);
        // Socket socket = new Socket();
        // socket.connect(endpoint,3000);//设置超时3000毫秒
        socket = new Socket(host, port);
        // socket.setSoTimeout(0);// 读超时,默认0,表示inputStream.read()方法会一直等待下去
        socket.setKeepAlive(true); // 防止长时间没有数据传输,socket被关闭
        // socket.setTcpNoDelay(true);// 防止发生黏包
        // 建立好连接后,从socket中获取输入流
        inputStream = socket.getInputStream();
        // 建立好连接后,从socket中获取输出流
        outputStream = socket.getOutputStream();

        // 接收消息,该方法会被阻塞
        new Thread(() -> {
            try {
                recvMsg();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();

        // 每隔30秒发送一次心跳包
        while (true) {
            sendMsg(0, "Ping");
            Thread.sleep(30000);
        }
    }


    /**
     * 发送消息
     * @param msgType 消息类型
     * @param msg     消息内容
     */
    private static void sendMsg(int msgType, String msg) {
        try {
            if (socket == null || socket.isClosed()) {
                return;
            }
            // 首先需要计算得知消息的长度
            byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
            int length = bytes.length;

            // write(int b)方法,只会写入一个字节到输出流,1个字节只有8位,而int类型有32位,要写入的字节是低8位。24个高位被忽略。

            // 与对方约定前3个字节为消息内容长度,第4个字节为消息类型。
            // 3个字节:表示一条消息内容最大长度 2^24-1 = 16777215K ≈ 16M。如果不够可继续约定4个字节为消息长度。

            // 将消息的长度优先发送出去,消息长度约定占3个字节
            outputStream.write(length >> 16);
            outputStream.write(length >> 8);
            outputStream.write(length);
            // 发送消息类型
            outputStream.write(msgType);
            // 发送消息内容
            outputStream.write(bytes);
            // socket.shutdownOutput();// 单向关闭输出流,发送流的终止符-1。
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void recvMsg() throws Exception {
        try {
            // read()方法将阻塞,直到输入数据可用、检测到流的结尾-1或引发异常。
            // read()方法将返回为0到255范围内的整数,如果到达流的结尾,则返回-1。

            // 与对方约定前3个字节为消息内容长度,第4个字节为消息类型。
            // 3个字节:表示一条消息内容最大长度 2^24-1 = 16777215K ≈ 16M。如果不够可继续约定4个字节为消息长度。

            // 消息的长度
            int first = inputStream.read();
            // 如果读取的值为-1 说明到了流的末尾
            // if (first == -1) {
            //     return;
            // }
            int second = inputStream.read();
            int third = inputStream.read();
            // 消息内容长度
            int length = (first << 16) + (second << 8) + third;

            // 消息类型,比如可以定义0为心跳
            int msgType = inputStream.read();

            // 构造一个指定长的byte数组
            byte[] bytes = new byte[length];
            // 读取指定长度的消息内容
            inputStream.read(bytes);
            // socket.shutdownInput();// 单向关闭输入流,发送流的终止符-1。
            // 消息内容
            String msg = new String(bytes, StandardCharsets.UTF_8);

// 到这里就可以将接收到的消息内容msg放入 MQ 或者开启一个子线程来处理消息内容了。

            //心跳
            if (0 == msgType) {
                System.out.println(LocalDateTime.now().format(formatter) + "=>" + msg);
                return;
            }
        } finally {
            // 重复接收消息
            recvMsg();
        }
    }
}

3、黏包和拆包

拆包:当一次发送(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即可防止在发送的时候黏包,但是当发送的速率大于读取的速率时,在服务端也会发生黏包,即因服务端读取过慢,导致它一次可能读取多个包。

拆包

如何应对拆包,那就是如何表明发送完一条消息了,上面通过定义每条消息的长度就可解决。
已知数据长度的,可以构造相同大小的数组,循环读取,示例代码如下:

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

4、性能再次提升

当现在的性能还不能满足需求的时候,就需要考虑使用NIO

NIO实现聊天功能

参考:

java socket 粘包解决_关于socket粘包、拆包的解决方法总结_weixin_39639919的博客-CSDN博客

【Socket】Java Socket编程基础及深入讲解 - 已往之不谏 - 博客园

  • 7
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值