Java Socket 发送/接收数据

java 专栏收录该内容
1 篇文章 0 订阅
这是一篇鸽了许久的博客,一直没有补充源码和设计方案。最近决定重新开始写博客,从还清历史债开始!

在上一篇了解过 Java Socket 的两种形式(TCP、UDP)后,本文将继续介绍如何利用 Java Socket 发送和接收数据及其内部原理的实现。
Java Socket 的目的在于实现与其他程序的信息交互,包括发送和接收信息两种主要操作。进行信息交互的前提在于相互之间具有共同的协议,协议规定了程序之间交互信息的规范和标准。比如 IPv4 和 IPv6 就是定义了如何传输数据的标准,头部的基本结构等信息。

下面将从一个 Demo 入手讲解如何根据需求设计传输协议,实现程序交互。由于关键目的是 socket 传输,所以方案设计重点放在如何设计协议,实现协议等关键问题上。

1. 协议设计与实现

背景信息:为一个学生管理系统设计传输协议。该系统包括客户端和服务端两部分,客户端根据需求发送增、删、改、查相关命令,而服务端根据命令完成相应操作,并反馈信息。学生存储基本信息如下表所示。

项目类型
idint
姓名string
性别“man” or “woman”
年龄int

学生最大范围为 0 ~ 65535,列表每页个数为 5 ~ 10 个。

1.1 需求分析

1.1.1 功能需求分析

  • :客户端传入学生姓名、性别和年龄,服务端解析信息,并执行新增操作。
  • :客户端传入学生 id,服务端解析信息,根据 id 执行删除操作。
  • :客户端传入学生 id 和待修改信息,服务端解析信息,根据 id 和修改项执行修改操作。
  • 查询列表:客户端传入 pageNo 和 pageSize ,服务端解析信息,根据 pageNo 和 pageSize 执行分页查找操作。
  • 查询信息:客户端传入 学生 id,服务端解析信息,根据 id 执行查询操作。

1.1.2 性能需求分析

在这个问题中,暂不考虑性能需求,但实际业务中需考虑到频繁请求的业务并给出可能边界,具体问题具体分析。

1.2 协议设计

对于客户端程序来说,可能会存在不同版本存在不同功能的情况,因此在协议设计的时候,最好预留版本号字段来控制不同版本。这样在业务处理代码里面,可以对不同版本号的程序做处理。在此为了简化问题,发送时按照最少字节数发送,考虑版本号采用 4 位。
在该问题中可能的操作有 5 种,需要的二进制位至少需要 3 位(23 = 8 > 5)。但在此考虑可能的功能扩展,若使用 3 位存储所需功能,那只剩下 3 个不同功能扩展,后期可能会出现问题,因此考虑设置 4 位,这个放大根据实际情况考虑即可,原则是后期扩展不会出现问题。

1.2.1 新增

新增功能时,需要传输学生的姓名、性别、年龄。
姓名:考虑采用 GBK 编码,每个中文占 2 个字节,名字的最大长度为 4 个汉字(不考虑少数民族,以汉族为例),因此一共占用 8 个字节,不足补 0。
性别:类别数据,仅含有两类 man 和 woman,故可采用 0 / 1 来分别表示男 / 女,只需要 1 位。
年龄:年龄的范围 1 ~ 120,因此考虑 7 位二进制数即可。
由此,一个新增学生的传输格式可表示如下:

版本号操作类型姓名性别年龄
4 bit4 bit64 bit1 bit7 bit

1.2.2 删除

删除功能时,需要传输学生的 id,系统用户数的预估为 65535 个,因此,占用 16 位,最多可存储 65536 个用户。
由此,一个删除学生的传输格式可表示如下:

版本号操作类型id
4 bit4 bit16 bit

1.2.3 修改

修改功能时,需传输学生的 id,待修改的项和内容。id 的传输方式同 1.2.2 删除一节中描述的传输格式。
对于待修改项和内容的设置可以考虑采用标记位的形式进行。以修改的项目位性别和年龄两个字段为例,在这里只考虑每次只修改一个的情况。
而对于学生信息的操作,则套用新增命令中的格式,即可实现对单个学生修改的传输。
此时,修改学生信息的传输格式可表示如下:

版本号操作类型id标记位年龄 / 性别
4 bit4 bit16 bit8 bit8 bit

在此为了简化问题,对年龄和性别均凑到单个字节来考虑,若是为了传输和性能最优考虑,需要根据标记位情况,对后面不同位数的数据做进一步处理。例如,年龄采用后面 7 位存储,性别采用后面 1 位存储。

1.2.4 查询列表

