# 后端手册
# 项目结构
包名 | 含义 |
---|---|
com.eva.api | 控制层/接口层 |
com.eva.biz | 复合业务层 |
com.eva.service | 颗粒业务层 |
com.eva.dao | 持久层 |
com.eva.config | 配置 |
com.eva.core | 项目核心 |
如果我们划分得更细致些,那么可以得到下表
包名 | 含义 |
---|---|
com.eva.api | 控制层/接口层 |
com.eva.biz | 复合业务层 |
com.eva.service | 颗粒业务层 |
com.eva.service.aware | 数据意识 |
com.eva.service.common | 通用颗粒业务 |
com.eva.service.proxy | 业务代理 |
com.eva.dao | 持久层 |
com.eva.config | 配置 |
com.eva.core | 项目核心 |
com.eva.core.annotation | 项目注解 |
com.eva.core.aware | 通用意识接口、意识默认实现及意识辅助注解 |
com.eva.core.constants | 常量定义 |
com.eva.core.exception | 自定义异常 |
com.eva.core.model | 通用模型 |
com.eva.core.utils | 通用工具类 |
设计思想
项目为四层结构,api > biz > service > dao
,相信很多新手会疑惑,但随着你技术、经验和思想的提升,你很快也会这么去做!现在让我们加快你成长的步伐,尝试着理解每一层的作用,为了更方便的理解,需要从后往前进行讲解!
- 持久层
持久层用于将 处理好的数据保存至数据库。这一层的代码可直接生成,不需要手动编写。
- 颗粒业务层
颗粒业务层用于 编写颗粒业务,例如怎样删除(逻辑删除还是物理删除),怎样批量删除(循环调用还是SQL实现)等。在这一层中不涉及多表操作(除查询外)。这一层的代码可直接生成,不需要手动编写。
- 复合业务层
复合业务层用于 编写复合业务,这一层可能涉及到多表的增删改操作,而单表的增删改操作的业务实现在颗粒业务层。所以复合业务层应该使用颗粒业务层对象,而不应该直接使用持久层对象。当然,如果你的业务足够简单,例如删除一条单表记录,那么这一层可以不用实现。如果你的业务是一个复合业务,例如新建时需要验证编码字段是否重复,那么此时需要在该层添加创建方法,并在该方法中进行验证。
- 控制层/接口层
控制层/接口层用于 定义接口并调用相关业务方法,而业务方法可以来自复合业务层和颗粒业务层。
# 服务端口配置
端口配置在application.yml的server.port属性中。调整后需重启服务。
# 接口文档
如果端口没有修改的话,那么接口地址为http://localhost:10010/doc.html
# 测试模式
某些时候我们为了测试方便,诸如短信、图形、密码等我们暂时不做验证以提高测试效率。为了方便的实现这一点,Eva提供了测试模式,只需要打开配置即可。如下
# 项目信息配置
project:
name: 伊娃
version: 1.0.0
# 环境,生产环境production,开发环境development
env: development
# 模式,testing测试模式
mode: testing
2
3
4
5
6
7
8
当前测试模式开启后,不会校验验证码。
# 权限控制
Eva整合Shiro实现权限控制,通过Shiro提供的@RequiresRoles
和@RequiresPermissions
注解可完成角色或权限的控制。
# @RequiresRoles
@RequiresRoles
注解用于配置接口要求用户拥有某(些)角色才可访问,它拥有两个参数:
- value: 角色列表
- logical: 角色之间的判断关系,默认为Logical.AND
示例1: 以下代码表示必须拥有admin角色才可访问
@RequiresRoles("admin")
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例2: 以下代码表示必须拥有admin 和 manager角色才可访问
@RequiresRoles({"admin", "manager"})
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例3: 以下代码表示必须拥有admin 或 manager角色才可访问
@RequiresRoles(value = {"admin", "manager"}, logical = Logical.OR)
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
# @RequiresPermissions
@RequiresPermissions
注解用于配置接口要求用户拥有某(些)权限才可访问,它拥有两个参数:
- value: 权限列表
- logical: 权限之间的判断关系,默认为Logical.AND
示例1: 以下代码表示必须拥有order:create权限才可访问
@RequiresPermissions("order:create")
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例2: 以下代码表示必须拥有order:create 和 order:update权限才可访问
@RequiresPermissions({"order:create", "order:update"})
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例3: 以下代码表示必须拥有order:create 或 order:update角色才可访问
@RequiresPermissions(value = {"order:create", "order:update"}, logical = Logical.OR)
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
# 参数验证
Eva采用spring自带的@Validated
注解进行参数验证。为了在使用实体类的情况下更好的区分出新建、编辑和其他操作验证的不同,Eva提供了OperaType
操作类型标识类。使用方式如下
MyController.java
// 新建
public ApiResponse create(@Validated(OperaType.Create.class) @RequestBody MyModel myModel) {
return ApiResponse.success(...);
}
// 编辑
public ApiResponse updateById(@Validated(OperaType.Update.class) @RequestBody MyModel myModel) {
return ApiResponse.success(...);
}
2
3
4
5
6
7
8
9
MyModel.java
public class MyModel {
// 仅在编辑时验证
@NotNull(message = "主键不能为空", groups = {OperaType.Update.class})
private Integer id;
// 在新建和编辑时验证
@NotBlank(message = "名称不能为空", groups = {OperaType.Create.class, OperaType.Update.class})
private String name;
}
2
3
4
5
6
7
8
9
10
11
扩展
OperaType类只是用于标识出不同的操作类型,所以如果你有更多的操作类型,你可以在OperaType类中继续添加新的标识。
# 无认证接口实现
有些时候我们允许不登录直接访问接口,此时需要在拦截器中配置接口放行。如下
com.myproject.WebConfigurer.java
@Configuration
public class WebConfigurer implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/resource/result/**",
"/resource/image/**",
"/resource/result",
"/resource/record",
"/sms/**",
"/doc.html",
"/swagger-resources/**",
"/webjars/**",
"/user/regis",
"/user/login",
"/user/setPwd",
"/user/findPwd",
"/new/interface"
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
注意
系统中提供的需要认证才能访问的接口,请勿在此放行。这样接口获取不到用户信息导致出现错误。
# 事务处理
在需要进行事务处理的方法上添加@Transactional
注解即可。无论是service层还是biz层这种方式都是有效的。
误区
有些同学可能因为"懒"的缘故,直接在Controller中实现biz层做的事情,无论Controller方法上添加@Transactional
是否能使事务生效,这种做法都是不规范的。
# 分页实现
虽然分页代码可以直接生成,但我们仍然需要为您说明分页的实现逻辑。
# 单表的分页实现
单表的分页使用MyBatis Plus自带的分页方法来实现是再好不过的选择了,这样可方便的实现多条件查询。
public PageData<MyModel> findPage(PageWrap<MyModel> pageWrap) {
IPage<MyModel> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
QueryWrapper<MyModel> queryWrapper = new QueryWrapper<>(WrapperUtil.blankToNull(pageWrap.getModel()));
return PageData.from(myMapper.selectPage(page, queryWrapper));
}
2
3
4
5
# 多表的分页实现
多表的分页需要编写SQL语句,使用MyBatis的分页插件来实现会是一个明智的选择。
@Override
public PageData<MyListVO> findPage(PageWrap<MyQueryDTO> pageWrap) {
PageHelper.startPage(pageWrap.getPage(), pageWrap.getCapacity());
List<MyListVO> myList = myMapper.selectManageList(pageWrap.getModel());
return PageData.from(new PageInfo<>(myList));
}
2
3
4
5
6
PageData和PageWrap
细心的同学可能已经发现,无论是单表分页还是多表分页,方法参数均为PageWrap
,方法返回均为PageData
,为什么不使用MyBatis Plus自带的IPage?有经验的同学可能不难理解这是一种包装,这样可以方便的扩展我们想要的字段,并且无论分页方式如何变化,方法的请求参数和响应结构都能始终保持一致。
# 分页字段排序的实现
有时候我们需要实现列表中存在按自定义列排序的需求。Eva做了些简单的封装支持了字段排序的功能。
# 单表的分页字段排序
单表的分页字段排序可以使用MyBatis Plus默认的实现来处理。
public PageData<MyModel> findPage(PageWrap<MyModel> pageWrap) {
IPage<MyModel> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
QueryWrapper<MyModel> queryWrapper = new QueryWrapper<>(MyBatisPlus.blankToNull(pageWrap.getModel()));
// 字段排序
for(PageWrap.SortData sortData: pageWrap.getSorts()) {
if (sortData.getDirection().equalsIgnoreCase(PageWrap.DESC)) {
queryWrapper.orderByDesc(sortData.getProperty());
} else {
queryWrapper.orderByAsc(sortData.getProperty());
}
}
return PageData.from(myMapper.selectPage(page, queryWrapper));
}
2
3
4
5
6
7
8
9
10
11
12
13
# 多表的分页字段排序
多表的排序需要结合新的方法参数和SQL处理来完成,不过实现也非常简单。
MyMapper.java
增加orderByClause,并通过@Param
注解标记SQL参数名称。
public interface MyMapper extends BaseMapper<MyModel> {
List<MyModelListVO> selectManageList(@Param("dto") QueryMyModelDTO dto, @Param("orderByClause") String orderByClause);
}
2
3
4
MyMapper.xml
<?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="...">
<select id="selectManageList" ...>
SELECT ...
FROM ...
LEFT JOIN ...
LEFT JOIN ...
<where>
<if dto.param != null>
AND ...
</if>
</where>
${orderByClause}
</select>
</mapper>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MyServiceImpl.java
@Override
public PageData<MyListVO> findPage(PageWrap<MyQueryDTO> pageWrap) {
PageHelper.startPage(pageWrap.getPage(), pageWrap.getCapacity());
List<MyListVO> myList = myMapper.selectManageList(pageWrap.getModel(), pageWrap.getOrderByClause());
return PageData.from(new PageInfo<>(myList));
}
2
3
4
5
6
注意
MyMapper.java中使用@Param注解标识了SQL参数的名称,条件参数对象被标识为dto
,因此在使用条件字段时应使用dto.prop
(prop为字段名称),而不应直接使用prop
。
# OSS文件的上传和访问
# 功能说明
大部分文件,如商品图片,合同签订后的PDF,用户视频等都会存储在文件服务器,也就是OSS。Eva默认提供了常用的文件上传和访问接口。
# 上传图片
接口类:FileUploadController.java
接口:POST /upload/image
参数 | 说明 |
---|---|
file | 图片文件 |
# 访问图片
接口类:FileAccessController.java
接口:GET /resource/image
参数 | 说明 |
---|---|
f | 图片上传后的fileKey |
# 上传文件
接口类:FileUploadController.java
接口:POST /upload/attach
参数 | 说明 |
---|---|
file | 文件 |
# 下载文件
接口类:FileAccessController.java
接口:GET /resource/attach
参数 | 说明 |
---|---|
f | 文件上传后的fileKey |
# 其它文件的访问
不同的文件类型对应的contentType不同,可以自行添加对应接口来访问。
# 本地文件的下载
# 功能说明
有时候我们希望某些文件直接存放在部署服务器上,但我们仍需要对这些文件进行下载,例如Excel导入的模版文件。为此Eva也提供了本地文件下载的功能。
# 存储文件
您只需要将文件存放在项目/files
路径下即可调用下载文件接口来直接下载。
# 下载文件
接口类:LocalFileAccessController.java
接口:GET /resource/local/download
参数 | 说明 |
---|---|
path | 文件路径,如文件路径为项目/files/test.txt,则文件路径参数为"/text.txt" |
name | 下载后的文件名称 |
# 导入Excel
# 功能说明
导入Excel指的是将Excel中的数据经过系统导入到数据库中,在Eva中导入Excel数据分成两个步骤
- 下载Excel模版
- 填写数据并执行导入
所以实现Excel的数据导入分成以下几个步骤进行。
# 1. 制作Excel模版
在做Excel导入之前,应该先提供一个Excel模版用于下载,这样可以避免用户使用错误的Excel格式,也方便企业各个岗位进行协作。模版制作后,可以阅读本地文件的下载来知晓如何存放。
前端ImportButton
组件中需要的模版路径参数,即为当前Excel文件存放路径。
# 2. 配置Excel列信息
@Data
public class MyExcelImportDTO {
@ExcelImportColumn
private String name;
@ExcelImportColumn
private String name2;
}
2
3
4
5
6
7
8
9
# 3. 实现ExcelImportCallback接口
@Component
public class MyExcelImportCallback implements ExcelImportCallback<MyExcelImportDTO> {
@Override
@Transactional
public int callback(List<MyExcelImportDTO> rows, boolean sync) {
if (CollectionUtils.isEmpty(rows)) {
return 0;
}
int total = 0;
// 在此做导入数据逻辑处理
return total;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sync参数
callback方法中的sync参数指的是在导入数据时是否需要同步已存在的数据。
# 4. 实现导入接口
@Api(tags = "Xxx模块")
@RestController
@RequestMapping("/xx/xxx")
public class MyController extends BaseController {
@Autowired
private MyExcelImportCallback myExcelImportCallback;
// ... 此处省略其它接口定义
@PreventRepeat
@ApiOperation("导入")
@PostMapping("/import")
@RequiresPermissions("xx:xxx:xxxx")
public ApiResponse importData(MultipartFile file, boolean sync) throws IOException {
return ApiResponse.success(
ExcelImporter
.build(MyExcelImportDTO.class)
.importData(file.getInputStream(), myExcelImportCallback, sync)
);
}
// ... 此处省略其它接口定义
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
如果不能理解示例代码中的xxx
,并且您在构建框架时选择了部门管理
模块,那么可以参考SystemDepartmentController
类的importExcel
接口方法,这个接口用于从Excel中导入部门数据。
# 数据转换
很多时候,Excel中的数据并不是我们真正想要得到的数据,而转换器的目的就是将Excel中的数据转为我们真正想要获取的数据。Eva默认提供了两个数据转换器。即DoubleToStringConverter
和IntegerToStringConverter
。分别用于将Double类型转为String和将Integer类型转为String。使用转换器非常的简单,如下:
public class MyExcelImportDTO {
@ExcelImportColumn(converter=IntegerToStringConverter.class)
private String phone;
}
2
3
4
5
# 自定义数据转换器
为了保证数据转换的可扩展性,Eva支持自定义的数据转换器,实现起来也非常简单,如下
public class MyDataConverter implements ExcelDataConverterAdapter {
@Override
Object format (Object... args) {
// 其中args[0]为单元格的数据,arg[1]及其之后为@ExcelColumn的args参数(在这里args[1]的结果为"arg1")
}
}
2
3
4
5
6
7
# ExcelImportColumn注解参数说明
参数 | 说明 |
---|---|
index | 排序,值越小越靠前,-1按字段反射顺序排序 |
converter | 数据转换器 |
args | 自定义数据转换器参数 |
# 导出Excel
# 功能说明
导出Excel指的是将一个列表导出成一个Excel文件,方便其它部门查看或进一步处理。在Eva中实现一个导出功能是非常简单的。如下
# 1. 配置Excel列信息
public class MyExportExcelVO {
@ExcelExportColumn(name="列名1")
private String name;
@ExcelExportColumn(name="列名2")
private String name2;
}
2
3
4
5
6
7
8
# 2. 实现导出接口
@Api(tags = "Xxx模块")
@RestController
@RequestMapping("/xx/xxx")
public class MyController extends BaseController {
@Autowired
private MyService myService;
@ApiOperation("导出Excel")
@PostMapping("/export")
@RequiresPermissions("xx:xxx:xxxx")
public void exportExcel (HttpServletResponse response) {
ExcelExporter.build(MyExportExcelVO.class).exportData(myService.find(), "Excel文件名称", response);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果不能理解示例代码中的xxx
的话,可以参考SystemTraceLogController
类的exportExcel
接口方法,这个接口用于导出跟踪日志Excel文件。
# 数据转换
有时候我们希望数据展现为一个特殊的格式,或者需要对数据进一步的加工。此时我们可以通过数据转换器来实现,如下
public class MyExportExcelVO {
@ExcelExportColumn(name="列名1", converter=MyConverter.class)
private String name;
}
2
3
4
5
6
# 自定义数据转换器
与后端手册/导入Excel/自定义数据转换器保持一致。
# ExcelExportColumn注解参数说明
参数 | 说明 |
---|---|
name | 列名 |
width | 列宽 |
index | 列宽(单位为字符),-1按字段反射顺序排序 |
align | 对齐方式 |
backgroundColor | 列头背景色 |
dataBackgroundColor | 数据单元格的背景色 |
color | 字体颜色 |
fontSize | 字体大小(像素) |
bold | 是否加粗 |
italic | 是否倾斜 |
valueMapping | 值映射,如0=女;1=男 |
prefix | 数据前缀 |
suffix | 数据后缀 |
dateFormat | 日期格式,只有数据为java.util.Date时才生效 |
converter | 数据转换器 |
args | 数据转换器参数 |
# 自定义数据权限
默认情况下,Eva实现了部门数据权限和独立的岗位数据权限控制。如果您需要添加新的数据权限,如审批数据权限,物品数据权限等,那么您也可以清晰且方便的实现。实现一套数据权限的步骤如下:
# 1. 定义业务模块和权限类型
文件:core/DataPermissionConstants.java
/**
* 数据权限模块
*/
@Getter
@AllArgsConstructor
enum Module {
MY_MODULE("MY_MODULE", "我的模块"),
;
}
/**
* 数据权限类型
*/
@Getter
@AllArgsConstructor
enum Type {
ALL((short)0,"全部", new Module[]{}),
MY_MODULE_CUSTOM((short)1001, "自定义数据", new Module[]{Module.MY_MODULE}),
MY_MODULE_CHILD((short)1002, "用户所属及其子数据", new Module[]{Module.MY_MODULE}),
;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 2. 编写数据意识类
@Component
public class MyModelDataPermissionAware extends DefaultDataPermissionAware<MyModel> {
/**
* 覆盖父类方法:返回当前数据权限的模块
*/
@Override
public DataPermissionConstants.Module module() {
return DataPermissionConstants.Module.MY_MODULE;
}
/**
* 覆盖父类方法:当角色未配置数据权限时的默认数据权限。
*/
@Override
public List<SystemDepartmentListVO> defaultData(Integer userId) {
return child(userId);
}
/**
* 所有权限
*/
@DataPermissionMapping(value = DataPermissionConstants.Type.ALL, priority = 1)
public List<SystemDepartmentListVO> all() {
// 数据权限为"全部"时的数据获取逻辑。
}
/**
* 自定义数据
*/
@DataPermissionMapping(value = DataPermissionConstants.Type.MY_MODULE_CUSTOM, priority = 2, injectCustomData = true)
public List<MyModel> custom (String customData) {
// 数据权限为"自定义数据"时的数据获取逻辑。
}
/**
* 用户所属及其子数据
*/
@DataPermissionMapping(value = DataPermissionConstants.Type.MY_MODULE_CUSTOM, priority = 3, injectUser = true)
public List<MyModel> child (Integer userId) {
// 数据权限为"用户所属及其子数据"时的数据获取逻辑。
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 3. 调用数据权限意识
DefaultDataPermissionAware
类提供了execute方法,该方法将自动根据用户角色配置的数据权限执行权限方法。
@Autowired
private MyModelDataPermissionAware myModelDataPermissionAware;
public List<MyModel> findList () {
return myModelDataPermissionAware.excute();
}
2
3
4
5
6
7
# @DataPermissionMapping说明
@DataPermissionMapping
注解用于标记和配置数据权限处理方法。参数如下:
参数 | 说明 |
---|---|
value | 权限类型 |
priority | 优先级,不允许重复 |
injectUser | 是否注入用户ID参数,为true时方法可接收Integer userId参数 |
injectCustomData | 是否注入自定义数据参数,为true时方法可接收String customData参数 |
priority/优先级的作用
一个用户可能拥有多个角色,多个角色意味着可能拥有不同的数据权限。priority
用于在一个用户拥有多种数据权限时该如何选择使用哪种数据权限,值越小,优先级越高。
# 防重复提交
在接口方法上添加@PreventRepeat
注解即可,他有如下参数:
# @PreventRepeat
参数 | 说明 |
---|---|
value | 防重复规则设定类,默认为PreventRepeatDefaultHandler.class |
interval | 间隔时间,小于此时间视为重复提交,默认为800毫秒 |
message | 错误消息 |
limit | 1分钟内限制的请求次数(<=0时表示不限制),默认为0 |
limitMessage | 被限制时的错误消息 |
lockTime | 超出请求限制次数时锁定的时长(ms),默认10分钟 |
示例1:采用默认参数
@PreventRepeat
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例2:指定防重复时间和错误消息
@PreventRepeat(interval = 1000, message = "请求过于频繁")
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例3:指定接口时限调用次数和消息
@PreventRepeat(limit = 10, limitMessage = "请求过于频繁,请休息10分钟以后再试")
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
示例4:指定自定义防重复规则实现类
@PreventRepeat(value = MyPreventRepeatHandler.class ,interval = 1000, message = "请求过于频繁")
public ApiResponse create(...) {
return ApiResponse.success(...);
}
2
3
4
# 自定义防重复提交规则
默认情况下防重复的规则由PreventRepeatDefaultHandler.class
设定,它将请求路径、请求体参数、Cookie信息合并在一起并签名作为请求的Key。如果你并不希望这样,那么你可以自定义防重复的规则。实现方式如下
@Component
public class MyPreventRepeatHandler extends PreventRepeatAdapter {
@Override
public String sign(HttpServletRequest request) {
// 根据您要验证的参数进行签名并返回即可
}
}
2
3
4
5
6
7
8
# 操作日志
Eva的操作日志分为两种模式——智能模式和手动模式。您可以在构建项目时选择你喜欢的模式,当然我们推荐智能模式,因为它的确非常方便。无论是手动模式还是智能模式,Eva都通过@Trace
注解来描述日志内容。
# @Trace
@Trace
注解可以添加在接口方法上,也可以添加到Controller类上。以下是注解参数说明。
参数 | 含义 | 默认值 | 说明 |
---|---|---|---|
module | 模块名称 | 不存在值时读取类上@Trace 的module参数,如果类上无@Trace 则读取Swagger @Api 的tags参数 | |
type | 操作类型 | TraceType.AUTO | 默认自动推测 |
remark | 操作备注 | 不存在值时读取Swagger @ApiOperation ,如果不存在@ApiOperation ,则读取操作类型的备注 | |
exclude | 是否排除 | false | 为true时表示不为接口添加日志记录 |
withRequestParameters | 是否写入请求参数 | true | 为false时表示日志中不记录请求参数 |
withRequestResult | 是否写入请求结果 | true | 为false时表示日志中不记录请求结果 |
# 智能模式
智能模式下,默认会为所有的非查询接口添加操作日志。并且会根据接口路径来自动识别操作类型。在一个管理系统中通常所有的数据流转操作都需要记录日志,所以我们推荐您使用智能模式来实现操作日志的功能。为了更好的管理不记录操作日志的情况,Eva提供了注解排除方式和路径排除方式。注解排除方式即@Trace(exclude = true)
,路径排除方式则通过配置文件处理,如下:
application.yml
trace:
# 开启智能跟踪模式
smart: true
# 排除跟踪的URL正则
exclude-patterns: .*/list$, .*/tree$, .*/page$
2
3
4
5
约定
智能模式自动识别的原理是通过正则匹配接口路径,为了能够准确的识别,你应该按如下约定进行接口路径的定义:
- 新增: 满足正则表达式
.+/create.*
- 修改: 满足正则表达式
.+/update.**
- 删除: 满足正则表达式
.+/delete.*
- 批量删除: 满足正则表达式
.+/delete/batch$
- 导入: 满足正则表达式
.+/import.*
- 导出: 满足正则表达式
.+/export.*
- 重置: 满足正则表达式
.+/reset.**
# 手动模式
手动模式下,接口必须添加@Trace
注解才会使接口记录日志。
不要随意切换
由于智能模式和手动模式在使用时刚好是反向作用,所以建议您在项目初期就定好使用哪种模式。在项目后期不要随意切换,防止操作日志的丢失。
# 线程池
有时候我们需要做一些简单的异步处理,也就是说需要开启一个新的线程,而线程池的加入可以让线程更加的安全和可控。在Eva中,你可以使用以下代码来直接通过线程池开启一个新的线程。
Utils.ThreadPool.start(() -> {
// 线程代码
});
2
3
# 定时任务
# 功能说明
定时任务用来在指定时间点执行某些代码来完成业务逻辑。例如每天18点扫描今日未打卡的员工,并给这些员工发送邮件提醒。再例如在租房项目中,每个月1号为租客生成上个月的水电账单等。定时任务在这些情况下就不可或缺了。
# 任务的种类
在Eva中,任务分为普通任务
和分片任务
。无论是普通任务还是分片任务,都是基于CRON表达式,预先安排好一系列时间节点,然后在这些时间节点上定时执行的。
- 普通任务: 传统的quartz任务,用于处理数据量较小的业务
- 分片任务: 一旦数据量较大或处理时间过长的业务,那么就需要将数据进行划分。例如在需要在下午2点开始处理100万条用户记录,需要在10分钟内处理完毕,显然1台服务一个线程去运行是远远不够的。此时如果将100万条记录划分成10个分片任务,10台机器分别执行一个分片,那么就可以做到横向扩展机器来提高任务的处理速度,这就是分片任务的作用。
# 认识JobParam
无论是普通任务还是分片任务,都会接受JobParam对象作为参数。
参数 | 说明 |
---|---|
id | 任务ID或分片ID |
businessTime | 业务时间,自动执行时为定时任务调度时间,手动执行时为界面选择的时间 |
triggerType | 触发类型 |
snippetIndex | 分片索引 |
runtimeData | 运行时数据,用于获取当前分片需要处理的数据 |
# 实现定时任务
在Eva中实现一个定时任务也是非常简单的。如下
1. 编写任务类
@Component("myJob")
public class MyJob extends BaseJob {
@Override
public JobContext execute(JobParam param) {
// 获取业务时间
Date businessTime = param.getBusinessTime()
// 根据业务时间进行业务处理(略)
// 设置执行上下文内容
JobContext jobContext = new JobContext();
jobContext.setHandleSuccessSize(0);
jobContext.setHandleTotalSize(0);
jobContext.setContext("MyJob Context");
return jobContext;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2. 添加定时任务记录
在系统/定时任务/任务管理
中新建一条记录。参数如下
参数 | 值 | 说明 |
---|---|---|
任务名称 | 测试任务 | |
任务描述 | 测试任务 | |
任务处理器类 | myJob | 取自MyJob类别名 |
CRON表达式 | CRON表达式,如0/10 * * * * ? | |
是否启用数据分片 | 禁用 | 数据分片可将一个任务需处理的数据分成多个子任务来处理,以提高任务的业务处理速度。 |
是否启用并发执行 | 启用或禁用均可 | 同一任务在同一机器是否允许并发执行,关闭后将使用Quartz提供的@DisallowConcurrentExecution注解实现 |
是否启用异步执行 | 禁用 | 启用后任务将通过创建分片的方式异步执行,提高服务器资源的利用率 |
是否启用日志 | 启用 | 启用日志后,每次任务及其分片任务的执行都会被记录进任务日志中 |
服务器白名单 | 无需填写 | 只有在白名单内的服务器才可执行该任务,不填写时表示所有服务均可执行 |
3. 重启服务
新增任务后需要重启服务后生效。
# 实现分片任务
1. 编写分片任务类
@Component("myJob")
public class MyJob extends BaseJob {
@Override
public JobContext execute(JobParam param) {
// 获取分片数据
List<MyModel> data = param.getRuntimeData(MyModel.class);
// 对分片数据进行业务处理(略)
// 设置执行上下文内容
JobContext jobContext = new JobContext();
jobContext.setHandleSuccessSize(permissions.size());
jobContext.setHandleTotalSize(permissions.size());
jobContext.setContext("MyJob Context);
return jobContext;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2. 编写分片任务分发类
分发类用于查询出任务所有的业务数据。
@Component("myJobDistributer")
public class MyJobDistributer extends BaseDistributer<MyModel.class> {
@Autowired
private MyService myService;
@Override
public List<MyModel> getBusinessData(JobParam jobParam) {
// 获取业务时间
Date businessTime = jobParam.getBusinessTime();
// 根据业务时间查询出业务数据并返回
return myService.find(businessTime);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
3. 添加定时任务记录
在系统/定时任务/任务管理
中新建一条记录。参数如下
参数 | 值 | 说明 |
---|---|---|
任务名称 | 测试分片任务 | |
任务描述 | 测试分片任务 | |
任务处理器类 | myJob | 取自MyJob类别名 |
CRON表达式 | CRON表达式,如0/10 * * * * ? | |
是否启用数据分片 | 启用 | 数据分片可将一个任务需处理的数据分成多个子任务来处理,以提高任务的业务处理速度。 |
数据分发处理器类 | myJobDistributer | 取自MyJobDistributer类别名 |
是否启用并发执行 | 启用或禁用均可 | 同一任务在同一机器是否允许并发执行,关闭后将使用Quartz提供的@DisallowConcurrentExecution注解实现 |
是否启用异步执行 | 启用或禁用均可 | 启用后任务将通过创建分片的方式异步执行,提高服务器资源的利用率 |
是否启用日志 | 启用 | 启用日志后,每次任务及其分片任务的执行都会被记录进任务日志中 |
服务器白名单 | 无需填写 | 只有在白名单内的服务器才可执行该任务,不填写时表示所有服务均可执行 |
4. 重启服务
新增任务后需要重启服务后生效。
# 关闭任务的自动执行
有时候我们希望定时任务不要自动执行,那么我们可以通过application.yml
进行配置,如下
# ...此处省略其它配置
spring:
# ...此处省略其它配置
quartz:
# 是否自动启动
auto-startup: false
# 调度器名称
scheduler-name: EvaScheduler
# ...此处省略其它配置
2
3
4
5
6
7
8
9
10
11
12
注意
定时任务不自动执行时,分片任务也不会自动执行。
# 安全通讯
# 功能说明
对于安全性要求较高的项目,我们希望请求参数和响应参数都进行加密处理。Eva通过【接口设置】模块可以非常简单和灵活的配置接口是否需要加解密操作。
# 配置接口
在系统/接口设置
中新建一条记录。假设我们需要加密的接口为/test/a
,那么接口配置参数如下
参数 | 值 |
---|---|
接口名称 | 测试接口 |
接口路径正则 | /test/a |
启用2FA认证 | 禁用 |
启用请求加密 | 启用 |
权重 | 0 |
备注 | 无需填写 |
# 密钥配置
在application.yml
中配置即可,如下
# ... 此处省略其它配置
# 安全配置
security:
aes:
key: 0000111100001111 # 16位
iv: 1111222211112222 # 16位
# ... 此处省略其它配置
2
3
4
5
6
7
8
9
# 配置所有接口都需要加密
可以按如下参数添加接口配置记录
参数 | 值 |
---|---|
接口名称 | 所有接口 |
接口路径正则 | .* |
启用2FA认证 | 禁用 |
启用请求加密 | 启用 |
权重 | 0 |
备注 | 所有接口加密 |
# 关闭Swagger安全通讯
当接口启用了加密后,那么在swagger ui中调用接口时请求参数也是需要加密的,响应结果也会被加密返回。这样在swagger ui中调试接口是非常不方便的。可以通过如下配置来关闭Swagger的安全通讯。
application.yml
# ...此处省略其它配置
# Swagger配置
swagger:
host:
title: ${project.name}接口文档
description: ${project.name}接口文档
# 启用Swagger,生产环境建议关闭
enabled: true
# 启用swagger加密
enable-encrypt: false
# 禁用swagger时的重定向地址
redirect-uri: /
# ...此处省略其它配置
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2FA二次认证
# 功能说明
某些敏感操作可能需要用户进行二次密码确认,例如退款审核、手动充值等。此时则可以利用2FA二次认证来完成。和接口加密一样,Eva通过【接口设置】模块可以非常简单和灵活的配置接口是否需要进行2FA认证。
# 配置接口
在系统/接口设置
中新建一条记录。假设我们需要进行2FA认证的接口为/test/a
,那么接口配置参数如下
参数 | 值 |
---|---|
接口名称 | 测试接口 |
接口路径正则 | /test/a |
启用2FA认证 | 启用 |
启用请求加密 | 禁用 |
权重 | 0 |
备注 | 无需填写 |
# 实用工具类
所有工具类都通过Utils调用,这样你可以不用去查找工具类就可以方便的获取工具类对象。
# Utils.Http
Utils.Http为Http请求工具类,我们在设计时采用了链式调用的方案,这样可以增强代码可读性,并且使用起来也更加方便。在Eva中,发起一次请求的基本逻辑如下:
- 构造Http.HttpWrap对象
- 请求设置(如设置请求头,请求超时时间等)
- 发起请求
- 请求结果转换
构造Http.HttpWrap对象
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
build | String url | Http.HttpWrap | 根据请求地址构造 |
build | String url, String charset | Http.HttpWrap | 根据请求地址和编码构造 |
请求设置
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
setRequestProperty | String key, String value | Http.HttpWrap | 设置请求头 |
setConnectTimeout | int timeout | Http.HttpWrap | 设置连接超时时间 |
setReadTimeout | int timeout | Http.HttpWrap | 设置读取超时时间 |
gzip | Http.HttpWrap | 开启gzip压缩 |
发起请求
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
get | Http.HttpResult | 发起GET请求 | |
post | Http.HttpResult | 发起POST请求 | |
post | String params | Http.HttpResult | 发起POST请求 |
postJSON | Map<String, Object> paramsMap | Http.HttpResult | 发起POST请求 |
postJSON | JSONObject paramJSONObject | Http.HttpResult | 发起POST请求 |
请求结果转换
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
toStringResult | String | 转为字符串 | |
toJSONObject | JSONObject | 转为fastjson JSONObject对象 | |
toClass | Class | 转为指定class后的对象 | 转为指定类对象 |
示例1: 发起GET请求
Utils.Http.build(url)
.get()
.toStringResult();
2
3
示例2: 发起POST请求
Utils.Http.build(url)
.post(data)
.toStringResult();
2
3
示例3: 开启gzip压缩
Utils.Http.build(url)
.gzip()
.post(data)
.toStringResult();
2
3
4
示例4: 设置请求编码
Utils.Http.build(url, Charset.forName("GBK").toString())
.get()
.toStringResult();
2
3
# Utils.OSS
Utils.OSS为OSS工具类,用于处理文件的上传与下载,跟Utils.Http一样,Utils.OSS也采用了链式调用设计。它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
setMaxSize | int | Utils.OSS | 设置文件大小限制,单位为M |
setFileTypes | String | Utils.OSS | 设置文件类型限制(多个类型使用","隔开,如".jpg,jpeg,.png") |
uploadImage | MultipartFile | OSS.UploadResult | 上传图片 |
uploadImage | MultipartFile, String | OSS.UploadResult | 上传图片,第二个参数为业务路径,如使用"avatar"表示用户头像路径,"goods/cover"表示商品封面图片等 |
upload | MultipartFile | OSS.UploadResult | 上传文件 |
upload | MultipartFile, String | OSS.UploadResult | 上传文件,如使用"contract"表示合同文件,"contract/attach"表示合同附件 |
download | String | InputStream | 下载文件 |
可以看到,所有上传的方法均返回OSS.UploadResult
对象,这样可以方便我们获取文件相关的重要内容。
OSS.UploadResult属性说明
属性名称 | 类型 | 说明 |
---|---|---|
originalFilename | String | 源文件名称 |
fileKey | String | 文件的key |
accessUri | String | 访问路径/下载路径,默认情况下图片访问路径为/resource/image ,文件下载路径为/resource/attach 。 |
示例1:文件大小和格式的限制
// 上传文件"avatar.jpg"
Utils.OSS
// 限定文件大小不超过5M
.setMaxSize(5)
// 限定只允许.png和.jpg格式文件的上传
.setFileTypes('.png,.jpg')
.uploadImage(file);
// 方法返回: { originalFilename: "avatar.jpg", fileKey: "avatar.jpg", accessUri: "/resource/image?f=avatar.jpg" }
2
3
4
5
6
7
8
示例2:指定业务路径
// 上传文件"avatar.jpg"
Utils.OSS.uploadImage(file, "avatar");
// 方法返回: { originalFilename: "avatar.jpg", fileKey: "avatar/avatar.jpg", accessUri: "/resource/avatar?f=avatar/avatar.jpg" }
2
3
下载/预览文件的特殊处理 & 业务路径
您可能会遇见在下载/预览文件时需要对文件特殊处理的场景,如为图片或PDF添加水印,下载时添加下载时间等。此时我们应该通过业务路径将其在业务上进行区分(这也是业务路径的主要用途),如上传文件时设置业务路径为contract
(标识为合同文件),那么合同文件的下载路径将变为/resource/contract
,我们为此实现接口即可。具体实现可参考FileAccessController.java
类。
# Utils.Date
Utils.Date为日期工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
getStart | java.util.Date date | java.util.Date date | 获取日期的开始时间 |
getEnd | java.util.Date date | java.util.Date date | 获取日期的结束时间 |
示例
// 返回时分秒毫秒均为0的当前日期对象
Utils.Date.getStart(new Date());
// 返回时分秒毫秒均为0的下一天日期对象
Utils.Date.getEnd(new Date());
2
3
4
5
# Utils.Location
Utils.Location为地区工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
getLocation | String ip | Location.Info | 根据IP获取地区信息 |
getLocationString | String ip | Location.Info | 根据IP获取地区简要 |
# Utils.UserClient
Utils.UserClient为用户客户端信息工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
getOS | HttpServletRequest request | String | 获取客户端操作系统信息 |
getBrowser | HttpServletRequest request | String | 获取客户端浏览器信息 |
getIP | HttpServletRequest request | String | 获取客户端IP |
getPlatform | HttpServletRequest request | String | 获取用户操作的平台 |
# Utils.Server
Utils.Server为服务器信息工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
getIP | String | 获取当前服务器IP(局域网) | |
getMAC | String | 获取当前服务器MAC地址 |
# Utils.Monitor
Utils.Monitor为服务监听工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
current | Monitor | 获取当前时刻的监听信息 |
# Utils.MyBatisPlus
Utils.MyBatisPlus为MyBatis Plus工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
blankToNull | T object | T | 将对象中所有的空字符串转为null |
提示
相信很多人会疑惑,blankToNull方法应该跟MyBatis Plus没关系才对。的确,但Eva期望MyBatis plus中的blankToNull是独立的,因为它可能有别于常见的blankToNull方法的处理逻辑。或许,在Eva 2.0版本将调整它的名称。
# Utils.Secure
Utils.Secure为安全处理工具类,它有如下方法:
方法名称 | 参数 | 方法返回 | 说明 |
---|---|---|---|
encryptPassword | String password, String salt | String | 根据密码和密码盐加密密码 |
# 响应状态定义及规范
系统的响应状态定义在ResponseStatus
枚举中,如下
@Getter
@AllArgsConstructor
public enum ResponseStatus {
// 400开头表示参数错误
BAD_REQUEST(4000, "参数错误"),
DATA_EMPTY(4001, "找不到目标数据"),
DATA_EXISTS(4002, "记录已存在"),
PWD_INCORRECT(4003, "密码不正确"),
VERIFICATION_CODE_INCORRECT(4004, "验证码不正确或已过期"),
ACCOUNT_INCORRECT(4005, "账号或密码不正确"),
// 510开头表示可能导致数据错误的异常
DUPLICATE_SUBMIT(5100, "请勿重复提交"),
NOT_ALLOWED(5101, "不允许的操作"),
;
private int code;
private String message;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
响应码如何定义对程序不会产生直接影响,但我们希望您在使用Eva时可以培养一个好的习惯——定义可识别的响应码。除了目前定义的响应码外,下面再给出一些例子供参考
响应码前缀 | 说明 |
---|---|
10 | 以10开头表示用户模块错误,如1000表示登录过于频繁 |
11 | 以11开头表示会员模块错误,如1100表示会员已到期 |
12 | 以12开头表示订单模块错误,如1200表示货源不足 |
400 | 以400开头表示参数错误所导致的各类情况的响应码 |
500 | 以500开头表示程序因异常终止,不同类型的异常对应不同的响应码 |
510 | 以510开头表示请求可能导致数据错误 |
您可以按照您团队和业务的考量将响应码以其它方式规范化。这样的一个显而易见的好处是,我们仅凭响应码就能识别错误的严重性。
# 自定义全局异常处理
全局异常处理在类GlobalExceptionHandler
中实现,通过spring的@RestControllerAdvice
注解实现。如果您需要全局捕获某异常,可以参考以下代码来增加处理。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常处理
*/
@ExceptionHandler(BusinessException.class)
public <T> ApiResponse<T> handleBusinessException (BusinessException e) {
log.error(e.getMessage(), e);
return ApiResponse.failed(e.getCode(), e.getMessage());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
无法捕获异常?
如果您的异常无法捕获,您可以从以下几个方面着手检查
- 异常是否已被处理,即抛出异常后被catch,打印了日志或抛出了其它异常
- 异常是否非Controller抛出,即在拦截器或过滤器中出现的异常
# 业务异常处理
Eva为业务异常封装了异常对象BusinessException
,通过四个构造方法可方便的构造业务异常对象。定义如下
@Data
public class BusinessException extends RuntimeException {
private Integer code;
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable e) {
super(message, e);
this.code = code;
}
public BusinessException(ResponseStatus status) {
super(status.getMessage());
this.code = status.getCode();
}
public BusinessException(ResponseStatus status, Throwable e) {
super(status.getMessage(), e);
this.code = status.getCode();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 登录令牌规则 & 安全验证
登录令牌对于一个系统来说十分重要,因为它标识了是谁在访问系统。所以,您可能需要更为复杂的登录令牌生成规则,并希望可以对令牌进行安全验证。Eva在ShiroDefaultTokenManager
类中实现了令牌的生成和验证。实现非常简单,如下
@Component
public class ShiroDefaultTokenManager {
public String build() {
return UUID.randomUUID().toString();
}
public void check(String token) throws UnSafeSessionException {
if (token == null || token.length() != 36) {
throw new UnSafeSessionException();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
您可以直接修改此类来完成令牌的生成和校验。
# 登录保留时长/会话过期时长
可通过application.yml
中的cache.session.expire
控制,单位为秒。
# 验证码过期时长
可通过application.yml
中的cache.captcha.expire
控制,单位为秒。
# Swagger的启用和禁用
可通过application.yml
中的swagger.enable
控制。为true
时表示启用,为false
时表示禁用。
记得关闭
为了系统安全,通常生产环境不建议开启swagger。
# Redis高可用配置
在Eva中,Redis的高可用采用主从 + 哨兵模式
方案。至于是否需要读写分离,可以根据您的项目需要来更改配置。下面详细的说明如何进行配置。
Redis版本:2.8.17
我将采用一台机器启动三台Redis服务的方式来模拟实际情况下的多台机器。因为无论是一台机器还是多台机器,他们的配置是一样的。所以我将Redis原解压文件拷贝了三份,得到了如下目录:
- master:主服务redis
- 7001:从服务redis
- 7002:从服务redis
每个目录下的内容目前都是一致的(都是原redis解压后的文件)。现在让我们进行主从服务的配置。
# 配置主服务master
文件:master/redis.conf
port 7000 # 主服务端口
requirepass <master-password> # 主服务密码(可为空,安全起见,在生产环境上必须设置)
2
# 配置从服务7001
文件:7001/redis.conf
port 7001 # 端口号
slaveof localhost 7000 # 设置本服务为主服务localhost 7000的从服务
slave-read-only yes # 设置从服务只读(读写分离时需设置此项为yes)
masterauth <master-password> # 主服务密码(如果主服务存在密码的话需要设置此项)
2
3
4
# 配置从服务7002
文件:7002/redis.conf
port 7002 # 端口号
slaveof localhost 7000 # 设置本服务为主服务localhost 7000的从服务
slave-read-only yes # 设置从服务只读(读写分离时需设置此项为yes)
masterauth <master-password> # 主服务密码(如果主服务存在密码的话需要设置此项)
2
3
4
这样,主从服务就配置完成了。下面我们还需要配置哨兵,以实现在主服务宕机时自动选举一个从服务为主服务,避免在读写分离时因主服务宕机而无法继续写入缓存。通常情况下,我们应该为每个服务节点配置一个哨兵。
# 配置主服务master哨兵
文件:master/sentinel.conf
port 27000
sentinel monitor mymaster 127.0.0.1 7000 2
2
# 配置从服务7001哨兵
文件:7001/sentinel.conf
port 27001
sentinel monitor mymaster 127.0.0.1 7000 2
2
# 配置主服务7002哨兵
文件:7002/sentinel.conf
port 27002
sentinel monitor mymaster 127.0.0.1 7000 2
2
其中127.0.0.1 7000
表示主服务,最后的数字2
为超过多少个哨兵认为主服务宕机时可以决定主服务宕机的常数。在我们的配置中,表示超过2个哨兵认为主服务宕机了就可以确定主服务确实宕机了。通常情况下这个数字不宜太高,当所需的服务不多时,此配置应该要大于服务总数的1/2
,但如果所需的服务足够多,则至少需要大于服务总数的1/3
。
到这里,服务和哨兵都配置完成了。我们需要相继启动服务和哨兵,如下
启动所有redis服务
cd master
./src/redis-server redis.conf
cd 7001
./src/redis-server redis.conf
cd 7002
./src/redis-server redis.conf
2
3
4
5
6
7
8
启动所有哨兵
cd master
./src/redis-sentinel sentinel.conf
cd 7001
./src/redis-sentinel sentinel.conf
cd 7002
./src/redis-sentinel sentinel.conf
2
3
4
5
6
7
8
现在,你可以对主从同步、自动选举进行测试了,注意留意redis服务和哨兵的日志,相信你很快就会明白。