搞懂Socket通信(三)

840 阅读3分钟

这是我参与8月更文挑战的第16天,活动详情查看:8月更文挑战

搞懂Socket通信(三)

前两篇都是理论知识,是实实在在的干货,这篇文章会用代码诠释和证明理论知识,包括完整的自己封装长连接通信。由于笔者从事Android开发,所以代码分析会更偏向于客户端。

一、场景

简单点说

场景:上位机(客户端) 想给 下位机(服务端) 发条消息

我们再延伸出一些需求

  1. 消息内容可能是字符串。
  2. 消息内容可能很大。
  3. 消息内容非常重要,要确保能收到。
  4. 他们不聊天了要及时让对方知道

二、代码设计

2.1 启动Socket TCP服务器

下位机对应服务端,在发送消息之前,要先让服务器运行起来,才能让上位机(客户端)进行连接。

启动Socket TCP 服务

            ServerSocket serverSocket = new ServerSocket(Config.PORT);
            serverSocket.setReuseAddress(true);
            Socket socket = serverSocket.accept();
            socket.setSendBufferSize(3000 * 1024);

实际上仅仅new ServerSocket(端口) 已经可以开启服务了,看看它内部是如何实现的。

public ServerSocket(int port) throws IOException {
        this(port, 50, null);
    }
    
public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException {
        setImpl();
        if (port < 0 || port > 0xFFFF)
            throw new IllegalArgumentException(
                       "Port value out of range: " + port);
        if (backlog < 1)
          backlog = 50;
        try {
            bind(new InetSocketAddress(bindAddr, port), backlog);
        } catch(SecurityException e) {
            close();
            throw e;
        } catch(IOException e) {
            close();
            throw e;
        }
    }

重点在 Bind方法。

setReuseAddress() 方法是复用端口地址,避免端口被占用的问题。 setSendBufferSize() 方法是设置缓冲区的大小,并不是说设置了缓冲区大小就能发送这么大的数据,传输数据的MTU首先于最小的瓶颈,比如说也局限于客户端的缓冲区大小,带宽的大小等等。

2.1 TCP服务器的设计

由于客户端可能会有多个,所以需要有个容器存放这么多的客户端。

我们需要 new 很多 SocketServerClient 放在容器中。

SocketServerClient是什么,它是一个可以管理客户端对象的一个类,可以监听到客户端消息,可以给客户端发消息,可以监听到客户端是否在线。

  • SocketServerClient方法中监听客户端的消息,由于是阻塞方法监听消息,我们需要开一个线程,等着消息过来。
新线程

 while (true) {
            try {
                InputStream inputStream = socket.getInputStream();
                out = socket.getOutputStream();
                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
                String str = reader.readLine();
                // string 为收到的消息
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

  • SocketServerClient方法发送消息,我们只需要在输出流OutputStream放入数据即可
String data = msg + "\n";
byte[] bytes = data.getBytes();
out.write(bytes);
out.flush();
  • 管理Socket客户端
List serverClients = new ArrayList<>();
serverClients.add(client);

2.2 客户端-连接

客户端连接到服务端有两种方式

  • 方式一
Socket socket = new Socket(ip, port);
  • 方式二
 Socket socket = new Socket();
 socket.connect(new InetSocketAddress(ip, port), 10 * 1000);

这里推荐方式二,因为可以便捷的设置连接超时时间,有人说方式一也可以设置setSoTimeout(),但我自己测下来,它并不是连接超时时间。

完整代码:

    /**
     * 尝试建立tcp连接
     *
     * @param ip
     * @param port
     */
    private boolean startTcpConnection(final String ip, final int port) {
        try {
            if (mSocket == null) {
                mSocket = new Socket();
                mSocket.connect(new InetSocketAddress(ip, port), 10 * 1000);

                mSocket.setKeepAlive(true);
                mSocket.setTcpNoDelay(true);
                mSocket.setReuseAddress(true);
                mSocket.setReceiveBufferSize(3000 * 1024);
            }
            is = mSocket.getInputStream();
            br = new BufferedReader(new InputStreamReader(is));
            OutputStream os = mSocket.getOutputStream();
            pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(os)), true);
            LogWrapper.d(TAG, "tcp 创建成功...");
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            LogWrapper.e(TAG, "startTcpConnection error:" + e.toString());
        }
        return false;
    }

