Home  >  Article  >  Web Front-end  >  新版卖家中心 Bigpipe 实践(二)_html/css_WEB-ITnose

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

WBOY
WBOYOriginal
2016-06-24 11:22:481290browse

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

核心问题

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

  • 同步加载首屏模块,服务端各个模块并行生成内容,客户端渲染内容依赖于最后一个内容的生成时间。这里的痛点是 同步 。因为要多模块同步,所以难免浏览器要等待,浏览器等待也就是用户等待。
  • 于是我们采用了滚动异步加载模块,页面框架优先直出,几朵菊花旋转点缀,然后首屏模块通过异步请求逐个展现出来。虽然拿到什么就能在客户端渲染显示,但还是有延迟感。这里的痛点是 请求 ,每个模块都需要多一个请求,也需要时间。
  • Facebook 的工程师们会不会是这样想的:一次请求,各个首屏模块服务端并行处理生成内容,生成的内容能直接传输给客户端渲染,用户能马上看到内容,这样好猴赛雷~
  • 其实 Bigpipe 的思路是从 微处理器的流水线 中受到启发

技术突破口

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

概念

  • 技术点:HTTP 协议的分块传输(在 HTTP 1.1 提供) 概念入口
  • 如果一个 HTTP 消息(请求消息或应答消息)的 Transfer-Encoding 消息头的值为 chunked ,那么,消息体由数量未定的块组成,并以最后一个大小为 0 的块为结束。
  • 这种机制使得网页内容分成多个内容块,服务器和浏览器建立管道并管理他们在不同阶段的运行。

实现

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

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>

  • 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

<!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>

  • 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('<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

  • 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('<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

  • 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('<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

  • 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('<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

  • 模块显示的顺序是 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('<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);});

  • 结果和 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('<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;

  • 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('<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);}

  • 此段代码未曾跑过验证,思路和代码摆在这里,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 = '<!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;}

  • 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,需要从性能上进行各方案的对比
Statement:
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn