β

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

Taobao FED | 淘宝前端团队 90 阅读

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

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

核心问题

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

技术突破口

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

概念

实现

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

PHP 的方式

<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

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

<!DOCTYPE html>
<html>
<head>
<!-- css and js tags -->
<link rel="stylesheet" href="http://taobaofed.org/blog/2016/03/25/seller-bigpipe-coding/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>
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");

页面输出:

moduleA
moduleB
moduleC

express 实现

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);

页面输出:

moduleA
moduleB
moduleC

koa 实现

var koa = require('koa');
var app = koa();

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

app.listen(3000);

流的意义

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

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

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);
};

页面输出:

moduleA
moduleB
moduleC

并行的实现

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

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

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

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

callback 的方式

async 的方式

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;

页面输出:

moduleC
moduleA
moduleB

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

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

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

稍微改造下:

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

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

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

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

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

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

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 部分给前端使用,降低了很大一部分配置的成本。

一些信息

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

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

renderViewText

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 模块

'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 调用

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();
{
id: 'seller_info',//该pagelet的唯一id
proxy: 'Seller.Module.Data.seller_info', // 接口配置
tpl: 'sellerInfo.xtpl', //需要的模板
js: '' //需要执行的js
}

改进

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

参考链接

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

自从上次通过

作者:Taobao FED | 淘宝前端团队
淘宝前端团队(FED)
原文地址:新版卖家中心 Bigpipe 实践(二), 感谢原作者分享。

发表评论