Java Web--XiyouLinux Group圖書借閱平臺的實現

源碼地址:XiyouLinux Group 圖書借閱平臺

項目地址中包含了一份README,因此對於項目的介紹省去部分內容。這篇博客,主要講述項目中各個模塊的實現細節。


項目概述及成果

首先將本項目使用到技術羅列出來:

  1. 使用Spring + Spring MVC進行後臺開發
  2. 使用Bootstrap和jQuery框架進行前端開發
  3. 使用自定義註解與自定義的JdbcRowMapper簡化JdbcTemplate對數據庫的操作
  4. 使用騰訊雲的對象存儲服務進行圖書照片的遠程存儲
  5. 使用MD5加密算法對用戶密碼在後臺進行加密存儲
  6. 使用過濾器進行一個會話中的身份校驗
  7. 手動從Spring容器中獲取bean
  8. 數據庫設計中的諸多細節… …

由於前端開發是由團隊中的其他人在負責,在加上博主對前端這塊並不瞭解,因此本篇博客並不討論有關第二點技術實現上的細節。

本項目如README中所述,在後期還有許多需要進行優化的地方。如果你對本項目感興趣,不妨在GitHub中將其Star,以獲得對本項目的持續關注~

至於項目成果大家可以閱讀README,我在其中有貼上程序運行後的部分截圖。或直接在本地搭建環境,運行此項目。過程中如有任何疑問,你也可以聯繫我:

spider_hgyi@outlook.com

關於項目的整體架構我也不再描述,README中對其進行了補充。


項目背景

這個項目的產生是有需求背景的。我們旨在爲XiyouLinux Group開發一個管理圖書借閱與歸還的平臺,從而能對小組中存在的大量書籍進行有效的管理。

我們的“老一屆boss”剛開始給我們提出了第一版的需求,在此需求上,我們最初使用Servlet + JSP的方式進行後臺開發。當然第一版由於太low我們對其進行了閹割。在我們學習了Spring與Spring MVC之後,就開始打算對其進行version 2.0的開發,並找來了一個專門學習前端的小可愛,纔有了當前的圖書借閱平臺。

此圖書借閱平臺實現的功能模塊請大家移步至README進行查看。

接下來,我就按照每個模塊的順序,給大家講一下本項目中用到的重點技術及其實現細節。


實現細節

注:博主只會挑幾個重點模塊去進行講述,因此有些模塊將不會涉及到。

模塊一:登錄模塊

登錄模塊分爲三個部分,登錄前主頁面、登錄後主頁面以及登錄框。

在這裏我給大家截一張圖看一下登錄前後主頁面的功能差距:

登錄前:
這裏寫圖片描述

登錄後:
這裏寫圖片描述

我對登錄後的頁面只截取了和登錄前有不同功能的區域。效果展示完畢,那麼接下來就談一談這個模塊中使用到的技術及其實現細節(只需考慮登錄後頁面實現的功能即可)。


分頁功能的實現

作爲一個展示信息的Web頁面,怎麼可能沒有分頁功能呢,只不過是由於上圖中的測試數據太少,沒有給大家展現出來罷了。我們使用的是傳統分頁功能,而傳統分頁中又分爲“真分頁”與“假分頁”:

  • 真分頁:每次從數據庫中只返回當前頁的數據,然後將數據交由視圖進行渲染
  • 假分頁:從數據庫中拿取所有需要或將要展示的數據,將數據交由視圖,由視圖實現數據的分頁功能(JS實現或JSTL實現)

我們也很容易判斷出哪種情況下何種方法最優:

如果數據量較小,使用假分頁的效果會更優;如果數據量龐大,使用真分頁的效果更優。

本項目使用的是“真分頁”,我們接下來看一下實現思路與實現代碼:

實現思路:

  1. 首先我們需要一個存儲頁面信息的Java Bean,也就是傳統的Java對象
  2. 使用GET方法進行頁面跳轉請求,也就是說,我們可以從URL中得到當前頁面是第幾頁
  3. 在後臺中進行邏輯構造,將Java Bean中的實例字段進行部分(完全)填充
  4. 使用Java Bean所提供的頁面信息,構造相應的SQL語句,拿到當前頁數據
  5. 使用TreeMap對數據進行時間維度上的排序,最終返回給視圖進行渲染

實現代碼:

  • 存儲頁面信息的Java Bean:
/**
 * Created by dela on 12/27/17.
 */
public class PagePO {
    private int everyPage;      // 每頁顯示記錄數
    private int totalCount;     // 總記錄數
    private int totalPage;      // 總頁數
    private int currentPage;    // 當前頁
    private int beginIndex;     // 查詢起始點
    private boolean hasPrePage; // 是否有上一頁
    private boolean hasNexPage; // 是否有下一頁

    public PagePO() { }

    public PagePO(int currentPage) {
        this.currentPage = currentPage;
        this.everyPage = 5;
        this.beginIndex = (currentPage - 1) * everyPage;
    }

    public PagePO(int currentPage, int everyPage) {
        this.currentPage = currentPage;
        this.everyPage = everyPage;
        this.beginIndex = (currentPage - 1) * everyPage;
    }

