您好,欢迎访问一九零五行业门户网

新版卖家中心 Bigpipe 实践(二)_html/css_WEB-ITnose

自从上次通过 新版卖家中心 bigpipe 实践(一) 阐述了 bigpipe 实现思路和原理之后,一转眼春天就来了。而整个实践过程,从开始冬天迎着冷风前行,到现在逐渐回暖。其中感受和收获良多,和大家分享下。代码偏多,请自带编译器。
核心问题 一切技术的产生或者使用都是为了解决问题,所以开始前,看下要解决的问题:
同步加载首屏模块,服务端各个模块并行生成内容,客户端渲染内容依赖于最后一个内容的生成时间。这里的痛点是 同步 。因为要多模块同步,所以难免浏览器要等待,浏览器等待也就是用户等待。 于是我们采用了滚动异步加载模块,页面框架优先直出,几朵菊花旋转点缀,然后首屏模块通过异步请求逐个展现出来。虽然拿到什么就能在客户端渲染显示,但还是有延迟感。这里的痛点是 请求 ,每个模块都需要多一个请求,也需要时间。 facebook 的工程师们会不会是这样想的:一次请求,各个首屏模块服务端并行处理生成内容,生成的内容能直接传输给客户端渲染,用户能马上看到内容,这样好猴赛雷~ 其实 bigpipe 的思路是从 微处理器的流水线 中受到启发 技术突破口 卖家中心主体也是功能模块化,和 facebook 遇到的问题是一致的。核心的问题换个说法: 通过一个请求链接,服务端能否将动态内容分块传输到客户端实时渲染展示,直到内容传输结束,请求结束。
概念 技术点:http 协议的分块传输(在 http 1.1 提供) 概念入口 如果一个 http 消息(请求消息或应答消息)的 transfer-encoding 消息头的值为 chunked ,那么,消息体由数量未定的块组成,并以最后一个大小为 0 的块为结束。 这种机制使得网页内容分成多个内容块,服务器和浏览器建立管道并管理他们在不同阶段的运行。 实现 如何实现数据分块传输,各个语言的方式并不一样。
php 的方式
php chunked

php 利用 ob_flush 和 flush 把页面分块刷新缓存到浏览器,查看 network ,页面的 transfer-encoding=chunked ,实现内容的分块渲染。 php 不支持线程,所以服务器无法利用多线程去并行处理多个模块的内容。 php 也有并发执行的方案,这里不做扩展,有兴趣地可以去深入研究下。 java 的方式 java 也有类似于 flush 的函数 实现简单页面的分块传输。 java 是多线程的,方便并行地处理各个模块的内容。 flush 的思考 yahoo 34 条性能优化 rules 里面提到 flush 时机是 head 之后,可以让浏览器先行下载 head 中引入的 css/js。 我们会把内容分成一块一块 flush 到浏览器端,flush 的内容优先级应该是用户关心的。比如 yahoo 之前优先 flush 的就是搜索框,因为这个是核心功能。 flush 的内容大小需要进行有效地拆分,大内容可以拆成小内容。 node.js 实现 通过对比 php 和 java 在实现 bigpipe 上的优势和劣势,很容易在 node.js 上找到幸福感。
node.js 的异步特性可以很容易地处理并行的问题。 view 层全面控制,对于需要服务端处理数据和客户端渲染有天然的优势。 node.js 中的 http 接口的设计支持许多 http 协议中原本用起来很困难的特性。 回到 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);
http 头 transfer-encoding=chunked ,我的天啊,太神奇了! 如果只是 response.write 数据,没有指示 response.end ,那么这个响应就没有结束,浏览器会保持这个请求。在没有调用 response.end 之前,我们完全可以通过 response.write 来 flush 内容。 把 bigpipe node.js 实现是从 helloworld 开始,心情有点小激动。 完整点 layout.html

