网络序?本地序?傻傻分不清楚

作者:link

这个问题源于最近做的一个项目,需要用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();
    });
}

终于写完了,现在各位前端的小伙伴们应该搞清楚网络序和本地序了吧?

原文链接:http://ivweb.io/topic/57fe263b2a25000c315a3d8a

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Kubernetes

Kubernetes 1.8抢占式调

Author: xidianwangtao@gmail.com 阅读本博文前,建议先阅读解析Kubernetes 1.8中的基于Pod优先级的抢占式调度...

41113
来自专栏Adamshuang 技术文章

Guava Cache -- Java 应用缓存神器

Guava 作为Google开源Java 库中的精品成员,在性能、功能上都十分出色,本文将从实际使用的角度,来对Guava进行讲解。

5867
来自专栏菩提树下的杨过

JeffreyZhao]正确使用异步操作

本想写一点有关LINQ to SQL异步调用的话题,但是在这之前我想还是先写一篇文章来阐述一下使用异步操作的一些原则,避免有些朋友误用导致程序性能反而降低。这篇...

18810
来自专栏数据和云

Oracle数据库12c release 2优化器详解

序言:优化器是Oracle数据库最引人入胜的部件之一,因为它对每一个SQL语句的处理都必不可少。优化器为每个SQL语句确定最有效的执行计划,这是基于给定的查询的...

3286
来自专栏HappenLee的技术杂谈

数据模型与查询语言 ------《Designing Data-Intensive Applications》读书笔记2

作为一个开发者来说,在一个复杂的应用程序中,是存在很多分层模型的,但基本思想还是一样的:每一层都提供了一个干净的数据模型,从而隐藏了底层的复杂性。通过这样的抽象...

682
来自专栏斑斓

响应式编程的实践

作者 | 张逸 特别说明:本文包含大量代码片段,若要获得更好阅读观感,请点击文末“阅读原文”或访问我的博客。 响应式编程在前端开发以及Android开发中有颇多...

3428
来自专栏GreenLeaves

EF基础知识小记一

1、EF等ORM解决方案出现的原因 因为软件开发中分析和解决问题的方法已经接近成熟,然后关系型数据库却没有,很多年来,数据依然是保存在表行列这样的模式里,所以,...

1679
来自专栏皮振伟的专栏

[linux][memory] 物理内存管理

前言: 书接上回《内存映射技术分析》,继续来分析一下linux的物理内存管理。 分析: 1,物理内存 PC上的内存条,或者手机上的内存芯片,物理上实实在在的...

4147
来自专栏服务端思维

异步「背压机制」,谈 RxJava 2.x 解决策略

在异步场景下,被观察者(生产者)发射事件(数据)的速度过快,导致观察者(消费者)处理事件(数据)不及时,从而造成 Buffer 溢出。对于这种现象,我们称之为「...

903
来自专栏Java3y

操作系统第六篇【存储器管理】

2037

扫码关注云+社区