all articles

protocol buffers v.s. json on browser environment

2016-11-21 @sunderls

protocolBuffers json

作为前端工程师,和后端工程师之间的合作最重要的就是模版的修改和API的沟通了吧。API的设计,MOCK,更新先不说,现就说API的格式的话,大家想到的必然是JSON了我觉得。作为一个非科班出身的程序员,在学习js的时候就接触的jQuery,对于网络请求什么的还不太明白的时候,就开始用json了,对于xml的使用并没有太多的经验。嗯,JSON的好处有很多,易于阅读,JS原声支持,浏览器支持好等等,除了有一点--废话太多,JSON字符串的属性名需要有引号,括号,冒号等,属性名如果很长,会导致真正API的有价值的内容比例降低。

怎么优化呢? 我们先写一个API的例子,比如,我们要获取一个用户列表,返回值大概是:

{
"total": 1000,
"items": [
    {
        "name": "sunder liu",
        "height": 172,
        "nationality": "China",
        "isSingle": false,
        "isLivingInOriginNation": false,
        "codingLevel": "perfect",
        "favoriteCodingLanguage": "js",
        "dateOfBirth": 1479543158011
    },

    // ... repeat
    ]
}

网络环境好的情况下,并不会出现大问题,上面一个unit大概300字符,1000个的话也就300KB,按照500KB/s的网速,只算传输时间的话,也就0.6s,内容再复杂一倍也就 1s。显示环境中返回1000个单位的API也是比较少的吧,普通100个话,也就0.1s的样子,也能接受。不过程不怕事儿多,能有什么办法优化呢? 大概一想能有一些方向

  • 1.gzip: gzip肯定是会用的,根据内容重复程度,能减少很大部分的量
  • 2.属性名太长: 能否压缩为"a":"sunder liu", "b":190之类的
  • 3.括号&冒号太冗余: 能否去掉
  • 4.数字部分可以用二进制表示

gzip部分,浏览器会进行支持,没问题。其他的都需要前后端都要进行修改,其中3和4改变了JSON格式,js代码中需要单独再进行parse,不知道会不会有性能影响。

说了这么多,实际上是想介绍一下google protocol buffers这个解决方案,本文通过用一个实际的本地服务器对这个方案进行说明,然后大家看看这个格式合不合适。

本文中的代码可以在github上查看: https://github.com/sunderls/sample-protobuf

1. JSON支持

首先我们搭建一个local server,来测试json,路由如下

path response
/ index.html, 这里前端js会调用本地API
/api/json 返回json api
/api/json_gzip 返回gzip过后的json api
/api/protobuf protobuf api
/api/protobuf_gzip gzip + protobuf

其他的后面再说,/index.html文件中的js脚本需要记录一下api请求的时间。

1.1 JSON 路由

这个没什么难的,代码如下:

index.js

const http = require('http');
const fs = require('fs');
const res = require('./data/json.js');

server = http.createServer((request, response) => {
    let path = request.url.split('?')[0];

    if (path === '/api/json' ) {
        let result = JSON.stringify(res);
        response.writeHead(200, {
            'Content-Type': 'application/json',
            'Content-Length': result.length
        });

        response.end(result);

    } else {
        let file = /js$/.test(path) ? '.' + path : './index.html';
        response.end(fs.readFileSync(file));
    }
}).listen(9000);

默认返回html,js文件的话返回js文件内容,请求/api/json时返回/data/json.js的内容,长度写在header中。

1.2 模拟数据

data/json.js

let item = {
    "name": "sunder liu",
    "height": 172,
    "nationality": "China",
    "isSingle": false,
    "isLivingInOriginNation": false,
    "codingLevel": "perfect",
    "favoriteCodingLanguage": "js",
    "dateOfBirth": 1479543158011
};

let items = [];

let i = 0;
while( i++ < 10000){
    items.push(Object.assign({}, item));
}

module.exports = {
    "total": 10000,
    "items": items
}

如果重复1000次的话,单个item的字符数在200左右,1000也就是 200KB,为了测试,增大10倍。

1.3 html

index.html

<!DOCTYPE html>
<html>
<head>
    <title>test protobuf</title>
</head>
<body>
<h1>test protobuf</h1>
<script src="./client.js"></script>
</body>
</html>

1.4 测试脚本

client.js


/**
 * log a line to <body>
 * @param {string} line - text
 * @param {string} tag - tag
 */
const log = (line, tag = 'p') => {
    let el = document.createElement(tag);
    el.textContent = line;
    document.body.appendChild(el);
}

/**
 * get json data
 * @param {string} api - api path
 */
const getJSon = (api) => {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", api, true);
    xhr.responseType = "json"

    log('getJSon', 'h2');
    let start = Date.now();
    xhr.onreadystatechange = () => {
        if (xhr.readyState === XMLHttpRequest.DONE) {
            log(Math.round(xhr.getResponseHeader("Content-Length") * 1 / 1024) + 'KB');
            log(`load done at:${Date.now() - start}ms`);
        }
    }
    xhr.send(null);
}

onload = () => {
    getJSon('/api/json');
}

getJson请求了/api/json,然后计算了api response的大小和所耗时间。注意的是,因为设置了responeType,所以在log的时候,response已经转换为了json object, xhr.response现在就是/data/json.js中数据。

2. json + gzip

index.js增加 gzip的处理

const zlib = require('zlib');
let res_gzip = null;
zlib.gzip(new Buffer(JSON.stringify(res), 'utf-8'),  (_, result) => {
    res_gzip = result;
});

//...

} else if (path === '/api/json_gzip' ) {
    response.writeHead(200, {
        'Content-Type': 'application/json',
        'Content-Length': res_gzip.length,
        'Content-Encoding': 'gzip'
    });

    response.end(res_gzip);

}

client.js中把api请求写成promise方便chain。

/**
 * get json data
 * @param {string} api - api path
 */
const getJSon = (api) => {
    return new Promise( (resolve, reject) => {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", api, true);
        xhr.responseType = "json"

        log(`getJSon: ${api}`, 'h2');
        let start = Date.now();
        xhr.onreadystatechange = () => {
            if (xhr.readyState === XMLHttpRequest.DONE) {
                log(Math.round(xhr.getResponseHeader("Content-Length") * 1 / 1024) + 'KB');
                log(`load done at:${Date.now() - start}ms`);
                resolve(xhr.response);
            }
        }
        xhr.send(null);
    })
}

onload = () => {
    getJSon('/api/json').then(data => {
        return getJSon('/api/json_gzip');
    });
}

可以看到response大小减小了99.5%,时间缩短了75%,嘛,这个因为APi都是重复数据了。重新调整data.js如下:

let item = {
    "name": "sunder liu",
    "height": 172,
    "nationality": "China",
    "isSingle": false,
    "isLivingInOriginNation": false,
    "codingLevel": "perfect",
    "favoriteCodingLanguage": "js",
    "dateOfBirth": 1479543158011
};

let items = [];

let i = 0;
while( i++ < 10000){
    let d = Object.assign({}, item);
    d.name += i;
    d.height += i;
    d.nationality += i;
    d.codingLevel += i;
    items.push(d);
}

module.exports = {
    "total": 10000,
    "items": items
}

这样的到的结果如下:gzip减少了95%的数据大小,75%的时间,没有太大变化。嗯。

3. protocol buffers

protocol buffers的详细信息可以参考这里 https://developers.google.com/protocol-buffers/ 。 简言之就是,protobuf把数据格式抽象为schema,然后api中只传输数据,接受数据方通过预先知晓的schema对数据进行解析获取想要的结果。

3.1 protobuf.js

$> npm install --save dcodeIO/protobuf.js

3.2 定义proto

/data/data.proto

package colla;
syntax = "proto3";

message Users {
    int32 total = 1;
    message User {
        string name = 1;
        int32 height = 2;
        string nationality = 3;
        bool isSingle = 4;
        bool isLivingInOriginNation = 5;
        string codingLevel = 6;
        string favoriteCodingLanguage = 7;
        int64 dateOfBirth = 8;
    }
    repeated User items = 2;
}

response中,定义了Users由User List组成,User的具体属性类型和顺序。