    ... ...

    public int getEveryPage() {
        return everyPage;
    }

    public void setEveryPage(int everyPage) {
        this.everyPage = everyPage;
    }

    ... ...

    public boolean isHasPrePage() {
        return hasPrePage;
    }

    public void setHasPrePage(boolean hasPrePage) {
        this.hasPrePage = hasPrePage;
    }

    ... ...

    @Override
    public String toString() {
        return "PagePO{" +
                "everyPage=" + everyPage +
                ", totalCount=" + totalCount +
                ", totalPage=" + totalPage +
                ", currentPage=" + currentPage +
                ", beginIndex=" + beginIndex +
                ", hasPrePage=" + hasPrePage +
                ", hasNexPage=" + hasNexPage +
                '}';
    }
}
  • 從URL中得到當前頁面是第幾頁,進行邏輯處理,填充上面Java Bean中的部分實例字段:
/**
 * Created by dela on 1/21/18.
 *
 * @Description: 登錄後主頁面對應的控制器
 */

@Controller
@RequestMapping("/auth")
public class MainController {
    ... ...

    @RequestMapping(value = {"", "/", "/page/{currentPagePre}"}, method = RequestMethod.GET)
    public String getMainPage(Model model, @PathVariable(value = "currentPagePre", required = false) String currentPagePre,
                              @RequestParam(value = "tag", required = false) String labelIdPre) {
        ... ...

        // 得到當前頁面的頁碼
        int currentPage = 1;
        if (currentPagePre != null) {
            currentPage = Integer.parseInt(currentPagePre);
        }
        PagePO pagePO = new PagePO(currentPage);

        ... ...

        // 得到當前分類下的數據總數(默認無分類)
        if (labelId == -1) {
            bookCount = bookInfoService.getBookCount();
        } else {
            bookCount = bookInfoService.getBookCountByLabelId(labelId);
        }
        pagePO.setTotalCount(bookCount);
        pagePO.setTotalPage((bookCount % 5 == 0) ? bookCount / 5 : bookCount / 5 + 1);

        // 根據頁面信息構造SQL語句,拿取當前頁的數據
        ... ...

        // 對獲取到的信息進行排序(按時間維度)
        ... ...

        ... ...

        /**
         * 分頁在後臺中的邏輯處理主要是以下部分:
         */

        // 在這裏添加分頁的邏輯是因爲JSP頁面中EL表達式對算數運算的支持不太良好
        model.addAttribute("ELPageValue", (currentPage - 1) / 5 * 5);

        // 當總頁數大於5時,需要如下屬性
        if (pagePO.getTotalPage() >= 6) {
            model.addAttribute("isOneOfNextFivePage", (pagePO.getTotalPage() - 1) / 5 * 5 + 1);
            model.addAttribute("reachNextFivePage", (currentPage + 4) / 5 * 5 + 1);
        }

        // 當前頁面大於等於6頁的時候, 需要顯示"[...]"按鈕--返回到前一個5頁
        if (currentPage >= 6) {
            model.addAttribute("returnPreFivePage", (currentPage - 1) / 5 * 5 - 4);
        }

        ... ...
    }
}
  • 根據頁面信息構造SQL語句,拿取當前頁的數據:
/**
 * Created by dela on 11/23/17.
 */

@Repository
public class BookInfoServiceImpl implements BookInfoService {
    private JdbcOperations jdbcOperations;
    private final static String GET_ONE_PAGE_BOOKINFO = "SELECT * FROM book_info WHERE amount > 0 ORDER BY pk_id DESC LIMIT ?, ?";

    @Override
    public List<BookInfoPO> getBookByPage(PagePO page) {
        return jdbcOperations.query(GET_ONE_PAGE_BOOKINFO,
                JdbcRowMapper.newInstance(BookInfoPO.class), page.getBeginIndex(), page.getEveryPage());
    }
}
  • 對獲取到的信息進行排序:(按時間維度)
public class BookUserMapUtil {
    public static Map<BookInfoPO, String> getBookInfo(List<BookInfoPO> bookInfoPOS, UserService userService) {
        ... ...
        // TreeMap可對數據進行排序,當然BookInfoPO要實現Comparable接口,並重寫compareTo方法
        Map<BookInfoPO, String> bookMap = new TreeMap<BookInfoPO, String>();

        ... ...

        return bookMap;
    }
}
  • JSP頁面中對應的分頁實現(JSTL與EL):
