网络序?本地序?傻傻分不清楚。。。
这个问题源于最近做的一个项目,需要用Node.js进行socket网络编程,涉及到使用TCP/UDP通过自定义的二进制数据序列化协议与android/iOS客户端进行通信。
当协商通信协议时,对接的客户端同学告诉我在发送数据的时候要将要发送的Buffer从本地序转换为网络序,当收到客户端的回包时,需要将收到的Buffer从网络序转换为本地序。
作为一个前端工程师,听到上面那段话,我脑海中的画面是:
网络序?本地序?傻傻分不清楚啊!
于是我决定翻开下面这本书,来一探究竟:
什么是网络序和本地序?
所谓的网络序和本地序其实就是一个跨越多个字节的程序对象(在Node.js中可以简单的认为是一个长度大于1的Buffer对象)在存储器中的存储顺序,在了解这两种字节顺序之前,我们来复习一下计算机的寻址规则。
寻址
在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址,毕竟位(bit)所能表示的信息太有限了(0 or 1)。例如,假设一个类型为int的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100。那么,x的4个字节将被存储在存储器的0x100、0x101、0x102和0x103的位置。
字节顺序规则
在存储器中如何排列一个跨越多个字节的程序对象,一般来说有两个通用的规则。
考虑一个w位(bit)的整数,位表示为[xw-1, xw-2, … ,x1, x0],其中xw-1是最高有效位,而x0是最低有效位。假设w是8的倍数,这些位就能被分组成为字节,其中最高有效字节包含位[xw-1, xw-2, … ,xw-8],而最低有效字节包含位[x7, x6, … ,xw-8],其他字节包含中间的位。某些机器选择在存储器中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效字节的顺序存储。前一种规则——最低有效字节在最前面的方式,称为小端法(little endian)。大多数Inter兼容机都采用这种规则。后一种规则——最高有效位在最前面的方式,称为大端法(big endian)。大多数IBM和Sun Microsystems的机器都采用这种规则。(注: IBM和Sun制造的个人计算机使用的是Inter兼容的处理器,这些机器采用的是小端法)
许多比较新的微处理器使用双端法(bi-endian),也就是说可以把它们配置成作为大端或者小端的机器运行。大家先记住小端法(little endian)和大端法(big endian)这两个名词,这对于我们理解网络序和本地序灰常重要。
继续我们前面的示例,假设变量x类型为int,占四个字节,位于地址0x100,它的十六进制值为0x1234567。地址的范围为0x100~0x103的字节,其排列顺序依赖与机器的类型。
大端法:
字节存储地址 | 0x100 | 0x100 | 0x100 | 0x100 | ||
---|---|---|---|---|---|---|
字节内容 | … | 01 | 23 | 45 | 67 | … |
小端法:
字节存储地址 | 0x100 | 0x100 | 0x100 | 0x100 | ||
---|---|---|---|---|---|---|
字节内容 | … | 67 | 45 | 23 | 01 | … |
注:在字0x1234567中,高位字节的十六进制值为0x01,而低位字节值为0x67。
对于大多数应用程序员来说,他们机器所使用的字节顺序是完全不可见的,无论为哪种类型的机器编译的程序都会得到同样的结果。不过以下三种情况,字节顺序会成为问题:
1. 在不同类型的机器之间通过网络传送二进制数据时。一个常见的问题是当小端法机器产生的数据被发送到大端法机器或者反方向发送时会发现,接收的数据里的字节成了反序的。聪明的读者看到这里可能已经知道这篇文章的标题所要解决的问题了,后面我们会重点阐述。
2. 当阅读表示整数数据的字节序列时,字节顺序也很重要。当在小端法机器上查看十六进制字节串时,机器显示的字节顺序与我们通常书写数字时的字节顺序正好相反。比如:0x64940408(自然书写的方式,数字最高有效位在最左边,最低有效位在最右边)在小端法机器中会显示成0x8049464(数字最低有效位在最左边,最高有效位在最右边)。
3. 当编写规避正常的类型系统的程序时。简单的说就是在C语言中可以用一种数据类型来引用任意类型的对象,强烈不推荐这种编程技巧。
看到这里,你可能会想,既然不同的字节顺序会带来这么多问题,为啥还要定义两种字节顺序呢?这不是闲得蛋疼吗?
你答对了!就是因为闲得蛋疼!
事实上,在哪种字节顺序是合适的这个问题上,人们表现的非常情绪化。而术语“little endian”(小端)和“big endian”(大端)出自《格列佛游记》一书,书中交战的两个派别无法就应该从哪一端(小端还是大端)打开一个半熟的鸡蛋达成一致。就像鸡蛋的问题一样,没有技术上的原因来选择字节顺序规则,因此,争论沦为关于社会政治问题的争论。
“端”(endian)的起源:
以下是《格列佛游记》一书在1726年关于大小端之争历史的描述:
“······我下面要告诉你的是,Lilliput 和Blefuscu 这两大强国在过去36 个月里一直在苦战。 战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端, 可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父 亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。 老百姓们对这项命令极为反感。历史告诉我们,由此曾发生过6次*,其中一个皇帝送了命, 另一个丢了王位。这些*大多都是由Blefuscu 的国王大臣们煽动起来的。*平息后,流亡 的人总是逃到那个帝国去寻救避难。据估计,先后几次有11000人情愿受死也不肯去打破鸡蛋较小的一端。关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派的任何人不得做官。“(此段译文摘自网上蒋剑锋译的《格列佛游记》第一卷第4章。)
在那个时代,《格列佛游记》是在讽刺英国(Lilliput)和法国(Blefuscu)之间持续的冲突。Danny Cohen,一位网络协议的早期开创者,第一次使用这两个术语来指代字节顺序,后来这个术语被广泛接纳了。”
网络序还是本地序?
扯了这么多没用的,终于要说说本文的重点了,什么是网络序?什么是本地序?什么时候该用哪种类型的字节顺序?
因为在互联网上运行的千千万万的计算机可以有不同的字节顺序,TCP/IP为任意整数数据项定义了统一的网络字节顺序(network byte order):大端字节顺序!例如IP地址:它放在包头中跨越网络被携带。在IP地址结构中存放的地址总是以(大端法)网络字节顺序存放的,即使主机字节顺序(host byte order)是小端法。Unix提供了下面这样的函数在网络和主机字节顺序间实现转换:
#include <netinet/in.h>
// 返回:按照网络字节顺序的值。
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
// 返回:按照主机字节顺序的值。
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);
htonl函数将32位整数由主机字节顺序转换为网络字节顺序。ntohl函数将32位整数从网络字节顺序转换为主机字节。htons和ntohs函数为16位的整数执行响应的转换。看起来这两个函数屏蔽了不同的本机字节顺序。
结论:
- 网络序就是大端法字节顺序。
- 本地序依据机器类型,可能是大端法字节顺序或者小端法字节顺序。
Node.js里怎么玩?
作为为web而生的Node.js当然提供了网络序(大端法)和本地序(大端 or 小端)互相转换。
Buffer | Node.js v6.7.0 Documentation
读字节流
写字节流
举个栗子
现在跟客户端的同学已经协商好了二进制的数据序列化协议如下:
Node.js发给客户端的包体协议:
按字段的前后顺序拼装数据包:
用户id(4个字节,不能为空)+用户类型(1个字节,可以为空)+消息序列号(4个字节,可以为空)+消息命令字(1个字节,不能为空)+消息体(给客户端的文案,1个字节buffer长度+utf-8编码的buffer)
客户端回包给Node.js的包体协议:
按字段的前后顺序拼装数据包:
返回码(2个字节,不能为空)
组包:
const BUFFER_OFFSET = 0;
const USERID_LENGTH = 4;
const USER_TYPE_LENGTH = 1;
const MSG_SEQ_LENGTH = 4;
const MSG_CMD_LENGTH = 1;
// 因为组好的包是要通过网络发送给客户端,所以要把数据以大端法写到数据包中。
function encodePkg(params) {
let userId = Buffer.alloc(USERID_LENGTH);
userId.writeUInt32BE((params.userId || 0), BUFFER_OFFSET );
let userType = Buffer.alloc(USER_TYPE_LENGTH);
userType.writeUIntBE((params.userType || 0), BUFFER_OFFSET , USER_TYPE_LENGTH);
let msgSeq = Buffer.alloc(MSG_SEQ_LENGTH);
msgSeq.writeUInt32BE((params.msgSeq || 0), BUFFER_OFFSET);
let msgCmd = Buffer.alloc(MSG_CMD_LENGTH);
msgCmd.writeUIntBE((params.msgCmd || 0), BUFFER_OFFSET , MSG_CMD_LENGTH);
let msgBuf = Buffer.from(params.msg, 'utf8');
let msgBufLength = Buffer.alloc(1, msgBuf.length);
msgBuf = Buffer.concat([msgBufLength , msgBuf]);
// 拼接各个字段的buffer
return Buffer.concat([userId, userType, msgSeq, msgCmd, msgBuf]);
}
拆包
const RETCODE_OFFSET = 2;
const USERID_LENGTH = 4;
const USER_TYPE_LENGTH = 1;
const MSG_SEQ_LENGTH = 4;
const MSG_CMD_LENGTH = 1;
// 因为组好的包是客户端通过网络发送回来的,所以要把数据以大端法读到本地。
function decodePkg(responseBuf) {
let result = {};
let retcodeBuf = responseBuf.slice(0,RETCODE_OFFSET);
let retcode = retcodeBuf.readUInt16BE(0);
result.retcode = retcode;
// 拼接各个字段的buffer
return result;
}
发包、收包,以UDP协议通信
const dgram = require('dgram');
const timeout = 2000; // UDP回包超时时间 单位:毫秒
function udpSvr(params) {
let socket = dgram.createSocket({
type: 'udp4'
});
let udpTimeoutWatcher = setTimeout(function() {
console.log('udp response timeout!!!')
socket.close();
}, timeout);
// 根据参数组包
let message = encodePkg(params.sendData);
let result = null;
// 发包
socket.send(message, 0, message.length, params.port, params.address, function(err) {
console.log('client send done!');
});
// 监听客户端的回包
socket.on('message', function(msg, info) {
clearTimeout(udpTimeoutWatcher);
// 根据协议解客户端的回包
result = decodePkg(info);
});
socket.on('close', function() {
console.log('socket closed.');
});
socket.on('error', function(err) {
console.log('socket err');
console.log(err);
socket.close();
});
}
终于写完了,现在各位前端的小伙伴们应该搞清楚网络序和本地序了吧?