head 里面放我们要加载的 assets 输出页面框架,a/b/c 模块的占位 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(''); response.write(''); response.write(''); // close body and html tags response.write(''); // finish the response response.end();}).listen(8080, 127.0.0.1);
页面输出:
moduleamodulebmodulec


flush layout 的内容 包含浏览器渲染的函数 然后进入核心的取数据、模板拼装,将可执行的内容 flush 到浏览器 浏览器进行渲染(此处还未引入并行处理) 关闭 body 和 html 标签 结束响应 完成一个请求 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(''); res.write(''); res.write(''); // close body and html tags res.write(''); // finish the response res.end();});app.listen(3000);
页面输出:
moduleamodulebmodulec


express 建立在 node.js 内置的 http 模块上,实现的方式差不多 koa 实现 var koa = require('koa');var app = koa();app.use(function *() { this.body = 'hello world';});app.listen(3000);
koa 不支持 直接调用底层 res 进行响应处理。 res.write()/res.end() 就是个雷区,有幸踩过。 koa 中,this 这个上下文对 node.js 的 request 和 response 对象的封装。this.body 是 response 对象的一个属性。 感觉 koa 的世界就剩下了 generator 和 this.body ,怎么办?继续看文档~ this.body 可以设置为字符串, buffer 、stream 、 对象 、 或者 null 也行。 stream stream stream 说三遍可以变得很重要。 流的意义 关于流,推荐看 @愈之的 通通连起来 – 无处不在的流 ,感触良多,对流有了新的认识,于是接下来连连看。
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 = 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(''); this.push(''); this.push(''); // close body and html tags this.push(''); // end the stream this.push(null);};
页面输出:
moduleamodulebmodulec