查询列表功能时,需传输 pageNo 和 pageSize 参数,其中 pageNo 表示需要查询的页码,pageSize 表示需要查询每页的个数。由于列表每页个数为 5 ~ 10 个,因此 pageSize 的值为 5 ~ 10,即需要4位存储,pageNo 的值为 6554 ~ 13107,即需要 14 位(16384)存储。操作中为了简化问题,将页码和每页个数向上取整到整数字节。
分页查询传输格式可表示如下:

版本号操作类型页码每页个数
4 bit4 bit16 bit8 bit

1.2.5 查询信息

查询信息功能时,需传输 id 信息,传输格式如 1.2.2 删除节所示,在此不再赘述。

2. 代码实现

2.1 架构设计

client 与 server 端建立 socket 连接,并将增删改查对应的指令编码(encode)发送到 server 端,server 端在接收到数据后,将指令解码(decode)处理并返回处理结果。
code structure

2.2 关键代码讲解

2.2.1 字节操作类 WBit

该类在 byte[] 的基础上做封装,主要功能用于合并和解析字节数组。该类的对象中会维护一个 pos 值,用于控制当前读写位置。
类的方法及功能包括:

// 构造函数:
public WBit(int length);
public WBit(byte[] bytes, int pos);

// 以 length 长度合并字节数组,若长度不够,则前面补 0,pos 的值会 + length
public void put(byte[] array, int length);
// 添加 1 个字节到数组中,pos 的值会 + 8
public void putByte(byte data);

// 获取 length 长度的字节数组,若为前置 0  则自动舍弃,pos 的值会 - length
public byte[] get(int length);
// 获取 1 个字节,pos 的值会 - 8
public byte getByte();

// 根据 length 对 bytes 数组做分割
public static byte[] cutArrayByLength(byte[] bytes, int length); 

// 获取 byte 数组
public byte[] getBytes();

其中关键的方法有两个:

get 方法

    public byte[] get(int length) throws Exception {
        if (0 != length % ByteSize) {
            throw new Exception("Not support non-integer length.");
        }

        this.pos -= length;
        int byteIndex = this.pos / ByteSize;

        byte[] operateBytes = new byte[length / ByteSize];
        int i = Math.max(0, byteIndex - 1), work = 0;
        for (; i < length / ByteSize; i++) {
            if (bytes[i] != 0) {
                operateBytes[work++] = bytes[i];
                bytes[i] = 0;
            }
        }
        System.arraycopy(bytes, i, bytes, 0, this.pos / ByteSize);

        return WBit.cutArrayByLength(operateBytes, work);
    }

核心思想:当需要获取字节时,需要从当前位置 pos 往回数 length 个位,然后再往后按照需要读出的字节数复制到字节数组中,并借助 work 指针去掉连续的前置 0,最后再对数据做裁剪,返回结果。

put 方法

    public void put(byte[] array, int length) throws Exception {
        if (0 != length % ByteSize) {
            throw new Exception("Not support non-integer length.");
        }

        int byteIndex = this.pos / ByteSize;

        if (array.length < length / ByteSize) {
            int supplyZeroCount = length / ByteSize - array.length;
            for (int i = 0; i < supplyZeroCount; i++) {
                bytes[i + byteIndex] = (byte)0;
            }

            byteIndex += supplyZeroCount;
        }

        System.arraycopy(array, 0, bytes, byteIndex, array.length);
        pos += length;
    }

核心思想:判断待添加数组和 length 的关系,若 length 较长,说明需要补充前置 0,否则则直接将数组内容从 pos 处拷贝并修改 pos 即可。

注:由于 java 对位的操作不是很方便,因此在此简化问题,尽可能把编码的位数凑到整数字节或相邻的编码位数凑到整数字节。

2.2.2 学生信息编解码 Student

由于在该系统中,可能会涉及对学生信息的编解码,因此直接在 Student 类上面添加 toBytes和可以直接加载 byte[] 的构造函数,处理逻辑如下所示。

构造函数

    public Student(byte[] data, boolean containsId) throws Exception {
        WBit wbit = new WBit(data, containsId ? Constant.TotalLength + Constant.IdLength : Constant.TotalLength);

        if (containsId) {
            byte[] idRes = wbit.get(Constant.IdLength);
            this.id = ByteBuffer.wrap(idRes).getInt();
        }

        this.name = new String(wbit.get(Constant.NameLength), DefaultCharset);

        byte sexAndAgeByte = wbit.get(ByteSize)[0];
        this.sex = sexAndAgeByte >> 7 == 0 ? Sex.Male : Sex.Female;
        this.age = sexAndAgeByte & 0x7F;
    }

