新版卖家中心 Bigpipe 实践(二)

dddd

自从上次通过 新版卖家中心 Bigpipe 实践(一) 阐述了 Bigpipe 实现思路和原理之后,一转眼春天就来了。而整个实践过程,从开始冬天迎着冷风前行,到现在逐渐回暖。其中感受和收获良多,和大家分享下。代码偏多,请自带编译器。

核心问题

一切技术的产生或者使用都是为了解决问题,所以开始前,看下要解决的问题:

技术突破口

卖家中心主体也是功能模块化,和 Facebook 遇到的问题是一致的。核心的问题换个说法: 通过一个请求链接,服务端能否将动态内容分块传输到客户端实时渲染展示,直到内容传输结束,请求结束。

概念

实现

如何实现数据分块传输,各个语言的方式并不一样。

PHP 的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<html>
<head>
<title>php chunked</title>
</head>
<body>

<?php sleep(1); ?>
<div id="moduleA"><?php echo 'moduleA' ?></div>
<?php ob_flush(); flush(); ?>

<?php sleep(3); ?>
<div id="moduleB"><?php echo 'moduleB' ?></div>
<?php ob_flush(); flush(); ?>

<?php sleep(2); ?>
<div id="moduleC"><?php echo 'moduleC' ?></div>
<?php ob_flush(); flush(); ?>

</body>
</html>

Java 的方式

flush 的思考

Node.js 实现

通过对比 PHP 和 Java 在实现 Bigpipe 上的优势和劣势,很容易在 Node.js 上找到幸福感。

回到 HelloWorld

1
2
3
4
5
6
7
8
9
var http = require('http');

http.createServer(function (request, response){
response.writeHead(200, {'Content-Type': 'text/html'});
response.write('hello');
response.write(' world ');
response.write('~ ');
response.end();
}).listen(8080, "127.0.0.1");

完整点

layout.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<!-- css and js tags -->
<link rel="stylesheet" href="index.css" />
<script>
function renderFlushCon(selector, html) {
document.querySelector(selector).innerHTML = html;
}
</script>
</head>
<body>
<div id="A"></div>
<div id="B"></div>
<div id="C"></div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var http = require('http');
var fs = require('fs');

http.createServer(function(request, response) {
response.writeHead(200, { 'Content-Type': 'text/html' });

// flush layout and assets
var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();
response.write(layoutHtml);

// fetch data and render
response.write('<script>renderFlushCon("#A","moduleA");</script>');
response.write('<script>renderFlushCon("#C","moduleC");</script>');
response.write('<script>renderFlushCon("#B","moduleB");</script>');

// close body and html tags
response.write('</body></html>');
// finish the response
response.end();
}).listen(8080, "127.0.0.1");

页面输出:

1
2
3
moduleA
moduleB
moduleC

express 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var express = require('express');
var app = express();
var fs = require('fs');

app.get('/', function (req, res) {
// flush layout and assets
var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();
res.write(layoutHtml);

// fetch data and render
res.write('<script>renderFlushCon("#A","moduleA");</script>');
res.write('<script>renderFlushCon("#C","moduleC");</script>');
res.write('<script>renderFlushCon("#B","moduleB");</script>');

// close body and html tags
res.write('</body></html>');
// finish the response
res.end();
});

app.listen(3000);

页面输出:

1
2
3
moduleA
moduleB
moduleC

koa 实现

1
2
3
4
5
6
7
8
var koa = require('koa');
var app = koa();

app.use(function *() {
this.body = 'Hello world';
});

app.listen(3000);

流的意义

关于流,推荐看 @愈之的通通连起来 – 无处不在的流,感触良多,对流有了新的认识,于是接下来连连看。

1
2
3
4
5
6
7
8
9
10
var koa = require('koa');
var View = require('./view');
var app = module.exports = koa();

app.use(function* () {
this.type = 'html';
this.body = new View(this);
});

app.listen(3000);

view.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var Readable = require('stream').Readable;
var util = require('util');
var co = require('co');
var fs = require('fs');

module.exports = View

util.inherits(View, Readable);

function View(context) {
Readable.call(this, {});

// render the view on a different loop
co.call(this, this.render).catch(context.onerror);
}

View.prototype._read = function () {};

View.prototype.render = function* () {
// flush layout and assets
var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();
this.push(layoutHtml);

// fetch data and render
this.push('<script>renderFlushCon("#A","moduleA");</script>');
this.push('<script>renderFlushCon("#C","moduleC");</script>');
this.push('<script>renderFlushCon("#B","moduleB");</script>');

// close body and html tags
this.push('</body></html>');
// end the stream
this.push(null);
};

页面输出:

1
2
3
moduleA
moduleB
moduleC

并行的实现

目前我们已经完成了 koa 和 express 分块传输的实现,我们知道要输出的模块 A 、模块 B 、模块 C 需要并行在服务端生成内容。
在这个时候来回顾下传统的网页渲染方式,A / B / C 模块同步渲染:

新版卖家中心 Bigpipe 实践(二)

采用分块传输的模式,A / B / C 服务端顺序执行,A / B / C 分块传输到浏览器渲染:

新版卖家中心 Bigpipe 实践(二)

时间明显少了,然后把服务端的顺序执行换成并行执行的话:

新版卖家中心 Bigpipe 实践(二)

通过此图,并行的意义是显而易见的。为了寻找并行执行的方案,就不得不追溯异步编程的历史。(读史可以明智,可以知道当下有多不容易)

callback 的方式

async 的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
var Readable = require('stream').Readable;
var inherits = require('util').inherits;
var co = require('co');
var fs = require('fs');
var async = require('async');


inherits(View, Readable);

function View(context) {
Readable.call(this, {});

// render the view on a different loop
co.call(this, this.render).catch(context.onerror);
}

View.prototype._read = function () {};

View.prototype.render = function* () {
// flush layout and assets
var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();
this.push(layoutHtml);

var context = this;

async.parallel([
function(cb) {
setTimeout(function(){
context.push('<script>renderFlushCon("#A","moduleA");</script>');
cb();
}, 1000);
},
function(cb) {
context.push('<script>renderFlushCon("#C","moduleC");</script>');
cb();
},
function(cb) {
setTimeout(function(){
context.push('<script>renderFlushCon("#B","moduleB");</script>');
cb();
}, 2000);
}
], function (err, results) {
// close body and html tags
context.push('</body></html>');
// end the stream
context.push(null);
});

};

module.exports = View;

页面输出:

1
2
3
moduleC
moduleA
moduleB

每个 task 函数执行中,如果有出错,会直接最后的 callback。此时会中断,其他未执行完的任务也会停止,所以这个并行执行的方法处理异常的情况需要比较谨慎。

另外 async 里面有个 each 的方法也可以实现异步编程的并行执行:

1
each(arr, iterator(item, callback), callback(err))

稍微改造下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var options = [
{id:"A",html:"moduleA",delay:1000},
{id:"B",html:"moduleB",delay:0},
{id:"C",html:"moduleC",delay:2000}
];


async.forEach(options, function(item, callback) {
setTimeout(function(){
context.push('<script>renderFlushCon("#'+item.id+'","'+item.html+'");</script>');
callback();
}, item.delay);

}, function(err) {
// close body and html tags
context.push('</body></html>');
// end the stream
context.push(null);
});

我们会发现在使用 async 的时候,已经引入了 co ,co 也是异步编程的利器,看能否找到更简便的方法。

co

co 作为一个异步流程简化工具,能否利用强大的生成器特性实现我们的并行执行的目标。其实我们要的场景很简单:

多个任务函数并行执行,完成最后一个任务的时候可以进行通知执行后面的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
var Readable = require('stream').Readable;
var inherits = require('util').inherits;
var co = require('co');
var fs = require('fs');
// var async = require('async');

inherits(View, Readable);

function View(context) {
Readable.call(this, {});

// render the view on a different loop
co.call(this, this.render).catch(context.onerror);
}

