Servlet與JSP專案實戰 — 部落格系統(下)

weixin_33806914發表於2017-01-01

前面兩篇文章已經介紹了這個部落格專案的主要功能。本文將討論餘下的一些高階功能。作為這個專案系列的終結,在這裡也要感謝原作者的慷慨分享,讓我們有機會得到這麼具體實用的鍛鍊。另外寫完這個系列的感受就是,它確實大大地幫助了我去深入思考和挖掘,教是最好的學習。今天是元旦,新年快樂!

網頁靜態化

JSP、ASP.NET等動態頁面是網際網路技術的一次飛躍。但它們也有缺陷。觀察一下淘寶、京東等訪問量巨大的網站,可以發現它們大多都是靜態的HTML頁面。

網頁靜態化就是指在功能不變的前提下,把這些動態頁面變成靜態的HTML頁面。

靜態化一些好處:

  • 提高開啟速度。動態頁面需要容器的很多操作,很消耗時間;而靜態頁面只需要HTTP伺服器就能夠處理了,可以大幅提高響應能力。這也是網頁靜態化的主要動力。

  • 有利於被搜尋引擎收錄。搜尋引擎的爬蟲更容易解析靜態頁面。

  • 更簡單,更安全。不容易被黑客發現漏洞;資料庫出故障照樣能開啟頁面。

那麼本專案是怎麼實現靜態化的呢?

setting.properties中的environment.product改為true。應用載入起來後,訪問http://localhost:8080/,你看到的就是一個靜態頁面。它就是web.xml中指定的歡迎頁面html/index.html。這個頁面中的大部分連結也都是靜態頁面。比如點選“全部文章”得到的是html/article_list_create_date_1.html,點選第一篇文章開啟的是html/article_1.html;點選右邊欄的“點選排行”開啟的是html/article_list_access_times_1.html。這些都是靜態頁面。

從這裡就能看出靜態化的好處:大部分使用者只是上來看看,切換幾個頁面,瀏覽幾篇文章——他們看到的都是靜態頁面,消耗的資源極少,從而大大減輕了伺服器的壓力。

下面來看看實現原理。開啟com.zuoxiaolong.listener包下ConfigurationListener的程式碼:

public class ConfigurationListener implements ServletContextListener {
    
    @Override
    public void contextInitialized(ServletContextEvent servletContextEvent) {
        
        ...
        if (Configuration.isProductEnv()) {
            ...
            Executor.executeTask(new FetchTask());
            ...
            Executor.executeTask(new BaiduPushTask());
            ...
        }
    }
    ...

這個方法在容器載入應用時被呼叫。Executor.executeTask()接受一個Runnable的實現類,就是啟動一個新執行緒來執行任務。

FetchTask類的實現如下:

public class FetchTask implements Runnable {
    
    private static final int THREAD_SLEEP_DAYS = Integer.valueOf(Configuration.get("fetch.thread.sleep.days"));

