前面我们花费了大量的篇幅在把我们的开发框架进行基础模块的不断完善,做了很多的铺垫后,我们将继续开发商品分类模块的管理功能。这节我们来实现商品分类的增、删、改的功能。话不多说,开干!
OpenApi3文档定义
现在我们将采用前面整好的swagger生成器,编写openApi3文档,让生成器基于它帮我们生成DTO和API接口。看下事情变得多么简单:
mall.yml
openapi: 3.0.1
...
paths:
...
/mall-api/admin/categories:
post:
tags:
- category
summary: 新增分类
description: 新增一个分类
operationId: addCategory
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryAdd'
responses:
200:
description: 新增成功
put:
tags:
- category
summary: 更新分类
description: 更新分类信息
operationId: updateCategory
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryUpdate'
responses:
200:
description: 更新成功
500:
description: 更新失败
x-errCode: '10001:参数校验失败(分类名称已存在、分类层级最多3级)'
/mall-api/admin/categories/{id}:
delete:
tags:
- category
summary: 删除分类
description: 按照id删除分类
operationId: deleteCategory
parameters:
- name: id
in: path
description: 分类id
schema:
type: integer
format: int64
required: true
responses:
200:
description: 删除成功
components:
schemas:
...
CategoryAdd:
description: 分类新增DTO类
type: object
properties:
name:
description: 分类名称
type: string
x-validation: "@NotNull(message = MSG_CATEGORY_NAME_REQUIRED) @Size(min = 2, max = 5, message = MSG_CATEGORY_NAME_LENGTH_RANGE)"
level:
description: 分类层级
type: integer
default: 1
x-validation: "@Max(value = 3, message = MSG_CATEGORY_LEVEL_MAX)"
pid:
description: 父级分类
type: integer
format: int64
default: 0
orderNum:
description: 排序
type: integer
x-validation: "@NotNull(message = MSG_ORDER_NUM_REQUIRED)"
CategoryUpdate:
description: 分类更新DTO类
type: object
properties:
id:
description: 分类id
type: integer
format: int64
x-validation: "@NotNull(message = MSG_CATEGORY_ID_REQUIRED)"
name:
description: 分类名称
type: string
level:
description: 分类层级
type: integer
x-validation: "@Max(value = 3, message = MSG_CATEGORY_LEVEL_MAX)"
pid:
description: 父级分类
type: integer
format: int64
orderNum:
description: 排序
type: integer
这里我们对分类模块管理API的定义严格的遵循了Restful API对资源操作的定义规范,不同的操作类型对应了不同的请求方法类型。
在DTO中对需要校验的字段使用扩展的方式定义了相关的校验。新增了校验错误消息常量:
package com.xiaojuan.boot.consts;
public interface ValidationConst {
...
String MSG_CATEGORY_ID_REQUIRED = "分类id不能为空";
String MSG_CATEGORY_NAME_REQUIRED = "分类名称不能为空";
String MSG_CATEGORY_NAME_LENGTH_RANGE = "分类名称长度为2-5";
String MSG_CATEGORY_NAME_EXISTS = "分类名称已存在";
String MSG_CATEGORY_TO_BE_DELETED_NOT_EXISTS = "要删除的分类不存在";
String MSG_CATEGORY_LEVEL_MAX = "分类层级最多3级";
String MSG_ORDER_NUM_REQUIRED = "排序字段不能为空";
}
生成API和DTO
这样我们只需要借助之前整好的插件,就能生成web层的接口和DTO类了。
先确保在build.gradle文件最后引入了swaggerGen.gradle
:
...
apply from: 'swaggerGen.gradle'
说明
该生成脚本只在我们需要执行生成器任务时引入,在启动本地spring boot应用或者单元测试时,先将其注释掉,因为它引入的依赖会在类路径上间接的把logback日志模块传递进来,和我们之前整好的log4j2模块有冲突,造成应用起不来。
执行生成任务:
双击运行后,看到生成的内容:
这里生成的API接口的命名,是swagger生成器按照我们在openApi2文档中定义的url路径推断出来的,这里我们也不用去调整了。
来看下生成的API接口:
很显然,swagger生成器按照我们文档的定义帮我们把方法签名以及swagger注解都生成好了,包括我们扩展的errCode
信息,这里我们指定了可能抛出的校验错误信息。实际生成的swagger在线文档中,会这样记录:
api分组
这里我们按照之前的swagger分组的配置:
springdoc:
group-configs:
- group: '用户模块'
packagesToScan: com.xiaojuan.boot.web.controller.user
pathsToMatch: /**
- group: '管理模块'
packagesToScan: com.xiaojuan.boot.web.controller.admin
pathsToMatch: /**
把商品分类管理纳入管理模块,看下包的结构:
后台接口开发
创建CategoryController
,实现生成的CategoryApi
接口:
package com.xiaojuan.boot.web.controller.admin;
import ...
@RequiredArgsConstructor
@RestController
public class CategoryController implements CategoryApi {
private final CategoryService categoryService;
@Override
public void addCategory(CategoryAddDTO categoryAddDTO) {
categoryService.addCategory(categoryAddDTO);
}
@Override
public void updateCategory(CategoryUpdateDTO updateDTO) {
categoryService.updateCategory(updateDTO);
}
@Override
public void deleteCategory(Long id) {
categoryService.deleteCategory(id);
}
}
小伙伴们发现,当我们把API的生成工作(包括了请求映射注解、参数映射注解、校验注解、swagger文档注解)全都交给了swagger生成器后,我们的controller
组件着实变成了一块净土。
再来看service
组件的定义和实现:
package com.xiaojuan.boot.service;
import ...
public interface CategoryService {
void addCategory(CategoryAddDTO categoryAddDTO);
void updateCategory(CategoryUpdateDTO updateDTO);
void deleteCategory(Long id);
}
package com.xiaojuan.boot.service.impl;
import ...
import static com.xiaojuan.boot.dao.generated.mapper.CategoryDynamicSqlSupport.*;
import static org.mybatis.dynamic.sql.SqlBuilder.*;
@RequiredArgsConstructor
@Service
public class CategoryServiceImpl implements CategoryService {
private final CategoryMapper categoryMapper;
@Override
public void addCategory(CategoryAddDTO categoryAddDTO) {
// 检查要提交的分类名称是否已被使用
long count = categoryMapper.count(c -> c.where(name, isEqualTo(categoryAddDTO.getName())));
if (count > 0) throw new BusinessException(ValidationConst.MSG_CATEGORY_NAME_EXISTS, BusinessError.RECORD_EXISTS.getValue());
Category category = new Category();
BeanUtils.copyProperties(categoryAddDTO, category);
category.setCreateTime(new Date());
category.setUpdateTime(new Date());
categoryMapper.insertSelective(category);
}
@Override
public void updateCategory(CategoryUpdateDTO updateDTO) {
// 检查其他分类记录是否已使用提交的分类名称
long count = categoryMapper.count(c -> c.where(name, isEqualTo(updateDTO.getName()), and(id, isNotEqualTo(updateDTO.getId()))));
if (count > 0) throw new BusinessException(ValidationConst.MSG_CATEGORY_NAME_EXISTS, BusinessError.RECORD_EXISTS.getValue());
Category category = new Category();
BeanUtils.copyProperties(updateDTO, category);
category.setUpdateTime(new Date());
categoryMapper.updateByPrimaryKeySelective(category);
}
@Override
public void deleteCategory(Long categoryId) {
// 检查要删除的分类是否存在
long count = categoryMapper.count(c -> c.where(id, isEqualTo(categoryId)));
if (count == 0) throw new BusinessException(ValidationConst.MSG_CATEGORY_TO_BE_DELETED_NOT_EXISTS, BusinessError.RESOURCE_NOT_EXISTS.getValue());
categoryMapper.deleteByPrimaryKey(categoryId);
}
}
实现逻辑很简单,这里不做赘述。
API测试
API的测试方式这里提供几种,任由小伙伴们选择:一种是我们之前搭建好的web单元测试、一种是使用idea自带的http client工具测试,当然不要忘了还有swagger在线生成文档提供的在线测试哈。这里我们介绍前两种,swagger在线测试文档想必小伙伴们都熟悉了吧。
这里小卷写的web单元测试覆盖了部分场景,小伙伴们可以继续完善:
package com.xiaojuan.boot.web.controller;
import ...
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
public class CategoryControllerTest extends WebTestBase {
@SneakyThrows
@Test
public void testAddWithoutAuth() {
runner.runScript(Resources.getResourceAsReader("db/user.sql"));
CategoryAddDTO addDTO = new CategoryAddDTO();
addDTO.setName("茶饮");
addDTO.setOrderNum(1);
ResponseEntity<Response<Void>> resp = postObject("/mall-api/admin/categories", new TypeReference<Void>() {}, addDTO, null);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
// 普通用户登录访问
Map<String, List<String>> params = new HashMap<>();
params.put("username", Collections.singletonList("zhangsan"));
params.put("password", Collections.singletonList("123"));
ResponseEntity<Response<UserInfoDTO>> resp2 = postForm("/mall-api/user/login", new TypeReference<UserInfoDTO>() {}, params);
assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);
String cookie = resp2.getHeaders().get("Set-Cookie").get(0);
Map<String, String> headerMap = new HashMap<>();
headerMap.put("Cookie", cookie);
// 没有访问权限
resp = postObject("/mall-api/admin/categories", new TypeReference<Void>() {}, addDTO, headerMap);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@SneakyThrows
@Test
public void testCategoryNameExistsThenSuccess() {
runner.runScript(Resources.getResourceAsReader("db/user.sql"));
runner.runScript(Resources.getResourceAsReader("db/data.sql"));
Map<String, List<String>> params = new HashMap<>();
params.put("username", Collections.singletonList("admin"));
params.put("password", Collections.singletonList("admin"));
ResponseEntity<Response<UserInfoDTO>> resp = postForm("/mall-api/user/admin/login", new TypeReference<UserInfoDTO>() {}, params);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
String cookie = resp.getHeaders().get("Set-Cookie").get(0);
Map<String, String> headerMap = new HashMap<>();
headerMap.put("Cookie", cookie);
// 断言商品分类数据库存在
CategoryAddDTO addDTO = new CategoryAddDTO();
addDTO.setName("水果");
addDTO.setOrderNum(1);
ResponseEntity<Response<Void>> resp2 = postObject("/mall-api/admin/categories", new TypeReference<Void>() {}, addDTO, headerMap);
assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(resp2.getBody().getErrCode()).isEqualTo(BusinessError.RECORD_EXISTS.getValue());
// 插入成功
addDTO.setName("水产");
resp2 = postObject("/mall-api/admin/categories", new TypeReference<Void>() {}, addDTO, headerMap);
assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
测试ok!
注意:
我们的单元测试使用的Spring Boot Test模块给我们提供的
TestRestTemplate
组件来发送请求的,在我们发送post请求得到后台的401
响应时会抛出这样的错误:
小卷参考了一篇博客:itecnote.com/tecnote/spr…
找到了解决问题的办法:多加一个test依赖:
dependencies { ... testImplementation 'org.apache.httpcomponents:httpclient:4.5.13' ... }
除了web单元测试,我们也可以启动本地服务用客户端工具来测试。首先要确保我们连接的本地h2数据库中初始化相关的数据,初始化数据脚本可参考项目:
小卷的测试脚本:
首先我们要用admin用户登录,才能访问要测试的接口,比如我们测试新增的层级超出的情况:
同时可以看到控制台输出的错误日志:
其他的场景,就由小伙伴们自行测试完成吧。
通过本节的实践,相信小伙伴对API接口的开发流程更加熟练了吧,下节我们继续完善商品分类剩下的查询模块的开发。大家加油!