3.3 server返回二进制data

有了上述定义,就可以用protobuf.js将数据转换为二进制数据了,操作如下:

index.js

const protobuf = require("protobufjs");

// protobuf
let protoBuffer = null;
protobuf.load("./data/data.proto", (err, root) => {
    // Obtain a message type
    var Users = root.lookup("colla.Users");
    var message = Users.create(res);

    // Encode a message
    protoBuffer = Users.encode(message).finish();
});

...

} else if (path === '/api/protobuf' ) {
    response.writeHead(200, {
        'Content-Length': protoBuffer.length,
    });
    response.end(protoBuffer, 'utf-8');
}

...

api请求 /api/protobuf的时候,就会返回二进制内容

3.4 build protobuf.js

npm install过后的protobuf.js并不包含浏览器端使用的build好的文件,则需要自己去build或者从github中自己download,也不知道为啥,这里就自己倒/node_modules/protobufjs中build好了,

html中添加如下代码:

<script src="/node_modules/protobufjs/dist/protobuf.js"></script>
<script src="./client.js"></script>

3.5 修改client.js

client.js中首先也要加载/data/data.proto,然后解析api中获得的数据

/**
 * load data.proto
 */
const loadProtoSchema = () => {
    return new Promise( ( resolve, reject) => {
        protobuf.load('/data/data.proto', (err, root) => {
            Users = root.lookup("colla.Users");
            resolve(Users);
        });
    });
}

/**
 * get json data
 * @param {string} api - api path
 * @param {object} msg - proto message to decode
 */
const getProtobuf = (api, msg) => {
    return new Promise( (resolve, reject) => {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", api, true);
        xhr.responseType = "arraybuffer";

        log(`getProtobuf:${api}`, 'h2');
        let start = Date.now();
        xhr.onload = () => {
            log(Math.round(xhr.getResponseHeader("Content-Length") * 1 / 1024) + 'KB');
            log(`load done at:${Date.now() - start}ms`);
            let resultJson = msg.decode(new protobuf.Reader(new Uint8Array(xhr.response)));
            log(`load & decode done at:${Date.now() - start}ms`);
            resolve();
        }
        xhr.send(null);
    })
}

4. protocol buffers + gzip

把protocol buffers的内容进行gzip的话又能减少resposne容量。

// protobuf
let protoBuffer = null;
let protoBuffer_gzip = null;
let Users = null;
protobuf.load("./data/data.proto", (err, root) => {
    // Obtain a message type
    Users = root.lookup("colla.Users");
    var message = Users.create(res);
    // Encode a message
    protoBuffer = Users.encode(message).finish();

    zlib.gzip(protoBuffer,  (_, result) => {
        protoBuffer_gzip = result;
    });
});

...

response.end(protoBuffer, 'utf-8');
} else if (path === '/api/protobuf_gzip' ) {
    response.writeHead(200, {
        'Content-Length': protoBuffer_gzip.length,
        'Content-Encoding': 'gzip'
    });
    response.end(protoBuffer_gzip, 'utf-8');
}
...

5. 测试

onload = () => {
    // getJSon('/api/json');
    getJSon('/api/json_gzip');
    // loadProtoSchema().then(msg => {
    //     return getProtobuf('/api/protobuf', msg)
    // });
    // loadProtoSchema().then(msg => {
    //     return getProtobuf('/api/protobuf_gzip', msg)
    // });
}

为了互不影响,上述onload中的方法调用分别注释后,在chrome的隐身窗口测试。分别修改/data/json.js中的循环次数后得到如下结果:

可以看到protocol buffers可以显著的压缩数据量,但是成效并不明显,同时需要浏览器端的js对二进制数据进行处理,总时间上反而不如JSON。所以如果单纯从性能上考虑的话,还是使用json比较好,因为浏览器原生支持。 对于原生APP或者服务器等环境而言,二进制数据处理能力更强,性能上的提升加上api定义规范化上的帮助,protocol buffers也许值得尝试。 类似的项目还有Thrift之类的。这里就不再说了(因为我也不太懂 w)

附github 代码: https://github.com/sunderls/sample-protobuf