Java Socket codec

要写好java的网络编程并不只是new几个Socket,get一下InputStream,write to OutStream这么简单的。如何定义高效,稳定的协议,如何处理TCP协议中字节的发送和接收,编码,解码问题?socket缓冲区又是什么?本文讲讨论这些问题。

 

codec

     TCP/IP 协议以字节的方式传输用户数据,并没有对其进行检查和修改。这个特点使应用程序可以非常灵活地对其传输的信息进行编码。TCP/IP 协议的唯一约束是,信息必须在块(chunks)中发送和接收,而块的长度必须是 8 位(一个字节)的倍数,因此,我们可以认为在 TCP/IP 协议中传输的信息是字节序列。鉴于此,我们可以进一步把传输的信息看作byte数组,每个数字的取值范围是 0 到 255。这与 8 位编码的二进制数值范围是一致的:00000000 代表 0,00000001 代表 1,00000010 代表 2,等等,最多到 11111111,即255。

 

基本整型

     如我们所见,TCP 和 UDP 套接字使我们能发送和接收字节序列(数组) 即范围在 0-255,之间的整数。使用这个功能,我们可以对值更大的基本整型数据进行编码,不过发送者和接收者必须先在一些方面达成共识。一是要传输的每个整数的字节大小(size)。例如,Java程序中,int 数据类型由 32 位表示,因此,我们可以使用 4 个字节来传输任意的 int 型变量或常量;short 数据类型由 16 位表示,传输 short 类型的数据只需要两个字节;同理,传输64 位的 long 类型数据则需要 8 个字节。
下面我们考虑如何对一个包含了 4 个整数的序列进行编码:

  • 一个 byte 型,
  • 一个 short 型,
  • 一个 int 型,
  • 以及一个 long 型,

     按照这个顺序从发送者传输到接收者。我们总共需要 15 个字节:第一个字节存放 byte 型数据,接下来两个字节存放 short 型数据,再后面 4 个字节存放 int 型数据,最后 8 个字节存放 long 型数据。如下图:


 

 

order of transmission:传输顺序

     我们已经做好深入研究的准备了吗?未必。对于需要超过一个字节来表示的数据类型,我们必须知道这些字节的发送顺序。显然有两种选择:从整数的右边开始,由低位到高位地发送,即 little-endian 顺序;或从左边开始,由高位到低位发送,即 big-endian 顺序。考虑长整型数 123456787654321L,其 64 位(以十六进制形式)表示为 0x0000704885F926B1。

如果我们以 big-endian 顺序来传输这个整数,其字节的十进制数值序列就如下所示:


 
如果我们以 little-endian 顺序传输,则字节的十进制数组序列为:


    关键的一点是,对于任何多字节的整数,发送者和接收者必须在使用 big-endian 顺序还是使用 little-endian 顺序上达成共识。如果发送者使用了 little-endian 顺序来发送上述整数,而接收者以 big-endian 顺序对其进行接收,那么接收者将取到错误的值,它会将这个 8 字节序列的整数解析成 12765164544669515776L。
    发送者和接收者需要达成共识的最后一个细节是:所传输的数值是有符号的(signed)还是无符号的(unsigned)。Java 中的四种基本整型都是有符号的,它们的值以二进制补码的方式存储,这是有符号数值的常用表示方式。

 

正确存入到字节数组
   为了清楚地展示需要做的步骤,我们将对如何使用"位操作(bit-diddling)"(移位和屏蔽)来显式编码进行介绍。这部分内容见:各种进制基础知识

 

DataOutputStream和DataInputStream

      如你所见,上面的强制编码方法需要程序员做很多工作:要计算和命名每个数值的偏移量和大小,并要为编码过程提供合适的参数。如果没有将 encodeIntBigEndian()方法提出来作为一个独立的方法,情况会更糟。基于以上原因,强制编码方法是不推荐使用的,而且 Java 也提供了一些更加易用的内置机制。不过,值得注意的是强制编码方法也有它的优势,除了能够对标准的 Java 整型进行编码外,encodeIntegerBigEndian() 方法对 1 到8 字节的任何整数都适用--例如,如果愿意的话,你可以对一个 7 字节的整数进行编码。

    构建本例中的消息的一个相对简单的方法是使用 DataOutputStream 类和ByteArrayOutputStream 类。DataOutputStream 类允许你将基本数据类型,如上述整型,写入一个流中:它提供了 writeByte(),writeShort(),writeInt(),以及 writeLong()方法。

ByteArrayOutputStream buf = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(buf);
out.writeByte(byteVal);
out.writeShort(shortVal);
out.writeInt(intVal);
out.writeLong(longVal);
out.flush();
byte[] msg = buf.toByteArray();

 

处理BigInteger
    对于基本整型,发送者和接收者必须在使用多大空间(字节数)来表示一个数值上达成共识。但是这又与使用 BigInteger 相矛盾,因为 BigInteger 可以是任意大小。一种解决方法是使用基于长度的Frame。

 