2.2 客户端-监听消息

创建接收线程 接收的消息必须附带换行,否则readLine无法读取到。测试过readLine方法不存在粘包的问题,重点在于长数据服务端发送时是否加锁。 TCP 虽然是有序的,但不保证服务端长数据分包发送时放入缓冲区的顺序是对的。

 private void startReceiveTcpThread() {
        ThreadPool.getInstance().execute(new Runnable() {
            @Override
            public void run() {
                // 获取数据
                String line = "";
                try {
                    while ((line = br.readLine()) != null) {
                        handleReceiveTcpMessage(line);
                    }
                    if (line == null) {
                        LogWrapper.e(TAG, "line == null(上位机断开),开始断开...");
                        disConnect(); // 上位机断开
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                    // 没有正常关闭时会出现
                    LogWrapper.e(TAG, "接收消息异常, 开始断开...:" + e.toString());
                    disConnect();
                }
            }
        });
    }

2.3 客户端-发送消息

数据直接丢在PrintWriter就可以了

public void sendTcpMessage(final String msg, final SendCallback sendCallback) {
        ThreadPool.getInstance().execute(new Runnable() {
            @Override
            public void run() {
                try {
                    pw.println(msg);
                    if (sendCallback != null)
                        sendCallback.success();
                } catch (Exception e) {
                    if (sendCallback != null)
                        sendCallback.failed();
                }
            }
        });
    }

2.4 客户端-心跳机制

如果是强制断网的情况,两边无法即时收到消息。这个时候就需要心跳来保证两边都在线的状态。 心跳机制的原理是比如10秒一次心跳,当发送心跳三次对方没有回应时,则认定为对方不在线,自己就应该执行disconnect方法去断开TCP连接,及时更新状态。

流程图如下:

graph TD
A[开始] --> B(发送心跳)
B --> C{心跳失败是否大于三次}
C -->|是| D[断开]
C -->|否| E[重置心跳]

心跳机制关键代码如下:


private class HeartRunnable implements Runnable {
        @Override
        public void run() {
            LogWrapper.d(TAG, "心跳机制-发送心跳任务 run");
            mHeartCurIndex++;

            if (mHeartCurIndex > HEART_SEND_COUNT) {
                // 三次失败了。
                LogWrapper.d(TAG, "心跳机制-三次失败-自动断开");
                if (mTcpSocket != null) {
                    LogWrapper.e(TAG, "心跳机制-三次失败-自动断开");
                    mTcpSocket.disConnect();
                }

                return;
            }

            String heartJson = "{ping}"; // 举例而已

            if (mTcpSocket != null)
                mTcpSocket.sendTcpMessage(heartJson, null);

            if (mHeartRunnable != null)
                mHeartHandler.postDelayed(mHeartRunnable, HEART_SEND_INTERVAL);

        }
    }

2.5 客户端-断线重连机制

在正常使用过程中,难免会遇到网络不好的情况,这个时候就需要进行断线重连机制来更快的让双方都在线。

graph TD
A[开始] --> B(断线了)
B --> C{断线重连次数>20}
C -->|是| D[20秒一次重连]
C -->|否| E[5秒一次重连]

断线重连机制关键代码

    /**
     * 异常重连机制 逻辑
     */
    public void errorReConnect() {
        //重置
        resetReconnectRunnable();
        //判断是否有网络
        if (!NetWorkUtils.isNetworkAvailable(AppCache.getContext())) {
            mReconnectCount = 0;
            return;
        }
        mReconnectCount++;
        long reconnectTime = minInterval;
        if (mReconnectCount > 20) {
            reconnectTime = maxInterval;
        }
        if (mReconnectRunnable == null) {
            mReconnectRunnable = new ReconnectRunnable();
        }
        LogWrapper.d(TAG, "执行重连的间隔时间" + reconnectTime);
        mReConnectHandler.postDelayed(mReconnectRunnable, reconnectTime);
    }