高性能頁面加載技術--BigPipe設計原理及Java簡單實現


1.技術背景

  動態web網站的歷史可以追溯到萬維網初期,相比於靜態網站,動態網站提供了強大的可交互功能.經過幾十年的發展,動態網站在互動性和頁面顯示效果上有了很大的提升,但是對於網站動態網站的整體頁面加載架構沒有做太大的改變.對於用戶而言,頁面的加載速度極大的影響着用戶體驗感.與靜態網站不同,除了頁面的傳輸加載時間外,動態網站還需考慮服務端數據的處理時間.像facebook這樣大型的用戶社交網站,必須考慮用戶訪問速度問題,

  傳統web模式采用了順序處理的流程來處理用戶請求.即用戶向客戶端發送一個請求后,服務器處理請求,加載數據並渲染頁面;最后將頁面返回給客戶端.整個過程是串行執行的,具體流程如下:

  1. 瀏覽器發送一個HTTP請求到Web服務器。
  2. Web服務器解析請求,然后讀取數據存儲層,制定一個HTML文件,並用一個HTTP響應把它發送到客戶端。
  3. HTTP響應通過互聯網傳送到瀏覽器。
  4. 瀏覽器解析Web服務器的響應,使用HTML文件構建了一個的DOM樹,並且下載引用的CSS和JavaScript文件。
  5. CSS資源下載后,瀏覽器解析它們,並將它們應用到DOM樹。
  6. JavaScript資源下載后,瀏覽器解析並執行它們。

  整個流程按必須順序執行,不能重疊,這也是為什么傳統模式隨着網絡速度的提升訪問速度沒有很大提升的原因.解決順序執行的速度問題,一般能想到的就是多線程並發執行.Facebook 的前端性能研究小組采用瀏覽器和服務端並發執行的思路,經過了六個月的努力,開發出了BigPipe頁面異步加載技術,成功的將個人空間主頁面加載耗時由原來的5 秒減少為現在的2.5 秒。這就是我們本文要介紹的高性能頁面加載技術---BigPipe.

2.BigPipe設計原理

  BigPipe的主要思想是實現瀏覽器和服務器的並發執行,實現頁面的異步加載從而提高頁面加載速度.為了達到這個目的,BigPipe首先根據頁面的功能或位置將一個頁面分成若干模塊(模塊稱作pagelet),並對這幾個模塊進行標識.舉個例子,在博客園個人首頁包括幾大板塊,如頭部信息,左邊信息,博文列表,footer等.我們可以將首頁按這些功能分塊,並用唯一id或名稱標識pagelet.客戶端向服務端發送請求后(發出一次訪問請求,如請求訪問個人博客首頁),服務端采用並發形式獲取各個pagelet的數據並渲染pagelet的頁面效果.一旦某個pagelet頁面渲染完成則立刻采用json形式將該pagelet頁面顯示結果返回給客戶端.客戶端瀏覽器會根據pagelet的id或標識符,在頁面的制定區域對pagelet進行轉載渲染.客戶端的模塊加載采用js技術.具體流程如下:

   1. 請求解析:Web服務器解析和完整性檢查的HTTP請求。
   2. 數據獲取:Web服務器從存儲層獲取數據。
   3. 標記生成:Web服務器生成的響應的HTML標記。
   4. 網絡傳輸:響應從Web服務器傳送到瀏覽器。
   5. CSS的下載:瀏覽器下載網頁的CSS的要求。
   6. DOM樹結構和CSS樣式:瀏覽器構造的DOM文檔樹,然后應用它的CSS規則。
   7. JavaScript中下載:瀏覽器下載網頁中JavaScript引用的資源。
   8. JavaScript執行:瀏覽器的網頁執行JavaScript代碼

  前三個階段由Web服務器執行,最后四個階段(5,6,7,8)是由瀏覽器執行。所以在服務器可以采用多線程並發方式對每個pagelet進行數據獲取和標記生成頁面,生成好的pagelet頁面發送給前端.同時在瀏覽器端,對css,js的下載可以采用並行化處理.這就達到了瀏覽器和服務器的並發執行的效果,這樣使得多個流程可以疊加執行,加少了整體頁面的加載時間.瀏覽器端的並行化交給瀏覽器自己處理不需要我們做額外工作.在BigPipe中主要是處理服務端的並行性.

