前回、新バージョンの Seller Center Bigpipe 実践編 (1) を通じて Bigpipe 実装のアイデアと原則を説明して以来、あっという間に春がやって来ました。そして、冬の冷たい風と向き合い始めてから、徐々に暖かくなってきた今までの練習の全過程。私はそこから多くのことを得たので、それを皆さんと共有したいと思います。コードが多いのでコンパイラはご自身でご用意ください。
すべてのテクノロジーは問題を解決するために作成または使用されます。そのため、開始する前に、解決すべき問題を見てください:
セラーセンターの本体も機能的にモジュール化されており、これは Facebook が直面する問題と一致しています。別の言い方をすると、中心的な質問は、リクエスト リンクを通じて、サーバーが動的コンテンツをチャンクに分けてクライアントに送信し、コンテンツの送信が完了してリクエストが完了するまで、リアルタイムのレンダリングと表示を行うことができるかということです。
ブロック単位でのデータ送信を実装する方法は、言語ごとに異なる方法があります。
<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>
Bigpipe の実装における PHP と Java の長所と短所を比較すると、Node.js で幸せを見つけるのは簡単です。
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");
<!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>
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");
ページ出力:
moduleAmoduleBmoduleC
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);
页面输出:
moduleAmoduleBmoduleC
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);
var Readable = require('stream').Readable;var util = require('util');var co = require('co');var fs = require('fs');module.exports = Viewutil.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);};
页面输出:
moduleAmoduleBmoduleC
目前我们已经完成了 koa 和 express 分块传输的实现,我们知道要输出的模块 A 、模块 B 、模块 C 需要并行在服务端生成内容。在这个时候来回顾下传统的网页渲染方式,A / B / C 模块同步渲染:
采用分块传输的模式,A / B / C 服务端顺序执行,A / B / C 分块传输到浏览器渲染:
时间明显少了,然后把服务端的顺序执行换成并行执行的话:
通过此图,并行的意义是显而易见的。为了寻找并行执行的方案,就不得不 追溯异步编程 的历史。(读史可以明智,可以知道当下有多不容易)
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;
页面输出:
moduleCmoduleAmoduleB
每个 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 作为一个异步流程简化工具,能否利用强大的生成器特性实现我们的并行执行的目标。其实我们要的场景很简单:
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;
这个方法由@大果同学赞助提供,写起来优雅很多。
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();});
如果成为标准并开始引入,相信代码会更精简、可读性会更高,而且实现的思路会更清晰。
async function flush(Something) { await Promise.all[moduleA.flush(), moduleB.flush(),moduleC.flush()] context.push('</body></html>'); // end the stream context.push(null);}
写到这里太阳已经下山了,如果在这里来个“预知后事如何,请听下回分解”,那么前面的内容就变成一本没有主角的小说。
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); });}
了解 Midway 这些信息,其实是为了弄清楚 Bigpipe 在 Midway 里面应该在哪里接入会比较合适:
什么样的场景比较适合 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);
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); }); });}
'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;}
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}