[springboot 開發單體web shop] 7. 多種形式提供商品列表
- 2019 年 11 月 22 日
- 筆記
上文回顧
上節 我們實現了仿jd
的輪播廣告以及商品分類的功能,並且講解了不同的注入方式,本節我們將繼續實現我們的電商主業務,商品信息的展示。
需求分析
首先,在我們開始本節編碼之前,我們先來分析一下都有哪些地方會對商品進行展示,打開jd
首頁,鼠標下拉可以看到如下:

可以看到,在大類型下查詢了部分商品在首頁進行展示(可以是最新的,也可以是網站推薦等等),然後點擊任何一個分類,可以看到如下:

我們一般進到電商網站之後,最常用的一個功能就是搜索,搜索鋼琴結果如下:

選擇任意一個商品點擊,都可以進入到詳情頁面,這個是單個商品的信息展示。 綜上,我們可以知道,要實現一個電商平台的商品展示,最基本的包含:
- 首頁推薦/最新上架商品
- 分類查詢商品
- 關鍵詞搜索商品
- 商品詳情展示
- …
接下來,我們就可以開始商品相關的業務開發了。
首頁商品列表|IndexProductList
開發梳理
我們首先來實現在首頁展示的推薦商品列表,來看一下都需要展示哪些信息,以及如何進行展示。
- 商品主鍵(product_id)
- 展示圖片(image_url)
- 商品名稱(product_name)
- 商品價格(product_price)
- 分類說明(description)
- 分類名稱(category_name)
- 分類主鍵(category_id)
- 其他…
編碼實現
根據一級分類查詢
遵循開發順序,自下而上,如果基礎mapper解決不了,那麼優先編寫SQL mapper,因為我們需要在同一張表中根據parent_id
遞歸的實現數據查詢,當然我們這裡使用的是錶鏈接
的方式實現。因此,common mapper
無法滿足我們的需求,需要自定義mapper實現。
Custom Mapper實現
和上節根據一級分類查詢子分類一樣,在項目mscx-shop-mapper
中添加一個自定義實現接口com.liferunner.custom.ProductCustomMapper
,然後在resourcesmappercustom
路徑下同步創建xml文件mapper/custom/ProductCustomMapper.xml
,此時,因為我們在上節中已經配置了當前文件夾可以被容器掃描到,所以我們添加的新的mapper就會在啟動時被掃描加載,代碼如下:
/** * ProductCustomMapper for : 自定義商品Mapper */ public interface ProductCustomMapper { /*** * 根據一級分類查詢商品 * * @param paramMap 傳遞一級分類(map傳遞多參數) * @return java.util.List<com.liferunner.dto.IndexProductDTO> */ List<IndexProductDTO> getIndexProductDtoList(@Param("paramMap") Map<String, Integer> paramMap); }
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.liferunner.custom.ProductCustomMapper"> <resultMap id="IndexProductDTO" type="com.liferunner.dto.IndexProductDTO"> <id column="rootCategoryId" property="rootCategoryId"/> <result column="rootCategoryName" property="rootCategoryName"/> <result column="slogan" property="slogan"/> <result column="categoryImage" property="categoryImage"/> <result column="bgColor" property="bgColor"/> <collection property="productItemList" ofType="com.liferunner.dto.IndexProductItemDTO"> <id column="productId" property="productId"/> <result column="productName" property="productName"/> <result column="productMainImageUrl" property="productMainImageUrl"/> <result column="productCreateTime" property="productCreateTime"/> </collection> </resultMap> <select id="getIndexProductDtoList" resultMap="IndexProductDTO" parameterType="Map"> SELECT c.id as rootCategoryId, c.name as rootCategoryName, c.slogan as slogan, c.category_image as categoryImage, c.bg_color as bgColor, p.id as productId, p.product_name as productName, pi.url as productMainImageUrl, p.created_time as productCreateTime FROM category c LEFT JOIN products p ON c.id = p.root_category_id LEFT JOIN products_img pi ON p.id = pi.product_id WHERE c.type = 1 AND p.root_category_id = #{paramMap.rootCategoryId} AND pi.is_main = 1 LIMIT 0,10; </select> </mapper>
Service實現
在service
project 創建com.liferunner.service.IProductService接口
以及其實現類com.liferunner.service.impl.ProductServiceImpl
,添加查詢方法如下:
public interface IProductService { /** * 根據一級分類id獲取首頁推薦的商品list * * @param rootCategoryId 一級分類id * @return 商品list */ List<IndexProductDTO> getIndexProductDtoList(Integer rootCategoryId); ... } --- @Slf4j @Service @RequiredArgsConstructor(onConstructor = @__(@Autowired)) public class ProductServiceImpl implements IProductService { // RequiredArgsConstructor 構造器注入 private final ProductCustomMapper productCustomMapper; @Transactional(propagation = Propagation.SUPPORTS) @Override public List<IndexProductDTO> getIndexProductDtoList(Integer rootCategoryId) { log.info("====== ProductServiceImpl#getIndexProductDtoList(rootCategoryId) : {}=======", rootCategoryId); Map<String, Integer> map = new HashMap<>(); map.put("rootCategoryId", rootCategoryId); val indexProductDtoList = this.productCustomMapper.getIndexProductDtoList(map); if (CollectionUtils.isEmpty(indexProductDtoList)) { log.warn("ProductServiceImpl#getIndexProductDtoList未查詢到任何商品信息"); } log.info("查詢結果:{}", indexProductDtoList); return indexProductDtoList; } }
Controller實現
接着,在com.liferunner.api.controller.IndexController
中實現對外暴露的查詢接口:
@RestController @RequestMapping("/index") @Api(value = "首頁信息controller", tags = "首頁信息接口API") @Slf4j public class IndexController { ... @Autowired private IProductService productService; @GetMapping("/rootCategorys") @ApiOperation(value = "查詢一級分類", notes = "查詢一級分類") public JsonResponse findAllRootCategorys() { log.info("============查詢一級分類=============="); val categoryResponseDTOS = this.categoryService.getAllRootCategorys(); if (CollectionUtils.isEmpty(categoryResponseDTOS)) { log.info("============未查詢到任何分類=============="); return JsonResponse.ok(Collections.EMPTY_LIST); } log.info("============一級分類查詢result:{}==============", categoryResponseDTOS); return JsonResponse.ok(categoryResponseDTOS); } ... }
Test API
編寫完成之後,我們需要對我們的代碼進行測試驗證,還是通過使用RestService
插件來實現,當然,大家也可以通過Postman來測試,結果如下:

商品列表|ProductList
如開文之初我們看到的京東商品列表一樣,我們先分析一下在商品列表頁面都需要哪些元素信息?
開發梳理
商品列表的展示按照我們之前的分析,總共分為2大類:
- 選擇商品分類之後,展示當前分類下所有商品
- 輸入搜索關鍵詞後,展示當前搜索到相關的所有商品
在這兩類中展示的商品列表數據,除了數據來源不同以外,其他元素基本都保持一致,那麼我們是否可以使用統一的接口來根據參數實現隔離呢? 理論上不存在問題,完全可以通過傳參判斷的方式進行數據回傳,但是,在我們實現一些可預見的功能需求時,一定要給自己的開發預留後路,也就是我們常說的可拓展性
,基於此,我們會分開實現各自的接口,以便於後期的擴展。 接着來分析在列表頁中我們需要展示的元素,首先因為需要分上述兩種情況,因此我們需要在我們API設計的時候分別處理,針對於 1.分類的商品列表展示,需要傳入的參數有:
- 分類id
- 排序(在電商列表我們常見的幾種排序(銷量,價格等等))
- 分頁相關(因為我們不可能把數據庫中所有的商品都取出來)
- PageNumber(當前第幾頁)
- PageSize(每頁顯示多少條數據)
2.關鍵詞查詢商品列表,需要傳入的參數有:
- 關鍵詞
- 排序(在電商列表我們常見的幾種排序(銷量,價格等等))
- 分頁相關(因為我們不可能把數據庫中所有的商品都取出來)
- PageNumber(當前第幾頁)
- PageSize(每頁顯示多少條數據)
需要在頁面展示的信息有:
- 商品id(用於跳轉商品詳情使用)
- 商品名稱
- 商品價格
- 商品銷量
- 商品圖片
- 商品優惠
- …
編碼實現
根據上面我們的分析,接下來開始我們的編碼:
根據商品分類查詢
根據我們的分析,肯定不會在一張表中把所有數據獲取全,因此我們需要進行多表聯查,故我們需要在自定義mapper中實現我們的功能查詢.
ResponseDTO 實現
根據我們前面分析的前端需要展示的信息,我們來定義一個用於展示這些信息的對象com.liferunner.dto.SearchProductDTO
,代碼如下:
@Data @NoArgsConstructor @AllArgsConstructor @Builder public class SearchProductDTO { private String productId; private String productName; private Integer sellCounts; private String imgUrl; private Integer priceDiscount; //商品優惠,我們直接計算之後返回優惠後價格 }
Custom Mapper 實現
在com.liferunner.custom.ProductCustomMapper.java
中新增一個方法接口:
List<SearchProductDTO> searchProductListByCategoryId(@Param("paramMap") Map<String, Object> paramMap);
同時,在mapper/custom/ProductCustomMapper.xml
中實現我們的查詢方法:
<select id="searchProductListByCategoryId" resultType="com.liferunner.dto.SearchProductDTO" parameterType="Map"> SELECT p.id as productId, p.product_name as productName, p.sell_counts as sellCounts, pi.url as imgUrl, tp.priceDiscount FROM products p LEFT JOIN products_img pi ON p.id = pi.product_id LEFT JOIN ( SELECT product_id, MIN(price_discount) as priceDiscount FROM products_spec GROUP BY product_id ) tp ON tp.product_id = p.id WHERE pi.is_main = 1 AND p.category_id = #{paramMap.categoryId} ORDER BY <choose> <when test="paramMap.sortby != null and paramMap.sortby == 'sell'"> p.sell_counts DESC </when> <when test="paramMap.sortby != null and paramMap.sortby == 'price'"> tp.priceDiscount ASC </when> <otherwise> p.created_time DESC </otherwise> </choose> </select>
主要來說明一下這裡的<choose>
模塊,以及為什麼不使用if
標籤。 在有的時候,我們並不希望所有的條件都同時生效,而只是想從多個選項中選擇一個,但是在使用IF
標籤時,只要test
中的表達式為 true
,就會執行IF
標籤中的條件。MyBatis 提供了 choose
元素。IF
標籤是與(and)
的關係,而 choose 是或(or)
的關係。 它的選擇是按照順序自上而下,一旦有任何一個滿足條件,則選擇退出。
Service 實現
然後在servicecom.liferunner.service.IProductService
中添加方法接口:
/** * 根據商品分類查詢商品列表 * * @param categoryId 分類id * @param sortby 排序方式 * @param pageNumber 當前頁碼 * @param pageSize 每頁展示多少條數據 * @return 通用分頁結果視圖 */ CommonPagedResult searchProductList(Integer categoryId, String sortby, Integer pageNumber, Integer pageSize);
在實現類com.liferunner.service.impl.ProductServiceImpl
中,實現上述方法:
// 方法重載 @Override public CommonPagedResult searchProductList(Integer categoryId, String sortby, Integer pageNumber, Integer pageSize) { Map<String, Object> paramMap = new HashMap<>(); paramMap.put("categoryId", categoryId); paramMap.put("sortby", sortby); // mybatis-pagehelper PageHelper.startPage(pageNumber, pageSize); val searchProductDTOS = this.productCustomMapper.searchProductListByCategoryId(paramMap); // 獲取mybatis插件中獲取到信息 PageInfo<?> pageInfo = new PageInfo<>(searchProductDTOS); // 封裝為返回到前端分頁組件可識別的視圖 val commonPagedResult = CommonPagedResult.builder() .pageNumber(pageNumber) .rows(searchProductDTOS) .totalPage(pageInfo.getPages()) .records(pageInfo.getTotal()) .build(); return commonPagedResult; }
在這裡,我們使用到了一個mybatis-pagehelper
插件,會在下面的福利講解中分解。
Controller 實現
繼續在com.liferunner.api.controller.ProductController
中添加對外暴露的接口API:
@GetMapping("/searchByCategoryId") @ApiOperation(value = "查詢商品信息列表", notes = "根據商品分類查詢商品列表") public JsonResponse searchProductListByCategoryId( @ApiParam(name = "categoryId", value = "商品分類id", required = true, example = "0") @RequestParam Integer categoryId, @ApiParam(name = "sortby", value = "排序方式", required = false) @RequestParam String sortby, @ApiParam(name = "pageNumber", value = "當前頁碼", required = false, example = "1") @RequestParam Integer pageNumber, @ApiParam(name = "pageSize", value = "每頁展示記錄數", required = false, example = "10") @RequestParam Integer pageSize ) { if (null == categoryId || categoryId == 0) { return JsonResponse.errorMsg("分類id錯誤!"); } if (null == pageNumber || 0 == pageNumber) { pageNumber = DEFAULT_PAGE_NUMBER; } if (null == pageSize || 0 == pageSize) { pageSize = DEFAULT_PAGE_SIZE; } log.info("============根據分類:{} 搜索列表==============", categoryId); val searchResult = this.productService.searchProductList(categoryId, sortby, pageNumber, pageSize); return JsonResponse.ok(searchResult); }
因為我們的請求中,只會要求商品分類id是必填項,其餘的調用方都可以不提供,但是如果不提供的話,我們系統就需要給定一些默認的參數來保證我們的系統正常穩定的運行,因此,我定義了com.liferunner.api.controller.BaseController
,用於存儲一些公共的配置信息。
/** * BaseController for : controller 基類 */ @Controller public class BaseController { /** * 默認展示第1頁 */ public final Integer DEFAULT_PAGE_NUMBER = 1; /** * 默認每頁展示10條數據 */ public final Integer DEFAULT_PAGE_SIZE = 10; }
Test API
測試的參數分別是:categoryId : 51 ,sortby : price,pageNumber : 1,pageSize : 5

可以看到,我們查詢到7條數據,總頁數totalPage
為2,並且根據價格從小到大進行了排序,證明我們的編碼是正確的。接下來,通過相同的代碼邏輯,我們繼續實現根據搜索關鍵詞進行查詢。
根據關鍵詞查詢
Response DTO 實現
使用上面實現的com.liferunner.dto.SearchProductDTO
.
Custom Mapper 實現
在com.liferunner.custom.ProductCustomMapper
中新增方法:
List<SearchProductDTO> searchProductList(@Param("paramMap") Map<String, Object> paramMap);
在mapper/custom/ProductCustomMapper.xml
中添加查詢SQL:
<select id="searchProductList" resultType="com.liferunner.dto.SearchProductDTO" parameterType="Map"> SELECT p.id as productId, p.product_name as productName, p.sell_counts as sellCounts, pi.url as imgUrl, tp.priceDiscount FROM products p LEFT JOIN products_img pi ON p.id = pi.product_id LEFT JOIN ( SELECT product_id, MIN(price_discount) as priceDiscount FROM products_spec GROUP BY product_id ) tp ON tp.product_id = p.id WHERE pi.is_main = 1 <if test="paramMap.keyword != null and paramMap.keyword != ''"> AND p.item_name LIKE "%${paramMap.keyword}%" </if> ORDER BY <choose> <when test="paramMap.sortby != null and paramMap.sortby == 'sell'"> p.sell_counts DESC </when> <when test="paramMap.sortby != null and paramMap.sortby == 'price'"> tp.priceDiscount ASC </when> <otherwise> p.created_time DESC </otherwise> </choose> </select>
Service 實現
在com.liferunner.service.IProductService
中新增查詢接口:
/** * 查詢商品列表 * * @param keyword 查詢關鍵詞 * @param sortby 排序方式 * @param pageNumber 當前頁碼 * @param pageSize 每頁展示多少條數據 * @return 通用分頁結果視圖 */ CommonPagedResult searchProductList(String keyword, String sortby, Integer pageNumber, Integer pageSize);
在com.liferunner.service.impl.ProductServiceImpl
實現上述接口方法:
@Override public CommonPagedResult searchProductList(String keyword, String sortby, Integer pageNumber, Integer pageSize) { Map<String, Object> paramMap = new HashMap<>(); paramMap.put("keyword", keyword); paramMap.put("sortby", sortby); // mybatis-pagehelper PageHelper.startPage(pageNumber, pageSize); val searchProductDTOS = this.productCustomMapper.searchProductList(paramMap); // 獲取mybatis插件中獲取到信息 PageInfo<?> pageInfo = new PageInfo<>(searchProductDTOS); // 封裝為返回到前端分頁組件可識別的視圖 val commonPagedResult = CommonPagedResult.builder() .pageNumber(pageNumber) .rows(searchProductDTOS) .totalPage(pageInfo.getPages()) .records(pageInfo.getTotal()) .build(); return commonPagedResult; }
上述方法和之前searchProductList(Integer categoryId, String sortby, Integer pageNumber, Integer pageSize)
唯一的區別就是它是肯定搜索關鍵詞來進行數據查詢,使用重載的目的是為了我們後續不同類型的業務擴展而考慮的。
Controller 實現
在com.liferunner.api.controller.ProductController
中添加關鍵詞搜索API:
@GetMapping("/search") @ApiOperation(value = "查詢商品信息列表", notes = "查詢商品信息列表") public JsonResponse searchProductList( @ApiParam(name = "keyword", value = "搜索關鍵詞", required = true) @RequestParam String keyword, @ApiParam(name = "sortby", value = "排序方式", required = false) @RequestParam String sortby, @ApiParam(name = "pageNumber", value = "當前頁碼", required = false, example = "1") @RequestParam Integer pageNumber, @ApiParam(name = "pageSize", value = "每頁展示記錄數", required = false, example = "10") @RequestParam Integer pageSize ) { if (StringUtils.isBlank(keyword)) { return JsonResponse.errorMsg("搜索關鍵詞不能為空!"); } if (null == pageNumber || 0 == pageNumber) { pageNumber = DEFAULT_PAGE_NUMBER; } if (null == pageSize || 0 == pageSize) { pageSize = DEFAULT_PAGE_SIZE; } log.info("============根據關鍵詞:{} 搜索列表==============", keyword); val searchResult = this.productService.searchProductList(keyword, sortby, pageNumber, pageSize); return JsonResponse.ok(searchResult); }
Test API
測試參數:keyword : 西鳳,sortby : sell,pageNumber : 1,pageSize : 10

根據銷量排序正常,查詢關鍵詞正常,總條數32,每頁10條,總共3頁正常。
福利講解
在本節編碼實現中,我們使用到了一個通用的mybatis分頁插件mybatis-pagehelper
,接下來,我們來了解一下這個插件的基本情況。
mybatis-pagehelper
如果各位小夥伴使用過:MyBatis 分頁插件 PageHelper, 那麼對於這個就很容易理解了,它其實就是基於Executor 攔截器來實現的,當攔截到原始SQL之後,對SQL進行一次改造處理。 我們來看看我們自己代碼中的實現,根據springboot編碼三部曲:
1.添加依賴
<!-- 引入mybatis-pagehelper 插件--> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.2.12</version> </dependency>
有同學就要問了,為什麼引入的這個依賴和我原來使用的不同?以前使用的是:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.1.10</version> </dependency>
答案就在這裡:依賴傳送門

我們使用的是springboot進行的項目開發,既然使用的是springboot,那我們完全可以用到它的自動裝配
特性,作者幫我們實現了這麼一個自動裝配的jar,我們只需要參考示例來編寫就ok了。
2.改配置
# mybatis 分頁組件配置 pagehelper: helperDialect: mysql #插件支持12種數據庫,選擇類型 supportMethodsArguments: true
3.改代碼
如下示例代碼:
@Override public CommonPagedResult searchProductList(String keyword, String sortby, Integer pageNumber, Integer pageSize) { Map<String, Object> paramMap = new HashMap<>(); paramMap.put("keyword", keyword); paramMap.put("sortby", sortby); // mybatis-pagehelper PageHelper.startPage(pageNumber, pageSize); val searchProductDTOS = this.productCustomMapper.searchProductList(paramMap); // 獲取mybatis插件中獲取到信息 PageInfo<?> pageInfo = new PageInfo<>(searchProductDTOS); // 封裝為返回到前端分頁組件可識別的視圖 val commonPagedResult = CommonPagedResult.builder() .pageNumber(pageNumber) .rows(searchProductDTOS) .totalPage(pageInfo.getPages()) .records(pageInfo.getTotal()) .build(); return commonPagedResult; }
在我們查詢數據庫之前,我們引入了一句PageHelper.startPage(pageNumber, pageSize);
,告訴mybatis我們要對查詢進行分頁處理,這個時候插件會啟動一個攔截器com.github.pagehelper.PageInterceptor
,針對所有的query
進行攔截,添加自定義參數和添加查詢數據總數。(後續我們會打印sql來證明。)
當查詢到結果之後,我們需要將我們查詢到的結果通知給插件,也就是PageInfo<?> pageInfo = new PageInfo<>(searchProductDTOS);
(com.github.pagehelper.PageInfo
是對插件針對分頁做的一個屬性包裝,具體可以查看屬性傳送門)。
至此,我們的插件使用就已經結束了。但是為什麼我們在後面又封裝了一個對象來對外進行返回,而不是使用查詢到的PageInfo
呢?這是因為我們實際開發過程中,為了數據結構的一致性做的一次結構封裝,你也可不實現該步驟,都是對結果沒有任何影響的。
SQL打印對比
2019-11-21 12:04:21 INFO ProductController:134 - ============根據關鍵詞:西鳳 搜索列表============== Creating a new SqlSession SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4ff449ba] was not registered for synchronization because synchronization is not active JDBC Connection [HikariProxyConnection@1980420239 wrapping com.mysql.cj.jdbc.ConnectionImpl@563b22b1] will not be managed by Spring ==> Preparing: SELECT count(0) FROM products p LEFT JOIN products_img pi ON p.id = pi.product_id LEFT JOIN (SELECT product_id, MIN(price_discount) AS priceDiscount FROM products_spec GROUP BY product_id) tp ON tp.product_id = p.id WHERE pi.is_main = 1 AND p.product_name LIKE "%西鳳%" ==> Parameters: <== Columns: count(0) <== Row: 32 <== Total: 1 ==> Preparing: SELECT p.id as productId, p.product_name as productName, p.sell_counts as sellCounts, pi.url as imgUrl, tp.priceDiscount FROM product p LEFT JOIN products_img pi ON p.id = pi.product_id LEFT JOIN ( SELECT product_id, MIN(price_discount) as priceDiscount FROM products_spec GROUP BY product_id ) tp ON tp.product_id = p.id WHERE pi.is_main = 1 AND p.product_name LIKE "%西鳳%" ORDER BY p.sell_counts DESC LIMIT ? ==> Parameters: 10(Integer)
我們可以看到,我們的SQL中多了一個SELECT count(0)
,第二條SQL多了一個LIMIT
參數,在代碼中,我們很明確的知道,我們並沒有顯示的去搜索總數和查詢條數,可以確定它就是插件幫我們實現的。
源碼下載
下節預告
下一節我們將繼續開發商品詳情展示以及商品評價業務,在過程中使用到的任何開發組件,我都會通過專門的一節來進行介紹的,兄弟們末慌!
gogogo!