核心思想:借助 WBit 类对 byte[] 进行读取和解析,这里加了一个对编码时是否包含 id 的特殊处理。考虑到在新增的时候,客户端无法确认 id,因此传的信息应该是不包含 id 信息的,但是在查询列表时,信息需要包含 id 的以区分同样姓名、性别和年龄的学生。这里加了对汉字的处理,采用 gbk 编码以控制名字的字节个数。

toBytes 方法

    public byte[] toBytes(boolean containsId) throws Exception {
        WBit wbit = new WBit(containsId ? Constant.TotalLength + Constant.IdLength : Constant.TotalLength);

        if (containsId) {
            byte[] idBytes = { id.byteValue()};
            wbit.put(idBytes, Constant.IdLength);
        }

        wbit.put(name.getBytes(DefaultCharset), Constant.NameLength);

        byte sexByte = (byte)(Sex.Male == sex ? 0 : 1);
        byte ageByte = age.byteValue();
        byte sexAndAgeByte = (byte)(sexByte << 7 | ageByte);

        wbit.putByte(sexAndAgeByte);
        return wbit.getBytes();
    }

核心思想:借助 WBit 来组合 byte [],对于 sexByte 和 ageByte 的组合处理,采用了位运算,即使用 sexByte 的地位作为 ageByte 的最高位,拼成一个 Byte。

2.2.3 Instruction 设计

在指令方面的设计,则考虑采用一个简单的指令工厂,借助 interface 实现。
首先定义一个 Instruction 的 interface:

public interface Instruction {

    // encode instruction to byte array
    byte[] encode() throws Exception;

    // get instruction type
    Integer getInstructionType();

    // execute command, not implement yet
    byte[] execute();
}

然后增、删、改、查等操作均实现该接口,完成编码和执行等相关功能。
以增加操作为例:

public class AddInstruction implements Instruction {
    private Student student;

    public AddInstruction(Student stu) {
        this.student = stu;
    }

    public AddInstruction(byte[] data) throws Exception {
        this.student = new Student(data, false);
    }

    @Override
    public byte[] encode() throws Exception {
        return this.student.toBytes(false);
    }

    @Override
    public Integer getInstructionType() {
        return 1;
    }

    @Override
    public byte[] execute() {
        System.out.println("Execute add student operations for stu: " + student.toString());
        return Response.getResponseBytes(Constant.Success);
    }

    public Student getStudent() {
        return student;
    }
}

上层的工厂类设计如下:

public class InstructionFactory {
    private Instruction instruction;
    private byte[] bytes;

    public InstructionFactory(Instruction instruction) {
        this.instruction = instruction;
    }

    public InstructionFactory(byte[] data) throws Exception {
        if (0 == data.length) {
            throw new Exception("Data is empty, can't decode");
        }

        bytes = data;
    }

    public byte[] getBytes() throws Exception {
        if (null == bytes) {
            byte[] instrBytes = instruction.encode();

            byte header = instruction.getInstructionType().byteValue();
            header |= Constant.ProtoctolNumber.byteValue() << 4;

            bytes = new byte[instrBytes.length + 1];
            bytes[0] = header;

            System.arraycopy(instrBytes, 0, bytes, 1, instrBytes.length);
        }

        return bytes;
    }

    public Instruction getInstruction() throws Exception {
        if (null == instruction) {

            byte header = bytes[0];
            byte[] instrBytes = new byte[bytes.length - 1];
            System.arraycopy(bytes, 1, instrBytes, 0, bytes.length - 1);

            int protoctolNumber = header >> 4;
            if (protoctolNumber != Constant.ProtoctolNumber) {
                System.out.printf("%d %d", protoctolNumber, Constant.ProtoctolNumber);
                throw new Exception("Protoctol don't match");
            }

            int type = header & 0x07;
            switch (type) {
                case 1:
                    instruction = new AddInstruction(instrBytes);
                    break;
                case 2:
                    instruction = new DeleteInstruction(instrBytes);
                    break;
                case 3:
                    instruction = new UpdateInstruction(instrBytes);
                    break;
                case 4:
                    instruction = new SelectInstruction(instrBytes);
                    break;
            }
        }

        return instruction;
    }

}

该类主要用于发送指令时统一添加 header 和接收指令时自动校验版本并将数据传递给不同指令解码,最后再调用执行对应操作(当前版本仅打印)。

2.2.4 数据传输设计

主要包括 server 和 client 端,client 端主要功能为将指令转换为 byte[],启动 socket 连接发送到 server 端,等待 server 端处理完成后,再获取相应信息。server 端

Client 端 - TCPClient

public class TCPClient {
    private InputStream input;
    private OutputStream output;
    private Socket socket;

