这是一篇鸽了许久的博客,一直没有补充源码和设计方案。最近决定重新开始写博客,从还清历史债开始!
文章目录
在上一篇了解过 Java Socket 的两种形式(TCP、UDP)后,本文将继续介绍如何利用 Java Socket 发送和接收数据及其内部原理的实现。
Java Socket 的目的在于实现与其他程序的信息交互,包括发送和接收信息两种主要操作。进行信息交互的前提在于相互之间具有共同的协议,协议规定了程序之间交互信息的规范和标准。比如 IPv4 和 IPv6 就是定义了如何传输数据的标准,头部的基本结构等信息。
下面将从一个 Demo 入手讲解如何根据需求设计传输协议,实现程序交互。由于关键目的是 socket 传输,所以方案设计重点放在如何设计协议,实现协议等关键问题上。
1. 协议设计与实现
背景信息:为一个学生管理系统设计传输协议。该系统包括客户端和服务端两部分,客户端根据需求发送增、删、改、查相关命令,而服务端根据命令完成相应操作,并反馈信息。学生存储基本信息如下表所示。
项目 | 类型 |
---|---|
id | int |
姓名 | 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 bit | 4 bit | 64 bit | 1 bit | 7 bit |
1.2.2 删除
删除功能时,需要传输学生的 id,系统用户数的预估为 65535 个,因此,占用 16 位,最多可存储 65536 个用户。
由此,一个删除学生的传输格式可表示如下:
版本号 | 操作类型 | id |
---|---|---|
4 bit | 4 bit | 16 bit |
1.2.3 修改
修改功能时,需传输学生的 id,待修改的项和内容。id 的传输方式同 1.2.2 删除一节中描述的传输格式。
对于待修改项和内容的设置可以考虑采用标记位的形式进行。以修改的项目位性别和年龄两个字段为例,在这里只考虑每次只修改一个的情况。
而对于学生信息的操作,则套用新增命令中的格式,即可实现对单个学生修改的传输。
此时,修改学生信息的传输格式可表示如下:
版本号 | 操作类型 | id | 标记位 | 年龄 / 性别 |
---|---|---|---|---|
4 bit | 4 bit | 16 bit | 8 bit | 8 bit |
在此为了简化问题,对年龄和性别均凑到单个字节来考虑,若是为了传输和性能最优考虑,需要根据标记位情况,对后面不同位数的数据做进一步处理。例如,年龄采用后面 7 位存储,性别采用后面 1 位存储。
1.2.4 查询列表
查询列表功能时,需传输 pageNo 和 pageSize 参数,其中 pageNo 表示需要查询的页码,pageSize 表示需要查询每页的个数。由于列表每页个数为 5 ~ 10 个,因此 pageSize 的值为 5 ~ 10,即需要4位存储,pageNo 的值为 6554 ~ 13107,即需要 14 位(16384)存储。操作中为了简化问题,将页码和每页个数向上取整到整数字节。
分页查询传输格式可表示如下:
版本号 | 操作类型 | 页码 | 每页个数 |
---|---|---|---|
4 bit | 4 bit | 16 bit | 8 bit |
1.2.5 查询信息
查询信息功能时,需传输 id 信息,传输格式如 1.2.2 删除节所示,在此不再赘述。
2. 代码实现
2.1 架构设计
client 与 server 端建立 socket 连接,并将增删改查对应的指令编码(encode)发送到 server 端,server 端在接收到数据后,将指令解码(decode)处理并返回处理结果。
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 写个分布式监控的小程序,框架搭了一部分,还在构思中~