[springboot 開發單體web shop] 7. 多種形式提供商品列表
上文回顧
上節 我們實現了仿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
,然後在resources\mapper\custom
路徑下同步建立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
引數,在程式碼中,我們很明確的知道,我們並沒有顯示的去搜索總數和查詢條數,可以確定它就是外掛幫我們實現的。
原始碼下載
Github 傳送門
Gitee 傳送門
下節預告
下一節我們將繼續開發商品詳情展示以及商品評價業務,在過程中使用到的任何開發元件,我都會通過專門的一節來進行介紹的,兄弟們末慌!
gogogo