View.prototype._read = function () {};

View.prototype.render = function* () {
// flush layout and assets
var layoutHtml = fs.readFileSync(__dirname + "/layout.html").toString();
this.push(layoutHtml);

var context = this;
var options = [
{id:"A",html:"moduleA",delay:100},
{id:"B",html:"moduleB",delay:0},
{id:"C",html:"moduleC",delay:2000}
];

var taskNum = options.length;
var exec = options.map(function(item){opt(item,function(){
taskNum --;
if(taskNum === 0) {
done();
}
})});

function opt(item,callback) {
setTimeout(function(){
context.push('<script>renderFlushCon("#'+item.id+'","'+item.html+'");</script>');
callback();
}, item.delay);
}

function done() {
context.push('</body></html>');
// end the stream
context.push(null);
}

co(function* () {
yield exec;
});
};

module.exports = View;

co 结合 promise

这个方法由@大果同学赞助提供,写起来优雅很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var options = [
{id:"A",html:"moduleAA",delay:100},
{id:"B",html:"moduleBB",delay:0},
{id:"C",html:"moduleCC",delay:2000}
];

var exec = options.map(function(item){ return opt(item); });

function opt(item) {
return new Promise(function (resolve, reject) {
setTimeout(function(){
context.push('<script>renderFlushCon("#'+item.id+'","'+item.html+'");</script>');
resolve(item);
}, item.delay);
});
}

function done() {
context.push('</body></html>');
// end the stream
context.push(null);
}

co(function* () {
yield exec;
}).then(function(){
done();
});

ES 7 async/wait

如果成为标准并开始引入,相信代码会更精简、可读性会更高,而且实现的思路会更清晰。

1
2
3
4
5
6
async function flush(Something) {  
await Promise.all[moduleA.flush(), moduleB.flush(),moduleC.flush()]
context.push('</body></html>');
// end the stream
context.push(null);
}

Midway

写到这里太阳已经下山了,如果在这里来个“预知后事如何,请听下回分解”,那么前面的内容就变成一本没有主角的小说。

Midway 是好东西,是前后端分离的产物。分离不代表不往来,而是更紧密和流畅。因为职责清晰,前后端有时候可以达到“你懂的,懂!”,然后一个需求就可以明确了。用 Node.js 代替 Webx MVC 中的 View 层,给前端实施 Bigpipe 带来无限的方便。

>
Midway 封装了 koa 的功能,屏蔽了一些复杂的元素,只暴露出最简单的 MVC 部分给前端使用,降低了很大一部分配置的成本。

一些信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function renderView(basePath, viewName, data) {
var me = this;
var filepath = path.join(basePath, viewName);
data = utils.assign({}, me.state, data);
return new Promise(function(resolve, reject) {
function callback(err, ret) {
if (err) {
return reject(err);
}
// 拼装后直接赋值this.body
me.body = ret;
resolve(ret);
}
render(filepath, data, callback);
});
}

MVC

Bigpipe 的位置

了解 Midway 这些信息,其实是为了弄清楚 Bigpipe 在 Midway 里面应该在哪里接入会比较合适:

建议在 Controller 中作为 Bigpipe 模块引入使用,取代原有 this.render 的方式进行内容分块输出

场景

什么样的场景比较适合 Bigpipe,结合我们现有的东西和开发模式。

封装

最后卖家中心的使用和 Bigpipe 的封装,我们围绕着前面核心实现的分块传输和并行执行,目前的封装是这样的:

由于 Midway this.render 除了拼装模板会直接 将内容赋值到 this.body,这种时候回直接中断请求,无法实现我们分块传输的目标。所以做了一个小扩展:

midway-render 引擎里面 添加只拼装模板不输出的方法 this.Html

1
2
// just output html no render;
app.context.Html = utils.partial(engine.renderViewText, config.path);