字符串和文本

     对于字符串只要我们指定如何对要传输的文本进行编码,我们就几乎能发送其他任何类型的数据:先将其表示成文本形式,再对文本进行编码。首先得将文本视为由符号和字符(characters)组成。实际上每个 String 实例都对应了一个字符序列(数组,char[]类型)。一个字符在 Java 内部表示为一个整数。例如,字符"a",即字母"a"的符号,与整数 97 对应;字符"X"对应了 88,而符号"!"(感叹号)则对应了 33。
       在一组符号与一组整数之间的映射称为编码字符集,或许你听说过 ASCII 编码字符集。Java 使用了一种称为 Unicode 的国际标准编码字符集来表示 char 型和 String 型值。Unicode 字符集将"世界上大部分的语言和符号"[ ]映射到整数 0 至 65535 之间,能更好地适用于国际化程序。例如,日文平假名中代表音节"o"的符号映射成了整数 12362。Unicode包含了 ASCII 码:每个 ASCII 码中定义的符号在 Unicode 中所映射整数与其在 ASCII 码中映射的整数相同。这就为 ASCII 与 Unicode 之间提供了一定程度的向后兼容性。

    发送者与接收者必须在符号与整数的映射方式上达成共识,才能使用文本信息进行通信。对于每个整数值都比 255小的一小组字符,则不需要其他信息,因为其每个字符都能够作为一个单独的字节进行编码。对于可能使用超过一个字节的大整数的编码方式,就有多种方式在线路上对其进行编码。因此,发送者和接收者还需要对这些整数如何表示成字节序列统一意见,即编码方案(encodingscheme)。编码字符集和字符的编码方案结合起来称为字符集(charset,见 RFC 2278)。你也可以定义自己的字符集,但没有理由这样做,世界上已经有大量不同的标准(standardized)字符集在使用。Java 提供了对任意字符集的支持,而且每种实现都必须支持以下至少一种字符集:US-ASCII(ASCII 的另一个名字) ISO-8859-1,UTF-8,UTF-16BE,UTF-16LE,UTF-16。

组合输入输出流

   Java 中与流相关的类可以组合起来从而提供强大的功能。例如,我们可以将一个 Socket实例的 OutputStream 包装在一个 BufferedOutputStream 实例中,这样可以先将字节暂时缓存在一起,然后再一次全部发送到底层的通信信道中,以提高程序的性能。我们还能再将这个BufferedOutputStream 实例包裹在一个 DataOutputStream 实例中,以实现发送基本数据类型的功能。以下是实现这种组合的代码

Socket socket = new Socket(server, port);
DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(socket.getOutputStream()));

 

Framing

    将数据转换成在线路上传输的格式只完成了一半工作,在接收端还必须将接收到的字节序列还原成原始信息。应用程序协议通常处理的是由一组字段组成的离散的信息,Framing解决了接收端如何定位消息的首尾位置的问题。无论信息是编码成了文本、多字节二进制数、或是两者的结合,应用程序协议必须指定消息的接收者如何确定何时消息已完整接收。这是一个没有处理好Frame的例子:socket的问题

    如果一条完整的消息负载在一个 DatagramPacket 中发送,这个问题就变得很简单了:DatagramPacket 负载的数据有一个确定的长度,接收者能够准确地知道消息的结束位置。然而,如果通过 TCP 套接字来发送消息,情况将变得更复杂,因为 TCP 协议中没有消息边界的概念。如果一个消息中的所有字段都有固定的长度,同时每个消息又是由固定数量的字段组成的话,消息的长度就能够确定,接收者就可以简单地将消息长度对应的字节数读到一个 byte[]缓存区中。但是如果消息的长度是可变的(例如消息中包含了一些变长的文本字符串),我们事先就无法知道需要读取多少字节。

    如果接收者试图从套接字中读取比消息本身更多的字节,将可能发生以下两种情况之一:如果信道中没有其他消息,接收者将阻塞等待,同时无法处理接收到的消息;如果发送者也在等待接收端的响应信息,则会形成死锁(deadlock); 另一方面,如果信道中还有其他消息,则接收者会将后面消息的一部分甚至全部读到第一条消息中去,这将产生一些协议错误。因此,在使用 TCP 套接字时,成帧就是一个非常重要的考虑因素。

主要有两个技术使接收者能够准确地找到消息的结束位置:

  • 基于定界符(Delimiter-based):消息的结束由一个唯一的标记(unique marker,)指出,即发送者在传输完数据后显式添加的一个特殊字节序列。这个特殊标记不能在传输的数据中出现。
  • 显式长度(Explicit length):在变长字段或消息前附加一个固定大小的字段,用来指示该字段或消息中包含了多少字节。

关于Frame的实现,参考Frame实现

 

 

 

 

 

 

 

 

 

 

  由于 TCP 提供了一种可信赖的字节流服务,任何写入 Socket 的 OutputStream 的数据复本都必须保留,直到其在连接的另一端被成功接收。向OutputStream写数据并不意味着数据实际上已经被发送--它们只是被复制到了本地缓冲区。就算在Socket 的 OutputStream 上进行 flush()操作,也不能保证数据能够立即发送到信道。

 

     在使用 TCP socket时需要记住的最重要一点是: 不能假设在连接的一端将数据写入输出流和在另一端从输入流读出数据之间有任何一致性。尤其是在发送端由单个输出流的 write()方法传输的数据,可能会通过另一端的多个输入流的 read()方法来获取;而一个 read()方法可能会返回多个 write()方法传输的数据。下面是一个例子:

 

byte[] buffer0 = new byte[1000];
byte[] buffer1 = new byte[2000];
byte[] buffer2 = new byte[5000];
...
Socket s = new Socket(destAddr, destPort);
OutputStream out = s.getOutputStream();
...
out.write(buffer0);
...
out.write(buffer1);
...
out.write(buffer2);
...
s.close();

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值