    TCPClient() throws IOException {
        socket = new Socket(Constant.ServerUrl, Constant.ListenPort);
        input = socket.getInputStream();
        output = socket.getOutputStream();
    }


    public byte[] sendDataToServer(byte[] data) throws IOException {
        byte[] result;

        try {
            output.write(data);
            output.flush();
            socket.shutdownOutput();

            byte[] receiveData = new byte[Constant.MaxTranslateSize];
            int receiveCount = input.read(receiveData);
            result = WBit.cutArrayByLength(receiveData, receiveCount);

            socket.shutdownInput();
            input.close();
            output.close();
            socket.close();

        } catch (IOException e) {
            if (null != input) {
                try {
                    input.close();
                } catch (IOException e1) {
                    System.out.println(e1);
                }
            }

            if (null != output) {
                try {
                    output.close();
                } catch (IOException e1) {
                    System.out.println(e1);
                }
            }

            if (null != socket) {
                try {
                    socket.close();
                } catch (IOException e1) {
                    System.out.println(e1);
                }
            }

            throw e;
        }

        return result;
    }
}

注意事项:

  • 对于 client 端来说,输出流为发送数据到 server 端,输入流为从远端接收数据。
  • 在正常场景下关闭流的顺序,倒序。
  • 异常场景下的处理,需将原始异常抛出给上层,避免异常丢失的场景。

**Server 端 **

public class TCPServer {

    private ServerSocket serverSocket;

    TCPServer() throws IOException {
        this.serverSocket = new ServerSocket(Constant.ListenPort);
    }

    public void run() {
        System.out.println("Listening on port: " + Constant.ListenPort);
        int recvMsgSize;
        Socket clntSock = null;
        InputStream in = null;
        OutputStream out = null;

        while (true) {
            try {
                clntSock = this.serverSocket.accept();
                SocketAddress clientAddress = clntSock.getRemoteSocketAddress();
                System.out.println("Handling client from " + clientAddress);

                in = clntSock.getInputStream();
                byte[] buffer = new byte[Constant.BufferSize];
                byte[] receiveBytes = new byte[Constant.MaxTranslateSize];
                int work = 0;
                while (-1 != (recvMsgSize = in.read(buffer))) {
                    System.arraycopy(buffer, 0, receiveBytes, work, recvMsgSize);
                    work += recvMsgSize;
                }

                receiveBytes = WBit.cutArrayByLength(receiveBytes, work);
                clntSock.shutdownInput();

                out = clntSock.getOutputStream();
                InstructionFactory factory = new InstructionFactory(receiveBytes);
                Instruction instruction = factory.getInstruction();

                byte[] result = instruction.execute();

                out.write(result);
                out.flush();

                clntSock.shutdownOutput();
            } catch (Exception e) {
                System.out.println(e);
                if (null != out) {
                    try {
                        out.close();
                    } catch (IOException e1) {
                        System.out.println(e1);
                    }
                }

                if (null != in) {
                    try {
                        in.close();
                    } catch (IOException e1) {
                        System.out.println(e1);
                    }
                }

                if (null != clntSock) {
                    try {
                        clntSock.close();
                    } catch (IOException e1) {
                        System.out.println(e1);
                    }
                }

            }
        }
    }

    public static void main(String[] args) throws Exception {
        TCPServer tcpServer = new TCPServer();
        tcpServer.run();
    }
}

注意事项:

  • server 端的异常处理需要避免因为单个请求处理失败,导致整个 server 挂掉的情况。
  • 对每次的 input 和 output 做处理,包括正常和异常场景。
  • 当前实现为阻塞式,循环处理,若是大量请求发到 server 端时可能会有问题,优化方式可考虑异步队列。

3. 总结与反思

在初始设计阶段,没想清楚到底做到什么程度,导致什么东西都想搞,想往上加,结果导致一直拖着拖着,差点拖没了。感谢评论区里小伙伴的无情催促,也算是我的动力之一吧。做工程还是完美主义不可取,这一版的程序虽然不完美,没有 UT,没有精细的异常处理,错误码,没有把传输协议细化到 bit,甚至功能也省掉了一大部分,但好歹是核心部分完成了。姑且算作结束吧,算是对之前未结束的事项一个结果。如果大家对源码感兴趣,想提各种意见或者完善优化这个程序,可以来这里 GitHub,可以互关一波,以后一起写写项目,搞搞技术。

对 java socket 的简单理解大概就到这里了,后续如果有时间的话,会继续研究一下这块的东西,写点更有意思的东西出来。近期在用的语言是 golang,计划下一个项目用 golang 写个分布式监控的小程序,框架搭了一部分,还在构思中~

  • 4
    点赞
  • 6
    评论
  • 14
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 鲸 设计师:meimeiellie 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值