renderViewText

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function renderViewText(basePath, viewName, data) {
var me = this;
var filepath = path.join(basePath, viewName);
data = utils.assign({}, me.state, data);

return new Promise(function(resolve, reject) {
render(filepath, data, function(err, ret){
if (err) {
return reject(err);
}
//此次 去掉了 me.body=ret
resolve(ret);
});
});
}

View.js 模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
'use strict';
var util = require('util');
var async = require('async');
var Readable = require('stream').Readable;

var midway = require('midway');
var DataProxy = midway.getPlugin('dataproxy');

// 默认主体框架
var defaultLayout = '<!DOCTYPE html><html><head></head><body></body>';

exports.createView = function() {
function noop() {};

util.inherits(View, Readable);

function View(ctx, options) {
Readable.call(this);

ctx.type = 'text/html; charset=utf-8';
ctx.body = this;
ctx.options = options;
this.context = ctx;

this.layout = options.layout || defaultLayout;
this.pagelets = options.pagelets || [];
this.mod = options.mod || 'bigpipe';
this.endCB = options.endCB || noop;
}

/**
*
* @type {noop}
* @private
*/
View.prototype._read = noop;


/**
* flush 内容
*/
View.prototype.flush = function* () {
// flush layout
yield this.flushLayout();

// flush pagelets
yield this.flushPagelets();
};

/**
* flush主框架内容
*/
View.prototype.flushLayout = function* () {
this.push(this.layout);
}

/**
* flushpagelets的内容
*/
View.prototype.flushPagelets = function* () {
var self = this;
var pagelets = this.pagelets;

// 并行执行
async.each(pagelets, function(pagelet, callback) {
self.flushSinglePagelet(pagelet, callback);
}, function(err) {
self.flushEnd();
});
}


/**
* flush 单个pagelet
* @param pagelet
* @param callback
*/
View.prototype.flushSinglePagelet = function(pagelet, callback) {
var self = this,
context = this.context;

this.getDataByDataProxy(pagelet,function(data){
var data = pagelet.formateData(data, pagelet) || data;

context.Html(pagelet.tpl, data).then(function(html) {
var selector = '#' + pagelet.id;
var js = pagelet.js;

self.arrive(selector,html,js);

callback();
});
});
}

/**
* 获取后端数据
* @param pagelet
* @param callback
*/
View.prototype.getDataByDataProxy = function(pagelet, callback) {
var context = this.context;

if (pagelet.proxy) {
var proxy = DataProxy.create({
getData: pagelet.proxy
});

proxy.getData()
.withHeaders(context.request.headers)
.done(function(data) {
callback && callback(data);
})
.fail(function(err) {
console.error(err);
});
}else {
callback&&callback({});
}
}

/**
* 关闭html结束stream
*/
View.prototype.flushEnd = function() {
this.push('</html>');
this.push(null);
}



// Replace the contents of `selector` with `html`.
// Optionally execute the `js`.
View.prototype.arrive = function (selector, html, js) {
this.push(wrapScript(
'BigPipe(' +
JSON.stringify(selector) + ', ' +
JSON.stringify(html) +
(js ? ', ' + JSON.stringify(js) : '') + ')'
))
}



function wrapScript(js) {
var id = 'id_' + Math.random().toString(36).slice(2)

return '<script id="' + id + '">'
+ js
+ ';remove(/'#' + id + '/');</script>'
}

return View;
}

Controller 调用

1
2
3
4
5
6
7
8
var me = this;
var layoutHtml = yield this.Html('p/seller_admin_b/index', data);

yield new View(me, {
layout: layoutHtml, // 拼装好layout模板
pagelets: pageletsConfig,
mod: 'bigpie' // 预留模式选择
}).flush();
1
2
3
4
5
6
{
id: 'seller_info',//该pagelet的唯一id
proxy: 'Seller.Module.Data.seller_info', // 接口配置
tpl: 'sellerInfo.xtpl', //需要的模板
js: '' //需要执行的js
}

改进

思路和代码实现都基于现有的场景和技术背景,目前只有实现的思路和方案尝试,还没形成统一的解决方案,需要更多的场景来支持。目前有些点还可以改进的:

参考链接