:基于Session的內存購物車實現(xiàn)與避坑指南)
簡介這是一套基于JavaWeb技術實現(xiàn)的簡易購物車系統(tǒng)完整源碼適合Java初學者及希望鞏固Web開發(fā)基礎的中級開發(fā)者。代碼圍繞Servlet與JSP、Session會話管理、JDBC數(shù)據(jù)庫交互、MVC設計模式、JSTL與EL表達式等核心知識點展開覆蓋商品展示、加入購物車、修改數(shù)量、刪除商品及訂單處理等典型業(yè)務流程并附有開發(fā)文檔與項目安裝說明能幫助讀者理清從頁面請求到數(shù)據(jù)持久化的完整鏈路。壓縮包共54個文件包含14個Java源文件、14個編譯后的class文件、6個JSP頁面、數(shù)據(jù)庫相關配置及properties、xml等輔助資源整體大小約1.58MB目錄結構清晰適合直接導入Eclipse或IntelliJ IDEA配合Tomcat運行學習。已有4125人瀏覽學習。通過研讀源碼和筆記可快速掌握JavaWeb項目從環(huán)境搭建、數(shù)據(jù)庫設計到功能實現(xiàn)的常見思路對提升實際項目開發(fā)能力很有幫助。1. 什么是 javaWeb 簡易購物車它不是一張表而是會話里的一塊內存很多人第一次接觸 javaWeb 購物車時會下意識地想把「購物車」設計成數(shù)據(jù)庫中的一張表加購就是 insert改數(shù)量就是 update刪除就是 delete。這個思路不算錯但對一個網頁端的簡易購物車來說它把問題想復雜了。真正的購物車在絕大多數(shù)教學項目和基礎實踐中只是一個放在 HttpSession 里的內存結構。用戶點一下「加入購物車」服務端往 Session 里塞一個 HashMap用戶清空購物車服務端把這個 Session 屬性刪掉。整個過程不碰數(shù)據(jù)庫也不落盤。這么設計的第一層原因是簡單不需要額外建表、不需要處理事務、不需要考慮未登錄用戶怎么關聯(lián)購物車數(shù)據(jù)一個會話對象全搞定了。第二層原因是貼合 HTTP 的無狀態(tài)模型——購物車本質是「某一次瀏覽過程里的臨時意圖」Session 天生就是為這種臨時狀態(tài)設計的。給剛入門 JavaWeb 的人做一個可以復現(xiàn)、能看清請求流轉路徑的項目用 Session 存購物車比引入 Redis 或數(shù)據(jù)庫表都直觀得多。這篇筆記面向的讀者很明確正在做 JavaWeb 課設或入門練手的人、需要給現(xiàn)有管理系統(tǒng)補一個購物車模塊的人、被教科書里各種「MVC 三層架構」繞暈的初學者。我會先用最樸素的方式講清楚購物車應該長什么樣然后給出一個能直接跑起來的最小工程再把加購、改數(shù)量、刪商品這些操作的代碼逐段拆開最后列幾個我實際踩過的坑。這套方案代碼量不大但該有的請求、轉發(fā)、重定向、會話、JDBC 讀寫都齊了值得照著做一遍。2. 技術選型與職責拆解為什么 JSP Servlet JDBC 就夠2.1 購物車為什么是內存結構而不是數(shù)據(jù)庫表購物車里的數(shù)據(jù)有一個鮮明特點生命周期極短的臨時性。用戶打開瀏覽器訪問一次購物網站往購物車里加了幾件商品然后關閉頁面走人這條數(shù)據(jù)就不再有任何意義了。如果把它落進數(shù)據(jù)庫你不僅要為每個匿名訪客建一張購物車明細表還要在會話過期時寫定時任務去清理孤兒數(shù)據(jù)否則表會越攢越臟。為一個簡易項目引入這種復雜度不劃算。常見的做法是用 HttpSession 的 setAttribute 方法存放一個 Map商品Id, 數(shù)量。同一個商品再次加購時只要數(shù)量加 1 而不是重新插入。這個 Map 的生命周期由容器管理默認超時時間一般是 30 分鐘用戶關掉瀏覽器后 Session 也隨之失效。要展示購物車里的商品詳情名稱、單價、小計再通過商品 Id 去查一遍商品表即可。也就是說購物車只存 Id 和數(shù)量兩個字段商品的其他信息仍然以數(shù)據(jù)庫為準。為什么不是 CookieCookie 也存在瀏覽器端看似也能存購物車但 Cookie 的容量限制只有 4KB 左右而一個購物車里動輒幾十件商品的 Id 列表很容易撐爆這個上限。更重要的是 Cookie 里的數(shù)據(jù)是明文的用戶可以隨意篡改。Session 把數(shù)據(jù)留在服務端客戶端只拿一個看不見內容物的會話 ID安全性和容量都更合適。簡易購物車用 Session是成本和安全性之間的平衡點不是沒考慮過其他方案。還有一個實操上的好處調試直觀。你用 Servlet 處理加購請求時直接打印 session.getAttribute(cart) 就能看到當前購物車的內容不用連數(shù)據(jù)庫執(zhí)行 select。對于還沒把調試工具鏈玩熟的初學者這個「黑匣子」能被直接看到內部狀態(tài)排查問題的成本低得多。2.2 職責拆解把 JSP、Servlet、JDBC 各放對位置一個可以交差的簡易購物車項目至少要分出四層職責但每層都很薄。第一層是 JSP 頁面負責展示商品列表和購物車內容第二層是 Servlet負責接收請求、調用業(yè)務方法、決定跳轉方向第三層是一個購物車相關的 JavaBeanCartItem 和 Cart封裝購物車的存儲結構第四層是商品數(shù)據(jù)的 JDBC 訪問代碼負責把商品表里的記錄查出來。不少人會把 JDBC 代碼直接寫進 Servlet 的 doGet 方法里這種做法在小項目里能跑但會讓你后面改數(shù)據(jù)庫連接配置時到處找代碼。我一般會單獨建一個類哪怕這個類只有一個靜態(tài)方法也要和 Servlet 分開。同理JSP 頁面里不要出現(xiàn) Java 代碼片段。用 EL 表達式讀購物車數(shù)據(jù)用 JSTL 的 c:forEach 遍歷商品列表這兩樣東西是 JavaWeb 入門階段必須順手學會的——它們能讓你把頁面代碼控制在二十行以內。關于版本選擇Servlet 可以用 3.1 或 4.0JSP 用 2.3 以上JDK 用 8 或 11Tomcat 用 9.x。這些版本都是經過多年驗證的穩(wěn)定組合網上能找到大量兼容性說明。不要用 Servlet 5.0 以上的版本因為從 5.0 開始采用了 Jakarta EE 命名空間與老教材里的 javax.servlet 包名不兼容會讓照著舊教程寫代碼的初學者在第一關就翻車。# 這里給一個最小目錄結構之后照著建就不會亂 src/main/java ├── com.demo.entity # CartItem、Cart、Product ├── com.demo.dao # 商品表訪問 ├── com.demo.web # CartServlet、GoodsServlet src/main/webapp ├── WEB-INF/web.xml # Servlet 映射 ├── goodsList.jsp # 商品列表頁 └── cart.jsp # 購物車頁面建目錄的時候包名盡量不要用默認包否則 Servlet 注解掃描和 web.xml 配置都可能出問題。用公司域名反寫或者干脆用 com.demo 這種形似域名反寫的結構是最穩(wěn)妥的選擇。3. 最小可運行工程目錄、依賴與數(shù)據(jù)庫初始化3.1 用 Maven 搭好骨架pom.xml 里的依賴清單我會用 Maven 來管理這個項目。原因不是它多高級而是它能幫你鎖定依賴版本避免手動往 WEB-INF/lib 里拷 jar 包時漏掉某一個、運行期才報 ClassNotFoundException。對于一個入門項目pom.xml 只需包含 servlet-api、jsp-api 和 mysql-connector-java 三樣其中前兩個還要顯式聲明 provided scope因為 Tomcat 容器本身自帶這兩個庫打包時不需要重復打入。dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies第二個依賴記得補上 jstl 和 taglibs因為頁面循環(huán)要用 c:forEach沒有 JSTL 你只能退回 Java 代碼片段頁面會變得很難讀。版本號不用追新能穩(wěn)定工作的老版本反而更省心。mysql-connector 用 8.0.x 配合 MySQL 5.7 或 8.0 都能正常連接驅動類全名是 com.mysql.cj.jdbc.Driver這個寫法在 8.x 里已經是標配。dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependencyMaven 配好后在項目根目錄執(zhí)行 mvn package確認能打出 war 包。這一步通過說明依賴引入沒問題后續(xù)任何報錯都不太可能與缺 jar 有關了。3.2 商品表與初始化數(shù)據(jù)購物車得有東西可加購物車本身不存商品明細但頁面得能從商品表查出可加購的物品。我一般只建一張 goods 表包含 id、name、price、stock 四個字段。price 用 decimal(10,2) 而不是 float避免浮點精度在結算小計時出現(xiàn) 0.30000000000000004 這種尷尬。CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 10 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO goods (name, price, stock) VALUES (機械鍵盤, 199.00, 50); INSERT INTO goods (name, price, stock) VALUES (游戲鼠標, 89.00, 80); INSERT INTO goods (name, price, stock) VALUES (顯示器支架, 129.00, 30);字符集用 utf8mb4 而不是 utf8是一個血淚經驗utf8 在 MySQL 里實際是 utf8mb3存不了 emoji也存不了部分生僻漢字。你寫個「」進商品名舊字符集直接報錯。建表時把字符集定了以后少一堆麻煩。數(shù)據(jù)庫連接信息按慣例寫在 src/main/resources 下的 jdbc.properties 里然后寫一個簡單的 DBUtil 類讀取配置、獲取連接。連接串里必須帶上 useUnicodetruecharacterEncodingutf8否則中文參數(shù)往數(shù)據(jù)庫里寫會亂碼。時區(qū)參數(shù) serverTimezoneAsia/Shanghai 在 MySQL 8.x 下是必須的不加會報時區(qū)錯誤。Class.forName(com.mysql.cj.jdbc.Driver); String url jdbc:mysql://localhost:3306/shopdb?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai; conn DriverManager.getConnection(url, root, 你的密碼);注意 Class.forName 在連接池或較新驅動里已經不是必寫項但對初學者來說保留更穩(wěn)。不需要理解它背后的 SPI 機制知道這行代碼是觸發(fā)驅動類加載即可。4. 購物車核心邏輯的實現(xiàn)加入、改數(shù)量、刪除4.1 定義 CartItem 和 Cart會話里存什么購物車里每一行是一條 CartItem至少要有商品 id、商品名稱、單價、加購數(shù)量四個屬性再加一個 getSubtotal() 方法計算小計。商品名稱和單價是在加購時從數(shù)據(jù)庫查出來的這樣頁面展示購物車時不必再查一次數(shù)據(jù)庫性能好一些代碼也簡單。public class CartItem { private int id; // 商品 id private String name; // 商品名稱 private double price; // 單價 private int count; // 加購數(shù)量 public double getSubtotal() { return price * count; } // 構造方法、getter/setter 省略 }Cart 類不直接操縱 Session它只負責持有一個 MapInteger, CartItem。加購時判斷 map 里有沒有同一個 id有就把 count 加 1沒有就 new 一個 CartItem 塞進去。把業(yè)務放在這個類里Servlet 就只做參數(shù)解析和跳轉兩件事后期如果想換成 Redis 存儲或其他方式只需要替換 Cart 的實現(xiàn)控制層可以不動。public class Cart { private MapInteger, CartItem items new LinkedHashMap(); public void addToCart(CartItem item) { CartItem exist items.get(item.getId()); if (exist ! null) { exist.setCount(exist.getCount() 1); } else { items.put(item.getId(), item); } } public ListCartItem list() { return new ArrayList(items.values()); } // remove、clear 方法類似不再贅述 }用 LinkedHashMap 而不是 HashMap 是有意為之它保證遍歷順序和插入順序一致購物車頁面顯示的順序就和用戶加購的順序一致了。這個小細節(jié)很多人不注意但頁面效果差異明顯。4.2 加購請求的完整鏈路Servlet 與 JSP 之間的配合加購操作的前端只是一個鏈接或者一個按鈕點擊后攜帶商品 id 請求 CartServlet。Servlet 根據(jù) action 參數(shù)分發(fā)處理add 是加購update 是改數(shù)量delete 是刪一項clear 是清空。最后統(tǒng)一重定向回商品列表頁或購物車頁而不是轉發(fā)到 JSP。WebServlet(/cart) public class CartServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } if (add.equals(action)) { int id Integer.parseInt(req.getParameter(id)); Product p ProductDao.findById(id); CartItem item new CartItem(p.getId(), p.getName(), p.getPrice(), 1); cart.addToCart(item); } else if (delete.equals(action)) { cart.remove(Integer.parseInt(req.getParameter(id))); } resp.sendRedirect(req.getContextPath() /goods); } }這里要解釋為什么用重定向而不是轉發(fā)轉發(fā)是服務端內部跳轉地址欄不變重定向是瀏覽器重新發(fā)起一次請求地址欄會變成目標地址。如果加購后用轉發(fā)回到商品列表頁用戶按 F5 刷新時會再次提交上一次的加購請求商品數(shù)量會被反復疊加。重定向正是解決「刷新導致重復提交」的最樸素方案。ProductDao.findById 里的 JDBC 代碼老生常談建立連接、寫 SQL、填參數(shù)、執(zhí)行查詢、封裝結果集、關資源。唯一要強調的是 finally 里逐個關閉 ResultSet、Statement、Connection并且要按這個順序關否則連接不釋放多刷新幾次頁面就會出現(xiàn)連接超時。public static Product findById(int id) { String sql SELECT id, name, price, stock FROM goods WHERE id?; // PreparedStatement 預編譯防 SQL 注入 // 結果封裝成 Product 對象返回 }在頁面端遍歷購物車只需這樣一段 EL JSTLc:forEach items${sessionScope.cart.list()} varitem tr td${item.name}/td td${item.price}/td td a hrefcart?actionupdateid${item.id}count1/a ${item.count} a hrefcart?actionupdateid${item.id}count-1-/a /td td${item.subtotal}/td tda hrefcart?actiondeleteid${item.id}刪除/a/td /tr /c:forEach需要留意的是 sessionScope 的寫法只有加上它才能明確告訴 EL 去 Session 里取 cart而不是去請求域或頁面域里找。這個小細節(jié)如果漏掉頁面上的購物車一直是空的排查時容易一頭霧水。改數(shù)量操作不用單獨加一個文本框直接在現(xiàn)有數(shù)字旁邊放加減兩個鏈接通過 count 參數(shù)傳 ±1后端拿到后修改數(shù)量這樣就避免了表單提交要處理的空值問題。5. 五個高頻坑Session 丟失與亂碼排查記錄5.1 商品數(shù)量莫名其妙疊加刷新一次漲一次現(xiàn)象加入一件商品后點瀏覽器刷新購物車里這件商品的數(shù)量每次 1無法通過刷新讓頁面停留在原狀。原因加購請求用的是 doGet瀏覽器地址欄的請求 URL 不會因為頁面跳轉而改變刷新時瀏覽器原樣重發(fā)上一次請求。如果加購后是轉發(fā)到列表頁這個請求會被反復提交每次都在原數(shù)量上加 1。另外頁面里如果用了 這種方式直接提交 GET 請求刷新重放的幾率也高。解決把加購處理結束后的響應改為重定向即 resp.sendRedirect() 返回列表頁。重定向會讓地址欄變成列表頁的 URL刷新時提交的是列表頁的查詢請求而不是加購請求。對于數(shù)量變化類操作更穩(wěn)妥的是改用 POST 提交并且處理完后仍然重定向雙保險。我習慣的規(guī)則是所有寫操作一律 POST 重定向讀操作才用 GET 轉發(fā)。5.2 加購時商品名變成 或亂碼現(xiàn)象使用 Tomcat 8 及以下版本運行項目時請求參數(shù)里的中文商品名在購物車里顯示為一堆問號或亂碼。原因GET 請求的中文參數(shù)默認按 ISO-8859-1 解碼超過這個字符集范圍的字符全部變成問號。Tomcat 8 及以上版本默認 URI 編碼是 UTF-8問題不大但很多教材配套的教學環(huán)境還在用 Tomcat 7。解決在請求到達 Servlet 之前先指定請求編碼或者在 Tomcat 的 server.xml 的 Connector 配置里加上 URIEncodingUTF-8。代碼層面最省事的做法是寫一個 Filter在 doFilter 開頭設置 req.setCharacterEncoding(UTF-8)然后 chain.doFilter 放行。注意設置編碼動作必須發(fā)生在讀取任何參數(shù)之前否則已解碼的參數(shù)無法回頭修正。同時JSP 頁面頂部寫 pageEncoding“UTF-8”數(shù)據(jù)庫連接串里帶 characterEncodingutf8三層一起到位才算是徹底解決。public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }5.3 同一個商品加購后數(shù)量沒有 1反而出現(xiàn)兩行現(xiàn)象往購物車里加一件商品兩次購物車頁面出現(xiàn)兩行同名的商品而不是一行數(shù)量為 2。原因CartItem 里的 equals 和 hashCode 沒有重寫導致 MapInteger, CartItem 的 key 是商品 idInteger 包裝類型時不存在重復問題但如果你把 Cart 實現(xiàn)里的 key 換成了 CartItem 對象且沒有重寫 equals那么每次 new 的 CartItem 都是不同對象永遠不能命中已有的項。初學者最容易在這里寫錯把 key 類型和實際類型混在一起。解決嚴格保持 Map 的 key 類型為 Integer 商品 id判斷是否存在時用 id 作為查找依據(jù)而不是把 CartItem 本身當作 key。如果你的設計里一定要用對象做 key那么必須重寫 equals 和 hashCode且只比較商品 id。經驗之談能用基本類型包裝類做 key 就不要用對象少寫代碼就是少出錯。5.4 頁面能打開但購物車數(shù)據(jù)顯示不完整點刪除報空指針現(xiàn)象列表頁能正常顯示商品但進入購物車頁后部分字段空白點擊刪除某個商品時后臺拋 NullPointerException頁面白屏。原因這是典型的用戶換了個瀏覽器或者清了 Cookie 導致 Session 丟失。Session 是靠 Cookie 里的 JSESSIONID 維持的瀏覽器禁止 Cookie 后每次請求都是新的會話cart 屬性自然為 null。另一個常見情況是先直接打開了購物車頁還沒通過列表頁加購過任何商品此時 session 里根本沒有 cart 這個屬性。解決在購物車頁面的 Servlet 里做空值兜底取出 cart 為 null 時就 new 一個空的 Cart 放入 session而不是直接拿 null 去調方法。頁面端為了優(yōu)雅可以在購物車為空時提示「還沒有商品去逛逛」而不是渲染一個空表格。這段邏輯兩三行就能寫完但能擋住大部分線上白屏。Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); }5.5 修改數(shù)量時輸入了負數(shù)或 0購物車里出現(xiàn)離譜數(shù)據(jù)現(xiàn)象用戶通過加減鏈接操作數(shù)量理論上每次只傳 ±1但如果有人手工修改 URL 參數(shù)傳 count-999購物車里的數(shù)量就變成了負數(shù)。原因后端缺少兜底校驗拿到參數(shù)后直接運算。簡易項目通常不面對惡意攻擊但一個「不小心手滑」的普通用戶也能制造同樣的臟數(shù)據(jù)。負數(shù)的購物車數(shù)量會導致購物車總計出現(xiàn)負數(shù)展示上非常難看計算結算金額時還會引發(fā)邏輯混亂。解決在后端更新數(shù)量之后、保存之前做一次最小值校驗數(shù)量小于 1 時直接刪除這一項或者重置為 1。數(shù)量大于庫存時按庫存上限截斷。這套校驗邏輯放在 Cart 類的 updateCount 方法里保證任何入口進來的數(shù)據(jù)都會被兜住。類似的兜底同樣適用于商品 id 參數(shù)Integer.parseInt 前先做一次正則匹配或 try-catch避免非數(shù)字參數(shù)直接讓 Servlet 拋 NumberFormatException。6. 從「能跑」到「能交差」的進階調整到此一個基于 Session 存儲、JSP Servlet 展示、JDBC 讀商品表的簡易購物車已經完整跑通了。如果做完這個還覺得不過癮或者這是你的課程設計需要「加亮點」下面幾個方向是我會優(yōu)先考慮的按性價比從高到低排列。第一個值得改的是把商品詳情也納入購物車展示。當前方案在加購時把商品名和單價存進了 CartItem這有個隱患后臺改了商品價格購物車里的舊價不會自動更新。進階做法是購物車只存 id 和數(shù)量每次渲染購物車頁時用 id 批量查一次商品表詳情實時對齊。代價是每次打開購物車多一次查詢但換來的是價格數(shù)據(jù)的準確性。這一步改動不大但能把「購物車是臨時視圖」這個理念貫徹到位。第二個方向是加上數(shù)量校驗與庫存聯(lián)動。在 updateCount 方法里查一下當前商品的庫存加購數(shù)量超過庫存時彈出提示不允許繼續(xù)添加。這樣即使沒人惡意攻擊也能擋住誤操作頁面體驗感提升明顯。庫存扣減可以放在「結算」動作里做如果沒有做結算功能至少要做到下單時二次校驗防止超賣——雖然簡易項目通常不需要真上鎖但提前把庫存校驗邏輯埋好后續(xù)接支付寶或微信支付時會省很多事。第三個方向是引入 Cookie 持久化未登錄用戶的購物車。做法是用戶未登錄時把購物車編碼為 JSON 字符串寫入 Cookie登錄后將 Cookie 中的內容合并進賬戶的購物車數(shù)據(jù)。這個功能在真實電商里是標配但實現(xiàn)起來涉及 JSON 序列化、Cookie 編解碼、合并策略量不小。我的建議是如果做的是課設這個方向屬于加分項但投入產出比一般把 Session 版本打磨到極致已經足夠如果是在公司里做企業(yè)級項目這幾乎是硬需求。我在經歷模擬項目X的時候一開始也想著把所有狀態(tài)都放進數(shù)據(jù)庫后來發(fā)現(xiàn)會話過期時數(shù)據(jù)庫里堆滿了沒人認領的臨時記錄清洗麻煩不說還拖慢查詢。把購物車放 Session 后又踩過刷新重復提交的坑被 QA 同學反復反饋「加一次變兩次」?!笇懖僮骱笠宦芍囟ㄏ颉惯@個習慣就是那次被磨出來的?;仡^來看做簡易購物車最有價值的收獲并不是學會了幾個標簽和 API而是真正理解了 HTTP 無狀態(tài)和 Session 之間的關系。如果你照著上面的代碼跑通了建議你試著改一個功能來驗證自己是否真理解把「加入購物車」改成「商品詳情頁內選擇數(shù)量后加入」看看參數(shù)傳遞和數(shù)量疊加邏輯會出現(xiàn)哪些新問題。這個改動不復雜但能考驗你對整個請求鏈路的掌握程度。希望幫到你。本文還有配套的精品資源點擊獲取