3. BigPipe的實現原理

  在BigPipe,一個用戶請求的生命周期是這樣的:在瀏覽器發送一個HTTP請求到Web服務器。在收到的HTTP請求,並在上面進行一些全面的檢查, 網站服務器立即發回一個未關閉的HTML文件,其中包括一個HTML 標簽和標簽的開始標簽。標簽包括BigPipe的JavaScript庫來解析Pagelet以后收到的答復。在標簽,有一個模板,它指定了頁面的邏輯結構和Pagelets占位符。例如:

 1 <html>
 2 <head>
 3     <title>struts2-bigpipe-plugin</title>
 4     <script type="application/javascript">
 5            function replace(id, content) {
 6                  var pagelet = document.getElementById(id);
 7                  pagelet.innerHTML = content;
 8            }
 9     </script>
10 </head>
11 <body>
12 <table width="100%">
13     <tr border="1">
14         <td width="50%" bgcolor="#f0f8ff"><div id="one">${one}</div></td>
15         <td width="50%" bgcolor="#faebd7"><div id="two">${two}</div></td>
16     </tr>
17     <tr>
18         <td width="50%" bgcolor="#7fffd4"><div id="three">${three}</div></td>
19         <td width="50%" bgcolor="#8a2be2"><div id="four">${four}</div></td>
20     </tr>
21 </table>
View Code

  注意:這個html沒有以</body> </html>結束,這是一個未關閉的頁面模板(這里定義為index.ftl,是freemarker模板).如果是封閉的頁面,那么瀏覽器就不會等待也不接收服務器之后返回的數據.所以這里必須設置為未關閉的頁面.

  服務端返回給客戶端頁面模板(index.ftl)后,並行的處理各個pagelet的數據並和將數據填補pagelet對應的頁面顯示模板(如下面代碼的one.ftl)中得到一個渲染完成的頁面.如果某個pagelet的頁面渲染完成,那么就將pagelet的id或標示符,html內容及相關數據組成json數據立刻發送給客戶端.客戶端根據pagelet的id和html內容,以及之前傳回來的模板(index.ftl),利用模板(index.ftl)中的JavaScript解析函數將pagelet的數據加載到頁面對應的位置.下面是對應上面模板中id為one的pagelet的頁面模板(one.ftl):

<h1>Part One</h1>
<h2>你好:${user.name},現在時間時 ${time}  </h2>

  服務端將數據填充到one.ftl頁面后,將構建json數據,並立即將json數據發送給客戶端(瀏覽器).下面是服務器發回給客戶端的pagelet one對應的數據.為了使解析的js書寫簡單(index.ftl的js解析方法replace()),這里沒有直接轉換為json,而是用html數據返回給前端:

<script type="application/javascript\">
       replace("one",              
              "<h1>Part One</h1>
                <h2>你好:John,現在時間時 23:20 </h2>" );
 </script>

  如果用json表達的話,一般是以下格式,但是需要將index.ftl的replace()函數改寫:

<script type=”text/javascript”>
replace(
{id:”one”,
 content:”<h1>Part One</h1>
          <h2>你好:John,現在時間時 23:20 </h2>”, 
 css:”[..]“,
js:”[..]“,
…} );
</script>

  這樣瀏覽器就會完成pagelet one的加載,其他pagelet按此方法進行.它們之間不是順序執行的,而是並行執行,因為在服務端采用並行方式.上面便是整個bigpipe的執行過程了.

4.java實現代碼

  這里以我的博客園個人首頁做例子,實現一個基於servlet的BigPipe簡單demo.因為是簡單的demo,沒有實現數據庫訪問和頁面的渲染操作,只是做一個簡單的模仿.

4.1 頁面模板  

首先我將個人模塊主頁分為header(頂部),sideBar(左邊),mainContent(文章列表),footer四個pagelet,寫了一個簡單的頁面框架,代碼如下:

<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-cn">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<title>JohnZheng - 博客園</title>
<!-- JS 解析函數 -->
<script type="application/javascript">
    function replace(id,content) {
          var pagelet = document.getElementById(id);
          pagelet.innerHTML = content;
}
 </script>
</head>
<body>
 <div style="margin:0px 0px;padding:0px 0px;">
    <!-- header 頭部 -->
    <div id="header" style="height:150px;background-color:#00FFFF;"></div>
    <div style="clear:both"></div>
    <div id="main">
        <!--sideBar 側邊欄容器 -->
        <div id="sideBar" style="width:200px;height:420px;float:left;background-color:#00ff00;"></div>
        <!--mainContent 主體內容容器-->
        <div id="mainContent" style="float:left;width:800px; height:420px;padding-left:10px;"></div>
        <div style="clear:both"></div>
    </div><!--end: main -->
    
    <div style="clear:both"></div>
    <!--footer -->
    <div id="footer" style="background-color:#C0C0C0;height:60px;text-align:center"></div>
</div><!--end: home 自定義的最大容器 -->
<!--</body>-->
<!--</html>-->
index.ftl

  這里需要注意以下幾點:

  • 模板頁面必須是未封閉的.
  • 因為這里只是模仿實現,所以我在模板中加入了Js解析函數.如代碼注解處.該解析函數用於將之后的服務端返回的pagelet解析並插入到模板中的對應位置.
  • 本案例是采用freemarker來寫的(其實在該案例中,將后綴名改為html也是可以的)
  • 雖然此處沒有引入css和js,但如果文件中有css,js,一般是將css放在頭部,而js則是返回所有的pagelet之后再將js返回給前端.

  顯示效果如下圖所示:

   

 4.2 各個Pagelet

   未各個pagelet編寫視圖模板,該視圖模板負責顯示對應的pagelet,規定了pagelet的顯示樣式.所以案例中編寫了header.ftl,sideBar.ftl, mainContent.ftl, footer.ftl. (這里的文件名不需要和pagelet的div id對應).

   頂部視圖模板 header.ftl:

<div>
<h1><a href="http://www.cnblogs.com/jaylon/">John Zheng</a></h1>
<h2>知止而后定,定而后能靜,靜而后能安,安而后能慮,慮而后能得.</h2>
</div><!--end: blogTitle 博客的標題和副標題 -->

<div id="navigator">    
    博客園 首頁 新聞 新隨筆 聯系 管理 訂閱
    <div>                
        <!--done-->
        隨筆- 4&nbsp;
        文章- 0&nbsp;
        評論- 2&nbsp;
    </div><!--end: blogStats -->
</div><!--end: navigator 博客導航欄 -->
header.ftl

  左邊視圖模板 sideBar.ftl

<div>
<h3 class="catListTitle">公告</h3>
    昵稱:JohnZheng</br>
    園齡:6個月</br>
    粉絲:4</br>
    關注:1</br>
</div>
    
sideBar.ftl

  文章列表視圖模板 mainContent.ftl

<div class="forFlow">
</br>
<div class="day">
    <div class="dayTitle">
        <a href="http://www.cnblogs.com/jaylon/archive/2015/10/29.html">2015年10月29日</a>                  
    </div>

    
    <div>
        <a  href="http://www.cnblogs.com/jaylon/p/4918914.html">Spring 入門知識點筆記整理</a>
    </div>
    <div ><div >摘要: spring入門學習的筆記整理,主要包括spring概念,容器和aop的入門知識點筆記的整理.<a href="http://www.cnblogs.com/jaylon/p/4918914.html" class="c_b_p_desc_readmore">閱讀全文</a></div></div>
    <div ></div>
    <div >posted @ 2015-10-29 19:59 JohnZheng 閱讀(395) 評論(2)  <a href ="http://i.cnblogs.com/EditPosts.aspx?postid=4918914" rel="nofollow">編輯</a></div>
</div>
</br>

<div >
    <div>
        <a  href="http://www.cnblogs.com/jaylon/archive/2015/10/28.html">2015年10月28日</a>                  
    </div>
    <div >
        <a  href="http://www.cnblogs.com/jaylon/p/4908075.html">spring遠程服務知識梳理</a>
    </div>
    <div>
        <div>摘要: 本文主要是對spring中的幾個遠程調度模型做一個知識梳理.spring所支持的RPC框架可以分為兩類,同步調用和異步調用.同步調用如:RMI,Hessian,Burlap,Http Invoker,JAX-WS. RMI采用java序列化,但很難穿過防火牆.Hessian,Burlap都是基於http協議,能夠很好的穿過防火牆.但使用了私有的對象序列化機制,Hessian采用二進制傳送數據,而Burlap采用xml,所以Burlap能支持很多語言如python,java等.Http Invoker 是sping基於HTTP和java序列化協議的遠程調用框架,只能用於java程序的通行.Web service(JAX-WS)是連接異構系統或異構語言的首選協議,它使用SOAP形式通訊,可以用於任何語言,目前的許多開發工具對其的支持也很好.
          同步通信有一定的局限性.所以出現了異步通信的RPC框架,如lingo和基於sping JMS的RPC框架.<a href="http://www.cnblogs.com/jaylon/p/4908075.html">閱讀全文</a></div>
    </div>
    <div >posted @ 2015-10-28 14:31 JohnZheng 閱讀(424) 評論(0)  <a href ="http://i.cnblogs.com/EditPosts.aspx?postid=4908075" rel="nofollow">編輯</a>
    </div>
</div>

</br>

<div>
    <div >
        <a  href="http://www.cnblogs.com/jaylon/archive/2015/10/24.html">2015年10月24日</a>                  
    </div>

    
    <div >
        <a  href="http://www.cnblogs.com/jaylon/p/4905769.html">Spring Security 入門詳解</a>
    </div>
    <div ><div class="c_b_p_desc">摘要: spring security主要是對spring應用程序的安全控制,它包括web請求級別和方法調度級別的安全保護。本文是主要介紹spring security的基礎知識,對spring security所涉及的所有知識做一個梳理。<a href="http://www.cnblogs.com/jaylon/p/4905769.html" class="c_b_p_desc_readmore">閱讀全文</a></div></div>
    <div></div>
    <div>posted @ 2015-10-24 11:47 JohnZheng 閱讀(331) 評論(0)  <a href ="http://i.cnblogs.com/EditPosts.aspx?postid=4905769" rel="nofollow">編輯</a></div>

</div>

</div><!--end: forFlow -->
mainContent.ftl

  尾部視圖模板 footer.ftl

</br>Copyright &copy;2015 JohnZheng

4.3 BigPipe 案例核心代碼

  現在是構建一個Servlet負責將訪問個人博客首頁的請求用BigPipe方式來加載頁面.這里我構建了一個BigPipeServlet實現bigPipe的整個流程.當用戶請求BigPipeServlet時

  • 首先將index.ftl頁面模板返回給流瀏覽器,因為只返回一個頁面框架所以用戶很快就可以看到上面顯示的那張圖的效果.
  • 將index.ftl發送出去以后,BigPipeServlet采用並行的方式獲取數據和渲染界面(這部分工作交給PageletWorker.java)這個線程完成,
  • 如果某個pagelet渲染完成則BigPipeServlet將起flush給瀏覽器.
  • 如果所有的pagelet都flush完成了,那么封閉頁面,告訴瀏覽器已經完成請求則不用接受服務器端的數據.

  下面是BigPipeServlet.java的代碼:

protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setHeader("Content-type", "text/html;charset=UTF-8");  
        response.setCharacterEncoding("UTF-8"); 
        PrintWriter writer = response.getWriter();
        //這里是得到一個渲染的頁面框架
        String frameView = Renderer.render("index.ftl");
        //將頁面框架返回給前端
        flush(writer,frameView);
       
    //並行處理pagelet ExecutorService executor
= Executors.newCachedThreadPool(); CompletionService<String> completionService = new ExecutorCompletionService<String>(executor); completionService.submit(new PageletWorker(1500,"header","pagelets/header.ftl")); //處理頭部 completionService.submit(new PageletWorker(2000,"sideBar","pagelets/sideBar.ftl"));//處理左邊信息 completionService.submit(new PageletWorker(4000,"mainContent","pagelets/mainContent.ftl"));//處理文章列表 completionService.submit(new PageletWorker(1000,"footer","pagelets/footer.ftl"));//處理尾部 //如果某個pagelet處理完成則返回給前端 try { for(int i = 0;i < 4; i++){ Future<String> future = completionService.take(); String result = future.get(); flush(writer,result); } } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } //最后關閉頁腳 closeHtml(writer); } /** * 返回給前端 * @param writer * @param content */ private void flush(PrintWriter writer,String content){ writer.println(content); writer.flush(); } /** * 關閉頁面 * @param writer */ private void closeHtml(PrintWriter writer){ writer.println("</body>"); writer.println("</html>"); writer.flush(); writer.close(); }

  這里采用了ExecutorCompletionService來負責並行處理pagelet,ExecutorCompletionService是一個包含Executor 和阻塞隊列的類.提交的線程會交給Executor執行,執行的結果放到阻塞隊列中,所以只要從隊列中獲取數據就可以了,簡化了多線程操作.

  代碼中為每個pagelet設置了不同的處理時間,這樣在瀏覽器可以看到不同頁面的在不同時間顯示.以上代碼中還引用了另一個類,PageletWorker. 該類主要負責pagelet的業務邏輯處理,獲取所需數據及頁面的渲染.最終將渲染完成的頁面交給BigPipeServlet處理.PageletWorker.java代碼如下:

public class PageletWorker implements Callable<String> {
    //模擬完成業務邏輯的運行時間(pagelet所需要的數據,渲染頁面等的總時間)
    private int runtime;
    //pagelet視圖模板
    private String pageletViewPath;
    private String pageletKey;
    /**
     * 創建一個pagelet 執行器.執行pagelet的業務邏輯和渲染頁面.這里只是模擬.
     * @param runtime 進行業務處理,數據獲取等的運行時間
   * @param pageletKey 對應html 中div id *
@param pageletViewPath 模板的視圖路徑 */ public PageletWorker(int runtime,String pageletKey, String pageletViewPath) { this.runtime = runtime; this.pageletKey = pageletKey; this.pageletViewPath = pageletViewPath; } public String call() throws Exception { //模仿業務邏輯的處理和相關數據獲取時間 Thread.sleep(runtime); //模仿頁面渲染過程 String result = Renderer.render(pageletViewPath); result = buildJsonResult(result); return result; } /** * 將結果轉化為json形式  * @param result * @return */ private String buildJsonResult(String result) { StringBuilder sb = new StringBuilder(); sb.append("<script type=\"application/javascript\">") .append("\nreplace(\"") .append(pageletKey) .append("\",\'") .append(result.replaceAll("\n","")).append("\');\n</script>"); return (String) sb.toString(); } }

 

  該類主要邏輯如下:

  • Thread.sleep(runtime) 表示模仿該pagelet的業務處理,數據獲取的時間
  • 將獲取到的數據及該pagelet的視圖模板交給渲染器渲染,得到渲染后的頁面內容.這里用Renderer類模仿頁面的渲染過程.
  • 將該頁面內容封裝成json形式返回

  Renderer.java 是模仿獲取渲染過程,這里主要是讀取pagelet視圖內容.具體代碼如下:  

public class Renderer {
    /**
     * 模范頁面的渲染,這里主要是獲取對應的頁面信息.
     * @param viewPath
     * @return
     */
    public static String render(String viewPath){
        String absolutePath = Renderer.class.getClassLoader().getResource(viewPath).getPath();
        File file = new File(absolutePath);
        StringBuilder contentBuilder = new StringBuilder();
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(file));
            String str;
            while ((str = br.readLine()) != null) {//使用readLine方法,一次讀一行
                contentBuilder.append(str + "\n");
            }    
        } catch (Exception e) {
            // TODO: handle exception
        }finally{
            if(br != null)
                try {
                    br.close();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
        }
        return contentBuilder.toString();
    }
}

  在web.xml中配置BigPipeServlet:  

<servlet>
      <servlet-name>bigPipeServlet</servlet-name>
      <display-name>bigPipeServlet</display-name>
      <description></description>
      <servlet-class>org.opensjp.bigpipe.servlet.BigPipeServlet</servlet-class>
  </servlet>
  <servlet-mapping>
      <servlet-name>bigPipeServlet</servlet-name>
      <url-pattern>/bigPipeServlet</url-pattern>
  </servlet-mapping>

  在頁面輸入/bigPipeServlet就可以看到效果了.先是顯示頁面框架內容,然后陸續的顯示各個pagelet的內容.

  代碼下載地址https://github.com/JohnZhengHub/BigPipe-ServletDemo 

5.實現細節

  如果要實現一個具有良好性能的BigPipe,需要考慮的東西還挺多.可以從一下幾個方面考慮,提高BigPipe框架的性能.

  1.對搜索引擎的支持.

  這是一個必須考慮的問題,如今是搜索引擎的時代,如果網頁對搜索引擎不友好,或者使搜索引擎很難識別內容,那么會降低網頁在搜索引擎中的排名,直接 減少網站的訪問次數。在BigPipe 中,頁面的內容都是動態添加的,所以可能會使搜索引擎無法識別。但是正如前面所說,在服務器端首先要根據user-agent 判斷客戶端是否是搜索引擎的爬蟲,如果是的話,則轉化為原有的模式,而不是動態添加。這樣就解決了對搜索引擎的不友好。

  2.將資源文件進行壓縮,提高傳輸速度.可以考慮使用G-zip對css和js文件進行壓縮

  3.對js文件進行精簡:對js 文件進行精簡,可以從代碼中移除不必要的字符,注釋以及空行以減小js 文件的大小,從而改善加載的頁面的時間。精簡js 腳本的工具可以使用JSMin,使用精簡后的腳本的大小會減少20%左右。這也是一個很大的提升。

  4.將樣式表放在頂部

  將html 內容所需的css 文件放在首部加載是非常重要的。如果放在頁面尾部,雖然會使頁面內容更快的加載(因為將加載css 文件的時間放在最后,從而使頁面內容先顯示出來),但是這樣的內容是沒有使用樣式表的,在css 文件加載進來后,瀏覽器會對其使用樣式表,即再次改變頁面的內容和樣式,稱之為“無樣式內容的閃爍”,這對於用戶來說當然是不友好的。實現的時候將css 文件放在<head>標簽中即可。

   5.將js放在底部

  支持頁面動態內容的Js 腳本對於頁面的加載並沒有什么作用,把它放在頂部加載只會使頁面更慢的加載,這點和前面的提到的css 文件剛好相反,所以可以將它放在頁尾加載。

 

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM