項目地址中包含了一份README,因此對於項目的介紹省去部分內容。這篇博客,主要講述項目中各個模塊的實現細節。
項目概述及成果
首先將本項目使用到技術羅列出來:
- 使用Spring + Spring MVC進行後臺開發
- 使用Bootstrap和jQuery框架進行前端開發
- 使用自定義註解與自定義的JdbcRowMapper簡化JdbcTemplate對數據庫的操作
- 使用騰訊雲的對象存儲服務進行圖書照片的遠程存儲
- 使用MD5加密算法對用戶密碼在後臺進行加密存儲
- 使用過濾器進行一個會話中的身份校驗
- 手動從Spring容器中獲取bean
- 數據庫設計中的諸多細節… …
由於前端開發是由團隊中的其他人在負責,在加上博主對前端這塊並不瞭解,因此本篇博客並不討論有關第二點技術實現上的細節。
本項目如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實現)
我們也很容易判斷出哪種情況下何種方法最優:
如果數據量較小,使用假分頁的效果會更優;如果數據量龐大,使用真分頁的效果更優。
本項目使用的是“真分頁”,我們接下來看一下實現思路與實現代碼:
實現思路:
- 首先我們需要一個存儲頁面信息的Java Bean,也就是傳統的Java對象
- 使用GET方法進行頁面跳轉請求,也就是說,我們可以從URL中得到當前頁面是第幾頁
- 在後臺中進行邏輯構造,將Java Bean中的實例字段進行部分(完全)填充
- 使用Java Bean所提供的頁面信息,構造相應的SQL語句,拿到當前頁數據
- 使用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}">«</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}">«</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}">»</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}">»</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還要新創建一個HttpSessionrequest.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中的使用,則要簡單許多:
- 通過MessageDigest.getInstance()確定加密算法,MessageDigest不止提供MD5
- 調用
update(byte[] input)
對指定的byte數組更新摘要- 執行
digest()
方法進行哈希計算。在調用此方法之後,摘要被重置- 對第三步返回的結果進行處理: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數據庫中如何存儲一個樹狀結構而進行一個簡單的介紹。
我在項目中設計的存儲結構並不高效,是一種最簡單且基本的實現。在網上有很多結構良好且性能高效的樹形結構的數據庫表設計,大家可以查閱一些相關資料。
對比上面的效果展示圖,我的標籤分類其實就是三層樹深度:
- 根節點(唯一)
- 一級標籤(大數據與雲計算… …)(多節點)
- 二級標籤(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給我們提供了文件上傳功能(兩種使用形式):
- 給控制器方法參數上添加@RequestPart註解,參數類型爲字節數組
- 給控制器方法參數上添加@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")
);
}
}
}
對上述代碼有幾點說明:
- JdbcOperations是一個接口,定義了JdbcTemplate所實現的操作。通過注入JdbcOperations從而使JdbcSpitterRepository與JdbcTemplate保持了鬆耦合
- 使用RowMapper對Spitter對象進行填充,最後得到從數據庫中查詢到的結果集合
- 使用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方法的實現思路:
- 通過
BeanUtils.getPropertyDescriptor()
得到當前JavaBean(mappedClass對應的PO)的PropertyDescriptor數組- 對PropertyDescriptor數組進行遍歷,拿到每一個實例變量的變量名
- 對變量名做相應轉換,轉爲對應的數據庫表字段名
- 將這些名字保存在合適的數據結構中,供接下來的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必定實現瞭如下功能:
- 從數據庫表中讀取結果集
- 將結果集中的元素填充到相應的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圖書借閱平臺的實現分析至此結束!