transfer-encoding:chunked 服务端和浏览器端建立管道,通过 this.push 将内容从服务端传输到浏览器端 并行的实现 目前我们已经完成了 koa 和 express 分块传输的实现,我们知道要输出的模块 a 、模块 b 、模块 c 需要并行在服务端生成内容。在这个时候来回顾下传统的网页渲染方式,a / b / c 模块同步渲染:
采用分块传输的模式,a / b / c 服务端顺序执行,a / b / c 分块传输到浏览器渲染:
时间明显少了,然后把服务端的顺序执行换成并行执行的话:
通过此图,并行的意义是显而易见的。为了寻找并行执行的方案,就不得不 追溯异步编程 的历史。(读史可以明智,可以知道当下有多不容易)
callback 的方式 首先 过多 callback 嵌套 实现异步编程是地狱 第二 选择绕过地狱,选择成熟的模块来取代 async 的方式 async 算是异步编码流程控制中的元老。 parallel(tasks, [callback]) 并行执行多个函数,每个函数都是立即执行,不需要等待其它函数先执行。传给最终 callback 的数组中的数据按照 tasks 中声明的顺序,而不是执行完成的顺序。 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(''); cb(); }, 1000); }, function(cb) { context.push(''); cb(); }, function(cb) { settimeout(function(){ context.push(''); cb(); }, 2000); } ], function (err, results) { // close body and html tags context.push(''); // end the stream context.push(null); }); };module.exports = view;
页面输出:
modulecmoduleamoduleb
模块显示的顺序是 c>a>b ,这个结果也说明了 node.js io 不阻塞 优先 flush layout 的内容 利用 async.parallel 并行处理 a 、b 、c ,通过 cb() 回调来表示该任务执行完成 任务执行完成后 执行结束回调,此时关闭 body/html 标签 并结束 stream 每个 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(''); callback(); }, item.delay); }, function(err) { // close body and html tags context.push(''); // end the stream context.push(null);});
结果和 parallel 的方式是一致的,不同的是这种方式关注执行过程,而 parallel 更多的时候关注任务数据 我们会发现在使用 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(''); callback(); }, item.delay); } function done() { context.push(''); // end the stream context.push(null); } co(function* () { yield exec; }); };module.exports = view;
yield array 并行执行数组内的任务。 为了不使用 promise 在数量可预知的情况 ,加了个计数器来判断是否已经结束,纯 co 实现还有更好的方式? 到这个时候,才发现生成器的特性并不能应运自如,需要补一补。 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(''); resolve(item); }, item.delay); });}function done() { context.push(''); // 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(''); // end the stream context.push(null);}
此段代码未曾跑过验证,思路和代码摆在这里,es 7 跑起来 ^_^。 midway 写到这里太阳已经下山了,如果在这里来个“预知后事如何,请听下回分解”,那么前面的内容就变成一本没有主角的小说。
midway 是好东西,是前后端分离的产物。分离不代表不往来,而是更紧密和流畅。因为职责清晰,前后端有时候可以达到“你懂的,懂!”,然后一个需求就可以明确了。用 node.js 代替 webx mvc 中的 view 层,给前端实施 bigpipe 带来无限的方便。
>midway 封装了 koa 的功能,屏蔽了一些复杂的元素,只暴露出最简单的 mvc 部分给前端使用,降低了很大一部分配置的成本。
一些信息 midway 其实支持 express 框架和 koa 框架,目前主流应该都是 koa,midway 5.1 之后应该不会兼容双框架。 midway 可以更好地支持 generators 特性 midway-render this.render(xtpl,data) 内容直接通过 this.body 输出到页面。 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 midway 的专注点是做前后端分离,model 层其实是对后端的 model 做一层代理,数据依赖后端提供。 view 层 模板使用 xtpl 模板,前后端的模板统一。 controller 把路由和视图完整的结合在了一起,通常在 controller 中实现 this.render。 bigpipe 的位置 了解 midway 这些信息,其实是为了弄清楚 bigpipe 在 midway 里面应该在哪里接入会比较合适:
bigpipe 方案需要实现对内容的分块传输,所以也是在 controller 中使用。 拼装模板需要 midway-xtpl 实现拼装好字符串,然后通过 bigpipe 分块输出。 bigpipe 可以实现对各个模块进行取数据和拼装模块内容的功能。 建议在 controller 中作为 bigpipe 模块引入使用,取代原有 this.render 的方式进行内容分块输出 场景 什么样的场景比较适合 bigpipe,结合我们现有的东西和开发模式。
类似于卖家中心,模块多,页面长,首屏又是用户核心内容。 每个模块的功能相对独立,模板和数据都相对独立。 非首屏模块还是建议用滚动加载,减少首屏传输量。 主框架输出 assets 和 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); }); });}
midway-render/midway-xtpl 应该有扩展,但是没找到怎么使用,所以选择这样的方式。 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 = '';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(''); 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 '' } return view;}
context.html 拼装各个 pagelet 的内容 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();
layouthtml 拼装好主框架模板 每个 pagelets 的配置 { id: 'seller_info',//该pagelet的唯一id proxy: 'seller.module.data.seller_info', // 接口配置 tpl: 'sellerinfo.xtpl', //需要的模板 js: '' //需要执行的js}
proxy 和 tpl 获取数据和拼装模板需要并行执行 js 通常进行模块的初始化 改进 思路和代码实现都基于现有的场景和技术背景,目前只有实现的思路和方案尝试,还没形成统一的解决方案,需要更多的场景来支持。目前有些点还可以改进的:
代码可以采用 es6/es7 新特性进行改造会更优雅,时刻结合 midway 的升级进行改进。 分块传输机制存在一些低版本浏览器不兼容的情况,最好实现异步加载模块的方案,分双路由,根据用户设备切换路由。 对于每个模块和内容进行异常处理,设置一个请求的时间限制,达到限制时间,关闭链接,不要让页面挂起。此时把本来需要进行分块传输的模块通过异步的方式引入。 并行的实现方案目前采用 async.each,需要从性能上进行各方案的对比
其它类似信息

推荐信息