    @Override
    public void run() {
        while (true) {
            try {
                ImageUtil.loadArticleImages();
                if (Configuration.isProductEnv()) {
                    Cnblogs.fetchArticlesAfterLogin();
                } else {
                    Cnblogs.fetchArticlesCommon();
                }
                LuceneHelper.generateIndex();
                Generators.generate();
                Thread.sleep(1000L * 60L * 60L * 24L * Long.valueOf(THREAD_SLEEP_DAYS));
            } catch (Exception e) {
                logger.warn("fetch and generate failed ...", e);
                break;
            }
        }
...

方法中的迴圈表明任務將會定期執行,預設間隔是一天。其他程式碼我們後面再探討,先來看Generators.generate()

為了弄清楚這個函式,先來看看com.zuoxiaolong.generator這個包。這個包下所有類都繼承自介面Generator

public interface Generator {

    ViewMode VIEW_MODE = ViewMode.STATIC;
    int order();
    void generate();
}

可以猜到,這個介面就定義了生成靜態頁面的介面。
Generators類在被呼叫之前先把包下面所有的靜態頁面生成類找到並存放到陣列中。Generators.generate()就是依次呼叫這些類的generate()方法。

ArticleGenerator類為例:

public class ArticleGenerator implements Generator {

    ...
    @Override
    public void generate() {
        List<Map<String, String>> articles = DaoFactory.getDao(ArticleDao.class).getArticles("create_date", Status.published, VIEW_MODE);
        for (int i = 0; i < articles.size(); i++) {
            generateArticle(Integer.valueOf(articles.get(i).get("id")));
        }
    }

    void generateArticle(Integer id) {
        Writer writer = null;
        try {
            Map<String, Object> data = FreemarkerHelper.buildCommonDataMap(VIEW_MODE);
            ArticleHelper.putDataMap(data, VIEW_MODE, id);
            String htmlPath = Configuration.getContextPath(ArticleHelper.generateStaticPath(id));
            writer = new FileWriter(htmlPath);
            FreemarkerHelper.generate("article", writer, data);
        } catch (IOException e) {
            ...

}

它的generate()方法就是對每篇文章呼叫generateArticle()。由於VIEW_MODE的取值始終是介面中的賦值ViewMode.STATIC,因此生成的結果中含有的連結都是靜態地址。而通過計算得到的靜態頁面地址htmlPath將會是html/article_id.html

得到靜態的文章地址,這沒問題。但是更上層的靜態頁面中的連結(比如首頁中的文章列表)應該指向這些靜態頁面,這樣才有意義。

我們來看看怎麼實現。以ArticleListGenerator類為例,它負責生成靜態的最新文章列表等頁面。其生成方法中呼叫了ArticleListHelper.putDataMap()方法,後者又呼叫了ArticleDao.getPageArticles()方法。最終這個方法呼叫了transfer()來把從資料庫中查詢到的變數轉換成用於模板的Map變數。來看看它的程式碼:

public Map<String, String> transfer(ResultSet resultSet, ViewMode viewMode) {
        Map<String, String> article = new HashMap<String, String>();
        try {
            String id = resultSet.getString("id");
            article.put("id", id);
            if (viewMode == ViewMode.DYNAMIC) {
                article.put("url", ArticleHelper.generateDynamicPath(Integer.valueOf(id)));
            } else {
                article.put("url", ArticleHelper.generateStaticPath(Integer.valueOf(id)));
            }
            ...

看到了嗎?由於開始傳入的VIEW_MODE始終是靜態的,url的值將會是文章的靜態頁面的地址。看到這裡你應該就能徹底理解VIEW_MODE的用意了。

以此類推,從最外層的歡迎頁面,到文章列表頁面,再到具體的文章頁面,這些靜態頁面含有的始終都是靜態頁面的連結。除非使用者點選頂欄選單中的“主頁”連結(這個連結指向的是動態地址),繞來繞去他都是在訪問靜態頁面!

最後,靜態頁面不是定期才重新整理的。否則會出現問題——假如有人提交了新的評論,其他人仍然看不到這個評論,只能等到一天後重新整理。觀察Generators類,它還含有一些靜態方法,比如generateArticle()。這些方法會在需要時被呼叫,而不用被動的等待任務定期重新整理。Ctrl+H檢視引用就能發現方法的呼叫情況。

快取

把一些常常被訪問的資料儲存到記憶體中,需要時直接獲取而不用進行磁碟IO,這便是常見的快取技術。作者自己實現了一個簡單的快取機制,程式碼在com.zuoxiaolong.cache包中。

快取的資料是用ConcurrentHashMap來存放的,並且用另一個ConcurrentHashMap來追蹤資料的生命週期。讀取資料時,先檢查資料有沒有過期,如果有則刪除資料,返回null。

檢視CacheManager的所有引用可以看出快取功能的使用情況。它主要用在兩方面:

  • 使用者訪問記錄。由於呼叫次數多,且邏輯非常簡單,使用快取可以提高效能。

  • 文章顯示在文章列表中的隨機配圖。由於這些圖都是事先準備好的,而且常常用到,所以用快取進行優化很合理。

Lucene搜尋

系統使用了大名鼎鼎的Apache Lucene作為全文搜尋引擎。這裡是它的官方網站。關於它的原理,如果你用過Everything這個檔案搜尋工具,或者諸如DT Search這樣的程式碼搜尋工具,就會很容易理解。簡單來說,它們都會事先掃描所有檔案的內容,然後把每個單詞建立索引(可以類比為Hash儲存),這樣在搜尋時將會非常快。這裡有一篇較為詳細的講解。

具體的實現大部分在com.zuoxiaolong.search.LuceneHelper類中。

  • generateIndex()方法被FetchTask任務定期呼叫,掃描文章生成索引。

  • search()方法呼叫Lucene引擎得到結果,並把結果用高亮標註。

  • common.js中的searchArticles()方法將搜尋事件轉發給article_list.ftl頁面,後者的動態資料類最終呼叫LuceneHelper的方法得到結果。

爬蟲

這個系統中引入的爬蟲只是為了將作者以前在CnBlogs的部落格搬運過來。程式碼全部在com.zuoxiaolong.reptile.Cnblogs這一個類中。

爬蟲的原理是使用Jsoup這個HTML解析器,後者可以讓HTML解析變得非常簡單。具體可以參考其官網。這裡不做更多探討。

RSS訂閱和百度主動推送

部落格網站往往都支援RSS訂閱,方便使用者在一個地方閱讀不同來源的內容。只不過無私一點的就把內容也放在Feed中;自私一點就只放文章連結,這樣使用者還得來訪問自己的網站;最自私的就是不提供訂閱…

RSS的原理很簡單,就是網站釋出一個Url,這個地址是一個XML文字,裡面用RSS格式描述網站的最新內容。如這個連結是阮一峰部落格的Feed。客戶端軟體儲存這個Url,然後定期地重新整理以獲得XML文字的最新內容,再通過比較就能夠得知網站是否存在更新,如果有就通知使用者。

點選主頁右邊欄的"RSS訂閱"按鈕,發現它開啟的網址是http://localhost:8080/blog/fe... 。根據web.xml的配置,.XML檔案也是跟.FTL一樣處理的。也就是說,也會有一個FreeMarker模板,與動態資料合併後生成內容。只不過最後輸出的XML文件。

那麼就來看看它分別對應的動態資料類Feed和模板blog/feed.ftl:

@Namespace
public class Feed implements DataMap {

    @Override
    public void putCustomData(Map<String, Object> data, HttpServletRequest request, HttpServletResponse response) {
        response.addHeader("Content-Type","text/xml; charset=utf-8");
        Map<String, Integer> pager = new HashMap<>();
        pager.put("current", 1);
        data.put("articles", DaoFactory.getDao(ArticleDao.class).getPageArticles(pager, Status.published, "create_date", ViewMode.STATIC));
        data.put("lastBuildDate", DateUtil.rfc822(new Date()));
    }
}

可見它就是把最新的文章從資料訪問層中取出,然後放到FreeMarker的變數中。注意getPageArticles()用的引數是ViewMode.STATIC,所以得到的都是靜態頁面。

再來看FreeMarker模板:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>左瀟龍個人部落格</title>
        <atom:link href="http://www.zuoxiaolong.com/feed.xml" rel="self" type="application/rss+xml"/>
        <link>http://www.zuoxiaolong.com</link>
        <description>一起走在程式設計的路上</description>
        <lastBuildDate>${lastBuildDate}</lastBuildDate>
        <language>zh-CN</language>
        <#list articles as article>
            <#if article_index gt 9>
                <#break />
            </#if>
            <item>
                <title>${article.subject}</title>
                <link>${contextPath}${article.url}</link>
                <pubDate>${article.us_create_date}</pubDate>
                <description>${article.summary}...</description>
            </item>
        </#list>
    </channel>
</rss>

一目瞭然,把文章的標題、連結、摘要等放入合適的RSS元素中。這裡也說明FreeMarker不是隻用來生成HTML的,它可以生成任何內容。

最後一部分內容是關於百度的主動推送

關於它的解釋可以參考這個連結,以及官方文件。大致意思是,使用主動連結推送可以第一時間把內容更新告知百度,而不用等待百度的蜘蛛爬蟲來解析你的網站。這樣做的一個好處就是保護原創,使內容可以在轉發之前被百度發現。

其實現在類BaiduPushTask中,也是作為一個單獨的執行緒被Executor啟動。來看程式碼:

@Override
    public void run() {
        boolean first = true;
        while (true) {
            try {
                if (first) {
                    first = false;
                    Thread.sleep(1000 * 60 * Integer.valueOf(Configuration.get("baidu.push.thread.wait.minutes")));
                }
                DaoFactory.getDao(HtmlPageDao.class).flush();
                HttpApiHelper.baiduPush(1);
                Thread.sleep(1000 * 60 * 60 * 24);
            } catch (Exception e) {
                logger.warn("baidu push failed ...", e);
                break;
            }
        }
    }

就是定期執行。先呼叫DaoFactory.getDao(HtmlPageDao.class).flush();重新整理要push的連結。再呼叫HttpApiHelper.baiduPush();將連結提交到百度。

HttpApiHelper.baiduPush()方法很簡單,就是把內容以json方式傳送到百度提供的介面上。當然要提前在百度申請好API的Token,配置在setting.properties檔案中。

相關文章