拆解:從依賴配置到部署優(yōu)化)
簡介基于SSM與JSP技術(shù)的二手交易平臺網(wǎng)站項目是一份適合畢業(yè)設(shè)計、課程設(shè)計及期末大作業(yè)的完整JavaWeb源碼包面向需要快速掌握SSM框架的在校生和初級開發(fā)者。壓縮包共2000個文件整體約53.33MB包含716個JS腳本、298個JSP頁面、290個CSS樣式、119個Java類以及117個XML配置文件前后端源碼、數(shù)據(jù)庫腳本、開發(fā)工具均已整理齊全。系統(tǒng)功能覆蓋商品信息展示、交易管理、后臺操作等常見模塊界面簡潔、交互流暢代碼注釋清晰新手可按模塊逐步閱讀項目結(jié)構(gòu)層次分明便于理解SSM框架的MVC分層思想。部署時建議使用MySQL 5.7和Tomcat 7.x或8.x項目已經(jīng)過嚴(yán)格調(diào)試并附有運(yùn)行視頻教學(xué)與部署答疑支持。已有112人參與學(xué)習(xí)下載后可快速參考部署適合作為高分開題或課程設(shè)計的參考項目藍(lán)本。1. 一套 SSMJSPHTML5 的二手商品交易系統(tǒng)到底值不值得拆拆這套基于 SSM JSP HTML5 的二手交易平臺時我先把“238”這個編號忽略掉——它更像是課程設(shè)計里的項目代號真正決定復(fù)現(xiàn)難度的是三個框架的版本協(xié)同、JSP 在 WEB-INF 下的路徑規(guī)則以及 HTML5 本地存儲與后端接口的職責(zé)切分。這套系統(tǒng)的價值在于它完整保留了 Java Web 單體時代的交往模式Spring 管 BeanSpringMVC 管路由MyBatis 管 SQLJSP 負(fù)責(zé)服務(wù)端渲染HTML5 只在交互層補(bǔ)充體驗。如果你正在準(zhǔn)備 Java 課程設(shè)計或者需要把一個老項目改成可運(yùn)行系統(tǒng)這篇正文能幫你把啟動順序、事務(wù)邊界和部署參數(shù)一次理清。2. 從空項目到 SSM 容器Spring、SpringMVC、MyBatis 三條線的縫合法2.1 先把依賴釘死再談代碼搭建 SSM 二手交易系統(tǒng)最容易翻車的是依賴版本互相打架。我一般按“Servlet 容器 → Spring → MyBatis → JSON 庫”的順序鎖版本。以 Tomcat 8.5 JDK 8 為例Maven 里的關(guān)鍵依賴是這樣一組properties spring.version5.1.18.RELEASE/spring.version mybatis.version3.5.6/mybatis.version /properties dependencies !-- Spring MVC 與上下文必須同版本否則容器會報 NoSuchMethodError -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis 官方 Spring 整合包版本要和 mybatis 對齊 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency !-- MySQL 驅(qū)動5.x 驅(qū)動類名是 com.mysql.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version scoperuntime/scope /dependency !-- JSP 標(biāo)簽庫與 APIjsp-api 必須 provided避免和 Tomcat 內(nèi)置類沖突 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjsp-api/artifactId version2.2/version scopeprovided/scope /dependency /dependencies為什么要把版本單獨(dú)抽成 properties因為 Spring 的 webmvc、context、tx 必須同版本MyBatis 3.5.6 與 mybatis-spring 2.0.6 的搭配針對 Spring 5.1 做過官方測試。MySQL 驅(qū)動選 5.1.49 是為了兼容老項目常用的連接串如果數(shù)據(jù)庫是 8.0就把驅(qū)動換成 8.0 系列并把 className 改成com.mysql.cj.jdbc.Driver。jstl 1.2 和 jsp-api 2.2 一個放 classpath、一個用 provided是為了避免 Tomcat 9 下 jar 沖突出現(xiàn)Unable to compile class for JSP的報錯。2.2 web.xml 中的加載順序決定 DispatcherServlet 會不會吞掉 JSPSSM 的入口仍然是 web.xml但不同配置會讓行為差很多。二手交易這類帶大量 JSP 頁面的項目我建議按下面這種結(jié)構(gòu)寫web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 !-- 先創(chuàng)建根容器負(fù)責(zé) service、dao、數(shù)據(jù)源等 -- listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-context.xml/param-value /context-param !-- 再創(chuàng)建 SpringMVC 子容器負(fù)責(zé) controller 與視圖解析 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping !-- 必須放在 DispatcherServlet 之后且映射到 /*否則 POST 提交中文會亂碼 -- filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping /web-app這里的順序很關(guān)鍵ContextLoaderListener先創(chuàng)建根容器DispatcherServlet再創(chuàng)建子容器子容器能訪問父容器的 Bean但父容器看不到子容器。如果url-pattern寫成*.doJSP 頁面里的靜態(tài)資源路徑會好理解但 HTML5 頁面想用干凈的/item/123路由就不方便所以這里用/配合mvc:default-servlet-handler/放行靜態(tài)資源。配置項位置作用context-paramweb.xml指定根容器配置文件路徑ContextLoaderListenerweb.xml創(chuàng)建 service/dao 所在的 Spring 容器DispatcherServletweb.xml創(chuàng)建 controller 所在子容器InternalResourceViewResolverspring-mvc.xml決定 JSP 前綴后綴2.3 Controller 和 Service 分兩個容器掃事務(wù)代理才安全很多課程設(shè)計的 SSM 項目把context:component-scan base-packagecom.shop/同時寫在兩個配置里結(jié)果出現(xiàn)同一個 Service 被兩個容器各實例化一次。SpringMVC 子容器會優(yōu)先用自己容器里的 Service而這個 Service 沒有經(jīng)過 AOP 代理Transactional直接失效。正確做法是在根容器掃排除 Controller!-- spring-context.xml -- context:component-scan base-packagecom.shop context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classorg.apache.commons.dbcp.BasicDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/second_hand?useUnicodetrueamp;characterEncodingUTF-8/ property nameusername valueroot/ property namepassword valueroot/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.shop.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean mybatis:scan base-packagecom.shop.mapper/子容器只掃 Controller!-- spring-mvc.xml -- mvc:annotation-driven/ mvc:default-servlet-handler/ context:component-scan base-packagecom.shop.controller use-default-filtersfalse context:include-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean idviewResolver classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix value.jsp/ /bean分完容器后JSP 頁面要放在webapp/WEB-INF/jsp下用戶直接訪問該路徑會被容器拒絕只能通過 controller 里return item_list轉(zhuǎn)發(fā)到/WEB-INF/jsp/item_list.jsp。這樣既保住了 JSP 服務(wù)端渲染能力又避免了和 HTML5 靜態(tài)資源混在一起。提示如果項目在 IDEA 中直接啟動Artifact 類型一定要選 war exploded否則 JSP 修改后要重啟才會生效。3. 二手交易數(shù)據(jù)模型與 MyBatis 持久層從表結(jié)構(gòu)到動態(tài) SQL3.1 五張表足夠撐起一個交易閉環(huán)把這套系統(tǒng)的業(yè)務(wù)收攏一下核心數(shù)據(jù)模型可以壓縮成五張表用戶、商品、分類、收藏、訂單。二手交易的特點(diǎn)是商品狀態(tài)會從“在售”流轉(zhuǎn)到“已賣出”因此設(shè)計表結(jié)構(gòu)時要特意留狀態(tài)字段而不是在商品被打下架后直接刪除記錄。表名職責(zé)關(guān)鍵字段user用戶注冊與登錄id, username, password_hash, phoneitem商品發(fā)布與檢索id, seller_id, category_id, title, price, stock, statuscategory商品分類樹id, name, parent_idcollect收藏關(guān)系id, user_id, item_id, create_timeorder_info交易訂單id, buyer_id, item_id, amount, status以下是一張可直接建的表字段注釋盡量寫全后面 mapper 寫動態(tài) SQL 時就不用來回翻表結(jié)構(gòu)CREATE TABLE item ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, seller_id INT UNSIGNED NOT NULL COMMENT 賣家用戶ID, category_id INT UNSIGNED NOT NULL COMMENT 商品分類ID, title VARCHAR(120) NOT NULL COMMENT 商品標(biāo)題, price DECIMAL(10,2) NOT NULL COMMENT 出售價格, stock INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 可賣數(shù)量二手商品通常為1, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-在售 1-下架 2-已賣出, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller (seller_id), KEY idx_category_status (category_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;價格必須用DECIMAL(10,2)不能用 FLOAT。float 是近似值訂單金額用浮點(diǎn)會出現(xiàn) 19.99 變成 19.989999 的金額誤差對賬時很難解釋。狀態(tài)字段用 TINYINT 而不是 VARCHAR是為了讓商品列表查詢能用組合索引idx_category_status把分類過濾和狀態(tài)過濾放在同一個索引里。create_time使用DATETIME而不是TIMESTAMP避免 2038 年問題也方便前端直接用字符串展示。3.2 Mapper 接口與 XML 分離查詢條件怎么拼才安全二手商品列表的搜索條件通常包含標(biāo)題關(guān)鍵字、分類、最低價、最高價、狀態(tài)和分頁。如果把這么多條件全塞進(jìn) Controller 再傳給 MapperSQL 會因為AND和WHERE的位置問題反復(fù)報錯。常見做法是定義一個ItemQuery查詢對象只放查詢字段public class ItemQuery { private String title; private Integer categoryId; private BigDecimal minPrice; private BigDecimal maxPrice; private Integer status; private Integer offset; private Integer pageSize; // getter/setter 省略 }接口定義public interface ItemMapper { ListItem selectByCondition(Param(condition) ItemQuery query); int insertItem(Item item); int deductStock(Param(id) Integer id); }對應(yīng) XML 里用動態(tài) SQL 拼接select idselectByCondition resultTypecom.shop.entity.Item SELECT id, seller_id, category_id, title, price, stock, status, create_time FROM item where if testcondition.title ! null and condition.title ! AND title LIKE CONCAT(%, #{condition.title}, %) /if if testcondition.categoryId ! null AND category_id #{condition.categoryId} /if if testcondition.minPrice ! null AND price gt; #{condition.minPrice} /if if testcondition.maxPrice ! null AND price lt; #{condition.maxPrice} /if if testcondition.status ! null AND status #{condition.status} /if /where ORDER BY id DESC LIMIT #{condition.offset}, #{condition.pageSize} /selectwhere標(biāo)簽會自動去掉第一個AND所以每個if判斷里的條件都要寫AND這一步是復(fù)現(xiàn)時最容易抄錯的地方。LIKE CONCAT(%, #{title}, %)把百分號拼進(jìn)參數(shù)里而不是用${condition.title}直接拼接原因是#{}會走 PreparedStatement 參數(shù)占位從根上避免 SQL 注入${}只適合表名、排序字段這類固定白名單位置絕不能用于用戶輸入。3.3 庫存扣減與事務(wù)邊界誰的 Service 該加注解二手交易和普通電商不同多數(shù)商品庫存只有 1但仍然會出現(xiàn)兩個人同時下單的情況。如果不做控制超賣后訂單表里會出現(xiàn)兩個買家都買同一件在售商品。常見做法是事務(wù)加條件更新Service 層這樣寫Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private ItemMapper itemMapper; Transactional(rollbackFor Exception.class) public Integer createOrder(Order order) { int updated itemMapper.deductStock(order.getItemId()); if (updated 0) { throw new BizException(商品已下架或庫存不足); } orderMapper.insertOrder(order); return order.getId(); } }deductStock的 SQL 是UPDATE item SET stock stock - 1 WHERE id #{id} AND stock 0。影響行數(shù)為 0 表示庫存不足直接拋業(yè)務(wù)異常讓事務(wù)整體回滾。這種方法比先 SELECT 再 UPDATE 更穩(wěn)避免兩個請求同時把 stock 讀到 1后一個提交時覆蓋前一個的扣減。Transactional放在 Service 而不是 Controller是因為事務(wù)要同時覆蓋deductStock和insertOrder兩個 Mapper 方法的調(diào)用范圍。如果在 Controller 層加注解代理對象無效異常不會觸發(fā)回滾。注意MyBatis 的if只做動態(tài)拼接不做類型轉(zhuǎn)換日期范圍需要現(xiàn)在 Service 里把 Date 轉(zhuǎn)成字符串再傳入。4. JSP 頁面與 HTML5 交互商品搜索、發(fā)布表單和本地緩存4.1 JSP 的標(biāo)簽循環(huán)與 EL 取值SSM 項目里 JSP 負(fù)責(zé)服務(wù)端渲染商品列表頁的數(shù)據(jù)在 Controller 存入Model然后由 JSP 用 JSTL 標(biāo)簽遍歷輸出% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:forEach varitem items${page.list} varStatusvs div classcard>form idpublishForm action/item methodpost input typetext nametitle required minlength4 maxlength120 placeholder商品名稱 input typenumber nameprice required min0.01 step0.01 placeholder價格 input typedate nameexpireDate input typeurl nameimageUrl placeholder圖片地址 input typetext listcategories namecategoryText datalist idcategories option value1手機(jī)數(shù)碼/option option value2家具家電/option option value3圖書教材/option /datalist button typesubmit發(fā)布/button /formtypenumber配合min和step0.01能限制用戶輸入三位小數(shù)typedate在 Chrome、Edge 上會彈出日歷但 Firefox 桌面端會退化為普通文本框所以后端要允許非標(biāo)準(zhǔn)格式并做轉(zhuǎn)換。datalist看起來像下拉框但用戶仍然能自由輸入服務(wù)端必須判斷傳入的分類 ID 是否存在于 category 表否則會出現(xiàn)孤立分類。HTML5元素前端作用服務(wù)端校驗重點(diǎn)typenumber限制數(shù)字格式用 BigDecimal 接收不能以 String 存價格typedate日期選擇處理空值格式化 yyyy-MM-ddtypeurlURL 格式校驗防止javascript:alert(1)注入datalist輸入提示分類 ID 必須存在4.3 用 localStorage 做瀏覽歷史把“最近看過”放在前端JSP 頁面每次跳轉(zhuǎn)會刷新整頁要記錄用戶看過哪些商品最簡單的方法不是在后端建瀏覽表而是用 HTML5 的 localStorage 在瀏覽器本地存商品 ID。這段代碼可以放在詳情頁底部function saveHistory(itemId) { const key used_item_history; let history []; try { history JSON.parse(localStorage.getItem(key)) || []; } catch (e) { history []; } history [itemId].concat(history.filter(id id ! itemId)).slice(0, 10); localStorage.setItem(key, JSON.stringify(history)); } // 從 URL 取商品 ID例如 /item/123 const match location.pathname.match(/\/item\/(\d)/); if (match) { saveHistory(parseInt(match[1], 10)); }這段代碼做了三件容易被忽略的事JSON.parse 包了 try/catch避免同一域名下其他頁面寫入臟數(shù)據(jù)導(dǎo)致腳本中斷filter(id id ! itemId)先去重再用concat把當(dāng)前商品放到最前面slice(0, 10)只保留 10 條防止 localStorage 被無限撐大。瀏覽歷史只存 ID不存商品標(biāo)題和價格因為二手商品價格會變存快照反而會讓頁面顯示過期數(shù)據(jù)。展示歷史時再調(diào)用批量接口async function renderHistory() { const key used_item_history; let ids []; try { ids JSON.parse(localStorage.getItem(key)) || []; } catch (e) { return; } if (ids.length 0) return; const params new URLSearchParams(); ids.forEach(id params.append(id, id)); const resp await fetch(/item/batch? params.toString()); const result await resp.json(); // result.data 是商品列表按 ids 順序重新排列 }URLSearchParams會把數(shù)組編碼成id1id2的格式后端可以用RequestParam(id) ListInteger ids接收。前端把商品 ID 傳給服務(wù)端拿數(shù)據(jù)比直接存儲整個商品對象更安全也更容易清理。4.4 用 fetch 提交表單解析統(tǒng)一 JSONJSP 頁面里用原生 HTML5 fetch 也能做無刷新提交關(guān)鍵是后端返回的 JSON 格式要統(tǒng)一const form document.getElementById(publishForm); form.addEventListener(submit, async (e) { e.preventDefault(); const body new FormData(form); const resp await fetch(form.action, { method: POST, body: body }); if (!resp.ok) { alert(請求失敗稍后再試); return; } const result await resp.json(); if (result.code 0) { location.href /item/ result.data.id; } else { alert(result.message); } });這里先檢查resp.ok再解析 JSON否則后端返回 500 錯誤頁時響應(yīng)體是 HTMLresp.json()會報Unexpected token 這種讓人摸不著頭腦的錯誤。FormData自動把表單里的 name 組裝成參數(shù)SpringMVC 用RequestParam或者直接讓Item對象的字段名與 name 一致就能完成綁定。提示使用 localStorage 記錄瀏覽歷史前先在頁面里做一次 JSON.parse 的 try/catch避免臟數(shù)據(jù)讓整個腳本失效。5. 部署到 Tomcat 之后連接池參數(shù)、日志定位和頁面緩存優(yōu)化5.1 打包后先看啟動日志里的 Deployment 字樣本地開發(fā)通過 IDEA 跑 Spring Boot 的人第一次部署這種 war 包項目往往會卡在“Tomcat 明明啟動了但頁面 404”。正確流程是mvn clean package -DskipTests cp target/shop-web.war $CATALINA_HOME/webapps/ cd $CATALINA_HOME/bin ./startup.sh tail -f $CATALINA_HOME/logs/catalina.out日志里出現(xiàn)Deployment of web application archive [shop-web.war] has finished才代表容器成功識別應(yīng)用。如果沒有這一行優(yōu)先檢查web.xml里web-app的版本聲名Servlet 3.1 規(guī)范在 Tomcat 8.5 下使用absolute-ordering/可能會跳過部分注解掃描需要在web.xml里顯式聲明需要的 jar。5.2 Druid 連接池的參數(shù)別照抄項目數(shù)據(jù)庫連接池如果換成 Druid很多課程設(shè)計喜歡把網(wǎng)上的參數(shù)整段復(fù)制結(jié)果maxWait設(shè)成600000一個查詢卡了會話十秒才報錯。二手交易平臺這種中小流量系統(tǒng)合理初始參數(shù)是bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/second_hand?useUnicodetrueamp;characterEncodingUTF-8/ property nameusername valueroot/ property namepassword valueroot/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value20/ property namemaxWait value3000/ property namevalidationQuery valueSELECT 1/ property nametestWhileIdle valuetrue/ /beaninitialSize代表啟動時建立 5 個連接maxActive20要結(jié)合 MySQL 的max_connections一起看。Linux 下登錄 MySQL 執(zhí)行SHOW VARIABLES LIKE max_connections;如果數(shù)據(jù)庫限制 100多個應(yīng)用共享時連接池 maxActive 就不能都設(shè)成 50。maxWait3000表示拿連接等待 3 秒超過就拋異常配合日志能快速定位是連接用完還是 SQL 卡住。5.3 JSP 首次訪問慢預(yù)編譯和緩存頭JSP 第一次被訪問時才編譯成 classTomcat 低配置服務(wù)器上首次請求可能耗時幾百毫秒。如果不想等運(yùn)行時編譯可以在部署前用 Jasper 預(yù)編譯java -cp $CATALINA_HOME/lib/*:target/classes org.apache.jasper.JspC \ -uriroot src/main/webapp \ -webxml src/main/webapp/WEB-INF/generated_web.xml \ -d target/jspc預(yù)編譯完成后把生成的 class 文件放到WEB-INF/classes再啟動 TomcatJSP 首請求時間會明顯下降。另一個容易忽略的優(yōu)化是給靜態(tài)資源加緩存頭HTML5 的 CSS、JS 文件可以在 Filter 里統(tǒng)一設(shè)置response.setHeader(Cache-Control, public, max-age86400);普通商品詳情頁不要設(shè)置長緩存因為價格和庫存會變圖片和 CSS 設(shè)置為 86400 秒再次訪問時瀏覽器會直接命中本地緩存Redis 都不用引入。做完這步后打開 Chrome DevTools 的 Network 面板對比商品列表頁和靜態(tài)資源請求的時間差能直接看到優(yōu)化前后的差異。本文還有配套的精品資源點(diǎn)擊獲取