<!--分頁的實現-->
<div id="index_pingination">
  <ul class="pagination">

    <!--噹噹前頁面不是第一頁的時候, 要顯示 "首頁"和 "<<"按鈕-->
    <c:if test="${pageInfo.currentPage != 1 && pageInfo.totalPage != 0}">
      <c:if test="${labelId == -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/1">首頁</a></li>
        <li>
          <a href="${pageContext.request.contextPath}/page/${pageInfo.currentPage-1}">&laquo;</a></li>
      </c:if>
      <c:if test="${labelId != -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/1?tag=${labelId}">首頁</a></li>
        <li>
          <a href="${pageContext.request.contextPath}/page/${pageInfo.currentPage-1}?tag=${labelId}">&laquo;</a></li>
      </c:if>
    </c:if>

    <!--噹噹前頁面大於等於6頁的時候, 要顯示 "[...]"按鈕--返回到前一個5頁-->
    <c:if test="${pageInfo.currentPage >= 6}">
      <c:if test="${labelId == -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/${returnPreFivePage}">[...]</a></li>
      </c:if>
      <c:if test="${labelId != -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/${returnPreFivePage}?tag=${labelId}">[...]</a></li>
      </c:if>
    </c:if>

    <!--顯示當前頁面所有應顯示的頁碼-->
    <c:forEach varStatus="i" begin="${ELPageValue+1}" end="${ELPageValue+5}" step="${1}">
      <c:if test="${i.current <= pageInfo.totalPage}">

        <!--當前頁的超鏈接處理爲不可點擊-->
        <c:if test="${i.current == pageInfo.currentPage}">
          <li class="pa_in">
            <a disabled="true">${pageInfo.currentPage}</a></li>
        </c:if>
        <c:if test="${i.current != pageInfo.currentPage}">
          <c:if test="${labelId == -1}">
            <li>
              <a href="${pageContext.request.contextPath}/page/${i.current}">${i.current}</a></li>
          </c:if>
          <c:if test="${labelId != -1}">
            <li>
              <a href="${pageContext.request.contextPath}/page/${i.current}?tag=${labelId}">${i.current}</a></li>
          </c:if>
        </c:if>
      </c:if>
    </c:forEach>

    <!--如果不是最後一個五頁中的頁碼, 要在後面顯示[...]按鈕--跳到下一個5頁-->
    <c:if test="${pageInfo.currentPage < isOneOfNextFivePage && pageInfo.totalPage >= 6}">
      <c:if test="${labelId == -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/${reachNextFivePage}">[...]</a></li>
      </c:if>
      <c:if test="${labelId != -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/${reachNextFivePage}?tag=${labelId}">[...]</a></li>
      </c:if>
    </c:if>

    <!--如果不是尾頁, 要顯示 ">>"和 "尾頁"按鈕-->
    <c:if test="${pageInfo.currentPage != pageInfo.totalPage && pageInfo.totalPage != 1 && pageInfo.totalPage != 0}">
      <c:if test="${labelId == -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/${pageInfo.currentPage+1}">&raquo;</a></li>
        <li>
          <a href="${pageContext.request.contextPath}/page/${pageInfo.totalPage}">尾頁</a></li>
      </c:if>
      <c:if test="${labelId != -1}">
        <li>
          <a href="${pageContext.request.contextPath}/page/${pageInfo.currentPage+1}?tag=${labelId}">&raquo;</a></li>
        <li>
          <a href="${pageContext.request.contextPath}/page/${pageInfo.totalPage}?tag=${labelId}">尾頁</a></li>
      </c:if>
    </c:if>
  </ul>
</div>

登錄校驗之過濾器實現

既然系統具有登錄功能,那麼我們就需要注意一些事情:

  • 怎麼防止未登錄的用戶訪問登錄後的頁面
  • 用戶的cookie失效之後,我們需要引導用戶進行重新登錄

爲了解決這兩個問題,就需要引入過濾器。關於過濾器的功能與在Serlvet中的使用請移步至這一篇博客:Servlet–Servlet進階API、過濾器、監聽器

我現在要說的是過濾器在Spring框架中的使用,先看實現代碼,並不難理解:

/**
 * Created by dela on 1/18/18.
 *
 * @Description: 對於想要在Spring中使用過濾器, 就要繼承OncePerRequestFilter
 * OncePerRequestFilter, 顧名思義, 就是每個請求只通過一次這個過濾器
 */

public class LoginFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {
        final String INDEX_PAGE = "/";    // 未登錄的URL
        Object sessionId = null;

        HttpSession session = httpServletRequest.getSession(false);

        // 未登錄和Cookie失效的處理機制
        if (session != null) {
            sessionId = session.getAttribute("uid");
            if (sessionId != null) {
                filterChain.doFilter(httpServletRequest, httpServletResponse);
            }
        }

        // 如果當前操作的用戶沒有登錄令牌, 那就彈出彈框提示重新登錄, 並跳轉到未登錄頁面
        if (session == null || sessionId == null) {
            // 設置response的字符集, 防止亂碼
            httpServletResponse.setCharacterEncoding("GBK");

            PrintWriter out = httpServletResponse.getWriter();
            String builder = "<script language=\"javascript\">" +
                    "alert(\"網頁過期,請重新登錄!\");" +
                    "top.location='" +
                    INDEX_PAGE +
                    "';" +
                    "</script>";

            out.print(builder);
        }
    }
}

有兩個問題需要解決~

1.什麼叫做每個請求只通過一次這個過濾器。Filter不都是僅僅經過一次的嗎?

不是的!不然就不會有這個類了。

此方式是爲了兼容不同的Web容器,特意而爲之,也就是說並不是所有的Web容器都像我們期望的只過濾一次,Servlet版本不同,表現也不同。

如,Servlet2.3與Servlet2.4也有一定差異 :

在Servlet-2.3中,Filter會過濾一切請求,包括服務器內部使用forward轉發請求和<%@ include file=“/index.jsp”%>的情況。

到了Servlet-2.4中Filter默認下只攔截外部提交的請求,forward和include這些內部轉發都不會被過濾,但是有時候我們需要forward的時候也要用到Filter。

因此,爲了兼容各種不同的運行環境和版本,默認Filter繼承OncePerRequestFilter是一個比較穩妥的選擇。

2.有關HttpSession session = httpServletRequest.getSession(false)的一點小知識。

現實中我們經常會遇到以下3中用法:

HttpSession session = request.getSession();
HttpSession session = request.getSession(true);
HttpSession session = request.getSession(false);

他們之間的區別是什麼?

getSession(boolean create)意思是返回當前reqeust中的HttpSession,如果當前request中的HttpSession爲null且create爲true,就創建一個新的HttpSession,否則就直接返回null。

簡而言之:

  • request.getSession(true)等同於如果當前沒有HttpSession還要新創建一個HttpSession
  • request.getSession(false)則等同於如果當前沒有HttpSession就直接返回null

那麼我們在使用的時候:

  • 當向HttpSession中存儲登錄信息時,一般建議:HttpSession session = request.getSession(true)
  • 當從HttpSession中獲取登錄信息時,一般建議:HttpSession session = request.getSession(false)

還有一種更簡潔的方式:

如果你的項目中使用到了Spring,對Session的操作就方便多了。如果需要在Session中取值,可以用WebUtils工具的getSessionAttribute(HttpServletRequestrequest, String name)方法,看看源碼:

public static Object getSessionAttribute(HttpServletRequest request, String name) {  
    Assert.notNull(request, "Request must not be null");  
    HttpSession session = request.getSession(false);

    return (session != null ? session.getAttribute(name) : null);  
}

使用時:

WebUtils.setSessionAttribute(request, “user”, User);
User user = (User) WebUtils.getSessionAttribute(request, “user”);

密碼加密之MD5算法

也許你沒有聽過MD5加密算法,但是有些人看到這個標題首先會產生一個疑問:對密碼爲什麼還要加密?

主要是從安全性的角度上考慮,我們知道如果不對密碼進行加密,那麼密碼將會在後臺以明文的形式存儲到數據庫中。如果你的數據庫足夠安全,保證不會被別人所侵略,這當然沒有什麼問題。但事實是,我們不得不小心SQL注入等一系列數據庫安全性問題,這時候,在數據庫中所存儲的有關個人隱私的信息,就顯得十分重要了。因此將密碼在後臺進行加密,對於真正的企業級開發來說,是一件不可或缺的事情。

解決掉這個疑惑之後,讓我們一起來看看MD5加密算法的核心思想及代碼實現。

好吧,博主看了一些關於MD5的核心思想,並沒有看懂,先在這裏給大家放一篇講述MD5加密算法實現原理的博客鏈接:MD5算法原理 — 博客中有少量錯誤,大家理性閱讀。

關於MD5在Java中的使用,則要簡單許多:

  1. 通過MessageDigest.getInstance()確定加密算法,MessageDigest不止提供MD5
  2. 調用update(byte[] input)對指定的byte數組更新摘要
  3. 執行digest()方法進行哈希計算。在調用此方法之後,摘要被重置
  4. 對第三步返回的結果進行處理:128位級聯值(16組有符號字節值)—>將每組10進制數字轉換爲16進制,並生成相應字符串
/**
 * @Author: spider_hgyi
 * @Date: Created in 上午11:53 18-3-11.
 * @Modified By:
 * @Description: MD5加密算法
 */
public class MD5 {
    public static String codeByMD5(String inStr) {
        MessageDigest md5;
        try {
            // 得到MD5加密算法實例
            md5 = MessageDigest.getInstance("MD5");
        } catch (Exception e) {
            e.printStackTrace();
        }

        // 使用指定的byte數組更新摘要
        assert md5 != null;
        md5.update(inStr.getBytes());

        // 通過執行諸如填充之類的最終操作完成哈希計算。返回值是16個有符號字節數,共128位
        byte[] md5Bytes = md5.digest();

        // 用於存儲最終得到的32位小寫16進制字符串
        StringBuilder hexValue = new StringBuilder();

        // 將其中的字節轉換爲16進制字符
        for (byte md5Byte : md5Bytes) {
            // 將得到的有符號字節轉換爲無符號字節
            int val = ((int) md5Byte) & 0xff;
            if (val < 16) {
                hexValue.append("0");
            }
            hexValue.append(Integer.toHexString(val));
        }

        return hexValue.toString();
    }
}

以上代碼生成小寫16進制字符串,代碼運行結果經過本人與在線MD5加密網站生成的結果進行了對比,測試無誤,可放心使用。


模塊二:標籤頁模塊

效果展示:
這裏寫圖片描述

目錄樹結構的數據庫設計

在標籤頁這一模塊中,我們主要對在MySQL數據庫中如何存儲一個樹狀結構而進行一個簡單的介紹。

我在項目中設計的存儲結構並不高效,是一種最簡單且基本的實現。在網上有很多結構良好且性能高效的樹形結構的數據庫表設計,大家可以查閱一些相關資料。

對比上面的效果展示圖,我的標籤分類其實就是三層樹深度:

  1. 根節點(唯一)
  2. 一級標籤(大數據與雲計算… …)(多節點)
  3. 二級標籤(Hadoop、Spark等等)(多節點)

可以看到,雖然樹的深度只有3,但其每個父節點都擁有多個子節點。

既然已經將標籤信息組織成多路樹結構,那麼數據庫結構設計如下:

pk_id              name              parent_id

pk_id用來標識此標籤名的唯一索引,name就是標籤名,parent_id則是此標籤其父節點對應的pk_id。

我將一級標籤的parent_id都設置爲0,表明一級標籤的父節點提供空數據,標籤頁只需要一級標籤及二級標籤的信息。

如此,我們便可查找任一一級標籤信息及其所擁有的二級標籤信息。

至於標籤頁面中的顯示形式,我們在後臺只要將每個一級標籤作爲Map數據結構中的鍵,當前一級標籤所擁有的二級標籤作爲對應的值,然後將Map作爲model返回給視圖進行解析渲染即可。

代碼實現如下:

/**
 * @Author: spider_hgyi
 * @Date: Created in 下午1:36 17-11-20.
 * @Modified By:
 * @Description:
 */
@Controller
@RequestMapping("/auth")
public class TagsController {
    ... ...

    @RequestMapping(value = "/tags", method = RequestMethod.GET)
    public String showLabel(Model model) {
        // 得到所有的一級標籤(parent_id == 0)
        List<BookLabelPO> parentLabels = bookLabelRepository.getBookLabelByParentId(0);
        // 得到所有的二級標籤(parent_id != 0)
        List<BookLabelPO> childrenLabels = bookLabelRepository.getChildrenLabelsByParentId(0);
        // 返回給視圖的model
        Map<String, Map<Integer, String>> labelsName = new HashMap<String, Map<Integer, String>>();

        // 找到每個一級標籤所擁有的二級標籤
        for (BookLabelPO parentLabel : parentLabels) {
            Map<Integer, String> childLabelsName = new HashMap<Integer, String>();
            for (BookLabelPO childrenLabel : childrenLabels) {
                if (parentLabel.getPkId() == childrenLabel.getParentId()) {
                    childLabelsName.put(childrenLabel.getPkId(), childrenLabel.getName());
                }
            }
            labelsName.put(parentLabel.getName(), childLabelsName);
        }

        // 將存儲標籤信息的Map對象添加進model對象
        model.addAttribute("labelsName", labelsName);

        return "alltags";
    }
}

模塊三:上傳書籍模塊

騰訊雲存儲服務—圖片存儲

由於有些書籍會上傳封面照片,而騰訊雲又提供了對象存儲服務,因此我並沒有選擇將圖片存儲至本地或雲服務器上,而是使用了騰訊雲所提供的雲對象存儲。

使用雲對象存儲,騰訊所提供的開發者文檔:對象存儲 — SDK 文檔


手動獲取bean

Spring MVC給我們提供了文件上傳功能(兩種使用形式):

  1. 給控制器方法參數上添加@RequestPart註解,參數類型爲字節數組
  2. 給控制器方法參數上添加@RequestPart註解,參數類型爲Part

但是我在使用Spring MVC所提供的文件上傳功能時,始終無法獲取到對應的字節流對象。我查閱了大量的相關文檔,並仔細的檢查了所寫的代碼,最終也沒有找到問題的根源。因此在項目中,對於書籍圖片的處理,我使用了Servlet所提供的原生API:request.getPart()

既然使用了Servlet所提供的原生API,因此圖書上傳模塊所對應的控制器便繼承於HttpServlet。在繼承了HttpServlet之後,還是出現了很多問題—怎麼使原生Servlet與Spring MVC的bean之間進行協作?

在使用了HttpServlet之後,便無法給此Servlet添加@controller註解,也就無法使用依賴注入。大概的原因是Servlet由Web容器管理,而bean由Spring容器管理。在這種情況下,我對bean進行了手動獲取。

手動獲取bean的代碼我寫到了Servlet的init方法中,對於此方法我不在這裏進行描述。

博主之所以將這一技術細節提取出來,也是想給那些遇到同樣問題的朋友們提供一些思路。

代碼實現如下:

/**
 * @Author: spider_hgyi
 * @Date: Created in 下午8:14 17-12-3.
 * @Modified By:
 * @Description:
 */
@WebServlet(urlPatterns = "/auth/upload.do")
@MultipartConfig
public class NewBookController extends HttpServlet {
    private static final Logger logger = LoggerFactory.getLogger(NewBookController.class);

    private BookInfoService bookInfoService;
    private BookLabelService bookLabelService;
    private BookRelationLabelService bookRelationLabelService;
    private COSStorage cosStorage;

    // 手動獲取bean
    public void init() throws ServletException {
        // 得到Servlet應用上下文
        ServletContext servletContext = this.getServletContext();
        // 得到Web應用上下文
        WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(servletContext);
        // 根據beanId獲取相應bean
        bookInfoService = (BookInfoService) ctx.getBean("bookInfoServiceImpl");
        bookLabelService = (BookLabelService) ctx.getBean("bookLabelServiceImpl");
        bookRelationLabelService = (BookRelationLabelService) ctx.getBean("bookRelationLabelServiceImpl");
        cosStorage = (COSStorage) ctx.getBean("cosStorage");
    }

    ... ...
}

模塊四:對Jdbc RowMapper的簡易封裝

本項目的架構採用Spring + Spring MVC + JdbcTemplate,其中Spring + Spring MVC對應ssm框架中的ss,我們並沒有使用Mybatis框架。Spring提供了相應的JDBC框架—JdbcTemplate。

對於JdbcTemplate的使用如下(在使用之前需要進行相關的Spring配置):

/**
 * Created by hg_yi on 17-11-7.
 */
@Repository
public class JdbcSpitterRepository implements SpitterRepository {
    JdbcOperations jdbcOperations;

    private final static String INSERT_SPITTER = "INSERT INTO spitter (username, password, " +
            "firstname, lastname) VALUES (?, ?, ?, ?)";
    private final static String QUERY_SPITTER_BY_USERNAME = "SELECT * FROM spitter " +
            "WHERE username = ?";

    @Inject
    public JdbcSpitterRepository(JdbcOperations jdbcOperations) {
        this.jdbcOperations = jdbcOperations;
    }

    // 數據庫插入操作
    public Spitter save(Spitter spitter) {
        jdbcOperations.update(INSERT_SPITTER, spitter.getUsername(), spitter.getPassword(),
                spitter.getFirstName(), spitter.getLastName());

        return spitter;
    }

    // 數據庫查詢操作
    public List<Spitter> findByUsername(String username) {
        return jdbcOperations.query(QUERY_SPITTER_BY_USERNAME,
                new SpitterRowMapper(), username);
    }

    private final static class SpitterRowMapper implements RowMapper<Spitter> {
        public Spitter mapRow(ResultSet resultSet, int rowNum) throws SQLException {
            return new Spitter(
                    resultSet.getInt("id"),
                    resultSet.getString("username"),
                    resultSet.getString("password"),
                    resultSet.getString("firstname"),
                    resultSet.getString("lastname")
            );
        }
    }
}

對上述代碼有幾點說明:

  1. JdbcOperations是一個接口,定義了JdbcTemplate所實現的操作。通過注入JdbcOperations從而使JdbcSpitterRepository與JdbcTemplate保持了鬆耦合
  2. 使用RowMapper對Spitter對象進行填充,最後得到從數據庫中查詢到的結果集合
  3. 使用JdbcTemplate極大的方便了對JDBC的操作,沒有了創建JDBC連接和語句的代碼,也沒有了異常處理的代碼,只剩下單純的數據插入與查詢代碼
  • 那麼我們爲何還要對RowMapper進行封裝?

由上面的代碼可知,每當我們從相同(不同)的數據庫表中得到不同的數據時,就有可能創建不同的RowMapper。那麼問題就凸顯出來了,我們的系統中必定有多張數據庫表,也必定要從各個表中查詢不同的數據,那麼就會創建大量不同的RowMapper類,這些RowMapper散落於項目中的各個角落。這樣的設計,顯然很失敗。

  • 我們自己封裝的JdbcRowMapper(與Spring所提供的RowMapper所區分)有什麼功能呢?

我們嘗試對RowMapper進行封裝,以提供這樣的功能:對於不同的對象,RowMapper在從數據庫中查詢到相應的數據之後,都可對其相應的字段進行自動填充。

我們先來看一下它的使用效果:

jdbcOperations.query(GET_BOOK_BY_LABEL_AND_PAGE_TYPESCONTROLLER,
                        JdbcRowMapper.newInstance(BookInfoPO.class), labelId,pagePO.getBeginIndex(), pagePO.getEveryPage());

jdbcOperations.query(QUERY_CHILDREN_LABELS_BY_PARENT_ID, JdbcRowMapper.newInstance(BookLabelPO.class), parentId);

可以看到,我們不必再爲不同的PO對象編寫不同的RowMapper。

現在開始分析它的具體實現:

根據上述代碼,我們先來分析它的newInstance方法:

public static <T> JdbcRowMapper<T> newInstance (Class<T> mappedClass) {
    return new JdbcRowMapper<T>(mappedClass);
}

這是一個泛型方法,返回值是泛型類:JdbcRowMapper<T>,方法參數是泛型Class對象。這個方法調用了JdbcRowMapper如下的構造方法:

public JdbcRowMapper(Class<T> mappedClass) {
    initialize(mappedClass);
}

繼續跟蹤,initialize方法:(核心方法之一)

  • initialize方法的作用:
  • 在說initialize方法的作用之前,我們先要知道什麼是PO。之前我所使用的Java Bean爲什麼都以PO爲後綴?簡單來說,這是Java Bean與持久化層之間的一層規約。這層規約可以簡單的概述爲:數據庫中表字段的命名方式都以下劃線分割單詞,而Java Bean中則是以駝峯式命名,並且,每個PO對象基本對應一張數據庫表。就拿BookInfoPO中的private int pkId屬性來說,它對應的就是數據庫表book_info中的pk_id字段。這裏涉及到了數據庫建表時的規範,我們之後再說。目前你就先這樣記住。
  • 有了這層規約,我們在封裝RowMapper的時候,就可以通過一些邏輯代碼,將Java Bean中的實例字段名轉換爲數據庫表中相應的字段名,也就爲我們的下一個方法:把從數據庫表中讀取到的數據填充到Java Bean中的相應字段做了鋪墊。
  • initialize方法的實現思路:
  1. 通過BeanUtils.getPropertyDescriptor()得到當前JavaBean(mappedClass對應的PO)的PropertyDescriptor數組
  2. 對PropertyDescriptor數組進行遍歷,拿到每一個實例變量的變量名
  3. 對變量名做相應轉換,轉爲對應的數據庫表字段名
  4. 將這些名字保存在合適的數據結構中,供接下來的mapRow方法使用(JdbcRowMapper中真正從數據庫中讀取所需數據的方法)

有了實現思路,那麼接下來看代碼實現:

protected void initialize(Class<T> mappedClass) {
    // 以下三個變量都是實例變量,在這裏進行初始化
    this.mappedClass = mappedClass;
    this.mappedFileds = new HashMap<String, PropertyDescriptor>();
    this.mappedProperties = new HashSet<String>();

    /** 
     * 通過BeanUtils.getPropertyDescriptor()得到當前JavaBean(mappedClass對應的PO)的PropertyDescriptor數組,PropertyDescriptors類是Java內省類庫的一個類。
     * Java JDK中提供了一套API用來訪問某個對象屬性的getter/setter方法,這就是內省。
     */

    // 獲取bean的所有屬性(也就是實例變量)列表   
    PropertyDescriptor[] propertyDescriptors = BeanUtils.getPropertyDescriptors(mappedClass);

    // 遍歷屬性列表
    for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
        // propertyDescriptor.getWriteMethod()獲得用於寫入屬性值的方法
        if (propertyDescriptor.getWriteMethod() != null) {
            // 得到此屬性名(變量名)
            String name = propertyDescriptor.getName();

            try {
                // 通過反射取得Class里名爲name的字段信息
                Field field = mappedClass.getDeclaredField(name);
                if (field != null) {
                    // 得到該屬性(field)上存在的註解值(下一個代碼給出示例)
                    Column column = field.getAnnotation(Column.class);

                    // 如果取得的column值不爲null, 那就給name賦值column.name
                    if (column != null) {
                        name = column.name();
                    }
                }
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            }

            // 將<屬性名字, 屬性>加入mappedFileds中
            this.mappedFileds.put(lowerCaseName(name), propertyDescriptor);

            // 不使用自定義註解,使用代碼將所得name轉換爲對應數據庫表字段
            String underscoredName = underscoreName(name);

            // 如果兩個不等,則將nderscoredName也添加進mappedFileds,相當於一種容錯機制
            if (!lowerCaseName(name).equals(underscoredName)) {
                this.mappedFileds.put(underscoredName, propertyDescriptor);
            }

            // 將屬性名添加至mappedProperties
            this.mappedProperties.add(name);
        }
    }
}

這就是initialize方法。接下來看一下其中所用到的自定義註解,也就是對這一行代碼的解釋:Column column = field.getAnnotation(Column.class)

  • 定義自定義註解:
/**
 * @Author: dela
 * @Date: 
 * @Modified By:
 * @Description: @Retention是JDK的元註解, 當RetentionPolicy取值爲RUNTIME的時候,
 * 意味着編譯器將Annotation記錄在class文件中, 當Java文件運行的時候,
 * JVM也可以獲取Annotation的信息, 程序可以通過反射獲取該Annotation的信息.
 */

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
// @Target也是JDK的一個元註解, 當ElementType取不同值的時候, 意味着這個註解的作用域也不同,
// 比如, 當ElementType取TYPE的時候, 說明這個註解用於類/接口/枚舉定義

public @interface Table {
    // 數據庫中表的名字
    String name();
}
/**
 * Created by dela on 12/20/17.
 */

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Column {
    // 數據庫中的表上的字段的名字
    String name();
}
  • 自定義註解在BookInfoPO中的應用:
/**
 * Created by dela on 11/22/17.
 */

// 書籍信息表
@Table(name = "book_info")
public class BookInfoPO implements Comparable<BookInfoPO> {
    @Column(name = "pk_id")
    private int pkId;                   // 無意義主鍵
    @Column(name = "ugk_name")
    private String ugkName;             // 書名(組合索引)
    @Column(name = "author")
    private String author;              // 作者
    @Column(name = "ugk_uid")
    private int ugkUid;                 // 所有者(即用戶表裏的id)(組合索引)
    @Column(name = "amount")
    private int amount;                 // 數量
    @Column(name = "upload_date")
    private String uploadDate;          // 上傳時間
    @Column(name = "book_picture")
    private String bookPicture;         // 書籍照片
    @Column(name = "describ")
    private String describ;             // 書籍描述

    public BookInfoPO() { }

    ... ...
}
  • 在initialize方法中還有一個underscoreName():(此方法就不打註解了)
protected String underscoreName(String name) {
    if (!StringUtils.hasLength(name)) {
        return "";
    }

    StringBuilder result = new StringBuilder();
    result.append(lowerCaseName(name.substring(0, 1)));
    for (int i = 1; i < name.length(); i++) {
        String s = name.substring(i, i + 1);
        String slc = lowerCaseName(s);

        if (!s.equals(slc)) {
            result.append("_").append(slc);
        } else {
            result.append(s);
        }
    }

    return result.toString();
}

Ok,接下來我們繼續探究核心方法二:mapRow()

剛說過initialize方法是爲了使mapRow方法可以把從數據庫表中讀取到的結果填充到Java Bean相應的字段上而做的一個鋪墊。那麼mapRow必定實現瞭如下功能:

  1. 從數據庫表中讀取結果集
  2. 將結果集中的元素填充到相應的Java Bean中(別忘了initialize方法已經幫我們將Java Bean中的實例變量名轉換爲了數據庫表中相應的字段名)

明白了mapRow中實現的大致功能,那麼我們直接來看源碼:

public T mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
    // Spring的斷言表達式, 傳入的Java Bean的Class對象不能爲空
    Assert.state(this.mappedClass != null, "Mapped class was not specified");
        // 實例化一個Java Bean
        T mappedObject = BeanUtils.instantiate(this.mappedClass);

        // BeanWrapper可以設置及訪問被包裝對象的屬性值
        BeanWrapper beanWrapper = PropertyAccessorFactory.forBeanPropertyAccess(mappedObject);

        // 從resultSet中拿到有關此數據庫表的元數據(字段名稱、類型以及數目等)
        ResultSetMetaData resultSetMetaData = resultSet.getMetaData();
        // 得到此數據庫表的字段數目
        int columnCount = resultSetMetaData.getColumnCount();

        for (int index = 1; index <= columnCount; index++) {
            // 得到數據庫表中當前字段名
            String column = JdbcUtils.lookupColumnName(resultSetMetaData, index);
            String field = lowerCaseName(column.replaceAll(" ", ""));

            // 根據數據庫表中的字段名拿到Java Bean中對應實例字段屬性的描述
            PropertyDescriptor propertyDescriptor = this.mappedFileds.get(field);
            if (propertyDescriptor != null) {
                try {
                    // 得到該field所對應的數據庫表中字段所對應的值(下一個代碼給出示例)
                    Object value = getColumnValue(resultSet, index, propertyDescriptor);

                    ... ...

                    try {
                        // 將得到值填充到Java Bean中相應的實例變量上
                        beanWrapper.setPropertyValue(propertyDescriptor.getName(), value);
                    } catch (TypeMismatchException ex) {
                        ... ...
                    }
                } catch (NotWritablePropertyException ex) {
                    ... ...
                }
            } else {
                // 沒有發現相應實例字段的屬性描述
                ... ...
            }
        }

        return mappedObject;
    }

getColumnValue()的源碼如下:

// 得到數據庫表中字段(column)對應的值(value)
protected Object getColumnValue(ResultSet resultSet, int index, PropertyDescriptor propertyDescriptor) throws SQLException {
    return JdbcUtils.getResultSetValue(resultSet, index, propertyDescriptor.getPropertyType());
}

設計數據庫

由於博主負責了本項目的數據庫設計,因此在這裏有一點心得想分享給大家。

首先是MySQL的建表規範(當然並不絕對):

  • 主鍵一律無意義,就算有意義,也必須是以後不會被更新,修改並且是自增的字段。命名規範一律是pk_id,數據類型爲int unsigned,字段not null。
  • 唯一索引命名一律以uk_爲前綴,唯一索引並不以提高查詢速率爲主要目的,主要是進行唯一性約束。
  • 唯一組合索引命名一律以ugk_爲前綴,目的同上,注意最左前綴的問題。
    由於主鍵一律設置的是無意義的自增字段,所以對於有外鍵約束的字段,只設置了級聯刪除(只更新父表的主鍵會存在外鍵約束)。
  • 日期字段的數據類型一律爲datetime。
  • 所有表的字段設置爲not null,數字默認值爲0,字符串默認值爲”,datetime沒有設置默認值,因此在後臺必須處理時間問題。

當初在設計本項目的數據庫時分別使用了主鍵與外鍵約束、唯一索引與組合索引、級聯更新與級聯刪除等技術。對於這些技術的講解在博主所置頂的幾篇博客中就可以看到,因此不再講解。

至於數據庫結構與數據的SQL文件,在本人GitHub的README中有提供,感興趣的可以去下載,源碼地址在本篇博客開始已經給出~

Ok,XiyouLinux Group圖書借閱平臺的實現分析至此結束!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章